名前付き戻り値との正しい付き合い方

はじめに

こんにちは!eurekaのAPIチームエンジニアをしている鈴木です。
今回はGo言語における名前付き変数の戻り値について書いていきます。

Go言語における名前付き変数の戻り値

Go言語でメソッドを書いている時、ポインタで返すのか値のみで返すのかメモリ領域の確保を考慮するなどの点で迷う時があると思います。

Go言語の戻り値には名前を付けることができ、引数パラメータのように通常の変数として扱うことができます。
名前が付与されていると、関数が呼び出されたときにその型のゼロ値で初期化されます。
引数を持たないreturn文を実行したときは、その時点で結果パラメータに格納されている値が、戻り値として使われます。

通常の値のみ返却する関数と名前付き戻り値を使用した関数、ポインタを使用した場合どちらの方がメモリを効率的に確保出来るのか調べたのでまとめていきます。

通常の値のみ返却する関数

下記のコードはreturn文ごとに新しいオブジェクトのインスタンスを生成しています。

package main
import (
"fmt"
)
type MyObject struct {
arg1 int32
arg2 int64
arg3 string
arg4 []int
}
func NoNamedReturnValues(i int) MyObject {
if i == 1 {
// something
return MyObject{}
}
if i == 2 {
// something
return MyObject{}
}
if i == 3 {
// something
return MyObject{}
}
// Normal return
return MyObject{}
}
func main() {
fmt.Println(NoNamedReturnValues(4))
}

Go言語のコンパイラが生成するアセンブリコードを出力してメモリの使用量を確認します。
Goにはgo tool compileという指定したファイルをコンパイルするコマンドがあります。

$ go tool compile -S main.goで出力します。

"".NoNamedReturnValues STEXT nosplit size=274 args=0x40 locals=0x8
0x0000 00000 (main.go:14) TEXT "".NoNamedReturnValues(SB), NOSPLIT, $8-64
0x0000 00000 (main.go:14) SUBQ $8, SP
0x0004 00004 (main.go:14) MOVQ BP, (SP)
0x0008 00008 (main.go:14) LEAQ (SP), BP
0x000c 00012 (main.go:14) FUNCDATA $0, gclocals·1d0ed49f611d7e40a62328b5976a2ede(SB)
0x000c 00012 (main.go:14) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x000c 00012 (main.go:14) MOVQ $0, "".~r1+24(SP)
0x0015 00021 (main.go:14) XORPS X0, X0
0x0018 00024 (main.go:14) MOVUPS X0, "".~r1+32(SP)
0x001d 00029 (main.go:14) MOVUPS X0, "".~r1+48(SP)
0x0022 00034 (main.go:14) MOVUPS X0, "".~r1+64(SP)
0x0027 00039 (main.go:14) MOVQ "".i+16(SP), AX
0x002c 00044 (main.go:16) CMPQ AX, $1
0x0030 00048 (main.go:16) JEQ 222
0x0036 00054 (main.go:21) CMPQ AX, $2
0x003a 00058 (main.go:21) JEQ 170
0x003c 00060 (main.go:26) CMPQ AX, $3
0x0040 00064 (main.go:26) JNE 118
0x0042 00066 (main.go:28) MOVQ "".statictmp_2(SB), AX
0x0049 00073 (main.go:28) MOVQ AX, "".~r1+24(SP)
0x004e 00078 (main.go:28) LEAQ "".~r1+32(SP), DI
0x0053 00083 (main.go:28) LEAQ "".statictmp_2+8(SB), SI
0x005a 00090 (main.go:28) DUFFCOPY $854
0x006d 00109 (main.go:28) MOVQ (SP), BP
0x0071 00113 (main.go:28) ADDQ $8, SP
0x0075 00117 (main.go:28) RET
0x0076 00118 (main.go:32) MOVQ "".statictmp_3(SB), AX
0x007d 00125 (main.go:32) MOVQ AX, "".~r1+24(SP)
0x0082 00130 (main.go:32) LEAQ "".~r1+32(SP), DI
0x0087 00135 (main.go:32) LEAQ "".statictmp_3+8(SB), SI
0x008e 00142 (main.go:32) DUFFCOPY $854
0x00a1 00161 (main.go:32) MOVQ (SP), BP
0x00a5 00165 (main.go:32) ADDQ $8, SP
0x00a9 00169 (main.go:32) RET
0x00aa 00170 (main.go:23) MOVQ "".statictmp_1(SB), AX
0x00b1 00177 (main.go:23) MOVQ AX, "".~r1+24(SP)
0x00b6 00182 (main.go:23) LEAQ "".~r1+32(SP), DI
0x00bb 00187 (main.go:23) LEAQ "".statictmp_1+8(SB), SI
0x00c2 00194 (main.go:23) DUFFCOPY $854
0x00d5 00213 (main.go:23) MOVQ (SP), BP
0x00d9 00217 (main.go:23) ADDQ $8, SP
0x00dd 00221 (main.go:23) RET
0x00de 00222 (main.go:18) MOVQ "".statictmp_0(SB), AX
0x00e5 00229 (main.go:18) MOVQ AX, "".~r1+24(SP)
0x00ea 00234 (main.go:18) LEAQ "".~r1+32(SP), DI
0x00ef 00239 (main.go:18) LEAQ "".statictmp_0+8(SB), SI
0x00f6 00246 (main.go:18) DUFFCOPY $854
0x0109 00265 (main.go:18) MOVQ (SP), BP
0x010d 00269 (main.go:18) ADDQ $8, SP
0x0111 00273 (main.go:18) RET

アセンブリの出力コードを見て頂くとreturn文ごとにDUFFCOPYが出力さていてメモリが割り当てられているのが確認出来ます。

ポインタで返す場合

ポインタで構造体を宣言した場合の出力を確認します。

package main
import (
"fmt"
)
type MyObject struct {
arg1 int32
arg2 int64
arg3 string
arg4 []int
}
func NoNamedReturnValues(i int) *MyObject {
// ポインタの構造体を宣言する
var m *MyObject
if i == 1 {
// something
return m
}
if i == 2 {
// something
return m
}
if i == 3 {
// something
return m
}
// Normal return
return m
}
func main() {
fmt.Println(NoNamedReturnValues(4))
}

出力結果

"".NoNamedReturnValues STEXT nosplit size=63 args=0x10 locals=0x0
0x0000 00000 (main5.go:14) TEXT "".NoNamedReturnValues(SB), NOSPLIT, $0-16
0x0000 00000 (main5.go:14) FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 00000 (main5.go:14) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main5.go:14) MOVQ "".i+8(SP), AX
0x0005 00005 (main5.go:17) CMPQ AX, $1
0x0009 00009 (main5.go:17) JEQ 53
0x000b 00011 (main5.go:22) CMPQ AX, $2
0x000f 00015 (main5.go:22) JEQ 43
0x0011 00017 (main5.go:27) CMPQ AX, $3
0x0015 00021 (main5.go:27) JNE 33
0x0017 00023 (main5.go:29) MOVQ $0, "".~r1+16(SP)
0x0020 00032 (main5.go:29) RET
0x0021 00033 (main5.go:33) MOVQ $0, "".~r1+16(SP)
0x002a 00042 (main5.go:33) RET
0x002b 00043 (main5.go:24) MOVQ $0, "".~r1+16(SP)
0x0034 00052 (main5.go:24) RET
0x0035 00053 (main5.go:19) MOVQ $0, "".~r1+16(SP)
0x003e 00062 (main5.go:19) RET

値のみを返却した場合とは異なりDUFFCOPYが出力されるということはありません。
さらに関数の大きさが274バイトから63バイトに減っていることが確認できます。

名前付き戻り値の場合

package main
import (
"fmt"
)
type MyObject struct {
arg1 int32
arg2 int64
arg3 string
arg4 []int
}
func NoNamedReturnValues(i int) (mo MyObject) {
if i == 1 {
// something
return
}
if i == 2 {
// something
return
}
if i == 3 {
// something
return
}
// Normal return
return
}
func main() {
fmt.Println(NoNamedReturnValues(4))
}

出力結果

"".NoNamedReturnValues STEXT nosplit size=54 args=0x40 locals=0x0
0x0000 00000 (main4.go:14) TEXT "".NoNamedReturnValues(SB), NOSPLIT, $0-64
0x0000 00000 (main4.go:14) FUNCDATA $0, gclocals·1d0ed49f611d7e40a62328b5976a2ede(SB)
0x0000 00000 (main4.go:14) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main4.go:14) MOVQ $0, "".mo+16(SP)
0x0009 00009 (main4.go:14) XORPS X0, X0
0x000c 00012 (main4.go:14) MOVUPS X0, "".mo+24(SP)
0x0011 00017 (main4.go:14) MOVUPS X0, "".mo+40(SP)
0x0016 00022 (main4.go:14) MOVUPS X0, "".mo+56(SP)
0x001b 00027 (main4.go:14) MOVQ "".i+8(SP), AX
0x0020 00032 (main4.go:16) CMPQ AX, $1
0x0024 00036 (main4.go:16) JEQ 53
0x0026 00038 (main4.go:21) CMPQ AX, $2
0x002a 00042 (main4.go:21) JEQ 52
0x002c 00044 (main4.go:26) CMPQ AX, $3
0x0030 00048 (main4.go:26) JNE 51
0x0032 00050 (main4.go:28) RET
0x0033 00051 (main4.go:32) RET
0x0034 00052 (main4.go:23) RET
0x0035 00053 (main4.go:18) RET

ポインタで返却した時と同様にDUFFCOPYが出力されていません。
274バイトから54バイトまで減っています。

まとめ

ポインタで返す必要がある場合や、nilのチェックをせずに使用するため値のみを返す場合など様々なケースがありますが、今回の検証で値のみを返却する場合は名前付き戻り値の方がメモリ領域を無駄に確保せず実装ができるということです。
新しいコードだけでなく既存のコードに対しても名前付き戻り値を使用してリファクタリングは可能だと思います。
値のみを返却する関数を使用しなければいけない場合は名前付き戻り値を積極的に使用して行きましょう。