この記事は、ニフティグループ Advent Calendar 2024 9日目の記事です。
こんにちは。ニフティ株式会社の statiolake です。
私は生粋の Neovimmer なのですが、これはなぜかというと自分の開発環境を自分好みにカスタマイズすることが大好きだからです。
複雑で覚えていられない処理を自動化して隠蔽し、シンプルで明快な操作で実行できるようにする。エンジニアリングの粋と言ってもよいでしょう。この営みを最も高速にイテレートできることこそ、自分の開発環境を整えることなのです。
いきなり脱線して恐縮なのですが、先日開催された VimConf 2024 のキーノートで、Neovim プラグイン開発者でストリーマーでもある TJ DeVries 氏もそのような講演をされていました。
こちら、VimConf ながら Vim 固有の話はほぼありませんので、どなたでも楽しんでいただけると思います。語り口の軽快さはさすがストリーマーといったところ。聞いていて飽きることがありませんでした。
つい最近 YouTube に動画が上がったようなので、よろしくお願いします。
何の話だ。
ええ、そういうこともあって、私はメタプログラミングも大好きです。
複雑で書いていられないボイラープレートを隠蔽し、簡単で直感的なコードで呼び出せるようになると、なんかいいですよね。動的型付けで様々なものがオブジェクトとして扱える Python は、コードを簡単にかけるようにすることにかけては超一流といって良いでしょう。
今回はそういったメタプログラミングで使えそうなテクニックの一つとして、メソッドのフリをするオブジェクトを作る方法を学んだので、共有したいと思います。
これを使ったら何ができるかは、またいずれ別の記事にするかもしれません。
※ なお、これは言語仕様などに立ち入った厳密な話ではないことを先んじてお断りしておきます。そこまで詳しいわけではないです…
関数のフリをするオブジェクトの作り方
いきなりメソッドに入る前に、普通の関数について考えてみましょう。
1 2 3 4 5 |
def add(a, b): return a + b print(add(1, 2)) # => 3 |
実は、関数のように使えるオブジェクトを作るのは結構簡単です。
確かに関数には add(1, 2)
という特別な文法を与えられてはいます。しかし、それは実は add.__call__(1, 2)
と同じ意味になっています。なので単に __call__
というマジックメソッドを定義してあげれば呼び出し構文が使えるようになります。
1 2 3 4 5 6 7 8 9 |
class AddFunction: def __call__(self, a, b): return a + b add = AddFunction() print(add(1, 2)) # => 3 # ^^^ AddFunction クラスのインスタンスであり、関数ではないが、呼び出せる |
完璧です。
メソッドはどうして難しいのか?
これができるなら、別にメソッドだって同じような気がしますよね。
1 2 3 4 5 6 7 |
class Calculator: def add(self, a, b): return a + b c = Calculator() print(c.add(1, 2)) # => 3 |
実際、これを先ほどのように __call__()
を持ったオブジェクトで置き換えても一見、使えているように見えます。
1 2 3 4 5 6 7 8 9 10 11 12 |
class AddFunction: def __call__(self, a, b): return a + b class Calculator: # def add(self, a, b): # return a + b add = AddFunction() c = Calculator() print(c.add(1, 2)) # => 3 |
しかし、これには致命的な欠点があります。それは add()
がインスタンスに結びついていないことです。
メソッドなのに self
に結びついていない
例えば電卓の M+
ボタンのように、Calculator クラス内に値を保持するメソッドを作りたいとします。しかし、今の形だとそれが実装できません。AddFunction
の __call__
にある self
はあくまでも AddFunction
であって Calculator
ではないからです。
そもそも振る舞いが違う
Python のメソッドは少し不思議な性質を持っています。 add()
がメソッドの場合、
1 2 3 4 5 6 7 8 |
class Calculator: def add(self, a, b): return a + b c = Calculator() print(Calculator.add(c, 1, 2)) # => 3 print(c.add(1, 2)) # => 3 |
このように インスタンス.メソッド名(引数...)
で呼び出したときは自動で self
にオブジェクトが入り、 クラス名.メソッド名(インスタンス, 引数…)
で呼び出したときには self
にあたるものも自分で指定することができます。
ところが、今作ったオブジェクトを使ってみると…
1 2 3 4 5 6 7 8 |
class Calculator: add = AddFunction() c = Calculator() print(Calculator.add(c, 1, 2)) # ^ TypeError: AddFunction.__call__() takes 3 positional arguments but 4 were given print(c.add(1, 2)) # => 3 |
なんと、3 つの引数で呼び出すところが例外になってしまいました。確かに __call__()
には 2 つの引数しか入れられないので、エラーになるのは理解できます。
しかし、ならばなぜメソッドはエラーにならないのでしょうか?
2 つの問題というふうにお伝えしましたが、本質的には 1 つだけです。
結論から言えば、魔法がかかるのはメソッド呼び出し時です。メソッド add
の本当の姿は、 クラス名.メソッド名
で呼び出されるのと同じ、 self
を含めた 3 引数の関数と考えてください。
つまり、Python が インスタンス.メソッド名(引数...)
のときだけ勝手に第一引数にインスタンスを結びつけてくれている、よって 2 引数の関数のように使えている、ということになります。
普通のオブジェクトがメソッドのふりをするためには、この仕組みを自分で実装しなければいけません。そしてそれを可能にする仕組みがデスクリプタです。
デスクリプタ
ここから少し込み入った話になりますが、Python にはデスクリプタというものが存在します。標準にある @property
デコレータの裏側で使われている機能でもあります。
デスクリプタは、メソッドやフィールドを取得しようとしたときに割り込んで好き勝手なオブジェクトを返すことができる機能です。まあ他にもできることはあるんですが、ここでは割愛します。
たとえば、以下の RandomValueDescriptor
を使うと参照するたびに値が変わっている不思議なフィールドを作ることができます。
1 2 3 4 5 6 7 8 9 10 11 |
class RandomValueDescriptor: def __get__(self, instance, owner): return random.randint(1, 100) class Calculator: value = RandomValueDescriptor() c = Calculator() print(c.value) # => 34 (例) print(c.value) # => 84 (例) |
普通のフィールドの場合、 value
に代入してもいないのに値が変わっているということはありません。
しかしデスクリプタを使うと、 value
にアクセスしたときに毎回 __get__()
関数を通してくれるようになります。そこではあらゆるコードが実行できるため、このように、ランダムな値を返すこともできるわけです。
そして、 __get__
にはいくつかの引数があります。
ここで着目したいのは instance
です。ここには インスタンス.フィールド名
で呼ばれた時の インスタンス
が与えられています。さらに、クラス名を経由して呼ばれたときは None が入っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class InstanceDetectorDescriptor: def __get__(self, instance, owner): if instance is None: return "without instance" return "with instance" class Calculator: instance_detected = InstanceDetectorDescriptor() c = Calculator() print(Calculator.instance_detected) # => without instance print(c.instance_detected) # => with instance |
なるほど、 instance
を見てインスタンスがあるかないかを取ることができる。…もうおわかりですかね?
デスクリプタでメソッドのフリをする
ここまでの考察を踏まえて、以下のうち memory_add()
メソッドをオブジェクトに置き換えてみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Calculator: def __init__(self): self.memory = 0 def memory_add(self, delta): self.memory += delta c = Calculator() Calculator.memory_add(c, 1) # memory: 0 + 1 = 1 print(c.memory) # => 1 c.memory_add(1) # memory: 1 + 1 = 2 print(c.memory) # => 2 |
この中の、引数の数がインスタンスかどうかで変わってくる部分が難しいのでした。しかし今の我々には、デスクリプタを使って以下のことが可能です。
- 参照された瞬間を
__get__()
で割り込むことができる __get__()
の中でインスタンスの有無がとれ、好きなものを返せる
であれば、こうでしょう。
- インスタンスが与えられなかったときは、普通の関数を返す。
- インスタンスが与えられたときは、それを第一引数に紐づけ、一つ引数が減った関数を返す。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class MemoryAddMethodDescriptor: def __get__(self, instance, owner): def inner(self, delta): self.memory += delta if instance is None: # インスタンスがない場合 - クラス名.メソッド名(インスタンス, 引数...) で呼び出された # 普通に関数をそのまま返す return inner # インスタンスがある場合 - インスタンス.メソッド名(引数...) で呼び出された # 第一引数にインスタンスを与え、引数を一つ減らした関数を作って返す # ここでは手っ取り早く lambda 式を使っている return lambda delta: inner(instance, delta) class Calculator: def __init__(self): self.memory = 0 memory_add = MemoryAddMethodDescriptor() |
いけるはずです。さて、実行してみると…
1 2 3 4 5 6 |
c = Calculator() Calculator.memory_add(c, 1) # memory: 0 + 1 = 1 print(c.memory) # => 1 c.memory_add(1) # memory: 1 + 1 = 2 print(c.memory) # => 2 |
成功です!しかも、きちんとインスタンスにもアクセスできていますね。
ここでは __get__()
が返すものをただの関数にしているので、あまり便利さが実感できないかもしれません。
しかし、ここで先程の「関数のふりをするオブジェクト」をさし挟むことができてしまうとなれば、どうでしょうか? あれも基本的には普通のクラスの普通のオブジェクトですから、その中にメソッドを追加することもできますし、そうすると… いろいろなことができてしまいます。
長くなってしまうので、今日はここまでですね。
終わりに
今回はメソッドのふりをするオブジェクトを作ってみました。もちろん、これは単体であれば普通にメソッドを定義すれば良く、大した意味を持ちません。しかし、オブジェクトには好き勝手に別のフィールドやメソッドを増やすことができ、自由度が高まります。
たとえば、私はこの機能を単体テストのためのモッククラスに応用してみています。
1 2 3 4 |
user = MockUserRepository() with user.get_all.patch([User(id=1, name="Alice")]): assert user.get_all() == [User(id=1, name="Alice")] |
この例では、 user.get_all
の戻り値を一時的に上書きする機能を提供しています。 get_all
はメソッドのように呼び出せますが、そこに patch()
というメソッドがあるのは、今回の方法を使って「メソッドのふりをしたオブジェクト」に差し替えているからです。具体的には、また別の記事を書くかもしれません。
そんなわけで、ちょっとした小道具でした。長らくお付き合いいただきありがとうございます。みなさんも何かご存知の小道具があれば、ぜひ教えてください!あと、TJ DeVries さんの講演もぜひ見てみてください。
明日は、dev-shimada さんの「社内Tapリポジトリを作り自作ツールをHomebrewで配布する」です。 お楽しみに!