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_coffee
は Coffee
のインスタンスではありません。
あくまで coffee
オブジェクトに処理を移譲しているだけです。
よって上記の is_a?
の挙動は問題なさそうです。
ただ、よく考えると、
cost
は coffee に移譲されるis_a?
は coffee に移譲されない
というふうに、メソッド移譲の挙動に違いがあることに気づきます。
この理由を、コードから追ってみます。
SimpleDelegator
は Delegator
を継承しています。
その 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 しています。
つまり、BasicObject
と Kernel
のうち必要な一部のメソッドだけが定義されているわけです。
それ以外のメソッド呼び出しについては、以下の 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
なぜこの非対称性が生まれるのでしょうか。
先ほど Delegator
が method_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_coffee
と coffee
の順番によって、結果が変わってしまいました。
これは Hash が内部的に eql?
を利用しているためです。
coffee.eql?(milk_coffee) # false
milk_coffee.eql?(coffee) # true
eql?
も Delegator
クラスで定義されており、==
と同じ問題が起きるわけです。
Hash を通す分、問題の原因に気づきにくいと思います。
これら非対称性にはすぐに気づけるなら良いのですが、「特定の入力パターンでのみ発生する」など、検知しにくいバグにつながる恐れもあると感じました。
delegate ライブラリを使う際には、Ruby のコアなオブジェクトが提供するメソッドについては、取り扱いに注意しておくと良さそうです。