はじめに
この記事は2021年Goアドベントカレンダー(3)の9日目の記事です。
ポインタにするか値にするか考え方針をまとめてみました。
この記事で書かれていることは既に多くの方が考えたり記事にしたりしていますが、どうするべきか迷うことがあり、例となるコード付きで方針をまとめたいと考え記事にします。(この記事の内容は個人的な方針です。サービスの特性などで変わる点はあると思います。)
以下の3つのパターンで記載しています。
- 構造体について
- 関数の引数について
- メソッドのレシーバについて
間違いなどありましたら教えていただけると嬉しいです。
構造体について
方針
- 構造体のフィールドに、スライスやmap、ポインタなどの参照型を持つ場合はポインタ
- 変更不可な構造体として、運用する場合は値でも良い(コンストラクタを定義し必ずコンストラクタで生成するようにする、不変なオブジェクトなど)
- 迷ったらポインタ
構造体のフィールドに、スライスやmap、ポインタなどの参照型を持つ場合は、ポインタで扱った方が良さそうと考えています。
理由は構造体が値の場合、コピーしたときに参照型ではないフィールドの変更は、コピー元のインスタンスには影響が無いですが、参照型のフィールドの変更はコピー元のインスタンスも変更されます。このようにフィールドによって挙動が異なる状態になってしまうのはわかりにくいです。
そのため参照型のフィールドを持つ場合はポインタで扱うようにした方がわかりやすそうです。
もしくは変更不可な構造体として、一度インスタンスを生成した後は変更されないようにするという方針であれば値でも良さそうです。
以下は、値として生成したインスタンスを別の変数に代入し、代入した変数のインスタンスを変更してみました。
type amount struct {
label string
amount int
}
type amounts struct {
name string
amount int
amounts []amount
amountMap map[string]int
amountPtr *amount
}
func main() {
val1 := amounts{
name: "amounts",
amount: 1000,
amounts: []amount{
amount{
label: "amount1",
amount: 1000,
},
},
amountMap: map[string]int{
"amount1": 1000,
},
amountPtr: &amount{
label: "amount1",
amount: 1000,
},
}
// val1を別の変数に代入
val2 := val1
// val2のnameフィールドを変更
val2.name = "amounts2"
// val2のamountフィールドを変更
val2.amount = 1001
// val2のamountフィールドの要素を変更
val2.amounts[0].amount += 1
// val2のamountMapフィールドの要素を変更
val2.amountMap["amount1"] += 1
// val2のamountPtrフィールドの要素を変更
val2.amountPtr.amount += 1
fmt.Printf("val1 %#v %d \n", val1, val1.amountPtr.amount)
fmt.Printf("val2 %#v %d \n", val2, val2.amountPtr.amount)
}
nameフィールドと、amountはval2のみ変更されていますが、それ以外のフィールドはval1も変更されます。
val1 main.amounts{name:"amounts", amount:1000, amounts:[]main.amount{main.amount{label:"amount1", amount:1001}}, amountMap:map[string]int{"amount1":1001}, amountPtr:(*main.amount)(0xc00000c048)} 1001
val2 main.amounts{name:"amounts2", amount:1001, amounts:[]main.amount{main.amount{label:"amount1", amount:1001}}, amountMap:map[string]int{"amount1":1001}, amountPtr:(*main.amount)(0xc00000c048)} 1001
関数の引数
方針
- 関数内で行う仮引数の変更を実引数に反映したい場合はポインタ
- 値のコピーのコストを無視できない場合はポインタ
- 引数でnilを表現する必要がある場合はポインタ
- スライスやmapなどの参照型は値で良い
基本的に、関数内で仮引数の変更を実引数に反映する場合以外は値でも良さそうですが、実際はチーム内でコードの統一性などを考慮して決めても良いと考えています。
type item struct {
name string
price int
}
// 引数が値の場合、変更は反映されない
func changeItemName(arg item) {
arg.name = "sakana"
}
// 引数がポインタの場合、変更は反映される
func changeItemNamePtr(arg *item) {
// 引数がポインタの場合、nilチェック
if arg == nil {
fmt.Println("引数 が nil")
return
}
arg.name = "sakana"
}
func NewItems(base item, args []item) []item {
// argsがnilの場合、lenは0
items := make([]item, 0, len(args)+1)
items = append(items, base)
return append(items, args...)
}
func main() {
i := item{
name: "sake",
price: 100,
}
changeItemName(i)
fmt.Println(i)
changeItemNamePtr(nil)
changeItemNamePtr(&i)
fmt.Println(i)
items := NewItems(i, nil)
fmt.Println(items)
items2 := NewItems(i, []item{
{
name: "beel",
price: 200,
},
})
fmt.Println(items2)
}
{sake 100}
引数 が nil
{sakana 100}
[{sakana 100}]
[{sakana 100} {beel 200}]
レシーバーについて
レシーバーも関数の引数をポインタにするか値にするかと同じ考えになります。
方針
- 構造体のフィールドを変更する場合はポインタ
- フィールドを変更をしない場合は値で良い
- mapなどの参照型(スライスは除く)のみをフィールドにもつ構造体は値で良い
- メソッド呼び出し時の値のコピーが無視できない場合ポインタ(大きな構造体の場合など)
- 迷ったらポインタ
- 1メソッドでもポインタレシーバにする場合は統一性をもたせるためにその構造体のメソッドは全てポインタ
レシーバーをポインタにした場合と、値にした場合で以下のような違いがあります。
ポインタレシーバー
- メソッド内でレシーバーのフィールドを変更することができる。
- チームの方針や設計にもよるが、レシーバーがnilでないかどうかチェックする必要がある。
- メソッド内でスライスのフィールドに対してappend関数での要素追加が可能。
- メソッドの呼び出しごとに呼び出し元インスタンスのポインタがコピーされる。
値レシーバー
- mapなど参照型のフィールドを除いて、メソッド内でレシーバーのフィールドを変更することができない。
- mapなど参照型のフィールドは、チームの方針や設計にもよるが、nilでないかチェックする必要がある。
- レシーバーが値でも、戻り値で変更後の新しいインスタンスを返すようにすることで、内容が変更されたインスタンスを扱うことはできる。
- 参照型でもスライスに関してはレシーバーが値の場合、append関数での追加はできない。インデックスを指定しての要素の変更は可能。
- 値レシーバーのメソッド内でappend関数を使うと、append関数は新しいスライスを返すので、メソッドの呼び出し元の参照先が変更されないことが理由。
- メソッドの呼び出しごとに呼び出し元インスタンスの値がコピーされる。
以下は上記の内容をコードにしています。
type item struct {
name string
price int
}
type amount struct {
label string
amount int
items []item
itemMap map[string]int
}
// レシーバーが値の場合、要素を追加できない
func (a amount) addItem(arg item) {
a.items = append(a.items, arg)
}
// レシーバーがポインタの場合、要素を追加できる
func (a *amount) addItemPtr(arg item) {
// レシーバーがポインタの場合、nilになる可能性を考慮する場合はnilチェック
if a == nil {
fmt.Println("レシーバー が nil")
return
}
a.items = append(a.items, arg)
}
// レシーバーが値でも要素の変更は可能
func (a amount) addItemIndexInsert(arg item) {
a.items[0] = arg
}
// レシーバーが値でもmapは変更できる
func (a amount) addItemMap(arg item) {
// レシーバーが値でもmapのフィールドがnilになる可能性を考慮する場合はnilチェック
if a.itemMap == nil {
fmt.Println("map が nil")
return
}
a.itemMap[arg.name] = arg.price
}
// レシーバーが値の場合、変更が反映されない
func (a amount) changeLabelValue(arg string) {
a.label = arg
}
// レシーバーがポインタの場合、変更が反映される
func (a *amount) changeLabelPtr(arg string) {
// レシーバーがポインタの場合、nilになる可能性を考慮する場合はnilチェック
if a == nil {
fmt.Println("レシーバー が nil")
return
}
a.label = arg
}
// レシーバーが値の場合でも、戻り値で変更されたインスタンスを返すことで同じようなことはできる
func (a amount) changeLabel(arg string) amount {
return amount{
label: arg,
amount: a.amount,
}
}
func main() {
a := amount{
label: "amount1",
amount: 1000,
}
a.addItem(item{
name: "pan",
price: 1000,
})
// panは追加されない
fmt.Println(a)
a.addItemPtr(item{
name: "niku",
price: 500,
})
// nikuは追加される
fmt.Println(a)
a.addItemIndexInsert(item{
name: "kome",
price: 1500,
})
// komeへの変更はできる
fmt.Println(a)
// この時点ではitemMapフィールドはnil
a.addItemMap(item{
name: "piza",
price: 2000,
})
// pizeの追加はされない
fmt.Println(a)
// itemMapフィールドに初期値を設定
a.itemMap = map[string]int{}
a.addItemMap(item{
name: "piza",
price: 2000,
})
// pizeが追加される
fmt.Println(a)
a.changeLabelValue("amount2")
// labelの変更は反映しない
fmt.Println(a)
a.changeLabelPtr("amount2")
// labelの変更が反映する
fmt.Println(a)
aa := a.changeLabel("amount3")
// labelの変更が反映された戻り値を受け取る
fmt.Println(aa)
// a2 は nil
var a2 *amount
// addItemPtrはレシーバーがポインタなのでメソッドを呼び出せる
a2.addItemPtr(item{
name: "niku",
price: 500,
})
//// addItemはレシーバーが値なのでメソッドを呼ぶことができない
//a2.addItem(item{
// name: "pan",
// price: 1000,
//})
//fmt.Println(a2)
}
{amount1 1000 [] map[]}
{amount1 1000 [{niku 500}] map[]}
{amount1 1000 [{kome 1500}] map[]}
map が nil
{amount1 1000 [{kome 1500}] map[]}
{amount1 1000 [{kome 1500}] map[piza:2000]}
{amount1 1000 [{kome 1500}] map[piza:2000]}
{amount2 1000 [{kome 1500}] map[piza:2000]}
{amount3 1000 [{kome 1500}] map[piza:2000]}
レシーバー が nil
ポインタ渡しと値渡しのパフォーマンスについて
関数の引数とレシーバーの項目で、構造体の大きさでポインタにするか値にするかを考慮すると記載していますが、とても大きな構造体である場合を除いて考慮する必要はなさそうです。
以下のような構造体で、関数のポインタ渡しと値渡しのベンチマークを取ってみました。
※実行結果は環境により変わると思いますのであくまで参考値です。今回テストする構造体の種類も2種類だけです。本来ならより多くのパターンのテストをすべきだとは思います。
ベンチマークの方法は以下の記事を参考にしました。
より詳しいベンチマークはこの記事を参考にしたほうが良いと思いますが記事とは別の構成の構造体でテストしております。
Go言語(golang)における値渡しとポインタ渡しのパフォーマンス影響について
※実行環境
- Go 1.17.2
- MacBook Pro (15-inch, 2018)
- プロセッサ 2.6 GHz 6コアIntel Core i7
- メモリ 32 GB 2400 MHz DDR4
// テスト用構造体と関数
// int64, stringのフィールドをもつ構造体.
type T1 struct {
f0 int64
f1 string
}
//int64 * 5, string * 5のフィールドをもつ構造体.
type T2 struct {
f0 int64
f1 string
f2 int64
f3 string
f4 int64
f5 string
f6 int64
f7 string
f8 int64
f9 string
}
var num int64
var str string
//go:noinline
func ValueFunc1(t1 T1) {
// 関数の処理は、引数の構造体のフィールドをpackageに定義した変数に代入するだけ(他の関数も同じ)
num = t1.f0
str = t1.f1
}
//go:noinline
func PtrFunc1(t1 *T1) {
num = t1.f0
str = t1.f1
}
//go:noinline
func ValueFunc2(t2 T2) {
num = t2.f0
str = t2.f1
num = t2.f2
str = t2.f3
num = t2.f4
str = t2.f5
num = t2.f6
str = t2.f7
num = t2.f8
str = t2.f9
}
//go:noinline
func PtrFunc2(t2 *T2) {
num = t2.f0
str = t2.f1
num = t2.f2
str = t2.f3
num = t2.f4
str = t2.f5
num = t2.f6
str = t2.f7
num = t2.f8
str = t2.f9
}
// ベンチマーク
import (
"testing"
)
// int64, stringのフィールドをもつ構造体で値渡しのベンチマーク.
func BenchmarkT1Value(b *testing.B) {
var t1 = T1{
f0: 9223372036854775807,
f1: create10MBStr(),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ValueFunc1(t1)
}
}
// int64, stringのフィールドをもつ構造体でポインタ渡しのベンチマーク.
func BenchmarkT1Ptr(b *testing.B) {
var t1 = T1{
f0: 9223372036854775807,
f1: create10MBStr(),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
PtrFunc1(&t1)
}
}
// int64 * 5, string * 5のフィールドをもつ構造体で値渡しのベンチマーク.
func BenchmarkT2Value(b *testing.B) {
var t2 = T2{
f0: 9223372036854775807,
f1: create10MBStr(),
f2: 9223372036854775807,
f3: create10MBStr(),
f4: 9223372036854775807,
f5: create10MBStr(),
f6: 9223372036854775807,
f7: create10MBStr(),
f8: 9223372036854775807,
f9: create10MBStr(),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ValueFunc2(t2)
}
}
// int64 * 5, string * 5のフィールドをもつ構造体でポインタ渡しのベンチマーク.
func BenchmarkT2Ptr(b *testing.B) {
var t2 = T2{
f0: 9223372036854775807,
f1: create10MBStr(),
f2: 9223372036854775807,
f3: create10MBStr(),
f4: 9223372036854775807,
f5: create10MBStr(),
f6: 9223372036854775807,
f7: create10MBStr(),
f8: 9223372036854775807,
f9: create10MBStr(),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
PtrFunc2(&t2)
}
}
// 10MBの文字列生成.
func create10MBStr() string {
s := "0123456789"
for i := 0; i < 20; i++ {
s += s
}
return s
}
ベンチマークの結果は以下です。フィールド数10(int64:5, string:5)のBenchmarkT2だと、値とポインタで差がありポインタの方が早いですが、処理速度の要件が厳しい場合以外はそこまで気にする必要はないかもです。
$ go test -bench . -benchmem ./...
goos: darwin
goarch: amd64
pkg: github.com/ShintaNakama/go-value-or-pointer-test/valorptr
cpu: Intel(R) Core(TM) i7-8850H CPU @ 2.60GHz
BenchmarkT1Value-12 670279506 1.790 ns/op 0 B/op 0 allocs/op
BenchmarkT1Ptr-12 671018950 1.785 ns/op 0 B/op 0 allocs/op
BenchmarkT2Value-12 157874214 8.443 ns/op 0 B/op 0 allocs/op
BenchmarkT2Ptr-12 266462283 4.548 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/ShintaNakama/go-value-or-pointer-test/valorptr 6.988s
まとめ
既に多くの方が記事にしている内容ではあり、普段Goを書いている人であれば当たり前のことかもしれませんが今回記事としてまとめることで再度理解しなおした点もありました。
あくまで個人的なひとつの方針ですが、誰かの参考になれたら嬉しいです。
参考記事
https://go.dev/doc/faq#methods_on_values_or_pointers
https://go.dev/doc/faq#stack_or_heap
Goの構造体の使われ方の設計
Go 言語の値レシーバとポインタレシーバ
Go言語(golang)における値渡しとポインタ渡しのパフォーマンス影響について