読者です 読者をやめる 読者になる 読者になる

この何日か特定のメソッドのセットを必ず呼び出すことについて悩んでいた。

python srcgen

この何日か特定のメソッドのセットを必ず呼び出すことについて悩んでいた。

この何日か特定のメソッドのセットを必ず呼び出すことについて悩んでいた。 ある処理が複雑になった時に、ちいさな処理に分けることがある。そしてユニットテストでは分けた範囲でテストをする。 もちろんそれらを結合する関数がどこかにあり、それは結合テストで呼ばれるわけではあるが。 時々呼び忘れであっても通るテストなどが有る(結合テストで全条件を網羅しなければあり得る。また、全条件を結合テストで網羅するのならばユニットテストを書く意味はあまりなくなる)。このような場合にある特定のメソッドのセットを必ず呼ぶということを指定するプロトコルのようなものが定義できないかということを考えていた。もちろん、以下のようなクラスデコレータを定義するのは却下。

class A(object):
    @mustcall("f","g","h")
    def execute(self, *args,**kwargs):
        pass

これは絶対に書き忘れる。f,g,hの他に新しいメソッドsを定義した時にこのmustcallのなかに"s"を追加するのと、executeの中でs()を呼ぶのとは本質的にはあまり変わらない。管理する対象が増える分手間が増えるだけ。

builder を使って呼ぶべき必要があるメソッドを指定する方法について考えていた

最初はbuilder patternについて考えていた。builder patternは何らかのファクトリーに渡す引数を状態を持つ形で設定可能にしたものらしいという理解。 例えば以下のようなPersonオブジェクトがあり。これらはname,ageという引数を取る。

from collections import namdetuple
Person = namedtuple("Person", "name age")  # factory

builderを通じてはadd_name,add_ageのようなメソッドを通じて値を設定する。

素朴なロジックの無いbuilderは以下の様になる。

class PersonBuilder(object):
    def __init__(self, name=None, age=None):
        self.name = name
        self.age = age

    def add_name(self, name):
        self.name = name

    def add_age(self, age):
        self.age = age

    def build(self):
        return Person(name=self.name, age=self.age)

以下の様にして使える。

b = PersonBuilder(name="foo")
b.add_age(20)
b.build()  # => Person(name='foo', age=20)

もちろん動作はするが気をつけることが多い。

  • add_age,add_nameが呼ばれていなかった場合にPersonにはNoneが渡される
  • Personの引数name,ageがともにNoneを許さなかった場合に意図しないオブジェクトが作られてしまう。

呼ばれるメソッドを制限する方法について考える

buildをしたタイミングでadd_name,add_ageが呼ばれているか確認することを考えてみる。雑に実装すると以下のようになる。

class PersonBuilder(object):
    def __init__(self, name=None, age=None):
        self.name = name
        self.age = age
        self.history = {"name": False, "age": False}

    def add_name(self, name):
        self.name = name
        self.history["name"] = True

    def add_age(self, age):
        self.age = age
        self.history["age"] = True

    def build(self):
        for k, is_called in self.history.items():
            if not is_called:
                raise Exception("not called: {}".format(k))
        return Person(name=self.name, age=self.age)

ユーザーのためにこのクラスの開発者が苦労しているというような感想を持つ。そもそも必要な動作を呼び忘れる人はチェックの定義も書き忘れる。そのため名前ベースでhistoryの指定をしているのはあまり好ましい状況ではない。また、呼んだ後にflagをTrueに変えている箇所でタイポしてしまった場合には、めんどくさくバカバカしいバグに悩まされることになる。

add_nameadd_ageが呼ばれることを確認できるメタクラスを作ろう。 以下の様にして使う。

class PersonBuilder(metaclass=MustCallMeta):
    def __init__(self, name=None, age=None):
        self.name = name
        self.age = age

    @must
    def add_name(self, name):
        self.name = name

    @must
    def add_age(self, age):
        self.age = age

    @check_constraint
    def build(self):
        return Person(name=self.name, age=self.age)

mustで指定したメソッドは必ず呼ばれることを期待する。名前ベースの指定ではない。新しい補助関数を定義する際に別の補助関数の定義からコピペして作り始めるということは十分に考えられるので、新たに設定したい値が増えた場合にその増えた設定の名前を登録するよりは、何らかのデコレーターで注釈を加えてあげる方が都合が良い。(もちろんつけ忘れは発生し得る)

check_constraintでデコレートされたメソッドが呼ばれる際に、mustで注釈を加えたメソッドが呼ばれたかを確認するチェックが行われる。 (呼ばれていないメソッドがあった場合には、ConstraintExceptionが発生する)

以下の様になる。

# PersonBuilder().build()
# __main__.ConstraintException: self.add_name() is not called

# pb = PersonBuilder()
# pb.add_name("foo")
# pb.build()
# __main__.ConstraintException: self.add_age() is not called

# pb = PersonBuilder()
# pb.add_age(20)
# pb.build()
# __main__.ConstraintException: self.add_name() is not called

pb = PersonBuilder()
pb.add_name("foo")
pb.add_age(20)
pb.build() # => Person(name='foo', age=20)

制限を省略する方法を考える

builderと言っても必ず指定したメソッドを通じてファクトリーに渡される引数を更新する必要はなさそうではある。 例えば、PersonBuilder(name="foo") で生成されたオブジェクトに対してはadd_name()は不要なはず。 以下の様に条件をつけられるようにした。

PersonBuilder(metaclass=MustCallMeta):
    def __init__(self, name=None, age=None):
        self.name = name
        self.age = age

    @must
    def add_name(self, name):
        self.name = name

    @add_name.skipif
    def skip_name(self):
        return self.name is not None

    @must
    def add_age(self, age):
        self.age = age

    @add_age.skipif
    def skip_age(self):
        return self.age is not None

    @check_constraint
    def build(self):
        return Person(name=self.name, age=self.age)

いよいよもって長大になってきた。このような単純なオブジェクトに対してbuilderを作るというのは大げさなような気もしている。 単純なものなら以下のような形でnamedtupleのように定義できても良い気がする。

PersonBuilder = make_builder("PersonBuilder", "name age", factoryname="Person")

namedtupleのようにある種の条件を決め打ちで、builderを生成する、make_bulderを作ってあげる。

from collections import namedtuple
Person = namedtuple("Person", "name age")
from srcgen.python import PythonModule


def as_python_code(fn):
    def wrapper(name, *args, **kwargs):
        m = PythonModule()
        fn(m, name, *args, **kwargs)
        logger.debug(m)
        # activate python code
        env = {}
        exec(str(m), globals(), env)
        return env[name]
    return wrapper


@as_python_code
def make_builder(m, name, attrs, factoryname, build="build", metaclass="MustCallMeta", python3=True):
    attrs = attrs.replace(",", " ").split()

    def define():
        with m.def_("__init__", "self", ", ".join("{k}=None".format(k=k) for k in attrs)):
            for k in attrs:
                m.stmt("self.{k} = {k}".format(k=k))

        for k in attrs:
            m.stmt("@must")
            with m.def_("add_{}".format(k), "self", k):
                m.stmt("self.{k} = {k}".format(k=k))

            m.stmt("@add_{}.skipif".format(k))
            with m.def_("skip_{}".format(k), "self"):
                m.return_("self.{} is not None".format(k))

        m.stmt("@check_constraint")
        with m.def_("build", "self"):
            m.return_("Person({})".format(", ".join("{k}=self.{k}".format(k=k) for k in attrs)))

    if python3:
        with m.class_(name, ("metaclass={}".format(metaclass))):
            define()
    else:
        with m.class_(name):
            m.stmt("__metaclass__ = {}".format(metaclass))
            define()
    return m

直接コードを生成するコードを書かずにメタプログラミングを使った抽象的なコードを書いた理由は何だろう?たぶん、このような単純なサンプルではmake_builderのようなマクロ的な何かでまとめられるような気がする。一方でこのマクロで生成されるbuilderの機能は実際のそれより幾分か限定的になっている。 具体的には以下のような制限がある。

  • skipの対象がself.valueがNone以外のもの限定になっている
  • 利用するfactoryについて条件分岐が存在しない(これはfactoryの定義の中で条件分岐すれば良いかもしれない)
  • 必ず必要なメソッドが呼ばれるという制約を満たすか確認する処理が走るのはbuild()メソッドが呼ばれた時のみ

恐らくbuilderが必要な場合は以下の2種類ある

  • オブジェクトの生成を柔軟にしたい
  • オブジェクトの生成に対して複雑なロジックを隠蔽したい。

前者に対応するためのbuilderと後者に対応するためのbuilderが存在する。ここでいきなりマクロ的なもので生成した時には前者のもののみに対する対応になってしまう。(冒頭の悩みについて考えると前者というよりは後者を求めている気がする。ということはマクロの話は完全に寄り道ということになるかもしれない。)

後者の場合には定義を直接書かなくてはいけないが、その時、作ったメタクラスが活きてくるような気がしている。 そして後者の場合は生成があまり柔軟である必要はなさそう。というよりは、__init__で渡される引数がそのままbuilderのfactoryで渡されるのではなく、builderに渡される引数を生成する各ロジックに対してのオプション引数のような形で使われそう。以下のような感じになる。

class ComplicatedLogic(metaclass=MustCallMeta):
    def __init__(self, today):
        self.today = today

    @must
    def step1(self, group_id):
        # do something
        self.group = <>

    @must
    def step2(self, obj, subject):
        # do something

    @must
    def step3(self, z, f):
        # do something
        self.f = <>

    @must
    def step4(self, y_id, a):
        # do something
        self.a = <>
        self.b = <>

    @must
    def step5(self, x_id):
        # do something
        self.x = <>
        self.y = <>

    @check_constraint
    def execute(self):
        return self.create_object(self.today, self.x, self.y, self.f, self.a, self.b)

そうなってくると何が何の前に呼ばれるかも定義できないとダメなのかもしれない。 (しかし、名前ベースの設定は設定のし忘れや不整合が起きやすくなるのであまりやりたいとは思わない)