このブログを検索

第7章 クラスとイテレータ

7.1. 没頭しよう

イテレータは、Python3における"隠し味"です。普段は見えていませんが、至るところに隠れています。
イテレータを簡単に書くためには、内包表記、ジェネレータといった方法があります。また、値を返す関数を使うと、イテレータを作らずにイテレータを作る、という便利で簡単なことができます。

では説明していきます。フィボナッチ・ジェネレータを覚えていますか?イテレータを使って作ると、このようになります。

# fibonacci2.py
class Fib:
    '''iterator that yields numbers in the Fibonacci sequence'''

    def __init__(self, max):
        self.max = max

    def __iter__(self):
        self.a = 0
        self.b = 1
        return self

    def __next__(self):
        fib = self.a
        if fib > self.max:
            raise StopIteration
        self.a, self.b = self.b, self.a + self.b
        return fib

1行目はこのようになっています。
class Fib:
class?クラスとは・・・なんでしょうか?

7.2. クラスを定義する

Pythonは、全てにおいてオブジェクト志向の言語です。クラスを自分で定義できますし、定義したクラスやビルトインのクラスを継承したり、定義したクラスをインスタンス化することもできます。

Pythonではいたって簡単にクラスを定義することができます。関数のように個別のインターフェイスを定義する必要はありません。クラスを定義して、コードを書き始めるだけです。Pythonのクラスを開始するには、予約語classから始めて、クラス名を続けて書きます。他のクラスからの継承がない場合、記述はこれだけです。
class PapayaWhip:  # ①
    pass           # ②
①このクラスの名前はPapayaWhipで、他のクラスを継承していません。クラス名は"EachWordLikeThis"というように大文字を使うのが慣例ですが、必須ではありません。

②おそらく想像したとおりでしょうが、関数、if文、ループや他のコードブロックの中のように、クラスの中のコードはすべてインデントされます。インデントがなくなった最初の行からが、クラスの外です。

このPapayaWhipというクラスには、メソッドや属性の定義がありません。ただし、構文上はクラス定義に何かが入っている必要があるため、pass文を入れています。passはPythonの予約語で、"ここで何も見るものなし。進め"という意味です。この命令文は何もしないので、関数やクラスを無かったことにするためのダミーとして使うと便利です。

👉 Pythonのpass文はJavaやCに出てくる空の波括弧( { } )と同じです。

他のクラスから継承することはよくありますが、このクラスでは継承はありません。メソッドを定義することもよくありますが、このクラスでは定義していません。Pythonのクラスが必ず持っていなければならないものは、名前だけです。特にC++のプログラマは、Pythonのクラスがコンストラクタ、デストラクタを明示的に持っていないことに違和感を覚えるかもしれません。必須ではありませんが、Pythonのクラスにもコンストラクタに似たものがあります。それが__init__()です。

7.2.1. __init__()メソッド

この例では、__init__()メソッドを使ってclass Fibを初期化しています。

class Fib:
    '''iterator that yields numbers in the Fibonacci sequence'''  # ①

    def __init__(self, max):                                      # ②

①モジュールや関数と同様に、クラスにはドキュメンテーション文字列を入れることができます(入れるべきです)。

②classのインスタンスが生成されると、__init__()メソッドが同時に呼ばれます。このメソッドをクラスのコンストラクタと呼んでしまいそうになりますが、厳密には違います。そう呼びたくなる理由は、C++のコンストラクタのように見えることや(慣例的に__init__()メソッドはクラスの最初に定義される)、コンストラクタのような挙動をすること(新しく作られたクラスのインスタンスで、最初にこの部分のコードが実行される)、コンストラクタと響きも似ていることです。しかし、これらは異なるものです。なぜなら__init__()メソッドが呼び出されたときには、すでにオブジェクトは形成(コンストラクト)されていて、新しいクラスのインスタンスはその時点で参照できるからです。

__init__()メソッドを含めて、すべてのクラスメソッドの最初の引数は、そのクラスのカレントインスタンスを参照します。この引数の名前は、慣例的にselfです。C++やJavaにおける"this"という予約語と同じ役割ですが、selfはPythonの予約語ではなく、こう名付けるのが慣例になっているだけです。ただし、強い慣例ですからself以外を使わない方がよいでしょう。

すべてのクラスメソッドでは、selfはそのメソッドを呼び出したインスタンスを参照します。__init__()メソッドの場合は特別で、メソッドを呼び出したインスタンスも、新しく生成されたオブジェクトです。メソッドを定義するときはselfを明示的に記述する必要がありますが、メソッドを呼び出すときにはPythonが自動で追加してくれるので、selfを引数として記述しなくても構いません

7.3. クラスのインスタンス化

Pythonでは、クラスのインスタンス化は簡単にできます。あるクラスをインスタンス化するためには、クラスを関数と同じように呼び出して、__init__()メソッドに必要な引数を渡すだけです。このときの返り値は、新しく生成されたオブジェクトです。
>>> import fibonacci2
>>> fib = fibonacci2.Fib(100)  # ①
>>> fib                        # ②
<fibonacci2.Fib object at 0x00DB8810>
>>> fib.__class__              # ③
<class 'fibonacci2.Fib'>
>>> fib.__doc__                # ④
'iterator that yields numbers in the Fibonacci sequence'
①fibonacci2モジュール内で定義されているFibクラスのインスタンスを作って、その新しいインスタンスを変数fibに代入しています。Fibの__init__()メソッドの引数maxにパラメータとして100を渡していています。

②fibはFibクラスのインスタンスです。

③すべてのクラスインスタンスは、ビルトイン属性の__class__を持っていて、これはオブジェクトのクラスです。(JavaプログラマであればClassクラスについてよく知っているかもしれません。オブジェクトのメタデータを得るために使われるgetName()、getSuperclass()といったメソッドを含むものです。Pythonでは、この種のメタデータは属性から得られますが、考え方としては同じです。)

④インスタンスのドキュメンテーション文字列には、関数やモジュールのときと同様にアクセスすることができます。ひとつのクラスのすべてのインスタンスは、同じドキュメンテーション文字列を共有しています。

👉 Pythonでは、新しいクラスを作るときは、新しいクラスインスタンスを作る関数であるかのようにclassを呼び出すだけです。C++やJavaのように明示的なnew演算子はありません。

7.4. インスタンス変数

次の部分を見てみましょう。

class Fib:
    def __init__(self, max):
        self.max = max        # ①

①self.maxとは何でしょうか?これは、インスタンス変数です。__init__()メソッドに引数として渡されるmaxとは別のものです。self.maxはインスタンスに対して「グローバル」です。つまり、他のメソッドからもアクセスすることができます。
class Fib:
    def __init__(self, max):
        self.max = max        # ①
    .
    .
    .
    def __next__(self):
        fib = self.a
        if fib > self.max:    # ②

①self.maxは、__init__()メソッドの中で定義されます。

②self.maxは、__next__()メソッドの中で参照されます。

インスタンス変数は、1つのインスタンスに対して固有です。例えば、異なる最大値をもつFibインスタンスを2つ生成した場合、それぞれが異なる値を記憶します。

>>> import fibonacci2
>>> fib1 = fibonacci2.Fib(100)
>>> fib2 = fibonacci2.Fib(200)
>>> fib1.max
100
>>> fib2.max
200
__init__、__iter__、__next__、これら3つのクラスメソッドはすべてアンダーステア"_"が前後についています。何故でしょうか?これらは魔法ではありませんが、"特別なメソッド"であることを表しています。直接呼び出されることがない、という意味で"特別"です。クラスやクラス内のインスタンス上で他の構文を使うときに、Pythonが呼び出します(詳細はAppendix B)

7.5. フィボナッチ・イテレータ

準備ができたので、イテレータの生成を学んでいきましょう。イテレータは、__iter__()メソッドを定義しているクラスのことです。 
class Fib:                                        # ①
    def __init__(self, max):                      # ②
        self.max = max

    def __iter__(self):                           # ③
        self.a = 0
        self.b = 1
        return self

    def __next__(self):                           # ④
        fib = self.a
        if fib > self.max:
            raise StopIteration                   # ⑤
        self.a, self.b = self.b, self.a + self.b
        return fib                                # ⑥

①イテレータをゼロから生成するには、Fibは関数ではなくクラスである必要があります。

②Fib(max)を"呼び出す"とき実際にやっているのは、クラスFibのインスタンスを生成して__init__()メソッドをmaxを使って呼び出す、ということです。__init__()メソッドによって最大値が変数として保存されて、あとで他のメソッドでも参照できるようになります。

③iter(fib)がコールされると、__iter__()メソッドが呼び出されます(forループ内では自動で呼び出していますが、すぐあとで出てくるように、手動で呼び出すこともできます)。self.aとself.bという2つのカウンタをリセットしてイテレーションを初期化します。そうすると、__iter__()メソッドは__next__()メソッドを持ったどんなオブジェクトでも返すことができます。ほとんどの場合、__iter__()は単にselfを返すことになります。なぜなら、クラスがnext()メソッドを持っているからです。

④__next__()メソッドは、あるクラスインスタンスのイテレータ上でnext()がコールされると呼び出されます。このことは、少しあとのほうでよく理解できるでしょう。

⑤__next__()メソッドがStopIterationという例外を出したとき、イテレーションが終了したと呼び出し側に通知します。他の例外と違ってこの場合はエラーではなく正常であって、イテレータがこれ以上値を生成しないということを表しています。forループ内で呼び出された場合は、このStopIteration例外があると、正常にループから抜けます(つまり、例外を受け入れるのです)。このちょっとした魔法があるため、イテレータをforループで使っているのです。

⑥次の値として、イテレータの__next()__メソッドは値を返します。ここでyieldを使うことはできません。yieldはジェネレータにだけに使える糖衣構文です。ここではイテレータをゼロから作りますから、代わりにreturnを使いましょう。

ついてこれていますか?では、このイテレータをどのように呼び出すか見てみましょう。

>>> from fibonacci2 import Fib
>>> 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

全く同じです!フィボナッチをジェネレータとして呼び出したときと1バイトも違いません(1文字が大文字になっただけです)。どういうことでしょうか?

forループに少し秘密があります。何が起こっているのかを説明しましょう。

  • forループがFib(1000)をコールして、Fibクラスのインスタンスが返ります。これをfib_instと呼びましょう。
  • 密かに、かつ非常にクレバーに、forループはiter(fib_inst)をコールしてイテレータオブジェクトを返しています。これをfib_iterと呼びましょう。この場合、fib_iter==fib_instです。なぜなら、__iter__()メソッドはselfを返しますが、forループはそれを知らない(あるいは気にしていない)からです。
  • イテレータを"ループし尽くす"ために、forループはnext(fib_iter)をコールし、fib_iterオブジェクト上の__next__()メソッドを呼び出します。このオブジェクトは次のフィボナッチ数を計算した値を返します。forループはこの値を受け取ってnに代入し、forループの中をnの値で実行します。
  • forループがいつ止まるのか、どうやって知るのかですって?よくぞ聞いてくれました! next(fib_iter)が例外StopIterationを出したとき、例外を受け入れて正常にforループを抜けます(他の例外も同様です)。例外StopIterationは、どこかで見ませんでしたか?もちろん、__next__()メソッドの中です!

7.6. 複数ルールのイテレータ

さぁ、終幕です。plural rules generator(第6章)をイテレータで書き直してみましょう。

class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')
        self.cache = []

    def __iter__(self):
        self.cache_index = 0
        return self

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]

        if self.pattern_file.closed:
            raise StopIteration

        line = self.pattern_file.readline()
        if not line:
            self.pattern_file.close()
            raise StopIteration

        pattern, search, replace = line.split(None, 3)
        funcs = build_match_and_apply_functions(
            pattern, search, replace)
        self.cache.append(funcs)
        return funcs

rules = LazyRules()

このクラスには__iter__()と__next__()があって、イテレータとして使うことができます。つまり、最後の行にあるようにクラスをインスタンス化してrulesに代入しています。これはインポートのときに一度だけ実行されます。

このクラスを少しずつ見ていきましょう。

class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')  # ①
        self.cache = []                                                  # ②

①Lazyrulesクラスをインスタンス化したとき、パターンファイルを開きますが、そこからは何も読みません(あとで説明します)。

②パターンファイルを開いたあと、キャッシュを初期化します。このキャッシュは、このあと__next__()メソッドの中でパターンファイルから行列を読むときに使います。

先に進む前に、rules_filenameを詳しく見てみます。これは__iter__()メソッドの中には定義されていません。実は、どのメソッドでも定義されていないのです。rules_filenameはクラスのレベルで定義される、クラス変数です。インスタンス変数(self.rules_filename)のような形でアクセスすることができて、LazyRulesクラスのすべてのインスタンスで共有されています。

>>> import plural6
>>> r1 = plural6.LazyRules()
>>> r2 = plural6.LazyRules()
>>> r1.rules_filename                               # ①
'plural6-rules.txt'
>>> r2.rules_filename
'plural6-rules.txt'
>>> r2.rules_filename = 'r2-override.txt'           # ②
>>> r2.rules_filename
'r2-override.txt'
>>> r1.rules_filename
'plural6-rules.txt'
>>> r2.__class__.rules_filename                     # ③
'plural6-rules.txt'
>>> r2.__class__.rules_filename = 'papayawhip.txt'  # ④
>>> r1.rules_filename
'papayawhip.txt'
>>> r2.rules_filename                               # ⑤
'r2-overridetxt'

①クラス内の各インスタンスは、クラスで定義された値をrules_filename属性として受け継ぎます。

②インスタンスの属性の値を変更しても他のインスタンスに影響しませんし・・・

③・・・クラスの属性が変わったりもしません。個々のインスタンスの属性ではなく、クラスの属性にアクセスするには、__class__という特別な属性を使います。

④クラス属性を変更すると、値を継承しているすべてのインスタンスが影響を受けます(ここではr1)。

⑤すでに属性を上書きしているインスタンスは影響を受けません(ここではr2)。


本筋に戻りましょう。

    def __iter__(self):       # ①
        self.cache_index = 0
        return self           # ②

①__iter__()メソッドは、誰か(例えばforループ)がiter(rules)をコールしたときに呼び出されます。

②すべての__iter__()メソッドがやるべき1つのことは、イテレータを返すことです。今の場合はselfを返しますから、このクラスには__next__()メソッドが定義されていて、それがイテレーションの間に返る値をすべて扱うことを示しています。

    def __next__(self):                                 # ①
        .
        .
        .
        pattern, search, replace = line.split(None, 3)
        funcs = build_match_and_apply_functions(        # ②
            pattern, search, replace)
        self.cache.append(funcs)                        # ③
        return funcs

①__next__()メソッドは誰か―例えばforループがnext(rules)をコールしたとき、いつでも呼び出されます。

②この関数の最後の部分には、見覚えがあるのではないでしょうか。build_match_and_apply_functions()は以前と同じです。

③変化点は1つだけで、タプルfuncsに入っているmatch、apply関数を返す前に、self.cacheの中に保存することです。

コードをさかのぼって見てみましょう・・・

    def __next__(self):
        .
        .
        .
        line = self.pattern_file.readline()  # ①
        if not line:                         # ②
            self.pattern_file.close()
            raise StopIteration              # ③
        .
        .
        .

①ここでは応用的なファイル操作をしています。readline()メソッドは(readlines()ではないことに注意)、開いたファイルから1行だけ読み取ります。具体的には、その次の行を読み込みます。(ファイルオブジェクトもイテレータです。端から端までイテレータです・・・)

②readline()で行が読めたのであれば、lineは空の文字列ではありません。空白行のときは、lineは文字列"\n" (改行文字)になります。lineが空の文字列のときは、ファイルから読む行がないということです。

③ファイルの終端まで来たら、ファイルを閉じて魔法のStopIteration例外を出します。覚えていますか?こうなる理由は、matchとapply関数が次のruleのために必要だからです。次のruleはファイルの次の行にあるはず・・・しかし次の行はありません! ということは返す値がないのです。イテレーションは終了です。(♫The party's over... ♫)

コードを全部さかのぼって__next__()メソッドの最初から見てみましょう。

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]     # ①

        if self.pattern_file.closed:
            raise StopIteration                         # ②
        .
        .
        .

①self.cacheは関数のリストで、これに個々のルールを当てはめなければなりません(少なくとも、知っているものであれば!)。self.cache_indexはキャッシュされた項目のどれに次を返すかを記録しています。キャッシュを出し尽くしていなければ、(つまり、self.casheの長さがself_cache_indexより大きければ)、キャッシュに行き当たります! ヒュ〜! match and apply関数を一から作らずに、キャッシュから返すことができます。

②一方で、キャッシュに当たらず、ファイルオブジェクトが閉じているときは、他に何もすることがありません(前のコードスニペットで見たように、メソッドを進めていくと起こりえることです)。もしファイルが閉じているなら、キャッシュを使い尽くしたということです。―パターンファイルのすべての行をすでに読みきっていて、各パターンに対してmatch and apply関数を生成してキャッシュ済みということです。ファイルは使い尽くされ、キャッシュも使い尽くされました、私ももうヘトヘトです・・・。え?何ですって?ふんばりましょう、もう少しです!

全部まとめると

  • インポートされたモジュールは、rulesという名前でLazyRulesクラスのインスタンスを生成します。このインスタンスはパターンファイルを開きますが、読むわけではありません。
  • 最初のmatch and apply関数でキャッシュを調べて、空であることが確認されました。そのため、パターンファイルを1行読んで、そのpatternからmatch and apply関数を生成し、キャッシュに入れます。
  • 例えば、ある引数のときに最初のルールがいきなりマッチしたとします。その場合はそれ以上match and apply関数は生成されませんし、パターンファイルの行を読むこともありません。
  • 他にも、ある引数のときに、呼び出し側が他の文字を複数形にするためにplulal()関数をもう1度呼び出したとします。plural()関数のforループはiter(rules)を呼び出してキャッシュインデックスをリセットしますが、開かれたファイルオブジェクトはリセットしません。
  • forループの1回目では常にrulesから値を受け取って__next__()メソッドを呼び出すことになります。一方でこのときはパターンファイルの1行目のpatternに応じてキャッシュされたmatch and apply関数のペアが最初に来ています。(match and apply関数は)前の単語を複数化する過程で生成され、キャッシュされているので、そのキャッシュから検索されます。キャッシュインデックスはインクリメントされるので、開いたファイルは触れられることはありません。
  • 例えば、ある引数で最初のルールが一致しないとします。forループが次に行って、rulesに他の値を要求します。こうなると__next__()メソッドを2度目に呼び出すことになります。今回は、キャッシュは使い尽くされます — 1つしか項目がなく、2番目だけを探しているためです —__next__()メソッドは継続されます。開いたファイルから次の行を読み、match and apply関数をpatternから生成し、キャッシュします。
  • このような、読む〜生成する〜キャッシュする、というプロセスは、複数化したい単語にパターンファイルから読んだルールがマッチしない間は続くことになります。ファイルの最終行に来るまでにルールがマッチすれば停止して、そのルールを使います。ただし、ファイルは開いたままにしておきます。ファイルポインタは読み進んだところで停止していて、次のreadline()コマンドが来るのを待っています。これを続けていると、キャッシュに多くの項目を持つことになります。新しい言葉を複数化し始めるときは、パターンファイルの次の行を読む前に、キャッシュの項目を使って複数化できるかを先に試します。
複数化の悟りに達しました。
  1. 最小の開始コスト importしたときに起こることは、1つのクラスのインスタンス化とファイルオープンだけです(ファイルは読みません)。
  2. 最大のパフォーマンス 先ほどの例では、単語を複数化するときは毎回、ファイルをすべて読んで関数を動的に生成します。今回のやり方では、生成された関数はすぐにキャッシュされまる。極端なケースでは、複数化する単語数に関わらず、パターンファイルを1度しか読みません。
  3. コードとデータの分離 パターンは別ファイルに保存されています。コードはコード。データはデータ。出会うことはありません。

👉 悟りに達しているでしょうか?Yesであり、Noです。LazyRulesの例で考慮すべき点は、パターンファイルが開かれているとき(__init__()の間)、最後のファイルが開いたままであるということです。Pythonが停止すると、最終的にはファイルは閉じられます。または最後のインスタンス化のあとでLazyRulesクラスが破壊されてもファイルを閉じますが、その場合はすぐではないこともあります。長く続くPythonプロセスの中にこのクラスがであるとすれば、Pythonインタプリタはなかなか停止しない可能性があり、そうするとLazyRulesオブジェクトも破壊されないかもしれません。

 対策としてはいろいろな方法があります。rulesを毎回1行ずつ読む間、__init__()でファイルを開いておくのではなく、ファイルを開いてすべてのルールを読んだら、すぐにファイルを閉じればよいのです。他の方法としては、ファイルを開いてルールを1つ読んで、ファイルの場所をtell()メソッドで保存してからファイルを閉じて、後で再度開いてseek()メソッドで保存した場所から読み込みを再開することもできます。例のように、ファイルが開いたままであることを心配しなくてよくなります。プログラミングというものはデザインであり、デザインではトレードオフと制約がすべてです。ファイルを開いたままにし過ぎると、問題が起きるかもしれませんが、コードが複雑になることも問題になるかもしれません。どちらの問題が重大かは、開発チーム、アプリケーションやランタイム環境に依存しています。

0 件のコメント:

コメントを投稿