パート17: 「コールバック」ではない方法

はじめに

このパートではコールバックに主眼を置き直します。 Twisted で generators を使ってコールバックを記述するもうひとつのテクニックを紹介しましょう。 このやり方がどうやって動くのかを示し、純粋な Deferred を使う場合と照らし合わせてみます。 最後に、このテクニックを使って詩のクライアントのひとつを書き直しましょう。 とはいえ、なんでこの手法がコールバックを生成するのにふさわしいかを理解するために、まずはジェネレータの動作を復習しておきましょうか。

ジェネレータに関する簡単な復習

ご存知のように、Python のジェネレータは関数内で yield を使って生成できる “restartable function” (TODO: 公式ドキュメントの和訳を見ておく) です。 こうすることで、ある関数は、一連のステップ内で実行するために利用できる iterator (イテレータ) を返す、ジェネレータ関数になります。 イテレータのそれぞれのサイクルは関数を再始動させ、次の yield に達するまで処理を続けます。

ジェネレータ (と、イテレータ) はしばしば lazily-created sequences of values (TODO: 日本語を調べる/考える) を表現するために使われます。 inline-callbacks/gen-1.py にあるサンプルコードを見てみましょう。

def my_generator():
    print 'starting up'
    yield 1
    print "workin'"
    yield 2
    print "still workin'"
    yield 3
    print 'done'

for n in my_generator():
    print n

ここでは、ジェネレータに 1, 2, 3 というシーケンスを生成させています。 コードを実行させてみると、ジェネレータの print 文が for ループ内の print 文にそれぞれのループで織り交ぜられていることが分かるでしょう。

ジェネレータ自身を生成することで、このコードをもっと明示的にすることもできます。 (inline-callbacks/gen-2.py):

def my_generator():
    print 'starting up'
    yield 1
    print "workin'"
    yield 2
    print "still workin'"
    yield 3
    print 'done'

gen = my_generator()

while True:
    try:
        n = gen.next()
    except StopIteration:
        break
    else:
        print n

シーケンスとして考えてみると、ジェネレータは後続の値を取得するためのオブジェクトにすぎません。 しかし、ジェネレータ自身の観点から考えてみることもできます。

  1. ジェネレータ関数はループによって呼び出されるまで動き出しません (next メソッドを使います)。

  2. ジェネレータ関数が動き出すと、ループに処理を返すまで動き続けます (yield を使います)。

  3. ループが他のコード (print 文のように) を実行しているときは、ジェネレータは動作していません。

  4. ジェネレータが動作しているときは、ループは動作していません (ジェネレータを待つためにブロックされています)。

  5. ジェネレータがループに制御を委譲 (yield) すると、ジェネレータが再度実行されるまで、任意の時間が渡されます (さらに任意の量のコードが実行されるかもしれません)。

非同期システムにおけるコールバックの動作とそっくりですね。 while ループを reactor、ジェネレータを yield 文で分割された一連のコールバックとみなせます。 すべてのコールバックは同じローカル変数の名前空間を共有し、その名前空間はあるコールバックから次のコールバックに引き継がれる点も見逃せません。 さらに、一度に複数のジェネレータを有効にでき (inline-callbacks/gen-3.py の例をみてください)、それぞれのコールバックはお互いに混ぜこぜになります。 ちょうど、Twisted のようなシステムで独立した非同期タスクを持てるようにです。

それでもいくつか見逃していることもあります。 コールバックは reactor から呼ばれるだけではありませんし、情報を受け取ることもできます。 遅延オブジェクトのチェーンの一部であるとき、コールバックは Python の単一の値として結果を受け取るか、 Failure としてエラーを受け取ります。

Python 2.5 からは inline-callbacks/gen-4.py で示すように、 ジェネレータを再始動させるときに情報を送信できるような方法に拡張されました。

class Malfunction(Exception):
    pass

def my_generator():
    print 'starting up'

    val = yield 1
    print 'got:', val

    val = yield 2
    print 'got:', val

    try:
        yield 3
    except Malfunction:
        print 'malfunction!'

    yield 4

    print 'done'

gen = my_generator()

print gen.next() # start the generator
print gen.send(10) # send the value 10
print gen.send(20) # send the value 20
print gen.throw(Malfunction()) # raise an exception inside the generator

try:
    gen.next()
except StopIteration:
    pass

Python 2.5 かそれ以降のバージョンでは、 yield 文は値の評価式です。 そして、ジェネレータを再始動させるコードは next ではなく send メソッドを使うという決定もできます (next を使うと、その値は None です)。 さらに、 throw メソッドを使って、ジェネレータの「内側」から任意の例外を投げることができます。 なんて素晴らしいんでしょう!

インラインコールバック

ジェネレータに値と例外を send することと throw することについて復習したことが分かると、ジェネレータを一連のコールバックとして想定できます。 遅延オブジェクトに含まれるものかのように、それは結果か失敗のどちらかを受け取ります。 コールバックは yield によって分割され、それぞれの yield の評価値は次のコールバックへの結果となります (もしくは yield が例外を投げると “failure” になります)。 図35はその対応を示します。

_images/p17_generator-callbacks1.png

図35:コールバックシーケンスとしてのジェネレータ

一連のコールバックが遅延オブジェクト内でチェーンとしてまとめられると、それぞれのコールバックは結果をひとつ前から受け取ります。 ジェネレータを使ってやってみるのは簡単そうですね。ジェネレータの前回の実行分から受け取った値を send し (yield した値ですね)、 それを使って次回は再始動させるだけです。 しかし、ちょっと馬鹿馬鹿しくも思えます。 ジェネレータは開始時にその値を計算するのに、なんで送り返すなんてことをするのでしょう? 次回に必要ならばジェネレータは値を変数に保存しておくこともできるでしょう。 何が重要なのでしょうか?

パート13: Deferred と行こう”で学んだことを思い出してください。遅延オブジェクト内のコールバックは遅延オブジェクト自身を返すことができましたよね。 この場合、外側の遅延オブジェクトは内側の遅延オブジェクトが開始するまで止まっていますので、外側の遅延オブジェクトのチェーンでの次のコールバック (もしくはエラー用コールバック) は、内側の遅延オブジェクトからの結果 (もしくは失敗) を引数として呼び出されます。

それでは、ジェネレータが通常の Python の値ではなく遅延オブジェクトを yield した場合を想像してみてください。 ジェネレータは停止 (“paused”) され、自動化されます。 ジェネレータはいつも、すべての yield 文の後で明示的に再始動されるまで停止します。 ですから、遅延オブジェクトが発火するまで、ジェネレータの再始動を遅らせることが可能です。 このとき私たちは値を send する (遅延オブジェクトが成功したら) か、例外を throw する (遅延オブジェクトが失敗したら) かのどちらかです。 これによってジェネレータを純粋な非同期コールバックのシーケンスにしていますし、そしてこれこそが twisted.internet.defer 内の inlineCallbacks 関数の背後にある考え方なのです。

inlineCalbacks

inline-callbacks/inline-callbacks-1.py にあるプログラム例について考えてみましょう。

from twisted.internet.defer import inlineCallbacks, Deferred

@inlineCallbacks
def my_callbacks():
    from twisted.internet import reactor

    print 'first callback'
    result = yield 1 # yielded values that aren't deferred come right back

    print 'second callback got', result
    d = Deferred()
    reactor.callLater(5, d.callback, 2)
    result = yield d # yielded deferreds will pause the generator

    print 'third callback got', result # the result of the deferred

    d = Deferred()
    reactor.callLater(5, d.errback, Exception(3))

    try:
        yield d
    except Exception, e:
        result = e

    print 'fourth callback got', repr(result) # the exception from the deferred

    reactor.stop()

from twisted.internet import reactor
reactor.callWhenRunning(my_callbacks)
reactor.run()

この例を実行してみると、ジェネレータが最後まで実行され、reactor を停止させることが分かりますね。 この例は inlineCallbacks 関数のいくつかの側面を表しています。 ひとつめに、 inlineCallbacks はデコレータであり、つねにジェネレータ関数、つまり yield を使う関数をデコレートします。 inlineCallbacks 全体の目的は、上述したスキームに沿って、ジェネレータを一連の非同期なコールバックにしてしまうことです。

ふたつめに、 inlineCallbacks でデコレートされた関数を呼び出すと、 nextsend あるいは throw 自身を呼び出す必要がありません。 細かいことはデコレータが面倒をみてくれますので、ジェネレータが最後まで実行されることを保証してくれます (例外を投げないと仮定してください)。

みっつめとして、ジェネレータから遅延オブジェクトではない値を yield すると、 yield の結果としてそれと同じ値を伴って即座に再始動されます。

そして最後に、ジェネレータから遅延オブジェクトを yield すると、それが発火されるまで再始動されません。 遅延オブジェクトが発火すると、 yield の結果は遅延オブジェクトからの値に過ぎません。 失敗した場合は yield 文が例外を投げます。 ここでの例外は Failure ではなく普通の Exception であることに注意してください。 yield 文を try/except 節で囲むことで例外を捕まえることができます。

この例では、短い時間の後で遅延オブジェクトを発火させるために callLater を使っているに過ぎません。 コールバックチェーンの中にノンブロッキングな遅延を詰めるにはお手軽な方法ですが、通常は、ジェネレータから呼び出される他の非同期操作 (つまり get_poetry) が返す遅延オブジェクトを yield させるでしょうね。

ここまでで inlineCallbacks でデコレートされた関数がどのように動き出すか分かりましたが、実際にそれを呼び出して得られる戻り値は何でしょうか? お考えのように、遅延オブジェクトです。 いつジェネレータが停止するのか正確には分かりませんので (複数個の遅延オブジェクトを yield するかもしれません)、デコレートされた関数自身は非同期であり、遅延オブジェクトが適切な戻り値なのです。 戻り値である遅延オブジェクトは、ジェネレータが yield するかもしれない遅延オブジェクトのひとつではないことに注意してください。 むしろ、ジェネレータが完全に動作を完了した後でのみ発火する (もしくは例外を投げる) 遅延オブジェクトです。

ジェネレータが例外を投げると、戻り値である遅延オブジェクトは Failure でラップされた例外を引数としてエラー用のコールバックチェーンを発火させます。 しかし、ジェネレータに通常の値を返してもらいたかったら、 defer.returnValue 関数を使って “return” させなくてはなりません。 通常の return 文のように、ジェネレータを停止させるでしょう (実際は特別な例外を投げます)。 inline-callbacks/inline-callbacks-2.py の例は両方の可能性を表現しています。

クライアント 7.0

新しいバージョンの詩のクライアントで動作するように inlineCallbacks を配置してみましょう。 コードは twisted-client-7/get-poetry.py にあります。 twisted-client-6/get-poetry.py のクライアント 6.0 と比較したくなるでしょう。 関連のある変更点は poetry_main にあります。

def poetry_main():
    addresses = parse_args()

    xform_addr = addresses.pop(0)

    proxy = TransformProxy(*xform_addr)

    from twisted.internet import reactor

    results = []

    @defer.inlineCallbacks
    def get_transformed_poem(host, port):
        try:
            poem = yield get_poetry(host, port)
        except Exception, e:
            print >>sys.stderr, 'The poem download failed:', e
            raise

        try:
            poem = yield proxy.xform('cummingsify', poem)
        except Exception:
            print >>sys.stderr, 'Cummingsify failed!'

        defer.returnValue(poem)

    def got_poem(poem):
        print poem

    def poem_done(_):
        results.append(_)
        if len(results) == len(addresses):
            reactor.stop()

    for address in addresses:
        host, port = address
        d = get_transformed_poem(host, port)
        d.addCallbacks(got_poem)
        d.addBoth(poem_done)

    reactor.run()

新しいバージョンでは、 inlineCallbacks ジェネレータ関数である get_transformed_poem は、詩を取得することと、その後に変換を適用することの両方に責任を持ちます (変換サービス経由で)。 どちらの操作も非同期ですから、それぞれの時点で遅延オブジェクトを渡し、(暗黙的に) その結果を待ちます。 クライアント 6.0 では、変換に失敗すると元の詩を返すだけです。 ジェネレータ内では非同期なエラーを処理するために try/except 節を使えることを確認しましょう。

新しいクライアントも以前と同じ方法でテストできます。 まずは変換サーバを起動させましょう。

python twisted-server-1/tranformedpoetry.py --port 10001

続いて二つの詩のサーバを起動させます。

python twisted-server-1/fastpoetry.py --port 10002 poetry/fascination.txt
python twisted-server-1/fastpoetry.py --port 10003 poetry/science.txt

それでは新しいクライアントを実行させましょう。

python twisted-client-7/get-poetry.py 10001 10002 10003

クライアントがエラーをどのように処理するかを確認するために、ひとつかそれ以上のサーバを停止させてみてください。

議論

Deferred オブジェクトのように、 inlineCallbacks 関数は非同期コールバックを構成する新しい方法を提示してくれます。 そして遅延オブジェクトがあるときと同じように、 inlineCallbacks はゲームのルールを変えません。 特に、コールバックは一度にひとつしか動作しませんし、reactor から呼び出されます。 これまでと同じようにインラインコールバックからのトレースバックを出力することで、このことを確認できます。 inline-callbacks/inline-callbacks-tb.py のサンプルスクリプトにある通りです。 このコードを実行させてみると、トレースバックのトップに reactor.run() があり、途中にたくさんのヘルパー関数、それから一番下に私たちのコールバックがあります。

図29を適応させることができます。 これは、 inlineCallbacks ジェネレータが遅延オブジェクトを yield するとき何が起こるかを見せることにより、遅延オブジェクト内で、あるコールバックがもうひとつの遅延オブジェクトを返すときに起こることを説明してくれます。 図36を見てください。

_images/p17_inline-callbacks1.png

図36:inlineCallbacks 関数における制御の流れ

描かれている考え方は一緒ですので、両方の場合で同じ図が活躍してくれていますね。— ある非同期操作がもう一つを待つことになります。 inlineCallbacks と遅延オブジェクトはこれと同じ問題の多くを解決するのに、どちらか片方を選ぶのは何故でしょう? inlineCallbacks の潜在的な利点はいくつかあります。

  • コールバックは名前空間を共有しますので、追加の状態を渡す必要がありません。

  • コールバックの順番を簡単に確認できます。上から下に実行するだけです。

  • 個別のコールバックの関数宣言と暗黙的な呼び出し制御がありませんので、タイピング量は概して少なくて済みます。

  • エラーは親しみのある try/except 節で処理されます。

落とし穴も潜んでいます。

  • ジェネレータの中のコールバックを個別に呼び出すことはできません。このことは、コードの再利用を難しくしてしまいます。 遅延オブジェクトを使うと、遅延オブジェクトを構築するコードは任意のコールバックを任意の順番で追加できます。

  • ジェネレータのコンパクトな形式は非同期コールバックも含まれているという事実をぼんやりさせてしまいます。 通常の連続的な関数の出現と見た目は似ていますが、ジェネレータは全く異なる振る舞いをみせます。 inlineCallbacks 関数は非同期なプログラミングモデルの学習を回避するための方法ではありません。

すべてのテクニックを持ってすれば、選択に必要な経験はこれまでの慣習が提供してくれるでしょう。

まとめ

このパートでは、 inlineCallbacks デコレータに関することと、Python ジェネレータの形式で非同期なコールバックのシーケンスを表現する方法ついて学びました。

パート18: Deferreds En Masse” では、 並行 (“parallel”) な非同期操作の集合を管理する方法を学んでいきましょう。

おすすめの練習問題

  1. なぜ inlineCallbacks 関数は複数形なのでしょうか?

  2. inlineCallbacks とそのヘルパー関数である _inlineCallbacks の実装を学習してください。 “the devil is in the details” というフレーズを考えてみましょう。

  3. N 個の yield 文を持つジェネレータにはいくつのコールバックが含まれるでしょうか? ループや if 文は存在しないと仮定してください。

  4. 詩のクライアント 7.0 は一度にみっつのジェネレータを実行することがあります。 概念的には、お互いに混ざり合う組み合わせはいくつあるでしょう? 詩のクライアントでの呼び出され方と inlineCallbacks の実装を考えてみると、実際に可能な組み合わせはいくつでしょう?

  5. クライアント 7.0 にある got_poem コールバックをジェネレータ内に移動させてください。

  6. 同様に poem_done コールバックをジェネレータ内に移動させてください。 注意!何があっても reactor が終了できるように、全ての失敗する場合を処理してください。 reactor を停止させるために、どのようにして出来上がったコードを遅延オブジェクトを使ったものと比べましょうか?

  7. while ループ内の yield 文を含むジェネレータは、概念的に無限数列を表現できます。 inlineCallbacks でデコレートされたそのようなジェネレータは何を表すのでしょうか?