下のQiitaの記事にコメントしたのだけど、なかなか整理するのが難しかったので、記事にすることにした。
Rubyの変数の振る舞いは難しいようで、理屈がわかってしまえばシンプルな理屈だ。ちょっと長くなってしまったが自分なりに整理してみた。
いくつかの例
Rubyの変数の振る舞いをいくつか見てみよう。まずは配列をコピーしてそれを逆順にしてみたい。
a = ['a', 'b', 'c']
b = a
b.reverse!
p b
# => ['c', 'b', 'a']
p a
# => ['c', 'b', 'a']
b
にa
のオブジェクトを代入したのだが、b
のオブジェクトを変更するとa
のオブジェクトも変更されてしまった。では、Object#dup
を使用してオブジェクトをコピーしてみよう。
a = ['a', 'b', 'c']
b = a.dup
b.reverse!
p b
# => ['c', 'b', 'a']
p a
# => ['a' ,'b' ,'c']
なるほど、今度は変数b
のオブジェクトを変更しても変数a
のオブジェクトは変更されなかった。Object#dup
を使えばオブジェクトは複製できるようなので、今度は値をHash
にしてみよう。ついでにキーの値を変更したい。
a = [{item: 'val1'}, {item: 'val2'}, {item: 'val3'}]
b = a.dup
b[0][:item] = 'new value'
b.reverse!
p b
# => [{:item=>"val3"}, {:item=>"val2"}, {:item=>"new value"}]
p a
# => [{:item=>"new value"}, {:item=>"val2"}, {:item=>"val3"}]
変数a
は逆順にはなっていないが、変数a
の配列の要素のHash
の値も書き換わってしまった。
なにが起こっているのか
だいたいの人はこの時点で気がつくので解説は要らないのであるが、一応自分なりに説明してみよう。
Rubyの変数はオブジェクトの実体そのものではなく、実体を参照するラベルのようなものである。つまり、変数a
にオブジェクトを代入する行為は、変数a
がオブジェクトを参照するようにすることである。変数b
を変数a
に代入すると、変数b
も変数a
も同じオブジェクトを参照するようになる。Rubyのオブジェクトはすべて固有のidをもっているので、これを見てみればよい。
a = ['a', 'b', 'c']
b = a
p b.object_id
# => 70196368118500
p a.object_id
# => 70196368118500
オブジェクトを複製するには、Object#clone
を使えば良い。しかし、このとき行われるのはオブジェクト自体のコピーだけであるので、オブジェクトのインスタンス変数が他のオブジェクトを参照している場合、その参照もそのままコピーされる。
a = [{item: 'val1'}, {item: 'val2'}, {item: 'val3'}]
b = a.clone
# 複製されたオブジェクトは異なるidを持っている
p b.object_id
# => 70196368297280
p a.object_id
# => 70196368278180
# 参照はそのままコピーされるので、配列の値は同じオブジェクトを参照している。
p b[0].object_id
# => 70196368278280
p a[0].object_id
# => 70196368278280
この挙動は浅いコピー(shallow copy)と呼ばれる。そもそもObject#clone
のリファレンスマニュアルをちゃんと読んでいればこの罠にはまることは少ないはずだ。
すべての値を複製するためには、オブジェクトが参照しているオブジェクト、参照しているオブジェクトが参照している全てのオブジェクト、参照しているオブジェクトが参照しているオブジェクト………といったかんじで複製していく必要がある。これは深いコピー(deep copy)と呼ばれる。
Rubyでは深いコピーを行う機能は提供されていない。これを簡単に実現する方法がオブジェクトのシリアライズ/デシリアライズである。シリアライズ、デシリアライズとはオブジェクトを文字列に変換、そして復元することである。Rubyでは、Marshal
モジュールでオブジェクトをシリアライズ/デシリアライズする機能が提供されている。
b = Marshal.load(Marshal.dump(a))
Marshal
を使えば、オブジェクトは一旦すべて文字列に変換され、その文字列をもとに新しいオブジェクトを生成するため、すべての値が複製されたのと同じ効果を得られる。もちろんMarshal
が使用できないオブジェクトはこの方法で複製できないので注意が必要である。
コピーの罠にはまらないために
さて、そもそもなぜこんな罠にはまったのかを考えてみよう。それは破壊的操作を行うメソッドによりオブジェクト自体を変更したからだ。破壊的操作とはその名の通りオブジェクト自体に変更を加える。もちろん、このときオブジェクトのidは変更されない。
a = ['a', 'b', 'c']
p a.object_id
# => 70196368230840
a.reverse!
p b.object_id
# => 70196368230840
では、破壊的操作を行わないメソッドを使用していればどうだっただろうか。メソッドが実行された時点で新しいオブジェクトが生成される。変数b
は新しいオブジェクトを参照することになる。変更された時点で新しいオブジェクトが生成されるため、無駄なリソースなども消費しない。
a = ['a', 'b', 'c']
b = a # この時点ではまだbもaも同じオブジェクトを参照している
b = b.reverse # ここでbは新しいオブジェクトを参照する
p b
# => ['c', 'b', 'a']
p a
# => ['a', 'b', 'c']
このように、破壊的操作を行うときにオブジェクトを複製すればよい。Array#[]=
、Hash#[]=
も破壊的操作に該当するので、そのまえにオブジェクトを複製する。
a = [{item: 'val1'}, {item: 'val2'}, {item: 'val3'}]
b = a
b = b.reverse
b[0] = b[0].clone.tap{|h| h[:item] = 'new value' } # 複製してから修正
p b
# => [{:item=>"new value"}, {:item=>"val2"}, {:item=>"val1"}]
p a
# => [{:item=>"val1"}, {:item=>"val2"}, {:item=>"val3"}]
このようにb[0]
の参照しているオブジェクトを複製して、そのオブジェクトに対して破壊的操作を行えば、a
の参照しているオブジェクトの参照しているオブジェクトには影響しない。Hash#merge
を使った方がシンプルかもしれない。
a = [{item: 'val1'}, {item: 'val2'}, {item: 'val3'}]
b = a
b = b.reverse
b[0] = b[0].merge({item: 'new value'}) # mergeは非破壊
p b
# => [{:item=>"new value"}, {:item=>"val2"}, {:item=>"val1"}]
p a
# => [{:item=>"val1"}, {:item=>"val2"}, {:item=>"val3"}]
ということで、コピーの罠だと思っていたのは破壊的操作の罠だったといえるかもしれない。破壊的操作が行われて困る場合はObject#freeze
を使うのも手だ。
まとめ
この変数とコピーの挙動は、あれどうだったっけとなることがたまにある。しかし、そういうときは大抵配列とハッシュが入れ子になった巨大な構造を変更する場合など、そもそもプログラムの構造が悪そうなときであることが多い。
Rubyらしいコードを書くように気をつければ、おのずとObject#clone
のようなメソッドを使う機会も減っていくのではないだろうか。その頃にはこういう罠にはまることもなさそうだけど。