仮想継承とsingledispatch

以前、emitjson というパッケージを公開した。使い方は簡単に こちらに 書いたが、要はfunctools.singledispatch() をちょっと使いやすくしたものにすぎない。

singledispatch() は、Python3の抽象基底クラスと組み合わせると、非常に面白い使い方ができる。emitjson を例にして、使い方を紹介してみたい。

emitjson

簡単に emitjson の使い方を說明しておこう

emitjson は、emitjson という名前だが、JSON を出力するためのモジュールではなく、いろいろなオブジェクトを json として出力可能なオブジェクトに変換するためのリポジトリを作成するモジュールだ。特にJSON専用ということもなく、たとえばCSVファイルを作成する場合などでも利用できる。

例として、datatime.date 型の日付と、PIL.Image.Image 型の画像データを変換するリポジトリを作成してみる。

import emitjson
import datetime
import base64
import PIL.Image

myrepo = emitjson.repository()  # emitjson リポジトリを作成

# date型をISO-8601形式の文字列に変換
@myrepo.register(datetime.date)
def conv_date(obj):
    return obj.isoformat()

# Image型をBASE64形式の文字列に変換
@myrepo.register(PIL.Image.Image)
def conv_date(obj):
    return base64.b64encode(obj.tobytes()).decode('ascii')

# 日付と画像の辞書を作成
src = {
    'date':datetime.date.today(),
    'img': PIL.Image.open('img.png')
}

# 辞書に格納した、dateオブジェクトとImageオブジェクトを変換する
converted = myrepo(src) 

仮想継承

上記の例でわかるように、emitjson は、オブジェクトの型を指定して、その変換用関数を登録する singledispatch 関数だ。変換関数は、指定した型そのものだけではなく、その派生型のオブジェクトにも適用される。

ここで、「派生型」とは、isinstance()issubsclass() で派生型と判定されるオブジェクトである、ということだ。singledispatch 関数は、型から対象の関数を取得するとき、単純に型から関数を取得するのではなく、オブジェクトの継承関係を調べ、もっとも「近い」型の関数を適用するようになっている。

ところで、 Pythonisinstance()issubsclass() が派生クラスと判定するのは、通常の継承関係の他に「仮想継承」がある。仮想継承は、直接の継承関係のない型同士であっても、派生クラスと判定されるように設定する機能だ。これを利用すれば、いろいろと面白い singledispatch の利用方法が考えられる。ちなみに、Pythonの仮想継承はC++言語の同じ用語の機能とはまったく無関係なので注意。

ABCMeta.register()

abcモジュールABCMeta メタクラスを利用すると、手軽にいろいろな型のグループを定義できる。例えば、次のような、2つのクラスがあった時、

class Spam:
    def __init__(self, n):
        self.spams = 'spam'*n
    def __str__(self):
        return self.spans

class Ham:
    def __init__(self, n):
        self.hams = 'ham'*n
    def __str__(self):
        return self.hams

どちらのクラスのインスタンスも、JSONに文字列として出力する場合には、次のように定義できる。

import abc

class ToStringTypes(metaclass=abc.ABCMeta):
    pass

# SpamとHamをToStringTypesの仮想サブクラスとして登録する
ToStringTypes.register(Spam)  
ToStringTypes.register(Ham)

# ToStringTypesの変換関数
@myrepo.register(ToStringTypes)
def toStringConv(obj):
    return str(obj)    # objを文字列化

myrepo(Spam())  # toStringConv(Spam()) が呼び出される

ToStringTypesabc.ABCMetaメタクラスとするクラスで、抽象基底クラスと呼ばれる。この「抽象基底クラス」も、C++言語の用語とは切り離して考えていただきたい。

ここでは、抽象基底クラスで利用可能な register() メソッドを使って、Spam クラスと Ham クラスを、ToStringTypes クラスの派生クラスとして登録する。

どちらのクラスも、親クラスとして ToStringTypes クラスを指定していないが、

isinstance(Spam(), ToStringTypes)`

や、

issubsclass(Ham, ToStringTypes)

は、どちらもTrue を返すようになる。

したがって、ToStringTypes オブジェクトの変換関数として toStringConv() を登録すると、Spam クラスと Ham クラスのインスタンスは、toStringConv() で文字列に変換されるようになる。

この方式では、Spam クラスと Ham クラスに一切手を入れずに、変換関数だけを定義できる。Pythonの標準オブジェクトや、サードパーティ製ライブラリのオブジェクトなどでも、ソースを修正せずに柔軟な変換関数を定義できるというのがポイントだ。

subclasshook()

ABCMeta.register() を使って明示的に仮想サブクラスを登録するのではなく、特定の条件のクラスをサブクラスとして扱うようにもできる。

class HasToDict(metaclass=abc.ABCMeta):
    @classmethod
    def __subclasshook__(cls, subclass):
        return hasattr(subclass, 'toDict')

この抽象基底クラス HasToDict は、__subclasshook__()メソッドを定義し、toDictという名前の属性をもつ型ならなんでも派生クラスとなるようにしている。HasToDict クラスを利用して、

class Spam:
    def toDict(self):
        return {'spam':100}

@myrepo.register(HasToDict)
def conv_to_dict(obj):
    return obj.toDict()

のように、toDict() メソッドをもつオブジェクトはすべて辞書に変換する、という変換関数を定義できる。