イテレーターパターンを学ぶ
イテレーターパターンを学ぶ
Pythonプログラミングに慣れてくると、for
ループを使ってリストなどの要素を一つずつ取り出す、という操作は日常的に行いますよね。
my_list = [1, 2, 3]
for item in my_list:
print(item)
では、このfor ... in ...
というシンプルな構文が、どのような仕組みで動いているか考えたことはありますか?その答えの核心にあるのが、今回学ぶイテレーターパターンです。
イテレーターパターンは、GoF(Gang of Four)によって定義された伝統的なデザインパターンのひとつで、「集合体の要素に順番にアクセスする方法を、その内部表現を公開することなく提供する」ものです。
この記事では、イテレーターパターンの基本的な概念から、なぜそれが重要なのか、そしてPythonでどのように実装するのかを、具体的なサンプルコードを通して一歩ずつ学んでいきます。for
ループの裏側を探る旅に出かけましょう!
イテレーターとは
イテレーター(Iterator)は、集合体の要素に順番にアクセスするための統一的な方法(インターフェース)を提供するオブジェクトです。Pythonでは、for
ループで使えるオブジェクトの裏側では、すべてこのイテレーターが活躍しています。
Pythonでオブジェクトをイテレーターとして振る舞わせるには、以下の二つの特別なメソッドを実装します。
-
__iter__()
: イテレーターオブジェクト自身を返します。for
ループの開始時などに呼び出されます。 -
__next__()
: 次の要素を返します。要素がなくなったらStopIteration
という例外を発生させて、ループの終了を伝えます。
イテレーターパターンのメリット:なぜ内部を隠蔽すべきか
このパターンの最大のメリットは、集合体の内部実装と、それを利用する側のコード(クライアント)を分離できる点にあります。
例えば、本の集まりを管理する BookShelf
というクラスを考えます。最初は内部を単純なリストで実装したとします。
もしクライアントコードが「BookShelf
の内部はリストである」という前提で、インデックスを使って要素にアクセスしていると、どうなるでしょうか。
# 悪い例:内部のリストに直接アクセスしている
for i in range(len(my_book_shelf.books)):
print(my_book_shelf.books[i].get_name())
将来、パフォーマンス向上のために BookShelf
の内部実装をリストから辞書に変更したくなった場合、上記コードは動かなくなります。BookShelf
を使っているすべてのクライアントコードを修正する必要に迫られ、非常に手間がかかり、バグの原因にもなります。
イテレーターパターンを使えば、クライアントは for book in my_book_shelf:
というシンプルな形で要素にアクセスできます。BookShelf
の内部がリストであろうと辞書であろうと、クライアント側のコードは一切変更する必要がありません。これにより、仕様変更に強く、保守性の高いコードを実現できるのです。
サンプルコード
それでは、実際に BookShelf
を例に、イテレーターパターンの実装を見ていきましょう。
まずは登場人物となる Book
クラスです。
class Book:
"""本を表すクラス。"""
def __init__(self, name: str):
self.name = name
def get_name(self) -> str:
return self.name
パターン1: 抽象基底クラスを継承する厳格な実装
Pythonの collections.abc
モジュールには、イテレーターが満たすべきインターフェースを定めた抽象基底クラス(ABC)があります。これらを継承することで、より厳格にパターンを実装できます。
-
Iterable
:__iter__
メソッドを持つことを示す。BookShelf
がこれを継承します。 -
Iterator
:__iter__
と__next__
を持つことを示す。BookShelfIterator
がこれを継承します。
from collections.abc import Iterator, Iterable
from Book import Book
class BookShelfIterator(Iterator):
def __init__(self, book_shelf: 'BookShelf'):
self._book_shelf = book_shelf
self._index = 0
def __next__(self) -> Book:
if self._index < self._book_shelf.get_length():
book = self._book_shelf.get_book_at(self._index)
self._index += 1
return book
else:
raise StopIteration
class BookShelf(Iterable):
def __init__(self):
self._books = []
def get_book_at(self, index: int) -> Book:
return self._books[index]
def append_book(self, book: Book):
self._books.append(book)
def get_length(self) -> int:
return len(self._books)
def __iter__(self) -> BookShelfIterator:
return BookShelfIterator(self)
パターン2: ダックタイピングによるPython的な実装
一方で、Pythonは「アヒルのように鳴き、アヒルのように歩くなら、それはアヒルだ」というダックタイピングの考え方が主流です。必ずしも抽象基底クラスを継承しなくても、必要なメソッド (__iter__
, __next__
) さえ実装されていれば、それはイテレーターとして扱われます。こちらのほうが、よりシンプルでPythonらしい書き方と言えるでしょう。
from Book import Book
class BookShelfIterator:
def __init__(self, book_shelf: 'BookShelf'):
self._book_shelf = book_shelf
self._index = 0
def __iter__(self):
return self
def __next__(self) -> Book:
if self._index < self._book_shelf.get_length():
book = self._book_shelf.get_book_at(self._index)
self._index += 1
return book
else:
raise StopIteration
class BookShelf:
def __init__(self):
self._books = []
def get_book_at(self, index: int) -> Book:
return self._books[index]
def append_book(self, book: Book):
self._books.append(book)
def get_length(self) -> int:
return len(self._books)
def __iter__(self):
return BookShelfIterator(self)
クライアントコード
重要なのは、BookShelf
がどちらのパターンで実装されていても、クライアント側のコードは全く同じになるという点です。
from Book import Book
# 実装に応じて BookShelf_abc や BookShelf_duck からインポート
from BookShelf_duck import BookShelf
my_book_shelf = BookShelf()
my_book_shelf.append_book(Book("デザインパターン"))
my_book_shelf.append_book(Book("Clean Code"))
# イテレータを使っているので、内部構造を気にせずループできる
for book in my_book_shelf:
print(book.get_name())
パターンの核心:「走査」と「操作」の分離
イテレーターパターンの本質を突き詰めると、それは**「走査(Traversal)」と「操作(Operation)」**の責務を分離することにあります。
- 走査: 集合体の要素をどのように巡るか、次の要素は何か、終わりはどこか、といったロジックです。これは イテレーターオブジェクト が担当します。
-
操作: 走査によって取り出された個々の要素に対して、何をするかという処理です。これは クライアント(
for
ループの中身) が担当します。
for book in my_book_shelf:
というコードでは、in my_book_shelf
の部分がイテレーターによる「走査」を、ループブロック内の print(book.get_name())
がクライアントによる「操作」を担っています。
分離がもたらすメリット
この責務の分離は、ソフトウェア設計にいくつかの大きな利点をもたらします。
1. 再利用性の向上
走査ロジックと操作ロジックをそれぞれ別の場所で再利用できます。
-
多様な操作に同じ走査方法を適用:
BookShelf
を走査するイテレーターは一度作れば、「全書籍のタイトルを印刷する」「全書籍の価格を合計する」「在庫切れの書籍をリストアップする」といった様々な操作で再利用できます。 -
多様な集合体に同じ操作を適用: 「タイトルを印刷する」という操作は、
BookShelf
から取り出した本にも、NewArrivals
(新刊リスト)という別の集合体から取り出した本にも、同じように適用できます。
2. 多様な走査方法の提供
一つの集合体に対して、複数の異なる走査方法を簡単に提供できます。例えば、BookShelf
に対して、以下のような異なるイテレーターを用意することが考えられます。
-
ForwardIterator
: 通常通り、前から順番に走査する。 -
ReverseIterator
: 後ろから逆順に走査する。 -
SaleItemIterator
: セール対象の書籍のみを走査する。
クライアントは、使いたいイテレーターを選択するだけで、同じ操作ロジック(例:タイトルを印刷する)を異なる順序や条件で実行できます。
# 逆順イテレータを使うイメージ (BookShelfに reverse() メソッドがあると仮定)
for book in my_book_shelf.reverse():
print(f"[逆順] {book.get_name()}")
3. 責務の明確化
それぞれのクラスが持つべき責任が明確になります(単一責任の原則)。
-
集合体 (
BookShelf
): データを保持・管理することに専念する。 -
イテレーター (
BookShelfIterator
): 集合体を走査する方法を知っていることに専念する。 -
クライアント (
main.py
): 取り出した要素を使って何をしたいか、というビジネスロジックに専念する。
このように責務を分離することで、各コンポーネントがシンプルになり、コードの見通しが良く、メンテナンスしやすくなります。
発展: 内部実装の変更に対応する
ここで、BookShelf
の内部実装をリストから辞書に変更してみましょう。イテレーターパターンがどのように機能するかを実感できるはずです。
from Book import Book
from typing import Dict, Iterator
# イテレータは辞書の中身(Bookオブジェクト)を順に返すように修正
class BookShelfIterator:
def __init__(self, books: Dict[str, Book]):
self._iterator: Iterator[Book] = iter(books.values())
def __iter__(self) -> Iterator[Book]:
return self
def __next__(self) -> Book:
return next(self._iterator)
# BookShelfの内部実装をリストから辞書に変更
class BookShelf:
def __init__(self):
self._books: Dict[str, Book] = {}
def append_book(self, book: Book):
self._books[book.get_name()] = book
def get_length(self) -> int:
return len(self._books)
def __iter__(self) -> BookShelfIterator:
return BookShelfIterator(self._books)
BookShelf
と BookShelfIterator
の実装は大きく変わりました。しかし、main.py
のクライアントコードは 一切変更する必要がありません。これこそが、イテレーターパターンによる疎結合の力です。
もしイテレーターパターンを使わなかったら?
繰り返しになりますが、もしイテレーターパターンを使わず、クライアントが BookShelf
の内部リストに直接アクセスしていた場合、この辞書への変更によってすべてのクライアントコードが動かなくなります。
my_book_shelf._books
にアクセスしている箇所がプロジェクト内に100箇所あったら、その100箇所すべてを my_book_shelf._books.values()
のように修正しなければなりません。これは多大な労力とバグのリスクを伴います。
まとめ
イテレーターパターンは、「走査」ロジックを集合体から分離し、統一的なアクセス方法を提供します。これにより、集合体の内部実装をカプセル化し、クライアントコードとの結合度を低く保ちます。結果として、走査方法と操作ロジックの再利用性が高まり、仕様変更に強く、保守性の高いコードを書くための強力な武器となるのです。
参考書籍:JAVA言語で学ぶデザインパターン入門 結城 浩
Discussion