golangのスライスについて ~ただの便利な配列ではない~
はじめに
golangメソッドにおける引数の影響範囲を知る上でポインタ型かどうかというのは重要な判断材料になります.
Code
// test_replce
// [A] 実体渡し
test1a := arr
replaceHead(test1a, "A")
fmt.Printf("[A] replace: %v -> %v\n", arr, test1a) // 更新されない
// [B] ポインタ渡し
test1b := arr
replaceHeadPtr(&test1b, "A")
fmt.Printf("[B] replace: %v -> %v\n", arr, test1b) // 更新される
// test_reassign
// [A] 実体渡し
test2a := arr
reassign(test2a, [2]string{"A", "B"})
fmt.Printf("[A] reassign: %v -> %v\n", arr, test2a) // 更新されない
// [B] ポインタ渡し
test2b := arr
reassignPtr(&test2b, [2]string{"A", "B"})
fmt.Printf("[B] reassign: %v -> %v\n", arr, test2b) // 更新される
golangでは代入や関数呼び出しで変数を引き渡す際には参照渡しではなく値渡しを行うので, もし引数を更新したい場合にはポインタで引き渡す必要があります. 上記の場合では[B]の場合のみで更新されます.
では以下の処理においてr.Read(buf)を実行した際にbufに値が埋め込まれるのは何故でしょうか?
Code
// 文字列を8バイトずつreaderで読み取らせてprintする処理
func main() {
r := strings.NewReader("Hello, io.Reader example!")
buf := make([]byte, 8)
for {
// []byte型(実体)を引き渡している
n, err := r.Read(buf)
if err != nil {
if err == io.EOF {
break
}
fmt.Println("読み込みエラー:", err)
return
}
fmt.Print(string(buf[:n]))
}
}
というわけでgolangのスライスについて知識を整理します.
スライス型の定義
スライスは以下のように定義され配列の先頭メモリのポインタや配列長, 容量を格納したヘッダと配列本体の情報を持ちます. 単に配列構造のみを持つ配列とは図のように比較できますね.
引き渡し/更新時の挙動について
golangにおいて関数や代入によって変数の引き渡しが行われる際, 同一のメモリ領域は引き渡さず, 値をコピーして引き渡します. よって被代入変数や引数を引き受けた関数内部では値そのものは同じでも定義元とは異なるメモリ領域を参照することになります. これを前提に, 配列とスライスとで値更新時の挙動について比較してみましょう.
まず配列の場合は配列そのものがコピーされるので下図のように, 定義元のスナップショットに対して操作が行われます. この場合, 実際に操作するメモリ領域と定義元のもメモリ領域が異なっているので定義元側には何の影響も与えず, 値が更新されることはありません.
一方でスライスの引き渡し時にも同様にコピーが実行されるのですが, ポインタ情報もそのままの状態でコピーされるため同一のメモリ領域にある配列を参照することになります. この場合, 実際に操作を行うメモリ領域と定義元のメモリ領域が同一のため, 値の更新等は定義元にも影響することになります.
補足: スライスの再割り当ての場合
ただしスライス実体そのものの置換ではヘッダ情報のメモリ情報ごと更新されるので定義元との追従は断たれます.
Code
func main() {
sli := []string{"a", "b", "c"}
test1 := make([]string, len(sli))
copy(test1, sli) // 通常の代入だと配列本体を共有してしまう
reassign(sli, []string{"A", "B", "C"})
fmt.Printf("reassign: %v -> %v", sli, test1) // 更新されない
}
func reassign(sli []string, newSli []string) {
sli = newSli
fmt.Printf("inFunc: %v\n", sli)
return
}
注意したい実装例
Q. 下記のように商品配列から税込価格へ変更した商品配列を取得する処理を考える. この時getProductWithTaxメソッドはどのように実装すべきか?
type Product struct {
Name string
Price int
}
func main() {
original := []Product{
{"Apple", 100},
{"Banana", 200},
}
fmt.Println("[1] original: ", original)
// 税込価格へ修正した商品配列を得る
formatted := getProductWithTax(original)
fmt.Println("[2] original:", original)
fmt.Println("[2] formatted:", formatted)
}
アンチパターン
値渡しだからと引数をそのまま編集するような実装をすると, 定義元の情報も編集してしまう.
Code
func getProductWithTax(products []Product) []Product {
for i := range products {
products[i].Price = int(float64(products[i].Price) * 1.1)
}
return products
}
新規に変数を作って代入しようとしてもポインタの参照先はそのままなのでこれもNG
Code
func getProductWithTax(products []Product) []Product {
productsWithTax := products
for i := range products {
productsWithTax[i].Price = int(float64(products[i].Price) * 1.1)
}
return productsWithTax
}
正しい実装例
copyメソッドを用いて配列部分も含めて異なるメモリ領域にコピーさせることで, 期待通りの処理を行わせることができる.
Code
func getProductWithTax(products []Product) []Product {
productsWithTax := make([]Product, len(products))
copy(productsWithTax, products)
for i := range products {
productsWithTax[i].Price = int(float64(products[i].Price) * 1.1)
}
return productsWithTax
}
終わりに
golangって静的型付けと実装の簡易さが良く特長として挙げられますよね. だけど型がどのように定義されるかやそれがメモリ上でどのように保存されているかについて全く知らないと, 意図せずポインタ渡しが使われていた, 意図せず値の上書きが行われていたなんて事態になりかねないですよね. 便利になってくモジュールやパッケージは積極的に使っていきたいですがその仕組みについては日々意識を向けたいものです.
Discussion