世界を動かす技術を、日本語で。

Pythonのパターンマッチングによる犯罪 (2022)

概要

  • Pythonの__subclasshook__ を使った ABCの拡張的な利用法 の紹介
  • パターンマッチングsubclasshook連携による型判定の柔軟性
  • 動的なABC生成複合判定 のテクニック
  • __subclasshook__のキャッシュ挙動副作用の実験
  • 実践利用のリスクダークマジック的な側面

Pythonの__subclasshook__による型判定の拡張

  • subclasshook は、 ABC(Abstract Base Class) が自分を サブクラスとみなす基準 を柔軟に定義可能
  • 対象クラスがABCを 継承していなくても条件次第でサブクラス扱い できる
  • 例:クラス名が回文ならTrueを返すPalindromicName ABC
    • Abba → True、Baba → Falseと判定

パターンマッチングとABCの連携

  • Python 3.10以降のパターンマッチング は、isinstance(obj, class)判定を内部で使用
  • __subclasshook__を活用することで、パターンマッチの判定基準を乗っ取る ことが可能
  • 例:NotIterable ABCを使い、__iter__を持たないオブジェクトのみをマッチさせる

フィールド存在判定の応用

  • パターンマッチングで「 特定のフィールドを持つ任意のオブジェクト」を判定可能
  • 例:distanceプロパティを持つものをDistanceMetric ABCで判定
    • Point2D, Point3Dなど、distance属性があればマッチ

パターンマッチングの柔軟性と制限

  • オブジェクトの分解(デストラクチャリング) は、マッチ後に行われる
  • ABC側で判定、オブジェクト側で分解 という分担が可能
  • 例:z座標の有無や値による細かい分岐も記述可能

動的ABC生成による合成判定

  • Not関数 で「特定クラスではないもの」を判定するABCを動的生成
  • And関数 で「複数クラス条件を満たすもの」も判定可能
    • 例:Iterableかつstrではないものだけをマッチさせる

__subclasshook__のキャッシュと副作用

  • __subclasshook__の戻り値は型ごとにキャッシュされる
  • 一度判定されると、以降は同じ結果が使われるため、 副作用を持たせても1回しか動作しない
  • 例:最初の型だけTrueを返し、以降はFalseにするOneWay ABC

副作用を持つABCの例

  • FlipFlop ABC :呼ばれるたびにTrue/Falseを交互に返す
  • Ask ABC :型ごとにユーザーに許可を尋ねる

実用上の注意

  • __subclasshook__によるパターンマッチの乗っ取りは「ダークマジック」
  • 保守性や予測可能性を損なうため、通常の業務コードには非推奨
  • 学術的・実験的な用途やライブラリの奥深い部分以外では使わない方が良い

おまけ:@propertyデコレータの役割

  • @propertyにより、distanceを メソッド呼び出しではなく属性アクセス として利用可能
  • __subclasshook__での属性チェックが簡単になる 利点

参考・謝辞

  • Predrag Gruevski氏のフィードバック に感謝
  • タイトルは「Crimes with Go Generics」から拝借
  • 元記事の初稿は筆者の ニュースレター で公開済み

まとめ

  • Pythonの ABCと__subclasshook__パターンマッチング を組み合わせることで、 柔軟かつ型安全性の低い判定ロジック が実現可能
  • 本記事の内容は実務利用非推奨、好奇心・実験用として楽しむべき技法

Hackerたちの意見

Pythonのパターンマッチングがもっと一般的じゃない理由が全然わからない。「case foo.bar」は値のマッチだけど、「case foo」は名前のキャプチャなんだよね。Pythonは「case .foo」を定義して「fooを普通の変数として参照する」って意味にすれば、全く曖昧さがなくなるのに、そうしなかった。あと、いくつかの組み込み型を特別扱いして、全体の値としてマッチさせる必要もないと思う。「case float(m): print(m)」でマッチしたfloatを表示できるけど、「case MyObject(obj): print(obj)」ではオブジェクトを表示できない。Pythonは「...」や「None」みたいなものを__match_args__に使って「全体のオブジェクト」を意味させることもできたのに、しなかったんだよね。

case .fooについては、https://peps.python.org/pep-0622/ に明記されてるよね。> 「潜在的には便利だけど、パターン構文をより表現力豊かにすることなく、奇妙な新しい構文を導入してしまう。実際、名前付き定数は既存のルールに従ってEnum型に変換するか、自分の名前空間に囲むことで機能させることができる(著者たちはこれを素晴らしいアイデアだと考えている)[...] 必要であれば、先頭ドットルール(または類似のバリアント)を後で追加しても、後方互換性の問題はない。」二つ目:case MyObject() as obj: print(obj)も使えるよ。

ErlangやScalaのパターンマッチングをやった後だと、Pythonの実装が本当に醜くて気持ち悪く感じる。Scalaのやり方をもっと参考にすればよかったのに。

限界にぶつかるのに疲れたから、マッチングは諦めたよ。でも、OPの行動が犯罪だとは思わない。ただ、そのSyntaxErrorはちょっと犯罪かもね。それに、クラス生成可能な呼び出し可能クラスがあれば、Pythonが__subclasshook__の結果をキャッシュするのを回避できるんじゃないかな。

これがそんなに悪いことなのか、誰か説明してくれない?私の推測では、複雑さが増してコードが読みづらくなるからだと思う。ローカルなことについてローカルに推論できないgotoスタイルみたいに。でも、著者はもっとネガティブな見方をしてる気がする(「犯罪」、「やめてくれ」、「暗い鼓動」、「エルモのGIF」)。

副作用

ミームは基本的にコメディ的な効果を狙ってると思ったんだけど、コードにはすごく間接的な部分があって、すぐには気づかないかもしれないね。投稿にもあったけど、そういう理由がある場合もあるかも。ただ、そうなるとつまらないコードを目指すのとは真逆になるよね。

もしかしたら「強い型付けの言語」的な見方が強すぎるかもしれないけど、isinstance()の便利さは、オブジェクトがそのクラスのインスタンスであることを確認するためのものだと理解してる。だから、その後のコードがそのオブジェクトと安全にやり取りできるし、クラス特有のメソッドを呼び出したり、クラス特有の不変条件に依存したりできるんだ。これってプログラマーとしても直接的に楽になるし、どのコードファイルを見ればそのオブジェクトの挙動を理解できるか分かるからね。リンターもその目的で使ってるし、例えば最後のisinstance()文を見て型を判断したりする。__subclasshook__は、クラスが自分のインスタンスについて嘘をつけることでリスクを生むんだ。例えば、こんなクラスを考えてみて:class Everything(ABC): @classmethod def subclasshook(cls, C): return True def foo(self): ... これでこんなコードが書けるようになる:if isinstance(x, Everything): x.foo() リンターはこのコードを警告なしで通すけど、実際にはifブロックはどんなオブジェクトでも入ることができて、foo()メソッドを持ってないオブジェクトは例外を投げることになるんだ。

要するに、あるクラスが他のクラスが自分のサブクラスかどうかを任意のロジックで判断して、そのロジックを使って他の人の任意のクラスをランタイムで分類するのは、ちょっとサイコパス的だと思う。これらの例は、他の言語でやることに似てるかも。例えば、'インターフェース'を定義して、そのインターフェースに従っているかをチェックするみたいな感じ。例えば、DistancePointというインターフェースを定義して、xとyのフィールドとdistance()メソッドを持たせて、「このオブジェクトがこのインターフェースを実装しているなら、Xを実行して」と言うことができる。けど、他の例は、インターフェースを実装したけど、インターフェースの制約が「このクラスにはこのメソッドがある」じゃなくて「今日は火曜日」みたいな感じになるのがバカげてる。これがこの問題を面白くもしてるんだよね。

本当に問題なのは、そもそもPythonのパターンマッチングのデザインだと思う。match status: case 404: return "Not found" not_found = 404 match status: case not_found: return "Not found" 言語の他の部分では、定数に名前を付けてもコードの動作は変わらないけど、この場合は二つのスニペットが全然違うんだよね。最初は等価性をチェックしてる(status == 404)、二つ目は代入を行ってる(not_found = status)。 https://x.com/brandon_rhodes/status/1360226108399099909

もっと良い提案があったのに、今のやつが採用されたのは残念だな。: https://peps.python.org/pep-0642/

だって、switchじゃなくてmatchだから、パターンマッチングなんだよね…

それはデストラクチャリングだね、バグじゃなくて機能だよ。どの関数型言語でも同じように動くし、慣れればめっちゃ便利。

ほとんど軽犯罪だね、すべての型チェックは決定論的だったし。

次のステップは、サブクラスが全てのコードをまとめてChatGPTに送って、「クラスAはクラスBのサブクラスであるべきだと思う?」って主観的に聞いて、その結果のテキストに対して感情分析を行って判断することだね。

最近、Pythonではますます怪しいものが設計されてる気がする。最近のPEPでは{/}を空集合として使うことを提案してるし。

それ、あんまり怪しいとは思わないな。∅の方が博士たちには親しみやすいかもね、set()よりも ;)

問題は、すでに空のリスト []、空のタプル ()、そして空の辞書には {} が使われてるってことだよね。だから、空のセットのための構文があるのは、実際に意味があると思う。

僕のお気に入りのPythonの「犯罪」は、__rrshift__を定義したクラスを右側で使うと、左側が__rshift__を定義していなくてもパイプオペレーターが使えることだね。結構型安全だし、チェーンを「閉じる」必要もない。書いてるチェーンの中で出力される値はすべてプリミティブ型にできる。例えば、x = Model.objects.all() >> by_pk >> call(dict.values) >> to_list >> get('name') >> _map(str.title) >> log_debug >> to_json みたいに。ノートブックやライブコーディングで、思考の流れをそのまま操作順にタイプしたいときに光るよ。何かがうまくいかないかもしれない場所をログに残したい?コマンドラインみたいにteeしてみて!イディオマティック?全然違う。プロダクションに持っていくもの?ピッチフォークで刺されるのが好きじゃない限り、無理だね。プロトタイピングには実際に役立つ?1000%そうだよ。

__ror__と | を使う同じトリックもできると思うよ(左側が or を定義していなければね)。x = Model.objects.all() | by_pk | call(dict.values) | to_list | get('name') | _map(str.title) | log_debug | to_json みたいに。

ああ、神様、またC++みたいになってる!

ここにPythonの使い方をちょっと間違えてる機能型プログラマーが潜んでるのを見つけたよ ;) 引数がひっくり返ってるから、関数合成みたいに見えるね。Haskellでは>>>だし、面白い!でも、命令型コードを書いて結果を変数に束縛してるなら、>>=と比べてもいいかもね。

Apache Beamがやってるよ: https://beam.apache.org/documentation/sdks/python-streaming/ 初めて見たときは目が痛くなった。

わあ、これすごい!特にあなたの例は素晴らしいね。

これは必ずしも「犯罪」だとは思わないけど、役に立つかもしれないね。似たようなアプローチの例としては、__instancecheck__をオーバーライドして、文字列が正規表現にマッチしたらtrueを返すメタクラスがあったとしたら、そのメタクラスを使って動的に定義されたクラスを作り、マッチステートメントで文字列を複数の正規表現に対してマッチさせることができるよね。残念ながら、キャプチャグループを抽出する良い方法は思いつかないけど。

うーん、ここで何が起きてるのか理解するのにちょっと時間がかかったけど、実際これは面白いし、たぶん役に立つね。もう10年以上Pythonを趣味でやってるけど、ランタイムでの動きには時々頭が混乱することもある。でも、やってて楽しいよ。E: ただ、これを使うなら、マルチファイルのコード構造の中に埋め込まないで、使うファイルと同じファイルに明示的に置いておいた方がいいよ。そうしないと、みんな混乱しちゃうから。

個人的には、PEP 634のパターンマッチングはあんまり好きじゃないな。Pythonでたくさんコードを書くけど、パターンマッチングを使える99%の場面では、シンプルなif文や辞書を使うことになる。大抵は、そっちの方がわかりやすくて読みやすいし、特に伝統的な制御フローに慣れてる開発者にはね。

それが必要ならif文を使った方がいいよ。match文は主に構造的パターンマッチング用だから。

限定的なキーと値の型定義を持つ辞書はいいけど、盲目的なストレージタイプとしての辞書は、プロダクションで型エラーやキーエラーでクラッシュする原因になるよ。構造的パターンマッチングは型安全をサポートするためにあるんだ。アイテムの型や、そのアイテムを使ったコードの行が実行時に何をするかがわからないなら、実際にはコードは読みやすくないと思う。

大学卒業後に他の言語を使ったけど、Pythonにはあんまりワクワクしないな。

3年前に話題になった: Pythonのパターンマッチングによる犯罪 406ポイント、2022年8月2日。120コメント https://news.ycombinator.com/item?id=32314368