ryotatake blog

Webエンジニア

Rubyで意図せず変数の値が書き換わってしまうことについて調べてみる

引数に文字列や配列を渡す場合、メソッドの中での処理のされ方によって元の変数の値が変わってしまうことがあります。

元の変数の値が変わってしまうことについて手を動かしながら確認してみました。

  • 数値を渡すパターン
def foo(a)
  a += 1
end

x = 10
puts foo(x) #=> 11
puts x      #=> 10 xは変更されていない
  • 文字列を渡すパターン
def foo(a)
  a = "foo"
end

def bar(a)
  a.upcase!
end

x = "abc"
puts foo(x) #=> foo
puts x      #=> abc xは変更されていない

puts bar(x) #=> ABC
puts x      #=> ABC xは変更されている
  • 配列を渡すパターン
def foo(a)
  a = [4, 5, 6]
end

def bar(a)
  a << 4
end

x = [1, 2, 3]
p foo(x) #=> [4, 5, 6]
p x      #=> [1, 2, 3] xは変更されていない

p bar(x) #=> [1, 2, 3, 4]
p x      #=> [1, 2, 3, 4] xは変更されている

この挙動について文字列を使って確認します。
Fiddle::Pointerクラスというものを使うと、どのメモリ領域が使われているかを知ることができます。

Fiddle::Pointerでどのアドレスに何が保存されているかを確認する

require "fiddle"

s = "adc"
cptr = Fiddle::Pointer[s]
p cptr.to_i  #=> 140737198084016
             # "abc"が格納されている先頭のアドレス = sが指しているアドレス
p cptr[0]    #=> 97
             # cptrの先頭アドレスに入っている値
p cptr[0, 1] #=> "a"
             # cptrの先頭アドレスから、1バイト分に入っている値を文字列に変換したもの
             # aは文字コードで97。
p cptr[1]    #=> 98
             # cptrの2バイト目に入っている値
p cptr[1, 1] #=> "b"
             # cptrの2バイト目に入っている値を文字列に変換
p cptr[2]    #=> 99
             # cptrの3バイト目に入っている値
p cptr[2, 1] #=> "c"
             # cptrの3バイト目に入っている値を文字列に変換

Fiddle::Pointer.[]to_iして出力される数値は、与えられた値が文字列の場合は、値が入っている先頭のアドレス(メモリの中での位置)です。

cptr[0]では、先頭のアドレスの中に入っている値を取得します。 cptr[0, 1]では、その値を文字列に変換した値を返します。

参考: ASCIIコード表

cptr[1], cptr[1, 1]はその次のアドレスに入っている値と、その値を文字列に変換した値を返します。

これを図で表すと次のようになります。

f:id:ryotatake:20200120221815j:plain

変数sの中には、文字列"abc"自体が入っているのではなくて、"abc"の先頭のアドレスが入っています。(Rubyソースコード完全解説 を読んでいるとこの説明は正確ではなさそうですが、だいたいのイメージはあっているのではと思います)

Fiddle::Pointerクラスを使って、元の変数の値が変わらないパターンと変わるパターンを詳しく追っていきます。

元の変数の値が変わらないパターン

s = "abc"

def foo(a)
  a = "foo"
end

foo(s)
puts s #=> abc

これをFiddle::Pointerを使って詳しく見てみます。

require 'fiddle'

s = "abc"
cptr = Fiddle::Pointer[s]
p cptr.to_i  #=> 140737198084016
p cptr[0]    #=> 97
p cptr[1]    #=> 98
p cptr[2]    #=> 99

def foo(a)
  cptr = Fiddle::Pointer[a] # ・・・1
  p cptr.to_i  #=> 140737198084016
  p cptr[0]    #=> 97
  p cptr[1]    #=> 98
  p cptr[2]    #=> 99

  a = "foo"

  cptr = Fiddle::Pointer[a] # ・・・2
  p cptr.to_i  #=> 140737198082816
  p cptr[0]    #=> 102
  p cptr[1]    #=> 110
  p cptr[2]    #=> 110
end

foo(s)
puts s #=> abc

コード中の1を図にすると次のようになります。

f:id:ryotatake:20200120223202j:plain

Rubyは値渡しなので、変数sに入っていた値をそのまま仮引数のaに渡します。

この時点では、aも文字列"abc"の先頭を指しています。

次にコード中の2を見ます。

f:id:ryotatake:20200120223514j:plain

a"foo"を代入したことで、aの指す先が変わりました。

aの指す先が変わっただけなので、sには何の影響もありませんでした。

元の変数の値が変わるパターン

s = "abc"

def bar(a)
  a.upcase!
end

bar(s)
puts s #=> ABC

これもFiddle::Pointerを使って詳しく見てみます。

require 'fiddle'

s = "abc"

def bar(a)
  cptr = Fiddle::Pointer[a] # ・・・1
  p cptr.to_i  #=> 140737198084016
  p cptr[0]    #=> 97
  p cptr[1]    #=> 98
  p cptr[2]    #=> 99

  a.upcase!

  cptr = Fiddle::Pointer[a] # ・・・2
  p cptr.to_i  #=> 140737198084016
  p cptr[0]    #=> 65
  p cptr[1]    #=> 66
  p cptr[2]    #=> 67
end

bar(s)
puts s #=> ABC

1の時点では先程と全く同じです。

f:id:ryotatake:20200120223202j:plain

2の時点を確認してみます。

f:id:ryotatake:20200120223921j:plain

aの指す先はsと変わらない状態で、指す先の文字列を変更していることが分かりました。これによって、sが指している文字列も変わってしまいました。

String#upcase!のように破壊的な変更をするメソッドはこのようにアドレスに保存されている文字自体を変更する挙動をするため、一度他の変数に代入していたとしても、同じアドレスを指す全ての変数が影響を受けてしまいます。

まとめ

その変数の中に本当に保持されているのが何か、を考えると個人的には理解しやすいように思いました。

数値であれば、変数に数値自身が保持されるので、a = 1; b = aのようなことをすれば、abは全く関係がなくなります。

しかし文字列や配列、また恐らくその他のクラスの多くも、変数の中には文字列全体などではなく、先頭のアドレス(やその他の付加情報)だけ保持されています。なので、a = "abc"; b = aとしても、abの間には関係が残っているので、aへの変更がbに影響を及ぼしうる、ということで注意して使わなければいけないのだと思いました。

補足

こういった内容の他の記事を調べていると、object_idを使って確認しているものをよく見ます。

今までobject_idがアドレスを指していると思っていたのですが、今回確認したところ違ったようです。ただ大体 "object_id * 2 ≒ 先頭のアドレス" という関係があったので何かしら関係はありそうです。

参考