Help us understand the problem. What is going on with this article?

Sublime Text のプラグインのコールバックをジェネレータで「インライン化」する

More than 3 years have passed since last update.

概要

Sublime Text の各種入力インターフェースを利用する処理がコールバック地獄になりがちなので、ジェネレータを使ってすっきり書いてみたよ! というお話です。
いま書いているプラグインでも使いまくりです。

背景

Sublime Text 3 の API は大体テキストエディタを介してやりたい操作は実現できます。メニューアイテムをカスタマイズしたクイックパネルを出したりテキスト入力パネルを出したりと各種の入力受付を出来るわけですが、使っている方はお分かりの通り、各種ユーザからの入力受付をやっている間もエディタの他の操作はブロックされませんよね? それはもちろんこれらの入力受付インターフェースを操作する関数が新しいスレッドで実行されるからですが、そこから本体のスレッドの処理に干渉したい場合は、予め渡しておいたコールバック関数を入力が行われた後で呼び出します。

例えばクイックパネルの表示であれば以下のような感じ:

from sublime_plugin import WindowCommand


class ShowMenu(WindowCommand):

    def run(self):
        menu = ["A", "B", "C"]
        def cb(index):
            print(menu[index])
        self.window.show_quick_panel(menu, cb)

で要素 "A", "B", "C" を含むメニューが表示でき、cb() 関数に選択されたメニューのインデックスが渡されます。まあこれくらいだったらコールバックの内容も少ないので大したことないのですが、コールバックの行数やコールバックに渡す変数の数が増えて行ったりするとまたしんどくなります。
さらに面倒なのはユーザ入力を待つようなコマンドを連続して呼ぶことになった場合で、コールバックの中で次のユーザ入力待ちコマンドを適切なコールバックと共に呼んで……って書いていこうとすると例えば次のようになります。

class Auth(WindowCommand):

    def run(self):
        self.window.show_input_panel("username", "", cb1)
        # 入力パネルが表示されたら入力を待たずにこちら側の行が続行されるので
        # 入力を使った処理はコールバックに書く


def _cb1(username):
    # show_input_panel()そのままだと入力文字列が表示されて本当はアレなのですが、とりあえずそれは気にしない
    self.window.show_input_panel(
        "password", "", functools.partial(_cb2, username))


def _cb2(username, password):
    _auth(username, password)


def _auth(username, password):
    # username と password を使って認証処理

まあ書けますけど読みやすくはないですよね。関数として切り出す単位が適切な訳でもないのに、コールバックとして実行するためだけに関数として分離しないといけないのは面倒で直観的ではない書き方になります。受け渡しをするパラメータの数がたくさんあったりするとまた面倒ですし、メンテナンス性も下がります。

もっとエレガントに書こう

もっとエレガントに書けないのか?……書けます。Python ならね。

原理的に言えばユーザからの入力を待つようになったところで関数の実行を一時停止出来れば良い訳です。――そう、 yield 文の出番です。yield 文を呼び出せばジェネレータに対して next() 関数あるいは send() 関数が呼ばれるまで実行途中の状態を保存できます。ユーザからの入力があったら send() 関数を使うことでジェネレータ内の式で用いることができます。なければ単に next() を呼べば良い訳です。という訳で、まずはこんな感じのデコレータを用意してやります。

def chain_callbacks(
    f: Callable[..., Generator[Callable[Callable[...]], Any, Any]
) -> Callable[..., None]:
    @wraps(f)
    def wrapper(*args, **kwargs):
        chain = f(*args, **kwargs)
        try:
            next_f = next(chain)
        except StopIteration:
            return

        def cb(*args, **kwargs):
            nonlocal next_f
            try:
                if len(args) + len(kwargs) != 0:
                    next_f = chain.send(*args, **kwargs)
                else:
                    next_f = next(chain)
                next_f(cb)
            except StopIteration:
                return
        next_f(cb)
    return wrapper

若干いかついですが、こいつを使うことでコールバックを「インライン化」できます。
yield 文には「呼ばれたら yield 文以降が実行されてほしいコールバック関数一つを引数に取る関数」を渡しましょう。もしそのコールバック関数に何かの値が渡された場合は yield の返り値として受け取ることができます。

from functools import partial

class Auth(WindowCommand):

    @chain_callback
    def run(self):
        # `functools.partial()` を使って `on_done` 引数一つだけを取る
        # 関数の形にして、yield
        username = yield partial(
            self.window.show_input_panel, "username", "",
            on_change=None, on_cancel=None)
        password = yield partial(
            self.window.show_input_panel, "password", "",
            on_change=None, on_cancel=None)
        # ここから username と password を使って認証処理

こちら側はスッキリですね!

こういうジェネレータの使い方は TwistedTornado のような ウェブフレームワークでも使われています。

ngr_t
学部で生物情報科学、修士で理論神経科学をやった後でデータ分析・分析ツールの開発の仕事をしています。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away