目次


Python でのメタクラス・プログラミング、第 3 回

メタクラスを使わないメタプログラミング

Comments

はじめに

私は昨年、EuroPython 2006 というカンファレンスに出席しました。カンファレンスは充実したもので、構成は申し分なく、講演のレベルは高く、出席者は素晴らしい人達でした。とはいえ、私は Python のコミュニティーに少し気がかりな傾向があることに気付きました。それが、この記事を書くきっかけとなっています。ほぼ同時に、私の共著者である David Mertz も、Gnosis Utilities に提出されたパッチに関して同じような問題を感じていました。その問題と感じた傾向というのは、技巧的になろうとする傾向です。かつての Python コミュニティーでは、技巧が使われているのはほとんど Zope と Twisted の中に限られていたのですが、今や至るところで見かけられるようになっています。

私達は実験的なプロジェクトや学習用の演習で使われる技巧に対して反対するつもりはありません。私達が困惑しているのは、私達がユーザーとして強制的に対応させられる、実動のフレームワークで使われている技巧です。この記事では、少なくとも私達の専門知識を発揮できる領域で、技巧的なプログラミングから遠ざかるためのささやかな貢献をしたいと思います。その領域というのが、メタクラスの乱用です。

この記事では、私達は非情な立場を取ります。ここでは、カスタムのメタクラスを使わなくても同じ問題を解決できるはずでありながらメタクラスを使っている場合を、すべてメタクラスの乱用と考えることにします。もちろん、この件に関して著者らに責任があることは明らかです。私達が執筆した Python のメタクラスに関する以前の記事では、メタクラスを一般的に使うように勧めたのです。『Nostra culpa (我らが罪)』です。

最も一般的なメタプログラミングのシナリオの 1 つは、動的に生成される属性とメソッドを持つクラスを作成するケースです。このケースは、一般的に考えられていることとは反対で、たいていの場合カスタムのメタクラスを必要としない、そしてカスタムのメタクラスを使ってはならないケースなのです。

この記事は、2 通りの読者を対象としています。つまり、メタプログラミングの手法をいくつか知っていれば得をするはずでありながら、脳が溶けそうな概念に恐れをなしている平均的なプログラマーと、非常に賢くてメタプログラミングの手法もよく知っていると思われるプログラマーという 2 通りです。後者のプログラマーの問題は、技巧的になることは簡単ですが、技巧的でなくなるためには長い時間が必要なことです。例えば、私達はメタクラスの使い方を理解するまでに数ヶ月かかりましたが、メタクラスを使わない方法を理解するためには数年かかっているのです。

クラスの初期化について

クラスの属性とメソッドは、クラスを作成する際に一度だけ設定されます。しかし正確に言えば、Python ではメソッドと属性は、ほとんどいつでも変更することができてしまいます、しかしそれが言えるのは、言うことを聞かないプログラマーが透過的に行われる処理を犠牲にした場合のみです。

さまざまな一般的状況では、単純に静的なコードを実行してクラスを作成するのではなく、もっと動的な方法でクラスを作成したいことがあります。例えば、構成ファイルから読み取られるパラメーターに従ってデフォルトのクラス属性を設定したいことがあるかもしれません。あるいは、データベース・テーブルのフィールドに従ってクラスのプロパティーを設定したいことがあるかもしれません。クラスの動作を動的にカスタマイズするための最も簡単な方法は、命令型のスタイルを使う方法です。つまり最初にクラスを作成し、次にメソッドと属性を追加する方法です。

例えば、私達の知り合いの優秀なプログラマーである Anand Pillai は、まさにこれを行う、Gnosis Utilities のサブパッケージ gnosis.xml.objectify を利用する方法を提案しました。「xml ノード・オブジェクト」を (実行時に) 保持することに特化した gnosis.xml.objectify._XO_ というベース・クラスは、いくつかの機能強化された動作によって「修飾」され、次のようになります。

リスト 1. ベース・クラスの動的な機能強化
setattr(_XO_, 'orig_tagname', orig_tagname)
setattr(_XO_, 'findelem', findelem)
setattr(_XO_, 'XPath', XPath)
setattr(_XO_, 'change_pcdata', change_pcdata)
setattr(_XO_,'addChild',addChild)

皆さんの中には、単純に XO ベース・クラスをサブクラス化すれば同じ機能強化を実現できると思う人がいるかもしれません (十分妥当な考えです)。それはある面では正しいのですが、Anand は機能強化の候補を約 20 も提案しています。特定のユーザーがそのいくつかを必要とすることはありえますが、そのユーザーには、それ以外のものは必要ありません。これでは、機能強化のシナリオそれぞれに対して、あまりにも多くの組み合わせで容易にサブクラスを作成できてしまいます。とはいえ、上記のコードは必ずしも整然としているとは言えません。上記のようなことは、XO に付加したカスタムのメタクラスでも実現することができます。しかしその場合、動作は動的に決定されます。これでは、避けたいと思っていた必要以上に技巧的なプログラミング (そして不透明さ) に戻ってしまいます。

上記の要求に対する簡潔で整然としたソリューションは、Python にクラス修飾子を追加することかもしれません。もし、そうした修飾子があったとしたら、次のようなコードを作成することになるでしょう。

リスト 2. Python へのクラス修飾子の追加
features = [('XPath',XPath), ('addChild',addChild), ('is_root',is_root)]
@enhance(features)
class _XO_plus(gnosis.xml.objectify._XO_): pass
gnosis.xml.objectify._XO_ = _XO_plus

しかしこの構文は、もし使えるようになったとしても、将来の話です。

メタクラスが複雑になる場合

この記事の、ここまでの部分は、中身のない議論に思えるかもしれません。では例えば、XO のメタクラスを Enhance として定義し、そしてそれで終了、としたらどうなのでしょう。Enhance.__init__() は、対象とする特定の使い方にどんな機能が必要だったとしても、その機能を追加することができます。そうすると次のようになるかもしれません。

リスト 3. XO の Enhance としての定義
class _XO_plus(gnosis.xml.objectify._XO_):
      __metaclass__ = Enhance
      features = [('XPath',XPath), ('addChild',addChild)]
gnosis.xml.objectify._XO_ = _XO_plus

しかし残念なことに、継承を気にし始めると、話はそれほど単純ではなくなります。ベース・クラスに対してカスタムのメタクラスを定義すると、その派生クラスはすべて、そのメタクラスを継承します。そのため、すべての派生クラスで、魔法にかかったように暗黙的に初期化コードが実行されます。これは、特定の状況では適切かもしれません (例えば、定義するすべてのクラスをフレームワークに登録しなければならない場合には、メタクラスを使えば、派生クラスの登録を忘れることがありません)。しかし多くの場合には、こうした動作を避けたいはずです。その理由は以下のとおりです。

  • 明示的である方が暗黙的であるよりも適切です。
  • 派生クラスは、ベース・クラスと同じ動的クラス属性を持ちます。派生クラスそれぞれにクラス属性を設定することは無駄です。いずれにせよクラス属性は継承によって得られるからです。これは、初期化コードの動作が遅い場合や初期化コードの計算負荷が重い場合には特に重大な問題です。メタクラスのコードにチェックを追加して属性が親クラスで既に設定されているかどうかを調べることもできますが、それは配管の追加になり、クラスごとに実際の制御をできるわけではありません。
  • カスタムのメタクラスによって、クラスは少しばかり魔力を持ったようになり、そして標準的でないものになります。メタクラスの競合や「__slots__」の問題、(Zope の) 拡張クラスとの競合、尋常でない複雑さなどを招くのは避ける必要があります。メタクラスは、多くの人が認識しているよりも脆弱です。私達は、実験的なコードにはメタクラスを 4 年も使っていますが、実動のコードには稀にしか使ったことがありません。
  • クラスの初期化という単純な作業には、カスタムのメタクラスは過剰であり、もっと単純なソリューションを使う必要があります。

つまりカスタムのメタクラスを使う必要があるのは、派生クラスのユーザーに気付かれずに派生クラス上でコードを実行させることが真の意図である場合に限られるのです。それ以外の場合には、メタクラスを避け、皆さんの (そして皆さんのユーザーの) 作業を楽にした方が賢明なのです。

classinitializer 修飾子

この記事のこれから先で示す内容は、技巧的であるために非難されるかもしれません。しかし技巧的な方法は、ユーザーには負担をかけずに、私達のような作成者のみが負担をすればすむものです。読者の皆さんは、私達が提案する仮説的な (整然とした) クラス修飾子によく似た方法を使うことができ、その方法を使えばメタクラスの方法で発生する継承やメタクラスの競合の問題が生じることはありません。後ほど詳しく示す「奥の深い魔法」の修飾子は、大まかに言えば、単純だが若干整然としていない面がある命令型の方法を単に機能強化するものであり、実質的にはこの命令型の方法と同じようなものです。

リスト 4. 命令型の方法
def Enhance(cls, **kw):
    for k, v in kw.iteritems():
        setattr(cls, k, v)
class ClassToBeInitialized(object):
    pass
Enhance(ClassToBeInitialized, a=1, b=2)

上記の命令型による機能強化はそれほど悪くありませんが、いくつかの欠点を持っています。つまり、クラス名を繰り返さなければならなかったり、クラス定義とクラスの初期化が分離されているため読みにくくなっていたりします (長いクラス定義では最後の行を見逃す可能性があります)。また、何かを定義して即座にそれを変更するのは不適切なことのように思えます。

classinitializer 修飾子は宣言型のソリューションを提供します。この修飾子は、クラス定義の中で使えるメソッドに Enhance(cls,**kw) を変換します。

リスト 5. 基本的な操作での魔法の修飾子
>>> @classinitializer # add magic to Enhance
... def Enhance(cls, **kw):
...     for k, v in kw.iteritems():
...         setattr(cls, k, v)
>>> class ClassToBeInitialized(object):
...     Enhance(a=1, b=2)
>>> ClassToBeInitialized.a
1
>>> ClassToBeInitialized.b
2

Zope インターフェースを使ったことのある人は、クラス初期化子 (zope.interface.implements) の例を見たことがあるかもしれません。実際、classinitializer は、(Phillip J. Eby による) zope.interface.advice からコピーされた手法を使って実装されています。この手法は「__metaclass__」フックを使いますが、カスタムのメタクラスは使いません。ClassToBeInitialized は、そのオリジナルのメタクラス (つまり、新しいスタイル・クラスの単純な組み込みメタクラス type) を保持します。

>>> type(ClassToBeInitialized)
<type 'type'>

基本的に、この手法は古いスタイル・クラスにも適用でき、古いスタイル・クラスを古いスタイルで保持する実装は作成しやすいはずです。しかし、Guido によれば「古いスタイル・クラスはおそらく廃止される可能性が高い」ため、現在の実装では古いスタイル・クラスを新しいスタイル・クラスに変換しています。

リスト 6. 新しいスタイルへの変換
>>> class WasOldStyle:
...     Enhance(a=1, b=2)
>>> WasOldStyle.a, WasOldStyle.b
(1, 2)
>>> type(WasOldStyle)
<type 'type'>

classinitializer 修飾子を使う理由の 1 つは、配管を隠すこと、そしてごく普通の開発者が、クラス作成の動作の詳細や _metaclass_ フックの秘密を知らなくても独自のクラス初期化子を容易に実装できるようにすることです。もう 1 つの理由は、たとえ Python のウィザードであっても、_metaclass_ フックを管理するコードを新しいクラス初期化子の作成ごとに作成し直すのは非常に面倒なためです。

最後の注意として、修飾された Enhance は、明示的なクラス引数を渡されれば、クラスのスコープ外では修飾されていない Enhance として動作し続けるということを指摘しておきます。

>>> Enhance(WasOldStyle, a=2)
>>> WasOldStyle.a
2

(過度に) 奥の深い魔法

下記は classinitializer のコードです。この修飾子を使うために、このコードを理解する必要はありません。

リスト 7. classinitializer 修飾子
import sys
def classinitializer(proc):
   # basic idea stolen from zope.interface.advice, P.J. Eby
   def newproc(*args, **kw):
       frame = sys._getframe(1)
       if '__module__' in frame.f_locals and not \
           '__module__' in frame.f_code.co_varnames: # we are in a class
           if '__metaclass__' in frame.f_locals:
               raise SyntaxError("Don't use two class initializers or\n"
                 "a class initializer together with a __metaclass__ hook")
           def makecls(name, bases, dic):
               try:
                   cls = type(name, bases, dic)
               except TypeError, e:
                   if "can't have only classic bases" in str(e):
                       cls = type(name, bases + (object,), dic)
                   else:  # other strange errs, e.g. __slots__ conflicts
                       raise
               proc(cls, *args, **kw)
               return cls
           frame.f_locals["__metaclass__"] = makecls
       else:
           proc(*args, **kw)
 newproc.__name__ = proc.__name__
 newproc.__module__ = proc.__module__
 newproc.__doc__ = proc.__doc__
 newproc.__dict__ = proc.__dict__
 return newproc

この実装から、クラス初期化子の動作は明らかです。クラス内部でクラス初期化子を呼び出すと、実際にはそのクラスのメタクラス (通常は type) から呼び出される _metaclass_ フックを定義していることになります。このメタクラスはそのクラスを (新しいスタイルのクラスとして) 作成し、それをクラス初期化子のプロシージャーに渡します。

注意すべき点と警告

クラス初期化子は _metaclass_ フックを (再) 定義するため、_metaclass_ フックを (暗黙的に継承するのではなく) 明示的に定義するクラスとは、うまく共存できません。もしクラス初期化子の後に _metaclass_ フックが定義されていると、このフックがその初期化子を暗黙のうちに無効にしてしまいます。

リスト 8. 表プロジェクト index.html のホーム
>>> class C:
...     Enhance(a=1)
...     def __metaclass__(name, bases, dic):
...         cls = type(name, bases, dic)
...         print 'Enhance is silently ignored'
...         return cls
...
Enhance is silently ignored
>>> C.a
Traceback (most recent call last):
  ...
AttributeError: type object 'C' has no attribute 'a'

残念ながら、この問題に対する一般的なソリューションはなく、その初期化子が無効にされたことを文字で表して伝えるのみです。一方、クラス初期化子を _metaclass_ フックの後に呼び出すと、例外が発生します。

リスト9. ローカルのメタクラスによって発生するエラー
>>> class C:
...     def __metaclass__(name, bases, dic):
...         cls = type(name, bases, dic)
...         print 'calling explicit __metaclass__'
...         return cls
...     Enhance(a=1)
...
Traceback (most recent call last):
   ...
SyntaxError: Don't use two class initializers or
a class initializer together with a __metaclass__ hook

明示的な _metaclass_ フックを暗黙のうちに無効にするよりも、エラーを発生させる方が適切です。しかしそうすると、2 つのクラス初期化子を同時に使おうとした場合や、同じクラス初期化子を 2 度呼び出そうとした場合に、エラーが発生することになります。

リスト 10. Enhance を 2 度繰り返すことによる問題の発生
>>> class C:
...     Enhance(a=1)
...     Enhance(b=2)
Traceback (most recent call last):
  ...
SyntaxError: Don't use two class initializers or
a class initializer together with a__metaclass__ hook

良い点として、継承された _metaclass_ フックの問題とカスタムのメタクラスの問題を、すべて処理することができます。

リスト 11. 継承されたメタクラスの適切な機能強化
>>> class B: # a base class with a custom metaclass
...     class __metaclass__(type):
...         pass
>>> class C(B): # class with both custom metaclass AND class initializer
...     Enhance(a=1)
>>> C.a
1
>>> type(C)
<class '_main.__metaclass__'>

クラス初期化子はベースの B によって継承されたメタクラスである C のメタクラスを妨害せず、この C のメタクラスはクラス初期化子を妨害しないため、クラス初期化子は適切に作業を行います。もしこうせずに、ベース・クラスで Enhance を直接呼び出そうとすると、問題が発生したはずです。

1 つにまとめる

この機構がすべて定義されると、クラス初期化子のカスタマイズは非常に単純になり、そしてスマートになります。これは次のような簡単なものになります。

リスト 12. 最も単純な形式の機能強化
class _XO_plus(gnosis.xml.objectify._XO_):
    Enhance(XPath=XPath, addChild=addChild, is_root=is_root)
gnosis.xml.objectify._XO_ = _XO_plus

この例は相変わらず「注入」を使っていますが、一般的な場合には注入を使うほどのことはありません。つまり、ここでは機能強化されたクラスをモジュールの名前空間の特定の名前の中に戻しています。これは、この特定のモジュールでは必要ですが、たいていの場合は必要ありません。いずれにせよ、Enhance() の引数は上記のようにコードで固定する必要はなく、Enhance(**feature_set) も同じように使うことができ、完全に動的なものにすることもできます。

もう 1 点覚えておくとよいのは、この Enhance() 関数では上記で説明した単純なこと以上のことを行えるということです。修飾子によって、もっと高度な機能を持った関数にすることができます。例えば、下記はクラスに「レコード」を追加します。

リスト 13. クラスの機能強化のバリエーション
@classinitializer
def def_properties(cls, schema):
    """
    Add properties to cls, according to the schema, which is a list
    of pairs (fieldname, typecast). A typecast is a
    callable converting the field value into a Python type.
    The initializer saves the attribute names in a list cls.fields
    and the typecasts in a list cls.types. Instances of cls are expected
    to have private attributes with names determined by the field names.
    """
    cls.fields = []
    cls.types = []
    for name, typecast in schema:
        if hasattr(cls, name): # avoid accidental overriding
            raise AttributeError('You are overriding %s!' % name)
        def getter(self, name=name):
            return getattr(self, '_' + name)
        def setter(self, value, name=name, typecast=typecast):
            setattr(self, '_' + name, typecast(value))
        setattr(cls, name, property(getter, setter))
        cls.fields.append(name)
        cls.types.append(typecast)

(a) 何を機能強化するのか、(b) この魔法がどのように動作するのか、(c) 基本的なクラスそのものは何をするのか、という、それぞれ異なる関心事項は独立に保たれています。

リスト 14. レコードのクラスのカスタマイズ
>>> class Article(object):
...    # fields and types are dynamically set by the initializer
...    def_properties([('title', str), ('author', str), ('date', date)])
...    def __init__(self, values): # add error checking if you like
...        for field, cast, value in zip(self.fields, self.types, values):
...            setattr(self, '_' + field, cast(value))

>>> a=Article(['How to use class initializers', 'M. Simionato', '2006-07-10'])
>>> a.title
'How to use class initializers'
>>> a.author
'M. Simionato'
>>> a.date
datetime.date(2006, 7, 10)

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


関連トピック


コメント

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux, Open source
ArticleID=264308
ArticleTitle=Python でのメタクラス・プログラミング、第 3 回
publish-date=09252007