Ruby

Rubyの変数の振る舞いまとめ

More than 1 year has passed since last update.

下のQiitaの記事にコメントしたのだけど、なかなか整理するのが難しかったので、記事にすることにした。

Rubyの変数の振る舞いは難しいようで、理屈がわかってしまえばシンプルな理屈だ。ちょっと長くなってしまったが自分なりに整理してみた。

いくつかの例

Rubyの変数の振る舞いをいくつか見てみよう。まずは配列をコピーしてそれを逆順にしてみたい。

test1.rb
a = ['a', 'b', 'c']
b = a
b.reverse!

p b
# => ['c', 'b', 'a']
p a
# => ['c', 'b', 'a']

baのオブジェクトを代入したのだが、bのオブジェクトを変更するとaのオブジェクトも変更されてしまった。では、Object#dupを使用してオブジェクトをコピーしてみよう。

test2.rb
a = ['a', 'b', 'c']
b = a.dup
b.reverse!

p b
# => ['c', 'b', 'a']
p a
# => ['a' ,'b' ,'c']

なるほど、今度は変数bのオブジェクトを変更しても変数aのオブジェクトは変更されなかった。Object#dupを使えばオブジェクトは複製できるようなので、今度は値をHashにしてみよう。ついでにキーの値を変更したい。

test3.rb
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をもっているので、これを見てみればよい。

test4.rb
a = ['a', 'b', 'c']
b = a

p b.object_id
# => 70196368118500
p a.object_id
# => 70196368118500

オブジェクトを複製するには、Object#cloneを使えば良い。しかし、このとき行われるのはオブジェクト自体のコピーだけであるので、オブジェクトのインスタンス変数が他のオブジェクトを参照している場合、その参照もそのままコピーされる。

test5.rb
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は変更されない。

test6.rb
a = ['a', 'b', 'c']

p a.object_id
# => 70196368230840

a.reverse!

p b.object_id
# => 70196368230840

では、破壊的操作を行わないメソッドを使用していればどうだっただろうか。メソッドが実行された時点で新しいオブジェクトが生成される。変数bは新しいオブジェクトを参照することになる。変更された時点で新しいオブジェクトが生成されるため、無駄なリソースなども消費しない。

test7.rb
a = ['a', 'b', 'c']
b = a                # この時点ではまだbもaも同じオブジェクトを参照している
b = b.reverse        # ここでbは新しいオブジェクトを参照する

p b
# => ['c', 'b', 'a']
p a
# => ['a', 'b', 'c']

このように、破壊的操作を行うときにオブジェクトを複製すればよい。Array#[]=Hash#[]=も破壊的操作に該当するので、そのまえにオブジェクトを複製する。

test8.rb
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を使った方がシンプルかもしれない。

test9.rb
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のようなメソッドを使う機会も減っていくのではないだろうか。その頃にはこういう罠にはまることもなさそうだけど。