DelegateClass の謎

DelegateClass に起因するスペックテスト問題 - ¬¬日常日記

というわけで、さて本論の DelegateClass の謎について書いてみたいと思います。まず前提として、私は delegation というものが何か今はまだよく分かっていません。だから、謎は謎のままです。今後緩やかに勉強していって、じゃあ DelegateClass はどうあるべきなのかという点を考えていくつもりです。つまり今回の範囲ではどうあるべきかといった結論は出さずに、Ruby の DelegateClass の振舞をただただ観察することが目的となります。

まず、DelegateClass の使い方は以下の記事がとても参考になります。

るびま

ここに挙げられている DelegateClass の使用例をそのまま引用します。

require 'delegate'
class ExtArray < DelegateClass(Array)     # Step 1
  def initialize()
    super([])                             # Step 2
  end
end
p ExtArray.ancestors #=> [ExtArray, #<Class:0x402a4f24>, Object, Kernel]
a = ExtArray.new
p a.type  # => ExtArray
a.push 25
p a       # => [25]

しかし、どうでしょうか。こうは書かれているものの、実際動かしてみないとどうなるのか分からないのが Ruby というものです。まず 1.8 trunk で動かしてみます。

[ExtArray, #<Class:0xb7cc24ac>, Object, Kernel]
ExtArray
[25]

というわけで大丈夫ですね(いや、Object#type の警告は出ますけれども)。では次に 1.9 trunk で動かしてみます(1.9 ではさすがに Object#type がないので、これは #class に書き換えて実行しています)。

[ExtArray, #<Class:0x82bdf10>, Delegator::MethodDelegation, Object, Kernel, BasicObject]
Array
[25]

ancestors の結果が色々と変わっていますし、また a.class の結果が Array です。前者の件については、1.9 的なクラス階層になっているだけでなく、Delegator::MethodDelegation というのが追加されています。つまり、1.9 では DelegateClass はなんか変わったよ、ということですね。でもこれはあまり影響がないので良いとします。しかし後者は激変と言って良いかと思われます。こうした振舞の差を次によって確かめてみましょう。

require "delegate"

class A; end
class B < DelegateClass(A)
  def initialize
    super(A.new)
  end
end

b = B.new

p b == b
p b.kind_of?(B)
p b.instance_of?(B)
b.instance_variable_set(:@b, :b)
p b.instance_eval{@b}
p b

まず 1.8 では結果は次のように表示されます。

false
true
true
:b
#<A:0xb7c13c2c>

なんとまぁ b == b が false になります。さすがにこれはどうか、と思うところですが、もっとマズいのは次の振舞いです。

b == b.__getobj__ #=> true
b.__getobj__ == b #=> false

よって #== に関する対称性が保たれていません。よって多分 1.8 系の DelegateClass の #== は壊れているのだろう、と判断します(いずれ ruby-dev か BTS で聞いてみるつもりです)。

あと細かい点ですが、p(b) の結果が # となっていますが、これは @b = :b が設定されていないことから分かるように、b#inspect が委譲されて __setobj__.inspect の結果が表示されているだけとなっています。

では次に 1.9 の結果を見てみます。

true
false
false
nil
#<A:0x82ce4b4 @b=:b>

おお、嬉しいことに b == b が真になっています(でも試してみたらやっぱり #== に関する対称性が保たれていません)。しかしやはり b.kind_of?(B) 及び b.instance_of?(B) は偽です。もちろん既に述べたように b#class は A になるわけで、これはある意味首尾一貫した変更です。つまり B のインスタンスである b はあくまでもクラス A のインスタンスのように振る舞うわけですから、#kind_of? も #instance_of? も #class も、まるで A であるかのように振る舞うわけなのでしょう。そのように DelegateClass の働きが変わってしまった、と理解することが出来ます。

個人的には b.kind_of?(B) が偽となるのはとても違和感を覚えるのですが、それは委譲について私が良く理解していないことを起因とする誤解かも知れませんので、ここではそうしたことは問題とするつもりはありません。しかしこうした 1.9 の DelegateClass のあり方について問題がないわけではありません。

まず #instance_variable_set/get も委譲されているのは問題であろうと思われます。この理由は、もしこれらのインスタンス操作系のメソッドを委譲するのであれば #extend や #instance_eval だって委譲しなければ首尾一貫しませんが、そうなっていません。しかしこれらを全て委譲するのは極めて不便であろうとは想像できます(ここでは具体例をすぐに示せませんがそのうちなにか考えます)。私はこうしたインスタンスの状態変化を起こすメソッドを委譲すべきではないと考えます。

次に、b.kind_of?(B) が偽であるのに、case 文における状況はそれと一致しません。

case B.new
when A; puts "A"
when B; puts "B"
end
#=> B

もしも B のインスタンスが DelegateClass の効果によりまるで A のインスタンスのように振舞い、それが #kind_of? や #class にまで影響するのであれば、case 文における比較についても同様であるべきでしょう。従って、ここでは"A"が印字されることを期待しますが、実際には"B"となります。つまり 1.9 での DelegateClass のあり方は #=== について問題を抱えています。しかも、これを解決するのは、なかなか大変ですよね。というのも、case 文における比較は実際には A === B.new のような形になって、これは A.===(B.new) であり、レシーバは A となります。さて困ったものだと思います。まさか A を直接いじるわけにはいかないですよね。でもだからと言って Module#=== をいじるのは、もっともっと悪いアイディアでしょうし。

そんなこんな感じです。結局のところ、1.8 系にせよ 1.9 系にせよ、DelegateClass の現状には問題があると言えます。以上の内容をまとめます。

  • 1.8 系では #== がちょっと耐え難い(修正は簡単かな?)
  • 1.9 系では #instance_variable_set/get や #=== が首尾一貫しない

個人的には 1.9 系の #kind_of?, #class の変更は無いことにしたいくらいですが、最初に述べたように、私は委譲についてきちんと理解していないので、実は 1.9 系のあり方が概念上優れていたり正しかったりする可能性があります。しかしその場合には #=== をどうにかしなければなりません。どうしようかな。