Goのスライス:使い方と内部構造

Go Slices: usage and internals by Andrew Gerrand

はじめに

Goのスライス型は、型付けされたデータの並びを扱うための便利で効率的な手段を提供します。 スライスは他の言語における配列に似ていますが、いくつか変わった性質を持っています。 この記事では、スライスとは何か、そしてどのように使うのかを見ていきます。

配列

スライス型はGoの配列型の上に構築された抽象化なので、スライスを理解するにはまず配列を理解する必要があります。

配列型の定義では長さと要素の型を指定します。例えば [4]int という型は4つの整数からなる配列を表します。 配列のサイズは固定であり、その長さは型の一部です([4]int[5]int は別の、互換性のない型です)。 配列は通常の方法でインデックスアクセスでき、式 s[n] は0から始まるn番目の要素にアクセスします。

var a [4]int
a[0] = 1
i := a[0]
// i == 1

配列は明示的に初期化する必要はありません。配列のゼロ値は、要素自体がゼロ化された、すぐに使える配列です。

// a[2] == 0、int型のゼロ値

[4]int のメモリ上の表現は、単に4つの整数値が連続して並んでいるだけです。

slice-array

Goの配列は値です。配列変数はCにおける先頭要素へのポインタではなく、配列全体を表します。 つまり、配列の値を代入したり渡したりすると、その中身のコピーが作られるということです。 コピーを避けるには配列への ポインタ を渡せますが、それは配列へのポインタであって、配列そのものではありません。 配列の考え方の一つとして、名前付きではなくインデックス付きのフィールドを持つ構造体のようなもの、 つまり固定サイズの複合値だと捉えられます。

配列リテラルは次のように指定できます。

b := [2]string{"Penn", "Teller"}

あるいは、コンパイラに配列の要素数を数えてもらえます。

b := [...]string{"Penn", "Teller"}

どちらの場合も、b の型は [2]string です。

スライス

配列にも活躍の場はありますが、少し柔軟性に欠けるため、Goのコードではそれほど頻繁には見かけません。 一方でスライスはいたるところで使われています。スライスは配列の上に構築され、大きな力と利便性を提供します。

スライスの型指定は []T で、T はスライスの要素の型です。配列型とは異なり、スライス型には長さの指定がありません。

スライスリテラルは配列リテラルと同じように宣言しますが、要素数を省略する点が異なります。

letters := []string{"a", "b", "c", "d"}

スライスは組み込み関数 make を使って作成できます。そのシグネチャは次の通りです。

func make([]T, len, cap) []T

ここで T は作成するスライスの要素の型を表します。make 関数は型、長さ、そして任意で容量を引数に取ります。 呼び出されると、make は配列を確保し、その配列を参照するスライスを返します。

var s []byte
s = make([]byte, 5, 5)
// s == []byte{0, 0, 0, 0, 0}

容量の引数を省略すると、デフォルトで指定した長さと同じ値になります。同じコードをより簡潔に書くと次のようになります。

s := make([]byte, 5)

スライスの長さと容量は、組み込みの len 関数と cap 関数を使って調べられます。

len(s) == 5
cap(s) == 5

次の2つの節では、長さと容量の関係について議論します。

スライスのゼロ値は nil です。nil スライスに対しては、len 関数と cap 関数はどちらも0を返します。

スライスは、既存のスライスや配列に対してスライシングを行うことでも作れます。 スライシングは、コロンで区切った2つのインデックスによって半開区間を指定することで行います。 例えば式 b[1:4] は、b の要素1から3までを含むスライスを作ります(作られたスライスのインデックスは0から2になります)。

b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'}、bと同じストレージを共有する

スライス式の開始と終了のインデックスは省略可能で、それぞれデフォルトで0とスライスの長さになります。

// b[:2] == []byte{'g', 'o'}
// b[2:] == []byte{'l', 'a', 'n', 'g'}
// b[:] == b

これは、配列からスライスを作成する際にも使う構文です。

x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // xのストレージを参照するスライス

スライスの内部構造

スライスとは、配列の一部分を記述するディスクリプタです。配列へのポインタ、そのセグメントの長さ、 そしてその容量(セグメントの最大長)から構成されています。

slice-struct

先ほど make([]byte, 5) で作成した変数 s は、次のような構造になっています。

slice-1

長さとは、スライスが参照している要素の数です。容量とは、(スライスのポインタが指す要素から始まる) 背後の配列にある要素数です。長さと容量の違いは、これから見ていくいくつかの例を通じて明らかになります。

s をスライスするにつれて、スライスのデータ構造がどう変化し、それが背後の配列とどう関係しているかを観察してみましょう。

s = s[2:4]

slice-2

スライシングはスライスのデータをコピーせず、元の配列を指す新しいスライス値を作るだけです。 そのため、スライス操作は配列のインデックスを操作するのと同じくらい効率的です。 同じ配列を参照しているので、(スライス自体ではなく)再スライスされたものの要素を変更すると、元のスライスの要素も変更されます。

d := []byte{'r', 'o', 'a', 'd'}
e := d[2:]
// e == []byte{'a', 'd'}
e[1] = 'm'
// e == []byte{'a', 'm'}
// d == []byte{'r', 'o', 'a', 'm'}

先ほど、s を容量より短い長さにスライスしました。もう一度スライスすることで、s を容量いっぱいまで拡張できます。

s = s[:cap(s)]

slice-3

スライスはその容量を超えて拡張することはできません。それを試みると、スライスや配列の範囲外への インデックスアクセスと同様に、実行時パニックが発生します。同じように、配列のより前の要素にアクセスするために、 スライスを0未満に再スライスすることもできません。

スライスを拡張する(copyとappend関数)

スライスの容量を増やすには、新しいより大きなスライスを作成し、元のスライスの中身をそこにコピーする必要があります。 この手法は、他の言語における動的配列の実装が裏側で行っていることと同じです。次の例では、新しいスライス t を作成し、 s の中身を t にコピーし、スライス値 ts に代入することで、s の容量を2倍にしています。

t := make([]byte, len(s), (cap(s)+1)*2) // cap(s) == 0の場合に備えて+1
for i := range s {
        t[i] = s[i]
}
s = t

このよくある操作のループ部分は、組み込みの copy 関数によって簡単に書けます。名前が示す通り、copy はコピー元の スライスからコピー先のスライスへデータをコピーします。コピーした要素数を返します。

func copy(dst, src []T) int

copy 関数は、長さの異なるスライス間のコピーにも対応しています(小さい方の要素数までしかコピーしません)。 さらに、copy はコピー元とコピー先が同じ背後の配列を共有している場合も扱え、重なり合ったスライスも正しく処理します。

copy を使うと、先ほどのコードスニペットを簡潔にできます。

t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t

よくある操作として、スライスの末尾にデータを追加するというものがあります。次の関数は、バイトのスライスに バイト要素を追加し、必要であればスライスを拡張して、更新後のスライス値を返します。

func AppendByte(slice []byte, data ...byte) []byte {
    m := len(slice)
    n := m + len(data)
    if n > cap(slice) { // 必要であれば再確保する
        // 将来の拡張のために、必要な量の倍を確保する。
        newSlice := make([]byte, (n+1)*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:n]
    copy(slice[m:n], data)
    return slice
}

AppendByte は次のように使えます。

p := []byte{2, 3, 5}
p = AppendByte(p, 7, 11, 13)
// p == []byte{2, 3, 5, 7, 11, 13}

AppendByte のような関数は、スライスの拡張方法を完全に制御できるという点で便利です。プログラムの特性によっては、 より小さな、あるいはより大きな単位で確保したい場合や、再確保のサイズに上限を設けたい場合があるでしょう。

しかしほとんどのプログラムでは完全な制御は必要ないため、Goはほとんどの用途に適した組み込みの append 関数を 提供しています。そのシグネチャは次の通りです。

func append(s []T, x ...T) []T

append 関数は、要素 x をスライス s の末尾に追加し、より大きな容量が必要であればスライスを拡張します。

a := make([]int, 1)
// a == []int{0}
a = append(a, 1, 2, 3)
// a == []int{0, 1, 2, 3}

あるスライスを別のスライスに追加するには、... を使って第2引数を引数リストに展開します。

a := []string{"John", "Paul"}
b := []string{"George", "Ringo", "Pete"}
a = append(a, b...) // "append(a, b[0], b[1], b[2])"と等価
// a == []string{"John", "Paul", "George", "Ringo", "Pete"}

スライスのゼロ値(nil)は長さ0のスライスと同じように振る舞うため、スライス変数を宣言してからループの中で それに append していけます。

// Filterはsの要素のうちfn()を満たすものだけを
// 保持する新しいスライスを返す
func Filter(s []int, fn func(int) bool) []int {
    var p []int // == nil
    for _, v := range s {
        if fn(v) {
            p = append(p, v)
        }
    }
    return p
}

起こりうる「落とし穴」

先に述べた通り、スライスを再スライスしても背後の配列のコピーは作られません。配列全体は、参照されなくなるまで メモリ上に保持され続けます。これが原因で、ほんの一部分のデータしか必要ないのに、プログラムがすべてのデータを メモリ上に保持してしまうことがあります。

例えば、次の FindDigits 関数は、ファイルをメモリに読み込み、最初に連続する数字の並びを検索して、 それを新しいスライスとして返します。

var digitRegexp = regexp.MustCompile("[0-9]+")

func FindDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)
}

このコードは見た目通りに動作しますが、返される []byte はファイル全体を含む配列を指しています。 スライスは元の配列を参照しているため、そのスライスが保持されている限り、ガベージコレクタはその配列を 解放できません。ファイルのうちほんのわずかな有用なバイトのために、内容全体がメモリに保持され続けてしまいます。

この問題を解決するには、返す前に必要なデータを新しいスライスにコピーすればよいのです。

func CopyDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = digitRegexp.Find(b)
    c := make([]byte, len(b))
    copy(c, b)
    return c
}

この関数のより簡潔なバージョンは append を使うことでも構築できます。これは読者への演習として残しておきます。

参考文献

Effective Go にはスライス配列についての詳細な解説があります。 また、Go言語仕様では、スライスと、 それに関連する長さと容量スライス生成追加とコピーの各関数が定義されています。

By Andrew Gerrand