目次


魅力的なPython: itertoolsモジュールの中で組み合わせ関数を使う

Pythonの関数型プログラミングは緩慢になります

Comments

新しいコンセプトの理解

イテレーターの発想はPythonのバージョン2.2で導入されました。その発想のヒントは、古い関数であるxrange()およびファイルメソッドの.xreadlines()の中に既にありましたので、バージョン2.2で始めて導入されたというのは完全に正しいわけではありません。Python2.2では、yieldキーワード(yieldの存在は関数を、順にイテレーターを返すジェネレーターに変化させます)が導入されたことにより、多くの内部実装における概念が一般化され、カスタム・イテレーターのプログラミングが非常に簡単になりました。

イテレーターを実装したことにより、モチベーションは2倍になりました。シーケンスとしてデータで作動することは多くの場合最も簡単なアプローチであり、また線形順序で処理されるシーケンスは大抵の場合同時に存在する必要はありません。

x*()を思い起こさせる関数は、これらの原理の明瞭な例です。もし何かを10億回繰り返したければ、プログラムの実行にはしばらく時間がかかるでしょうが、多量のメモリを要求することはありません。同様に多くのタイプのファイルについても、一行ずつ読み込んで処理できるので、メモリにファイル全体を格納する必要がありません。他のシーケンスのようなものも全て、この上なく緩慢に扱われます。それらは、チャネル上に少しずつ到着するデータ、あるいは着実に進む演算に左右されるかもしれませが、他のシーケンスのようなものも全て、この上なく緩慢に扱われます。

通常、ちょうど真のシーケンスがそうであるように、イテレーターはforループの中で利用されます。イテレーターには、明示的に呼び出せる.next()メソッドがありますが、十中八九は以下のようになります。

for x in iterator:
    do_something_with(x)

バックグラウンドでのiterator.next()の呼出しがStopIteration例外を上げると、ループは終了します。ところで、真のシーケンスはiter(seq)を呼び出すことにより、イテレーターに変えることができます。これは少しもメモリを節約しないでしょうが、以下に取り上げられた関数において有用となりえます。

Pythonの進行した二面性

Pythonの関数型プログラミング(FP)に対する考え方に矛盾があります。一方では、多くのPython開発者が従来のFP関数map()filter()およびreduce()を悪く言い、大抵はリストの内包表記を使用することを推奨します。しかし、itertoolsモジュール全体は、完成したシーケンス(リスト、タプル)上でではなく、「緩慢なシーケンス」(イテレーター)上で単に作動し、ほとんど同種の関数から構成されています。更に、Python 2.3には「イテレーターの内包表記」(それらはリストの内包表記と同じような動機をもっているように思われる)用のシンタックスはありません。

私は、Pythonが最終的にイテレーターの内包表記のあるフォームを持てるか懸念していますが、それは適切に普通のシンタックスを見つけられるかに左右されます。その一方で、itertoolsモジュールには多くの有用な組み合わせの関数があります。概して、これらの関数の各々はいくつかのパラメータ(通常基本的なイテレーターを含んで)を受け取り、新しいイテレーターを返します。例えば、関数ifilter()imap()およびizip()は、最初のiを欠く組み込み関数にそのまま相当します。

見当たらない同義語

当然のように思われるかもしれませんが、itertoolsireduce()はありません。可能なPythonの実装は以下の通りです。

リスト1:ireduce()の実装例
def ireduce(func, iterable, init=None):
    if init is None:
        iterable = iter(iterable)
        curr = iterable.next()
    else:
        curr = init
    for x in iterable:
        curr = func(curr, x)
        yield curr

ireduce()を使うケースは、reduce()の使用に似ています。例えば、大容量のファイルに含まれる数値のリストを加算したいが、ただし条件が満たされる場合に停止すると仮定します。次のようにすれば進行中の合計をモニターできます。

リスト2:数値のリストを加算して合計する
from operator import add
from itertools import *
nums = open('numbers')
for tot in takewhile(condition, ireduce(add, imap(int, nums)):
    print "total =", tot

より現実的な例は、おそらく、GUIウィジェットにのようにステートフルなオブジェクトにイベントのストリームを適用するようなものでしょう。しかし上記のような単純な例でさえ、イテレーター・コンビネーターはFPの趣きを呈します。

基本的なイテレーター・ファクトリー

itertoolsの関数はすべて、容易にジェネレーターとして純粋なPythonに実装できます。Python 2.3+にモジュールが含まれる主旨は、標準的な振る舞いおよびいくつかの有用な関数名を提供することです。プログラマは独自のバージョンを書けますが、実際にはわずかに非互換性のバリエーションを作成することになるでしょう。しかし別のポイントとしては、効率的なCコードでイテレーター・コンビネーターを実装することがあげられます。itertools関数を使うと、独自のコンビネータを書くより少し速くなるでしょう。標準的ドキュメントで、ピュアPythonと同様な各itertools関数の実装を説明していますので、本稿では繰り返しません。

itertoolsの関数は十分に基礎的であり、十分明確に命名されています。それはおそらく、モジュールからすべての名前を取り込んでいることを意味しています。例えば、enumerate()関数は上手くitertoolsに収めているかもしれませんが、むしろPython 2.3+の組込み関数です。特に、itertools関数の点から容易にenumerate()を表わすことができます。

from itertools import *
enumerate = lambda iterable: izip(count(), iterable)

基礎として、他のイテレーターを使用せず、簡単に「ゼロから」イテレーターを作成するいくつかのitertools関数を最初に見ていきましょう。times()関数は、同一のオブジェクトを何回でももたらすイテレーターを返します。本来、この能力は適度に有用です。しかしそれ以上に、単にアクションを繰り返すので、xrange()とインデックス変数の必要以上の使用の代わりとして非常に優れています。すなわち

for i in xrange(1000):
    do_something()

ではなく、よりニュートラルに以下のように使えます。

for _ in times(1000):
    do_something()

二つ目の引き数がtimes()に与えられない場合、単に繰り返しNoneを生成します。repeat()関数はtimes()に似ていますが、際限なく同じオブジェクトを返します。このイテレーターは、ループが独立したブレーク状態になった場合や、izip()imap()のようにコンビネーター内にある場合にも有用です。

count()関数は、repeat()xrange()に部分的に重複する箇所があります。count()は、(任意の引数からスタートして)連続する整数を無限に返します。しかしながらcount()が現在のところ、適正にロング整数へのオーバーフローをサポートしないのであれば、まだxrange(n,sys.maxint)を使う方がよいでしょう。文字通り限られていないわけではありませんが、ほとんどの目的に対し同じことになります。count()は、repeat()のように他のイテレーター・コンビネーターの内部でとりわけ有用です。

組み合わせ関数

ifilter()izip()およびimap()は、ちょうど対応するシーケンス関数に期待するような動きをします。ifilterfalse()は、lambdaあるいはdef(それは重要なファンクション・コールのオーバーヘッドをセーブします)の中で、述語関数を無効にする必要がないので便利です。しかし機能的に、次のようにifilterfalse()(ほぼNone述語を無視して)を定義することができます。

def ifilterfalse(predicate, iterable):
    return ifilter(lambda predicate: not predicate, iterable)

dropwhile()関数およびtakewhile()は述部によってイテレーターを分割します。前者は、述部が完了されるまで生成された要素を無視し、後者は述部が保持している間生成します。dropwhile()は、イテレーターの最初の要素の不定数をスキップします。したがって、遅延のしばらく後まで、繰り返しはじめないかもしれません。takewhile()はすぐにスタートしますが、渡された述語が真になるとイテレーターは終了します。

islice()関数は、基本的にはまさにリスト・スライシングのイテレーター・バージョンです。ちょうど通常のスライスと同様に、スタート、ストップおよびステップを指定することができます。スタートが与えられると、渡されたイテレーターが対象となる要素に達するまでに、いくつかの要素は捨てられます。これは、Pythonにできる改善のケースの一つであると思います。イテレータにとっては単純に(islice()動作の同義語として)リスト動作のようにスライスを認識することが一番良いことです。

最後の一つの関数となったstarmap()は、imap()のちょっとしたバリエーションです。引数として渡された関数が、複数の引数を持つ場合は、渡されたiterableは適正なサイズのタプルを生成します。これは基本的に、事前にizip()で結合したiterblesのまとまりと、個別にiterblesが渡されるimap()と同じものです。

基本を越えて

itertoolsに盛り込まれた関数は開始時に役立ちます。少なくともイテレーターを利用すること、組み合わせることで、Pythonプログラマーはより楽になっていきます。一般的には、イテレーターが広範囲に使用されることは、Pythonの将来にとってあきらかに重要なことです。しかし過去に盛り込まれたモジュールには、将来のアップデートに貢献する何かがあります。後に盛り込まれる際に名前やインターフェースの詳細が違っているということもありえますが、当然これらを簡単かつ速やかに使うことができます。

一般的な使用に役に立つように見えるカテゴリは、引き数として複数のiterableを持ち、次に、それぞれのiterableからの個々の要素を生成する関数です。これと対照的に、izip()は、要素のタプルを生成します。また、imap()は、ベースの要素から計算された値を生成します。私見によれば、2つの明らかな配置がchain()weave()です。1番目は、実質上(但し適度に緩慢な)シーケンスの連結に似ています。すなわち、簡単なシーケンスで使うかもしれない例です。

for x in ('a','b','c') + (1, 2, 3):
    do_something(x)

使用できる一般的なiterablesの例です。

for x in chain(iter1, iter2, iter3):
    do_something(x)

Pythonの実装は次のとおりです。

リスト3:chain()の実装例
def chain(*iterables):
    for iterable in iterables:
        for item in iterable:
            yield item

iterablesで、またそれらを埋め込むことによりいくつかを組み合わせるかもしれません。シーケンスで同じことをするビルトイン・シンタックスはありません。しかし、weave()関数自身は完成したシーケンスでも申し分なく動作します。可能な実装は、以下の通りです(Magnus Lie Hetlandは、comp.lang.python上で同様の関数を提案しました)。

リスト4:weave()の実装例
def weave(*iterables):
    "Intersperse several iterables, until all are exhausted"
    iterables = map(iter, iterables)
    while iterables:
        for i, it in enumerate(iterables):
            try:
                yield it.next()
            except StopIteration:
                del iterables[i]

実装例からは、すぐに分からないかもしれないので、weave()の振る舞いを例証させてください。

>>> for x in weave('abc', xrange(4), [10,11,12,13,14]):
...    print x,
...
a 0 10 b 1 11 c 2 12 13 3 14

いくつかのイテレーターが使い果たされた後さえ、あるポイントで利用可能なすべてが生成されるまで、残りのものは価を生成し続けます。

私はitertools関数のさらなる可能性について提案します。これは、問題を理解する関数型プログラミング技法のおかげです。icompose()は、上述したireduce()関数とある対称をなしています。ireduce()関数の場合には、値に(緩慢な)シーケンスが入力され、各結果が生成されますが、icompose()は、各先行関数の戻り値に関数のシーケンスを適用します。ireduce()の有望な使い方は、永続するオブジェクトにイベントのシーケンスを送ることです。icompose()は新しいオブジェクトを返す突然変異的な関数に、むしろオブジェクトを繰り返して渡すかもしれません。1番目はイベントについて考えるかなり伝統的なOOP技法です。一方、2番目はよりFPな考え方です。

ここに、icompose()の可能な実装があります。

icompose()の実装例
def icompose(functions, initval):
    currval = initval
    for f in functions:
        currval = f(currval)
        yield currval

まとめ

イテレーター(緩慢なシーケンスと見なされた)は、Pythonプログラミングの新しいスタイルを開く強力なコンセプトです。しかしながら、データ・ソースとしてイテレーターを考えることと、シーケンシャルなスタイルにおいて考えることの間には、微妙な違いがあります。本質的にどちらの考え方も、それほど真実ではありませんが、後者はプログラムのイベント操作に、組み合わせの省略表現へのいとぐちとなります。itertools(および、とりわけ私が提案するような、発達するかもしれないもの)中の組み合わせ関数は、プログラミングの宣言のスタイルに近づいてきました。私の考えでは、これらの宣言的なスタイルは一般的にエラーの傾向が少なくより強力です。


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


関連トピック

  • 魅力的なPythonの前回までの記事は、こちらでお読みになれます。
  • イテレーターとシンプル・ジェネレーター(developerWorks 2001年9月)は、Python2.2に導入されたジェネレーターとイテレーターに関する優しい紹介です。
  • Davidは、宣言型ミニ言語の作成 (developerWorks 2003年2月)の中で、一組の宣言型エレメントによってPythonを軽減させる方法を述べています。
  • itertoolsの公式ドキュメントは、機能のそれぞれまでがそっくりな純粋なPythonを含み、わかりやすい説明でなされています。
  • itertoolsへのインスピレーションの多くは、全面的に怠惰な言語であるHaskellから得ています。多くの類似した関数のうちの幾つかは、Haskell's standard libraryからダウンロードできます。Haskellのリスト関数のドキュメンテーションは、まったく不可解に見える時には、最初にHaskell 98 Reportの紹介部分の数章をお読みになるとよいでしょう。
  • Pythonの関数型プログラミングの概要は、developerWorksにあるDavidの"Functional programming in Python" シリーズをお読み下さい。第1回はPythonにおける関数型プログラミングの概念と関数テクニックの実行のさせ方について取り上げ、第2回では、Davidが幾つかの中級、上級者向けの関数型プログラミングの概念を明示しています。また第3回では、追従するような、追加された関数とXoltar Toolkitに搭載された他の高次の関数について取り上げています。
  • developerWorks のLinuxゾーンには、他にも Linux開発者向けの参考文献が多数掲載されています。

コメント

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=228124
ArticleTitle=魅力的なPython: itertoolsモジュールの中で組み合わせ関数を使う
publish-date=06122003