Pythonのリスト、辞書、セット内包表記:簡潔で読みやすく書く

1. 概要

Comprehension(内包表記)はPythonの特徴的な構文です。反復可能なオブジェクトからリスト、辞書、またはセットをたった1行で作成できます。正しく書けばコードはループより短くて明確です。乱用すると1行で理解し難いコードになってしまいます。

この記事ではリスト、辞書、セット内包表記とジェネレータ式を具体例を交えて説明します。読み終わったあと、いつ内包表記を使うべきか、いつforループに戻すべきか、データ変換ロジックのテストの書き方が分かるようになります。すべての例はPython 3.12で実行しています。

2. リスト内包表記

2.1. 基本的な構文

注文のリストがあり、各注文の合計金額を取得する必要があるとしましょう。従来のforループで書くと次のようになります:

from dataclasses import dataclass

@dataclass
class Order:
    id: int
    customer: str
    total: float
    status: str

orders = [
    Order(1, "An", 250_000, "PAID"),
    Order(2, "Bình", 90_000, "CANCELLED"),
    Order(3, "An", 1_200_000, "PAID"),
    Order(4, "Chi", 450_000, "PENDING"),
]

totals = []
for order in orders:
    totals.append(order.total)
print(totals)

リスト内包表記で書き直すと:

totals = [order.total for order in orders]
print(totals)
[250000, 90000, 1200000, 450000]

構造は[式 for 要素 in 反復可能]です。左から右へ読むと:「ordersの各orderについてorder.totalを取得する」という意味です。空のリストを宣言する必要もなく、appendを呼び出す必要もありません。「新しいリストを作成する」という意図が最初の[で明確です。

2.2. ifで要素をフィルタリング

支払済みの注文のみを取得したい場合、最後にif句を追加します:

paid_ids = [order.id for order in orders if order.status == "PAID"]
print(paid_ids)
[1, 3]

最後のifはフィルタとして機能します。条件を満たさない要素はスキップされるため、結果のリストは元のリストより短くなる可能性があります。

2.3. 最初のif-else式

条件に基づいてすべての要素を変換する場合、if-elseを式の部分に配置します:

labels = ["大きい" if order.total >= 500_000 else "小さい" for order in orders]
print(labels)
['小さい', '小さい', '大きい', '小さい']

ここは多くの人が混同します。2つのルールを覚えておけば大丈夫です:

  • forの後ろのifはフィルタリング用です。この句にはelseがありません。
  • a if cond else b forの前は条件式です。この句は必ずelseを持ち、要素の数は変わりません。

[x for x in data if cond else y]と書くとSyntaxErrorになります。

3. 辞書内包表記

辞書内包表記は{}を使用し、key: valueのペアを持ちます。例えば、idで注文を素早く参照する必要がある場合:

orders_by_id = {order.id: order for order in orders}
print(orders_by_id[3])
Order(id=3, customer='An', total=1200000, status='PAID')

既存の辞書を変換することもできます。例えば、価格表に10%の税金を適用する場合:

prices = {"cafe": 30_000, "tra_sua": 45_000, "banh_mi": 20_000}
prices_with_vat = {name: round(price * 1.1) for name, price in prices.items()}
print(prices_with_vat)
{'cafe': 33000, 'tra_sua': 49500, 'banh_mi': 22000}

prices.items()は(key, value)ペアを返し、ループ内で即座にアンパックしています。

便利な技法として辞書を反転させることができます:

status_codes = {"PAID": 1, "PENDING": 2, "CANCELLED": 3}
code_to_status = {code: status for status, code in status_codes.items()}
print(code_to_status[2])
PENDING

注意: 2つの要素が同じキーを生成する場合、後の要素が前の要素を静かに上書きします。例えば{order.customer: order.total for order in orders}では、「An」の最後の注文(1,200,000)だけが保持され、250,000の注文は警告なしに消えます。グループ化が必要な場合は、defaultdictとループを使用してください(セクション7参照)。

4. セット内包表記

セット内包表記も{}を使用しますが、式が1つだけで:がありません。結果は自動的に重複排除されます。例えば、注文したことのある顧客がどれだけいるか知りたい場合:

customers = {order.customer for order in orders}
print(sorted(customers))
['An', 'Bình', 'Chi']

「An」は2つの注文を持っていても、各顧客は1回だけ出現します。セット内の順序は保証されないため、上の例では出力時にsortedを使用しています。

セット内包表記は高速なメンバーシップチェックが必要な場合に便利です:

banned_emails = {"spam@x.com", "Fake@Y.com"}
normalized = {email.strip().lower() for email in banned_emails}
print("fake@y.com" in normalized)
True

注意: 空の{}は辞書であり、セットではありません。空のセットを作成するにはset()と書く必要があります。

5. 複数のforと入れ子の内包表記

1つの内包表記に複数のforを書くことができます。読む順序は複数のループを上から下へ書く場合と全く同じです:

carts = {
    "An": ["cafe", "banh_mi"],
    "Chi": ["tra_sua"],
}

items = [(customer, item) for customer, products in carts.items() for item in products]
print(items)
[('An', 'cafe'), ('An', 'banh_mi'), ('Chi', 'tra_sua')]

上の内包表記は以下のコードと同等です:

items = []
for customer, products in carts.items():
    for item in products:
        items.append((customer, item))

最も一般的なケースは、入れ子のリストを平坦化することです:

matrix = [[1, 2, 3], [4, 5, 6]]
flat = [value for row in matrix for value in row]
print(flat)
[1, 2, 3, 4, 5, 6]

覚え方:最も外側のループが最初に来ます。2回目の読み直しで順序を理解する必要がある場合、それは通常のforループに分ける時のサインです。

6. ジェネレータ式:リスト全体が不要な場合

[]を()に変えるとジェネレータ式になります。Pythonはメモリにリスト全体を作成しません。値はそれらを消費するコードがあるたびに1つずつ生成されます。

revenue = sum(order.total for order in orders if order.status == "PAID")
print(revenue)
1450000

ジェネレータが関数の唯一の引数である場合、括弧を省略できます。このスタイルはsum、any、all、max、minと相性が良いです:

has_big_order = any(order.total > 1_000_000 for order in orders)
all_positive = all(order.total > 0 for order in orders)
print(has_big_order, all_positive)
True True

anyは条件を満たす最初の要素を見つけたら即座に停止します。そのため大規模なデータではジェネレータがany([...])より省メモリで高速です。

注意: ジェネレータは1回だけイテレーションできます。2回目のイテレーションは何も返しません:

paid = (o for o in orders if o.status == "PAID")
print(len(list(paid)))
print(len(list(paid)))
2
0

複数回結果を使う必要がある場合はリスト内包表記を使用してください。

7. 内包表記を読みやすく保つ

7.1. 複雑なロジックに関数を使用

長い条件は内包表記を読みにくくします:

vip = [o.customer for o in orders if o.status == "PAID" and o.total >= 1_000_000 and not o.customer.startswith("test")]

条件を明確な名前の関数に分離できます:

def is_vip_order(order: Order) -> bool:
    return (
        order.status == "PAID"
        and order.total >= 1_000_000
        and not order.customer.startswith("test")
    )

vip = [order.customer for order in orders if is_vip_order(order)]
print(vip)
['An']

これで内包表記は文のように読め、ビジネスロジックはテストできる場所があります。

7.2. 適切に改行する

内包表記は1行である必要はありません。括弧内では自由に改行できます:

summary = {
    order.id: order.total
    for order in orders
    if order.status != "CANCELLED"
}
print(summary)
{1: 250000, 3: 1200000, 4: 450000}

各行は1つの役割を持ちます:式、ソース、フィルタ。

7.3. Walrus演算子で二重計算を避ける

フィルタリングと同じ計算の結果を使う必要がある場合があります。演算子:=(Python 3.8+)は1回だけ計算します:

def discount_for(order: Order) -> float:
    return order.total * 0.05 if order.total >= 400_000 else 0

discounts = {
    order.id: d
    for order in orders
    if (d := discount_for(order)) > 0
}
print(discounts)
{3: 60000.0, 4: 22500.0}

walrus演算子を控えめに使用してください。変数dは内包表記の外側のスコープに「リーク」します。また、多くの読者がこの構文に慣れていません。

8. データ変換ロジックのテスト

内包表記は通常、データ変換関数内にあるため、他のロジックのようにテストする必要があります。まずpytestをインストール:

pip install pytest

reports.pyというモジュールがあるとします:

# reports.py
from dataclasses import dataclass

@dataclass
class Order:
    id: int
    customer: str
    total: float
    status: str

def revenue_by_customer(orders: list[Order]) -> dict[str, float]:
    customers = {o.customer for o in orders if o.status == "PAID"}
    return {
        c: sum(o.total for o in orders if o.customer == c and o.status == "PAID")
        for c in customers
    }

対応するテストファイル:

# test_reports.py
from reports import Order, revenue_by_customer

def test_revenue_only_counts_paid_orders():
    orders = [
        Order(1, "An", 250_000, "PAID"),
        Order(2, "An", 1_200_000, "PAID"),
        Order(3, "Bình", 90_000, "CANCELLED"),
        Order(4, "Chi", 450_000, "PAID"),
    ]

    result = revenue_by_customer(orders)

    assert result == {"An": 1_450_000, "Chi": 450_000}
    assert "Bình" not in result

def test_empty_orders_returns_empty_dict():
    assert revenue_by_customer([]) == {}
pytest -q
..                                                                       [100%]
2 passed in 0.01s

最初のテストは2つのことを確認しています:キャンセルされた注文はカウントされない、そしてキャンセルされた注文のみがある顧客は結果に現れない。2番目のテストは空のリストの境界ケースをチェックしています。

上の関数は正しいですが、計算量が O(n × 顧客数)です。各顧客についてorders全体を再度イテレーションするからです。大規模なデータではループとdefaultdictを使う方が読みやすく高速です:

from collections import defaultdict

def revenue_by_customer(orders: list[Order]) -> dict[str, float]:
    totals: dict[str, float] = defaultdict(float)
    for order in orders:
        if order.status == "PAID":
            totals[order.customer] += order.total
    return dict(totals)

古いテストは修正なしでパスします。ここがテストの利点です:内包表記からループ、またはその逆へリファクタリングしても安心です。

9. 内包表記を使用すべきでない場合

  • 副作用が必要な場合。 [print(o) for o in orders]や[db.save(o) for o in orders]と書かないでください。この方法は無意味なNoneのリストを生成し、読者を結果が必要だと欺きます。ループを使用してください。
  • 3階層以上ネストしている場合か多数のifが混在している場合。3つのループが1行に入ることはほぼ常に、分割版より読みにくいです。
  • グループ化や累積が必要な場合(合計や数え方など)。defaultdict、Counter、またはループを使用してください。
  • 各要素の例外処理が必要な場合。 内包表記はtry/exceptを含めることができません。ロジックを別の関数に抽出して内包表記から呼び出すか、ループを使用してください。
  • データが非常に大きく、1回だけイテレーションする場合。 リスト内包表記の代わりにジェネレータ式を使用してください。

注意: Python 3では、内包表記のループ変数はスコープからリークしません。[x for x in range(3)]の後、名前xは外側のスコープに存在しません。これは通常のループとは異なります。唯一の例外は:=で割り当てられた変数です(セクション7.3参照)。

10. まとめ

内包表記は「既存のコレクションから新しいコレクションを作成する」という考えを簡潔に表現できます:

  • リスト用は[...]、辞書用は{k: v ...}、セット用は{...}、ジェネレータ用は(...)です。
  • forの後のifはフィルタリング、forの前のa if cond else bは変換です。
  • 複雑な条件を明確な名前の関数に分離し、式が長い場合は改行してください。
  • sum、any、allでジェネレータ式を使用してメモリを節約してください。
  • 副作用がある場合、グループ化やフィルタリング、例外処理が必要な場合、または内包表記が読みにくくなり始めたらforループに戻してください。

最もシンプルなルール:同僚が内包表記を2回読まなければ理解できない場合、forループで書き直してください。簡潔さは読みやすい場合のみ価値があります。

python内包表記clean codeジェネレータpytest