🤔

図解「generator・native coroutine・with」 〜 関心やコードを分離する文法と、処理順序・構造

2020/10/25に公開

概要

※この記事はQiita/Qrunchに書いた記事の再録です。
この記事では、以下のような事を扱います。

  • (Pythonの)generator、native coroutine、withの文法と、普通の関数呼び出しとの違いを図解で説明
  • コルーチン・native coroutineという言葉にまつわるPythonの"混乱"の整理
  • これらの文法のメリット・よりバグを生みにくいコードの書き方についての考察

細かい関数に分けることと、スパゲティコードと、バグりにくいコードと...というような事について、図解を交えながら、文法的な部分から考察します。native coroutineといった用語の話以外は、必ずしもPythonに限定した話ではないので、他の言語を使用されている方もある程度は自然に読めるかなと思います。

なお、似たような観点で、コールバックやポリモーフィズムを扱った姉妹記事もあります。(こちらはJavaScriptですが)
コールバックと、ポリモーフィズムと、それからコルーチンを構造的に見る

関数呼び出し

Pythonでは、defという語を用いて、関数を定義することができます。
次のコード断片では、fという名前の関数を定義します。

def f(name: str) -> str:
    return 'Hello, ' + name

f('taro')  # Hello, taro

定義した関数は、関数名の後ろに()をつけて記述することによって 呼び出す(call) ことができます。

関数を構成する要素には引数(argument)戻り値(return value) というものがあり、関数には引数を受け取って戻り値を返す、という機能性があります。関数定義において、defと書いた行の()の中身が引数で、returnの後ろに書いてあるものが戻り値です。

そもそも関数とは

中学や高校で習う数学においては、関数とは「ある二つの数量x,yにおいて、xの値に対して対応するyの値が一つ定まるとき、yxの関数であるという。」などと定義されています。このような関係のとき、yxによって決まる ことを強調して、y=f(x)というような書き方をするのでした。
yの話をしていたのに、いきなり出てくるfは一体何だ?」
と思うのですが、xの値に対して対応する値を計算する決め方のルールのことをf(x)と表現しているのでした。
このような関数の考え方と先程の関数定義を対比すると、xは引数、yは戻り値ということになります。また、高校までの数学における関数は数と数の対応でしたが、先程のものは文字列(str)と文字列の対応 になっているのでした。

しかし、Pythonに限らず、プログラミングにおける関数にはもう少し別の側面があります。
例えば、高校までの数学で、関数を呼び出すというようなことは、普通は言わないはずです。証明の問題で「関数 f(x)=x^2 を呼び出すと」などという事を書いている人はほとんど居ないと思います。これは、プログラミングにおける関数には、数学における関数とは少し別の由来があることを意味します。

手続きとルーチン

プログラミングは、しばしばコンピュータに対する命令をまとめる作業に例えられます。
実際、Pythonをコマンドラインで起動すると、以下のような文字列が表示され、Pythonは命令待ち状態になります。

Python 3.7.7 (default, Mar 10 2020, 15:43:27) 
[Clang 10.0.0 (clang-1000.11.45.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

この >>> というのは、人間からの指示を待っている事を意味していて、Pythonの言語仕様に沿った命令(コマンド)を書くと、それに従ってPythonが適当な計算を行うようになっています。ゲームなどによってすっかり馴染みのある「コマンド」という単語ですが、これは「命令する」という意味ですね。

さて、いくつかのコマンドを並べる事によって、まとまった手続き をPythonに行わせる事ができます。例えば、以下のようにして、幅20・高さ45の長方形の面積を求める事ができます。(これはPythonの公式チュートリアルから引っ張ってきた例です。なぜそのようなものを計算しているのかは不明ですが...)

>>> width = 20
>>> height = 5 * 9
>>> width * height
900

このような手続きについて、頻繁に用いる決まった手続き:ルーチン として記述する事で、再利用しやすくできます。Pythonを含む多くのプログラミング言語では、関数をルーチンの定義に用いることもできます。例えば、以下のようにして、関数にルーチンの機能をもたせることができます。

def okan():
    print('飯を食え')
    print('歯を磨け')
    print('着替えろ')

okan()
# 飯を食え
# 歯を磨け
# 着替えろ

このようにして関数をルーチンとして扱う際には、「関数に代入する」という考え方よりも、「関数(ルーチン)を呼び出す」という考え方の方がよりしっくりと来る場合があります。そこで、関数に対しても「呼び出す」という言葉遣いをする場合があるのでした。

ちなみに、ここで出てくるprint()というのも関数で、print()は引数に指定した文字列を画面などに出力するのでした。関数の中でさらに別の関数を呼び出すという、関数の呼び出しの連続によってプログラミングは行われている、 と言っても過言ではありません。呼び出しという部分について興味を持った方は、ぜひコールスタックというものを調べてみてください。(例えば、【図解】コールスタックとクロージャーを理解する

ちなみに、先程のokan()には引数は存在せず、戻り値も存在しません。高校までの数学では、そのような関数は存在しない(引数と戻り値以外には関数の要素というものはない)のですが、プログラミングの関数においては、引数や戻り値以外にも、何らかの作用 が存在するということになります。

関数呼び出しの図解

さて、ようやく本題の図解に入ります。以下のようなプログラムがあったとします。

def main():
    print('first')
    sub_routine()
    return


def sub_routine():
    print('second')
    return


main()

このプログラムが実行される時の処理の流れを、行ごとに分割して可視化すると以下のようになります。

main()を実行するときには、途中でsub_routine()が呼ばれますが、sub_routine()が呼ばれてからはその処理を一通り実行して戻ってくる、というような流れになっています。
重要なポイントとしては、サブルーチンが呼び出されてからは終了するまでmain()に戻ってくることはないということです。

しかし、一般にルーチンを作る時、AルーチンとBルーチンの間で行ったり来たりした方が表現がわかりやすい、という事があるかもしれません。例えば、さきほど例であげたokan()は言いたい事を全て言うだけのokanになっていますが、「飯を食え」と言った後は子供が飯を食べるまで待ち、その後に「歯を磨け」と言った方が適切なように思われます。このような、ある種の対話性を持ったルーチン呼び出しが求められる場合があります。そこで、コルーチンというものを考えます。

コルーチン

コルーチンは、プログラミング言語全般における用語です。単純な関数呼び出しによるサブルーチンとは異なり、呼び出し元と呼び出し先を行ったり来たりするような関係性のルーチンを指します。コルーチンのコとは、コラボのコと同じで、「共に」というようなニュアンスの接頭語です。
後ほど詳しく説明しますが、Pythonでは別の概念に対して「coroutine」という語を用いているため、この記事ではコルーチンと書いた時には今述べた意味でのコルーチンを表すものとします。

Pythonで、一般的なコルーチンを実現するには、generatorというものを用います。文法的には、yieldというものをreturnの代わりに書くだけです。

def main():
    a = simple()
    print('first')
    x = a.send(None)
    print(x)  # third
    y = a.send(None)
    print(y)  # fifth


def simple():
    print('second')
    yield 'third'
    print('forth')
    yield 'fifth'


main()

では、これを図解してみます。

まず、generatorの変わった特徴として、simple()のように呼び出しをしても、そのタイミングでは後続の処理が行われないという事があります。simple()は処理を行うのではなくて、処理を順番に行うための「generator object」を返します。このgenerator objectに対してsend()というメソッドが定義されているので、send()を繰り返すことによって実際の処理を行う、という流れになっています。単純な関数呼び出しとは異なり、呼び出し元のルーチンと呼び出し先のsimple()というルーチンを行ったり来たりしていることになります。

このsimple()の'third'や'fifth'を返却しているところで、'飯を食え'や'歯を磨け'を返すようにすれば、言いたいことを言って終了するだけではないokan() を作る事ができます。

なお、この例では処理順序に注目をしているため説明を省略したのですが、generatorの重要な特徴として、yieldした後にsend()するとその時の状態を引き継いで処理が継続される という事があります。処理の順序だけではなく、関数の中で計算した変数の値などもそのまま再利用できるため、柔軟な書き方ができる文法になっています。

ただ、この書き方では、複数のokan()が同時に処理をするような場合をうまく記述することができません。同時に一人のokan()しか扱うことができないので、複数のokan()が居る家庭では一人ずつokan()が行動することになり、実に効率の悪い家庭になってしまいます。 (どういう家庭だろうか)

native coroutine(Pythonでいうcoroutine)・async/await

複数のokan()を効率よく扱う方法として、Pythonではnative coroutineというものを用意しています。これは、先程のカタカナのコルーチンとはまた違う意味を持っているのですが、説明していきます。

...okan()の例が少し苦しくなってきたので、真面目な話をします。
多くのコンピューターには複数の計算装置が備わっており、これらのリソースをできる限り並列で動かした方が計算効率がよくなります。また、計算装置を扱う場合以外でも、例えば複数箇所との通信が必要なプログラムにおいて、通信を同時に投げておいてから、返答があったものから順番に処理をするというような仕組みにした方が効率的な場合があります。
そうしたプログラミングを行うときは、これまでの図解でみたフローのような常に一本道のフローではなくなります。例えば、同時に三つの道を並行で通る、というような計算の仕方が必要になるのです。

それを、Pythonではどのような文法で表現すればよいのでしょうか。

以下に、それを実現するnative coroutineのサンプルを示します。

import asyncio
import random

async def main():
    print('first')
    await asyncio.gather(
        native_coroutine(1),
        native_coroutine(2),
        native_coroutine(3),
    )


async def native_coroutine(x):
    await asyncio.sleep(
        random.random())
    print(x)


asyncio.run(main())

このプログラムを実行すると、firstと出力されたあとに、ランダムで1,2,3が並び替わって出力されます。
await asyncio.sleep(random.random())
というところが、ランダムで0〜1秒待つという処理になっているので、その時間の長さによって1,2,3のどれが先に出るかが変わってくる、というような並列処理のサンプルです。
これまでの文法との大きな違いは、defの前にさらにasyncという用語が付いたり、関数呼び出しの前にawaitという用語が付いたりしている部分ですが、それらの説明の前に、まずはどのような順序で処理が行われるかをフローで見ます。

まず、asyncの付いている関数定義は、generatorと同じように、呼び出しをしても直ちに実行はされない関数になります。generatorの場合はsend()を都度実行するのでしたが、native coroutineの場合はasyncio.run()やasyncio.gather()などによって実行します。

asyncio.runでmain()を実行すると、まずfirstと出力されますが、次のawaitでnative(1),native(2),native(3)の結果が返されるまで処理を待つようになります。
native(1),native(2),native(3)は"同時に"実行されます。その事を模式的に表現したのが三本の矢印たちです。

ところで、このフローを見ると、(カタカナ表記の)コルーチンのような行ったり来たりする構造がありません。
どういうことでしょうか。

実は、await asyncio.gather()をすると、その引数のnative coroutine達の処理が終了するか、またはタイムアウトするまで待ち続けてしまう ので、コルーチンにおける行ったり来たりという処理ができないのです。ですが、Pythonではこれを(native) coroutineと呼んでいます。

native coroutineという名前がついた経緯

なぜ、このようなモノのことをcoroutineと呼ぶ事になったのか?
その経緯は新雑誌「n月刊ラムダノート」の『「コルーチン」とは何だったのか?』の草稿を公開しますに詳しくまとまっています。

掻い摘んで書くと

JavaScript界隈(やUnity界隈など)で「非同期の処理を含む特殊なコルーチン」の事を、単に「コルーチン」と呼ぶ事があり、それに引きずられた形になっています。

もう少し詳しく書くと、通常、JavaScriptで非同期処理を書こうとすると、コールバック関数を渡すなどして複雑な書き方をする必要がありました。しかし、上記の特殊なコルーチンを使って書くと、同期処理の時のように平易な書き方をすることができた のです。
例えば、対話的okan()のprintを通信処理と思うことにして、各通信処理が終わったらokan()自身にsend()させると、全てのyield(≒通信処理)が完了するまで待つコードを簡単に書くことができ、通信処理などを書く時に複雑なコールバック関数を渡さずに同期処理的な 書き方をすることができるのでした。

ただし、この書き方においては「呼び出した元と行ったり来たりする」という事はありませんでした(自分自身にsend()するので)
でもそのような事情があるとはいえ、元々コルーチンの一種ではあるので、そのような特殊なコルーチンについても単にコルーチンと呼ばれることがあったのでした。
(これは先程のラムダノートの草稿に具体的なJSのコード付きで書いてあります)

これを受けて、Pythonの仕様PEP492に落とし込んだ時にcoroutineと呼ぶことになってしまったのでした。
実は、これを行ったり来たりできるようにした async generator というものも存在していて、それはPEP525でPEP492と同じ方が提案して実際に使えるようになっていますが、この記事ではasync generatorの使い方については省略します。
なお、generatorはPEP342で導入されていますが、ここでははっきりCoroutines via Enhanced Generatorsと書いてあるので、ここのCoroutineの概念ともPEP492の概念は異なる状態になってしまいました。

関数呼び出しやコルーチンと、スパゲティコード

さて、ここで話をがらりと変えてみます。
保守しにくいコードの代表格として、「スパゲティコード」というものが取り上げられます。これは、次のような性質を持つと言われます。

  • goto文が濫用されている
  • グローバル変数など、広い範囲に影響する変数が様々なところで使用され、様々な処理が変数の状態に依存するようになっている

しかし、ではgoto文やグローバル変数などを使わなければスパゲティコードではないのか?というと、そうではありません。
実際、関数呼び出しというのは、プログラムソースを文字列の集まりとして見た時、「その関数の定義された場所に行け」というのと近い意味を持つものであって、gotoに似た機能性があります。そこで無闇に関数を作ってしまうと逆に処理を追いかけにくくなります。
また、コルーチンでyieldしたりsend()したりというのもgotoに近い性質を持ちます。さらにコルーチンでyieldするということは、何らかの状態を保持させたまま元のルーチンに戻るという事なので、関数呼び出しと比較してある種の状態依存性を作り出しているとも言えます。
従って、これらの文法も、悪用するとスパゲティコードを生産する元になってしまうわけです。
しかし一方で、うまく使えば分かりやすい・バグりにくいコードを書くことに繋がります。 うまく使うためには、どうすればよいのでしょうか。その特性を比較してみましょう。

ただ、native coroutineに関しては、主目的が非同期処理を扱うことであるのは明らかなので、ここでは特に関数呼び出しとコルーチンを比較する形で考察をします。

関数呼び出しとコルーチンの比較

コルーチンでyieldによって挟まれている部分を、別の関数に分けて実装することを考えてみます。

コードブロックの呼び出し順序の保証

その場合、関数呼び出しを組み合わせる場合は呼び出し順序を規定することはできません。例えば、関数begin(), middle(), end()を順番に実行してもらいたいと実装した人が思ったとしても、必ずbegin(), middle(), end()という順番で実行されているかどうかを文法は保証してくれません。
また、関数が定義されているコードを読む人も、begin(), middle(), end()が順番に呼ばれるものだと思って読んでくれるとは限りません。(名前から、類推をする可能性はありますが)
一方、コルーチンでは必ず呼び出し順序が保たれますし、yieldを挟んで続けて処理が書かれているので、連続する処理は読みやすくなります。 そのような意味で、ある種の非同期的な処理も記述がしやすくなります。

呼び出し元で次の処理を行う時のわかりやすさ

呼び出し元でコルーチンの続きを取得するにはsend()などのような関数(メソッド)を使いますが、これには「どこまで進んだ続きなのか」という情報が含まれません。begin(), middle(), end()は明確にどの関数が呼ばれるかがわかりますが、send()ではどこから処理が再開するかがわからない ということです。その意味では、呼び出し元のコードを読む時・書く時はsend()だと意味がわかりにくい可能性があります。(ただし、yieldから再開する時にsend()の引数を受け取る事はできるので、それによって分岐させるというような実装はありえます。)

また、もし、1番目のyieldで初期化完了、2番目のyieldで処理、3番目のyieldで後始末、というコルーチンを期待したインターフェイスになっているコードがあったとします。そうすると、もしコルーチン(generator)の中に新しく「処理(yield)」の追加が必要になった場合は、現在の3番目の処理が4番目にずれてしまい、既存の処理に大きく影響を与えてしまう可能性があります。(つまり、そのようにyieldで取り出す時の回数に意味をもたせるような実装はアンチパターン ということになります。)
これに対して、begin(), middle(), end()等とそれぞれ関数で実装していれば、middle2()を追加しても既存コードへの影響はありません。

呼び出し先での返却のしやすさ、不特定回数のデータ取得

一方で、send()だけでyieldの結果が受け取れることを利用して、ループ等で不定回数のデータを取得する という事もできます。
少し見方を変えると、呼び出し元がループでsend()の結果を全て取得するような実装になっていれば、呼び出し先のコルーチン側は自由にyieldで結果を返すことができる という事もできます。このようなコルーチン(generator)の実装は、例えばストリーミング処理等で威力を発揮します。全ての計算・処理が終了する前に、ちょっとずつ結果を返却する、というような事が可能になるからです。Webアプリで、レスポンスを実際に処理するような部分についてはyieldした値を受け取る側のフレームワーク的な部分で実装をしておくことで、返却する内容の計算処理と、その周りの処理について関心・コードをきれいに分離して実装することができます。(例えばFlaskにはそのような実装があります)

そもそも、genertor(発生器)という名称は、何らかの結果を複数回生成・発生させるようなニュアンスで付けられたものなのでした。実際、next()で結果を取得するiteratorと似たようなところがありますね。

総じて、良くも悪くも、コルーチンは呼び出し元や呼び出し先のインターフェイスが簡素になっていると言えます。(send()とyieldでつながっている)

コルーチンを使うと見通しがよくなるところ

他にも使い方は色々あると思いますが、代表的なものとしては以下のようなものがあります。

  • ストリーミングなど、少しずつ・何度も結果を返却する必要があるところ
    • 特に、不特定回数になる場合
    • 全てのデータをメモリに展開するとパンクするような大きなデータを扱う場合
  • 複雑な計算による配列など
    • 特に、無限長になりえる場合
  • 非同期的な扱いをする必要のある対象の表現
    • ゲームなど

いずれの場合も、コルーチンの中では、目的とする計算に注力しやすくなります。

おまけ:withを用いた簡単な処理構造

さきほどの、コルーチンを使うことに関して検討をしたところで、begin(), middle(), end()などと実装をした方が見通し・保守性が良い可能性もありました。
このような場面において適した書き方として、Pythonにはwithを用いた記述があるので、これを簡単に説明しておきます。

def main():
    with EnterExit() as ee:
        print('second')
        x = ee.method()
        print(x)  # third
        # withを抜ける
    return


class EnterExit:
    def __enter__(self):
        print('first')
        return self
    def method(self):
        return 'third'
    def __exit__(
            self, exc_type,
            exc_val, exc_tb):
        print('forth')


main()

このプログラムの処理の流れを見てみましょう。

Pythonでは、withを用いたコードブロックにおいては、その先頭で必ず__enter__()が呼ばれ、末尾または例外による抜け出しで必ず__exit__()が呼ばれる、という事が保証されます。
この構文で使用する代表格は、ファイルのオープン with open('file_name.txt') as f: というようなものかと思います。一般に、ファイルのオープンをしたらクローズが必要になりますが、withでオープンをすると、そのスコープを抜ける時に必ずクローズされる事が保証されます。また、asで開いたファイルをfに代入することによって、スコープ内でfを自由に使うこともできます。

コルーチンで実現した「行ったり来たり」という部分については、実質的にはこのような書き方でも再現することができます。この書き方では、無限に要素を処理するという事は難しいですが、代わりに呼び出し先の処理の先頭と末尾以外の処理を柔軟に書くことができる ようになっています。
また、これはPythonの標準の決まりなので、全てを単純な関数呼び出しに分解した場合とも異なり、呼び出し順序なども意識させることができます。

まとめ

普通の関数呼び出し・コルーチン・native coroutine・withのそれぞれについて、どのように処理が流れるかという事を図解で確認しました。
オブジェクト指向的な考え方では、様々なオブジェクトのメソッドを呼び出す≒関数を呼び出すことによって処理を記述しますが、以下のような条件を満たすことが理想的です。

  • 読むときには追いかけやすい
    • 処理のフローを追いかけやすい
    • 関心事・役割・目的によってまとまったコードになっている
  • 書くときにはある程度のパターンに沿いながら無駄なく簡潔に書きやすい
    • 抜け漏れや、その他の書き間違いが発生しにくい

それを改めて意識するために"図解"をして、お互いの処理がどのようなフローで流れるのかを把握できるようにしてみました。特殊な構文が実際にどのように機能しているか、このような形で見ると分かりやすいのではないかと思いました。
適切な書き分けを意識して、保守性の高いコードを書きたいですね!

Discussion