結論
PyQtのconnect()の引数にはlambdaを使うべき。
はじめに
C++のGUIフレームワークQtをPythonで扱うためのラッパーライブラリPyQtのイベント処理についての記事です。Qtの各クラスは、特定のタイミングで発火するイベントをいくつか持っています。これをsignalと言います。signalを発火させる事をemitと言います。emitするだけでは何も起きないので、signalを「何らかの処理」とconnectする必要があります。connectは関数であり「何らかの処理」を引数として渡します。以下に例を示します。
#closePushButtonはQPushButtonクラスを継承
#以下ではclickedというsignalにself.close()という関数をconnectしています
closePushButton.clicked.connect(self.close)
関数connect()の引数がself.close()という関数です。
lambda式
さて、Pythonにはラムダ式という構文が存在し、無名関数(anonymous function)を生成出来ます。関数は通常、def文でのみ宣言出来ますが、その例外がこのラムダ式です。以下に例を示します。
#通常の関数定義
def def_add(a, b):
return a + b
#ラムダ式
lambda_add = lambda a, b: a + b
if __name__ == "__main__":
def_add_value = def_add(1, 2)
lambda_add_value = lambda_add(1,2)
print(def_add_value, lambda_add_value) #(3, 3)
ラムダ式は構文にクセがありますが、通常の関数定義と同様の動きを実装出来ます。
def文とラムダ式の違い
先ほどの関数をprintしてみましょう。
print(def_add, lambda_add)
#<function def_add at 0x10ad214c0> <function <lambda> at 0x10ad4f700>
いずれもfunction型とわかります。違いは前者がdef_addという関数名が保存されている一方、後者は<lambda>となっている点です。これが無名関数の由縁です。stackoverflowには以下のようなコメントがありました。
lambda functions can't be pickled because they have no (unique) name associated with them. (Therefore, they can't be used with multiprocessing for example -- which has bit me with a PicklingError on more than one occasion ) – mgilson
Also, it might be worth pointing out that lambda is an expression whereas def is a statement. Since lambda is an expression, it can only contain other expressions (no statements are allowed) -- Although this is more of an issue at the programmer's level as opposed to "Why does python keep track of the difference" – mgilson
lambda関数にはそれと関連する固有の名前がないためpickled出来ません(ゆえにマルチプロセッシングには使用出来ませんーー私の場合はPicklingErrorが複数回発生しました(一度ではないという意味))
また、lambdaは式である一方、defは文である点を指摘する価値があるかもしれません。lambdaは式なので、式のみを含むことが出来ます(文を含む事が出来ません)。「Pythonがその違い(defとlambda)を追跡(または保存)している理由」というよりは、プログラマーレベルの問題ですが。
という事で調べてわかった事は「ラムダ関数は名前がなく、また文を含む事は出来ない」という事でした。それ以外のふるまいの違いはわかりませんでした。
connectの引数としてのdefとlamnda
ここからが本題です。前述までの話で、どうやらdef関数でもラムダ式でも、基本的には同じ挙動をしそうだという事がわかりました。じゃあ関数をPyQtでconnect()の引数とする場合にも同じ挙動をするはず…がしません。いえ、通常の使い方をしていれば同じ挙動になりますが、少し複雑な構造になると挙動が変わります、def関数では動かなくなる事があります。
def関数じゃダメなのはどんなとき?
結論を言うと詳細な原因・タイミングはわかりませんでした。メモリ周りに原因がありそうだという事はなんとなくわかったのですが、詳細なタイミングや原因解明は出来ていません。具体的な状況としては、QThreadを継承したクラスにセットしたカスタムsignalをconnectする際にdef関数だと動作しませんでした。
2022-06-03追記
下記記事にて、上記問題の核心らしきものが紹介されていました、以前に私が遭遇していた挙動からしても、納得のいく理由です。やはりdef関数とlambda式では挙動が違った…。
https://qiita.com/MachineCAT/items/1141f03da64dfac0be8e
connectにはラムダ式を使おう
前述の状況では、def関数ではなくラムダ式を引数とすると問題が解決しました。はっきりした原因はわかりませんが、def関数を引数として渡すと、PyQt内部の変数らしきものとして保存されてるんじゃないかな、と推測しています。一方ラムダ式の場合はあくまで実行すべき式だけを保存するのかな、と。以下はconnectにラムダ式を渡すパターンですが、これを読むとなんとなく私が言いたい事がわかっていただけるかもしれません(self.close()という式そのものが渡されるイメージ?)。
#def関数の場合はclosePushButton.clicked.connect(self.close)
closePushButton.clicked.connect(lambda:self.close())
ここまでやっても明確な原因はわかっていませんが、何はともあれ基本的に同じ挙動をするんだし式使っておけばよいという結論に達しました。
connectにdef関数を渡すべきでないもうひとつの理由
def関数をconnectの引数として使うべきではない理由をもう一つあげます。signalは何らかの値を渡してくる場合があります。その際、connectに渡すdef関数には引数を受ける用意が必要です。以下が例です。
#progressChangedというsignalは、connectされた関数にintを渡します
self.tile_downloader.progressChanged.connect(self.update_download_progress)
def update_download_progress(self, value:int):
self.ui.download_progressBar.setValue(value)
通常の関数の呼び方だとupdate_download_progress(100)など、引数が明示されます。しかしconnectに引数として渡す場合はどこにも引数が現れません。PyQtはそういうもん、とわかっておく必要があり、コードの可読性が非常に低くなります(progressChangedがintを渡すという事がわかっていないと、このconnectだけを読んでも挙動がわからない)。lambda式だと以下になります。
self.tile_downloader.progressChanged.connect(lambda v: self.update_download_progress(v))
def update_download_progress(self, value:int):
self.ui.download_progressBar.setValue(value)
lambda式なら、connectを読めばprogressChangedというsignalはなんらかの値を渡してくる事がわかります(signalの値のほかに引数として渡したい変数がある場合はhttps://stackoverflow.com/questions/35819538/using-lambda-expression-to-connect-slots-in-pyqtを参照)。
終わりに
長くなりましたが、結論はconnectにはlambda式を使おう。これだけです。理由は、①なぜかはわからないけどlambda式の方が安定している②lambdaの方が可読性が高い、という2点です。ご意見や、情報(特に①に関して)がありましたらコメントしてください。ここまで読んでいただきありがとうございました。