32
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

HRBrainAdvent Calendar 2022

Day 4

Goのsliceはsafeじゃない

Last updated at Posted at 2022-12-03

Advent Calendar 4日目の記事です。

--

この前とある実装をしているときに、既存のコードをちょっとだけいじった結果、不思議な挙動に遭遇しました。

事象として面白かったので共有します。

それでも消えてしまったナポリタン

Why Naporitan?

まずはコードを見ていただきたいと思います。

ある日、こんなコードがありました。(※転載用に要点絞って改変してあります)

type Item struct {
	Options json.RawMessage
}

type (
	Option struct {
		Value string
	}
	Options = []Option
)

func (item *Item) RemoveOption(value string) {
	var options Options
	_ = json.Unmarshal(item.Options, &options)

	newOptions := make(Options, 0, len(options))
	for _, option := range options {
		if option.Value == value {
			continue
		}
		newOptions = append(newOptions, option)
	}

	b, _ := json.Marshal(newOptions)
	_ = item.Options.UnmarshalJSON(b)
}

func main() {
	items := []Item{newItem()}
	fmt.Println(string(items[0].Options))

	newItems := make([]Item, 0, len(items))
	for _, item := range items {
		item.RemoveOption("ナポリタン")
		newItems = append(newItems, item)
	}

	fmt.Println(string(newItems[0].Options))
}

func newItem() Item {
	options := Options{
		{Value: "マグロ"},
		{Value: "ナポリタン"},
		{Value: "あんぱん"},
	}
	b, _ := json.Marshal(options)
	return Item{Options: b}
}

やっていることを簡単に説明すると、RemoveOptionの中でItemの中のJSONメッセージであるOptionsを改変しています。メソッド引数として渡した文字列valueとJSONメッセージ内の.Valueが一致したら取り除くといった処理です。

上の例ではOptionsの中から「ナポリタン」が消えてくれれば期待通りといったところです。

これを実行するとどうなるか結果を見てみます。

出力結果
[{"Value":"マグロ"},{"Value":"ナポリタン"},{"Value":"あんぱん"}]
[{"Value":"マグロ"},{"Value":"あんぱん"}]

上がbefore、下がafterです。どうやらやりたいことは実現できているようです。
ここまでは動作としてはOKですね。

ここから僕が加えた変更を見てみます。

変更点
func main() {
-	newItems := make([]Item, 0, len(items))
	for _, item := range items {
		item.RemoveOption("ナポリタン")
-		newItems = append(newItems, item)
	}
}

なぜこの変更をしたかというと、別のスライスにわざわざ詰め替える必要はないからです。なぜならRemoveOptionがポインタレシーバーメソッドとして定義されているので、構造体を直接変更するはずです。

では結果を見てみましょう。

出力結果
[{"Value":"マグロ"},{"Value":"ナポリタン"},{"Value":"あんぱん"}]
[{"Value":"マグロ"},{"Value":"あんぱん"}]"},{"Value":"あんぱん"}]

JSONがバグりました...。

これは一旦みなかったことにしましょう。
さっきのコードをもう一度みてください。

for _, item := range items {
	item.RemoveOption("ナポリタン")
}

このコード、そもそも期待した結果にはなり得ません

RemoveOptionはポインタレシーバーメソッドとして定義されているので、レシーバーの値を直接書き換えるはずですが、for文の中で要素をitem変数の中に一度assignしたものがメソッドに渡ってしまっています。

なので本来itemsの一要素としては変更されていないはずです。なのに上の例ではなぜか変更されていてしかも期待とは違う結果を得ます。

ちなみに期待挙動としては、こうなります。

期待する出力結果
[{"Value":"マグロ"},{"Value":"ナポリタン"},{"Value":"あんぱん"}]
[{"Value":"マグロ"},{"Value":"ナポリタン"},{"Value":"あんぱん"}]

つまりこの場合何も変わっていないことが正のはずです。

ちゃんと配列の一要素を改変する挙動にするためには、for文の中では配列の一要素としてメソッドコールをする必要があります。なので正しくはこうです。

for i := range items {
	items[i].RemoveOption("ナポリタン")
}

items[i]として呼ぶ必要があり、そしてこれは期待通りの結果を得ます。

出力結果
[{"Value":"マグロ"},{"Value":"ナポリタン"},{"Value":"あんぱん"}]
[{"Value":"マグロ"},{"Value":"ナポリタン"}]

これで問題は解決なのですが、先ほど偶然にも遭遇してしまった謎が気になります。

for文の中で、コピーされたitemが渡されているにもかかわらず、itemsの一部であるOptionsが書き換えられてしまうのは何故でしょうか。

消えたナポリタンを追え

さて、もう一度件の謎を見てみましょう。
「ナポリタン」がなくなっていることもアレですが、JSONとしてバグってるのもおかしいです。

出力結果
[{"Value":"マグロ"},{"Value":"ナポリタン"},{"Value":"あんぱん"}]
[{"Value":"マグロ"},{"Value":"あんぱん"}]"},{"Value":"あんぱん"}]

before/afterでどういった違いがあるか観察してみるとあることに気づきます。

元のJSON前方部分が改変後のJSONで置換されているように見受けられました。

もしそうであるなら、今回の場合はRemove処理のためbeforeとafterの長さは一緒のはずですね。文字列だと見た目でわかりにくいのでbyteスライスの長さを比較してみます。

bfr := len([]byte(`[{"Value":"マグロ"},{"Value":"ナポリタン"},{"Value":"あんぱん"}]`))
aft := len([]byte(`[{"Value":"マグロ"},{"Value":"あんぱん"}]"},{"Value":"あんぱん"}]`))
fmt.Println(bfr == aft)

結果はtrueとなり長さは一致していました。どうやらこの仮説は正しそうです。

type Item struct {
	Options json.RawMessage
}

json.RawMessageの実装を見るとこうなっています。

type RawMessage []byte

byteスライスのdefined typeとして定義されています。そしてUnmarshalerを実装しています。

func (m *RawMessage) UnmarshalJSON(data []byte) error {
	省略
}

今回の事例の中でRemoveOptionメソッドはレシーバーのOptionsを変更する際にUnmarshalJSONをコールしています。
つまりポインタの指し示す先のbyte配列の一部が書き換えられることになります。

for文の中で、コピーされたitemが渡されているにもかかわらず、itemsの一部であるOptionsが書き換えられてしまう理由がこれで少しわかりそうな気がしませんか?

json.RawMessage型であるOptionsのポインタアドレスがそのままコピーされていて同一のデータを参照しているのではないか。

for文の中でassignされた一時変数のitemとスライスの一要素としてのitems[i]それぞれのOptionsのポインタアドレスを比較してみましょう。

for i, item := range items {
	fmt.Printf("item.Options addr:     %p\n", item.Options)
	fmt.Printf("items[i].Options addr: %p\n", items[i].Options)
	//item.RemoveOption("ナポリタン")
}
出力結果
item.Options addr:     0xc0000c8000
items[i].Options addr: 0xc0000c8000

一致しました。これで一つのことがわかります。

コピーされたitemを渡しても内包するbyteスライスが参照するポインタアドレスは同一になる(アドレス値がコピーされる)ゆえに、書き換えも可能ということになります。

それがわかった上でも、まだ説明できないことが残っています。

なぜ元々のJSONの一部を書き換えるような挙動になっているのでしょうか?
これですね。

出力結果
[{"Value":"マグロ"},{"Value":"あんぱん"}]"},{"Value":"あんぱん"}]

この理由を理解するためにはスライスの構造を知る必要がありそうです。

sliceの内部構造

公式の記事を見つけたのでみてみましょう。
https://go.dev/blog/slices-intro

実は、sliceは配列を参照しているに過ぎません
その実態は構造体であり、図にあるように3つの情報を保有しています。

  • ptr: 配列データを指し示すポインタアドレス値
  • len: 配列のうちスライスとして実際に使っている長さ
  • cap: 配列として確保している長さ。

上の例だと、ptrのアドレス先からcap=5の長さの配列が確保されていて、スライスとしてlen=5の長さで利用している、といった感じの説明になります。

繰り返しになりますが、スライスは構造体として表現されており、ポインタではありません。そのフィールドの一部がポインタを参照しているものであるということになります。

そしてスライスのlenとは配列の実データの長さのことではなくその参照範囲の長さです。

ここまで理解すると、JSONがバグっていたことを完全に説明できるようになりました。

消えたナポリタンの謎の正体

もう一度ポインタレシーバーメソッドを呼んでいるコードに戻ります。

for _, item := range items {
	item.RemoveOption("ナポリタン")
}

itemにスライスの一要素がassignされるとき、内部フィールドのjson.RawMessage(byteスライス)はスライス構造体としてコピーされます。

この時のptr,len,capはそれぞれどうなっているかというと、コピー元のオリジナルと全く同じです。
コピーなので、このスライス構造体自体への変更はオリジナルのスライス構造体には影響を与えません。

ではRemoveOptionが呼ばれた後にスライス構造体のptr,len,capがそれぞれどう変化するかイメージしてみましょう。

ptrのアドレス参照先のbyte配列が書き変わります。ptrのアドレスはそのままです。
lenはbyte配列の改変に合わせて更新されます。(今回のRemoveOptions処理の場合は短くなりますね)
capは変わりません。

オリジナルのスライス構造体そのものには影響を与えませんが、ptrポインタの参照先データには変更があるため間接的に影響することになります。

言葉だけで説明したので少しわかりにくかったかと思います。

ここからはさっきの記事の図に沿ってみてみましょう。(今回取り上げた事例のJSON byteスライスで図示すると大変なので、以下ではシンプルな5byteのスライスを例に取り扱っています)

左にあるものがスライスを表現する構造で上からptr,len,capを順に示しています。右が参照先の配列データです。

メソッド処理前のitem[i].Options

スクリーンショット 2022-11-30 14.39.51.png
オリジナルのスライス構造体items[i]です。
参照先の配列データには「abcde」が入っておりlen=5です。

メソッド処理前のitem.Options

スクリーンショット 2022-11-30 14.39.51.png
渡ってきたbyteスライスを想定します。
これはオリジナルのコピーitem(for文内一時変数)で、内部的にも全く同じです。

メソッド処理後のitem.Options

スクリーンショット 2022-11-30 14.36.38.png
次にメソッド実行後をイメージしてみます。ここでは「abcde」から「b」をremoveしています。
スライスが「acde」に変更されました。変更後の長さに応じてスライス構造体のlen=4に更新されます。
ちなみにここで配列5番目の「e」はスライスとしては使われなくなりましたが、実データの配列要素としてメモリ上に残り続けます
itemOptionsの中身を出力すると想定通りの結果がちゃんと確認できます。

メソッド処理後のitem[i].Options

スクリーンショット 2022-11-30 14.38.57.png
呼び出し元のfor文に戻ってきました。上図はitemの元となった配列の一要素items[i]を示しています。
先程のitemへの変更はコピーされたスライス構造体の変更結果にすぎないので、こちらは影響を受けていません。
なので元のままlen=5です。しかし参照先データは書き換わっています。

ここで問題が発生します。

オリジナルのスライス構造体は一切変わっていないにもかかわらず、参照先配列データへの変更内容に対して元のlenで切り取るため、意図しない内容を示すスライスが出来上がります。
「acde」となっててほしいのに「acdee」という結果を得ることになります。

これが今回の謎の正体です。

さて、注目すべきはコピー元とコピー先のlenが違う(反映されていない)ことでした。
最後に実例を元に、スライスのlenの状態遷移を確認してみることにします。

func main() {
	items := []Item{newItem()}
	fmt.Println("処理前のitems[0].Optionsのlen", len(items[0].Options))

	for _, item := range items {
		fmt.Println("処理前のitem.Optionsのlen", len(item.Options))
		item.RemoveOption("ナポリタン")
		fmt.Println("処理後のitem.Optionsのlen", len(item.Options))
	}

	fmt.Println("処理後のitems[0].Optionsのlen", len(items[0].Options))
}
出力結果
処理前のitems[0].Optionsのlen 76
処理前のitem.Optionsのlen 76
処理後のitem.Optionsのlen 48
処理後のitems[0].Optionsのlen 76

一時変数itemがメソッドに渡るためオリジナルのlenはそのままになっていますね。
これがもし(ちゃんと?)48であれば期待結果を得られることは以下で確認できます。

func main {
    省略
	fmt.Println(string(items[0].Options[:48]))
}
出力結果
[{"Value":"マグロ"},{"Value":"あんぱん"}]

最後に

今回こういった現象に偶然遭遇したのは、ポインタレシーバーを呼んでいるにもかかわらずfor文の中で一時変数itemを渡してしまっていたことによるものでした。

結論ちゃんとitems[i]でメソッドコールしていれば問題なしです。
こういう変なデータの書き換えがある時って大体ポインタが関わっている気がしますね。

Goのslice、注意して付き合っていきましょう。

追伸

事例で挙げたコードの中に、以下のようなものをサンプルとして記載しましたが主教的な意味は特にありません。

	options := Options{
		{Value: "マグロ"},
		{Value: "ナポリタン"},
		{Value: "あんぱん"},
	}

社内レビューでは、貴重なご意見を頂きましたので今後の参考とさせていただきます。

スクリーンショット 2022-12-01 18.58.41.png

弊社ではエンジニアを募集しています。

32
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?