値オブジェクトとは?

値オブジェクトは、ビジネス上重要な概念(金額、住所、メールアドレスなど)を専用のクラスとして表現する設計パターンです。 主にオブジェクト指向プログラミングやドメイン駆動設計(DDD)で使われる用語です。

なぜ必要なのか?

普段のプログラミングで、こんな問題に遭遇したことはありませんか? 以下の問題のあるコード例を見てみましょう。
# 問題のあるコード例
price = 1000        # (1) これは何の金額?日本円?ドル?
tax = price * 0.1
total = price + tax # (2) 異なる通貨での計算リスク

user_email = "user@example.com"
user_email = "invalid-email" # (3) 不正な値を設定できてしまう

問題点

  1. 意味が不明確: 1000が何を表すのかわからない(円?ドル?)
  2. 型安全性の欠如: 異なる種類の値を誤って混ぜてしまうリスク(円とドルの足し算など)
  3. バリデーション不足: 不正な値(不正なメール形式など)を防げない

解決方法:値オブジェクトの活用

値オブジェクトを使うことで、これらの問題を解決できます:
# 改善されたコード例
price = Money.new(1000, "JPY")  # 何の値かが明確
tax = Money.new(100, "JPY")
total = price + tax             # 型安全な演算

email = EmailAddress.new("user@example.com")
# EmailAddress.new("invalid-email")    # エラー:不正な値は作成できない

値オブジェクトの「値」とは何か?

値オブジェクトにおける「値」とは、初期化時に渡される引数の組み合わせのことです。
# 値オブジェクトの「値」の例
money1 = Money.new(1000, "JPY")     # 値:(1000, "JPY")
money2 = Money.new(1000, "JPY")     # 値:(1000, "JPY") ← money1と同じ値
money3 = Money.new(500, "JPY")      # 値:(500, "JPY")  ← 異なる値

address1 = Address.new("東京都", "渋谷区", "1-1-1")  # 値:("東京都", "渋谷区", "1-1-1")
address2 = Address.new("東京都", "渋谷区", "1-1-1")  # 値:("東京都", "渋谷区", "1-1-1") ← address1と同じ値
この「値」の概念は以下の場面で重要になります:
  • 等価性判定: 同じ値(引数の組み合わせ)なら等しいオブジェクト
  • 不変性: この値の組み合わせが作成後に変更されない
  • 識別: オブジェクトは ID ではなく、この値によって識別される

値オブジェクトの 3 つの特徴

1. 不変性(変更できない)

一度作成されると、内容を変更することができません。
address = Address.new("東京都", "渋谷区")
# address.city = "新宿区"  # エラー:変更不可

2. 値による等価性: 同じ値なら同じオブジェクト

addr1 = Address.new("東京都", "渋谷区")
addr2 = Address.new("東京都", "渋谷区")
addr1 == addr2  # => true(同じ値なので等しい)

3. 自己完結性: 振る舞いとデータが一体化

値オブジェクトは、そのデータに関連するすべての処理(バリデーション、計算、変換など)を自分自身の中に持っています。外部に処理を依存せず、それ単体で完結しているため「自己完結性」と呼びます。
# ❌ 自己完結していない例:処理が散在している
price = 1000
currency = "JPY"

# 通貨の変換処理が外部に分散
if currency == "JPY"
  price_in_usd = price / 110  # 為替レート処理が外部
end

# ✅ 自己完結している例:Moneyクラスがすべてを管理
money1 = Money.new(1000, "JPY")
money2 = Money.new(500, "JPY")

# 通貨の計算ロジックがMoneyクラスに内包されている
total = money1 + money2  # => Money.new(1500, "JPY")

# 異なる通貨の計算も自動でチェック
usd = Money.new(10, "USD")
# money1 + usd  # => エラー:異なる通貨は計算できない
これらの特徴により、バグの減少コードの可読性向上型安全性の向上を実現できます。

値オブジェクトが防ぐ具体的なバグ例

値オブジェクトを使わない場合、以下のようなバグが発生しやすくなります:
# バグ例1: 異なる単位の値を誤って計算
price_in_yen = 1000
price_in_dollar = 10
total = price_in_yen + price_in_dollar  # 1010(意味のない計算)

# バグ例2: 無効な状態のオブジェクトが作られる
user_email = ""
send_mail(user_email)  # 空のメールアドレスでメール送信を試みてエラー

# バグ例3: 予期しない値の変更
original_address = "東京都渋谷区"
modified_address = original_address
modified_address.gsub!("渋谷", "新宿")  # 元の値も変更されてしまう
puts original_address  # => "東京都新宿区"(予期しない変更)
値オブジェクトを使用することで、これらのバグをコンパイル時や実行時の早い段階で防ぐことができます。

実装例:シンプルな値オブジェクト

不変性を実現する freeze メソッドについて

値オブジェクトの重要な特徴である「不変性」を実現するために、Ruby ではfreezeメソッドを使用します。
# freezeなしの場合の問題
name = "商品名"
name << "追加"  # 文字列が変更される
puts name  # => "商品名追加"

# freezeありの場合
frozen_name = "商品名".freeze
# frozen_name << "追加"  # RuntimeError: can't modify frozen String
なぜ freeze が必要なのか?
  1. 予期しない変更を防ぐ: 他のコードが値を変更してしまうリスクを排除
  2. スレッドセーフ: 複数のスレッドから同時にアクセスされても安全
  3. デバッグが容易: 値が変わらないことが保証されるため、問題の原因を特定しやすい
これから実装する値オブジェクトでは、この freeze メソッドを活用して不変性を保証します。

練習問題

例 1: 商品名の値オブジェクト化

まずは最もシンプルな例から始めましょう。

問題:文字列で商品名を管理

# 問題のあるコード
class Product
  attr_accessor :name  # 文字列で管理

  def initialize(name)
    @name = name
  end
end

product = Product.new("スマートフォン")
product.name = ""  # nameを変更できてしまう
問題点:
  • 値が後から書き変わってしまう

解決方法:値オブジェクトとして実装

ステップ 1: 読み取り専用での実装

まずは、値の変更を防ぐために読み取り専用アクセサのみを提供します。
# ステップ1: 読み取り専用のProductNameクラス
class ProductName
  # attr_reader: 読み取り専用のアクセサメソッドを定義
  # setterメソッドは定義されないため、外部から値を変更できない
  attr_reader :value

  def initialize(value)
    # バリデーション:空文字を防ぐ
    # 値オブジェクトでは作成時に必ず正しい状態にする
    raise ArgumentError, "商品名は空にできません" if value.nil? || value.empty?

    # @value に値を代入(setterメソッドは定義しない)
    @value = value

    # オブジェクト全体をfreezeし以下を防ぐ
    # * 新しい属性追加
    # * 既存の属性の変更
    # * メソッドの動的追加
    freeze
  end

  # 文字列表現:puts等で表示される際の形式
  def to_s
    @value
  end
end

# 使用例
product_name = ProductName.new("スマートフォン")
puts product_name  # => スマートフォン

# 値の変更はできない(setterメソッドが存在しない)
# product_name.value = "別の商品"  # => NoMethodError

# 不正な値での作成はエラーになる
# ProductName.new("")  # => ArgumentError: 商品名は空にできません

ステップ 2: 不変性と等価性を追加した完全な実装

次に、freeze メソッドで不変性を実現し、等価性判定も追加します。
# ステップ2: 完全なProductNameクラス
class ProductName
  attr_reader :value

  def initialize(value)
    # バリデーション
    raise ArgumentError, "商品名は空にできません" if value.nil? || value.empty?

    # 一度作成したら変更できないようにする
    @value = value.freeze  # 文字列自体もfreezeして変更を防ぐ

    # オブジェクト全体をfreezeして構造変更を防ぐ
    freeze
  end

  # 値による等価性の実装
  # 同じクラスで同じ値なら等しいとみなす
  def ==(other)
    other.is_a?(ProductName) && value == other.value
  end

  # 文字列表現
  def to_s
    @value
  end
end

# 使用例
product1 = ProductName.new("スマートフォン")
product2 = ProductName.new("スマートフォン")

# 等価性の確認
puts product1 == product2  # => true(同じ値なので等しい)

# 不変性の確認
# product1.value << "追加"  # => RuntimeError: can't modify frozen String
改善された点:
  1. 不変性の保証: 作成後に値を変更することができない
  2. freezeによる完全な不変性: 文字列レベルでの変更も防ぐ
  3. 明確な意味: コードを読むだけで商品名であることがわかる
  4. バリデーション: 不正なデータを作成時に防ぐ

例 2: 住所の値オブジェクト化

問題:文字列で住所を管理

# 問題のあるコード
class Order
  attr_accessor :shipping_address  # 文字列で管理

  def initialize(shipping_address)
    @shipping_address = shipping_address
  end
end

order = Order.new("東京都渋谷区1-1-1")
order.shipping_address = ""  # 空文字を設定できてしまう
問題点:
  • 値が後から書き変わってしまう
  • 不正な値(空文字など)を防げない
  • 住所のフォーマットロジックが散在する

解決方法:値オブジェクトとして実装

# シンプルなAddressクラス
class Address
  # 読み取り専用のアクセサメソッドを定義
  # prefecture、city、streetメソッドが自動生成される
  attr_reader :prefecture, :city, :street

  def initialize(prefecture, city, street)
    # バリデーション:値オブジェクトは作成時に必ず有効な状態にする
    # ArgumentErrorは不正な引数に対する標準的な例外クラス
    raise ArgumentError, "都道府県が空です" if prefecture.nil? || prefecture.empty?
    raise ArgumentError, "市区町村が空です" if city.nil? || city.empty?
    raise ArgumentError, "番地が空です" if street.nil? || street.empty?

    # インスタンス変数への代入(setterメソッドは作らない)
    # 各要素をfreezeして個別に変更不可にする
    @prefecture = prefecture.freeze
    @city = city.freeze
    @street = street.freeze

    freeze
  end

  # 値による等価性の実装
  # 住所の全要素が同じなら等しいとみなす
  def ==(other)
    other.is_a?(Address) &&
      prefecture == other.prefecture &&
      city == other.city &&
      street == other.street
  end

  # 住所の文字列表現(表示用)
  def to_s
    "#{prefecture}#{city}#{street}"
  end
end

# 使用例
address = Address.new("東京都", "渋谷区", "1-1-1")
order = Order.new(address)

# 不正な値での作成はエラーになる
# Address.new("", "渋谷区", "1-1-1")  # => ArgumentError
改善された点:
  1. バリデーション: 不正なデータを作成時に防ぐ
  2. 不変性: 一度作成されたオブジェクトは変更できない
  3. 明確な意味: コードを読むだけで住所であることがわかる
  4. 複数属性の管理: 関連する複数の値を一つのオブジェクトで安全に管理
  5. 構造化された比較: 全要素を考慮した等価性判定

値オブジェクトを使うべきかの判断

✅ 値オブジェクトに適している場合

  • ビジネス上重要な概念(金額、住所、メールアドレスなど)
  • バリデーションルールがある
  • 一度作成したら変更しない

❌ 値オブジェクトに適していない場合

  • 状態が頻繁に変更される
  • ID で識別されるオブジェクト
  • 外部システムとの通信が必要

実装時の重要ポイント

必須項目

  1. バリデーション: 不正な値を防ぐ
  2. 不変性: 一度作成したら変更できないようにする
  3. 等価性: ==メソッドで値を比較
  4. 文字列表現: to_sメソッドで表示

実践例:より複雑な値オブジェクト

例 3: 金額(Money)オブジェクト

異なる通貨の混合や不正な金額を防ぐための値オブジェクトです。
class Money
  attr_reader :amount, :currency

  def initialize(amount, currency = "JPY")
    raise ArgumentError, "金額は数値である必要があります" unless amount.is_a?(Numeric)
    raise ArgumentError, "金額はマイナスにできません" if amount < 0
    raise ArgumentError, "通貨コードは3文字である必要があります" unless currency.match?(/^[A-Z]{3}$/)

    @amount = amount
    @currency = currency.freeze
    freeze
  end

  def +(other)
    check_same_currency(other)
    Money.new(amount + other.amount, currency)
  end

  def ==(other)
    other.is_a?(Money) && amount == other.amount && currency == other.currency
  end

  def to_s
    "#{currency} #{sprintf('%.2f', amount)}"
  end

  private

  def check_same_currency(other)
    raise ArgumentError, "異なる通貨同士の演算はできません" unless currency == other.currency
  end
end

# 使用例
price = Money.new(1000, "JPY")
tax = Money.new(100, "JPY")
total = price + tax
puts total  # => JPY 1100.00

# 異なる通貨での演算はエラー
# usd_price = Money.new(10, "USD")
# price + usd_price  # => ArgumentError: 異なる通貨同士の演算はできません

演習問題

演習: メールアドレスの値オブジェクト化

以下のコードを値オブジェクトを使って改善してください。

問題コード

class User
  attr_accessor :email

  def initialize(email)
    @email = email
  end
end

user = User.new("user@example.com")
user.email = "invalid-email"    # 不正な値を設定できてしまう

解答例

class EmailAddress
  # 読み取り専用のアクセサメソッド
  attr_reader :value

  def initialize(value)
    # メールアドレスのフォーマットをチェック(基本的な検証)
    # 注:ここでは学習用に簡略化したチェックを使用しています
    unless value.include?("@") && value.include?(".")
      raise ArgumentError, "メールアドレスの形式が不正です"
    end

    # 正規化:小文字に統一してから保存
    # freezeで文字列の変更を防ぐ
    @value = value.downcase.freeze

    # オブジェクト全体をfreeze
    freeze
  end

  # 値による等価性:正規化された値で比較
  def ==(other)
    other.is_a?(EmailAddress) && value == other.value
  end

  # 文字列表現
  def to_s
    @value
  end
end

class User
  attr_reader :email

  def initialize(email)
    @email = EmailAddress.new(email)
  end
end

# 使用例
user = User.new("User@Example.COM")
puts user.email  # => user@example.com

# 不正な値での作成はエラーになる
# EmailAddress.new("invalid-email")  # => ArgumentError: メールアドレスの形式が不正です

メールアドレス検証についての補足

ここでは学習目的のため簡略化した検証を使用しています。 実際のプロジェクトでは、より厳密な検証や外部ライブラリの使用を検討してください。

改善された点

  1. バリデーション: 不正な形式を作成時に防ぐ
  2. 正規化: 大文字小文字を統一
  3. 型安全性: 文字列ではなく専用の型として扱える
  4. 一貫した状態: 作成時に正規化されるため常に期待される形式
  5. 不変性の保証: freezeにより作成後の変更を防ぐ

まとめ

値オブジェクトは、ビジネス上重要な概念を安全で明確な形で表現するための強力なパターンです。

重要なポイント

  1. 不変性: 一度作成されたら変更できない
  2. バリデーション: 不正な値を作成時に防ぐ
  3. 等価性: 値によって等しいかどうかを判断
  4. 明確な意味: コードを読むだけで何を表すかがわかる
値オブジェクトを使うことで、より安全で保守しやすいコードを書くことができます。