Goにおけるポインタの使いどころ

公開:2020/12/21
更新:2020/12/26
4 min読了の目安(約4400字TECH技術記事

本記事は 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
}

go playgroundで試す

逆に関数内でレシーバに変更を加えない関数は、値レシーバを使った方が「この関数はレシーバに変更を加えない」というのがシグネチャだけで明示的になるので、可読性の観点でも好ましいと思われます

コピーを避けたいデータを引数、レシーバにする場合

os.Filesync.Mutex などコピーが発生すると問題が生じるような構造体の場合は、ポインタで扱うことでコピーされないようにします

// os.Open では File 型をポインタで返している
func Open(name string) (file *File, err error) {
  return OpenFile(name, O_RDONLY, 0)
}

大きな構造体や配列を扱う場合

大きな構造体や配列を扱う場合はポインタを使う方が効率的です
逆にintstringなどのプリミティブな型やフィールドが多くない構造体に関しては、コピーのコストはあまり気にならず、むしろポインタで扱った方がGCの兼ね合いで非効率になるので、値レシーバにした方が良いようです

構造体の「大きい」の基準は Go Code Review Comments には以下のように書かれています

もし構造体の全ての値を引数に渡すと仮定してください。多すぎると感じたなら、それはポインタにしても良いくらいの大きさです。

なかなか難しい基準ですが、利便性や変更容易性、GCの負荷等を考慮して決めます

迷っている場合はレシーバをポインタにしましょう。

という記述もあったので、迷ったらポインタを使うのが良さそうです

Go Code Review Comments 日本語訳 (Receiver Type)

大きな構造体をスライスに持たせる場合

スライス は cap 以上に append した際や、for ~ range でsliceの要素を取得する際に、全レコードのコピーが発生するので、slice 要素に大きな構造体を持たせる場合はポインタにしておくと、コピーのコストが抑えられます

// listのcapが足りない場合新しくアロケートされる
list = append(list, x)

// xにlistの要素がコピーされる
for _, x := range list {
    ...
}

まとめ

値とポインタの使い分けの基準が自分の中で少しはっきりとしました
こうしてみるとポインタの活躍箇所は結構限られたポイントになるのかなと思います

今回GCの負荷についてはあまり深く触れられなかったのですが、その辺りは@imotyさんの記事がとてもわかりやすかったです

Go のGCのオーバーヘッドが高くなるケースと、その回避策

ポインタを上手く使って、読み手側が意味を汲み取りやすい保守性の高いコードを書きましょう