この記事はエイチーム引越し侍/エイチームコネクトの社員による、Ateam Hikkoshi samurai Inc.× Ateam Connect Inc. Advent Calendar 2021 12日目の記事です。
本日は1歳児の育児でちょくちょく離席が多いパパエンジニア@lostfindが担当します。
誤った内容や日本語の指摘は大歓迎です。とても助かります。
はじめに😀
11月に**「引越し侍 ネット見積もりサービス」**をリリースしました。
こちらのBackend ビジネスロジックはGo
によるClean Architecture
の概念をベースで開発しました。
( 当サービスの詳細な技術の構成については1日目の記事と、2日目の記事をご覧ください )
Clean Architecture
の特性上、構造体(モデル)が複数の層を渡って処理されるパターンが多いです。
その時に、引数としての構造体とそのスライスの型はほとんど構造体のポインタ型で扱っています。
ポインタ型だから値コピーされないので、早い、メモリ効率が良いと思い込んでいましたが、
「それ、本当か?」と思いましたので、軽く浅く検証してみました。
Benchmark⏱
go version 1.17.4
検証の準備
const sliceLen = 25
const maxNestedLevel = 4
type TestStr struct {
ID int
Name string
Groups []string
Steps []string
Child *SubStr
}
type SubStr struct {
ID int
SubName string
}
func NewTestStr() TestStr {
rand.Seed(time.Now().UnixNano())
id := rand.Intn(10000)
name := fmt.Sprintf("TesterValueName_%d", id)
child := SubStr{
ID: id + 1000,
SubName: name,
}
return TestStr{
ID: id,
Name: name,
Groups: []string{name, name, name, name, name, name, name},
Steps: []string{"init"},
}
}
構造体のスライス🥒
リポジトリからデータを取得する時はだいたい構造体のスライスを返しているので、
その結果を持って各層で何かをする想定の検証用コードです。
func RecursionPointerSlice(input []*TestStr, lv int) []*TestStr {
for i := range input {
input[i].Steps = append(input[i].Steps, strconv.Itoa(lv))
}
nextLevel := lv + 1
if nextLevel > maxNestedLevel {
return input
}
return RecursionPointerSlice(input, nextLevel)
}
func RecursionValueSlice(input []TestStr, lv int) []TestStr {
// 中身は上の関数と同じ
...
}
func Benchmark_Pointer_NestedFunc_PointerSlice(b *testing.B) {
input := PointerSlice()
b.ResetTimer()
for i := 0; i < b.N; i++ {
RecursionPointerSlice(input, 1)
}
}
func Benchmark_Value_NestedFunc_ValueSlice(b *testing.B) {
...
}
結果
ベンチマークの結果は構造体のポインタではない方早かったです。差は10%程度。
ただ、sliceの内部ではポインタになっているので、何回ベンチマークして見たら両方のパフォーマンスの差はないようでした。
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz
Benchmark_Pointer_NestedFunc_PointerSlice-4 160000 7877 ns/op 8831 B/op 0 allocs/op
Benchmark_Value_NestedFunc_ValueSlice-4 160000 6938 ns/op 8831 B/op 0 allocs/op
構造体の単体🍅
構造体の単体で扱う時はどれぐらい差があるか確認してみました。
引数で受け取った構造体の中身でなにかをすることを想定してJustReturn()
という関数を呼び出しています。
func RecursionPointer(input *TestStr, lv int) *TestStr {
nextLevel := lv + 1
JustReturn(input.Name)
if nextLevel > maxNestedLevel {
return input
}
return RecursionPointer(input, nextLevel)
}
func RecursionValue(input TestStr, lv int) TestStr {
...
}
//go:noinline
func JustReturn(name string) string {
return name
}
JustReturn()関数はinline展開されて何も影響しなくなるので go:noinline というpragmaをつけます
結果
ポインタ型のほうが4倍ぐらい早い結果となりました。
テスト対象である構造体を大きくしたり、再帰をもっと深くすると、差はもっと広がりポインタ型の方が10倍ぐらい早い結果となりました。
Benchmark_Pointer_NestedFunc_Pointer-4 17000000 17.35 ns/op 0 B/op 0 allocs/op
Benchmark_Value_NestedFunc_Value-4 17000000 66.96 ns/op 0 B/op 0 allocs/op
まとめ
Slice、Mapのときはポインタ型にしなくても良い。
単体の構造体インスタンスを複数の関数やメソッドに引き渡すときはポインタ型が良い。
Immutabilityが重要なところではポインタ型は扱いに注意するべき。
これ以外にも、コードを色々変えながらベンチマークしてみましたが、もっと得られる情報はありませんでした。
構造体が小さい場合や、メソッド呼び出しのネストが深くない場合はポインタ型でなくても良いかと思います。
すこしの心残り
goのbenchmarkだけで判断しているため、偏ったまとめになってしまいました。
garbage collectorの動き方によっては実際に動くアプリケーションでは異なる結果になるかもしれないなと思いました。
明日 😉
明日は@kekiさんの記事です!お楽しみに