Advent Calendar 4日目の記事です。
--
この前とある実装をしているときに、既存のコードをちょっとだけいじった結果、不思議な挙動に遭遇しました。
事象として面白かったので共有します。
それでも消えてしまったナポリタン
まずはコードを見ていただきたいと思います。
ある日、こんなコードがありました。(※転載用に要点絞って改変してあります)
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
オリジナルのスライス構造体items[i]
です。
参照先の配列データには「abcde」が入っておりlen=5です。
メソッド処理前のitem.Options
渡ってきたbyteスライスを想定します。
これはオリジナルのコピーitem
(for文内一時変数)で、内部的にも全く同じです。
メソッド処理後のitem.Options
次にメソッド実行後をイメージしてみます。ここでは「abcde」から「b」をremoveしています。
スライスが「acde」に変更されました。変更後の長さに応じてスライス構造体のlen=4に更新されます。
ちなみにここで配列5番目の「e」はスライスとしては使われなくなりましたが、実データの配列要素としてメモリ上に残り続けます。
item
のOptions
の中身を出力すると想定通りの結果がちゃんと確認できます。
メソッド処理後のitem[i].Options
呼び出し元の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: "あんぱん"},
}
社内レビューでは、貴重なご意見を頂きましたので今後の参考とさせていただきます。
弊社ではエンジニアを募集しています。