PySideのイベント関数をシグナル化してみた。
あるいはイベントとシグナルとMeta-Object Sytemについて。
github.com
イベントとシグナル
正直なところ、イベントを使うために毎度しかるべき関数をオーバーライドするのは結構だるいと思ってる。全部シグナルとして使えたらいいのに……って。
でもQtがイベントとシグナルを明確に分離してるのには理由があって、ドキュメントをみるとちゃんと書いてある。
In Qt, events are objects, derived from the abstract QEvent class, that represent things that have happened either within an application or as a result of outside activity that the application needs to know about.
https://doc.qt.io/qtforpython-6/overviews/eventsandfilters.html#the-event-system
Signals are emitted by objects when they change their state in a way that may be interesting to other objects. This is all the object does to communicate.
https://doc.qt.io/qtforpython-6/tutorials/basictutorial/signals_and_slots.html#signals-and-slots
ものすごくざっくりいうと、イベントは「アプリ内外で起きた出来事を知らせる」ための仕組み。一方でシグナルは「各オブジェクト内で起きた状態の変更を他のオブジェクトに伝える」ための仕組みだ。またインターフェイス的には、イベントはQObject*1のイベント関数をオーバーライドするかたちで利用するのに対して、Signal/SlotはQObject*2の外部からconnectして利用するかたちになっている。
この「状態の変更」というのがポイントで、Qtはオブジェクト指向の枠組みで設計されたものだから、当然ながらその基本的な考え方、つまり、各オブジェクトが自身の状態を変化させながら相互作用する、というモデルに沿って設計されている。これを扱うのがシグナルという仕組みなので、シグナルと状態変更は紐付いている。一方でイベントは状態変更と紐付かない。イベントが状態変更のトリガーとなることは間々あっても、状態変更の結果としてイベントが発行されるという考え方ではない。*3
例えばQCheckBoxがクリックされたときの挙動を考える。以下のテストコードを実行して表示されたチェックボックスをクリックすると、"press" "release" "stateChanged" の3行が出力される。
class MyCheckBox(QCheckBox): def __init__(self, text: str, parent: QWidget = None): super().__init__(text, parent) def mousePressEvent(self, e: QMouseEvent): print('press') return super().mousePressEvent(e) def mouseReleaseEvent(self, e: QMouseEvent): print('release') return super().mouseReleaseEvent(e) c = MyCheckBox('TEST') c.stateChanged.connect(lambda _: print('stateChanged')) c.show()
このときQCheckBoxは、「自身の当たり判定内でマウスボタンが押された」「自身の当たり判定内でマウスボタンが離された」という連続した出来事をQtシステムから受け取り、これをトリガーとして自身のチェック状態を変化させ、その内部状態の変化を外部に伝えるためにシグナルを発行した。QCheckBoxは、自身の状態変更を引き起こすためにmousePressEventとmouseReleaseEventを購読し、その結果起きた状態変更を外部に伝えるためにstateChangedシグナルを発行する。そう考えると、PySideにおいてイベントが関数として用意される一方で、シグナルは属性として用意される理由にも納得がいく。
というわけで、Qtにおけるイベントとシグナルは根本的に別物であって、両者を混同するのはアンチマナーといえる。それを念頭においてこの記事が書かれていることはご承知いただきたく。
Meta-Object System
QtにはMeta-Object Systemという仕組みがあって、PySideにも当然引き継がれてる。これはいろんな用途があるんだけど、ざっくりいうと、アプリ実行時に必要なQObjectのメタ情報*4を扱うための仕組みで、シグナル情報もここに含まれる。つまり、シグナルはMeta-Object Systemの上で成立するものだといえる。オリジナルのC++版QtにはMOC*5というものがあって、ソースコードのコンパイル時にメタ情報がQObjectに書き込まれる。Q_OBJECTとかsignals:みたいな謎のマクロや指定子は、MOCがメタ情報を生成するためのタグ情報として使われる。
さて、ここで問題なのは「PySideではいつ誰がどうやってメタ情報を生成しているか?」なんだけど、これが正直よく分からない。ドキュメントの該当ページはC++版のコピペだし……。適当にぐぐってみるとQObject.__init__()内でメタ情報が構築されてると書かれてたりもするけど、それは嘘だ。いや、まぁPySideの思想的にはQObject.__init__内で行われるとされるべきなのかもしれない*6けど、実際にはいろんな場所で動的にメタ情報が書き換わっていく。ただそれも仕方ない話ではあって……。Pythonは動的型付けの言語だから、アプリ実行中に型定義やメソッド定義の追加削除が簡単にできてしまう。この言語仕様と辻褄をあわせるためには、C++版みたいに初期化時に一律でメタ情報の生成だけしとけばいいってわけにはいかない。言語自体が動的に実行される前提なんだから、フレームワークが扱う実行時メタ情報も動的である必要がある。
例えばこんなコードを実行してみると、connectのタイミングでMeta-Objectが書き換わってることが分かる。
class MyLabel(QLabel): def __init__(self, text: str, parent: QWidget = None): super().__init__(text, parent) label = MyLabel('TEST') print(id(label.metaObject())) label.__class__.aaa = Signal(object) print(id(label.metaObject())) label.aaa.connect(print) print(id(label.metaObject()))
こんな無茶苦茶な処理*7にどれだけの意義があるかはさておき、書き換わっているということ自体は認識しておけるといいのかも?
実装
ともあれ、そんなこんなを諸々踏まえて実装してみたのが冒頭にリンクを貼ったコード。
やってること自体はわりとシンプルで、__getattr__ のタイミングで「シグナル名に相当するイベント関数が存在する」かつ「まだ一度も自動生成されていないシグナル」が指定されたときに、Signalオブジェクトをself.__class__に突っ込んでイベント関数を差し替えてるだけ。type関数を使ってself.__class__自体を差し替えてる実装もstackoverflowに落ちてたけど、クラス変数にさえ突っ込めば動くんだからそんなあからさまに危なっかしいこと*8をやる必要はない。
*1:またはQObjectを継承したクラス
*2:またはQObjectを継承した以下略
*3:もちろん状態変更とイベント発行が同時に行われることはある。ただし、そのイベントは「何らかの出来事が起きたことを通知する」ために発行されるのであって、「状態変更を通知する」ために発行されるのではない。
*4:型やメソッドについての情報などなど
*5:Meta-Object Compiler
*6:というか、C++版ではSignalはメンバ関数だったのにPySideではクラス変数なのは、たぶんこの思想が理由なんだと思う。あらかじめクラス変数に入ってないと__init__で検知できないし。
*7:metaObjectが異なる同一オブジェクトをQtがどれだけ正しく "同一だ" と認識してくれるのか、とか。
*8:そもそも動的なシグナルの追加なんてものが危なっかしくないかどうかはさておき。