関数を書いていて、引数を省略したときの値をあらかじめ決めておきたくなることはありませんか。買い物かごを受け取る関数なら、何も渡されなければ空のかごから始めたいはずです。
そこでbasket=[]と書きたくなります。自然な発想ですし、私も最初にこう書きました。
ところがこの一行が、Pythonでもっとも有名な落とし穴のひとつです。この記事では、何が起きているのかと、どう書き直せばいいのかを順番に見ていきます。
同じ関数を呼んだだけなのに、前の結果が残っている¶
まずは実際の動きを見てみましょう。品物をかごに入れて返すだけの、短い関数です。
手元のPython 3.11で動かした結果を、そのまま載せます。
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item('りんご')) # ['りんご']
print(add_item('みかん')) # ['りんご', 'みかん']
print(add_item('ぶどう')) # ['りんご', 'みかん', 'ぶどう']
2回目の呼び出しで、りんごが混ざってきました。3回目にはみかんまで居座っています。
期待していたのは、毎回['みかん']のように、その回に入れた品物だけが入ったかごです。けれど実際には、前の呼び出しの結果がそのまま引き継がれています。
ここで多くの人は、自分のコードのどこかで変数を使い回してしまったのだろうと考えます。私もそう疑って、呼び出し側を何度も読み返しました。
犯人は呼び出し側ではありません。basket=[]と書いた、その場所です。
デフォルト値は、関数を定義したときに1回だけ作られる¶
Pythonのデフォルト引数には、はっきりとした決まりがあります。デフォルト値はdef文が実行されたときに評価され、その結果が関数に貼り付けられたまま残るという決まりです。
つまり[]が作られるのは、関数を呼んだときではありません。関数を定義したときの、たった1回だけです。
def文が実行される瞬間に起きていること¶
defは、実行時に読み飛ばされる宣言ではありません。プログラムが上から下へ流れる途中で実際に実行され、関数オブジェクトをひとつ作る命令です。
そのときデフォルト値も一緒に評価され、作られた空のリストが関数に結びつけられます。以降どれだけ呼び出しても、そのリストは作り直されません。
引数を省略して呼ぶたびに、毎回まったく同じリストが手渡されているというのが、この現象の正体です。中身を書き換えれば、当然その変化は次の呼び出しにも残ります。
関数が抱え込んだデフォルト値をのぞいてみる¶
言葉だけだと半信半疑かもしれません。関数が持っているデフォルト値は、__defaults__という属性から直接のぞけます。
さきほどの3回の呼び出しのあとに、確かめてみましょう。
print(add_item.__defaults__)
# (['りんご', 'みかん', 'ぶどう'],)
関数そのものが、3つの品物を抱え込んでいました。呼び出し側のどこにも存在しない、この関数だけが知っているかごです。
ここを一度見てしまえば、もう忘れないはずです。デフォルト値は関数の持ち物であって、呼び出しごとの新品ではありません。
壊れる値と壊れない値の境目¶
では、デフォルト引数はすべて危ないのでしょうか。そんなことはなく、境目ははっきりしています。
分かれ目は、その値が中身を書き換えられるかどうかです。書き換えられる値をミュータブル、書き換えられない値をイミュータブルと呼びます。
よく使う型を、この観点で並べてみます。
| デフォルト値の例 | 種類 | 使い回しても安全か |
|---|---|---|
0、3.14 |
イミュータブル | 安全。書き換えられないので影響が残らない |
'hello' |
イミュータブル | 安全 |
None、True |
イミュータブル | 安全。定番の書き方 |
() 空のタプル |
イミュータブル | 安全 |
[] 空のリスト |
ミュータブル | 危険。前回の変更が残る |
{} 空の辞書 |
ミュータブル | 危険 |
set() 空の集合 |
ミュータブル | 危険 |
| 自作クラスのインスタンス | 多くはミュータブル | 危険なことが多い |
数値や文字列が安全なのは、そもそも中身を書き換える手段がないからです。n += 1のような書き方は、元の値を変えるのではなく、新しい値を作って名前を付け替えているだけです。
リストとタプルのどちらを使うか迷ったことがある方は、この違いがそのまま効いてきます。【関連記事】Pythonのリスト(list)とタプル(tuple)、どっちを使う? それぞれの違いを徹底解説
正しい書き方はNoneを目印にすること¶
直し方は決まりきっていて、Pythonを書く人なら誰もが同じ形を使います。デフォルトにはNoneを置き、関数の中で新しいリストを作るやり方です。
さきほどの関数を書き直してみましょう。
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item('りんご')) # ['りんご']
print(add_item('みかん')) # ['みかん']
print(add_item.__defaults__) # (None,)
今度は独立したかごが返ってきました。__defaults__に入っているのもNoneだけで、何も溜め込んでいません。
大事なのは、空のリストを作る場所が関数の中へ移ったことです。関数の中は呼び出しのたびに実行されるので、毎回新しいリストが生まれます。
比較に==ではなくisを使っているのも理由があります。Noneはプログラム全体でただひとつしか存在しない値なので、同一性で比べるのが正確で速いのです。
Noneという値そのものが少し不思議に見える方は、こちらで正体を押さえておくと安心です。【関連記事】Noneはただの空ではない。Pythonに1つしか存在しないシングルトンの正体とは?
時刻をデフォルトにすると、そこで時間が止まる¶
この落とし穴は、リストや辞書だけの話ではありません。デフォルト値として関数を呼び出したときにも、まったく同じことが起こります。
いちばん事故になりやすいのが、現在時刻です。次のコードを見てください。
from datetime import datetime
import time
def log(msg, at=datetime.now()):
return f'{at:%H:%M:%S} {msg}'
print(log('one')) # 20:08:33 one
time.sleep(2)
print(log('two')) # 20:08:33 two
2秒待ってから呼んだのに、時刻はまったく同じでした。datetime.now()が実行されたのは関数を定義した瞬間だけで、その1回の結果がずっと使い回されているからです。
これが厄介なのは、エラーがまったく出ないところです。ログの時刻がおかしいと気づくまで、何日も動き続けてしまいます。
こちらの直し方も同じで、デフォルトをNoneにして、関数の中でdatetime.now()を呼びます。呼び出しのたびに現在時刻を取り直したいなら、取り直す場所を関数の中に置く。それだけの話です。
dataclassは同じ間違いをエラーで止めてくれる¶
クラスを書くときにも、同じ問題は顔を出します。ただしdataclassを使っている場合は、Pythonのほうが先に気づいて止めてくれます。
リストをそのままデフォルトにすると、どうなるでしょうか。
from dataclasses import dataclass, field
@dataclass
class User:
name: str
tags: list = []
# ValueError: mutable default <class 'list'> for field tags is not allowed:
# use default_factory
クラスを定義した時点でValueErrorが飛びました。しかも、代わりにdefault_factoryを使えという指示までメッセージに入っています。
指示どおりに直すと、こうなります。
@dataclass
class User:
name: str
tags: list = field(default_factory=list)
a = User('taro')
b = User('hanako')
a.tags.append('admin')
print(a) # User(name='taro', tags=['admin'])
print(b) # User(name='hanako', tags=[])
default_factoryには、値ではなく値を作る関数を渡します。listと書いているのは、リストそのものではなくリストを作る関数だからです。
こうしておけば、インスタンスを作るたびに新しいリストが用意されます。片方にタグを足しても、もう片方は空のままです。
dataclassの書き方をまだ触っていない方は、先にこちらで全体像をつかんでおくと読みやすくなります。【関連記事】Pythonのdataclassとは?クラスの定義がぐっと短くなる書き方を初心者向けに解説
目で探さず、ツールに見つけてもらう¶
ここまで読めば仕組みは分かります。それでも、書いている最中にうっかりやってしまうのが人間です。
だからこの手のミスは、機械に探させるのがいちばん確実です。ruffというツールには、まさにこれを見つけるためのルールが入っています。
先ほどの壊れた関数を、ruffに読ませてみました。
$ ruff check --select B006 sample.py
B006 Do not use mutable data structures for argument defaults
--> sample.py:1:27
|
1 | def add_item(item, basket=[]):
| ^^
help: Replace with `None`; initialize within function
問題の位置を指して、Noneに置き換えて関数の中で初期化せよ、と直し方まで教えてくれます。B006というのが、このルールに付けられた番号です。
ruffは非常に速く、保存のたびに走らせても気になりません。この種のバグは、覚えるよりも仕組みで防ぐほうが確実です。
ruff自体をまだ導入していない方は、こちらから始めてみてください。【関連記事】Ruff(ラフ)とは?Pythonで綺麗なコードを書く初心者向けにわかりやすく解説
実務で気をつけていること¶
私は10年ほどエンジニアとして開発に関わってきましたが、この落とし穴には一度しっかり痛い目を見ています。
集計処理の関数で、オプションの設定をoptions={}という形で受け取っていました。テストでは問題なく通り、レビューも通り、そのまま動き続けました。
おかしくなったのは、数か月後です。ある利用者の設定が、別の利用者の集計結果に混ざるという報告が上がってきました。
関数の中でoptionsに値を足していたので、その追加が全員共有の辞書に積み上がっていたのです。原因を突き止めるまで、まる一日かかりました。
厄介なのは、1回だけ呼ぶテストでは絶対に再現しないところです。同じ関数を2回以上呼んで初めて牙をむくので、テストを書いていても見逃します。
それ以来、引数のデフォルトに書いていいのはNoneか数値か文字列だけ、と自分の中で決めています。迷ったらNoneにしておけば、まず間違いになりません。
もうひとつ、値が共有されているかどうかを確かめる癖も付きました。オブジェクトのコピーまわりの感覚は、ここでも役に立ちます。【関連記事】Pythonの浅いコピー(シャローコピー)と深いコピー(ディープコピー)の違いを徹底解説!
まとめ¶
Pythonのデフォルト値は、関数を定義したときに1回だけ作られます。呼び出しのたびに作り直されるわけではありません。
そのため[]や{}をデフォルトにすると、すべての呼び出しが同じ1つの入れ物を共有します。中身を書き換えれば、その変化は次の呼び出しにも残ります。
直し方はNoneを目印にして、関数の中で新しい値を作ることです。現在時刻のように、呼ぶたびに変わってほしい値も同じ扱いになります。
クラスならfield(default_factory=list)を使い、書き忘れはruffのB006に見つけてもらう。この3点を押さえておけば、もう踏むことはありません。
まずはお手元のコードで、引数に=[]や={}と書いている場所を検索してみてはいかがでしょうか。ここまでお読みいただきありがとうございました。