🐢

ただのクラスを値オブジェクトのクラスに育てていく例

2022/12/24に公開

ただのデータクラス

class C
  attr_reader :value

  def initialize(value)
    @value = value
  end
end

C.new(1)  # => #<C:0x0000000109785870 @value=1>

いちいち new を書きたくない

def C.[](...)
  new(...)
end

C[1]      # => #<C:0x00000001097455b8 @value=1>
  • なんでも new はダサいので工夫したい

[ ] も書きたくない

def C(...)
  C.new(...)
end

C(1)  # => #<C:0x000000010971fe80 @value=1>

Object.private_methods.grep(/C/)  # => [:C, :Complex]
  • この C は C クラスと何も関係ない
  • Object のプライベートメソッドになるので潔癖症な方には向かないかもしれないけど、どうせトップレベルでクラスを定義したら Object.constants に入る

inspect の表示を簡潔にしたい

C(1)  # => #<C:0x000000010971fe80 @value=1>

class C
  def inspect
    "<#{value}>"
  end
end

C(1)  # => <1>
  • value.to_s を返すとデバッグ目的の表示と区別つかないので工夫したい

文字列に埋めたときは文字列化してほしい

"#{C(1)}"  # => "#<C:0x000000010971e738>"

class C
  def to_s
    value.to_s
  end
end

"#{C(1)}"  # => "1"

足し算したい

C(1) + C(2) rescue $! # => #<NoMethodError: undefined method `+' for <1>:C>

class C
  def +(other)
    self.class.new(value + other.value)
  end
end

C(1) + C(2)           # => <3>

四則演算用のメソッドをコピペするのが面倒

class C
  %i(+ - * /).each do |op|
    define_method(op) do |other|
      self.class.new(value.send(op, other.value))
    end
  end
end

C(1) + C(2)  # => <3>
  • お金値オブジェクトだった場合「お金×お金」は変なのでそこは工夫する
  • 加減と剰余でロジックが変わるはず

send を使いたくない

%i(+ - * /).each do |op|
  C.class_eval <<-EOT, __FILE__, __LINE__ + 1
    def #{op}(other)
      self.class.new(value #{op} other.value)
    end
  EOT
end

C(1) + C(2)  # => <3>
Warming up --------------------------------------
       define_method   135.218k i/100ms
          class_eval   164.677k i/100ms
Calculating -------------------------------------
       define_method      1.323M (± 4.2%) i/s -      6.626M in   5.016970s
          class_eval      1.610M (± 7.9%) i/s -      8.069M in   5.071298s

Comparison:
          class_eval:  1609896.7 i/s
       define_method:  1323193.0 i/s - 1.22x  (± 0.00) slower
  • ちょっとだけ速くなる
  • けどエディタでインデントがおかしくなりがち
  • ベクトル値オブジェクトで演算がボトルネックになってるなら一考の価値あり

マイナスをつけたらマイナスになってほしい

-C(1) rescue $!  # => #<NoMethodError: undefined method `-@' for <1>:C

class C
  def -@
    self.class.new(-value)
  end
end

-C(1)  # => <-1>

比較がおかしい

C(1) == C(1)  # => false

class C
  def ==(other)
    self.class == other.class && value == other.value
  end
end

C(1) == C(1)  # => true
  • おかしくはないが中身が同じ値オブジェクトだったら同一視したい
  • == は緩めな判定をしていいので相手が整数だったらそのまま比較するとかしてもいい
  • が、値オブジェクトの目的は人為的ミスよる間違った組み合わせを咎めたいからでもあるのできっちり判定した方がよさそう

ハッシュキーとして使ったら別もの扱いされている

{C(1) => "ok"}[C(1)]  # => nil

class C
  def hash
    value.hash
  end

  def eql?(other)
    self.class == other.class && value == other.value
  end
end

{C(1) => "ok"}[C(1)]  # => "ok"

C(1).eql?(C(1)) && C(1).hash == C(1).hash # => true
  • hash と eql? をペアで定義する
  • hash だけあればよさそうなのになんで eql? もいるのかはわからない
    • hash が衝突したときのため?

ソートできない

[C(2), C(1)].sort rescue $! # => #<ArgumentError: comparison of C with C failed>

class C
  def <=>(other)
    [self.class, value] <=> [other.class, other.value]
  end
end

[C(2), C(1)].sort           # => [<1>, <2>]

Immutable にしておく

C(1).instance_eval { @value = 0 } # => 0

class C
  def initialize(value)
    @value = value
    freeze
  end
end

C(1).instance_eval { @value = 0 } rescue $! # => #<FrozenError: can't modify frozen #<Class:#<C:0x00007ffe50046630>>>

Discussion