目次


魅力的なPython: 多重ディスパッチ

multimethodsを使って多態性を一般化する

Comments

多態性とは

プログラマーが多態性を利用する場合 (Pythonであれ、他のオブジェクト指向プログラミング言語であれ)、ほとんどが、現実的、実際的な使い方をしています。おそらく、多態性の最も一般的な応用例は、共通なプロトコルに従うオブジェクトのファミリーを作成する場合でしょう。Pythonの場合、これは、通常、ad-hocな多様性 (ad-hoc polymorphism) を使えば済むことです。他の言語では、多くの場合、形式的なインターフェースを宣言したり、これらのファミリーが同じ祖先を共有するようにします。

たとえば、「ファイル系」オブジェクトを操作する関数はたくさん存在します。ここでファイル系というのは、.read().readlines()、あるいは.seek() などのメソッドをいくつかサポートするという意味です。read_app_data() のような関数は、src という引数をとったりします。この関数を呼び出す場合、その引数にローカル・ファイルを渡すのか、urllib オブジェクトを渡すのか、cStringIO オブジェクトを渡すのか、あるいはその関数にsrc.read() を呼び出させるようなカスタム・オブジェクトを渡すのかを選択したりします。それぞれのオブジェクトの型は、read_app_data() の中で、それがどのような働きをするのかという意味では相互に交換可能です。

このとき、どんなことが行われているのか、ここで少し立ち止まって考えてみたいと思います。われわれにとって重要なことは、基本的に、あるコンテキストの中で実行すべき正しいコード・パスを選択するということです。旧来の手続き型のコードでも、同等の決定を行うことはできます。OOPは、それを優雅に行えるようにしているだけです。たとえば、手続き型の (擬似) コードの場合、次のようなものとなるでしょう。

リスト1. オブジェクトの型によるコード・パスの選択 (手続き型コードの場合)
...bind 'src' in some manner...
if <<src is a file object>>:
    read_from_file(src)
elif <<src is a urllib object>>:
    read_from_url(src)
elif <<src is a stringio object>>:
    read_from_stringio(src)
...etc...

いろいろな型のオブジェクトが共通のメソッドを実装できるようにすることで、ディスパッチ の決定をオブジェクトの中に移動し、明示的な条件ブロックから外します。指定されているsrc オブジェクトは、その継承ツリーをたどることで、どのコード・ブロックを呼び出す必要があるのかを知っています。それでも、暗黙のスイッチが動いているわけですが、それはオブジェクトsrc の型にしたがってのスイッチです。

オブジェクトsrc には、そのメソッドに渡されるどの引数よりも優先される特権が与えられています。OOPの構文は、この特権の付与を必然的であるかのように扱っていますが、本当は、そうではありません。多くの場合、手続き型のスイッチングが、クラスのメソッド本体の中に押し込められているだけのことです。たとえば、以下のようなFooBar というプロトコル互換の2つのクラスを (擬似Pythonで) 実装したとします。

リスト2. FooとBarがメソッド .meth() を実装する
class Foo:
    def meth(self, arg):
        if <<arg is a Foo>>:
            ...FooFoo code block...
        elif <<arg is a Bar>>:
            ...FooBar code block...
class Bar:
    def meth(self, arg):
        if <<arg is a Foo>>:
            ...BarFoo code block...
        elif <<arg is a Bar>>:
            ...BarBar code block...
# Function to utilize Foo/Bar single-dispatch polymorphism
def x_with_y(x, y):
    if <<x is Foo or Bar>> and <<y is Foo or Bar>>:
        x.meth(y)
    else:
        raise TypeError,"x, y must be either Foo's or Bar's"

x_with_y() が呼び出される場合、5通りのコード・パス/ブロックが実行される可能性があります。xy の型が適合しない場合、例外が起こされますが (もちろん、例外を起動せずに別のことを行うようにすることもできます)、型が適合しているとすると、コード・パスは、まず 多態的ディスパッチによって選択され、次に 手続き型のスイッチによって選択されます。また、Foo.meth() およびBar.meth() の定義の中のスイッチングは、ほぼ同じです。多態性 (単一ディスパッチの変形としての) は、中途半端なものでしかありません。

完全な多態性を実現する

単一ディスパッチの多態性の場合、メソッドを「所有する」オブジェクトが選び出されます。構文上は、そのオブジェクトは、ドットの前に指定されることで、Pythonによって選び出されます。ドット、メソッド名、および左括弧以下のものは、すべて、引数です。しかし、意味的にも、オブジェクトは、メソッドを解決するために継承ツリーを利用するという点で特殊です。

1個のオブジェクトだけを特別扱いせずに、コード・ブロックに関与するすべてのオブジェクトが正しいコード・パスを選択できるようにしたとすると、どうなるでしょうか。たとえば、先の5通りのスイッチを、以下のように、もっと対称的な方法で表現したとします。

リスト3. FooとBarに基づく多重ディスパッチ
x_with_y = Dispatch([((object, object), <<exception block>>)])
x_with_y.add_rule((Foo,Foo), <<FooFoo block>>)
x_with_y.add_rule((Foo,Bar), <<FooBar block>>)
x_with_y.add_rule((Bar,Foo), <<BarFoo block>>)
x_with_y.add_rule((Bar,Bar), <<BarBar block>>)
#...call the function x_with_y() using some arguments...
x_with_y(something, otherthing)

複数の引数に基づいて多態的なディスパッチを行うときのこの対称性のほうが、先のスタイルよりも、はるかに優雅なのではないでしょうか。また、このスタイルの場合、適当なコード・パスの決定に関して、関係する2つのオブジェクトが対等な役割を担っていることを文書化しやすくなります。

標準的なPythonでは、この形式の多重ディスパッチを設定することはできませんが、幸い、以前に紹介したmultimethods というモジュールを使えば、それができるようになります。このモジュールは、単体で、あるいはGnosis Utilitiesの一部としてダウンロードできます (参考文献参照)。multimethods をインストールしたら、後は、アプリケーションの先頭に以下の行を含めればよいだけの話です。

from multimethods import Dispatch

multimethodsは、一般に多重ディスパッチ (multiple dispatch) と同義ですが、multimethodという名前は、抽象的な概念である多重ディスパッチを具体的に処理する関数/オブジェクトの意味合いが強くなります。

Dispatch のインスタンスは、呼び出し可能なオブジェクトであり、いくらでも必要な数だけルールを設けて構成することができます。Dispatch.remove_rule() というメソッドを使ってルールを削除することもできます。それによって、multimethods による多重ディスパッチは、静的なクラス階層の場合より、もう少し動的なものになります (といっても、Pythonのクラスを使えば、実行時にいろいろと難解なことを行うこともできますが)。また、Dispatch のインスタンスは、可変個の引数をとることができます。その場合、まず引数の数によってマッチングが行われた後、型によるマッチングが行われます。Dispatch のインスタンスが、ルールに定義されていないパターンで呼び出されると、TypeError が起こされます。未定義のケースを例外で対処したい場合、予備的な(object,object) のパターンを使ってx_with_y() を初期化する必要はありません。

Dispatch の初期化呼び出しに指定される、各(pattern,function) タプルは、.add_rule() メソッドに渡されるだけです。ルールを初期化時に設定するか、後で設定するかは、単にプログラマーの都合で決めればよいことです (先の例と同様、適当に組み合わせて行うこともできます)。関数がディスパッチャーから呼び出される際、ディスパッチャーの呼び出しに指定された引数が、その関数に渡されます。使用する関数が、マッチングされる数だけの引数をとれるかどうか確認する必要があります。たとえば、以下のコードは、同等の働きをします。

リスト4. ディスパッチされる関数の明示的な呼び出し
# Define function, classes, objects
def func(a,b): print "The X is", a, "the Y is", b
class X(object): pass
class Y(object): pass
x, y = X(), Y()
# Explicit call to func with args
func(x,y)
# Dispatched call to func on args
from multimethods import Dispatch
dispatch = Dispatch()
dispatch.add_rule((X,Y), func)
dispatch(x,y)         # resolves to 'func(x,y)'

もちろん、設計の時点でxy の型がわかっていれば、ディスパッチャーをセットアップする仕掛けは、オーバーヘッドでしかありませんが、その場合、多態性にも同じ制約が発生します。多態性は、すべての実行パスについてオブジェクトを1つの型に制限できない場合にしか有効ではありません。

継承の改良

多重ディスパッチは、多態性を一般化するだけでなく、いろいろなコンテキストで継承をもっと柔軟に実現する方法を提供してもいます。以下に示す例で、このことがよくわかります。さまざまな形状を扱う描画プログラムやCADプログラムをプログラミングしているとします。とくに、2つの形状を、それら両方の形状に応じて組み合わせられる ようにしたいものとします。また、扱うことのできる形状のコレクションは、派生アプリケーションやプラグインで拡張できるようにします。形状クラスのコレクションを拡張する場合、以下のように、拡張の手法が手際の悪いものになってしまいます。

リスト5. 機能拡張用の継承
# Base classes
class Circle(Shape):
    def combine_with_circle(self, circle): ...
    def combine_with_square(self, square): ...
class Square(Shape):
    def combine_with_circle(self, circle): ...
    def combine_with_square(self, square): ...
# Enhancing base with triangle shape
class Triangle(Shape):
    def combine_with_circle(self, circle): ...
    def combine_with_square(self, square): ...
    def combine_with_triangle(self, triangle): ...
class NewCircle(Circle):
    def combine_with_triangle(self, triangle): ...
class NewSquare(Square):
    def combine_with_triangle(self, triangle): ...
# Can optionally use original class names in new context
Circle, Square = NewCircle, NewSquare
# Use the classes in application
c, t, s = Circle(...), Triangle(...), Square(...)
newshape1 = c.combine_with_triangle(t)
newshape2 = s.combine_with_circle(c)
# discover 'x' of unknown type, then combine with 't'
if isinstance(x, Triangle): new3 = t.combine_with_triangle(x)
elif isinstance(x, Square): new3 = t.combine_with_square(x)
elif isinstance(x, Circle): new3 = t.combine_with_circle(x)

たとえば、既存の形状クラスは、それぞれ、子孫で機能追加を行う必要があり、その結果、組み合わせが複雑になり、保守が難しくなります。

これに対して、多重ディスパッチの手法は、以下のように簡単明瞭なものになります。

リスト6. 機能拡張用のmultimethods
# Base rules (stipulate combination is order independent)
class Circle(Shape): pass
class Square(Shape): pass
def circle_with_square(circle, square): ...
def circle_with_circle(circle, circle): ...
def square_with_square(square, square): ...
combine = Dispatch()
combine.add_rule((Circle, Square), circle_with_square)
combine.add_rule((Circle, Circle), circle_with_circle)
combine.add_rule((Square, Square), square_with_square)
combine.add_rule((Square, Circle),
                 lambda s,c: circle_with_square(c,s))
# Enhancing base with triangle shape
class Triangle(Shape): pass
def triangle_with_triangle(triangle, triangle): ...
def triangle_with_circle(triangle, circle): ...
def triangle_with_square(triangle, square): ...
combine.add_rule((Triangle,Triangle), triangle_with_triangle)
combine.add_rule((Triangle,Circle), triangle_with_circle)
combine.add_rule((Triangle,Square), triangle_with_square)
combine.add_rule((Circle,Triangle),
                 lambda c,t: triangle_with_circle(t,c))
combine.add_rule((Square,Triangle),
                 lambda s,t: triangle_with_square(t,s))
# Use the rules in application
c, t, s = Circle(...), Triangle(...), Square(...)
newshape1 = combine(c, t)[0]
newshape2 = combine(s, c)[0]
# discover 'x' of unknown type, then combine with 't'
newshape3 = combine(t, x)[0]

新しいルール (およびサポート関数/メソッド) の定義は、だいたい同じですが、多重ディスパッチ・スタイルの大きな利点は、未知のタイプの形状をスムーズに組み合わせることができる点にあります。明示的で (長々と続く) 条件ブロックに立ち戻らなくても、ルール定義によって、いろいろな問題を自動的に解決することができます。さらに良いことは、個々の組み合わせに対応したメソッドをずらずらと用意しなくても、すべての組み合わせをcombine() 呼び出し1個で処理できるという点です。

ディスパッチの伝播

ディスパッチについてあれこれ思案しなくても、multimethods.Dispatch クラスが、ディスパッチャーに対して指定された呼び出しに「最適な」関数/メソッドを選択してくれます。ただし、「最善」が「唯一」ではないことを知っておいたほうがよい場合もあります。たとえば、dispatch(foo,bar) という呼び出しは、定義済みのルール(Foo,Bar) にぴったり適合するかもしれませんが、(FooParent,BarParent) とも (非適合ではなく) ある程度適合するかもしれません。継承されたメソッドでスーパークラスのメソッドを呼び出したいときがあるのと同じように、ディスパッチャーでも、特定的でないルールを呼び出したいときもあります。

multimethods モジュールでは、特定的でないルールを簡単に呼び出すこともできれば、細かい指定をして呼び出しを行うこともできます。大雑把な処理を行う場合には、通常、コード・ブロックの実行の最初または最後で、特定的でないルールを自動的に呼び出すようにすればよいでしょう。同様に、たいていは、子孫のメソッド本体の最初または最後で、スーパークラスのメソッドを呼び出すようにします。最初または最後で特定的でないメソッドの一般的な呼び出しを行う場合、ルールの中でそのことを指定することができます。たとえば、以下のようにします。

リスト7. ディスパッチの自動伝播
class General(object): pass
class Between(General): pass
class Specific(Between): pass
dispatch = Dispatch()
dispatch.add_rule((General,), lambda _:"Gen", AT_END)
dispatch.add_rule((Between,), lambda _:"Betw", AT_END)
dispatch.add_rule((Specific,), lambda _:"Specif", AT_END)
dispatch(General())  # Result: ['Gen']
dispatch(Specific()) # Result: ['Specif', 'Betw', 'Gen']

もちろん、場合によっては ((General) ルールのように)、定義されたルールに特定的でないメソッドが存在しないこともありますが、統一を図るために、ディスパッチャー呼び出しは、すべて、伝播先を制御するすべての関数からの戻り値のリストを返すようになっています。AT_ENDAT_START もルールに指定されていない場合、伝播は起こりません (返されてくるリストの長さは1です)。先の形状プログラムの例で不思議に思われたかもしれませんが、[0] という添え字が出てきたのは、このためです。

伝播制御を細かく指定する場合は、ディスパッチャーの.next_method() を使って行います。手動の伝播を利用するには、.add_rule() メソッドではなく、.add_dispatchable() メソッドを使ってルールを定義する必要があります。また、ディスパッチされる側の関数も、dispatch 引数をとる必要があります。ディスパッチャーを呼び出す場合、dispatch引数を指定するか、あるいは専用の.with_dispatch() メソッドを使う手もあります。たとえば、以下のようにします。

リスト8. 手動伝播によるプログラミング
def do_between(x, dispatch):
  print "do some initial stuff"
  val = dispatch.next_method() # return simple value of up-call
  print "do some followup stuff"
  return "My return value"
foo = Foo()
import multimethods
multi = multimethods.Dispatch()
multi.add_dispatchable((Foo,), do_between)
multi.with_dispatch(foo)
# Or: multi(foo, multi)

特定的でないmultimethodsへの手動伝播は、スーパークラスのメソッドの呼び出しが巧妙でわかりにくくなりがちなのと多くの点で似ています。簡単に扱えるように、.next_method() 呼び出しは、つねに、up-callの簡単な戻り値を返すようになっています。AT_END 引数が行っているように、そうした戻り値をリストにしたいのであれば、必要に応じて、値をアペンドしたり操作する必要があります。しかし、最も一般的な「使用事例」は、関連する一連の初期化を実行する場面であり、この場合、戻り値は、通常、関係してきません。

スレッドの安全性について

ある問題に読者が直面することがないよう、少し補足しておきたいと思います。伝播では、(代々特定的でない) ルールが呼び出されてきた経路を、状態にしたがってたどっていきますので、ディスパッチャーは、スレッド的に安全なものではありません。複数のスレッドで1個のディスパッチャーを使いたい場合には、スレッドごとにディスパッチャーの「クローンを作成する」必要があります。そうしてもメモリーやCPUの資源の面で高価ではありませんので、ディスパッチャーのクローンを作成することで大きな不利益は生じません。たとえば、ある関数をスレッド間で呼び出すとすると、以下のような記述を行うことになります。

リスト9. スレッドの安全性を考えてクローンを作成する
def threadable_dispatch(dispatcher, other, arguments)
    dispatcher = dispatcher.clone()
    #...do setup activities...
    dispatcher(some, rule, pattern)
    #...do other stuff...

threadable_dispatch() の中で新しいスレッドが作成 (spawn) されないのであれば、何も問題はありません。

オブジェクト指向プログラミングに精通されている方であっても、あるいは精通している方はとくに、多重ディスパッチの考え方を納得できるまでに時間がかかります。しかし、少し試してみると、手続き型プログラミングと比べたときのOOPの最大の特長を、多重ディスパッチが一般化し、拡張していることがわかるようになることと思います。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=239850
ArticleTitle=魅力的なPython: 多重ディスパッチ
publish-date=03202003