Blog

メソッドのフリをするオブジェクトの作り方

この記事は、ニフティグループ Advent Calendar 2024 9日目の記事です。

こんにちは。ニフティ株式会社の statiolake です。

私は生粋の Neovimmer なのですが、これはなぜかというと自分の開発環境を自分好みにカスタマイズすることが大好きだからです。
複雑で覚えていられない処理を自動化して隠蔽し、シンプルで明快な操作で実行できるようにする。エンジニアリングの粋と言ってもよいでしょう。この営みを最も高速にイテレートできることこそ、自分の開発環境を整えることなのです。

いきなり脱線して恐縮なのですが、先日開催された VimConf 2024 のキーノートで、Neovim プラグイン開発者でストリーマーでもある TJ DeVries 氏もそのような講演をされていました。
こちら、VimConf ながら Vim 固有の話はほぼありませんので、どなたでも楽しんでいただけると思います。語り口の軽快さはさすがストリーマーといったところ。聞いていて飽きることがありませんでした。
つい最近 YouTube に動画が上がったようなので、よろしくお願いします。

何の話だ。

ええ、そういうこともあって、私はメタプログラミングも大好きです。
複雑で書いていられないボイラープレートを隠蔽し、簡単で直感的なコードで呼び出せるようになると、なんかいいですよね。動的型付けで様々なものがオブジェクトとして扱える Python は、コードを簡単にかけるようにすることにかけては超一流といって良いでしょう。

今回はそういったメタプログラミングで使えそうなテクニックの一つとして、メソッドのフリをするオブジェクトを作る方法を学んだので、共有したいと思います。
これを使ったら何ができるかは、またいずれ別の記事にするかもしれません。

※ なお、これは言語仕様などに立ち入った厳密な話ではないことを先んじてお断りしておきます。そこまで詳しいわけではないです…

関数のフリをするオブジェクトの作り方

いきなりメソッドに入る前に、普通の関数について考えてみましょう。

実は、関数のように使えるオブジェクトを作るのは結構簡単です。
確かに関数には add(1, 2) という特別な文法を与えられてはいます。しかし、それは実は add.__call__(1, 2) と同じ意味になっています。なので単に __call__ というマジックメソッドを定義してあげれば呼び出し構文が使えるようになります。

完璧です。

メソッドはどうして難しいのか?

これができるなら、別にメソッドだって同じような気がしますよね。

実際、これを先ほどのように __call__() を持ったオブジェクトで置き換えても一見、使えているように見えます。

しかし、これには致命的な欠点があります。それは add() がインスタンスに結びついていないことです。

メソッドなのに self に結びついていない

例えば電卓の M+ ボタンのように、Calculator クラス内に値を保持するメソッドを作りたいとします。しかし、今の形だとそれが実装できません。
AddFunction__call__ にある self はあくまでも AddFunction であって Calculator ではないからです。

そもそも振る舞いが違う

Python のメソッドは少し不思議な性質を持っています。 add() がメソッドの場合、

このように インスタンス.メソッド名(引数...) で呼び出したときは自動で self にオブジェクトが入り、 クラス名.メソッド名(インスタンス, 引数…) で呼び出したときには self にあたるものも自分で指定することができます。

ところが、今作ったオブジェクトを使ってみると…

なんと、3 つの引数で呼び出すところが例外になってしまいました。確かに __call__() には 2 つの引数しか入れられないので、エラーになるのは理解できます。

しかし、ならばなぜメソッドはエラーにならないのでしょうか?


2 つの問題というふうにお伝えしましたが、本質的には 1 つだけです。

結論から言えば、魔法がかかるのはメソッド呼び出し時です。メソッド add の本当の姿は、 クラス名.メソッド名で呼び出されるのと同じ、 self を含めた 3 引数の関数と考えてください。
つまり、Python が インスタンス.メソッド名(引数...) のときだけ勝手に第一引数にインスタンスを結びつけてくれている、よって 2 引数の関数のように使えている、ということになります。

普通のオブジェクトがメソッドのふりをするためには、この仕組みを自分で実装しなければいけません。そしてそれを可能にする仕組みがデスクリプタです。

デスクリプタ

ここから少し込み入った話になりますが、Python にはデスクリプタというものが存在します。標準にある @property デコレータの裏側で使われている機能でもあります。

デスクリプタは、メソッドやフィールドを取得しようとしたときに割り込んで好き勝手なオブジェクトを返すことができる機能です。まあ他にもできることはあるんですが、ここでは割愛します。

たとえば、以下の RandomValueDescriptor を使うと参照するたびに値が変わっている不思議なフィールドを作ることができます。

普通のフィールドの場合、 value に代入してもいないのに値が変わっているということはありません。

しかしデスクリプタを使うと、 value にアクセスしたときに毎回 __get__() 関数を通してくれるようになります。そこではあらゆるコードが実行できるため、このように、ランダムな値を返すこともできるわけです。

そして、 __get__ にはいくつかの引数があります。
ここで着目したいのは instance です。ここには インスタンス.フィールド名 で呼ばれた時の インスタンス が与えられています。さらに、クラス名を経由して呼ばれたときは None が入っています。

なるほど、 instance を見てインスタンスがあるかないかを取ることができる。…もうおわかりですかね?

デスクリプタでメソッドのフリをする

ここまでの考察を踏まえて、以下のうち memory_add() メソッドをオブジェクトに置き換えてみましょう。

この中の、引数の数がインスタンスかどうかで変わってくる部分が難しいのでした。しかし今の我々には、デスクリプタを使って以下のことが可能です。

  1. 参照された瞬間を __get__() で割り込むことができる
  2. __get__() の中でインスタンスの有無がとれ、好きなものを返せる

であれば、こうでしょう。

  • インスタンスが与えられなかったときは、普通の関数を返す。
  • インスタンスが与えられたときは、それを第一引数に紐づけ、一つ引数が減った関数を返す。

いけるはずです。さて、実行してみると…

成功です!しかも、きちんとインスタンスにもアクセスできていますね。

ここでは __get__() が返すものをただの関数にしているので、あまり便利さが実感できないかもしれません。
しかし、ここで先程の「関数のふりをするオブジェクト」をさし挟むことができてしまうとなれば、どうでしょうか? あれも基本的には普通のクラスの普通のオブジェクトですから、その中にメソッドを追加することもできますし、そうすると… いろいろなことができてしまいます。

長くなってしまうので、今日はここまでですね。

終わりに

今回はメソッドのふりをするオブジェクトを作ってみました。もちろん、これは単体であれば普通にメソッドを定義すれば良く、大した意味を持ちません。しかし、オブジェクトには好き勝手に別のフィールドやメソッドを増やすことができ、自由度が高まります。

たとえば、私はこの機能を単体テストのためのモッククラスに応用してみています。

この例では、 user.get_all の戻り値を一時的に上書きする機能を提供しています。 get_all はメソッドのように呼び出せますが、そこに patch() というメソッドがあるのは、今回の方法を使って「メソッドのふりをしたオブジェクト」に差し替えているからです。具体的には、また別の記事を書くかもしれません。

そんなわけで、ちょっとした小道具でした。長らくお付き合いいただきありがとうございます。みなさんも何かご存知の小道具があれば、ぜひ教えてください!あと、TJ DeVries さんの講演もぜひ見てみてください。

明日は、dev-shimada さんの「社内Tapリポジトリを作り自作ツールをHomebrewで配布する」です。 お楽しみに!

ニフティでは、
さまざまなプロダクトへ挑戦する
エンジニアを絶賛募集中です!
ご興味のある方は以下の採用サイトより
お気軽にご連絡ください!

ニフティに興味をお持ちの方は
キャリア登録をぜひお願いいたします!

connpassでニフティグループに
参加いただくと
イベントの
お知らせが届きます!