病みつきエンジニアブログ

機械学習、Python、Scala、JavaScript、などなど

今どきの Python ならデフォルト引数に空の list や dict を指定しても良くない?

この記事はPython Advent Calendar 2020 の7日目の記事です。

今年の PyConJP 2020 では、Python の型ヒントについて登壇させていただきました。

speakerdeck.com

2020 年も終わりかけですから、「もう 2021 年からは Python のデフォルト引数に list 入れてもよくないか?」という提案をしてみたいと思います。

Python のデフォルト引数のアンチパターン

Python は引数にデフォルト値(デフォルト引数)を指定することができます。

def generate_zero(x=0):
    return x

zero = generate_zero()
print(zero)
# 0
print(zero + 1)
# 1
print(generate_zero())
# 0

当たり前のように、デフォルト引数に指定した 0 という数字が使われています。generate_zero の返す値は常に 0 です。当たり前ですね。

しかし、デフォルト引数に listdict のようなミュータブルなデータ構造を入れると、おかしなことになってきます。

def generate_empty_list(x=[]):
    return x

list1 = generate_empty_list()
print(list1)
# []
list1.append('append')
print(list1)
# ['append']

list2 = generate_empty_list()
print(list2)
# [] ではなく ['append'] になる

generate_empty_list が返す list1list2 は、同じインスタンスを参照しています。そのため、デフォルト引数に空のリスト等を入れて初期値としてしまうと、その値に後から破壊的な変更を加えたときに、初期値の値が書き換わってしまうのです!

この挙動は、例えば TypeScript などとは異なります

function generate_empty_list(x=[]): string[] {
  return x
}

const list1 = generate_empty_list()
console.log(list1)
// []
list1.push('append')
console.log(list1)
// ['append']

const list2 = generate_empty_list()
console.log(list2)
// []

この挙動があるために、Python のデフォルト引数には、空の list や dict などのミュータブルな構造を入れるべきでない といったプラクティスが提唱されています*1。結果として、Python での安全なプログラムは次のようになります。

def generate_empty_list(x=None):
    if x is None:
        return []
    return x

言語学習者にとって、この仕様は冗長というか、直感的ではない感じがします。実際、しばしば Python のハマりどころとして紹介されています。そこで今日はこのプラクティスに異論を提起してみます。

新しいプラクティス

提案したいプラクティスは イミュータブルな型ヒントをつけよう です。

デフォルト引数の値が書き換わってしまう理由は、厳密には「ミュータブルなものを指定したから」ではなく「破壊的変更を加えたから」です。したがって破壊的変更さえ加えなければ、今回の問題は発生しません。Python では 3.5 から型ヒントという機能が提供されており、mypy などの型チェッカーで検査することができます。破壊的変更を加えることにできない、append のない list を作ってしまえば、デフォルト引数の問題は解消します。

ちょうど Python では「イミュータブルな list」や「イミュータブルな dict」のような型が提供されており、コレクション抽象基底クラスとして提供されています。例えば Sequence は実質イミュータブルな list で、Mapping は実質イミュータブルな dict になっています。これは抽象クラスのパッケージではありますが、型チェッカー上では Protocol のような振る舞いをもたせられます。 *2

実際の例を見てみましょう。

from collections.abc import Sequence

immutable_list: Sequence = []
immutable_list.append('append')

# ***.py:4: error: "Sequence[Any]" has no attribute "append"
# Found 1 error in 1 file (checked 1 source file)

この例は mypy でチェックをするとエラーを吐きます。なぜかというと、 immutable_list の変数は Sequence 型であり、 append などの破壊的なメソッドを持たないからです。

しかし一方で、読み込み専用の list の振る舞いは問題なく持っています。また、Protocol のような振る舞いなので、Sequence やその継承したインスタンスそのものを代入する必要はありません。

from collections.abc import Sequence

immutable_list: Sequence = []
print([x for x in immutable_list])  # OK
print('element' in immutable_list)  # OK

# Success: no issues found in 1 source file

それでは、当初の問題であった「デフォルト引数に list / dict 入れる問題」について、「イミュータブルな型ヒントをつけよう」というプラクティスを適用してみます。

from collections.abc import Sequence

def generate_empty_list(x=[]) -> Sequence:
    return x

list = generate_empty_list()
print(list)

list.append('append')  # error: "Sequence[Any]" has no attribute "append"
print(list)

list2 = generate_empty_list()
print(list2)

このように戻り値や引数に型ヒントをつけてあげると、mypy が「append できないよ」と注意してくれます。こうして、デフォルト引数が変わっちゃう問題は解消されました。めでたしめでたし

メリットとデメリット

メリットとしては、見通しが非常にすっきりするのではないかと思います。TypeScript 同様、デフォルト引数に list を入れて良いので、他言語学習者にとって直感的かもしれません。個人的な意見として、Python は明らかに型ヒントのマナー・役割を広げようとしているので、これは将来 "Pythonic" な書き方とされるかもしれません。

と、提案してみたものの、 デメリットも大きい と思ってます。これはランタイムレベルでのチェックではありません。なので、mypy 等による型チェックを行うこと、その型推論が正しく動くことが不可欠です。mypy の精度は、例えば TypeScript などと比べてもそれほど高くない、というのは正直な感想です(2020年の感想です)。

終わりに

本稿では「デフォルト引数にミュータブルなものを入れてはいけない」というプラクティスに対して、型ヒントによる解決策を提示しました。登壇内容のはてブのコメントが荒れたように「動的型付き言語に型を持ち込むこと」に対しては、様々な意見があろうと思います。

僕個人としては、Python の型周りのエコシステムが進化し、さらに使いやすくて安全な言語となることを願ってやみません。Guido さんも Microsoft 入ったし!

*1:Python デフォルト引数 ミュータブル」でググる記事がいっぱいでてきます

*2:ランタイム上では list だが、型チェックの上では append や __setitem__ などを持たない