Railsのソースコード読んでみる | ActiveSupport deep_dup編

f:id:sktktk1230:20190921180106p:plain

普段仕事で使っているRuby on Railsですが、ソースコードを読む機会もなかなかないので、試しにやってみることにしました

読めるようにするまで

以前書いた記事に読めるようにするまでの設定を画像キャプチャ付きで解説しましたので、よろしければこちらをご参照下さい
shitake4.hatenablog.com

読んだ箇所

deep_dup を今日は読んでみようと思います

どんな使い方だっけ?

読んでみる前にまずは使い方を調べてみます
RailsガイドのActive Supportコア拡張機能の 2.4 deep_dupの項目を見てみます

deep_dupメソッドは、与えられたオブジェクトの「ディープコピー」を返します。
Rubyは通常の場合、他のオブジェクトを含むオブジェクトをdupしても、他のオブジェクトについては複製しません。
このようなコピーは「浅いコピー (shallow copy)」と呼ばれます。たとえば、以下のように文字列を含む配列があるとします。

array = ['string']
duplicate = array.dup
duplicate.push 'another-string'
# このオブジェクトは複製されたので、複製された方にだけ要素が追加された
array # => ['string']
duplicate # => ['string', 'another-string']
duplicate.first.gsub!('string', 'foo')

# 1つ目の要素は複製されていないので、一方を変更するとどちらの配列も変更される

array # => ['foo']
duplicate # => ['foo', 'another-string']

上で見たとおり、Arrayのインスタンスを複製して別のオブジェクトができたことにより、一方を変更しても他方は変更されないようになりました。
ただし、配列は複製されましたが、配列の要素はそうではありません。dupメソッドはディープコピーを行わないので、配列の中にある文字列は複製後も同一オブジェクトのままです。
オブジェクトをディープコピーする必要がある場合はdeep_dupをお使いください。例:

array = ['string']
duplicate = array.deep_dup
duplicate.first.gsub!('string', 'foo')
array # => ['string']
duplicate # => ['foo']

オブジェクトが複製不可能な場合、deep_dupは単にそのオブジェクトを返します。

number = 1
duplicate = number.deep_dup
number.object_id == duplicate.object_id # => true

引用:Active Supportコア拡張機能:2.4 deep_dup

オブジェクトをコピーしたいが、別物として利用したい場合に利用するメソッドのようです

ソースコードを読んでみる

1. railsプロジェクトのactivesupportにある機能なので、activesupportディレクトリのlib配下で def deep_dup を探してみます

f:id:sktktk1230:20171220113937p:plain

2. 該当箇所が3箇所だったので、それぞれみてみます

activesupport > lib > active_support > core_ext > object > deep_dup.rb
Object.deep_dup
class Object
  # Returns a deep copy of object if it's duplicable. If it's
  # not duplicable, returns +self+.
  #
  #   object = Object.new
  #   dup    = object.deep_dup
  #   dup.instance_variable_set(:@a, 1)
  #
  #   object.instance_variable_defined?(:@a) # => false
  #   dup.instance_variable_defined?(:@a)    # => true
  def deep_dup
    duplicable? ? dup : self
  end
end

まずduplicable? かどうか判定しているようです
※ duplicable?のソースコードリーディングについてはこちら
shitake4.hatenablog.com

trueの場合は dup が実行されるようです
Rubyリファレンスのclone,dupで挙動を調べてみると

cloneメソッドとdupメソッドは、レシーバのオブジェクトのコピーを作成して返します。オブジェクトのコピーとは、同じ内容を持つ別のオブジェクトです。具体的には、元のオブジェクトと同じクラスの新しいオブジェクトで、元のオブジェクトのインスタンス変数を新しいオブジェクトにコピーしたものです。

中略

浅いコピー

cloneとdupは「浅いコピー」を作ることに注意してください。
上記のCatクラスの例では、@nameがoriginalからcopiedにコピーされますが、@nameが指しているオブジェクト(文字列)は同じものです。
copiedの@nameに対して破壊的なメソッド(レシーバ自身を変更するメソッド)を呼び出すと、originalの@nameが指している文字列も変更されます。

Objectに対するdeep_dupは浅いコピーになるようです
Rubyリファレンスのclone,dupに記述している例をrails consoleで確認してみると
f:id:sktktk1230:20171220114045p:plain

originalのnameも変わっています

Array.deep_dup
class Array
  # Returns a deep copy of array.
  #
  #   array = [1, [2, 3]]
  #   dup   = array.deep_dup
  #   dup[1][2] = 4
  #
  #   array[1][2] # => nil
  #   dup[1][2]   # => 4
  def deep_dup
    map(&:deep_dup)
  end
end

レシーバに対してmapしています self.map(&:deep_dup)と同一です
(&:deep_dup) の&:は配列の各要素に対して deep_dup を実行しています
例えば、

[1, 2, 3].map(&:to_s)
=> ["1", "2", "3"]

という感じです ※詳しい解説は@kasei-san氏のこちらの記事がわかりやすいかと思います
qiita.com

ということで、配列の各要素に対してdeep_dupをおこなっています

Hash.deep_dup
class Hash
  # Returns a deep copy of hash.
  #
  #   hash = { a: { b: 'b' } }
  #   dup  = hash.deep_dup
  #   dup[:a][:c] = 'c'
  #
  #   hash[:a][:c] # => nil
  #   dup[:a][:c]  # => "c"
  def deep_dup
    hash = dup
    each_pair do |key, value|
      if key.frozen? && ::String === key
        hash[key] = value.deep_dup
      else
        hash.delete(key)
        hash[key.deep_dup] = value.deep_dup
      end
    end
    hash
  end
end

まず浅いコピーでレシーバをhashに入れています
each_pair を調べてみると

eachメソッドは、ハッシュの要素(キーと値)の数だけブロックを繰り返し実行します。繰り返しごとにブロック引数にはキーkeyと値valが入ります。each_pairメソッドは、eachの別名です。
引用:Rubyリファレンス:each, each_pair (Hash)

ということなので、レシーバのpairのkey,valueを取り出しています

次に

if key.frozen? && ::String === key
  hash[key] = value.deep_dup

を見ていきます
まずfrozen? を調べてみると

frozen?メソッドは、オブジェクトが凍結状態ならtrueを、そうでなければfalseを返します。オブジェクトを凍結状態にするには、freezeメソッドを使います。
引用:Rubyリファレンス:frozen?

更に freeze を調べてみると

freezeメソッドは、オブジェクトを凍結、つまり変更不可にします。凍結状態のオブジェクトを変更しようとすると、Ruby 1.8では例外TypeErrorが、Ruby 1.9では例外RuntimeErrorが発生します。
凍結状態を調べるには、frozen?メソッドを使います。凍結状態を元に戻すメソッドはありません。
標準クラスのオブジェクトでは、凍結したあとで破壊的なメソッド(レシーバ自身を変更するメソッド)を呼び出すと例外が発生します。
引用:Rubyリファレンス:freeze

freezeされた状態 = 破壊的変更が出来なくなっているということのようです

つまり if key.frozen? && ::String === key とは破壊的変更が出来ないStringクラスのキーの場合、valueのみをdeep_dupしています

そして

else
  hash.delete(key)
  hash[key.deep_dup] = value.deep_dup
end

はkeyが破壊的変更が可能な値の為、key,valueともにdeep_dupしています
これでコピー元のkeyに影響が無いようにしています

読んでみて

Objectでdeep_dupする場合は浅いコピーの為、気をつけなければいけないと思いました
コードを読んでみてわかることもあるので、プロジェクトでライブラリなどを導入する場合はちゃんと実装まで理解しないとバグを発生させる可能性も出てきてしまうので気をつけたいです