値オブジェクトの概念と実装方法を学び、不変性と等価性を活用したクリーンなコード設計を理解する
# 問題のあるコード例 price = 1000 # (1) これは何の金額?日本円?ドル? tax = price * 0.1 total = price + tax # (2) 異なる通貨での計算リスク user_email = "user@example.com" user_email = "invalid-email" # (3) 不正な値を設定できてしまう
1000
# 改善されたコード例 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と同じ値
address = Address.new("東京都", "渋谷区") # address.city = "新宿区" # エラー:変更不可
addr1 = Address.new("東京都", "渋谷区") addr2 = Address.new("東京都", "渋谷区") addr1 == addr2 # => true(同じ値なので等しい)
# ❌ 自己完結していない例:処理が散在している 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
# freezeなしの場合の問題 name = "商品名" name << "追加" # 文字列が変更される puts name # => "商品名追加" # freezeありの場合 frozen_name = "商品名".freeze # frozen_name << "追加" # RuntimeError: can't modify frozen String
# 問題のあるコード class Product attr_accessor :name # 文字列で管理 def initialize(name) @name = name end end product = Product.new("スマートフォン") product.name = "" # nameを変更できてしまう
# ステップ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: 完全な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
# 問題のあるコード 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
==
to_s
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: メールアドレスの形式が不正です