条件式における副作用と評価順序の罠(Python)
Python の条件式では、評価順序と短絡評価により「評価されない部分」が生じる。副作用を伴うコードをそこに書くと、実行されたりされなかったりして予期せぬバグを引き起こす。この記事では、条件式における副作用の罠と、それを避けるための原則を解説する。
副作用とは何か
副作用(side effect)とは、式の評価が値を返す以外の影響を及ぼすことだ。具体的には以下のようなものが該当する。
短絡評価と副作用の組み合わせ
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()安全に副作用を扱う原則
条件式で副作用を安全に扱うための原則をまとめる。
副作用を伴う処理は条件式の外に出し、結果を変数に格納してから条件式で使う。
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()短絡評価のカスケードは便利だが、各関数にログ出力などの副作用があると、どこまで実行されたのか追跡が難しくなる。重要な処理では明示的な制御フローを使う方が安全だ。



