Python では a < b < c のように比較演算子を連鎖させて書ける。これは a < b and b < c と等価だが、内部的には単純な展開ではなく、短絡評価と中間値の再利用が行われる。この記事では、比較演算子の連鎖がどのように処理されるかを詳しく解説する。
連鎖比較の基本
Python では複数の比較演算子を連続して書ける。
x = 5
# 連鎖比較
print(1 < x < 10) # True
print(1 < x < 3) # False
print(1 <= x <= 5) # True
print(x < 10 > 3) # True(x < 10 and 10 > 3)
これは数学の記法に近く、直感的に読める。他の多くの言語ではこの構文はサポートされていない。
論理展開との違い
連鎖比較 a < b < c は a < b and b < c と同じ結果を返すが、b の評価回数が異なる。
def get_value():
print("get_value called")
return 5
# 連鎖比較:get_value() は 1 回だけ呼ばれる
result = 1 < get_value() < 10
# 出力: get_value called
print(result) # True
# and で展開:get_value() は 2 回呼ばれる
result = 1 < get_value() and get_value() < 10
# 出力:
# get_value called
# get_value called
print(result) # True
連鎖比較では中間値が一度だけ評価され、その結果が再利用される。副作用のある式や計算コストの高い式では、この違いが重要になる。
短絡評価の適用
連鎖比較でも短絡評価は適用される。左側の比較が False なら、右側は評価されない。
def never_called():
print("This should not print")
return 100
# 左の比較が False なので、右側は評価されない
result = 10 < 5 < never_called()
print(result) # False
# "This should not print" は出力されない
バイトコード解析
dis モジュールでバイトコードを見ると、連鎖比較の実装が分かる。
import dis
def chained(a, b, c):
return a < b < c
dis.dis(chained)
Python 3.11 以降の出力例:
# 2 0 LOAD_FAST 0 (a)
# 2 LOAD_FAST 1 (b)
# 4 SWAP 2
# 6 COPY 2
# 8 COMPARE_OP 0 (<)
# 14 POP_JUMP_IF_FALSE 28
# 16 LOAD_FAST 2 (c)
# 18 COMPARE_OP 0 (<)
# 24 RETURN_VALUE
# >> 26 POP_TOP
# 28 LOAD_CONST 1 (False)
# 30 RETURN_VALUE
ポイントは COPY 命令で b の値をスタックに複製し、2 つの比較で使い回していることだ。最初の比較が False なら POP_JUMP_IF_FALSE で直接 False を返す。
複雑な連鎖の例
3 つ以上の値を連鎖させることも可能だ。
a, b, c, d = 1, 2, 3, 4
# 4 つの値の連鎖比較
print(a < b < c < d) # True
print(a < b > c < d) # False(b > c が False)
# 等価演算子も連鎖可能
x = 5
print(1 < x == 5 < 10) # True
# 展開すると: 1 < x and x == 5 and 5 < 10
異なる演算子の混在
比較演算子は種類が異なっていても連鎖できる。
x = 5
# < と <= の混在
print(1 < x <= 5) # True
print(1 < x <= 4) # False
# == と != の連鎖
print(1 == 1 != 2) # True(1 == 1 and 1 != 2)
print(1 == 1 == 1) # True
# < と == の混在
print(0 < x == 5) # True
print(0 < x == 6) # False
is と in も連鎖可能
比較演算子だけでなく、is や in も連鎖に参加できる。
a = [1, 2, 3]
b = a
# is の連鎖
print(a is b is a) # True
# in の連鎖
print(1 in a in [a]) # True(1 in a and a in [a])
# 混在
x = None
print(x is None == True) # False(None == True が False)
ただし、is と == を混在させると意図しない結果になることがあるので注意が必要だ。
連鎖比較の落とし穴
直感に反する動作をする場合がある。
# 意外な結果
print(1 < 2 < 3) # True(期待通り)
print(1 < 2 > 1) # True(1 < 2 and 2 > 1)
print(1 == 1 in [1]) # True(1 == 1 and 1 in [1])
print(1 in [1] == True) # False !
# なぜ False ?
# 1 in [1] == True は
# (1 in [1]) and ([1] == True) と展開される
# [1] == True は False
複雑な連鎖は可読性を損なうので、シンプルな範囲チェック以外では使わない方が安全だ。
実践的な使用例
連鎖比較が効果的なのは、範囲チェックの場面だ。
# 範囲チェック(推奨)
def is_valid_age(age):
return 0 <= age <= 150
def is_valid_percentage(value):
return 0 <= value <= 100
def is_business_hours(hour):
return 9 <= hour < 18
# 文字コードのチェック
def is_uppercase(char):
return 'A' <= char <= 'Z'
def is_digit(char):
return '0' <= char <= '9'
# 複数条件の範囲チェック
def is_valid_rgb(r, g, b):
return 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255
# 連鎖比較を使わない場合
def is_valid_rgb_verbose(r, g, b):
return (r >= 0 and r <= 255 and
g >= 0 and g <= 255 and
b >= 0 and b <= 255)
パフォーマンス
連鎖比較は中間値を 1 回しか評価しないため、and で展開するより効率的な場合がある。
import timeit
def chained(a, b, c):
return a < b < c
def expanded(a, b, c):
return a < b and b < c
# 単純な値なら差はわずか
print(timeit.timeit("chained(1, 2, 3)", globals=globals(), number=1000000))
print(timeit.timeit("expanded(1, 2, 3)", globals=globals(), number=1000000))
単純な値では差はわずかだが、中間値の計算にコストがかかる場合は連鎖比較の方が有利だ。
まとめ
Python の比較演算子の連鎖は、内部的に以下のように処理される。
a < b < c では b が 1 回だけ評価され、2 つの比較で使い回される。
左側の比較が False なら、右側は評価されない。
範囲チェックには積極的に使うべきだが、複雑な連鎖は避けた方がよい。特に is、in、== を混在させると意図しない結果になりやすいので注意が必要だ。