⌨️

Golangのio.ReadAllからsliceの挙動を学ぶ

2022/07/18に公開

io.ReadAllの実装

Goのioパッケージ関数ReadAll
io.Readerインターフェースを引数に受け取り、([]byte, error)を返却します。
(Go1.18.0のコードから抜粋)
関数内部で[]byteを生成していますが、このsliceに対して行われている操作に学びが多いと思ったので、
この関数の挙動をsliceを中心にまとめたいと思います。

// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r Reader) ([]byte, error) {
	b := make([]byte, 0, 512)
	for {
		if len(b) == cap(b) {
			// Add more capacity (let append pick how much).
			b = append(b, 0)[:len(b)]
		}
		n, err := r.Read(b[len(b):cap(b)])
		b = b[:len(b)+n]
		if err != nil {
			if err == EOF {
				err = nil
			}
			return b, err
		}
	}
}

io.ReadAllにおけるsliceの扱われ方

(1)sliceをmake関数で初期化して長さ・容量を指定する

まずは1行目で[]byteの変数bを初期化しています。

// io.ReadAll L1
b := make([]byte, 0, 512)

make関数はslice, map, channel のいずれかの初期化時に利用できます。

func make(t Type, size ...IntegerType) Type

sliceの場合の挙動は以下になります。
(make関数のコメントから抜粋)

The size specifies the length. The capacity of the slice is
equal to its length. A second integer argument may be provided to
specify a different capacity; it must be no smaller than the
length. For example, make([]int, 0, 10) allocates an underlying array
of size 10 and returns a slice of length 0 and capacity 10 that is
backed by this underlying array.

  • sizeでsliceの長さ(length)を指定する
  • sliceの容量(capacity)は長さと同じになる
  • 別の容量を指定する場合は2番目の整数引数を指定する(長さ以上の値を指定)
  • make([]int, 0, 10)は基になるサイズ10の配列を割り当て、長さ:0、容量:10のsliceを返却する

つまり、io.ReadAllの1行目で長さ0、容量512のsliceを作っていることになります。
これはサイズ512の配列がメモリを確保したということでもあるので、
長さ512まではsliceのためのメモリ再割り当ての必要がなく、リソースを効率的に活用できます。

動作確認のために以下のコードを実行します。

func sample1() {
	s0 := []int{}              // makeを使わない(length:0)
	s1 := []int{0, 0, 0, 0, 0} // makeを使わない(length:5)
	s2 := make([]int, 0, 10)   // makeを使う(length:0, capacity:10)
	s3 := make([]int, 5, 10)   // makeを使う(length:5, capacity:10)

	fmt.Printf("[s0] len: %d, cap: %d, s: %d\n", len(s0), cap(s0), s0)
	fmt.Printf("[s1] len: %d, cap: %d, s: %d\n", len(s1), cap(s1), s1)
	fmt.Printf("[s2] len: %d, cap: %d, s: %d\n", len(s2), cap(s2), s2)
	fmt.Printf("[s3] len: %d, cap: %d, s: %d\n", len(s3), cap(s3), s3)
}

結果は以下の通りです。
makeで容量を長さ以上に指定しても、長さの分しかslice内の値は出力されません。
また、slice内の値はゼロ値で初期化されています。
makeを使わずに初期化した場合、長さと容量は一致しているため、
sliceに1つでも値を追加すると新たな配列を作成してsliceに割り当てなおす必要があり、結果としてメモリの再割り当てが発生します。

sliceの最終的な長さがほぼ確定している場合や、ある程度大きくなることが予想される場合は、
make関数で容量を指定してsliceを作成したほうが良さそうです。

[s0] len: 0, cap: 0, s: []
[s1] len: 5, cap: 5, s: [0 0 0 0 0]
[s2] len: 0, cap: 10, s: []
[s3] len: 5, cap: 10, s: [0 0 0 0 0]

(2)append関数を使ってsliceの容量を拡張

続いて2行目以降はfor文になっており、
3-6行目では、b []byteが容量いっぱいまで埋まった際に、append関数でbを拡張しています。
具体的にはappendで1つだけ要素を追加することで容量を拡張させた後、
元の長さのsliceとして切り取っています。
容量をどれだけ拡張するかはappendの挙動に任せています。

// io.ReadAll L3-6
if len(b) == cap(b) {
	// Add more capacity (let append pick how much).
	b = append(b, 0)[:len(b)]
}

同様の処理を行った際の動作確認のため、以下のコードを実行します。

func sample2() {
	b := make([]byte, 0)

	for i := 0; i < 5000; i++ {
		if len(b) == cap(b) {
			fmt.Printf("[before|%p] len: %d, cap: %d\n", b, len(b), cap(b))
			b = append(b, 0)[:len(b)]
			fmt.Printf("[after |%p] len: %d, cap: %d\n", b, len(b), cap(b))
		}
		b = append(b, 0)
	}
}

結果は以下の通りです。

  • len(b)cap(b)が同じ状態でappendを行うと、bのアドレスが変わり、容量も増える
    • 容量は8~512までは2倍ずつ増える
    • それ以降は増加率が徐々に低下する
  • len(b)cap(b)が同じ状態でappend(b, 0)[:len(b)]とすると、容量が拡張され長さはそのまま
[before|0x552090] len: 0, cap: 0
[after |0xc0000140a8] len: 0, cap: 8
[before|0xc0000140a8] len: 8, cap: 8
[after |0xc0000140c0] len: 8, cap: 16
[before|0xc0000140c0] len: 16, cap: 16
[after |0xc000018120] len: 16, cap: 32
[before|0xc000018120] len: 32, cap: 32
[after |0xc00001a140] len: 32, cap: 64
[before|0xc00001a140] len: 64, cap: 64
[after |0xc000022080] len: 64, cap: 128
[before|0xc000022080] len: 128, cap: 128
[after |0xc000092000] len: 128, cap: 256
[before|0xc000092000] len: 256, cap: 256
[after |0xc000094000] len: 256, cap: 512
[before|0xc000094000] len: 512, cap: 512
[after |0xc000096000] len: 512, cap: 896
[before|0xc000096000] len: 896, cap: 896
[after |0xc000098000] len: 896, cap: 1408
[before|0xc000098000] len: 1408, cap: 1408
[after |0xc00009c000] len: 1408, cap: 2048
[before|0xc00009c000] len: 2048, cap: 2048
[after |0xc00009e000] len: 2048, cap: 3072
[before|0xc00009e000] len: 3072, cap: 3072
[after |0xc0000a4000] len: 3072, cap: 4096
[before|0xc0000a4000] len: 4096, cap: 4096
[after |0xc0000a6000] len: 4096, cap: 5376

(3)配列に要素を追加してからsliceの長さを拡張する

最後に7~8行目でio.Readerからの読み込んだ結果を[]byteで受け取っています。

// io.ReadAll L7-8
n, err := r.Read(b[len(b):cap(b)])
b = b[:len(b)+n]

7行目で使われているio.Readerは以下のようにRead関数を持ったinterfaceになっています。

//io package
type Reader interface {
	Read(p []byte) (n int, err error)
}

Read関数は以下のように実装するという指針があります。

  • バイト数がlen(p)分だけ読み込んで、pに対して読み込んだ値を格納する
  • 読み込んだバイト数nと、発生したエラーerrを返却する
    • 0 <= n <= len(p)

7行目のb[len(b):cap(b)]bが長さ(len)として確保している分の次のインデックスから、
容量(cap)までを別のsliceとして切り出したものです。
その後、8行目のb = b[:len(b)+n]で、
bの長さ(len)をr.Readで読み込んだバイト数の分だけ増やしています。

この実装から、以下の挙動がわかります。

  • スライスsに対して、len(s) < cap(s)になっている場合もs[len(s):cap(s)]という形でsの元になっている配列の一部を別のスライスとして切り出すことができる
  • 別のスライスとして切り出したs[len(s):cap(s)]に格納された値はsの元配列と同じ配列に格納されているので、s = s[:cap(s)]とすればsの一部として参照可能

こちらも動作確認してみます。

func sample3() {
	s := make([]int, 5, 10) //len:5, cap:10
	fmt.Printf("s:%d\n", s)
	s2 := s[len(s):cap(s)]
	for i, _ := range s2 {
		s2[i] = i + 1
	}
	fmt.Printf("s2:%d\n", s2)
	s = s[:len(s)+len(s2)]
	fmt.Printf("s:%d\n", s)
}

結果は以下のようになりました。

s:[0 0 0 0 0]
s2:[1 2 3 4 5]
s:[0 0 0 0 0 1 2 3 4 5]

以上の(1)~(3)の操作を行うことで、sliceの容量を適宜拡張しながら読み込みが実行されていました。
簡単な小ネタのような内容でしたが、シンプルにGoのコードを書くヒントにもなったので、
今後の参考にしていきたいと思います。

Discussion