困るなぁ:「tkinter.TclError: No more menus can be allocated.」

追記前提で書き始めるのもどうかとは思うのだが。

英語でも情報がないもんで、英語で書こうかなんてこともちょっと思ったが、疲れるのでやめることにする。とりあえず日本語で。かつ、「そうかもなぁ」の理解だけひとまず。情報が確定したら追記するかもしれないし、しないかもしれない。

ffchopreview.py でポップアップメニューを多用しているのだけれど、一定時間(1時間以内?)動かし続けると喰らう、「No more menus can be allocated.」を。

何せキック元はあくまでも「Python」なのだから、「メモリ確保失敗に至るリーク」は考えにくくて、しかもどうしても日本人は「allocated」をメモリなどの基底リソースを思い描いてしまうけれど、なんか違う気がするんだよね、だって「No more menus」だからね、メモリが、とは言ってない。つまりさ、「ガーベージコレクション内蔵の Python では参照されなくなったリソースは解放される」の理解だと、以下が問題を起こす状況って、限られるんだよね:

importとかは省略
 1     menu = tkinter.Menu(master, tearoff=0)
 2     for i, (txt, cmd) in enumerate(items):
 3         menu.add_command(
 4             label=txt, command=functools.partial(cmd, i))
 5     ppos_x = master.winfo_rootx() + 10
 6     ppos_y = master.winfo_rooty() - 20
 7     try:
 8         menu.tk_popup(ppos_x, ppos_y)
 9     finally:
10         menu.grab_release()

ここでの変数「menu」は global にしているわけでもないし、見ての通りインスタンス変数にもしていないので、このコードブロックを抜ければいずれ破棄される。だからこれを「何度も何度も繰り返し」たからといって「No more menus can be allocated.はなかろうよ」と思うわけだ。

あくまでも想像の話である。どうにも振る舞いから察するに、tcl/tk が、起動元のアプリケーション単位で「作っていいメニュー(アイテム?)の数」の監視と制限をかけているんじゃないかと。そして「追加したよ/使わなくなったよ」のペアの管理がオカシイことになってる、てことなのではないかと。上のコードの例で「menu」オブジェクトはもういないのに、tcl/tk は「活きている」としてトラッキングを続けている、と解釈しない限り、このヘンな振る舞いは説明できない、と思う。

もしも誰かが悪いんだとしたらこれは「tkinter」が悪い。確かに C++ と違って「クリーンアップはデストラクタで」というわけにいかないので、自動削除的な処理は結構厄介な問題ではある。特に「CPython 以外の Python」のことまで考えれば。けれどもそれは「dispose」だの「destroy」だのの明示的なメソッドを用意して「呼べよ」マナーを周知すればいい。すなわちこの場合の「何すりゃ tcl/tk はこのメニューを失念してくれるんだ?」が全くわからないことが、である。

で、情報もないので試行錯誤をするわけだ:

 1     menu = tkinter.Menu(master, tearoff=0)
 2     for i, (txt, cmd) in enumerate(items):
 3         menu.add_command(
 4             label=txt, command=functools.partial(cmd, i))
 5     ppos_x = master.winfo_rootx() + 10
 6     ppos_y = master.winfo_rooty() - 20
 7     try:
 8         menu.tk_popup(ppos_x, ppos_y)
 9     finally:
10         menu.grab_release()
11         #menu.destroy()
12         menu.delete(0, "end")
13         #del menu

destroy も delete もダメである。何がダメって、「やってる場所がダメ」。command に与えるコマンドにもよるけれど、ポップアップメニューが立ち上がり、ユーザがそれを選択し、コマンドが起動される、という一連の流れにおいて、「destroy(など)」はコマンド起動「前」に実行されてしまう。ちょっと不思議なフローな気もするが、まぁ GUI プログラミングなんてこんなもんだ。

タイミングがダメなのだとなると、「コマンド実行後に」とするしかないわけだが、そうなると以下のようなちょっとバカげたことが必要になりそうなのだ:

 1 class CmdwrapperForDynmenu(object):
 2     """
 3     This is a workaround for 'No more menus can be allocated.'
 4     when we are using tkinter.Menu dynamically.
 5     """
 6     def __init__(self, menu, cmd):
 7         self._menu = menu
 8         self._cmd = cmd
 9     def __call__(self, *args, **kwargs):
10         try:
11             self._cmd(args, kwargs)
12         finally:
13             # 管理が「メニューアイテム数」てことなら delete でも良さげだ
14             #self._menu.delete(0, "end")
15             self._menu.destroy()
16 
17 # ...
18     menu = tkinter.Menu(master, tearoff=0)
19     for i, (txt, cmd) in enumerate(items):
20         menu.add_command(
21             label=txt, command=CmdwrapperForDynmenu(menu, functools.partial(cmd, i)))
22     ppos_x = master.winfo_rootx() + 10
23     ppos_y = master.winfo_rooty() - 20
24     try:
25         menu.tk_popup(ppos_x, ppos_y)
26     finally:
27         menu.grab_release()

ひとまずこれで「コマンド実行後に menu.destroy (など)」を実現出来る。ひとまずこれで様子を見ようとは思うが、これで解決しなかったらほんと、完全に手詰まり、というかそれこそ「tcl/tk (と tkinter)のソースコード解析」までしないとわからん気がする。


翌日追記:
実際に取り込んでみて数時間動作させ続けてるが、問題なさそうである。昨日の想像が真実なのかどうかは「tcl/tk (と tkinter)のソースコード解析」しないと相変わらずわからんとは思う。たとえば「実はメモリエラー」かもしらんし。けど「破棄するタイミング」はこれでよく、なおかつ破棄手段は destroy で良い、という「措置目線」は上記コードで正しい、と思われる。

なお、上の措置例は、追加する command オブジェクト側で対処しているが、Menu class を派生してゴニョりたい人も多かろう。特に「オブジェクト指向」に毒されすぎてると、「派生して作らないのが気持ち悪い/おさまり悪い」と感じるのは、まぁわからんでもない。別にどっちか一方だけが一方的に優れてるってもんではないけれど、ただ、tkinter の場合は「ttk.OptionMenu」があることも忘れずに。両方に措置を入れたければ、二重化することになるだろう。あと entryconfigure 等々、波及するメソッドが結構あることにも注意。それと…、「クライアントコードにどう見せるか」が結構考えるのうっといよね、きっとこんなだと思うのよ:

動作は保証しない、「想像上のコード」
1 class MyMenu(tkinter.Menu):
2     # ... (snip) ...
3     def add_command(self, label, command, menu_will_suicide_on_command_finished=False):
4         if menu_will_suicide_on_command_finished:
5             # 結局は CmdwrapperForDynmenu に似たもの、ここでは、与えられたコマンド実行とともに
6             # 自殺するデコレータである _wrap という MyMenu メソッドがいると仮定して:
7             super().add_command(label, self._wrap(command))
8         else:
9             super().add_command(label, command)

「menu_will_suicide_on_command_finished」ってなんやねん、みたいな、ヘンな説明が必要なコントロール引数をイジさせるか、もしくは「add_command_for_dynamic」みたいなことさね。ね、なんか気持ち悪いと思うよね。

注意してほしいのは、この問題が起こるのは「Menu オブジェクトのインスタンスを再利用せずに、必要となるたびに構築する」というスタイルの場合だけね。たとえば「多重起動が許されていないメインウィンドウに唯一存在するメニューバー」を、ウィンドウ初期化時に構築して以後放置プレイ、って使い方なら、こんなことにはならないし、まぁ「メニュー」のおよそ7割以上はそういう使い方だと思うしね。だから、この問題を喰らうおそらくほとんどのパターンが、「コンテキストメニュー」だろう。

あと、「再利用しないことが問題」であることから、インスタンス変数として維持し続けて delete だのを駆使することも、ひょっとしたら措置になるのかもしれない。さすがに最後の手段として頭の片隅には置いたけれど、まぁ保守のことを考えたら、これに手を付けるべきではないね。