今回はPythonの闇挙動についてです。


解説動画はこちら


 
Pythonの闇挙動10選


Pythonにはその独特の仕様があり
コードの書き方で思わぬ挙動を引き起こします。

そんな
変てこりんな挙動を10選んでみました。



1.is演算子の罠
(256 == 256 はTrueでも 257 == 257 はFalse!?)
a = 256
b = 256
print(a is b)  # True

c = 257
d = 257
print(c is d)  # True or False !?!?
True
False


Pythonは 小さい整数(-5~256)のオブジェクトを
キャッシュ する最適化を行っています。

そのため、a と b は同じオブジェクトを参照しており
is が True になります。

しかし、257 はキャッシュ対象外なので
c と d は別のオブジェクトとなり is が False になります。

対策:

値の比較には is ではなく == を使うべき





2. ミュータブルなデフォルト引数の罠

ミュータブル :
作成後にもその状態を変えることの出来るオブジェクトのこと

イミュータブル :
作成後にその状態を変えることのできないオブジェクトのこと

デフォルト引数に空のリストを設定した
関数を作ります。

これを実行してみると
# デフォルト引数に空のリストを設定した関数
def append_to_list(value, my_list=[]):
    my_list.append(value)
    return my_list

print(append_to_list(1))  # [1]
print(append_to_list(2))  # [1, 2] (???)
print(append_to_list(3))  # [1, 2, 3] (!!!)
[1]
[1, 2]
[1, 2, 3]


Pythonの関数のデフォルト引数は
関数が定義されたときに評価 され
一度作られたオブジェクトが 再利用 されます。

そのため my_list は毎回新しくなるわけではなく
前回の呼び出し時の変更が次の呼び出しに影響を与えます。

対策:

デフォルト引数にはミュータブルな値
(list, dict など)を使わず、None を使うようにする




3. += と + の違い(リストの参照問題)

リストを代入して新しいリストを作って
元のリストに要素を加えると...
# a のリストをコピーして新しいリスト b を作ってみよう
a = [1, 2, 3]
b = a
a += [4, 5]
print(a)  # [1, 2, 3, 4, 5]
print(b)  # [1, 2, 3] or [1, 2, 3, 4, 5] ?!?
[1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]


a += [4, 5] は a.extend([4, 5]) のように
リスト自体を変更 するため
b も同じオブジェクトを参照しているので
b にも変更が反映される

対策:

新しいリストを作成したい場合は + を使う
a = [1, 2, 3]
b = a
a = a + [4, 5]  # 新しいリストが作られる
print(a)  # [1, 2, 3, 4, 5]
print(b)  # [1, 2, 3] (bは変更されない)
[[1, 2, 3, 4, 5]
[1, 2, 3]



4. sorted() と sort() の違いに注意

リストの並び替えを行う方法は大きく2種類あります
a = [3, 1, 2]
print(sorted(a))  # [1, 2, 3]
print(a)  # [1, 2, 3] or [3, 1, 2] (???)

a.sort()
print(a)  # [1, 2, 3]
[1, 2, 3]
[3, 1, 2]
[1, 2, 3]



sorted(a) は 新しいリストを返す ため
a 自体は変更されない

a.sort() は リストを直接変更する ため
a の内容が書き換わる

対策:

リストをそのまま並び替えたい場合は リスト.sort()
元のリストを残したい場合は sorted(リスト) を使う




5. 0.1 + 0.2 == 0.3 が False になる!?

Pythonで小数点の値を比較すると...
print(0.1 + 0.2 == 0.3)  # False (???)
print(0.1 + 0.2)  # 0.3 ?!?!
False
0.30000000000000004


浮動小数点の計算誤差が原因。
0.1 や 0.2 は 2進数で正確に表現できないため
足すと誤差が生じる。

対策:

誤差を考慮して math.isclose(値 , 比較値) を使う
import math
print(math.isclose(0.1 + 0.2, 0.3))  # True
True



6."" or "Hello" は "Hello" なのに "0" or "Hello" は "0" になる!?

print("" or "Hello")  # Hello
print("0" or "Hello")  # 0 or "Hello" (???)
Hello
0



or は 最初に「真」と評価された値を返すため
""(空文字)は False なので "Hello" が返る

しかし "0" は 非空の文字列であり True と評価される ため
そのまま "0" が返る。

対策:

文字列の比較をする場合は bool(value) を明示的に使う




7.sum() で文字列を合計するとエラーになるのに max() は動く!?

print(max(["a", "b", "c"]))  # c
c
print(sum(["a", "b", "c"]))  # TypeError (???)
TypeError


sum() は 数値の合計を計算する関数 なので
文字列を足そうとするとエラーになる

max() は 「大きい方を返す」関数 なので
辞書順で "c" を返す

対策:

文字列の連結には sum() ではなく
"".join() を使う





8.range() の「スタート」には 0 が入るのに slice() には入らない!?

print(list(range(5)))  # [0, 1, 2, 3, 4]

print("hello"[slice(5)])  # hello (???)
print("hello"[slice(None, 5)])  # hello (???)
print("hello"[slice(5, None)])  # (空文字)
[0, 1, 2, 3, 4]
hello
hello



range(5) は デフォルトの開始値が 0 になるので
[0, 1, 2, 3, 4] になる

slice(5) は デフォルトの開始値が None になり
slice(None, 5) と解釈される

slice(5, None) は 5 以降の文字を取得しようとするが
範囲外なので空文字になる




9.set の順番がランダムに見える!?

文字列のデータをSET型のデータにしてみると
s = set("hello world")
print(s)
{'r', 'd', 'o', 'l', 'h', 'e', 'w', ' '}

set は 順序を保持しないデータ構造 のため
出力される順番は内部のハッシュ値によって変わる

対策:

順番を維持したいなら set ではなく
OrderedDict や list を使う

from collections import OrderedDict
ordered_set = "".join(OrderedDict.fromkeys("hello world"))
print(ordered_set)
helo wrd



10. dict.keys() の結果は list じゃない!?

d = {"a": 1, "b": 2}

print(d.keys())  # dict_keys(['a', 'b'])
print(type(d.keys()))  # 
print(list(d.keys()))  # ['a', 'b'] (明示的にリスト化)
dict_keys(['a', 'b'])
< cla ss 'dict_keys'>
['a', 'b']

d.keys() は 「ビューオブジェクト」 であり
リストではない
そのため、リストと同じように扱えないことがある

対策:

リストとして扱いたい場合は
list(d.keys()) を使う




まとめ

リストの操作や比較演算子周りには
意外と知られていない挙動が多い

1文字違うだけで別の挙動になったり
操作の順番で意図しない結果になったりする

細かい仕様を把握する必要ありますねー

ということで
今回はバグを生みやすい
Pythonの変な挙動10選についてでした。

それでは。