サイト管理人のアイコン
Kawahito's Blog

Ruby の 標準ライブラリ delegate の注意点


今回使用する例

Ruby のバージョンは 3.3.1 です。

以下の例を使います。

require 'delegate'

class Coffee
  def cost
    1
  end
end

class MilkDecorator < SimpleDelegator
  def cost
    super + 0.2
  end
end

class SugarDecorator < SimpleDelegator
  def cost
    super + 0.4
  end
end

coffee = Coffee.new
milk_coffee = MilkDecorator.new(coffee)
sugar_milk_coffee = SugarDecorator.new(milk_coffee)

coffee.cost # => 1
milk_coffee.cost # => 1.2
sugar_milk_coffee.cost # => 1.6

の違いを、呼び出し側は気にせず、cost を取得できます。

同じことは継承を使っても実現できます。

class MilkCoffee < Coffee
  def cost
    super + 0.2
  end
end

ただしこの方法では、すべてのトッピングの組み合わせごとにクラスを作る必要があります。

SimpleDelegator を利用すると、トッピングの種類を柔軟に増やせます。

いわゆる Decorator パターンの実装が簡単にできて便利です。

ただし、いくつか注意点も見つけたので、ここにメモしておきます。

移譲されないメソッドに注意

すべてのメソッドが元のオブジェクトに移譲されるわけではありません。

例として is_a? を見てみます。

coffee.is_a?(Coffee) # => true
sugar_coffee.is_a?(Coffee) # => false

sugar_coffeeCoffee のインスタンスではありません。

あくまで coffee オブジェクトに処理を移譲しているだけです。

よって上記の is_a? の挙動は問題なさそうです。

ただ、よく考えると、

というふうに、メソッド移譲の挙動に違いがあることに気づきます。

この理由を、コードから追ってみます。

SimpleDelegatorDelegator を継承しています。

その Delegator クラスの定義がこちら。

# @see https://github.com/ruby/ruby/blob/d21e4e76c44b3be940c4fd8be6a649cdf366f0f9/lib/delegate.rb#L41-L57
class Delegator < BasicObject
  kernel = ::Kernel.dup
  kernel.class_eval do
    alias __raise__ raise
    [:to_s, :inspect, :!~, :===, :<=>, :hash].each do |m|
      undef_method m
    end
    private_instance_methods.each do |m|
      if /\Ablock_given\?\z|\Aiterator\?\z|\A__.*__\z/ =~ m
        next
      end
      undef_method m
    end
  end
  include kernel
end

まず、Ruby の通常のオブジェクトが持つ大量のメソッドを、最初から持たないようにするために BasicObject を継承しています。

次に、Kernel モジュールを dup で複製しつつ、class_eval の中で不要なメソッドの定義を undef_method で削除しています。

そして、この複製した Kernel を include しています。

つまり、BasicObjectKernel のうち必要な一部のメソッドだけが定義されているわけです。

それ以外のメソッド呼び出しについては、以下の method_missing により元のオブジェクトに移譲されます。

# @see https://github.com/ruby/ruby/blob/d21e4e76c44b3be940c4fd8be6a649cdf366f0f9/lib/delegate.rb#L82-L93
ruby2_keywords def method_missing(m, *args, &block)
  r = true
  target = self.__getobj__ {r = false}

  if r && target_respond_to?(target, m, false) # 元のオブジェクトに移譲
    target.__send__(m, *args, &block)
  elsif ::Kernel.method_defined?(m) || ::Kernel.private_method_defined?(m) # Kernel に移譲
    ::Kernel.instance_method(m).bind_call(self, *args, &block)
  else # BasicObject に移譲
    super(m, *args, &block)
  end
end

これで is_a? が移譲されない理由がわかりました。

is_a? は Kernel が持つメソッドであるため、上記の method_missing は呼ばれず、元のオブジェクトへの移譲もされないわけです。

念のため、利用側からも確認してみます。

coffee.method(:is_a?).owner # => Kernel

milk_coffee.method(:is_a?).owner # => <Module:0x000000010482f320>
MilkDecorator.ancestors # => [MilkDecorator, SimpleDelegator, Delegator, #<Module:0x000000010482f320>, BasicObject]

ここの <Module:0x000000010482f320> が、Kernel.dup して Delegator に include したモジュールを表していそうです。

さらに Coffee#is_a? を以下のように、オーバーライドしてみましょう。

class Coffee
  def is_a?(klass)
    "override"
  end
end

coffee.is_a?(Coffee) # => override
milk_coffee.is_a?(Coffee) # => false

やはり Kernel 由来のメソッドであるために、元のオブジェクトに移譲されていないことが分かります。

eql? や == の非対称性に注意

以下の例のように、== の左辺と右辺を入れ替えると、結果が変わります。

coffee == milk_coffee # false
milk_coffee == coffee # true

milk_coffee == sugar_milk_coffee # false
sugar_milk_coffee == milk_coffee # true

なぜこの非対称性が生まれるのでしょうか。

先ほど Delegatormethod_missing で元のオブジェクトに処理を移譲していると書きました。

しかし == については、Delegator 自身にメソッドが定義されています。

# @see https://github.com/ruby/ruby/blob/d21e4e76c44b3be940c4fd8be6a649cdf366f0f9/lib/delegate.rb#L151-L159
def ==(obj)
  return true if obj.equal?(self)
  self.__getobj__ == obj
end

ここでself.__getobj__ は、移譲先のオブジェクトを表しています。

つまり移譲先のオブジェクトと、相手を比較しているのです。

これが上記の非対称性を生む理由です。

coffee == milk_coffee # false
milk_coffee == coffee # true(coffee == coffee と同じ)

milk_coffee == sugar_milk_coffee # false(coffee == sugar_milk_coffee と同じ)
sugar_milk_coffee == milk_coffee # true(milk_coffee == milk_coffee と同じ)

また Delegator#== の定義からわかる通り、以下のケースでは、どちらも false となります。

sugar_coffee == milk_coffee # false(coffee == milk_coffee と同じ)
milk_coffee == sugar_coffee # false(coffee == sugar_coffee と同じ)

また Hash を使う場合にも注意が必要です。

def count_up(hash, obj)
  if hash[obj].nil?
    hash[obj] = 1
  else
    hash[obj] += 1
  end
end

hash1 = {}
hash2 = {}

count_up(hash1, coffee)
count_up(hash1, milk_coffee)

hash1[coffee] # => 2
hash1[milk_coffee] # => 2

count_up(hash2, milk_coffee)
count_up(hash2, coffee)

hash2[coffee] # => 1
hash2[milk_coffee] # => 1

count_up 関数に渡す milk_coffeecoffee の順番によって、結果が変わってしまいました。

これは Hash が内部的に eql? を利用しているためです。

coffee.eql?(milk_coffee) # false
milk_coffee.eql?(coffee) # true

eql?Delegator クラスで定義されており、== と同じ問題が起きるわけです。

Hash を通す分、問題の原因に気づきにくいと思います。

これら非対称性にはすぐに気づけるなら良いのですが、「特定の入力パターンでのみ発生する」など、検知しにくいバグにつながる恐れもあると感じました。

delegate ライブラリを使う際には、Ruby のコアなオブジェクトが提供するメソッドについては、取り扱いに注意しておくと良さそうです。