cover_effective_python

【 Effective Python, 2nd Edition 】インスタンスを関数として利用可能にする __call__ 特殊関数を含んだクラスを定義してフック ( hook ) として利用することで、既存の API の機能拡張を計ろう! 投稿一覧へ戻る

Tags: Python , Effective , closure , defaultdict , hook , __call__

Published 2020年7月7日20:57 by T.Tsuyoshi

数多くの Python 組み込み API では、引数として関数を受け取ることでその「振る舞い」をカスタマイズできるようになっています。


このようなことをフック ( hook ) と呼んでいますが、定義には若干曖昧なところが見受けられ、「API の振る舞いを渡す関数によってカスタマイズする」行為そのものを指す場合や、API の振る舞いをカスタマイズするために渡す「関数自身」を指す場合等があるようです。
このことを踏まえて、この記事で「フック」という言葉が出てきた場合は、読まれている方々それぞれが解釈しやすい意味合いで捉えていただければ、と思います。


さて、本題に戻りまして、API 側からみればこのフックというのは、実行中にこちら側のコードを呼び出す手段、であるわけです。


分かりやすいところでは、list タイプの sort メソッドが、要素をどのように並べ替えるのか、ということを決定するために key オプションパラメータとして受け取ります。


名前のリストを長さ順に並べ替えるために、組み込みの len() を渡す例です。


names = ["Sumomo", "Nana", "Sakura", "Kaede"]
names.sort(key=len)

print(names)
# ['Nana', 'Kaede', 'Sumomo', 'Sakura']



多くのプログラミング言語ではフックを実現するために抽象クラス ( abstract class ) を定義しますが、Python ではただ単に、引数を受け取り結果を返す、状態を保持しない ( stateless な) 普通の関数で大丈夫です。


これは、Python の関数が first-class 関数だからです。
つまり、他の値と同じように関数を渡したり参照したりできる、からですね。


ここで defaultdict クラスの「振る舞い」をカスタマイズしてみましょう。
defaultdict は、与えられたキーが辞書に存在しない場合、そのキーのデフォルト値として利用する値を返す関数を受け取ることができます。


キーが存在しなかった場合に「無いキーが指定されたよ!」というログを出力し、かつ、デフォルト値として 0 を返すフックを定義してみます。


def log_missing():
print("キーが追加されました!")
return 0


from collections import defaultdict


current = {'green': 12, 'blue': 3}
increments = [
('red', 5),
('blue', 17),
('orange', 9),
]

d = defaultdict(log_missing, **current)

print(f"Before: {d}")
# Before: defaultdict(, {'green': 12, 'blue': 3})

for k, v in increments:
d[k] += v

print(f"After: {d}")
# キーが追加されました! -> キーとして 'red' が追加されたとき
# キーが追加されました! -> キーとして 'orange' が追加されたとき
# After: defaultdict(, {'green': 12, 'blue': 20, 'red': 5, 'orange': 9})



このようにちょっとした関数を渡すだけで新たな機能を簡単に付け加えることが可能ですし、開発段階でテストをしなければいけないのはこの「ちょっとした」関数の部分だけ、という気軽さです。


次も defaultdict に初期値を提供するフックを作成しますが、新規に加えられたキーの数を内部で保持する機能を持たせます。
実装方法の1つとして、状態を維持しておけるクロージャー関数 ( stateful closure function ) を利用してみましょう。


from typing import List, Tuple


def increment_with_report(current: dict, increments: List[Tuple[str, int]]):
added_count = 0

def missing():
nonlocal added_count
added_count += 1
return 0

result = defaultdict(missing, **current)
for k, v in increments:
result[k] += v

return result, added_count


result, added_count = increment_with_report(current, increments)

print(result)
print(f"追加したキーの合計数: {added_count}")
# defaultdict(.missing at 0x0000000002719D30>, {'green': 12, 'blue': 20, 'red': 5, 'orange': 9})
# 追加したキーの合計数: 2



期待通りの結果です。
しかも、defaultdict 自体は、このフックが状態を保持している、なんてことは一切頓着せずに自分の仕事をこなしているだけです。
さらに、このような形のインターフェースを提供することで、クロージャーによって内部の状態は秘匿したまま将来的に機能拡張が容易、という利点を享受できます。


ただ、残念ながら、クロージャーを利用したこのような実装は若干読解性に劣ります。
そこで、保持する値を属性として持つ小さいクラスを定義する、という実装方法を採用してみましょう。


class CountMissing:
def __init__(self):
self.added_count = 0

def missing(self):
self.added_count += 1
return 0


counter = CountMissing()
result = defaultdict(counter.missing, **current)
for k, v in increments:
result[k] += v

print(result)
print(f"追加したキーの合計数: {counter.added_count}")
# defaultdict(>, {'green': 12, 'blue': 20, 'red': 5, 'orange': 9})
# 追加したキーの合計数: 2



ここでも「Python の関数は first-class function である」という利点を活用して、クラスメソッドをそのまま「デフォルト値提供フック」として渡しています。


クロージャーを利用した実装と比較して、コードがシンプルで追いかけやすいのではないでしょうか?


しかし残念ながらここでも不満が出てきます。
このクラスだけを見たときに「何をするためのクラスなのか?」ということがほとんど分からない、ということです。


実際に defaultdict のフックとして missing() クラスメソッドが利用されているのを見て初めて、
「あー、デフォルト値提供フックとして利用されるクラスメソッドで、状態を保存しておくためのクラスなんだ」と納得できます。


このような状況を考慮して、Python には __call__ 特殊関数が用意されています。


__call__ 特殊関数は、クラスインスタンスが関数として呼び出された場合に実行されます
さらに __call__ 特殊関数を実装しているクラスのインスタンスを callable 組み込み関数に渡すと True を返します。つまり、通常の関数ですよ、と判断されるんです。


class BetterCountMissing:
def __init__(self):
self.added_count = 0

def __call__(self, *args, **kwargs):
self.added_count += 1
return 0


counter = BetterCountMissing()

assert counter() == 0 # クラスインスタンスに () をつけて普通の関数のように実行していることに注目です!
assert callable(counter)
# エラーは出力されません。立派に呼び出し可能な「関数」なんです。

counter = BetterCountMissing()
result = defaultdict(counter, **current)
for k, v in increments:
result[k] += v

print(result)
print(f"追加したキーの合計数: {counter.added_count}")
# defaultdict(<__main__.BetterCountMissing object at 0x00000000026AD220>, {'green': 12, 'blue': 20, 'red': 5, 'orange': 9})
# 追加したキーの合計数: 2



この実装によって、「 BetterCountMissing クラスインスタンスは、API フックのような引数として関数が渡される場所で使われる可能性があるんだな。」ということが明白になります。


さらにその推測から、「じゃあ、このクラスは、何かの状態を保存しておくクロージャーのような役割があるんだな」という判断を導き出しやすくなります。


小さなことかもしれませんが、チームとして開発を進めている場合、自分のコードを見た誰かがその意図を明確に読み取れるように努力をしておくことは非常に大切なことのはずです。


まとめ:

1: ある処理を既存の API 等にフックとして機能する関数を渡すことで実現ができれば、一からクラスを定義するよりも開発工程がシンプルになりますし、慣れ親しんだインターフェースを使い回すことができます。

2: Python における関数は first-class function なので、他の型と同様に受け渡し等が可能です。

3: クラス定義に __call__ 特殊関数を含めることで、そのクラスのインスタンスを通常の関数のように呼び出すことが可能になります。

4: ある属性の状態を監視し続ける必要がある場合、stateful なクロージャー関数を定義するのではなく、__call__ 特殊関数を実装したクラスを定義できないか検討しましょう。

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)