44
27

単純なのに間違える!?Pythonコードの落とし穴

Last updated at Posted at 2024-09-23

はじめに

Pythonは、初心者でも扱いやすいと言われる、シンプルで直感的な構文が特徴のプログラミング言語です。そのため、初心者から上級者まで幅広く使われていますが、シンプルだからこそ思わぬミスを引き起こすことも少なくありません。

本記事では、そんな「単純なのに間違えやすい」Pythonコードの落とし穴にスポットを当て、見逃しやすいポイントや、気をつけるべき注意点を解説します。具体的なコードを紹介するので、出力結果を予想してみましょう!

弊社Nucoでは、他にも様々なお役立ち記事を公開しています。よかったら、Organizationのページも覗いてみてください。
また、Nucoでは一緒に働く仲間も募集しています!興味をお持ちいただける方は、こちらまで。

落とし穴的Pythonコード

1. 最初の落とし穴:インデント

まずはPythonの最初の落とし穴、インデントです。早速ですが、以下のコードを実行するとどのような出力が得られるでしょう?

def calculate_sum(a, b):
    result = a + b
        return result

print(calculate_sum(2, 3))

Pythonのインデントは、初心者や他のプログラミング言語に慣れている人が間違えやすいポイントです。特に、他の言語ではブロックを中括弧 {} で囲むスタイルに慣れている場合、インデントの重要性に気づかずミスをしてしまうことがあります。

Pythonでは、関数のブロック内にあるコードはインデントする必要があります。インデントでコードの階層を示すため、インデントが欠けるとエラーになるのです。
したがって、このコードの出力は以下の通りになります。

出力
  File "/省略/main.py", line 3
    return result
IndentationError: unexpected indent

インデントを揃えれば良いので、直感的で見やすいコードを意識することで解決につながりますが、慣れるまでは少し注意が必要な部分ですね。

もうわかっていると思いますが、修正案は以下の通りです。

修正案
def calculate_sum(a, b):
    result = a + b
    return result

print(calculate_sum(2, 3))

2. boolか?intか?

TrueとFalseの論理値は、コードを書く上で理解必須の値です。正誤の判別に用いられるわかりやすい値ですが、以下のコードを実行すると、出力はどうなるでしょうか?

number_list = [3, True, 5, 19, "msg"]
cnt = 0

for idx, elem in enumerate(number_list):
    if isinstance(elem, int):
        pass
    else:
        cnt += 1
        print(f"リストの {idx+1} 番目に整数ではない値が含まれています。")

print(f"整数以外の値が {cnt} 個検出されました。")

実は、Pythonにおいてbool型はint型のサブタイプとなっており、Trueには1、Falseには0の値が割り当てられています。

上記のコードはnumber_listの中身が整数値かどうかを判別し、整数値ではない場合にその旨を出力するというものです。
見ての通りリストの2番目と5番目に整数値ではないものが含まれているので、「リストの 2(5) 番目に整数ではない値が含まれています。」という2文が出力された後「整数以外の値が 2 個検出されました。」と出力されるのが期待する挙動のはずですが、実際の出力は以下のようになります。

出力
リストの 5 番目に整数ではない値が含まれています。
整数以外の値が 1 個検出されました。

bool型がint型のサブタイプであることは便利な特性ですが、この性質によって思わぬミスが発生することもあります。今回の例に関してはint型の中のbool型を除けばいいので、修正案は以下の通りです。

修正案
number_list = [3, True, 5, 19, "msg"]
cnt = 0

for idx, elem in enumerate(number_list):
    if isinstance(elem, int) and not isinstance(elem, bool):
        pass
    else:
        cnt += 1
        print(f"リストの {idx+1} 番目に整数ではない値が含まれています。")

print(f"整数以外の値が {cnt} 個検出されました。")

3. 代入処理の罠

変数への代入において = はよく使用される演算子だと思いますが、:= はあまり見かけません。では、以下のコードを実行した時の出力はどうなるでしょうか。

a, b = 1, 2
print(a, b)

(a, b := 3, 4)
print(a, b)

結論から言ってしまうと、:= 演算子の罠は複数の値を一度に代入できないことにあります。
(a, b := 3, 4) と書いたときに期待される動作は a に 3、b に 4 を代入することですが、実際には b := 3 の部分だけが有効になり、a は変更されず、4 も無視されます。
a, b = 1, 2で a と b の二つの変数へ一度に値を代入できたのと同じように考えてしまってはいけません。

そういうわけで、出力は以下のようになりますね。

出力
1 2
1 3

この演算子を使う場合は1つの変数に対してのみ使うようにし、複数の変数への同時代入が必要な場合は、従来の代入文 = を使ったほうがいいでしょう。

修正案
a, b = 3, 4
print(a, b)
# もしくは
(a := 3, b := 4)
print(a, b)

4. デフォルト引数がデフォルトされない

これは、Pythonで可変なデフォルト引数(例えばリストや辞書)を使うときに起こる問題です。以下のコードを実行した時の出力はどうなるか、考えてみましょう。

def add_to_list(value, my_list=[]):
    my_list.append(value)
    return my_list

print(add_to_list(1))
print(add_to_list(2))
print(add_to_list(3))

上記のコードの場合、期待される挙動は関数を呼び出す度に新しい空のリストにvalueとして指定した値を追加していくものだと思うのですが、実際はこのadd_to_list関数のようにデフォルト引数に [] や {} を使用すると、関数が呼び出されるたびに同じリストや辞書が使われます

したがって、出力は以下のようになります。

出力
[1]
[1, 2]
[1, 2, 3]

この問題を避けるためには、デフォルト値として None を使い、関数内部で新しいリストを初期化する方法が一般的です。これにより、関数を呼び出すたびに新しいリストが作成され、デフォルト引数の再利用による問題が解消されます。

修正案
def add_to_list(value, my_list=None):
    if my_list is None:
        my_list = []
    my_list.append(value)
    return my_list

print(add_to_list(1))
print(add_to_list(2))
print(add_to_list(3))

5. 参照する値はどれ?

Pythonにおけるジェネレータとは、必要な時に値を作成する仕組みです。そんなジェネレータに関して、以下のコードの出力を予想してみましょう。

data_a = [0,1,2,3]
gen_a = (item for item in data_a)
data_a = [4,5,6,7]

data_b = [0,1,2,3]
gen_b = (item for item in data_b)
data_b[:] = [4,5,6,7]

print(list(gen_a))
print(list(gen_b))

ここで一度、ジェネレータ式について考えてみましょう。ジェネレータ式では、in節は宣言時に評価されます。そのため、print(list(gen_a))の時点でdata_aは新しいオブジェクト [4,5,6,7] にバインドされているのですが、gen_a内では古いオブジェクト[0,1,2,3]を参照し続けます。

一方、data_bは値の再代入がスライス割り当てによって行われ、古いオブジェクト [0,1,2,3] が [4,5,6,7] に更新されます。そのため、print(list(gen_b))の時点でgen_bでも古いオブジェクトの更新結果である[4,5,6,7]を参照するのです。

さて、出力結果が以下のようになることは予想できていたでしょうか。

出力
[0, 1, 2, 3]
[4, 5, 6, 7]

どちらの結果を期待するかは置いておいて、予期しない問題を避けるためにはジェネレータを使う際にはオブジェクトが変更されないことを確認するか、オブジェクトをコピーするなどの工夫をする必要がありますね。

6. 計算が合わない

Pythonでは小数点の計算において誤差が生じ、正しい計算結果を得られない場合があります。まず以下のコードの出力を予想し、なぜそうなるかも考えてみてください。

result = 0.1 + 0.2
print(result == 0.3)

本来であれば 0.1 + 0.2 = 0.3 であるので、出力結果はTrueになるはずです。しかし結果は...

出力
False

こうなってしまいます。なぜFalseになってしまうのでしょうか。
この問題の原因は、10進数の一部が2進数で正確に表現できないことです。10進数の0.1を2進数に直すと0.00011...と続く循環小数になり、0.2でも同様に0.00110...と続く循環小数になります。循環小数なので数字は無限に続きますが、メモリにも限りがあるのでどこかで区切らなくてはいけません。
その部分を丸め処理を用いて丸めた結果、若干の誤差が生まれてしまうのです。

実際に、0.1 + 0.2の値を出力すると誤差を確認することができます。

print(0.1 + 0.2)
出力
0.30000000000000004

この内容に関してのより詳しい解説は、以下の記事に載っています。気になる方は併せてご覧ください。

「0.1+0.2≠0.3」を説明できないエンジニアがいるらしい

7. カンマのつけ忘れ

タプルを使用する際にも、思いがけない落とし穴が存在します。以下のコードの出力結果を予想してみましょう。

tuple_a = (1,2,3)
tuple_b = (4)

print(tuple_a, type(tuple_a))
print(tuple_b, type(tuple_b))

上記では、tuple_atuple_bでタプルを指定し、その中身と型名をそれぞれ出力するコードを記述しています。()で囲っていますし、型名としてはどちらも<class 'tuple'>と出力されることを期待してしまいます。

しかし、タプルは必ず,をつけなくては例え()で囲っていてもタプルと認識されないと言う特性を持っています。tuple_aの場合は要素が複数のためカンマを使用していますが、tuple_bの場合は要素が一つのためカンマを使用していません。したがって出力は以下のようになります。

出力
(1, 2, 3) <class 'tuple'>
4 <class 'int'>

どちらも()で囲っているのにカンマがないだけでタプルの(4)は整数の4と同じ意味合いになってしまうことがわかると思います。

タプルとリストは違いが分かりずらいと言われますが、必ず,が必要なのがタプル、必ずしも必要なわけではないのがリストです。
今回のコードにおいて(4)をタプルにしたいのであれば、以下のように修正するといいでしょう。

修正案
tuple_b = (4,)

print(tuple_b, type(tuple_b))

8. リストの要素飛ばし

for文等のループ処理中にリストの要素を削除したらどうなるでしょうか。やってしまった経験がある人もいるかと思いますが、ひとまず以下のコードを実行するとどのような出力が得られるか予想してみましょう。

list_a = [0, 1, 2, 3]
for idx, elem in enumerate(list_a):
    list_a.remove(elem)

print(list_a)

これは初心者の頃にやりがちなミスの一つですが、リストの反復処理中に要素を削除すると要素がシフトされ、次のインデックスが正しい要素を参照できなくなります
例えば、最初に0を削除すると、1がインデックス0に移動しますが、次のループではインデックス1を参照するため、1がスキップされてしまいます。このため、リスト内の要素が期待通りに処理されず、反復がうまく行きません。

したがって、出力は以下のようになってしまいます。

出力
[1, 3]

そもそも、反復処理中のオブジェクトを変更するのは悪手です。安全にリストを操作するためには、元のリストをそのままにしてリストのコピーを使って反復処理を行うのがベストです。例えば、以下のように修正すると期待通りに正しく動作します。

修正案
list_a = [0, 1, 2, 3]
for idx, elem in enumerate(list_a[:]):
    list_a.remove(elem)

print(list_a)

9. 結果が異なる同じ演算

プログラミングにおいて、同じ内容のはずなのに結果が異なってくる演算というものは多々存在しますが、今回もその一例です。以下のコードから得られる出力はどうなるでしょうか?

list_a = [0, 1, 2, 3]
list_b = list_a
list_a = list_a + [4, 5, 6, 7]
print(list_a)
print(list_b)

list_c = [0, 1, 2, 3]
list_d = list_c
list_c += [4, 5, 6, 7]
print(list_c)
print(list_d)

本来であれば、list_alist_clist_blist_dがそれぞれ同じ結果になる挙動を期待するでしょう。α += β と α = α + β は同じ挙動をもつ演算のはずですが、リストとなると少々違います。

a = a + [4, 5, 6, 7]の場合新しいリストが作成され、その新しいリストがaに割り当てられますが、bは元のリストを指したままなので変更されません。一方、a += [4, 5, 6, 7]はリストのextend関数に対応しており、リストaがその場で変更されるため、abは同じリストを参照し続け、両方に変更が反映されます。

したがって、出力結果は以下のようになります。

出力
[0, 1, 2, 3, 4, 5, 6, 7]
[0, 1, 2, 3]
[0, 1, 2, 3, 4, 5, 6, 7]
[0, 1, 2, 3, 4, 5, 6, 7]

数値の計算等の場合は特に気にする必要はありませんが、リストを扱う場合は注意しましょう。

10. 特殊な丸め処理

Pythonの組み込み関数roundは四捨五入のような丸め処理を行う関数ですが、特定の状況では期待とは異なる丸め方をすることがあります。まずは以下のコードの出力を予想してみましょう。

print(round(0.5))
print(round(1.5))
print(round(2.5))
print(round(3.5))

Pythonのroundによる丸め処理には、「偶数への丸め」が適用されます。偶数への丸めとは、丸める値が5より小さい場合は切り捨て、5より大きい場合は切り上げし、ちょうど5の場合は切り捨てと切り上げのうち結果が偶数となる方へ丸める方法を指します。別名「銀行家の丸め」とも言います。

したがって、出力が次の通りです。

出力
0
2
2
4

また、roundでは丸める位置を指定することができますが、丸める値が小数点以下でなかったとしてもこのルールが適用されます。
以下のコードは、一つ目の引数を十の位で丸めるものです。

print(round(5, -1))
print(round(15, -1))
print(round(25, -1))
print(round(35, -1))
出力
0
20
20
40

もし、言葉の通り正しく四捨五入(四捨五入する値が5より小さい場合は切り捨て、5以上の時は切り上げで固定)したいのであれば、decimalモジュールを使用することで可能になります。

修正案
from decimal import Decimal, ROUND_HALF_UP
print(Decimal(2.5).quantize(Decimal('0'), rounding=ROUND_HALF_UP))
print(Decimal(3.5).quantize(Decimal('0'), rounding=ROUND_HALF_UP))

11. 自作ファイル名の注意

記述するコードが複雑になってくると、どうしても一つのファイルだと見づらくなるためファイルを複数に分ける必要が出てきます。そんな時、自作するファイルにつける名前には気をつけた方がいいでしょう。
ひとまず、以下のmain.py実行時の出力結果を考察してみましょう。

unittest.py
def my_function():
    print("Hello, World!")
main.py
import unittest

unittest.my_function()

自作ファイルのunittest.pyの中にはエンジニアお馴染みの"Hello, World!"を標準出力させる関数が記述してあり、main.pyの方でそれを呼び出すと以下の出力が求められます。

出力
Hello, World!

unittest.pyの呼び出しが成功し、期待通りの挙動をしていますね。
ところで、Pythonには標準でunittestというモジュールが存在します。次は全く同じ条件でそのモジュールの方を使ってみましょう。main.pyの中身のみ以下のように書き換えます。

main.py
import unittest

def is_even(n):
    return n % 2 == 0

class TestIsEvenFunction(unittest.TestCase):
    def test_is_even(self):
        self.assertTrue(is_even(2))
        self.assertTrue(is_even(0))

if __name__ == '__main__':
    unittest.main()

is_even(n)関数は引数nが偶数かどうかを判定する関数で、TestIsEvenFunctionクラスのtest_is_evenメソッド内でis_even関数が正しく動作するかどうかをテストしています。本来であれば動作テストの結果が出力されて欲しいのですが、実際の結果は以下のとおりです。

出力
    class TestIsEvenFunction(unittest.TestCase):
                             ^^^^^^^^^^^^^^^^^
AttributeError: module 'unittest' has no attribute 'TestCase'

エラーが出てしまいました。
この二つの結果から、自作ファイル名と標準モジュール名が一致した場合、自作ファイルの方が優先されてしまうことがわかります。

この問題の解決策は一つ、自作ファイル名を既存のモジュール名と被らないように工夫することです。今回の例で言えば、unittest.pymy_unittest.pyとすることでunittestの名前の衝突を回避でき、以下のような期待通りの挙動が得られるようになるのです。

出力
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

この問題に関してはunittestに限らず他の既存のモジュール名に関しても言えることなので、ファイル名を決める際は被らないように注意しながら命名することをおすすめします!

12. コンストラクタ使い分けミス

クラスを記述するとき、__init__と__new__の使い分け方を意識していますか?この二つは内容が似ていてどう使い分けるべきなのか悩むこともあるかと思いますが、それぞれに役割があるため適切に使わなければ期待する挙動を実現できない可能性があります。

まずはこちらのシングルトンの実装コードの出力を考えてみましょう。

class Singleton:
    _instance = None

    def __init__(self, value):
        if Singleton._instance is None:
            Singleton._instance = self
            self.value = value

s1 = Singleton(1)
s2 = Singleton(2)

print(s1.value)
print(s2.value) 

シングルトン
そのクラスのインスタンスが必ず1つであることを保証するデザインパターン。

上記のコードでは、シングルトンの実装を__init__だけで完成させるように書かれています。しかし、__init__メソッドはインスタンスが既に生成された後に呼ばれ、インスタンスの生成そのものを制御することはできません。そのため、シングルトンを__init__だけで実装することはできないのです。

したがって、出力結果は以下のようになります。

出力
1
Traceback (most recent call last):
  File "/省略/main.py", line 10, in <module>
    print(s2.value)
          ^^^^^^^^
AttributeError: 'Singleton' object has no attribute 'value'

シングルトンでは、クラスのインスタンスが1つだけであることを保証するために、インスタンスの生成自体を制御する必要があります。__new__メソッドでは、それを実現できます。

修正案
class Singleton():
    def __new__(cls, *args, **kargs):
        if not hasattr(cls, "_instance"):
            cls._instance = super().__new__(cls)
        return cls._instance
    
    def __init__(self, value):
        self.value = value
        
s1 = Singleton(1)
s2 = Singleton(2)

print(s1.value)
print(s2.value) 

これで、s1.valueが更新されればs2.valueも更新されるというような、インスタンスがたった一つだけとなるシングルトンを記述することができました。

また、イミュータブル(不変)なオブジェクトの変更においても、__init__と__new__を正しく使い分けることで実現することができます。以下はイミュータブルなオブジェクトであるタプルimm_objを(1,5,3)から(1,2,3)へ変更するコードです。出力結果はどうなるでしょうか。

class ImmChange:
    def __init__(self, values):
        self.values = values
        self.values[1] = 2
        return self.values

imm_obj = ImmChange((1, 5, 3))
print(imm_obj)

イミュータブルオブジェクトは文字通り変更できないオブジェクトであるので、クラスの中で処理したとしてももちろん結果は決まっています。

出力
    self.values[1] = 2
    ~~~~~~~~~~~^^^
TypeError: 'tuple' object does not support item assignment

エラーが出てしまいました。しかし、これを__new__を用いてインスタンスの生成から行うと、

修正案
class ImmChange(tuple):
    def __new__(cls, values):
        self = tuple.__new__(cls, (
            values[0], 2, values[2]
        ))
        return self

imm_obj = ImmChange((1, 5, 3))
print(imm_obj)
出力
(1, 2, 3)

このように、期待通りの出力が返ってくることが確認できました。
__new__は、インスタンスの生成そのものを制御するもの。
__init__は生成されたインスタンスを初期化するもの。
これら二つの役割を把握し、使い分けましょう。

13. 一見同じでも中身は違う

見た目は同じでも挙動が異なってくるものはいくつかありますが、今回はリストについて扱います。以下のコードの出力はどうなるでしょうか?

line_a = [''] * 3
grid_a = [line_a] * 3
grid_a[0][0] = 3
print(grid_a)

grid_b = [['']*3 for i in range(3)]
grid_b[0][0] = 3
print(grid_b)

作成方法は違いますが、どちらも二次元リストを作成し、1行1列目の値を3に変更しています。しかし、結果は同じではありません。

grid_aは、line_aが同じリストを3回参照しているため1つの変更がすべての行に反映されます。一方grid_bは、各行が別々のリストとして作成されるため他の行に影響を与えません。
したがって、出力結果は以下のようになります。

出力
[[3, '', ''], [3, '', ''], [3, '', '']]
[[3, '', ''], ['', '', ''], ['', '', '']]

一見同じコードでも、リストの参照先が同じか異なるかで動作が変わることがあります。特に、リストのコピーや生成方法に注意しないと、意図しない箇所に変更が反映される可能性があります。

14. 区切りの規則

文字列の分割は、データを扱う上で使用頻度がかなり高い部類に入る処理です。文字列の分割を行う時はsplitメソッドを用いることが多いと思いますが、以下のコードの場合splitメソッドの挙動はどうなるでしょう?

sp_a = ''.split()
print(sp_a, len(sp_a))

sp_b = ''.split(' ')
print(sp_b, len(sp_b))

このコードを理解するためには、splitメソッドの空白文字の扱いを理解する必要があります。

デフォルトでは、連続する空白文字を1つの区切りとして扱い、結果には先頭や末尾の空白は含まれません
一方、明示的に ' ' という区切り文字を指定すると、連続する区切り文字は個別に処理され、空の要素が含まれることがあります
したがって、出力の結果はこのようになります。

出力
[] 0
[''] 1

これだといまいちピンとこない人もいるかもしれませんが、以下のコードのように先頭と末尾の空白文字がどのように処理されるかに注目すると、わかりやすいかもしれません。

space_str = '  a ' # 空白、空白、a、空白
print(space_str.split())
print(space_str.split(' '))
出力
['a']
['', '', 'a', '']

この結果に、splitメソッドがデフォルトでは連続する空白文字を1つの区切りとして扱い、' ' という区切り文字を指定すると連続する区切り文字を個別に処理するという挙動がよく表れていますね。

15. 推奨されないインポートの理由

最後に、皆さんがよく使うであろうimportについてです。以下のmain.pyを実行するとエラーが発生してしまうのですが、なぜエラーになるかわかりますか?

greeting.py
def greet_func():
    print("How are you?")

def _another_greet_func():
    print("What's up?")
main.py
from greeting import *

greet_func()
_another_greet_func()

実は、ワイルドカードインポート(from greeting import *)は推奨されていません。名前が明示的に指定されないため、意図しないバグが発生してしまうかもしれないからです。
特に、先頭にアンダースコア( _ )がついた名前は、通常インポートされません。したがって、このコードの出力結果および発生するエラーは以下の通りです。

出力
How are you?
Traceback (most recent call last):
  File "/省略/main.py", line 4, in <module>
    _another_greet_func()
    ^^^^^^^^^^^^^^^^^^^
NameError: name '_another_greet_func' is not defined

ワイルドカードインポートはあまり使用するべきではないですが、どうしても使用したい場合は以下のようにワイルドカードインポートを行うときに使用できるパブリックオブジェクトのリストを含むリスト __all__ をモジュール内に定義する必要があります。

greeting.py
__all__ = ['greet_func', '_another_greet_func']

def greet_func():
    print("How are you?")

def _another_greet_func():
    print("What's up?")

また、今回はインポートしたファイルが一つだけだったためgreet_func関数は問題なく処理されましたが、インポートする順番によって意図せず上書きする可能性もあり、__all__を使用すれば全て解決するわけではありません。
意図しないバグを防ぐためにも、できるだけワイルドカードインポートは使用せず、必要なものだけインポートするようにしましょう。

まとめ

「単純なのに間違える!?Pythonコードの落とし穴」いかがだったでしょうか。当たり前のことすぎて落とし穴だとは思わなかった、と言うような上級Pythonistの方は是非コメントでその他の落とし穴を教えてください。

Pythonはシンプルで直感的な言語として知られていますが、その一方で、思わぬ落とし穴にハマることもあります。シンプルなコードでも、Python特有の挙動を理解していないと思わぬ結果を招くことがあります。注意深くコードを確認する習慣をつけましょう!

弊社Nucoでは、他にも様々なお役立ち記事を公開しています。よかったら、Organizationのページも覗いてみてください。
また、Nucoでは一緒に働く仲間も募集しています!興味をお持ちいただける方は、こちらまで。

44
27
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
27