🛵

Go の make 関数の使い方とベストプラクティス

2022/03/09に公開
1

Go には特殊なデータ構造を備えた「参照型」という型が定義されています。
参照型はデータのメモリアドレスを参照し、データ自体ではなくそのアドレスを通して間接的にデータにアクセスする型です。
直接的なデータ操作よりもデータの参照(アドレス)を操作することにより、データの共有や管理を容易にし、Go のメモリ管理の柔軟性と効率性を高めています。
参照型には、「スライス(slice)」「マップ(map)」「チャネル(channel)」 が標準で含まれおり、これらの変数を別の変数に代入すると、データそのものではなくデータへの参照(アドレス)がコピーされます。

Go の組み込み関数 make はこれらの参照型であるスライス、マップ、およびチャネルを生成(初期化)するのに使用されます。

これら以外のデータ型、例えば基本型(int, float, bool など)や構造体、配列などには make は使用されず、それらのデータ型は通常、直接的な宣言や new 関数を用いて初期化されます。
new 関数は、指定された型のポインタを返し、その項目はゼロ値で初期化されます。これは make とは異なる動作です。

※ Go では、配列は「固定長配列」、スライスは「可変長配列」でありそれらは異なるものとして区別されています。
固定長である配列は宣言時に要素数が決められ実行時に変更されないため、メモリの使用がより予測可能になることやキャッシュの利用効率などの理由から他のプリミティブな型と同じ扱いになっています。

make の使用パターン一覧

make による各参照型生成(初期化)の方法は以下の通りです

呼び出し形式 意味
make(T, n) スライス 要素数と容量がTであるT型のスライスを生成
make(T, n, m) スライス 要素数が n で容量が m であるT型のスライスを生成
make(T) マップ T型のマップを生成
make(T, n) マップ T型のマップに要素数 n を付与
make(chan T) チャネル バッファのないT型のチャネルを生成
make(chan T, n) チャネル バッファサイズ n のT型のチャネルを生成


make 関数を使わずにスライスやマップを生成することは可能です。これらは、要素数と容量(後述)が記述した通りの数に決定されます。

// 要素数・容量ともに3となる
s := []int{1, 2, 3}

// 要素数・容量ともに0となる
var s []int

make を使用せずにチャネルを生成する方法もあります。具体的には、チャネル変数を宣言するだけで、nil チャネルを生成することができます。

var ch chan int

ただし、nil チャネルは使用する前に make を使って初期化する必要があります。

ch := make(chan int)
ch := make(chan int, 10)

つまり、チャネルを使用する際には make による初期化が必須です。

「容量」とは

上記の一覧から、スライスやマップの要素数の他に「容量(capacity)」も指定できることがわかります。
容量とはメモリ上に確保する領域のことです。

例えば2つの引数を使い make でスライスを生成(初期化)した場合には、そのスライスの要素数・容量はどちらも2番目の引数で指定した整数値と同じ値になります。

// 要素数と容量5の string 型のスライスを生成
s := make([]string, 5)
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 5

そして、明示的に第3引数によって容量を指定することもできます。これにより、以下のような要素数と容量が異なるスライスを作成できます。

// 要素数5、容量10の string 型のスライスを生成
s2 := make([]string, 5, 10)
fmt.Println(len(s2)) // 5
fmt.Println(cap(s2)) // 10

さて、あらかじめ容量を指定すると何が嬉しいのでしょうか。
例えば最初に生成(初期化)したスライス s に要素を追加して拡張したいとします。

s[0] = "a"
s[1] = "b"
s[2] = "c"
s[3] = "d"
s[4] = "e"

// s はあらかじめ要素数を5・容量を5としているため、

| 0 | 1 | 2 | 3 | 4 |
| a | b | c | d | e |

// 6つ目の要素をそのまま追加しようとすると存在しないアドレスへのアクセスとなりランタイムパニックになる
s[5] = "f"

// append を使い要素を追加する必要がある
s = append(s, "f")
s = append(s, "g")

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | // 5, 6番目の要素が追加されている
| a | b | c | d | e | f | g |

上記の通り、あらかじめ要素数を5・容量を5と指定された s に5つ目の要素を追加(s[4] = 4)した時点で、元々確保された、連続したメモリ領域は使い切ってしまっており
6つ目以降の要素を追加する際には append を使い要素を拡張しています。元々用意されていない、存在しない6つ目以降のアドレスへのアクセスはできないためです。

append が実行されたタイミングで、Go のランタイムは元の容量より大きな連続するメモリ領域を確保して、元のスライスが格納していたデータを丸ごと新しい領域へコピーします。
スライスの容量を拡張するために、自動的にコピー処理が実行され、メモリ上の別領域に移動させられるという動作は非常にコストが高い処理です。

つまり、あらかじめスライスが持ちうる要素の数と容量がわかる場合には、できるだけそれらを指定する方がCPUフレンドリーです。

ベストプラクティス

make の使用パターン一覧 にもある通り、make を使用しなくてもスライスやマップは作成することは可能ですが、それはベストプラクティスではありません。

スライスやマップ、チャネルを生成(初期化)する際にはなるべく make を使用し、
さらにその際に、「容量」とは での説明にもあるように、実行性能を落とさず良質なパフォーマンスを維持するためにも必要なサイズや容量を事前に検討し、それらを適切に設定するのがベストプラクティスです。

開発者がメモリ操作について特に気にせずともサクッと高パフォーマンスを発揮してくれるのが Go の強みではありますが、より実行効率の良いプログラムにするためには、それらスライスやマップ、チャネルのメモリ領域の変動を最低限に抑えることに留意する必要もあります。

Discussion