クラス内のフィールドが定義された順番を保持する(Python・メタクラス)

はじめに

普通のPythonのclassはフィールドが定義された順番を持たないけど、それが知りたい場合メタクラスで実現できると @crohaco 先生が教えてくれた。

メタクラスは基本的に使わない方がいいけど、こういう特殊な事をするにはメタクラスによってクラスを拡張する方法しか無いらしい。 せっかく教えてもらったので忘れないようにメモ(間違い等あればコメント下さい)。

2016-01-07 追記

@crohaco 先生がメタクラスについて記事書いてくれた

[Python] メタクラスをたおした - くろのて

2016-09-30 追記

tell-k先生のPyCon JPの発表がとても分かりやすかった。この記事よむよりこの動画見ましょう。

https://www.youtube.com/watch?v=807SjfhRuUY

2016-12-23 追記

Python 3.6では、今回のことがもっと簡単に行えそう。

https://docs.python.org/3.6/whatsnew/3.6.html#pep-487-simpler-customization-of-class-creation

2020-08-29 追記

露木さんのPyCon JP 2020のセッションもとてもわかりやすかったです。この記事で扱ったフィールドの定義順の保持の仕方だけでなく、メタクラスの用途を網羅的に扱ってくれています。

speakerdeck.com

このセッションで言われているように、Python 3.6からdictが順序を保持する実装に変わり、3.7から仕様化されたためこの記事で扱った内容はもうPython2.7とPython 3.5が無くなりつつある今となっては必要ありません。

メタクラス

Pythonでは型を調べるのにtypeを使ったりしてますが、typePythonの全てのクラスを生成するのに使われているものらしいです(参考: oop - What are metaclasses in Python? - Stack Overflow)。

ドキュメントによると、type の引数は class type(name, bases, dict)type を使って以下のようにクラスを動的に生成することができます。

# type(name, bases, attrs) で動的に生成。型は (str, tuple, dict)。
>>> type("Hoge", (), {})
<class '__main__.Hoge'>

# 静的に生成するならこうかな
>>> class Hoge(type):
...     def __init__(self, name, bases, attrs):
...         super(Hoge, self).__init__(name, bases, attrs)
...
>>> Hoge
<class '__main__.Hoge'>

colanderのソースコードを読む

colanderっていうvalidationライブラリがある。 colanderの使用例は以下(参考: [Python] colander備忘録 - くろのて)。

>>> import colander
>>> class User(colander.MappingSchema):
...     id   = colander.SchemaNode(colander.Int(), missing=None)
...     name = colander.SchemaNode(colander.Str())
...     age  = colander.SchemaNode(colander.Int())

colanderは id, name, age と定義された順番を把握しているとのこと。 内部的にはメタクラスを使ってそうなので、colanderのソースコード を見ながら、どのように id, name, age の定義された順番が保持されているのか見てみる

SchemaNode クラスと MappingSchema クラス

上の例で使われていた2つのクラスを探した。

# https://github.com/Pylons/colander/blob/master/colander/__init__.py#L2181
SchemaNode = _SchemaMeta(
    'SchemaNode',
    (_SchemaNode,),
    {'__doc__': _SchemaNode.__doc__}
    )

class Schema(SchemaNode):
    schema_type = Mapping

MappingSchema = Schema

_SchemaMeta というメタクラスを使って _SchemaNode をベースとした SchemaNode という名前のクラスを生成しているらしい。 この2つのクラスのコードを読む必要がありそう。

_SchemaNode クラス

長いので何行か抽出しました。

# https://github.com/Pylons/colander/blob/master/colander/__init__.py#L1781
class _SchemaNode(object):
    _counter = itertools.count()

    def __new__(cls, *args, **kw):
        node = object.__new__(cls)
        node._order = next(cls._counter)
        node.children = []
        _add_node_children(node, cls.__all_schema_nodes__)
        return node

# https://github.com/Pylons/colander/blob/master/colander/__init__.py#L1781
def _add_node_children(node, children):
    ...

オブジェクトを生成する前に呼ばれる __new__ のところで _counter という属性に itertools.count() を使って呼び出された順番を保持している。itertools.count は以下のように動作する。

>>> counter = itertools.count()
>>> next(counter)
0
>>> next(counter)
1

オブジェクトが生成された順番を記憶し、object.__new__(cls) で生成したオブジェクトの _order フィールドに格納しています。

_SchemaMeta クラス

# https://github.com/Pylons/colander/blob/master/colander/__init__.py#L2158
class _SchemaMeta(type):
    def __init__(cls, name, bases, clsattrs):
        nodes = []

        for name, value in clsattrs.items():
            if isinstance(value, _SchemaNode):
                delattr(cls, name)
                if not value.name:
                    value.name = name
                if value.raw_title is _marker:
                    value.title = name.replace('_', ' ').title()
                nodes.append((value._order, value))

        nodes.sort()
        cls.__class_schema_nodes__ = [ n[1] for n in nodes ]
        # Combine all attrs from this class and its _SchemaNode superclasses.
        cls.__all_schema_nodes__ = []
        for c in reversed(cls.__mro__):
            csn = getattr(c, '__class_schema_nodes__', [])
            cls.__all_schema_nodes__.extend(csn)

clsattrs.items() で定義された属性の名前と変数を取り出します。上の例ならclsattrsは以下のようになるはず。

{
  id: colander.SchemaNode(colander.Int(), missing=None),
  name: colander.SchemaNode(colander.Str()),
  age: colander.SchemaNode(colander.Int())
}

Pythonの辞書型は順序を持たないですが、valueに入っている_SchemaNodeにはorderフィールドは先程確認したように定義された順番を保持しています。nordesリストに(value.order, value)をappendしてからsortすることでnodesリストには、nodes.sort()によってソートされた_SchemaNodeクラスのオブジェクトが格納されるようです。

自分でも簡単な例を書いてみる

gistに張りました

Pythonのメタクラスを使ってフィールドが定義された順番を保持する

おわりに

とりあえず、どうやってフィールドの定義された順番を保持しているのか分かった気がする。 ふぅ...疲れた pandas_validatorでも必要そうなら実装します。