💎
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におけるイミュータブルなデータ構造の新しい標準となりつつあります。主な利点は:
- イミュータビリティ: 予期しない変更を防ぎ、バグを減らす
- スレッドセーフ: 並行処理でも安全に使用可能
- 明確な意図: データが不変であることを明示的に表現
一方で、既存のコードベースではStructの方が適している場合もあります。プロジェクトの要件に応じて適切に選択することが重要です。
FrozenErrorに遭遇した場合は、withメソッドを使って新しいインスタンスを作成することで解決できます。これにより、イミュータブルなデータ構造の利点を活かしながら、柔軟にデータを扱うことができます。
Discussion