Go 言語のスライスの使い方、仕組みを理解する-その 2 pointer / length / capacity

はじめに

前回のエントリスライス配列 の違いや使い方について触れました。

今回は スライス の内部はどうなっているのか?についてもう少しだけ深堀りしていきたいと思います。

スライスの内部を覗いてみる

スライスの実体は、 「配列」「一部分を指す」 ものです。
文章だけではわかりにくいですが、 「配列」 が存在している必要があります。

スライスは以下 3 つの情報を持っています。

  • pointer : 「配列へのポインタ」
  • length : 「配列へのポインタ」から切り取る「要素数」
  • capacity : 「配列へのポインタ」から配列の最後の要素までの「要素数」

make([]byte, 5) という命令が実行された場合は、以下のような 配列スライス が生成されます。

s := make([]byte, 5)

[配列]
| h | << [スライス] length=5 / capacity=5
| e |
| l |
| l |
| o |
  • 配列
    • 5 つの要素を持つ配列が生成されます。
  • スライス
    • pointer ( ポインタ ) は配列の先頭を指しています。
    • length=5 となります、ポインタ位置から 5 つの要素を参照できます。
    • capacity=5 となります。

length ( 長さ ) capacity ( 容量 ) の違いが今ひとつわかりませんが、先に進んでいきます。

この スライス を 「分割」してみます。

s = s[2:4]

[配列]
| h |
| e |
| l | << [スライス] length=2 / capacity=3
| l |
| o |

スライス を「分割」した場合は、新たな 配列 は生成されません。
配列 を残したまま、 新たな スライス が生成されます。

  • スライス
    • pointer ( ポインタ ) は配列の 3 つ目の要素を指しています。
    • length=2 となります、ポインタ位置から 2 つの要素を参照できます。
    • capacity=3 となります。

長い配列を操作すればするほど、毎回配列をメモリ上に確保する必要がなくなるためメリットが大きいです。

複数のスライスが同じ配列を「共有」しているということは?

感の良い人はお気づきでしょうが、スライスからスライスを生成した場合には、 実体である「配列」を共有する ため 一方のスライスの要素変更は他方に影響 します。

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'}

スライス d 、スライス e の 2 つの変数は宣言直後は以下のようになっています。

[配列]
| r | << [スライスd] length=4 / capacity=4
| o |
| a | << [スライスe] length=2 / capacity=2
| d |

ここで スライス e の 2 つ目の要素を変更すると、以下のように変わります。

[配列]
| r | << [スライスd] length=4 / capacity=4
| o |
| m | << [スライスe] length=2 / capacity=2
| d |

スライスの length を拡張する

以下の状態を思い出してください。

s = s[2:4]

[配列]
| h |
| e |
| l | << [スライス] length=2 / capacity=3
| l |
| o |

s の中身は []byte['l', 'l'] です。

length の値が capacity よりも小さい値となっています。

この場合、 lengthcapacity のサイズまで拡張することができます。

// len(s) == 2
s = s[:cap(s)]
// len(s) == 3

[配列]
| h |
| e |
| l | << [スライス] length=3 / capacity=3
| l |
| o |

capacity を超えて拡張することはできません。
( 配列やスライスの範囲外の要素を参照しようとした場合にも同様のエラーが発生します。 )

スライスの capacity を拡張する

スライスの capacity ( 容量 ) を拡張したい場合は以下の手順を踏みます。

  1. 新しいスライスを生成
  2. 元のスライスの要素に格納されている値を 1 つずつ新しいスライスにコピー

他のプログラミング言語で動的配列がリサイズされるタイミングで内部的に行われていることによく似ています。

以下のコードでは、スライス s の 2 倍の capacity をもつスライス t を新たに生成し、 s の要素を t にコピーし、最後に s に戻しています。

s := []byte{'h', 'e', 'l', 'l', 'o'}

t := make([]byte, len(s), (cap(s)+1)*2) // +1 in case cap(s) == 0
for i := range s {
    t[i] = s[i]
}
s = t

...という処理を何度も記述するのはナンセンスですので、 copy という組み込み関数を利用できます。

一方のスライスから他方のスライスへ要素をコピーします。実行結果としてコピーされた要素数が返却されます。

func copy(dst, src []T) int

先のコードは以下のように簡素化できます。

s := []byte{'h', 'e', 'l', 'l', 'o'}

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

copy 関数を利用した場合でも、 実体となる配列 を参照している他のスライスが同時に影響を受ける点に注意が必要です。

s := []byte{'h', 'e', 'l', 'l', 'o'}

x := []byte{'a', 'b', 'c', 'd', 'e', 'f', 'g'}

t := x
copy(t, s)

// x == []byte{'h', 'e', 'l', 'l', 'o', 'f', 'g'}

スライスに要素を追加する

スライスに要素を追加するために、 copy して capacity を増やして... のような操作をする手間が省けるように append という組み込み関数が用意されています。

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

第 1 引数に指定したスライスに、第 2 引数で指定した複数の要素を追加した結果を新たなスライスとして返却します。

package main

import (
    "fmt"
)

func main() {
    a := make([]int, 1)
    // a == []int{0}

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

    fmt.Println(a)
}

$ go run ./main.go
[0 1 2 3]

あるスライスに別のスライスを追加する場合にも利用できます。その場合、スライスを展開して第二引数に指定します。

package main

import (
    "fmt"
)

func main() {
    a := []string{"John", "Paul"}
    b := []string{"George", "Ringo", "Pete"}
    c := append(a, b...)
    // c == []string{"John", "Paul", "George", "Ringo", "Pete"}
    fmt.Printf("%q\n", a)
    fmt.Printf("%q\n", b)
    fmt.Printf("%q\n", c)
}

$ go run ./main.go
["John" "Paul"]
["George" "Ringo" "Pete"]
["John" "Paul" "George" "Ringo" "Pete"]

スライスを利用する際の注意

これまで見てきたように、スライスを分割しても その実体である配列は複製しません

スライスに参照されている間はすべての配列情報はメモリ内に保持され続けます。

場合によってはほんの一部の情報が必要なだけなのに、すべての配列情報がメモリ内に保存され続けてしまいます。

例をあげます。

FindDigits 関数はファイルをロードしてメモリに蓄え、連続した数字を探し、最初に見つかったデータをスライスとして返却します。

package main

import (
    "io/ioutil"
    "regexp"
)

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

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

このコードは想定通り動作しますが、戻り値の []byte スライスは、ファイルの全データを保持する配列を指し示しています。

スライスは配列を指し示しているため、このスライスが破棄されるまではガベージコレクタが配列を開放できません。

この問題を解決するために、新しいスライドを作成し、変換したいデータだけをコピーします。

package main

import (
    "io/ioutil"
    "regexp"
)

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

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

append 関数を利用すれば更に簡潔に記述できます。

package main

import (
    "io/ioutil"
    "regexp"
)

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

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

append を使えば copy は不要なのでは?

ふと思ったのですが、 lengthcapacity の値を変更する必要のない場合は、 copy の代わりに append を使えるのでは?と思いました。

例えば、以下のようにスライス a に対して append で要素を追加したスライス b を作ります。
a の任意の要素に値を代入しても、 b には影響がありません。

package main

import (
    "fmt"
)

func main() {
    a := []string{"AAA", "BBB", "CCC"}
    b := append(a, "DDD", "EEE")

    a[1] = "XXX"

    fmt.Printf("%q\n", a)
    fmt.Printf("%q\n", b)
}

実行結果

["AAA" "XXX" "CCC"]
["AAA" "BBB" "CCC" "DDD" "EEE"]

では、追加する要素を空のスライスにしたらよいのでは?
試してみました。

package main

import (
    "fmt"
)

func main() {
    a := []string{"AAA", "BBB", "CCC"}
    b := append(a, []string{}...)

    a[1] = "XXX"

    fmt.Printf("%q\n", a)
    fmt.Printf("%q\n", b)
}

実行結果

["AAA" "XXX" "CCC"]
["AAA" "XXX" "CCC"]

だめみたいでした。
append の第二引数以降は任意の数の引数ですので指定しないことも可能ですが、指定しなかったとしても同じ結果でした。

copy と同じように使えるわけではなさそうです。

ひとこと

配列とスライスに着いて、だいぶ理解が進んだのではないでしょうか?

両者の違い、スライスのメリット、スライスの落とし穴とその回避方法について理解できたでしょうか?

Posted by genzouw