条件式における副作用と評価順序の罠(Python)

Python の条件式では、評価順序と短絡評価により「評価されない部分」が生じる。副作用を伴うコードをそこに書くと、実行されたりされなかったりして予期せぬバグを引き起こす。この記事では、条件式における副作用の罠と、それを避けるための原則を解説する。

副作用とは何か

副作用(side effect)とは、式の評価が値を返す以外の影響を及ぼすことだ。具体的には以下のようなものが該当する。

変数への代入や属性の変更
ファイルやデータベースへの書き込み
ネットワーク通信
標準出力への print
グローバル状態の変更
リストや辞書などミュータブルオブジェクトの変更

短絡評価と副作用の組み合わせ

and / or の短絡評価では、結果が確定した時点で残りの式は評価されない。ここに副作用を含む式があると問題になる。

# 危険な例:ログ出力が条件次第で行われたり行われなかったり
result = validate(data) and log_success() and process(data)

# validate が False を返すと log_success() は呼ばれない
# これは意図した動作か?

意図的にこの挙動を使う場合もあるが、暗黙的に依存していると読み手を混乱させる。

# より明確に書く
if validate(data):
    log_success()
    result = process(data)
else:
    result = False

三項演算子での副作用

三項演算子でも、選ばれなかった側の式は評価されない。

# 副作用を含む三項演算子
result = increment_counter() if condition else decrement_counter()

condition が True なら decrement_counter() は呼ばれない。これが意図通りなら問題ないが、「両方のカウンターを更新した上でどちらかの値を返す」つもりだったら完全なバグだ。

# バグの例:両方更新したかったのに片方しか更新されない
def get_result(condition):
    # 意図:両カウンターを更新して、条件に応じた値を返す
    return increment_a() if condition else increment_b()
    # 実際:condition に応じて片方しか呼ばれない

# 修正版
def get_result(condition):
    val_a = increment_a()
    val_b = increment_b()
    return val_a if condition else val_b

リスト内包表記での副作用

リスト内包表記の中で副作用を起こすのは特に危険だ。

# 悪い例:リスト内包表記の中で副作用
results = [process_and_log(item) for item in items if validate(item)]

# validate が False の item では process_and_log が呼ばれない
# すべての item をログに残したかったら?
# 改善:副作用と内包表記を分離
logged_items = [log_item(item) or item for item in items]  # まずログ
results = [process(item) for item in logged_items if validate(item)]

# または明示的なループ
results = []
for item in items:
    log_item(item)
    if validate(item):
        results.append(process(item))

代入式(ウォルラス演算子)の副作用

Python 3.8 のウォルラス演算子 := は、式の中で代入という副作用を起こす。短絡評価と組み合わせると、代入が行われない場合が生じる。

# 短絡評価で代入が行われないケース
if False and (x := compute()):
    print(x)

# x は定義されない!後で参照すると NameError
print(x)  # NameError: name 'x' is not defined
# or でも同様の問題
default = "fallback"
result = default or (calculated := expensive_computation())

# default が Truthy なので calculated は定義されない
print(calculated)  # NameError

この問題を避けるには、代入式を短絡評価の影響を受けない位置に置くか、事前に変数を初期化しておく。

# 修正:事前に初期化
calculated = None
result = default or (calculated := expensive_computation())

# または短絡評価の外で代入
calculated = expensive_computation() if not default else None
result = default or calculated

関数呼び出しの評価順序

Python は左から右に評価する。関数の引数も左から右に評価される。

def show(x):
    print(f"evaluating: {x}")
    return x

# 左から右に評価される
result = show("a") and show("b") and show("c")
# 出力:
# evaluating: a
# evaluating: b
# evaluating: c
# 短絡評価で途中終了
result = show("a") and show("") and show("c")
# 出力:
# evaluating: a
# evaluating:
# ("c" は評価されない)

関数引数でも同様だ。

def func(a, b, c):
    return a + b + c

# 引数は左から右に評価
result = func(show(1), show(2), show(3))
# 出力:
# evaluating: 1
# evaluating: 2
# evaluating: 3

ジェネレータと遅延評価の罠

ジェネレータ式は遅延評価されるため、副作用のタイミングがさらに複雑になる。

# ジェネレータ式での副作用
def process_with_log(x):
    print(f"processing: {x}")
    return x * 2

# ジェネレータを作成した時点では何も起きない
gen = (process_with_log(x) for x in [1, 2, 3])
print("Generator created")

# イテレートするときに初めて処理される
for item in gen:
    print(f"got: {item}")

# 出力:
# Generator created
# processing: 1
# got: 2
# processing: 2
# got: 4
# processing: 3
# got: 6

これは意図した動作かもしれないが、「ジェネレータ作成時にすべて処理される」と勘違いしていたらバグになる。

複合条件での評価順序

複数の条件を and / or で繋げた場合、短絡評価により後ろの条件は評価されないことがある。

# 安全なパターン:None チェックしてからアクセス
if user is not None and user.is_active:
    process(user)

# user が None なら user.is_active は評価されない
# これは AttributeError を防ぐ良いパターン

しかし、これが副作用を含む関数呼び出しだと話が変わる。

# 危険:副作用を含む関数が呼ばれないかもしれない
if check_permission() and audit_log("accessed"):
    process_sensitive_data()

# check_permission() が False を返すと audit_log は呼ばれない
# 権限チェック失敗をログに残したかったら?
# 改善:ログは常に記録
permitted = check_permission()
audit_log("access_attempt", permitted=permitted)
if permitted:
    process_sensitive_data()

安全に副作用を扱う原則

条件式で副作用を安全に扱うための原則をまとめる。

原則 1:副作用を条件式から分離する

副作用を伴う処理は条件式の外に出し、結果を変数に格納してから条件式で使う。

原則 2:短絡評価の影響範囲を意識する

and / or / 三項演算子で「評価されない部分」がどこかを常に意識する。そこに重要な副作用があってはならない。

# 悪い例
result = fetch_from_cache() or fetch_from_db() or fetch_from_api()

# 良い例(どれが呼ばれたか明確)
cached = fetch_from_cache()
if cached:
    result = cached
else:
    db_result = fetch_from_db()
    if db_result:
        result = db_result
    else:
        result = fetch_from_api()

短絡評価のカスケードは便利だが、各関数にログ出力などの副作用があると、どこまで実行されたのか追跡が難しくなる。重要な処理では明示的な制御フローを使う方が安全だ。