🍣

Pythonのenumerateの挙動を理解する

2023/09/11に公開
3

これはなに

Effective Python 第2版の項目7「rangeではなくenumerateを使おう」において、下記の記述があった。

enumerateは、遅延評価ジェネレータでイテレータをラップします。
enumerateは、ループのインデックスとイテレータの次の値の対をyieldします。

この記述の意味がさっぱりわからなかったので調べた。

そもそもenumerateとは

enumerateはPythonの組み込み関数のひとつである。

enumerate(iterable, start=0)

引数

引数 名前 渡すもの
第1引数 iterable シーケンス、イテレータ、あるいはイテレーションをサポートするその他オブジェクト
第2引数 start enumerate()が返すカウントの初期値

戻り値

enumerate()はイテレータを返す。
enumerate()が返すイテレータの__next__()メソッドは、startからのカウントと、iterableのイテレーションによって得られた値を含むtupleを返す。

使い方

主に、イテラブルなオブジェクトをイテレータでループしつつ要素のインデックスを取得したい場合に使う。たとえば、下記のように利用すると、listをループさせつつ要素のインデックスを取得できる。

languages = ["Python", "Golang", "VHDL", "C++"]
for idx, lang in enumerate(languages):
    print(f"{idx + 1} : {lang}")

上記コードを実行すると、下記出力が得られる。

1 : Python
2 : Golang
3 : VHDL
4 : C++

記述を理解するための知識を得る

enumerateは、遅延評価ジェネレータでイテレータをラップします。
enumerateは、ループのインデックスとイテレータの次の値の対をyieldします。

という記述を理解するためには、下記5つの単語を理解しなければならない。

  • 遅延評価
  • イテレータ
  • ジェネレータ
  • yieldする
  • ラップする

キーワードの要点

  • 遅延評価: 値が要求されるまでその評価を遅らせること。
  • イテレータ: 要素を1つずつ順番に取り出せるオブジェクト。__iter__()__next__()を持つ。
  • ジェネレータ: 遅延評価の性質を持つイテレータ。yieldを使用して生成する。
  • yield: ジェネレータ関数内で使用され、関数の状態を一時停止し、値を返す構文。
  • ラップ: あるオブジェクトを取り囲んで新しい機能や情報を提供すること。

遅延評価とは

遅延評価(Lazy Evaluation)とは、プログラムの評価を値が必要になるまで遅らせる手法である。言いかえると、値が要求されるまでその評価(計算や取得)を行わない方法である。対義語は先行評価[1]

たとえば、通常のlistの場合、変数定義時にすべての要素が計算されてメモリに格納される。これは先行評価である。

listの場合
# リストを生成
squares = [x * x for x in range(10)]

# この時点で、`squares`には10個の要素が計算済みの状態で格納されている
print(squares)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

一方で、ジェネレータを利用すると、next関数を使って値を要求するたびに次の値を生成する。これは遅延評価である。

ジェネレータの場合
# ジェネレータを生成(ジェネレータ式)
squares_generator = (x * x for x in range(10))

# この時点では、`squares_generator`にはジェネレータオブジェクトが格納されている
# そのため、`squares_generator`をprintしても、値は得られない
print(squares_generator)  # <generator object <genexpr> at 0x7f4decfd42b0>

square_value = next(squares_generator)  # ここで初めて1つ目の値が評価される
print(square_value)  # 0

先行評価を利用した場合は、評価されたすべての値が同時にメモリ上に存在する。一方で、遅延評価を利用した場合は、値が要求されるたびに次の値を生成する。
そのため、遅延評価ではすべての値を同時にメモリ上に存在させなくてすむ。ゆえに、遅延評価を利用すると、大量のデータを扱う場合でもメモリ効率が良くなる。たとえば、数千万、数億の数字を2乗する場合、リスト内包表記を使うとメモリが圧迫されてしまう。しかし、ジェネレータを使用すれば、メモリを節約しながら1つずつ処理できる。

イテレータとは

イテレータとは、要素を1つずつ順番に取り出せるオブジェクトである。イテレータは下記2つのメソッドを持つことが要求される。

  • __iter__() : イテレータ自身を返す
  • __next__() : 次の要素を返す。次の要素がない場合はStopIterationの例外を投げる

たとえば、listに対してiter()関数を用いると、イテレータを取得できる。

my_list = [1, 2, 3]

# リストからイテレータを取得
iterator = iter(my_list)

# イテレータを使って要素を1つずつ取り出す
print(next(iterator))  # 1
print(next(iterator))  # 2
print(next(iterator))  # 3

# 要素がなくなった時点で、StopIteration例外が発生
# print(next(iterator))

iter()関数でイテレータを取得できるオブジェクトはイテラブル(iterable)と呼ばれる。list, tuple, strなどはiterableである。enumerateはこのiterableを第1引数にとる。

ジェネレータとは

Pythonにおけるジェネレータとは、遅延評価の性質を持つ特別なイテレータの一種である。

ジェネレータはイテレータであるため、__iter__()__next__()の2つのメソッドを持つ。また、遅延評価の性質を持つため、__next__()メソッドが呼ばれるまで次の要素を生成しない。__next__()メソッドが呼ばれてはじめて次の要素を生成する。

基本的なジェネレータ関数の例を以下に示す。これは、0からlimitに指定した数までの整数を順に返すジェネレータを生成する。

def count_up_to(limit):
    count = 0
    while count <= limit:
        yield count
        count += 1


counter = count_up_to(5)
for number in counter:
    print(number)  # 0 1 2 3 4 5

yieldするとは

ジェネレータ関数では、returnの代わりにyieldを使う。yieldを使うと、関数はyield文の実行後に処理を一時停止して、内部状態を保存するように動作する。

たとえば、下記のジェネレータ関数では、countの値をyieldしている。yieldを利用しているため、関数を実行してもwhile文はすべて実行されず、yield文が実行された時点で動作を一時停止する。

def count_up_to(limit):
    count = 0
    while count <= limit:
        yield count
        count += 1


counter = count_up_to(5)

print(next(counter)) # 0
# `yield count`の行が実行され、0が返される
# `count_up_to`内の`count`の値は0のまま一時停止する

print(next(counter)) # 1
# この実行ではじめて`count += 1`が評価されている
# その後`yield count`の行が実行され、1が返される
# `count_up_to`内の`count`の値は1のまま一時停止する

つまり、「yieldする」とは、処理をすべて実行せずに、必要な処理を終えたら一時停止するようにするということである。

ラップするとは

「ラップする」とは、何かを取り込み、その上で追加の機能や情報を提供する新しいオブジェクトを作成することを意味する。なにかのオブジェクトを上から包むようなイメージである。

たとえば、「イテレータをラップする」という表現は、元のイテレータをそのままの形で返さず、何らかの追加情報や変更を加えて新しいイテレータを提供することを指す。

記述を理解する

enumerateは、遅延評価ジェネレータでイテレータをラップします。
enumerateは、ループのインデックスとイテレータの次の値の対をyieldします。

という記述は、enumerateが順を追うと下記の動作をするということを表している。

  1. enumerateが第1引数でiterableを受け取る
  2. 受け取ったiterableのイテレータをジェネレータでラップし、新しいジェネレータイテレータを返す
  3. ジェネレータイテレータは、呼ばれるたびにループのインデックスとイテレータの次の値のペアをyieldして返す

公式ドキュメントによると、enumerateは下記関数と等価だと記されている。これは上記記述を簡潔に表している。

enumerateと等価な関数
def enumerate(iterable, start=0):
    n = start
    for elem in iterable:
        yield n, elem
        n += 1

ちなみに、文書中の「遅延評価ジェネレータ」とは、Pythonにおいてはジェネレータそのものである。なぜなら、Pythonのジェネレータは__next__()メソッドを呼んではじめて次の要素を評価する遅延評価の性質を持つためである。

参考文献・URL

脚注
  1. ちなみに、遅延評価/先行評価という単語は、決してPython特有のものではない。 ↩︎

Discussion

yKesamaruyKesamaru

素晴らしい記事をありがとうございます😃!

enumerateの使いどころってどんなシーンでしょうか?
巨大な配列を順番に処理していくときには__next__を使っていたのですが、enumerateの方が簡潔に書けそう?🤔

NakuReiNakuRei

コメントありがとうございます。
enumerateは、listtupleなどをループさせつつ要素のインデックスを得たい場合に有用です。これは、本記事を書くきっかけとなったEffective Python 第2版の項目7「rangeではなくenumerateを使おう」にも同様の記載があります。

たとえば、listをループさせつつ要素のインデックスを得たい場合、下記のように、enumerateを使うことで簡潔に処理できます。

languages = ["python", "golang", "VHDL", "C++"]

# rangeを使う(よくない)
for i in range(len(languages)):
    lang = languages[i]
    print(f"{i + 1} : {lang}")

# enumerateを使う(よい)
for i, lang in enumerate(languages, 1):
    print(f"{i} : {lang}")

文字列の文字とその位置を取得するような場合も有用です。

word = "hello"
for idx, char in enumerate(word):
    print(idx, char)

巨大な配列を順番に処理していく場合にenumerateを使うと簡潔に書けるかということについては、その処理で要素のインデックスも得たいかどうかに依るかと思います。要素のインデックスも取得したいならenumerateは有用ですが、そうでないなら使う理由はないでしょう。

yKesamaruyKesamaru

なるほど!ありがとうございます😊🌟

今までzipとnext(スマホなのでアンスコでない…)使ってました。確かに便利です!
ありがとうございます!😆🌟