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