Golang における配列とスライスのコピー時の違い

commits4 min read読了の目安(約4200字

Golang における配列とスライスのコピー時の違い

今回、私が配列とスライスを使用した時、そもそも配列とスライスの違いを意識せず使っていたため挙動を理解できていなかった経験からメモとして残しておく。

この時の私は全く理解できてなかったのでこんな感じですね↓

出典:荒川弘『鋼の錬金術師』

では、仮にこういう問題を考えてみましょう

例えば、複数人で使うツール開発をしているとき、それぞれのユーザがそれぞれのデータを保持するために配列もしくはスライスを使う必要性があるとします。
ここで、それぞれのデータを共有して使いたいと考え、配列もしくはスライスを別の配列もしくはスライスにコピーして使用したいと考えました。
コピーしたデータを書き換えた際に、コピー元である他人のデータが勝手に書き変わったりそうでなかったりと不規則に影響が出てしまいました。果たしてそれは何故でしょうか。

実験

先程のシチュエーションを再現して考察してみましょう。

要素にする構造体
今回の説明で使用する構造体は以下の通りに定義されています。 配列(array)とスライス(slice),TestData型です。配列とスライスで比較しやすいように構造体にします。

struct
type TestDataI struct {
	slice []int
	array [4]int
}

type TestDataJ struct {
	slice []int
	array [4]int
}

ちなみにTestDataIとTestDataJで分けているのはIをオリジナル、Jをコピー先として使用するためです。

Arrayは固定長配列とも言いますが、固定するために配列の要素数をあらかじめ宣言しなければなりません。
今回は [4]int とint型要素数4の配列を宣言しています。
また、int型のArrayは宣言時にゼロパディングされます。

同様に、Sliceは可変長配列とも言いますが、Sliceの場合はあらかじめ要素数を宣言してはいけません。

初期化

配列やスライスを使うためにはデータの初期化処理が必要になります、
構造体で定義した配列とスライスを今回は main() で初期化することにします。

main()
func main() {
	var i TestDataI
	var j TestDataJ
	i.slice = []int{1, 2, 3, 4}
	i.array = [4]int{1, 2, 3, 4}
}

変数 i , j にそれぞれの構造体型を宣言します。
また、i にはあらかじめデータを入れておきます。

さて早速コピーしてみましょう。
Golang で配列やスライスをコピーするには=演算子を使う方法とcopy関数を使う方法などがありますが、今回は=演算子を使う方法で試してみます。

配列の場合

copy
j.array = i.array
fmt.Print("i:")
fmt.Println(i.array)
fmt.Print("j:")
fmt.Println(j.array)

スライスの場合

copy
j.slice = i.slice
fmt.Print("i:")
fmt.Println(i.slice)
fmt.Print("j:")
fmt.Println(j.slice)

実行結果

i:[1 2 3 4]
j:[1 2 3 4]

無事にコピーされたことを確認できましたね。

次にコピー元の i のデータを書き換えると何が起こるのでしょうか。
実際に書き換えを行ってみましょう。

配列の場合

rewrite
fmt.Println("i.array[0] = 5")
i.array[0] = 5
fmt.Print("i:")
fmt.Println(i.array)
fmt.Print("j:")
fmt.Println(j.array)

スライスの場合

rewrite
fmt.Println("i.slice[0] = 5")
i.slice[0] = 5
fmt.Print("i:")
fmt.Println(i.slice)
fmt.Print("j:")
fmt.Println(j.slice)

実行結果

i.array[0] = 5
i:[5 2 3 4]
j:[1 2 3 4]
i.slice[0] = 5
i:[5 2 3 4]
j:[5 2 3 4]

実行結果を見て貰えば一目瞭然なのですが、 ij で値が違いますね。

なぜ違いが発生するのか

簡単にいえば、配列とスライスでコピー時のメモリの使い方が異なっているからです。
これはメモリアドレスを見ればイメージできるようになります。

playgroundでこの例を実装してみます。メモリアドレスを覗いてみましょう。

https://play.golang.org/p/Qdcq1HCXXak

実行結果

データ確認
i-slice:[1 2 3 4]
j-slice:[]
i-array:[1 2 3 4]
j-array:[0 0 0 0]
データ確認
配列にコピーして値書き換え
i:[1 2 3 4]
j:[1 2 3 4]
i メモリアドレス:0xc0000bc058
j メモリアドレス:0xc0000bc098
i.array[0] = 5
i:[5 2 3 4]
i メモリアドレス:0xc0000bc058
j:[1 2 3 4]
j メモリアドレス:0xc0000bc098
配列にコピーして値書き換え
スライスにコピーして値書き換え
i:[1 2 3 4]
j:[1 2 3 4]
i メモリアドレス:0xc0000bc040
j メモリアドレス:0xc0000bc080
i.slice[0] = 5
i:[5 2 3 4]
i メモリアドレス:0xc0000bc040
j:[5 2 3 4]
j メモリアドレス:0xc0000bc080
スライスにコピーして値書き換え

ここで、一見メモリアドレスには変化がないのに、値だけが変わっていますね。
そうです、配列そのもの、スライスそのもののアドレスはそれぞれ独立していて共有されていません。
なので、一見値が書き変わったり変わらなかったりが不思議に思うかもしれません。

次にもう少しネストしてメモリアドレスを覗いてみましょう。

再度playgroundでこの例を実装してみます。メモリアドレスを覗いてみましょう。

https://play.golang.org/p/9wLYku7PGrI

実行結果

データ確認
i-slice:[1 2 3 4]
j-slice:[]
i-array:[1 2 3 4]
j-array:[0 0 0 0]
データ確認
配列にコピーして値書き換え
i:[1 2 3 4]
j:[1 2 3 4]
i メモリアドレス:0xc00001a098
i メモリアドレス要素0:0xc00001a098
j メモリアドレス:0xc00001a0d8
j メモリアドレス要素0:0xc00001a0d8
i.array[0] = 5
i:[5 2 3 4]
i メモリアドレス:0xc00001a098
i メモリアドレス要素0:0xc00001a098
j:[1 2 3 4]
j メモリアドレス:0xc00001a0d8
j メモリアドレス要素0:0xc00001a0d8
配列にコピーして値書き換え
スライスにコピーして値書き換え
i:[1 2 3 4]
j:[1 2 3 4]
i メモリアドレス:0xc00001a080
i メモリアドレス要素0:0xc00007e000
j メモリアドレス:0xc00001a0c0
j メモリアドレス要素0:0xc00007e000
i.slice[0] = 5
i:[5 2 3 4]
i メモリアドレス:0xc00001a080
i メモリアドレス要素0:0xc00007e000
j:[5 2 3 4]
j メモリアドレス:0xc00001a0c0
j メモリアドレス要素0:0xc00007e000
スライスにコピーして値書き換え

気付きましたね、配列は全てのアドレスが異なっています、なので、配列をコピーした後はそれぞれに書き込みを行っても他の配列やスライスに影響が出ないことがわかります。
逆に、スライスでは、スライスの要素のアドレスが同じですので、スライスの要素の書き換え等を行った場合、他のスライスに影響が出てくるということです。

わかりやすく図にしてみると以下のようになります。

最後に

今まで私はこの違いを意識していませんでしたが、配列やスライスといった基本的なことでもこうやって確認していくことが大切だなと改めて思いました。

スライスについてあるシーンを思い出しました。
「オレもアルもその大きい流れの中のほんの小さなひとつ、全の中の一、だけどその一が集まって全てが存在する、この世は想像もつかない大きな法則に従って流れている、その流れを知り分解して再構築する・・・それが錬金術」
出典:荒川弘『鋼の錬金術師』

いくら可変長配列として無限に要素を定義できるといっても、その実態は配列であり、「それがスライス」ということ意識しなければなりませんね。

※間違い、誤植等、発見されましたらご指摘、ご指南いただけると幸いです。