プログラマーなら誰でも、関数というものを知っています。
関数とは、あらかじめ決められたタスクを実行するために、1つのユニットとしてグループ化された命令のシーケンスです。
関数は単一のエントリポイントを持ち、引数を受け取ることができ、戻り値を持つことも持たないこともあり、プログラムの実行中にいつでも呼び出すことができます。
プログラムが関数を呼び出すと、現在の実行コンテキストは保存され、関数に制御が渡され、実行が再開されます。
その後、関数は新しいコンテキストを作成し、それ以降、新しく作成されたデータは関数の実行時間中にのみ存在します。
タスクが完了すると、制御は呼び出し元に戻され、新しいコンテキストは事実上削除され、以前のものに置き換わります。
コルーチン
コルーチンは、意図的に呼び出し側に制御を委ねる特別なタイプの関数ですが、その過程でコンテキストを終了せず、その代わりにアイドル状態を維持します。
コルーチンはそのライフタイムを通してデータを保持し、関数とは異なり、実行の中断と再開のためのいくつかのエントリポイントを持つことができるという利点があります。
Pythonのコルーチンはジェネレータと非常によく似た方法で動作します。
どちらもデータ上で動作するので、主な違いを簡単に説明しましょう。
ジェネレータはデータを生成します。
ジェネレータはデータを生成する
ジェネレータはデータを生成します。
キーワード yield
の明確な取り扱いによって、どちらを操作しているのかが決まります。
コルーチンの定義
コルーチンの定義は以下の通りです。
def bare_bones():
while True:
value = (yield)
通常のPythonの関数に似ていることは明らかです。
while True:`ブロックは、値を受け取る限りコルーチンの継続的な実行を保証します。
値は yield
ステートメントを通して収集されます。
これについてはまた後ほど…。
このコードが実質的に無駄であることは明らかなので、いくつかの print
ステートメントで締めくくりましょう。
def bare_bones():
print("My first Coroutine!")
while True:
value = (yield)
print(value)
さて、このように呼び出すとどうなるでしょうか。
coroutine = bare_bones()
もしこれが通常のPythonの関数であれば、この時点で何らかの出力が得られると期待されます。
しかし、この状態でコードを実行すると、 print()
が一度も呼び出されていないことに気がつくでしょう。
これはコルーチンが最初に next()
メソッドを呼び出すことを要求しているからです。
def bare_bones():
print("My first Coroutine!")
while True:
value = (yield)
print(value)
coroutine = bare_bones()
next(coroutine)
これは、コルーチンが最初のブレークポイントである value = (yield)
に到達するまで実行を開始します。
そして停止し、main に実行を戻し、新しい入力を待つ間アイドル状態になります。
My first Coroutine!
新しい入力は send()
で送ることができます。
coroutine.send("First Value")
変数 value
は文字列 First Value
を受け取り、それを表示します。
そして while True:
ループの新しい反復により、コルーチンは再び新しい値が送られるのを待つことになります。
これを好きなだけ繰り返すことができます。
最後に、コルーチンが終了し、もはやそれを使用したくない場合は、 close()
を呼び出すことでリソースを解放することができます。
これは GeneratorExit
例外を発生させ、処理する必要があります。
def bare_bones():
print("My first Coroutine!")
try:
while True:
value = (yield)
print(value)
except GeneratorExit:
print("Exiting coroutine...")
coroutine = bare_bones()
next(coroutine)
coroutine.send("First Value")
coroutine.send("Second Value")
coroutine.close()
出力
出力: “`
My first Coroutine!
First Value
Second Value
Exiting coroutine…
#### 引数の受け渡し
関数と同じように、コルーチンも引数を受け取ることができます。
def filter_line(num):
while True:
line = (yield)
if num in line:
print(line)
cor = filter_line(“33”)
next(cor)
cor.send(“Jessica, age:24”)
cor.send(“Marco, age:33”)
cor.send(“Filipe, age:55”)
出力
Marco, age:33
#### 複数のブレークポイントを適用する
複数の `yield` 文を同じ個別のコルーチン内で連続して実行することができます。
def joint_print():
while True:
part_1 = (yield)
part_2 = (yield)
print(“{} {}”.format(part_1, part_2))
cor = joint_print()
next(cor)
cor.send(“So Far”)
cor.send(“So Good”)
出力
So Far So Good
#### StopIteration Exception
コルーチンが終了した後、再び `send()` を呼び出すと `StopIteration` 例外を発生させます。
def test():
while True:
value = (yield)
print(value)
try:
cor = test()
next(cor)
cor.close()
cor.send(“So Good”)
except StopIteration:
print(“Done with the basics”)
出力されます。
出力: ```
Done with the basics
コルーチンとデコレーター
これはとても良いことです。
しかし、大きなプロジェクトでは、いちいちコルーチンを手動で起動するのは非常に面倒です。
ということです。
Decoratorの力を使えば、もうnext()`メソッドを使う必要はありません。
def coroutine(func):
def start(*args, **kwargs):
cr = func(*args, **kwargs)
next(cr)
return cr
return start
@coroutine
def bare_bones():
while True:
value = (yield)
print(value)
cor = bare_bones()
cor.send("Using a decorator!")
このコードを実行すると、次のような結果が得られます。
Using a decorator!
パイプラインの構築
パイプラインとは、各要素の出力が次の要素の入力となるように編成された一連の処理要素のことである。
データは、最終的に消費されるまでパイプを通じてプッシュされます。
すべてのパイプラインは、少なくとも1つのソースと1つのシンクを必要とします。
パイプの残りのステージでは、データのフィルタリングから変更、ルーティング、削減まで、さまざまな処理を行うことができます。
コルーチンはこれらの操作を行うための自然な候補であり、send()
操作で互いにデータを渡すことができ、エンドポイントの消費者としても機能します。
次の例を見てみましょう。
def producer(cor):
n = 1
while n < 100:
cor.send(n)
n = n * 2
@coroutine
def my_filter(num, cor):
while True:
n = (yield)
if n < num:
cor.send(n)
@coroutine
def printer():
while True:
n = (yield)
print(n)
prnt = printer()
filt = my_filter(50, prnt)
producer(filt)
出力
1
2
4
8
16
32
つまり、ここでは producer()
がソースとして動作し、いくつかの値を作成して、シンク (この場合は printer()
) のコルーチンによって印刷される前にフィルターにかけるということです。
my_filter(50, prnt)` はパイプラインの単一の中間ステップとして動作し、引数として自身のコルーチンを受け取ります。
この連鎖はコルーチンの強さを完全に表しています。
コルーチンは大きなプロジェクトに対してスケーラブルで(必要なのはパイプラインにステージを追加することだけです)、簡単にメンテナンスできます(1つの変更でソースコード全体を書き換える必要はありません)。
オブジェクトとの類似性
鋭い目を持つプログラマは、コルーチンがPythonのオブジェクトとある種の概念的な類似性を持っていることに気が付くかもしれません。
必要な事前定義からインスタンスの宣言と管理まで。
なぜ、オブジェクト指向プログラミングではなく、コルーチンを使うのかという疑問が湧くでしょう。
コルーチンは、関数定義が1つで済み、しかも高速に動作するという利点があります。
次のコードを見てみましょう。
class obj:
def __init__(self, value):
self.i = value
def send(self, num):
print(self.i + num)
inst = obj(1)
inst.send(5)
def coroutine(value):
i = value
while True:
num = (yield)
print(i + num)
cor = coroutine(1)
next(cor)
cor.send(5)
この2つのコードを timeit
モジュールを使って1万回実行するとどうなるか見てみましょう。
オブジェクト | コルーチン |
---|---|
0.791811 | 0.6343617 |
0.7997058 | 0.6383156 |
0.8579286 | 0.6365501 |
0.838439 | 0.648442 |
0.9604255 | 0.7242559 |
両者とも同じ単純作業を行うが、2番目の例の方がより速い。
オブジェクトの self
ルックアップがないため、速度が向上しています。
よりシステムに負荷のかかるタスクの場合、この機能は従来のハンドラオブジェクトの代わりにコルーチンを使う説得力のある理由となります。
コルーチン使用時の注意点
send() メソッドはスレッドセーフでない
import threading
from time import sleep
def print_number(cor):
while True:
cor.send(1)
def coroutine():
i = 1
while True:
num = (yield)
print(i)
sleep(3)
i += num
cor = coroutine()
next(cor)
t = threading.Thread(target=print_number, args=(cor,))
t.start()
while True:
cor.send(5)
send()は適切に同期されておらず、またスレッド関連の誤呼び出しに対する固有の保護もないため、次のようなエラーが発生しました:
ValueError: generator already executing`.
コルーチンと並行処理を混ぜる場合は、細心の注意を払って行う必要があります。
コルーチンをループさせることはできない。
def coroutine_1(value):
while True:
next_cor = (yield)
print(value)
value = value - 1
if next_cor != None:
next_cor.send(value)
def coroutine_2(next_cor):
while True:
value = (yield)
print(value)
value = value - 2
if next != None:
next_cor.send(value)
cor1 = coroutine_1(20)
next(cor1)
cor2 = coroutine_2(cor1)
next(cor2)
cor1.send(cor2)
同じ ValueError
が顔を出しています。
これらの簡単な例から、 send()
メソッドは一種のコールスタックを構築し、ターゲットが yield
ステートメントに到達するまで返さないことが推測されます。
このように、コルーチンの使用は太陽と虹のようなものばかりではなく、適用する前に注意深く考えなければなりません。
結論
コルーチンは、通常のデータ処理機構に代わる強力な代替手段を提供します。
コルーチンのライフサイクルを通じた変数の持続性から利益を得ながら、コードの単位を簡単に組み合わせ、変更し、書き直すことができます。
コルーチンは熟練したプログラマの手にかかると、よりシンプルな設計と実装を可能にし、同時に大幅な性能向上をもたらす有意義な新しい道具となる。
また、単純な処理に落とし込むことで、プログラマの労力と時間を節約することができ、さらに、余計なオブジェクトを詰め込まないことで、単純な処理しかできないコードにすることができます。