目次


Python でのオンデマンド・データ, 第 1 回

Python のイテレーターとジェネレーター

Python でプリエンプティブな方法ではなく、オンデマンドで効率的にデータを処理する方法を学ぶ

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: Python でのオンデマンド・データ, 第 1 回

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:Python でのオンデマンド・データ, 第 1 回

このシリーズの続きに乞うご期待。

市場原理によってメモリー、ディスク、さらには CPU 能力の価格が以前からは考えられないほど低くなっている時代に生きている私たちは幸せ者です。けれどもそれと同時に、ビッグデータ、AI、コグニティブ・コンピューティングなどのアプリケーションが急速に普及していることから、目のくらむような勢いでコンピューティング・リソースの要件が高くなっていることも事実です。皮肉なことに、計算リソースが有り余るほどある時代でありながら、競争力を維持する上では、開発者がリソースの消費量を抑える方法を理解することの重要性が増しています。

約 20 年間にわたり、Python がこれほどまでよく使われているプログラミング言語であり続けている主な理由は、簡単に習得できることにあります。1 時間足らずで、リストと辞書を操作するのがいかに簡単であるかがわかるはずです。ただし、アプリをスケーリングするとなると、手当たり次第にリストと辞書を使って問題を解決するという単純な手法では、たちまち問題に突き当たります。注意を怠っていると、Python は他のプログラミング言語よりもかなり多量のリソースを消費する傾向があるためです。

幸い、Python には処理の効率化を促す有用な機能がいくつかあります。これらの機能の多くが基礎としているのは、このチュートリアルで主題として取り上げる、Python のイテレーター・プロトコルです。今回のチュートリアルの内容を基に、全 4 回からなるこのチュートリアル・シリーズ全体を通して、大規模なデータ・セットを Python で効率的に処理する方法を説明します。

このチュートリアル・シリーズでは、読者に Python の基礎知識 (条件、ループ、関数、例外、リスト、辞書など) があることを前提としています。また、このシリーズで重点を置くのは Python 3 です。チュートリアルのコードを実行するには、Python 3.5 以降のバージョンが必要となります。

イテレーター

ほぼ間違いなく、皆さんが最初に学んだ Python ループは以下のようなコードだったでしょう。

for ix in range(10):
    print(ix)

Python の for ステートメントは、イテレーターと呼ばれるものに対して作用します。イテレーターとは、何度も呼び出すことで一連の値を生成できるオブジェクトのことです。in キーワードの後に続く値がイテレーターになっていない場合、for はそれをイテレーターに変換しようとします。上記の range 組み込み関数は、イテレーターに変換できる関数の一例です。この関数がイテレーターに変換されると、一連の数値を生成します。生成された数値は、for ループによって繰り返し処理されて、順番に変数 ix に代入されていきます。

これから、Python についての理解を深めるために、range などのイテレーターを詳しく見ていきます。Python インタープリターに、以下の式を入力してください。

r = range(10)

これで range イテレーターは初期化されましたが、それだけです。イテレーターを実際に使用するために、最初の値を要求します。Python でイテレーターに値を要求するには、next 組み込み関数を使用します。

>>> r = range(10)
>>> print(next(r))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'range' object is not an iterator

上記では例外が返されています。この例外は、オブジェクトをイテレーターとして使用するには、その前にイテレーターに変換しなければならないことを意味します。そこで、オブジェクトをイテレーターに変換できる iter 組み込み関数を使用します。

r = iter(range(10))
print(next(r))

期待通り、今回は 0 が出力されます。続いてもう一度 print(next(r)) を入力すると、1 が出力されるといった具合です。この同じ行の入力を繰り返してください。ありがたいことに、ほとんどのシステム上では、Python インタープリター上で上矢印キーを押すと前回使用したコマンドを取得できるので、その後に Enter キーを押すだけで、コマンドを再実行できます。さらに必要に応じて、Enter キーを押す前に微調整することもできます。

この例の場合、最終的には以下のようになります。

>>> print(next(r))
9
>>> print(next(r))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

10 個の整数の範囲を要求しているだけなので、9 が出力された時点で範囲の終わりに達します。この時点でイテレーターが何らかの動作によって範囲の終了を即座に示すことはしませんが、その後に next() を呼び出すと、StopIteration 例外が発生します。あらゆる例外の場合と同じく、この例外を処理する独自のコードを作成することもできます。イテレーター r を使い尽くした後で、以下のコードを試してください。

try:
    print(next(r))
except StopIteration as e:
    print("That's all folks!")

このように、「That's all folks! (これで終わりです!)」というメッセージが出力されます。上記のコードにより、for ステートメントが StopIteration 例外を基準に、ループを終了するタイミングを判断するという仕組みです。

その他のイテラブルなオブジェクト

range の他にも、イテレーターに変換できる類のオブジェクトがあります。以下のインタープリター・セッションは、標準的なさまざまなタイプがどのようにイテレーターとして解釈されるかを示します。

>>> it = iter([1,2,3])
>>> print(next(it))
1
>>> it = iter((1,2,3))
>>> print(next(it))
1
>>> it = iter({1: 'a', 2: 'b', 3: 'c'})
>>> print(next(it))
1
>>> it = iter({'a': 1, 'b': 2, 'c': 3})
>>> print(next(it))
a
>>> it = iter(set((1,2,3)))
>>> print(next(it))
1
>>> it = iter('xyz')
>>> print(next(it))
x

リストやタプルの場合は、かなり単純明快です。辞書では、キーだけを繰り返し処理します。もちろん、その順序は保証されません。繰り返し処理の順序は set の場合でも保証されません。この場合、イテレーターから返される最初のアイテムはセットを作成するために使用されるタプル内の最初のアイテムとなりますが、これが順序の保証を意味するわけではありません。文字列では、文字列に含まれる各文字を繰り返し処理します。このようなオブジェクトのすべてが「イテラブル」なオブジェクトと呼ばれます。

ご想像どおり、すべての Python オブジェクトをイテレーターに変換できるというわけではありません。

>>> it = iter(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'int' object is not iterable
>>> it = iter(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'NoneType' object is not iterable

イテレーターの醍醐味は、当然、独自のイテレーター型を作成できることです。特別な名前を付けたメソッドを含めたクラスを定義するだけで、独自のイテレーター型を作成できます。その方法についての具体的な説明は、このチュートリアル・シリーズの範囲外ですが、ご心配なく。独自のカスタム・イテレーターを作成するのに最も簡単な方法は、特殊なクラスを定義することではなく、ジェネレーター関数と呼ばれる特殊な関数を使用することだからです。続いて、ジェネレーター関数について説明します。

ジェネレーター

関数の概念はご存知のとおり、何らかの引数を取り、値を返して呼び出し元に戻るか、None で終了するというものです。関数には複数の出口点や return ステートメントがある場合もあります。あるいは、関数の行が最後にインデントされるだけの場合もありますが (このコードは、return None と同じことです)、関数が実行されるたびに毎回、関数内の条件に応じて出口点のうちの 1 つだけが選択されます。

ジェネレーター関数は、それを呼び出したコードと、より複雑ながらも有用な方法でやり取りする特殊なタイプの関数です。以下に単純な例を示します。このコードをインタープリター・セッションに貼り付けてください。

def gen123():
    yield 2
    yield 5
    yield 9

上記のコードのように、関数の本文に少なくとも 1 つ以上の yield ステートメントを含めると、関数はジェネレーター関数になります。この微妙な 1 つの違いだけで、通常の関数がジェネレーター関数になるわけですが、通常の関数とジェネレーター関数との間には大きな違いがあるので注意が必要です。

ジェネレーター関数は、他のあらゆる関数を呼び出すときと同じ方法で呼び出します。

>>> it = gen123()
>>> print(it)
<generator object gen123 at 0x10ccccba0>

ジェネレーター関数を呼び出すと、関数はすぐに呼び出し元に戻りますが、関数本文内で指定された値は返されません。ジェネレーター関数を呼び出す場合は常に、ジェネレーター・オブジェクトと呼ばれるものが返されます。ジェネレーター・オブジェクトとは、ジェネレーター関数の本文に含まれる yield ステートメントから値を生成するイテレーターのことです。標準的な言い方をすると、ジェネレーター・オブジェクトは一連の値になります。前のコード・スニペットに示したジェネレーター・オブジェクトを詳しく見ていきましょう。

>>> print(next(it))
2
>>> print(next(it))
5
>>> print(next(it))
9
>>> print(next(it))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

オブジェクトに対して next() を呼び出すたびに、次の yield 値が返され、yield 値がなくなると StopIterator 例外が返されます。もちろん、この関数はイテレーターなので、for ループ内で使用できます。ただし、最初のジェネレーター・オブジェクトは使い尽くされているので、忘れずに新しいジェネレーター・オブジェクトを作成してください。

>>> it = gen123()
>>> for ix in it:
...     print(ix)
... 
1
2
3
>>> for ix in gen123():
...     print(ix)
... 
1
2
3

ジェネレーター関数の引数

ジェネレーター関数が引数を受け入れると、その引数はジェネレーターの本文に渡されます。以下のジェネレーター関数を貼り付けてください。

def gen123plus(x):
    yield x + 1
    yield x + 2
    yield x + 3

関数に渡す引数を変えて、このジェネレーター関数を試してください。以下に一例を示します。

>>> for ix in gen123plus(10):
...     print(ix)
... 
11
12
13

ジェネレーター・オブジェクトを繰り返し処理する間、そのジェネレーター関数は中断され、処理が完了すると再開されるという状態になります。これが、Python 関数での新しい概念です。この概念では実質的に、複数の関数から重複する形でコードを実行できます。以下のセッションを見てください。

>>> it1 = gen123plus(10)
>>> it2 = gen123plus(20)
>>> print(next(it1))
11
>>> print(next(it2))
21
>>> print(next(it1))
12
>>> print(next(it1))
13
>>> print(next(it2))
22
>>> print(next(it1))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> print(next(it2))
23
>>> print(next(it2))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

上記のセッションでは、1 つのジェネレーター関数から 2 つのジェネレーター・オブジェクトを作成しています。これで、いずれかのジェネレーター・オブジェクトから次のアイテムを取得できます。それぞれのオブジェクトが独立して中断、再開されていることに注目してください。2 つのジェネレーター・オブジェクトはあらゆる点で独立しています。これには、オブジェクトがどのように StopIteration 状態になるかという点も含まれます。

確実に理解するまで、このセッションでの動作を十分に調べてください。この動作を理解すれば、ジェネレーターの基本と、ジェネレーターが強力な機能であるのはなぜなのかを把握したことになります。

通常の位置指定引数とキーワード引数のすべても使用できます。

ジェネレーター関数内でのローカル状態

ジェネレーター関数内では、条件、ループ、ローカル変数を使用した通常の処理をどれでも行うことができます。こうした処理を組み合わせることで、極めて高度な特化されたイテレーターを作成できます。

次の例で面白いことを試してみましょう。誰もが天気に振り回されることに飽き飽きしています。それならば、自分自身の天気を作成しましょう。リスト 1 は、時折コメントを織り交ぜて、一連の晴れた日または雨の日を出力する気象シミュレーターです。

天気について考えると、晴れの日の翌日は晴れて、雨の日の翌日は雨になることがよくあります。これをシミュレートするために、翌日の天気をランダムに、ただし同じ天気が続く確率が大きいほうを優先して選択します。非常に変わりやすい天気を一言で表すと、「volatile (不安定)」です。そこで、このジェネレーター関数では volatility という引数を使用して、その値を 0 から 1 の範囲で設定します。この引数の値が小さいほど、翌日も同じ天気が続く可能性が高いことを意味します。以下のリストでは、volatility の値が 0.2 に設定されています。この値は、5 つの遷移のうち、平均で 4 つが同じ状態を維持することを意味します。

このリストでは追加機能として、晴れの日または雨の日が 3 日以上連続すると、コメントが掲載されるようにしています。

リスト 1. 気象シミュレーター
import random

def weathermaker(volatility, days):
    '''
    Yield a series of messages giving the day's weather and occasional commentary

    volatility - a float between 0 and 1; the greater this number the greater
                    the likelihood that the weather will change on each given day
    days - number of days for which to generate weather
    '''
    #Always start as if yesterday were sunny
    current_weather = 'sunny'
    #First item is the probability that the weather will stay the same
    #Second item is the probability that the weather will change
    #The higher the volatility the greater the likelihood of change
    weights = [1.0-volatility, volatility]
    #For fun track how many sunny days in a row there have been
    sunny_run = 1
    #How many rainy days in a row there have been
    rainy_run = 0
    for day in range(days):
        #Figure out the opposite of the current weather
        other_weather = 'rainy' if current_weather == 'sunny' else 'sunny'
        #Set up to choose the next day's weather. First set up the choices
        choose_from = [current_weather, other_weather]
        #random.choices returns a list of random choices based on the weights
        #By default a list of 1 item, so we grab that first and only item with [0]
        current_weather = random.choices(choose_from, weights)[0]
        yield 'today it is ' + current_weather
        if current_weather == 'sunny':
            #Check for runs of three or more sunny days
            sunny_run += 1
            rainy_run = 0
            if sunny_run >= 3:
                yield "Uh oh! We're getting thirsty!"
        else:
            #Check for runs of three or more rainy days
            rainy_run += 1
            sunny_run = 0
            if rainy_run >= 3:
                yield "Rain, rain go away!"
    return

#Create a generator object and print its series of messages
for msg in weathermaker(0.2, 10):
    print(msg)

weathermaker 関数ではよく使われるプログラミング機能の多くを使用していますが、それと同時にジェネレーターに伴う興味深い側面も明らかにしています。その 1 つは、生成されるアイテムの数は固定されないことです。日数の分だけアイテムが生成されることもあれば、連続する晴れの日または雨の日に関するコメントにより、日数を上回る数のアイテムが生成されることもあります。アイテムは、異なる条件分岐の結果として生成されます。

このリストを実行すると、以下のような出力が表示されるはずです。

$ python weathermaker.py
today it is sunny
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is rainy
today it is sunny
today it is rainy
today it is rainy
today it is rainy
Rain, rain go away!
today it is rainy
Rain, rain go away!

当然、このリストはランダム性に基づいていて、5 回中 4 回は同じ天気が続くことから、以下のような出力になることもあります。

$ python weathermaker.py
today it is sunny
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!
today it is sunny
Uh oh! We're getting thirsty!

時間をかけて自分でいろいろと試してみてください。とりわけ、volatility 引数と days 引数で異なる値を渡してから、ジェネレーター関数コード自体を微調整することをお勧めします。ジェネレーターがどのように機能するのかを確実に理解するには、実験してみるのが最良の方法です。

少々趣向を凝らしたこの例によって、ジェネレーターの力に関する皆さんの想像力が刺激されたことを願います。確かにジェネレーターを使用しなくても上記のコードを作成することはできますが、ジェネレーターを使用した手法はより表現力豊かで、通常はより効率的です。それだけではありません。リストの最後にある単純なループに加え、weathermaker ジェネレーターを他の興味深い方法で再利用できるという利点もあります。

ジェネレーター式

ジェネレーターの一般的な使用法は、1 つのイテレーターを繰り返し処理して何からの方法で操作し、変更されたイテレーターを生成することです。

イテレーターを取り、指定された代替単語のセットに応じて検出された値を順に代入するジェネレーターを作成しましょう。

def substituter(seq, substitutions):
    for item in seq:
        if item in substitutions:
            yield substitutions[item]
        else:
            yield item

以下のセッションで、このジェネレーターを使用する例を確認できます。

>>> s = 'hello world and everyone in the world'
>>> subs = {'hello': 'goodbye', 'world': 'galaxy'}
>>> for word in substituter(s.split(), subs):
...     print(word, end=' ')
... 
goodbye galaxy and everyone in the galaxy

このジェネレーターについても、これがどのように機能するかを明確に理解するまで、異なるループ操作を使用していろいろと試してください。

この類の操作はかなりよく使用されるため、Python にはジェネレーター式と呼ばれる便利な構文が用意されています。ジェネレーター式を使用して上記のセッションを実装すると、以下のようになります。

>>> words = ( subs.get(item, item) for item in s.split() )
>>> for word in words:
...     print(word, end=' ')
... 
goodbye galaxy and everyone in the galaxy

要するに、for 式を括弧で囲むとジェネレーター式になります。生成されるオブジェクト (この例の場合は単語に割り当てられるオブジェクト) は、ジェネレーター・オブジェクトです。このような式に合うように、さらに興味深い Python の機能を使用することになる場合もあります。そのような場合、私は辞書に対する get メソッドを利用してキーを検索し、キーが見つからなかった場合に返すデフォルト値を指定するようにしています。item の代入値を検索し、見つかった場合はその値を返し、見つからなかった場合は item をそのまま返すという方法です。

リスト内包表記の復習

よくご存知だと思いますが、リスト内包表記では同様の構文で、大括弧を使用します。リスト内包表記の結果はリストになります。

>>> mylist = [ ix for ix in range(10, 20) ]
>>> print(mylist)
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]

ジェネレーター式の構文も似ていますが、ジェネレーター式が返すのはジェネレーター・オブジェクトです。

>>> mygen = ( ix for ix in range(10, 20) )
>>> print(mygen)
<generator object <genexpr> at 0x10ccccba0>
>>> print(next(mygen))
10

上記の 2 つの例にある実質的な大きな違いとして、最初の例で作成されるリストは、作成された瞬間からそこに居座り、リストに値を格納するために必要なすべてのメモリーを消費します。一方、ジェネレーター式はそれほど多くのストレージを使用しません。ジェネレーター関数の本体と同じように、繰り返し処理されるたびに中断され、処理が完了すると再開されます。事実上、ジェネレーター式を使用すれば、あらかじめデータを用意しておくのではなく、オンデマンドでデータを取得できます。

即席で作った例えで説明すると、一家の牛乳消費量が 1 年間で 200 ガロンだとしても、それだけの量の牛乳を貯蔵するための設備を地下室に作ることはしたくありません。それよりも、牛乳が必要になった時点で食料品店に行って、毎回 1 ガロンずつ購入するほうが賢明です。常にリストを作成するのではなくジェネレーターを使用するということは、自宅に貯蔵施設を作るのでなく食料品店を利用することと似ています。

辞書式もありますが、これについてはこのチュートリアルの範囲外です。ジェネレーター式は簡単にリストに変換できます。場合によってはリストに変換するという方法でジェネレーターをいっせいに使用することもありますが、注意しなければ、メモリーを大量に消費するリストを作成してしまう恐れがあります。そうなると、ジェネレーターを使用する目的が台無しになります。

>>> mygen = ( ix for ix in range(10, 20) )
>>> print(list(mygen))
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]

このチュートリアル・シリーズでは折に触れて、ジェネレーターからリストを作成する方法をデモンストレーションします。

フィルタリングとチェーニング

ジェネレーター式内で単純な条件を使用するだけで、入力イテレーターからアイテムをフィルタリングすることができます。以下の例では、2 の倍数でも 3 の倍数でもない 1 から 20 までのすべての数値を生成します。この例で使用している math.gcd は、2 つの整数の最大公約数 (GCD) を返す便利な関数です。例えば、ある数値と 2 の GCD が 1 だとすると、その数値は 2 の倍数ではありません。

>>> import math
>>> notby2or3 = ( n for n in range(1, 20) if math.gcd(n, 2) == 1 and math.gcd(n, 3) == 1 )
>>> print(list(notby2or3))
[1, 5, 7, 11, 13, 17, 19]

上記の例には、ジェネレーター式内に if 式を直接埋め込む方法が示されています。ジェネレーター式の中で for 式をネストすることもできます。ただし、この場合もそうですが、ジェネレーター式はジェネレーター関数を簡潔にするための構文であるため、かなり複雑なジェネレーター式が必要になってきた場合は、ジェネレーター関数をそのまま使用したほうが読みやすいコードになることも考えられます。

ジェネレーター式を含め、ジェネレーター・オブジェクトを連鎖させることもできます。

>>> notby2or3 = ( n for n in range(1, 20) if math.gcd(n, 2) == 1 and math.gcd(n, 3) == 1 )
>>> squarednotby2or3 = ( n*n for n in notby2or3 )
>>> print(list(squarednotby2or3))
[1, 25, 49, 121, 169, 289, 361]

このようなジェネレーターのチェーニングは、強力で効率的なパターンとなります。上記の例では、最初の行でジェネレーター・オブジェクトを定義していますが、オブジェクトの処理はまったく行われません。2 番目の行で定義している 2 つ目のジェネレーター・オブジェクトは、最初のジェネレーター・オブジェクトを参照しますが、これらのオブジェクトの処理はいずれも行われません。この例の場合はリスト・コンストラクターによって完全な繰り返し処理が要求された時点で、すべての処理が行われます。このように必要に応じて繰り返し処理を行うという概念を、遅延評価と呼びます。遅延評価は、ジェネレーターを使用して適切に設計されたコードに伴う特徴の 1 つです。ただし、ジェネレーターやジェネレーター式のチェーニングを使用する際は、繰り返し処理を実際にトリガーするものが必要であることに注意してください。この例の場合は list 関数によって繰り返し処理がトリガーされますが、for ループを使用してトリガーすることもできます。あらゆる類のジェネレーターをセットアップした後、繰り返し処理をトリガーするのを忘れて、コードが何も処理しない原因に頭を悩ませるのは、よくありがちな過ちです。

遅延処理の価値

このチュートリアルでは、イテレーターの基礎と、イテレーター、ジェネレーター関数、ジェネレーター式のとりわけ興味深いソースについて説明しました。

このチュートリアルに記載したすべてのコードは確かにジェネレーターを使わなくても作成できますが、ジェネレーターの使用方法を学ぶことで、このシリーズで発展させていく概念やデータの処理に対する柔軟かつ効果的な考え方をより理解できるようになります。前述の例えを繰り返すと、1 年分の牛乳を賄うための貯蔵設備を自宅に設けるよりも、一度に必要なだけの牛乳を食料品店から買うほうが理に適っています。これと同じような手法を、開発者は遅延評価と呼んでいます。けれども、遅延処理でより大きな重点が置かれるのは、必要なものを必要なときに取得するタイミングです。1 日おきに食料品店に足を運ぶのであれば、遅延とは呼ぶほどではありません。同様に、ジェネレーターを使用するコードを作成するとなると多少手間がかかり、その作業に手が離せなくなってしまうことさえありますが、その報いは、よりスケーラブルな処理という形で返ってきます。

イテレーターとジェネレーターを学ぶことは、Python を習得する上での重要なステップの 1 つです。さらに、別の重要なステップとして、標準ライブラリーに用意されている多数の素晴らしいツールを学ぶことも欠かせません。それが、このシリーズの次回のチュートリアルで取り上げるトピックです。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Cloud computing
ArticleID=1064512
ArticleTitle=Python でのオンデマンド・データ, 第 1 回: Python のイテレーターとジェネレーター
publish-date=01242019