6章クロージャとジェネレータ

飛び込む

まずは「名詞の複数形はどのように作るのか」ということから話を始めよう。

名詞の複数形の基本的な規則:

  • ある単語がS、X、Zで終わっていればESを付ける。 例えば、Bassはbasses、faxはfaxes、waltzはwaltzesになる。
  • もし単語が有音のHで終わるなら、ESをつける。 無音のHで終わるなら、Sだけをつければよい。 有音のHというのは、 他の文字と組み合わさって、耳に聞こえる音になるHのこと。 例えば、
    • coachはcoaches、rashはrashes になる。
    • cheetahのHは無音なので、cheetahsになる。
  • 単語の終わりのYをIのように発音するなら、YをIESに変える。Yが母音と合わさって別の発音になるなら、ただSだけをつける。だから、
    • vacancyはvacancies、
    • dayはdays。
  • もし以上の全ての規則に当てはまらなかったら、単純にSをつけて、あとは「これでうまくいきますように」と願えばいい。

例外:

  • manはmen、
  • womanはwomenになるが、humanはhumans
  • mouseはmiceに、
  • louseはliceになるのに、
  • houseはhousesになる
  • Knifeはknivesに、
  • wifeはwivesになるのだが、lowlifeはlowlifesになる。
  • 独自の複数形を持つsheepとかdeerとかhaikuとかの単語がある

正規表現を使う

少なくとも英語では文字列を調べるということを意味している。そしてここにはいくつかの規則があり、それによれば異なる文字の組み合わせを見つけだした上で、それぞれに異なった処理を施す必要がある

正規表現を使った置換について

角括弧[] は「どれか一文字にマッチする」という意味。

In [1]:
import re
re.search('[abc]', 'Mark')
Out[1]:
<_sre.SRE_Match object; span=(1, 2), match='a'>
In [2]:
re.sub('[abc]', 'o', 'Mark')
Out[2]:
'Mork'
In [3]:
re.sub('[abc]', 'o', 'rock')
Out[3]:
'rook'

re.subは最初の一つだけではなく、マッチしたもの全てを置換する。だから、この正規表現はcapsをoopsになる。 このcとaの両方がoに置き換えられる。

In [4]:
re.sub('[abc]', 'o', 'caps')
Out[4]:
'oops'

否定を使った正規表現について

In [5]:
import re
re.search('[^aeiou]y$', 'vacancy')
Out[5]:
<_sre.SRE_Match object; span=(5, 7), match='cy'>

boyはマッチしない。oyで終わっているが、yの前の文字がoであってはならないと明示されているからだ。dayもマッチしない。この単語はayで終わっているからだ。

In [6]:
re.search('[^aeiou]y$', 'boy')
In [7]:
re.search('[^aeiou]y$', 'day')
In [8]:
re.search('[^aeiou]y$', 'pita')
In [9]:
re.sub('y$', 'ies', 'vacancy')
Out[9]:
'vacancies'
In [10]:
re.sub('y$', 'ies', 'agency')
Out[10]:
'agencies'

この二つの正規表現(一方が規則を適用すべきかを調べ、他方が実際にその規則に従って処理する)を組み合わせて一つの正規表現にすることもできる。

例えば、ケーススタディ: 電話番号をパースするで学んだ、グループを表す括弧が使われていて、ここではyの直前にある文字を記憶するのに使われている。

一方で、置換文字列の中では\1という新しい構文が使われているが、これは「ねえ、最初に記憶したグループがあったよね? それをここに置いてよ」ということを表している。この例では、yの前のcを記憶しているので、置換を行うと、cをcで、yをiesで置き換えることになる(記憶されるグループが複数ある場合は、\2、\3というように使うことができる)

In [11]:
re.sub('([^aeiou])y$', r'\1ies', 'vacancy')
Out[11]:
'vacancies'

[sxz]は「s、xまたはz」ということを表しているが、ただしこのどれか一つだけにマッチするものでなくてはならない。この$には馴染みがあるだろう。これは文字列の末尾にマッチするものだ。 まとめると、この正規表現はnounがs、x、zのいずれかで終わるかどうかを調べている。

In [12]:
import re

def plural(noun):
    if re.search('[sxz]$', noun):
        return re.sub('$', 'es', noun)
    elif re.search('[^aeioudgkprt]h$', noun):
        return re.sub('$', 'es', noun)
    elif re.search('[^aeiou]y$', noun):
        return re.sub('y$', 'ies', noun)
    else:
        return noun + 's'
    
re.sub('$', 'es', noun)

文字列の末尾を($でマッチさせて)esで置き換えている。言い換えると、esを文字列に付け加えている。 これと同じことは、文字列の連結を使ってもできるのだが(例えば、noun + 'es')、ここではそれぞれの規則について正規表現を使って実装することにする。

re.search('[^aeioudgkprt]h$', noun):

有音のHで終わる単語の処理:

  • この角括弧の中の一文字目にある^は特別な意味を持っている。つまり否定だ。例えば、[^abc]は「a、b、c以外の一文字」という意味になる。だから、[^aeioudgkprt]はa、e、i、o、u、d、g、k、p、r、t以外の任意の一文字を表すことになるのだ。
  • さらにその文字にhが続き、そこで文字列が終わりになる必要があった。今探しているのは、有音のHで終わる単語。

Yが末尾にあって、さらにそのYをIというように発音する(直前が子音)単語を探している:

elif re.search('[^aeiou]y$', noun):
        return re.sub('y$', 'ies', noun)

これはYで終わる単語で、しかもその直前の文字がa、e、i、o、uのいずれでもないものにマッチする。

正規表現による置換は非常に強力なものだが、\1構文を加えると一層強力なものになる。

しかし、全ての処理を一つの正規表現にまとめてしまうと、コードがかなり読みにくくなってしまうし、最初に述べた複数形化の規則の表現とも直接は対応しなくなってしまう。 もとはといえば、これらの規則は「もし、単語がS、X、Zのいずれかで終わっているなら、ESを加えよ」というように表されていた。plural()関数を見てみれば、「もし、単語がS、X、Zのいずれかで終わっているなら、ESを加えよ」という内容を表す二行のコードがあることがわかるだろう。

関数のリスト

このコードを抽象化していく。 この章では「これこれなら、あの処理をなせ。それ以外の場合は、次の規則にすすめ」という規則のリストを定義することから始めた。 ここでは、とりあえずプログラムの一部を複雑化させることで、他の部分を単純化してみる。

In [13]:
import re

def match_sxz(noun):
    return re.search('[sxz]$', noun)

def apply_sxz(noun):
    return re.sub('$', 'es', noun)

def match_h(noun):
    return re.search('[^aeioudgkprt]h$', noun)

def apply_h(noun):
    return re.sub('$', 'es', noun)

マッチの規則( match_XX() )が、 それぞれre.search()関数を実行した結果を返す関数で表されるようになっている。

In [14]:
def match_y(noun):
    return re.search('[^aeiou]y$', noun)

処理の規則もそれぞれ re.sub() を呼び出す関数で表されていて、適切な複数形化の規則に基づいて処理するようになっている。

In [15]:
def apply_y(noun):
    return re.sub('y$', 'ies', noun)

def match_default(noun):
    return True

def apply_default(noun):
    return noun + 's'

いくつもの規則が入った一つの関数(plural())を作る代わりに、 rulesというデータ構造、つまり関数のペアからなるシーケンスを作っている。

In [16]:
rules = ((match_sxz, apply_sxz),
         (match_h, apply_h),
         (match_y, apply_y),
         (match_default, apply_default)
)

複数形化の規則が取り出されて別のデータ構造に収められているため、 新しい plural() はほんの数行になっている。

ここでは、このようにforループを使うことで、マッチのための規則と処理のための規則の二つをrules構造体から一度に取り出している。 このfor文の最初のループでは、mathes_ruleにはmatch_sxzが渡され、apply_ruleにはapply_sxzが渡される。

一方、(一回目では終わらなかったとして)二回目のループではmatches_ruleにmatch_hが割り当てられ、apply_ruleにapply_hが割り当てられることになる。

さらに言うと、この関数は必ず何らかの値を返すことが保証されている。 というのも、最後のループのマッチの規則(match_default)は単純にTrueを返すので、ループの終わりまで来た場合には、常にこのマッチの規則に対応する処理規則(apply_default)が適用されることになる。

In [17]:
def plural(noun):
    for matches_rule, apply_rule in rules:
        if matches_rule(noun):
            return apply_rule(noun)
        
    

ここで加えた抽象化がよく分からなかったら、この関数を展開してみて、それで得られた等価なコードを見てみるとよい。このforループ全体は以下のコードに等しい:

def plural(noun):
    if match_sxz(noun):
        return apply_sxz(noun)
    if match_h(noun):
        return apply_h(noun)
    if match_y(noun):
        return apply_y(noun)
    if match_default(noun):
        return apply_default(noun)

このように抽象化を施す利点は、plural()関数が単純化されるということにある。この関数は、どこか別の所で定義された規則を含むシーケンスをとり、それをイテレートしていくという一般的な処理を行うのだ。

  1. マッチルールを取得。
  2. マッチした? なら、処理のルールを呼び出して、その結果を返す。
  3. マッチしなかった? それなら1に戻れ。

この抽象化では、 例えば、この関数に新しい規則を加える時、

  • 最初のコードだと、plural()関数にif文を加えなくてはならない。
  • 二番目のコードだと、match_foo()とapply_foo()の二つの関数を新たに定義した上で、rulesシーケンスを書き換えて、この新しいマッチ関数と処理関数が、他の関数との関係で何番目に呼び出されるべきなのかを定めなくてはならない。

パターンのリスト

マッチと処理のための規則の一つ一つについて、個別に名前のある関数を定義する必要は、実のところあまりない。そもそも、これらの関数は直接呼び出されるわけではなく、rulesシーケンスに加えられた上で、そこから呼び出される。 もっと言えば、各々の関数は次の二つのパターンのうちのどちらかに従っている。

つまり、全てのマッチ関数はre.search()を呼び出し、 全ての処理関数はre.sub()を呼び出す。

新しい規則を定義するのがもっと楽になるように、このパターンを取り出していく。

build_match_and_apply_functions() は他の関数を動的に生成する関数。 この関数はpattern、search、replace を引数にとり、 mathes_rule()関数を定義する。 このmathes_rule()関数は、build_match_and_apply_functions()関数に渡されたpatternと、mathes_rule() 自身に渡されるwordを引数としてre.search() を呼び出す。

処理関数も同じように生成される。 この処理関数は一つの引数をとる関数で、 build_match_and_apply_functions() に渡されたsearchやreplaceと、 このapply_rule() 自身に渡されたwordを引数としてre.sub() を呼び出している。

このように、動的な関数の中で外部の引数の値を使うテクニックを「クロージャ」と呼ぶ。実質的には、これらの定数は処理関数内部で定義されていると言える。処理関数は一つの引数(word)をとるのだが、この関数が定義されるときに定められる他の二つの値(searchとreplace)も用いるのだ。

最後に、build_match_and_apply_functions()関数は二つの値が入ったタプル、つまりたった今生成した二つの関数からなるタプルを返す。 これらの関数の中で定めた定数(matches_rule()の中のpatternとapply_rule()の中のsearchとreplace)は、build_match_and_apply_functions()から返されたあとも残ってくれる。

In [18]:
import re

def build_match_and_apply_functions(pattern, search, replace):
    def matches_rule(word):
        return re.search(pattern, word)
    def apply_rule(word):
        return re.sub(search, replace, word)
    
    return (matches_rule, apply_rule)

複数形化の「規則」は、文字列(関数ではない)のタプルからなるタプルとして定義されている。それぞれのグループの最初の文字列はre.search() で用いる正規表現のパターンで、 これを使って名詞を選別する。 各グループの二つ目と三つ目の文字列は、検索と置換のための正規表現で、 re.sub() の中で用いて実際に規則を適用し、名詞を複数形にする。

ここのフォールバックの部分に少し変更が加えられている。前のコードだと、 match_default()関数が単純にTrueを返すようになっていて、 他の個別の規則のいずれにもマッチしなかった場合には、与えられた単語の終わりにsを付けるという処理を施していた。このコードもそれと機能的に等価な処理を行っている。

最後の正規表現はその単語に終わりがあるかどうかを調べるものだ($は文字列の末尾にマッチする)。もちろん、空白文字を含むあらゆる文字列には終わりがあるので、 この正規表現はどんな文字列にもマッチすることになる。

つまり、これは常にTrueを返すmatch_default()と同じ役割を果たすことになるのだ。結局のところ、このコードのおかげで、特定の規則のどれともマッチしないような場合には、 その単語の末尾にsがつけられることになる。

In [19]:
patterns = (
    ('[sxz]$',           '$',  'es'),
    ('[^aeioudgkprt]h$', '$',  'es'),
    ('(qu|[^aeiou])y$',  'y$', 'ies'),
    ('$',                '$',  's')
)

patternsに収められた、文字列からなるシーケンスを引数にとり、関数の入ったシーケンスに変えている。

build_match_and_apply_functions()関数に文字列を「対応させる」。 つまり、三つ組の文字列をそれぞれ取り上げ、これらの文字列を引数としてbuild_match_and_apply_functions()を呼び出しているのだ。 build_match_and_apply_functions()は二つの関数からなるタプルを返すので、結局このrulesは機能的に前のコードのものに等しいもの—関数のペアのタプルからなるリスト—になる。 ちなみに、この一つ目の関数はre.search()を呼び出すマッチ関数で。二つ目の関数はre.sub()を呼び出す処理関数だ。

In [20]:
rules = [build_match_and_apply_functions(pattern, search, replace) 
         for (pattern, search, replace) in patterns]

このバージョンのスクリプトを締めくくるのは、 メインのエントリーポイントとなるplural()関数。

rulesリストは前のコードのものと同じものなので、 plural()関数に何も手が加えられていなくても少しも驚くことはない。 これは規則を表す関数のリストを受け取って順番に呼び出していくだけの、完全に一般的な関数なのであって、その規則がどのように定義されるかなんてことには構わないのだ。前のコードでは、これらはそれぞれ名前のついた関数として定義されていた。

一方、このコードでは build_match_and_apply_functions()関数の戻り値と文字列のリストを対応させることで動的に生成されている。 しかし、こういったことは plural() 関数に何の影響も与えない。 この関数は以前と同じように動いてくれる。

In [21]:
def plural(noun):
    for matches_rule, apply_rule in rules:
        if matches_rule(noun):
            return apply_rule(noun)
        
    

パターンのファイル

当然の流れとして、次はこの文字列を取り出して別のファイルに保存できるようにする。 こうすることで、コードとは別々に管理できるようになる。

まずはテキストファイルを作って、そこにお好みの規則を加えよう。 手が込んだデータ構造を使うのではなく、単に文字列を空白文字で三列に区切って書けばいい。そしてこれにplural4-rules.txt という名前をつける。

どのようにこのファイルを使うのか。

build_match_and_apply_functions()関数は何も変更されていない。 クロージャ を用いて、外部の関数で定義された変数を使用する二つの関数を動的に生成している。

In [22]:
import re

def build_match_and_apply_functions(pattern, search, replace):
    def matches_rule(word):
        return re.search(pattern, word)
    def apply_rule(word):
        return re.sub(search, replace, word)
    return (matches_rule, apply_rule)

グローバル関数のopen() はファイルを開いてファイルオブジェクトを返すもの。 このコードで開いているファイルには、名詞を複数形にするためのパターンを表す文字列が入っている。

with文はいわゆるコンテクストを作るもので、withブロックが終われば、たとえブロック内で例外が送出されていようが、Pythonは自動でファイルを閉じてくれる。

for line in <fileobject> というコードは、 開かれたファイルから一度に一行だけデータ読み込み、その読み出したテキストをline変数に代入していく。

ファイルの各行には三つの値が入っている。 これらの値は空白文字(タブかスペース。どちらでも違いはない)で区切られている。

ここから値を切り出すには、split() という文字列メソッドを使えばいい。 split()関数の

  • 一番目の引数はNone になっているが、これは「空白文字(タブかスペース。違いはない)があったらそこで分割する」ということを意味している。
  • 二番目の引数は3だが、「空白文字で3回だけ行を分割して、残りはそのままにしておく」ということを表している。

例えば、[sxz]$ $ es のような行は分割されて['[sxz]$', '$', 'es'] というリストになり、結局、patternには'[sxz]$' 、searchには'$'、replaceには'es'が渡されることになる。

In [23]:
rules = []
with open('plural4-rules.txt', encoding='utf-8') as pattern_file:
    for line in pattern_file:
        pattern, search, replace = line.split(None, 3)
        rules.append(build_match_and_apply_functions(pattern, search, replace))
    
In [24]:
with open('plural4-rules.txt', encoding='utf-8') as pattern_file:
    print(pattern_file)
<_io.TextIOWrapper name='plural4-rules.txt' mode='r' encoding='utf-8'>
In [25]:
with open('plural4-rules.txt', encoding='utf-8') as pattern_file:
    for line in pattern_file:
        print(line)
    
[sxz]$                    $   es

[^aeioudgkprt]h$ $   es

[^aeiou]y$             y$ ies

$                            $    s

ここでの改良点は、 名詞を複数形にする規則を外部のファイルに分離して、コードとは別に管理できるようにしたことにある。

  • コードはコード
  • データはデータ。

ジェネレータ

規則が入ったファイルの解析も行う、より包括的なplural()関数があったらすばらしいと思わないだろうか? 規則を取得する、マッチするかどうかをチェックする、適切な変換を施す、次の規則に移る。plural()関数にこれだけの機能があれば十分だし、またまさしくこの全ての機能を備えているべきだとも言える。

yield というキーワードは、 make_counter が普通の関数ではないことを示している。 これは一度に一つだけ値を生成するという特殊な関数なのだ。 レジューム(再開)できる関数だと捉えることもできる。 この関数を呼び出すと「ジェネレータ」が返されるのだが、 このジェネレータを使えばxの値を連続的に生成していくことができる。

In [26]:
def make_counter(x):
    print('entering make_counter')
    while True:
        yield x
        print('incrementing x')
        x = x + 1
    

make_counterジェネレータのインスタンスを作るには、 他の関数と同じように呼び出せばいい。 気をつけてほしいのは、このように呼び出してもこの関数内のコードは実行されないということだ。現にmake_counter()関数は一行目でprint()を呼び出しているのに、ここには何も出力されていない。

In [27]:
counter = make_counter(2) 

make_counter()関数はジェネレータオブジェクトを返す。

In [28]:
counter
Out[28]:
<generator object make_counter at 0x0000022E91A384C0>

next() 関数はジェネレータオブジェクトを受け取って、 そのジェネレータが次に生成する値を返す。 最初にcounterを渡してnext()関数を呼び出した時には、 make_counter() の一番初めからyield文までのコードが実行され、さらにyieldされた値が返される。この場合、make_counter(2)としてこのジェネレータを生成したので、2が返されることになる。

In [29]:
next(counter)
entering make_counter
Out[29]:
2

同じジェネレータオブジェクトを渡してnext()を再び呼び出すと、 中断した所から処理を再開し、次のyield文にあたるまでコードを実行してゆく。

あらゆる変数やジェネレータ内部の状態などはyield文が実行されたときに保存され、next()が呼び出されると復元される。 この例では、実行を待っていた次の行のコードはprint()を呼び出してincrementing xと出力するものだった。 そして、それに続いてx = x + 1という代入文が実行され、それからwhile以下を再びループすることになる。whileループの一番初めにあるのはyield xだから、そこで状態が保存されて現在のxの値(つまり3)が返されるのだ。

In [30]:
next(counter)
incrementing x
Out[30]:
3

二度目にnext(counter)を呼び出した時にも、全く同じ処理が行われる。

In [31]:
next(counter)
incrementing x
Out[31]:
4

make_counterのループには終わりが無いので、原理的にはこれを永久に繰り返せる。このジェネレータはいつまでもxをインクリメントして、その値を返し続けてくれる。

フィボナッチ数列ジェネレータ

フィボナッチ数列とは、それぞれの数がその直前の二つの数の和になっているような数列のこと。 この数列は0と1から始まり、最初は緩やかに増加していくのだが、 後の方になればなるほど急激に値は大きくなってゆく。 この数列を生成し始めるには、まず二つの変数を用意しなくてはならない。 つまり、0から始まるaと、1から始まるbだ。

aは数列の現時点での値を表している。これをyieldする。

bは数列の次の値を表す。だからこれをaに代入するのだが、 同時に次の値(a + b)も計算しておき、後で使えるようにbに代入しておく。

この処理が平行して行われていることに注意。 もし、aが3でbが5なら、a, b = b, a + b とすると

  • aには5(直前のbの値)が代入され、
  • bには8(直前のaとbの値の和)が代入される。
In [32]:
def fib(max):
    (a, b) = (0, 1)
    while a < max:
        yield a
        (a, b) = (b, a + b)
    

再帰を使っても同じことができるのだが、こうする方が読みやすくなるのだ。ちなみに、この関数はforループで使うこともできる。

fib() のようなジェネレータはforループで直接使うことができる。 「forループは自動でnext()を呼び出し」て、 fib()ジェネレータから値を受け取り、forループのインデクス変数(n)に代入してくれる。

forループを反復する度に、fib()のyield文がnに新しい値を渡してくれるので、 後はその値を出力するだけでいい。fib()の数列が終わりに達したら(aがmax、つまりこの例で言えば1000を越えた場合)、forループはそこで何の問題も起こすことなく終了する。

In [33]:
for n in fib(1000):
    print(n, end=' ') 
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 

list()関数にジェネレータを渡すと、 (直前の例のforループと同じように)ジェネレータ全体をイテレートして、その全ての値からなるリストを返してくれる。

In [34]:
list(fib(1000))
Out[34]:
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987]

名詞を複数形にする規則のジェネレータ

このバージョンのplural()関数がどのように動くのか。

ここでyieldする。二つの関数だよ。これらの関数はお馴染みのbuild_match_and_apply_functions()関数で生成されたものだが、この関数は前のコードから全く変わっていない。要するに、rules()はマッチと処理のための関数をオンデマンドで返してくれるジェネレータなのだ

In [35]:
def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:
            pattern, search, replace = line.split(None, 3)
            yield build_match_and_apply_functions(pattern, search, replace)
        
    

rules() はジェネレータなので、forループでそのまま使える。forループを最初に実行した時には、まずrules()関数が呼び出される。 この関数は、パターンファイルを開き、最初の行を読み出した上で、その行に入っているパターンからマッチ関数と処理関数を動的に生成し、その二つの関数をyieldしてくれる。forループを二度目に反復したときには、 rules()関数が停止した地点(for line in pattern_fileループの途中)から処理を始めることになる。

つまり、最初にファイル(これはまだ開かれたままだ)の次の行の読み出した上で、その行にあるパターンに基づいて別のマッチ関数と処理関数を動的に生成し、この二つの関数をyieldするのだ。

In [36]:
def plural(noun, rules_filename='plural5-rules.txt'):
    for matches_rule, apply_rule in rules(rules_filename):
        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun))

このコードより何が優れているのか

起動時間だ。四番目のコードでは、plural4モジュールをインポートすると、plural()を呼び出すことを考える間もなく、パターンファイル全体が読み込まれ、適用される可能性のある規則全てを集めたリストが生成された。

  • ジェネレータを使えば、のらくらと処理していける。最初に一つ規則を読みこんで関数を作り、試してみる。それで上手くいけば、残りのファイルを読み込んだり別の関数を作ったりすることもない。

1度にファイルのデータすべてを読み込まず、 1lineずつ読み込んでクローじゃ(factory)をつくり実行するので起動時間(処理の開始)が早くなる。

パターンファイルを再び開いて、また一行目から読み出していくため、 代わりにパフォーマンスを失うplural()関数を呼び出すたびに、rules()ジェネレータは最初から処理をやりなおす — 。 それも一度に一行ずつ。

この二つの長所を兼ね備えることもできる。 つまり、

  • 起動時の負担を最小にして(要するにimportしたときにコードを一つも実行することなく)、かつ
  • パフォーマンスを最大にする(同じ関数を何度も何度も作り直さない)。

それと同じ行を繰り返し読み込まなくてよいなら、規則を別のファイルに保存しておきたい (なぜならコードはコードでデータはデータだから)。

そのためにはクラスが必要。

参考リンク