Goにおけるポインタの使いどころ
本記事は Go 2 Advent Calendar 2020 の22日目の記事です
Goにはポインタの概念が存在します
ポインタによる変数の受け渡しは、値をコピーする必要がなくアドレスを渡すだけで完了するので効率的ですが、Goの場合ポインタを使いすぎるとガベージコレクションに負荷がかかってしまい、多くのCPU時間を消費するようになる可能性があります
例えば、以下のようにS
という構造体を、コピーして返す関数、ポインタで返す関数をそれぞれ用意します
type S struct {
a, b, c int64
d, e, f string
g, h, i float64
}
func byCopy() S {
return S{
a: 1, b: 1, c: 1,
e: "foo", f: "foo",
g: 1.0, h: 1.0, i: 1.0,
}
}
func byPointer() *S {
return &S{
a: 1, b: 1, c: 1,
e: "foo", f: "foo",
g: 1.0, h: 1.0, i: 1.0,
}
}
用意した関数を、以下のようなコードを書いてベンチマークを取ると
func BenchmarkStack(b *testing.B) {
var s S
f, err := os.Create("stack.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
for i := 0; i < b.N; i++ {
s = byCopy()
}
trace.Stop()
b.StopTimer()
_ = fmt.Sprintf("%v", s.a)
}
func BenchmarkMemoryHeap(b *testing.B) {
var s *S
f, err := os.Create("heap.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
for i := 0; i < b.N; i++ {
s = byPointer()
}
trace.Stop()
b.StopTimer()
_ = fmt.Sprintf("%v", s.a)
}
以下のような結果が得られます(go version go1.15.6 linux/amd64)
コピーする関数の方が良い結果が出てます
BenchmarkStack 127888153 9.41 ns/op 0 B/op 0 allocs/op
BenchmarkMemoryHeap 16394270 68.2 ns/op 96 B/op 1 allocs/op
使いどころ
ではどのような場面でポインタは使われるべきなのでしょうか?
引数やレシーバを関数内で書き換える必要がある場合
引数やレシーバを関数内で書き換える必要がある場合、書き換える対象はポインタで渡す必要があります
type S struct { value string }
func (s S) SetA (v string) {
s.value = v
}
func (s *S) SetB (v string) {
s.value = v
}
func main() {
var s S
s.SetA("a")
fmt.Println(s.value) // sはゼロ値のまま
s.SetB("b")
fmt.Println(s.value) // b
}
逆に関数内でレシーバに変更を加えない関数は、値レシーバを使った方が「この関数はレシーバに変更を加えない」というのがシグネチャだけで明示的になるので、可読性の観点でも好ましいと思われます
コピーを避けたいデータを引数、レシーバにする場合
os.File
や sync.Mutex
などコピーが発生すると問題が生じるような構造体の場合は、ポインタで扱うことでコピーされないようにします
// os.Open では File 型をポインタで返している
func Open(name string) (file *File, err error) {
return OpenFile(name, O_RDONLY, 0)
}
大きな構造体や配列を扱う場合
大きな構造体や配列を扱う場合はポインタを使う方が効率的です
逆にint
やstring
などのプリミティブな型やフィールドが多くない構造体に関しては、コピーのコストはあまり気にならず、むしろポインタで扱った方がGCの兼ね合いで非効率になるので、値レシーバにした方が良いようです
構造体の「大きい」の基準は Go Code Review Comments には以下のように書かれています
もし構造体の全ての値を引数に渡すと仮定してください。多すぎると感じたなら、それはポインタにしても良いくらいの大きさです。
なかなか難しい基準ですが、利便性や変更容易性、GCの負荷等を考慮して決めます
迷っている場合はレシーバをポインタにしましょう。
という記述もあったので、迷ったらポインタを使うのが良さそうです
大きな構造体をスライスに持たせる場合
スライス は cap
以上に append
した際や、for ~ range
でsliceの要素を取得する際に、全レコードのコピーが発生するので、slice 要素に大きな構造体を持たせる場合はポインタにしておくと、コピーのコストが抑えられます
// listのcapが足りない場合新しくアロケートされる
list = append(list, x)
// xにlistの要素がコピーされる
for _, x := range list {
...
}
まとめ
値とポインタの使い分けの基準が自分の中で少しはっきりとしました
こうしてみるとポインタの活躍箇所は結構限られたポイントになるのかなと思います
今回GCの負荷についてはあまり深く触れられなかったのですが、その辺りは@imotyさんの記事がとてもわかりやすかったです
ポインタを上手く使って、読み手側が意味を汲み取りやすい保守性の高いコードを書きましょう
Discussion