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