💎

RubyのStructとData.defineの違い:イミュータブルなデータ構造への移行

に公開

はじめに

Ruby 3.2で導入されたData.defineは、従来のStructに代わるイミュータブルなデータ構造を提供します。本記事では、両者の違いと使い分けについて、実際のエラー事例を交えながら解説します。

StructとData.defineの基本的な違い

Struct:ミュータブルなデータ構造

Structは長年Rubyで使われてきたデータ構造で、変更可能(mutable) な特性を持ちます。

# Structの定義と使用
User = Struct.new(:id, :name, :email) do
  def display_name
    "@#{name}"
  end
end

user = User.new(1, "Alice", "alice@example.com")
puts user.name  # => "Alice"

# 属性を変更できる
user.name = "Bob"
puts user.name  # => "Bob"

Data.define:イミュータブルなデータ構造

Data.defineはRuby 3.2で導入され、変更不可(immutable) なデータ構造を提供します。

# Data.defineの定義と使用
User = Data.define(:id, :name, :email) do
  def display_name
    "@#{name}"
  end
end

user = User.new(id: 1, name: "Alice", email: "alice@example.com")
puts user.name  # => "Alice"

# 属性を変更しようとするとエラー
user.name = "Bob"  # => FrozenError!

主要な違いの比較表

特性 Struct Data.define
可変性 ミュータブル イミュータブル
初期化方法 位置引数/キーワード引数 キーワード引数のみ
パフォーマンス 通常 より効率的
Ruby バージョン 全バージョン 3.2以降
#hash#eql? 値ベース 値ベース
freezeの必要性 明示的に必要 自動的にfrozen

実際のエラー事例:FrozenError

実際のプロジェクトで遭遇する可能性のあるエラー例:

FrozenError: can't modify frozen #<data Product id=1, name="Ruby Book", price=3000>

このエラーは、Data.defineで定義されたオブジェクトを変更しようとした際に発生します。

エラーが発生するコード例

# Data.defineで商品クラスを定義
Product = Data.define(:id, :name, :price) do
  def apply_discount(rate)
    # これはエラーになる!
    self.price = price * (1 - rate)  # FrozenError
  end
  
  def update_name(new_name)
    # これもエラー!
    self.name = new_name  # FrozenError
  end
end

# 使用例
product = Product.new(
  id: 1,
  name: "Ruby Book",
  price: 3000
)

# 変更しようとするとエラー
product.price = 2500  # FrozenError!
product.name = "Ruby Programming Book"  # FrozenError!

正しい解決方法

Data.defineでは、新しいインスタンスを作成する必要があります:

# Data.defineで商品クラスを正しく実装
Product = Data.define(:id, :name, :price) do
  # withメソッドで一部の属性を変更した新しいインスタンスを作成
  def apply_discount(rate)
    with(price: price * (1 - rate))
  end
  
  def update_name(new_name)
    with(name: new_name)
  end
  
  def update_price(new_price)
    with(price: new_price)
  end
end

# 使用例
product = Product.new(
  id: 1,
  name: "Ruby Book",
  price: 3000
)

# 新しいインスタンスを作成して変更を適用
discounted_product = product.apply_discount(0.1)  # 10%割引
renamed_product = product.update_name("Ruby Programming Book")

puts product.price          # => 3000 (元のまま)
puts discounted_product.price  # => 2700 (新しいインスタンス)
puts product.name           # => "Ruby Book" (元のまま)
puts renamed_product.name   # => "Ruby Programming Book" (新しいインスタンス)

# 複数の属性を一度に変更
updated_product = product.with(
  name: "Advanced Ruby Book",
  price: 3500
)

どちらを使うべきか?

Structを使うべき場合

  • Ruby 3.2より前のバージョンを使用している
  • 属性の変更が頻繁に必要
  • 既存のコードベースとの互換性が重要
  • ActiveRecordのようなORMとの連携が必要
# Structが適している例:ステートフルな設定オブジェクト
GameState = Struct.new(:level, :score, :lives) do
  def level_up!
    self.level += 1
    self.score += 100
  end
  
  def lose_life!
    self.lives -= 1
  end
end

state = GameState.new(1, 0, 3)
state.level_up!  # 直接状態を変更
puts state.level  # => 2

Data.defineを使うべき場合

  • イミュータブルなデータ構造が必要
  • 関数型プログラミングのアプローチを採用
  • スレッドセーフティが重要
  • 値オブジェクト(Value Object)パターンの実装
# Data.defineが適している例:値オブジェクト
Money = Data.define(:amount, :currency) do
  def add(other)
    raise ArgumentError unless currency == other.currency
    Money.new(amount: amount + other.amount, currency: currency)
  end
  
  def to_s
    "#{currency}#{amount}"
  end
end

price = Money.new(amount: 1000, currency: "JPY")
tax = Money.new(amount: 100, currency: "JPY")
total = price.add(tax)  # => Money.new(amount: 1100, currency: "JPY")

移行のベストプラクティス

既存のStructからData.defineへの移行を行う際の注意点:

1. 段階的な移行

# Step 1: まずStructをfreezeして動作確認
Article = Struct.new(:title, :body) do
  def initialize(...)
    super
    freeze  # freezeを追加
  end
end

# Step 2: 問題がなければData.defineに移行
Article = Data.define(:title, :body)

2. withメソッドの活用

# Before (Struct)
article = Article.new("Title", "Content")
article.title = "New Title"

# After (Data.define)
article = Article.new(title: "Title", body: "Content")
article = article.with(title: "New Title")

3. テストの追加

require "minitest/autorun"

class ArticleTest < Minitest::Test
  def test_immutability
    article = Article.new(title: "test", body: "content")
    
    # イミュータビリティのテスト
    assert_raises(FrozenError) do
      article.title = "new title"
    end
    
    # withメソッドのテスト
    new_article = article.with(title: "new title")
    assert_equal "test", article.title
    assert_equal "new title", new_article.title
  end
end

まとめ

Data.defineは、Rubyにおけるイミュータブルなデータ構造の新しい標準となりつつあります。主な利点は:

  1. イミュータビリティ: 予期しない変更を防ぎ、バグを減らす
  2. スレッドセーフ: 並行処理でも安全に使用可能
  3. 明確な意図: データが不変であることを明示的に表現

一方で、既存のコードベースではStructの方が適している場合もあります。プロジェクトの要件に応じて適切に選択することが重要です。

FrozenErrorに遭遇した場合は、withメソッドを使って新しいインスタンスを作成することで解決できます。これにより、イミュータブルなデータ構造の利点を活かしながら、柔軟にデータを扱うことができます。

参考リンク

Discussion