Pythonの内包表記に再入門する

Pythonの内包表記について改て整理しました。 「そもそも内包って何」というところについても軽く触れています。
2022.05.31

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

内包表記とは

Pythonではリストの作成において幾つかの方法がありますが、今回は内包表記について取り上げます。 そもそも内包表記とはどんなものでしょうか?

以下に例を示します。

リスト内包表記

L = [x for x in range(3) if x < 2]
-> L = [0, 1]

これは3未満の自然数のうち、2未満のもののリストです。

数学っぽく書くとわかりやすい人もいるかもしれません。

つまりは、range(3)このリストが取り扱う入力としての集合を定めて、ifでこのリストの要素が満たすべき条件を記述しているわけです。 実はこのリスト内包表記は数学の集合内包表記と近いものになっています。

集合における記法で集合を定義する際に大きく分けて2通りの記法があります。 それは以下の二つです。

記法 説明
外延 L = {0, 1} 要素を全て列挙している
内包 L = {x | x は2未満の自然数} 要素が満たすべき性質を記述している

Pythoにおけるリスト内包表記は以下のような文法で表現されます。

リスト内包表記の文法

新しいリスト = [式 for 要素の変数 in イテラブルな変数 if 条件式]

改めて見ると内包表記と近い書き方な気がします。

じつはリスト内包表記と同じものをFor文でも書くことができます。

L = []
for x in range(3):
    if x < 2:
        L.append(x)

こうやってみると、こちらは全ての要素を列挙してるので外延的と言えるかもしれません。 ただ、見た目についての個人的な感覚なので厳密に定義されている訳ではないです。

以下では内包表記を用いた様々な書き方をFor文で書いた場合と比較しながら整理していきます。

リスト内包表記

変換

全ての値を5倍する

dist = []
for i in range(5):
    dist.append(i * 5)
print(dist)
-> [0, 5, 10, 15, 20]

dist = [
    i * 5
    for i in range(5)
]
print(dist)
-> [0, 5, 10, 15, 20]

ここでは各要素を5倍するような処理です。 どちらも同じ値が出力されます。 リスト内包表記ではi * 5の部分が評価され、全ての値が5倍になったリストが生成されています。

フィルタ

2で割った余が0のものだけを取り出す

dist = []
for i in range(5):
    if i % 2 == 0:
        dist.append(i)
print(dist)
-> [0, 2, 4]

dist = [
    i
    for i in range(5)
    if i % 2 == 0
]
print(dist)
-> [0, 2, 4]

2で割った余りが0になるもののリストです。 こちらは式自体はiをそのまま出力しているだけですが、条件式が付いています。 For文の方で分岐に使用していたのと同じ条件式が内包表記内に存在し、その条件式がTrueになるものだけが、最終的に出力されます。

2重ループ

2重ループ

dist = []
for i in range(2):
    for j in range(3):
        dist.append((i, j))
print(dist)
-> [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]

dist = [
    (i, j)
    for i in range(2)
    for j in range(3)
]
print(dist)
-> [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]

2重ループです。 内包表記では多重ループが可能です。 ループの順番は最初に書いた方(iの方)が先に処理されます。 なのでここでは2x3のループなっています。

少しトリッキーな2重ループ

トリッキーな2重ループ

dist = []
for i in range(3):
    tmp_dist = []
    for j in range(i):
        tmp_dist.append(j)
    dist.append(tmp_dist)
print(dist)
-> [[], [0], [0, 1]]

dist = [
    [j for j in range(i)]
    for i in range(3)
]
print(dist)
-> [[], [0], [0, 1]]

こちらは少し複雑な2重ループです。 1つ目のループの変数の値に応じて、その長さのリストを追加しています。 ここで言いたいのは、内包表記内の式部分にも内包表記を書くことができるということです。

他の構造体の内包表記

内包表記で定義できるのはリストだけではありません。 辞書型, 集合型, ジェネレータも定義可能です。

辞書内包表記

辞書内包表記

dist = {}
for k, v in zip('abc', range(3)):
    dist[k] = v
print(dist)
-> {'a': 0, 'b': 1, 'c': 2}

dist = {k: v for k, v in zip('abc', range(3))}
print(dist)
-> {'a': 0, 'b': 1, 'c': 2}

辞書型も定義可能です。 {キー: 値 内包表記}のようにして書くことができます。

集合内包表記

集合内包表記

dist = set()
for i in range(5):
    dist.add(i % 3)
print(dist)
-> {0, 1, 2}

dist = {i % 3 for i in range(5)}
print(dist)
-> {0, 1, 2}

こちらは5未満の自然数を2で割った余りの集合です。 2で割った余りは0, 1, 2のいずれかになるので合っていますね。 辞書型との違いはキーの指定がないことです。 うっかりキーを指定し忘れると集合型になってしまうので気をつけてください。

ジェネレータ内包表記

ジェネレータ内包表記

dist = (i for i in range(3))
print(type(dist))
-> <class 'generator'>

print(next(dist))
-> 0
print(next(dist))
-> 1
print(next(dist))
-> 2
print(next(dist))
-> Traceback (most recent call last):
  File "xxx", line xxx, in <module>
    print(next(dist))
StopIteration

()で内包表記を定義するとジェネレータになります。

これは以下と同じ挙動をします。

ジェネレータ

def gen():
    for i in range(3):
        yield i
dist = gen()

リストと間違えて()で括るとジェネレータになってしまうので気をつけてください。

ジェネレータ内包表記を使用した処理の例

ジェネレータ内包表記はジェネレータに対してパイプライン処理のようなものを書きたい時に便利です。 個人的には無限長のイテレータやメモリの観点から遅延評価を行いたい時に使うことが多いで す。

内包表記を使った遅延評価

dist = (i for i in [2, 1, 0])
dist = (4 / i for i in dist)

# ここまで  4 / i は評価されない
dist = [(i, v) for i, v in zip(range(2), dist)]
print(dist)
-> [(0, 2.0), (1, 4.0)]

ジェネレータの遅延評価を利用して上のような処理もかけます。 リスト内包表記で書いた場合は、2行目で全ての値が評価されエラーが発生してしまいます。

ですが、ジェネレータを使用した場合は4行目まで評価されず、4行目では先頭から2個(2, 1)しか評価されないため、ゼロ除算が発生せずリストを出力可能です。 もちろん4行目をrange(3)に書き換えたらエラーになります。

まとめ

内包表記でさまざまなループを表現してみました。 個人的には余分な変数を定義しなくてよかったり、見た目がスッキリする場合が多く好きな書き方です。 参考になれば幸いです。

ちなみにリスト内包表記は表現力が高いので以下のようなこともできたりします。