Code Explain

Geminiの鋭い視点と分かりやすい解説で、プログラミングスキルを向上させましょう!

Rubyにおける配列の「深いコピー」徹底解説:参照渡し、シャローコピー、ディープコピーの全て

Rubyでプログラムを書いていると、「あれ? 配列をコピーしたはずなのに、元の配列まで変わってしまった!」という経験はありませんか? 特に、ネストされた配列や、配列の中にオブジェクトが入っている場合、この現象は頻繁に起こり、デバッグを困難にします。

これは、Rubyにおける「参照渡し」と「シャローコピー(浅いコピー)」が深く関わっています。そして、この問題を根本的に解決するために必要となるのが、「ディープコピー(深いコピー)」の概念です。

本記事では、プロのブロガーとして、Rubyの配列コピーにおける「深い」問題について、その基本から応用、そして具体的な解決策までを徹底的に解説します。Google検索で「ruby 配列 コピー 深い」と検索するあなたが、この記事を読み終える頃には、自信を持ってRubyの配列コピーを扱えるようになることをお約束します。

はじめに:なぜRubyの配列コピーは「深い」を意識する必要があるのか?

あなたはデータ分析ツールを開発しています。ある顧客の注文履歴データ(配列の配列)を加工してレポートを作成する際、元のデータは保持しつつ、加工用のコピーを作成しました。

original_orders = [
  { id: 1, items: [{ name: 'Apple', price: 100 }, { name: 'Banana', price: 50 }] },
  { id: 2, items: [{ name: 'Orange', price: 120 }] }
]

# このコピー方法は本当に安全でしょうか?
processed_orders = original_orders.dup

# 加工処理(例:1番目の注文のアイテム名を変更)
processed_orders[0][:items][0][:name] = 'Red Apple'

puts "Original Orders: #{original_orders}"
# => Original Orders: [{:id=>1, :items=>[{:name=>"Red Apple", :price=>100}, {:name=>"Banana", :price=>50}]}, {:id=>2, :items=>[{:name=>"Orange", :price=>120}]}]

結果を見て驚いたはずです。processed_orders を変更したのに、original_orders の中身まで変わってしまっています! これが、まさに「深い」コピーを意識する必要がある典型的な例です。

Rubyのオブジェクトは、その多くが「参照」によって扱われます。配列をコピーする際に、この参照の挙動を理解していないと、意図しないデータの変更や、それによるバグを引き起こす可能性があります。本記事では、この問題を回避し、安全にデータを操作するための「ディープコピー」の様々な方法を掘り下げていきます。

Rubyのオブジェクトと「参照」の基本概念

Rubyでは、すべての値はオブジェクトです。変数はそのオブジェクト自体を保持するのではなく、オブジェクトへの「参照」を保持します。

オブジェクトID (object_id) で確認する参照

すべてのオブジェクトは一意のobject_idを持っています。これを使って、変数が同じオブジェクトを参照しているのか、それとも別のオブジェクトを参照しているのかを確認できます。

a = [1, 2, 3]
b = a

puts "aのobject_id: #{a.object_id}" # => aのobject_id: 70129033333300 (例)
puts "bのobject_id: #{b.object_id}" # => bのobject_id: 70129033333300 (例)

# aとbは同じオブジェクトを参照しているため、object_idが同じ
puts "a == b: #{a == b}" # => a == b: true

b.push(4)
puts "a: #{a}" # => a: [1, 2, 3, 4]
puts "b: #{b}" # => b: [1, 2, 3, 4]

上記の例では、b = a とすることで、ba と同じ配列オブジェクトを参照するようになります。そのため、b を変更すると、a からもその変更が見えるのです。

この挙動こそが、配列コピーの際に「深い」理解が必要となる根本原因です。

シャローコピー(浅いコピー)を理解する

多くのプログラマーが「コピー」として最初に思い浮かべるのが、このシャローコピーです。シャローコピーは、元のオブジェクトの「トップレベル」の要素のみをコピーし、ネストされたオブジェクトについては参照を共有します。

1. dup メソッドと clone メソッド

Rubyのすべてのオブジェクトは、dup および clone メソッドを持っています。これらはオブジェクトのコピーを作成しますが、そのコピーはシャローコピーです。

original_array = [1, [2, 3], { a: 4 }]
copied_array_dup = original_array.dup
copied_array_clone = original_array.clone

puts "Original Array object_id: #{original_array.object_id}"
puts "Duped Array object_id:    #{copied_array_dup.object_id}"
puts "Cloned Array object_id:   #{copied_array_clone.object_id}"

# トップレベルの配列オブジェクト自体は新しいobject_idを持つ
# => Original Array object_id: 70129033333300
# => Duped Array object_id:    70129033333320
# => Cloned Array object_id:   70129033333340

# しかし、ネストされた要素のobject_idは同じ
puts "Original nested array object_id: #{original_array[1].object_id}"
puts "Duped nested array object_id:    #{copied_array_dup[1].object_id}"
puts "Cloned nested array object_id:   #{copied_array_clone[1].object_id}"
# => Original nested array object_id: 70129033333360
# => Duped nested array object_id:    70129033333360
# => Cloned nested array object_id:   70129033333360

# ネストされた配列を変更してみる
copied_array_dup[1].push(5)

puts "Original Array: #{original_array}"
# => Original Array: [1, [2, 3, 5], {:a=>4}]
puts "Duped Array: #{copied_array_dup}"
# => Duped Array: [1, [2, 3, 5], {:a=>4}]

ご覧の通り、copied_array_dup[1] を変更したところ、original_array[1] も変更されてしまいました。これがシャローコピーの挙動です。

dupclone の違い

  • dup: オブジェクトのインスタンス変数と基本情報(クラスなど)をコピーします。特異メソッドやフリーズ状態、taint状態などはコピーされません。シンプルに「中身のコピー」が欲しい場合に用います。
  • clone: dup の内容に加えて、特異メソッド、フリーズ状態、taint状態などもコピーします。より「完全な複製」が欲しい場合に用いますが、ほとんどの場合、dup で事足ります。特に注意が必要なのは、clone はフリーズ状態もコピーするため、元のオブジェクトが freeze されていると、コピーされたオブジェクトも freeze されてしまい、変更不可になります。

2. スライスを使ったコピー [...][0..-1]

配列をスライスする記法 [0..-1]+ [] なども、新しい配列オブジェクトを生成します。これもまたシャローコピーです。

original_array = [1, [2, 3], { a: 4 }]
copied_array_slice = original_array[0..-1] # または original_array + []

puts "Original Array object_id: #{original_array.object_id}"
puts "Sliced Array object_id:   #{copied_array_slice.object_id}"
# => Original Array object_id: 70129033333300
# => Sliced Array object_id:   70129033333320

puts "Original nested array object_id: #{original_array[1].object_id}"
puts "Sliced nested array object_id:   #{copied_array_slice[1].object_id}"
# => Original nested array object_id: 70129033333360
# => Sliced nested array object_id:   70129033333360

copied_array_slice[1].push(5)
puts "Original Array: #{original_array}" # => Original Array: [1, [2, 3, 5], {:a=>4}]
puts "Sliced Array: #{copied_array_slice}" # => Sliced Array: [1, [2, 3, 5], {:a=>4}]

dupclone と同様に、トップレベルの配列はコピーされますが、ネストされた配列やハッシュは参照が共有されるため、元の配列も変更されてしまいます。

これらのシャローコピーは、配列の要素がすべて数値、文字列、シンボル、nil, true, false のような「イミュータブル(不変)オブジェクト」である場合には問題になりません。これらのオブジェクトは変更されることがなく、常に新しいオブジェクトが生成されるからです。しかし、配列、ハッシュ、カスタムオブジェクトのような「ミュータブル(可変)オブジェクト」が要素に含まれる場合、シャローコピーでは不十分です。

ディープコピー(深いコピー)の必要性

シャローコピーでは、ネストされたミュータブルなオブジェクトまで複製されません。そのため、コピー元とコピー先で内部のオブジェクトを共有してしまい、片方を変更するともう片方も影響を受けてしまうという問題が発生します。

このような問題を避けるために、ネストされたすべてのミュータブルなオブジェクトも含めて、完全に独立した新しいオブジェクト群として複製するのが「ディープコピー」です。

ディープコピーが必要となる典型的なシナリオは以下の通りです。

  1. 設定オブジェクトの複製: アプリケーションの設定値を保持するハッシュや配列を、特定の処理で一時的に変更したいが、元の設定は保持したい場合。
  2. 複雑なデータ構造の操作: データベースから取得した多層的なデータ(例: 注文明細、商品情報、顧客情報がネストされた構造)を加工する際、元のデータを変更せずに作業したい場合。
  3. テストデータの準備: テストケースごとに独自のテストデータを作成する際、テンプレートとなるデータ構造をディープコピーして利用したい場合。
  4. 状態の保持: あるオブジェクトの現在の状態を「スナップショット」として保存し、後でその状態に戻したり、別の処理で利用したりする場合。

Rubyでディープコピーを実現する具体的な方法

Rubyには標準で「ディープコピー」という直接的なメソッドは提供されていません。しかし、いくつかの方法やライブラリを活用することで実現できます。それぞれの方法にはメリット・デメリットがあり、状況に応じて使い分ける必要があります。

1. Marshal.dumpMarshal.load を利用する方法

RubyのMarshalモジュールは、オブジェクトをバイトストリームに変換(シリアライズ)し、それを元に戻す(デシリアライズ)機能を提供します。この性質を利用すると、非常に簡単にディープコピーを実現できます。

original_data = [
  1,
  [2, 3, 4],
  { a: 'hello', b: [true, false] },
  MyClass.new('original') # カスタムオブジェクトも含む
]

class MyClass
  attr_accessor :name
  def initialize(name)
    @name = name
  end
  def to_s
    "MyClass instance: #{@name}"
  end
end

original_data = [
  1,
  [2, 3, 4],
  { a: 'hello', b: [true, false] },
  MyClass.new('original')
]

# Marshal を使ったディープコピー
deep_copied_data = Marshal.load(Marshal.dump(original_data))

puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

puts "\n--- object_idの比較 ---"
puts "Original Array object_id:       #{original_data.object_id}"
puts "Deep Copied Array object_id:    #{deep_copied_data.object_id}"

puts "Original nested array object_id: #{original_data[1].object_id}"
puts "Deep Copied nested array object_id: #{deep_copied_data[1].object_id}"

puts "Original hash object_id:        #{original_data[2].object_id}"
puts "Deep Copied hash object_id:     #{deep_copied_data[2].object_id}"

puts "Original MyClass object_id:     #{original_data[3].object_id}"
puts "Deep Copied MyClass object_id:  #{deep_copied_data[3].object_id}"

# 変更を加えてみる
deep_copied_data[1].push(5)
deep_copied_data[2][:a] = 'world'
deep_copied_data[3].name = 'modified'

puts "\n--- 変更後 ---"
puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

# => Original Data: [1, [2, 3, 4], {:a=>"hello", :b=>[true, false]}, MyClass instance: original]
# => Deep Copied Data: [1, [2, 3, 4, 5], {:a=>"world", :b=>[true, false]}, MyClass instance: modified]

Marshal.dumpMarshal.loadを使うと、トップレベルのオブジェクトだけでなく、その内部にネストされたオブジェクトやカスタムオブジェクトまで、完全に独立した新しいオブジェクトとして複製されていることがわかります。これこそがディープコピーです!

メリット

  • シンプルさ: 短いコードでディープコピーを実現できる。
  • 強力さ: ほとんどのRubyオブジェクト(配列、ハッシュ、カスタムオブジェクトのインスタンスなど)を適切にシリアライズ・デシリアライズできる。
  • 標準ライブラリ: 外部ライブラリの依存なしに利用できる。

デメリット

  • シリアライズできないオブジェクト: Proc, Method, IO, Socket, Mutex, 無名クラス、特定のC拡張で実装されたオブジェクトなど、一部のオブジェクトはMarshalでシリアライズできません。これらのオブジェクトが含まれるとTypeErrorが発生します。
  • パフォーマンス: 大量のデータや非常に深いネスト構造の場合、シリアライズ・デシリアライズの処理に時間がかかり、メモリを消費する可能性があります。
  • 循環参照: オブジェクト間に循環参照がある場合、Marshalはそれを検知して正しく処理しますが、パフォーマンスに影響を与える可能性があります。

2. 再帰的な処理を自作する方法

Marshalが使えないオブジェクトが含まれる場合や、コピー処理をより細かく制御したい場合は、配列やハッシュを再帰的に巡回し、要素を一つずつdupしていくdeep_dupメソッドを自作する方法があります。

# ArrayとHashにdeep_dupメソッドを追加するモジュール
module DeepCopyable
  def deep_dup
    case self
    when Array
      # 配列の各要素を再帰的にdeep_dupする
      self.map(&:deep_dup)
    when Hash
      # ハッシュの各キーと値を再帰的にdeep_dupする
      new_hash = {}
      self.each do |k, v|
        new_hash[k.deep_dup] = v.deep_dup
      end
      new_hash
    when Numeric, Symbol, TrueClass, FalseClass, NilClass # イミュータブルなオブジェクト
      self.dup rescue self # dupできない場合はそのまま返す
    else # その他のオブジェクト(カスタムクラスなど)
      # ここでオブジェクトのdupを試みるが、
      # 循環参照や特定のオブジェクトの扱いには注意が必要
      begin
        self.dup
      rescue TypeError # dupできないオブジェクトの場合
        self # そのまま返すか、エラーハンドリング
      end
    end
  end
end

# ArrayとHashにモジュールをインクルード
class Array; include DeepCopyable; end
class Hash; include DeepCopyable; end
# その他、ディープコピーしたいカスタムクラスがあればインクルード

original_data = [
  1,
  [2, 3, 4],
  { a: 'hello', b: [true, false] },
  MyClass.new('original') # 上記で定義したMyClass
]

deep_copied_data = original_data.deep_dup

puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

puts "\n--- object_idの比較 (自作deep_dup) ---"
puts "Original Array object_id:       #{original_data.object_id}"
puts "Deep Copied Array object_id:    #{deep_copied_data.object_id}"

puts "Original nested array object_id: #{original_data[1].object_id}"
puts "Deep Copied nested array object_id: #{deep_copied_data[1].object_id}"

puts "Original hash object_id:        #{original_data[2].object_id}"
puts "Deep Copied hash object_id:     #{deep_copied_data[2].object_id}"

# MyClassのインスタンスはdup可能であればdupされる
puts "Original MyClass object_id:     #{original_data[3].object_id}"
puts "Deep Copied MyClass object_id:  #{deep_copied_data[3].object_id}"

# 変更を加えてみる
deep_copied_data[1].push(5)
deep_copied_data[2][:a] = 'world'
deep_copied_data[3].name = 'modified'

puts "\n--- 変更後 (自作deep_dup) ---"
puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

この自作deep_dupメソッドは、各要素が配列であれば再帰的にdeep_dupを呼び出し、ハッシュであればキーと値をそれぞれdeep_dupします。数値やシンボルといったイミュータブルなオブジェクトはdupしても意味がない(あるいはTypeErrorになる)ため、self.dup rescue selfのようにエラーを捕捉してそのまま返す工夫が必要です。カスタムオブジェクトについてもdupを試みますが、そのオブジェクトがdupを適切に実装している必要があります。

メリット

  • 柔軟性: 特定のオブジェクトをコピー対象から外したり、特定の型のオブジェクトに対して独自のコピーロジックを適用したりできる。
  • Marshalでシリアライズできないオブジェクトに対応可能: Procなどのオブジェクトが含まれていても、それらをそのまま参照として保持するか、特別な処理を施すかを選択できる。

デメリット

  • 実装の複雑さ: 循環参照のハンドリング、様々なオブジェクト型への対応、パフォーマンス最適化など、考慮すべき点が多く、実装が複雑になりがち。
  • バグのリスク: 自作であるため、潜在的なバグが混入しやすい。
  • パフォーマンス: 大量のデータや深いネストに対しては、Marshalよりも遅くなる可能性がある。

【循環参照への対応について】 上記の実装では循環参照に対応していません。もしa = []; a << aのような構造があると無限ループに陥ります。これを解決するには、既にコピー済みのオブジェクトを記憶しておくハッシュマップ(例: original_id => copied_object)を渡す必要があります。これはさらに実装を複雑にします。

module DeepCopyableWithCircularRef
  def deep_dup_with_circular_ref(memo = {})
    # 既にコピー済みなら、そのコピーを返す
    return memo[self.object_id] if memo.key?(self.object_id)

    case self
    when Array
      # 新しい配列を作成し、メモに登録してから要素をコピー
      new_array = []
      memo[self.object_id] = new_array
      self.each do |elem|
        new_array << elem.deep_dup_with_circular_ref(memo)
      end
      new_array
    when Hash
      # 新しいハッシュを作成し、メモに登録してから要素をコピー
      new_hash = {}
      memo[self.object_id] = new_hash
      self.each do |k, v|
        new_hash[k.deep_dup_with_circular_ref(memo)] = v.deep_dup_with_circular_ref(memo)
      end
      new_hash
    when Numeric, Symbol, TrueClass, FalseClass, NilClass
      # イミュータブルなオブジェクトはそのまま返す
      self
    else
      # その他のオブジェクトはdupを試みる
      begin
        new_obj = self.dup
        memo[self.object_id] = new_obj # コピーされたオブジェクトをメモに登録
        new_obj
      rescue TypeError
        self # dupできない場合はそのまま返す
      end
    end
  end
end

class Array; include DeepCopyableWithCircularRef; end
class Hash; include DeepCopyableWithCircularRef; end
# その他、ディープコピーしたいカスタムクラスがあればインクルード

# 循環参照の例
arr = []
arr << 1
arr << arr # arr[1]はarr自身を参照

copied_arr = arr.deep_dup_with_circular_ref

puts "Original: #{arr.object_id}, #{arr[1].object_id}"
puts "Copied:   #{copied_arr.object_id}, #{copied_arr[1].object_id}"
puts "Original == Copied: #{arr == copied_arr}" # => true (値は同じ)
puts "Original is Copied: #{arr.object_id == copied_arr.object_id}" # => false (別のオブジェクト)

# 内部参照も別のオブジェクトになっている
puts "Original internal == Copied internal: #{arr[1].object_id == copied_arr[1].object_id}" # => false

この修正により、循環参照を正しくハンドリングできるようになりますが、さらに複雑さが増します。

3. JSONライブラリを利用する方法

Marshalと同様に、JSON形式にシリアライズ・デシリアライズする方式もディープコピーとして利用できます。Ruby標準ライブラリのjsonを使用します。

require 'json'

original_data = [
  1,
  [2, 3, 4],
  { a: 'hello', b: [true, false] },
  "string",
  :symbol # JSONでは文字列になる
]

# JSON を使ったディープコピー
deep_copied_data = JSON.parse(JSON.generate(original_data))

puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

# 変更を加えてみる
deep_copied_data[1].push(5)
deep_copied_data[2]['a'] = 'world' # JSONはキーを文字列にする
deep_copied_data[4] = 'modified_symbol' # 元がシンボルでも文字列としてコピーされる

puts "\n--- 変更後 (JSON) ---"
puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

JSONはシンボルを文字列に変換したり、カスタムオブジェクトを扱えなかったりするため、データ構造によっては注意が必要です。

メリット

  • シンプルさ: Marshalと同様に短く書ける。
  • 汎用性: JSON形式は多くの言語でサポートされており、デバッグ時にも内容が確認しやすい。
  • 標準ライブラリ: 外部ライブラリの依存なしに利用できる。

デメリット

  • 扱えるオブジェクトの制限: JSONで表現できるのは、数値、文字列、ブーリアン、null、配列、オブジェクト(ハッシュ)のみです。シンボル、カスタムオブジェクトのインスタンス、Timeオブジェクト、Procなどは正しくシリアライズできません。シンボルは文字列に変換され、カスタムオブジェクトはJSON.dumpの際にエラーになるか、意図しない形になります。
  • ハッシュのキーが文字列になる: Rubyのハッシュではシンボルをキーにできますが、JSONではキーは常に文字列になります。デシリアライズ後、シンボルキーを前提としたコードは動かなくなります。
  • パフォーマンス: Marshalと同様に、シリアライズ・デシリアライズのオーバーヘッドがある。

4. YAMLライブラリを利用する方法

YAMLもJSONと同様に、データ構造をシリアライズ・デシリアライズするのに使えます。JSONよりも表現力が豊かで、シンボルをそのまま扱える利点があります。

require 'yaml'

original_data = [
  1,
  [2, 3, 4],
  { a: 'hello', b: [true, false] },
  "string",
  :symbol, # YAMLはシンボルをそのまま扱える
  Time.now # Timeオブジェクトも扱える
]

# YAML を使ったディープコピー
deep_copied_data = YAML.load(YAML.dump(original_data))

puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

# 変更を加えてみる
deep_copied_data[1].push(5)
deep_copied_data[2][:a] = 'world'
deep_copied_data[4] = :modified_symbol # シンボルとして変更可能

puts "\n--- 変更後 (YAML) ---"
puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

YAMLはシンボルやTimeオブジェクトを扱えますが、ProcやカスタムオブジェクトについてはMarshalと同様に注意が必要です。

メリット

  • シンプルさ: 短く書ける。
  • JSONより高い表現力: シンボル、Timeオブジェクトなどもそのまま扱える。
  • 標準ライブラリ: 外部ライブラリの依存なしに利用できる。
  • 可読性: YAML形式は人間にとって読みやすい。

デメリット

  • Marshalと同様の制限: Proc, IOなどの一部オブジェクトはシリアライズできない。カスタムオブジェクトについては、そのクラスがシリアライズ・デシリアライズ可能であるようto_yamlなどのメソッドを実装する必要がある場合がある。
  • パフォーマンス: シリアライズ・デシリアライズのオーバーヘッドがある。

5. ActiveSupportの deep_dup を利用する方法(Railsの場合)

もしあなたがRailsアプリケーションを開発しているのであれば、ActiveSupport(Railsのユーティリティライブラリ)が提供するdeep_dupメソッドが最も便利で強力な選択肢となるでしょう。これはArrayHashオブジェクトに自動的に追加されます。

# Rails環境を想定 (Railsコンソールなどで実行)
# require 'active_support/core_ext/object/deep_dup' # Rails環境でなければ手動で読み込む

original_data = [
  1,
  [2, 3, 4],
  { a: 'hello', b: [true, false] },
  MyClass.new('original') # 上記で定義したMyClass
]

# ActiveSupportのdeep_dup
deep_copied_data = original_data.deep_dup

puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

puts "\n--- object_idの比較 (ActiveSupport::deep_dup) ---"
puts "Original Array object_id:       #{original_data.object_id}"
puts "Deep Copied Array object_id:    #{deep_copied_data.object_id}"

puts "Original nested array object_id: #{original_data[1].object_id}"
puts "Deep Copied nested array object_id: #{deep_copied_data[1].object_id}"

puts "Original hash object_id:        #{original_data[2].object_id}"
puts "Deep Copied hash object_id:     #{deep_copied_data[2].object_id}"

# MyClassのインスタンスもdup可能であればdupされる
puts "Original MyClass object_id:     #{original_data[3].object_id}"
puts "Deep Copied MyClass object_id:  #{deep_copied_data[3].object_id}"


# 変更を加えてみる
deep_copied_data[1].push(5)
deep_copied_data[2][:a] = 'world'
deep_copied_data[3].name = 'modified'

puts "\n--- 変更後 (ActiveSupport::deep_dup) ---"
puts "Original Data: #{original_data}"
puts "Deep Copied Data: #{deep_copied_data}"

ActiveSupport::deep_dupは、内部的にMarshalのようなシリアライズ手法ではなく、再帰的なdup処理を効率的に行っています。循環参照にも対応しており、非常に堅牢です。

メリット

  • シンプルさ: メソッド一つで完結する。
  • 強力さ: 配列、ハッシュ、そしてネストされたオブジェクトまで、多くのケースで適切にディープコピーを行う。循環参照にも対応している。
  • 堅牢性: Railsの長年の実績とコミュニティによって磨かれた信頼性。
  • パフォーマンス: 最適化されており、再帰的な自作メソッドよりも効率的であることが多い。

デメリット

  • Rails依存: Railsアプリケーションでなければ、ActiveSupportライブラリを別途インストールして読み込む手間がある。
  • Marshalと同様の制限: Proc, IOなどの一部オブジェクトは、deep_dupでは正しく複製されず、参照が共有されるかエラーになる可能性がある。カスタムオブジェクトについては、dup可能であるか、あるいは独自のdeep_dupロジックをクラスに実装する必要がある。

各ディープコピー手法の比較と使い分け

特徴 Marshal.load(Marshal.dump(obj)) 自作再帰メソッド (deep_dup) JSON.parse(JSON.generate(obj)) YAML.load(YAML.dump(obj)) ActiveSupport#deep_dup (Rails)
シンプルさ ◎ (非常にシンプル) △ (実装が複雑) ◎ (非常にシンプル) ◎ (非常にシンプル) ◎ (非常にシンプル)
対応オブジェクト ほとんどのRubyオブジェクト (一部特殊なものは不可) 実装依存 (柔軟にカスタマイズ可能だが、実装の手間大) 数値、文字列、真偽値、null、配列、ハッシュのみ (制限あり) 数値、文字列、真偽値、null、配列、ハッシュ、シンボル、Timeなど 配列、ハッシュ、dup可能なオブジェクト (一部特殊なものは不可)
循環参照 ○ (対応) △ (実装で考慮が必要、なければ無限ループ) × (エラーになる) × (エラーになる) ○ (対応)
パフォーマンス △ (シリアライズのオーバーヘッド) △〜○ (実装次第) △ (シリアライズのオーバーヘッド) △ (シリアライズのオーバーヘッド) ○ (比較的効率的)
依存性 なし (標準ライブラリ) なし (自作) なし (標準ライブラリ) なし (標準ライブラリ) Railsが必要 (gemとして利用も可能)
用途 最も汎用的で手軽なディープコピー 特殊な要件や対象外オブジェクトがある場合に、手間をかけて実装 シンプルなデータ構造のコピー、JSON出力前提の場合 JSONより多くの型を扱いたい場合、可読性重視の場合 Rails環境での標準的なディープコピー

使い分けのヒント

  • まず試すべきは Marshal: ほとんどのケースでMarshal.load(Marshal.dump(obj))が最も手軽で効果的です。特にカスタムオブジェクトが含まれる場合でも、Marshalがサポートする範囲であれば問題ありません。
  • Railsを使っているなら ActiveSupport#deep_dup: Rails環境であれば、これ一択と言えるほど便利で信頼性があります。
  • シンプルなデータ構造で外部連携も視野に入れるなら JSON または YAML: JSON形式で外部システムとデータをやり取りするような場面では、JSONでのディープコピーは理にかなっています。シンボルやTimeも扱いたいならYAMLが選択肢になります。
  • 特殊なオブジェクトやパフォーマンスがシビアな場合、究極の柔軟性が必要な場合: 自作の再帰メソッドを検討します。ただし、実装の複雑さやバグのリスクを十分に理解しておく必要があります。

ディープコピーで陥りやすい落とし穴と注意点

ディープコピーは強力なツールですが、いくつか注意すべき点があります。

  1. 不必要にディープコピーしない: ディープコピーはシャローコピーよりも処理コストが高く、メモリも消費します。本当にディープコピーが必要なのか、シャローコピーで十分なケースではないか、あるいは元のデータを直接変更しても問題ないか、常に検討しましょう。イミュータブルなオブジェクト(数値、文字列、シンボルなど)だけで構成された配列やハッシュであれば、ディープコピーは不要です。
  2. Marshalの制限を理解する: Proc, IO, Mutexなどのシステムリソースや実行時の状態に強く依存するオブジェクトはMarshalできません。これらのオブジェクトが含まれる場合は、TypeErrorが発生するか、正しくコピーされません。その場合は、自作メソッドやActiveSupport#deep_dupで個別に対応するか、オブジェクト設計を見直す必要があります。
  3. 循環参照: オブジェクトが互いを参照し合う「循環参照」がある場合、一部のディープコピー手法(JSON, YAML, 簡略化された自作再帰メソッド)は無限ループに陥るか、エラーになります。MarshalActiveSupport#deep_dupは循環参照に対応していますが、常に確認が必要です。
  4. カスタムオブジェクトの扱い: Marshalは多くのカスタムオブジェクトをシリアライズできますが、_dump_loadメソッドを定義することで、シリアライズ方法をカスタマイズすることも可能です。また、自作のdeep_dupを使う場合は、カスタムオブジェクトに対してdupが適切に機能するか、あるいは独自のコピーロジックが必要かを検討する必要があります。
  5. パフォーマンスへの影響: 非常に大きなデータ構造や頻繁なディープコピーは、アプリケーションのパフォーマンスに影響を与える可能性があります。特にボトルネックとなる場合は、その部分のロジックを見直すか、より効率的なディープコピー手法(C拡張など)を検討する必要があるかもしれません。

まとめ:Ruby配列の「深いコピー」マスターへの道

本記事では、「Ruby 配列 コピー 深い」というテーマで、以下の内容を詳しく解説しました。

  • 参照渡しとオブジェクトID: Rubyのオブジェクトが参照によって扱われる基本を理解しました。
  • シャローコピー(浅いコピー): dup, clone, スライスといったメソッドが、トップレベルのオブジェクトのみをコピーし、ネストされたオブジェクトの参照を共有することを学びました。
  • ディープコピー(深いコピー)の必要性: なぜシャローコピーでは不十分で、いつディープコピーが必要になるのかを具体的なシナリオと共に理解しました。
  • ディープコピーの具体的な方法:
    1. Marshal.load(Marshal.dump(obj))
    2. 再帰的な自作メソッド
    3. JSON.parse(JSON.generate(obj))
    4. YAML.load(YAML.dump(obj))
    5. ActiveSupport#deep_dup (Rails) それぞれのメリット・デメリット、対応可能なオブジェクト、パフォーマンス、循環参照への対応などを比較し、具体的なコード例で示しました。
  • 落とし穴と注意点: 不必要なディープコピーの回避、Marshalの制限、循環参照、カスタムオブジェクトの扱い、パフォーマンス影響について理解を深めました。

Rubyで配列やハッシュを扱う上で、「深いコピー」の概念とその実現方法は避けては通れない重要な知識です。この知識を身につけることで、意図しないバグを防ぎ、より堅牢で保守性の高いコードを書くことができるようになります。

あなたも今日から、Rubyの配列の「深いコピー」をマスターし、自信を持ってデータ構造を操作してください!

よくある質問 (FAQ)

Q: なぜ array.map(&:dup) ではダメなのですか?

A: array.map(&:dup) は、配列の各要素に対してdupを呼び出します。これは一見ディープコピーのように見えますが、実は完全にディープコピーできるわけではありません

例を見てみましょう。

original = [[1, 2], [3, 4]]
copied_map_dup = original.map(&:dup)

# トップレベルの配列も、その要素であるネストされた配列もそれぞれdupされる
puts original.object_id         # => 70129033333300
puts copied_map_dup.object_id   # => 70129033333320
puts original[0].object_id      # => 70129033333340
puts copied_map_dup[0].object_id # => 70129033333360 (異なるオブジェクトID)

# しかし、ネストがさらに深い場合
deep_nested_original = [[1, [2, 3]], [4, 5]]
deep_nested_copied = deep_nested_original.map(&:dup)

# ここまではOK: [[1, [2, 3]], [4, 5]] -> [[1, [2, 3]], [4, 5]]
# ただし、元の要素が配列やハッシュの場合、その"中の要素"はdupされない
puts deep_nested_original[0][1].object_id    # => 70129033333380
puts deep_nested_copied[0][1].object_id      # => 70129033333380 (同じオブジェクトID!)

deep_nested_copied[0][1].push(99) # コピーした方の内部の配列を変更
puts deep_nested_original # => [[1, [2, 3, 99]], [4, 5]] (元まで変わってしまった!)

map(&:dup)は、配列の第一階層にある要素(この場合は[1, [2, 3]][4, 5])をdupしますが、その要素自体がさらにミュータブルなオブジェクトを含んでいる場合、その内部まではdupされません。したがって、map(&:dup)は「一層深いシャローコピー」と考えるのが適切であり、完全なディープコピーではありません。

Q: ディープコピーは常に必要ですか?

A: いいえ、常に必要ではありません。 ディープコピーは処理コストが高く、メモリも消費します。以下の場合はディープコピーは不要です。

  • イミュータブルなオブジェクトのみの配列/ハッシュ: 数値、文字列、シンボル、true, false, nil など、変更できないオブジェクトのみで構成されている場合、コピーしても参照渡しによる問題は起こりません。
  • 元のデータを変更しても問題ない場合: コピーした後に元のデータが変更されても、プログラムのロジック上問題ない場合は、シャローコピーで十分です。
  • 部分的な変更のみで済む場合: 配列の一部要素のみを変更し、それ以外の部分の共有が問題ない場合もシャローコピーで対応できます。

「本当にディープコピーが必要なのか?」を常に自問自答し、最も適切なコピー戦略を選択することが重要です。

Q: 独自のクラスのインスタンスを含む配列をディープコピーするには?

A:

  1. Marshalを利用: 最も簡単な方法はMarshal.load(Marshal.dump(arr))を使うことです。ほとんどのカスタムクラスインスタンスはこれでディープコピー可能です。ただし、Marshalでシリアライズできない特殊なインスタンス変数(Procなど)を持つ場合はTypeErrorになります。
  2. _dump / _load メソッドを実装: Marshalがデフォルトでシリアライズできない、あるいはカスタムのシリアライズロジックが必要な場合、クラスに_dump(limit)_load(str)メソッドを実装することで、Marshalの挙動をカスタマイズできます。
  3. ActiveSupport#deep_dupを利用 (Rails): クラスがdupメソッドを適切に実装していれば、ActiveSupport#deep_dupでディープコピーされます。dupメソッドがシャローコピーしか行わない場合でも、ActiveSupport::deep_dupはインスタンス変数まで再帰的にdeep_dupしようと試みます。
  4. 独自のdeep_dupメソッドをクラスに実装: 自作の再帰的なディープコピーメソッドを使う場合、そのクラス自身にdeep_dupメソッドを実装し、インスタンス変数を適切にコピーするロジックを書く必要があります。

Q: frozenなオブジェクトをディープコピーするとどうなりますか?

A:

  • dup: frozenなオブジェクトに対してdupを呼び出すと、frozenではない新しいオブジェクトが作成されます(freeze状態は引き継がれない)。
  • clone: frozenなオブジェクトに対してcloneを呼び出すと、frozenな新しいオブジェクトが作成されます(freeze状態も引き継がれる)。
  • MarshalJSONYAMLActiveSupport#deep_dup: これらのディープコピー手法は、通常、新しく生成されるオブジェクトのfreeze状態は引き継ぎません(つまり、コピーされたオブジェクトは通常freezeされていません)。ただし、Marshalはオブジェクトのfreeze状態を保存・復元する能力があります。コピー元のオブジェクトがfreezeされていても、コピー先のオブジェクトが自動的にfreezeされるかどうかは、実装やRubyのバージョンに依存する場合があるため、明示的にfreezeしたい場合はコピー後にfreezeを呼び出すのが確実です。
\ この記事をシェア/
この記事を書いた人
pekemalu
I love codes. I also love prompts (spells). But I get a lot of complaints (errors). I want to be loved by both of you as soon as possible.
Image