The Go Blog
配列、スライス(および文字列):「append」の仕組み
はじめに
手続き型プログラミング言語の最も一般的な機能の1つに、配列の概念があります。配列は単純なものに見えますが、言語に追加する際には、次のような多くの質問に答える必要があります。
- 固定サイズか可変サイズか?
- サイズは型の一部か?
- 多次元配列はどのように見えるか?
- 空の配列には意味があるか?
これらの質問に対する答えは、配列が言語の単なる機能であるか、それとも設計の核心部分であるかに影響します。
Goの初期開発では、これらの質問に対する答えを決定するまでに約1年かかり、その設計が適切であると感じられるようになりました。重要なステップは、固定サイズの配列に基づいて柔軟で拡張可能なデータ構造を提供するスライスの導入でした。しかし今日に至るまで、Goの初心者はスライスの動作方法につまずくことが多く、おそらく他の言語での経験が彼らの思考に影響を与えているのでしょう。
この記事では、その混乱を解消しようと試みます。そのために、組み込み関数appendがどのように機能するのか、そしてなぜそのように機能するのかを説明するために、各要素を構築していきます。
配列
Goにおいて配列は重要な構成要素ですが、建物の基礎のように、より目に見えるコンポーネントの下に隠されていることが多いです。スライスという、より興味深く、強力で、目立つアイデアに進む前に、簡単に配列について話す必要があります。
配列のサイズがその型の一部であるため、Goプログラムでは配列が頻繁に見られることはありません。これは表現力を制限します。
宣言
var buffer [256]byte
変数bufferを宣言し、256バイトを保持します。bufferの型にはそのサイズ、[256]byteが含まれます。512バイトの配列は、異なる型[512]byteになります。
配列に関連付けられたデータは、まさにそれです。要素の配列です。模式的に、私たちのバッファはメモリ内でこのように見えます。
buffer: byte byte byte ... 256 times ... byte byte byte
つまり、この変数は256バイトのデータのみを保持し、他には何もありません。おなじみのインデックス構文、buffer[0]、buffer[1]、... buffer[255]を使ってその要素にアクセスできます。(インデックス範囲0から255は256個の要素をカバーします。)この範囲外の値でbufferをインデックスしようとすると、プログラムがクラッシュします。
組み込み関数lenがあり、配列やスライスの要素数、およびその他のいくつかのデータ型を返します。配列の場合、lenが何を返すかは明らかです。私たちの例では、len(buffer)は固定値256を返します。
配列にはそれぞれの役割があります。たとえば変換行列の優れた表現ですが、Goでの最も一般的な目的は、スライスのストレージを保持することです。
スライス:スライスヘッダー
スライスこそが核心ですが、うまく使うには、それが正確に何であり、何をするのかを理解しなければなりません。
スライスは、スライス変数自体とは別に格納された配列の連続したセクションを記述するデータ構造です。スライスは配列ではありません。スライスは配列の一部を記述します。
前のセクションのbuffer配列変数があるとして、配列をスライスすることで、要素100から150(正確には100から149までを含む)を記述するスライスを作成できます。
var slice []byte = buffer[100:150]
このスニペットでは、明示的にするために完全な変数宣言を使用しました。変数sliceは型[]byte(「バイトのスライス」と発音)を持ち、配列bufferから、要素100(含む)から150(含まない)をスライスすることによって初期化されます。より慣用的な構文では、初期化式によって設定される型を省略します。
var slice = buffer[100:150]
関数内では、短縮宣言形式を使用できます。
slice := buffer[100:150]
このスライス変数とは正確には何でしょうか?それはまだ完全な話ではありませんが、今のところ、スライスは長さと配列の要素へのポインタという2つの要素を持つ小さなデータ構造であると考えてください。舞台裏ではこのように構築されていると考えることができます。
type sliceHeader struct {
Length int
ZerothElement *byte
}
slice := sliceHeader{
Length: 50,
ZerothElement: &buffer[100],
}
もちろん、これは単なる例示です。このスニペットが示唆しているにもかかわらず、sliceHeader構造体はプログラマには見えず、要素ポインタの型は要素の型に依存しますが、これはメカニズムの一般的な考え方を提供します。
これまで配列に対してスライス操作を使用してきましたが、次のようにスライスをスライスすることもできます。
slice2 := slice[5:10]
これまでと同様に、この操作は新しいスライスを作成します。この場合、元のスライスの要素5から9(含む)であり、これは元の配列の要素105から109を意味します。slice2変数の基となるsliceHeader構造体は次のようになります。
slice2 := sliceHeader{
Length: 5,
ZerothElement: &buffer[105],
}
このヘッダーは、依然としてbuffer変数に格納されている同じ基となる配列を指していることに注意してください。
スライスをリサイズすることもできます。つまり、スライスをスライスし、その結果を元のスライス構造に格納することです。
slice = slice[5:10]
の後、slice変数のsliceHeader構造は、slice2変数のものとまったく同じになります。スライスを切り詰めるなど、リサイズが頻繁に使用されているのがわかるでしょう。この文は、スライスの最初と最後の要素を削除します。
slice = slice[1:len(slice)-1]
[練習問題:この代入の後、sliceHeader構造がどのように見えるか書き出してください。]
経験豊富なGoプログラマーが「スライスヘッダー」について話しているのをよく耳にするでしょう。なぜなら、それがまさにスライス変数に格納されているものだからです。たとえば、bytes.IndexRune のようにスライスを引数として取る関数を呼び出すとき、そのヘッダーが関数に渡されます。この呼び出しでは、
slashPos := bytes.IndexRune(slice, '/')
IndexRune関数に渡されるslice引数は、実際には「スライスヘッダー」です。
スライスヘッダーにはもう1つのデータ項目がありますが、それについては以下で説明します。しかし、まず、スライスヘッダーの存在がスライスをプログラムするときに何を意味するのかを見てみましょう。
スライスを関数に渡す
スライスがポインターを含んでいても、それ自体が値であることを理解することが重要です。内部的には、ポインターと長さを保持する構造体の値です。構造体へのポインターではありません。
これは重要です。
前の例でIndexRuneを呼び出したとき、スライスヘッダーのコピーが渡されました。この動作には重要な意味があります。
この単純な関数を考えてみましょう。
func AddOneToEachElement(slice []byte) {
for i := range slice {
slice[i]++
}
}
その名前が示すとおり、スライスのインデックスを(for rangeループを使用して)反復処理し、その要素をインクリメントします。
試してみてください
func main() {
slice := buffer[10:20]
for i := 0; i < len(slice); i++ {
slice[i] = byte(i)
}
fmt.Println("before", slice)
AddOneToEachElement(slice)
fmt.Println("after", slice)
}
(これらの実行可能なスニペットは、探索したい場合は編集して再実行できます。)
スライスヘッダーは値渡しされますが、ヘッダーには配列の要素へのポインターが含まれているため、元のスライスヘッダーと関数に渡されたヘッダーのコピーの両方が同じ配列を記述します。したがって、関数が戻ると、変更された要素は元のスライス変数を通じて見ることができます。
次の例が示すように、関数への引数は実際にはコピーです。
func SubtractOneFromLength(slice []byte) []byte {
slice = slice[0 : len(slice)-1]
return slice
}
func main() {
fmt.Println("Before: len(slice) =", len(slice))
newSlice := SubtractOneFromLength(slice)
fmt.Println("After: len(slice) =", len(slice))
fmt.Println("After: len(newSlice) =", len(newSlice))
}
ここでは、スライス引数の内容は関数によって変更できますが、そのヘッダーは変更できないことがわかります。slice変数に格納されている長さは、関数にスライスヘッダーのコピーが渡され、オリジナルではないため、関数呼び出しによって変更されません。したがって、ヘッダーを変更する関数を作成したい場合は、ここで示したように、結果パラメーターとしてそれを返す必要があります。slice変数は変更されませんが、返された値は新しい長さを持ち、それがnewSliceに格納されます。
スライスへのポインタ:メソッドレシーバー
関数がスライスヘッダーを変更するもう1つの方法は、スライスヘッダーへのポインタを渡すことです。これは、前の例のバリアントで、これを行います。
func PtrSubtractOneFromLength(slicePtr *[]byte) {
slice := *slicePtr
*slicePtr = slice[0 : len(slice)-1]
}
func main() {
fmt.Println("Before: len(slice) =", len(slice))
PtrSubtractOneFromLength(&slice)
fmt.Println("After: len(slice) =", len(slice))
}
この例では、特に間接参照の余分なレベルを扱うのがぎこちなく(一時変数が役立ちます)、スライスへのポインタを見かける一般的なケースが1つあります。スライスを変更するメソッドのポインタレシーバーを使用するのは慣用的です。
スライスの末尾のスラッシュで切り詰めるメソッドをスライスに持たせたいとします。このように書くことができます。
type path []byte
func (p *path) TruncateAtFinalSlash() {
i := bytes.LastIndex(*p, []byte("/"))
if i >= 0 {
*p = (*p)[0:i]
}
}
func main() {
pathName := path("/usr/bin/tso") // Conversion from string to path.
pathName.TruncateAtFinalSlash()
fmt.Printf("%s\n", pathName)
}
この例を実行すると、呼び出し元のスライスが適切に更新されることがわかります。
[練習問題:レシーバーの型をポインターではなく値に変更して、もう一度実行してください。何が起こるか説明してください。]
一方、path内のASCII文字を大文字にするメソッド(局所的に非英語名を無視する)をpath用に書きたい場合、値レシーバーは同じ基となる配列を指し続けるため、そのメソッドは値でも構いません。
type path []byte
func (p path) ToUpper() {
for i, b := range p {
if 'a' <= b && b <= 'z' {
p[i] = b + 'A' - 'a'
}
}
}
func main() {
pathName := path("/usr/bin/tso")
pathName.ToUpper()
fmt.Printf("%s\n", pathName)
}
ここでは、ToUpperメソッドはfor range構文の2つの変数を使用して、インデックスとスライス要素を取得しています。この形式のループは、本体でp[i]を複数回記述することを避けます。
[練習問題:ToUpperメソッドをポインターレシーバーを使用するように変更し、その動作が変わるかどうかを確認してください。]
[上級練習問題:ToUpperメソッドを、ASCII文字だけでなくUnicode文字も処理するように変換してください。]
容量
引数のintスライスを1要素拡張する次の関数を見てください。
func Extend(slice []int, element int) []int {
n := len(slice)
slice = slice[0 : n+1]
slice[n] = element
return slice
}
(なぜ変更されたスライスを返す必要があるのでしょうか?)さて、実行してください。
func main() {
var iBuffer [10]int
slice := iBuffer[0:0]
for i := 0; i < 20; i++ {
slice = Extend(slice, i)
fmt.Println(slice)
}
}
スライスが成長し、そして...成長しなくなるのがわかります。
スライスヘッダーの3番目のコンポーネント、つまり容量について話す時が来ました。配列ポインターと長さの他に、スライスヘッダーはその容量も格納します。
type sliceHeader struct {
Length int
Capacity int
ZerothElement *byte
}
Capacityフィールドは、基となる配列が実際にどれだけのスペースを持っているかを記録します。これは、Lengthが到達できる最大値です。スライスをその容量を超えて成長させようとすると、配列の制限を超えてしまい、パニックが発生します。
例のスライスが次のように作成された後、
slice := iBuffer[0:0]
そのヘッダーはこのようになります。
slice := sliceHeader{
Length: 0,
Capacity: 10,
ZerothElement: &iBuffer[0],
}
Capacityフィールドは、基となる配列の長さから、スライスの最初の要素の配列内インデックス(この場合はゼロ)を引いた値と等しくなります。スライスの容量を知りたい場合は、組み込み関数capを使用します。
if cap(slice) == len(slice) {
fmt.Println("slice is full!")
}
作成
スライスをその容量を超えて成長させたい場合はどうすればよいでしょうか?それはできません!定義上、容量は成長の限界です。しかし、新しい配列を割り当て、データをコピーし、新しい配列を記述するようにスライスを変更することで、同等の結果を達成できます。
まず、割り当てから始めましょう。組み込み関数newを使用してより大きな配列を割り当て、その結果をスライスすることもできますが、代わりに組み込み関数makeを使用する方が簡単です。これは新しい配列を割り当て、それを記述するスライスヘッダーを一度に作成します。make関数は3つの引数を取ります:スライスの型、初期の長さ、および容量(makeがスライスデータを保持するために割り当てる配列の長さ)。この呼び出しは、長さ10のスライスを作成し、さらに5つ(15-10)のスペースがあることを示します。これを実行することで確認できます。
slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
このスニペットは、intスライスの容量を2倍にしますが、長さを同じに保ちます。
slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
newSlice := make([]int, len(slice), 2*cap(slice))
for i := range slice {
newSlice[i] = slice[i]
}
slice = newSlice
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
このコードを実行すると、スライスは再割り当てが必要になる前に、さらに多くの成長余地を持つことになります。
スライスを作成する場合、長さと容量が同じになることがよくあります。組み込み関数makeには、この一般的なケースの短縮形があります。長さ引数はデフォルトで容量となるため、両方を同じ値に設定するために省略することができます。その後、
gophers := make([]Gopher, 10)
gophersスライスの長さと容量は両方とも10に設定されます。
コピー
前のセクションでスライスの容量を2倍にしたとき、古いデータを新しいスライスにコピーするためのループを作成しました。Goにはこれを簡単にする組み込み関数copyがあります。引数は2つのスライスで、右側の引数から左側の引数にデータをコピーします。copyを使用するように書き直された例がこれです。
newSlice := make([]int, len(slice), 2*cap(slice))
copy(newSlice, slice)
copy関数は賢いです。両方の引数の長さに注意を払い、コピーできるものだけをコピーします。言い換えれば、コピーされる要素の数は、2つのスライスの長さの最小値です。これにより、少しの簿記を節約できます。また、copyはコピーされた要素の数である整数値を返しますが、常にチェックする価値があるわけではありません。
copy関数は、ソースとデスティネーションが重なる場合でも正しく動作するため、単一のスライス内でアイテムを移動させるために使用できます。以下は、copyを使用してスライスの中央に値を挿入する方法です。
// Insert inserts the value into the slice at the specified index, // which must be in range. // The slice must have room for the new element. func Insert(slice []int, index, value int) []int { // Grow the slice by one element. slice = slice[0 : len(slice)+1] // Use copy to move the upper part of the slice out of the way and open a hole. copy(slice[index+1:], slice[index:]) // Store the new value. slice[index] = value // Return the result. return slice }
この関数にはいくつか注意すべき点があります。まず、もちろん、その長さが変更されたため、更新されたスライスを返す必要があります。次に、便利な短縮形を使用しています。式
slice[i:]
は、これと全く同じ意味です。
slice[i:len(slice)]
また、まだこのトリックは使っていませんが、スライス式から最初の要素を省略することもできます。これはデフォルトでゼロになります。したがって、
slice[:]
はスライス自体を意味し、配列をスライスする際に便利です。この式は、「配列のすべての要素を記述するスライス」と言う最も短い方法です。
array[:]
それが済んだところで、Insert関数を実行してみましょう。
slice := make([]int, 10, 20) // Note capacity > length: room to add element.
for i := range slice {
slice[i] = i
}
fmt.Println(slice)
slice = Insert(slice, 5, 99)
fmt.Println(slice)
Append: 例
数セクション前、私たちはスライスを1要素拡張するExtend関数を書きました。しかし、そのスライスの容量が小さすぎると関数がクラッシュするというバグがありました。(私たちのInsertの例も同じ問題を抱えています。)これでそれを修正するための部品が揃ったので、整数スライス用の堅牢なExtendの実装を書きましょう。
func Extend(slice []int, element int) []int {
n := len(slice)
if n == cap(slice) {
// Slice is full; must grow.
// We double its size and add 1, so if the size is zero we still grow.
newSlice := make([]int, len(slice), 2*len(slice)+1)
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0 : n+1]
slice[n] = element
return slice
}
この場合、再割り当てが発生すると、結果のスライスが全く異なる配列を記述するため、スライスを返すことが特に重要です。スライスが満たされていくときに何が起こるかを示す小さなスニペットです。
slice := make([]int, 0, 5)
for i := 0; i < 10; i++ {
slice = Extend(slice, i)
fmt.Printf("len=%d cap=%d slice=%v\n", len(slice), cap(slice), slice)
fmt.Println("address of 0th element:", &slice[0])
}
初期配列(サイズ5)が満たされたときの再割り当てに注目してください。新しい配列が割り当てられると、容量と0番目の要素のアドレスの両方が変化します。
堅牢なExtend関数をガイドとして、複数の要素でスライスを拡張できる、さらに優れた関数を書くことができます。これを行うために、Goの機能を利用して、関数が呼び出されたときに引数のリストをスライスに変換します。つまり、Goの可変引数関数機能を使用します。
その関数をAppendと呼びましょう。最初のバージョンでは、可変引数関数のメカニズムが明確になるように、Extendを繰り返し呼び出すことができます。Appendのシグネチャはこれです。
func Append(slice []int, items ...int) []int
これは、Appendが1つの引数(スライス)に続いて0個以上のint引数を取ることを意味します。これらの引数は、Appendの実装に関する限り、まさにintのスライスです。以下に示すように。
// Append appends the items to the slice. // First version: just loop calling Extend. func Append(slice []int, items ...int) []int { for _, item := range items { slice = Extend(slice, item) } return slice }
items引数の要素を反復処理するfor rangeループに注目してください。この引数は暗黙的に[]int型です。また、このケースでは不要なループ内のインデックスを破棄するために、空白識別子_が使用されていることにも注目してください。
試してみてください
slice := []int{0, 1, 2, 3, 4}
fmt.Println(slice)
slice = Append(slice, 5, 6, 7, 8)
fmt.Println(slice)
この例のもう1つの新しいテクニックは、複合リテラル(スライスの型に続いて括弧で囲まれた要素)を記述することでスライスを初期化していることです。
slice := []int{0, 1, 2, 3, 4}
Append関数は別の理由でも興味深いものです。要素を追加できるだけでなく、呼び出し側で...表記を使用してスライスを「展開」することで、スライス全体を追加することもできます。
slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...) // The '...' is essential!
fmt.Println(slice1)
もちろん、Extendの内部を基にして、一度だけ割り当てを行うことでAppendをより効率的にすることができます。
// Append appends the elements to the slice. // Efficient version. func Append(slice []int, elements ...int) []int { n := len(slice) total := len(slice) + len(elements) if total > cap(slice) { // Reallocate. Grow to 1.5 times the new size, so we can still grow. newSize := total*3/2 + 1 newSlice := make([]int, total, newSize) copy(newSlice, slice) slice = newSlice } slice = slice[:total] copy(slice[n:], elements) return slice }
ここでは、copyを2回使用していることに注目してください。1回はスライスデータを新しく割り当てられたメモリに移動するために、もう1回は追加する項目を古いデータの末尾にコピーするためです。
試してみてください。動作は以前と同じです。
slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...) // The '...' is essential!
fmt.Println(slice1)
Append:組み込み関数
そして、組み込み関数appendの設計の動機にたどり着きました。これは、私たちのAppendの例と全く同じことを、同等の効率で行いますが、あらゆるスライス型に機能します。
Goの弱点の1つは、汎用型操作はすべてランタイムによって提供されなければならないことです。いつか変わるかもしれませんが、今のところ、スライスを扱いやすくするために、Goは組み込みの汎用append関数を提供しています。これは、私たちのintスライスバージョンと同じように機能しますが、あらゆるスライス型に対して機能します。
スライスヘッダーはappendの呼び出しによって常に更新されるため、呼び出し後に返されたスライスを保存する必要があることを忘れないでください。実際、コンパイラは結果を保存せずにappendを呼び出すことを許可しません。
以下に、いくつかの1行コードとプリント文を混ぜた例を示します。試してみて、編集し、探索してみてください。
// Create a couple of starter slices.
slice := []int{1, 2, 3}
slice2 := []int{55, 66, 77}
fmt.Println("Start slice: ", slice)
fmt.Println("Start slice2:", slice2)
// Add an item to a slice.
slice = append(slice, 4)
fmt.Println("Add one item:", slice)
// Add one slice to another.
slice = append(slice, slice2...)
fmt.Println("Add one slice:", slice)
// Make a copy of a slice (of int).
slice3 := append([]int(nil), slice...)
fmt.Println("Copy a slice:", slice3)
// Copy a slice to the end of itself.
fmt.Println("Before append to self:", slice)
slice = append(slice, slice...)
fmt.Println("After append to self:", slice)
その例の最後の1行について、スライスの設計がどのようにこの単純な呼び出しを正しく機能させているかを詳細に考えてみる価値があります。
コミュニティが作成した「スライストリック」Wikiページには、append、copy、およびスライスを使用するその他の方法の例がさらにたくさんあります。
Nil
余談ですが、新たな知識によってnilスライスの表現が何であるかが分かります。当然ながら、それはスライスヘッダーのゼロ値です。
sliceHeader{
Length: 0,
Capacity: 0,
ZerothElement: nil,
}
または単に
sliceHeader{}
重要な詳細は、要素ポインタもnilであるということです。次のように作成されたスライスは、
array[0:0]
長さはゼロですが(そしておそらく容量もゼロですが)、そのポインタはnilではないため、nilスライスではありません。
明らかであるように、空のスライスは成長できますが(非ゼロの容量がある場合)、nilスライスは値を格納する配列がなく、1つの要素さえ保持するように成長することはできません。
そうは言っても、nilスライスは何も指していませんが、機能的には長さゼロのスライスと同等です。長さはゼロであり、割り当てを伴って追加できます。例えば、上記の1行コードで、nilスライスに追記することでスライスをコピーしているものを見てください。
文字列
さて、Goにおける文字列について、スライスの文脈で簡単に説明します。
文字列は実際には非常にシンプルです。言語からのちょっとした構文サポートが付いた、バイトの読み取り専用スライスに過ぎません。
読み取り専用であるため、容量は必要ありません(拡張できません)。しかし、それ以外はほとんどの目的で、読み取り専用のバイトスライスと同じように扱うことができます。
まず、インデックスを使って個々のバイトにアクセスできます。
slash := "/usr/ken"[0] // yields the byte value '/'.
文字列をスライスして部分文字列を取得できます。
usr := "/usr/ken"[0:4] // yields the string "/usr"
文字列をスライスするとき、舞台裏で何が起こっているかはもう明らかでしょう。
通常のバイトスライスを取り、簡単な変換でそこから文字列を作成することもできます。
str := string(slice)
そして逆方向にも進むことができます。
slice := []byte(usr)
文字列の基になる配列は非表示になっています。文字列を介して以外にその内容にアクセスする方法はありません。つまり、これらの変換のいずれかを行うとき、配列のコピーが作成される必要があります。もちろんGoがこれを処理するので、心配する必要はありません。これらの変換のいずれかの後、バイトスライスの基になる配列への変更は、対応する文字列に影響を与えません。
文字列のこのスライスのような設計の重要な結果は、部分文字列の作成が非常に効率的であることです。必要なのは、2語の文字列ヘッダーを作成するだけです。文字列は読み取り専用であるため、元の文字列とスライス操作の結果として得られる文字列は、同じ配列を安全に共有できます。
歴史的な注記:文字列の初期実装は常に割り当てを行っていましたが、スライスが言語に追加されたとき、効率的な文字列処理のモデルを提供しました。その結果、一部のベンチマークでは大幅な高速化が見られました。
もちろん、文字列には他にも多くのことがあり、別のブログ記事でより深く掘り下げています。
まとめ
スライスの動作を理解するには、その実装方法を理解することが役立ちます。スライス変数に関連付けられた項目であるスライスヘッダーという小さなデータ構造があり、そのヘッダーは個別に割り当てられた配列の一部を記述します。スライスの値を渡すと、ヘッダーはコピーされますが、それが指す配列は常に共有されます。
動作を理解すれば、スライスは使いやすいだけでなく、特に組み込み関数copyとappendの助けを借りて、強力で表現力豊かなものになります。
さらに読む
Goのスライスについては、インターチューブ上にたくさんの情報があります。前述の通り、「Slice Tricks」Wikiページには多くの例があります。Go Slicesのブログ記事は、明確な図を使ってメモリレイアウトの詳細を説明しています。Russ CoxのGo Data Structuresの記事には、Goの他の内部データ構造とともにスライスに関する議論が含まれています。
さらに多くの資料が入手可能ですが、スライスについて学ぶ最善の方法は、実際に使用することです。
次の記事: Goにおける文字列、バイト、ルーン、文字
前の記事: 最初のGoプログラム
ブログインデックス