Pythonの関数:スコープ・デフォルト引数の罠・クロージャを実行結果で
*args・**kwargs、/ と *、ミュータブルなデフォルト引数の罠、LEGB と UnboundLocalError、ループ内 lambda、3.11 の match、関数呼び出しのコストを実行結果で整理。
シリーズ:Python 講座 2/6(初級)。 def は書けるけれど、関数が前のデータを「覚えていた」り、変数が突然「referenced before assignment」で落ちたりして困った人向けの記事です。読み終えると、デフォルト値がいつ評価されるか、名前をどこから探すか、関数呼び出し1回にどれだけかかるかを正確に説明できるようになります。
業務の集計スクリプトや Slack ボットを書き始めたころの関数のバグは、ほとんどがロジックの問題ではありません。あまり強調されない3つのルールから来ています。デフォルト値は1回しか作られない、名前に代入するとその関数全体でローカル変数になる、クロージャは変数を作成時ではなく実行時に読む。以下ではルールごとに、1ファイルに貼り付けて動かせるコードと、Python 3.9・Python 3.11(Apple Silicon Mac、M1 Pro)で実際に表示された出力を示します。トレースバックのパスは ~/demo/ に置き換えています。
まだ第1回を読んでいなければ、最初の節「名前はオブジェクトを指している」だけでも目を通してください。この記事の罠はすべてそこから来ています。
引数:位置・キーワード・args・*kwargs
def describe(name, version, *tags, debug=False, **extra):
print(f"name={name} version={version} tags={tags} debug={debug} extra={extra}")
describe("app", "1.0")
describe("app", "1.0", "ios", "android", debug=True, store="jp")
args = ("app", "2.0", "web")
opts = {"debug": True, "region": "eu"}
describe(*args, **opts)
name=app version=1.0 tags=() debug=False extra={}
name=app version=1.0 tags=('ios', 'android') debug=True extra={'store': 'jp'}
name=app version=2.0 tags=('web',) debug=True extra={'region': 'eu'}
*tagsは余った位置引数をタプルにまとめます。*tagsより後ろはキーワードでしか渡せないので、debugはキーワード専用引数です。**extraは未知のキーワード引数を辞書にまとめます。- 呼び出し側の
*args・**optsは逆に、シーケンスと辞書を展開して引数として渡します。
/ と * の印
Python 3.8 で / が加わりました。それより前の引数は位置でしか渡せません。名前のない * より後ろはキーワードでしか渡せません。
def clamp(value, /, low=0, high=100, *, strict=False):
if strict and not (low <= value <= high):
raise ValueError(f"{value} out of range")
return max(low, min(high, value))
print(clamp(150), clamp(-5, low=-10), clamp(50, 0, 40))
100 -5 40
間違った呼び方をしたときの TypeError 4種類です。メッセージは 3.9 と 3.11 で同じでした。
clamp(value=5) -> TypeError: clamp() got some positional-only arguments passed as keyword arguments: 'value'
clamp(5, 0, 100, True) -> TypeError: clamp() takes from 1 to 3 positional arguments but 4 were given
describe('a') -> TypeError: describe() missing 1 required positional argument: 'version'
describe('a', '1', version='2') -> TypeError: describe() got multiple values for argument 'version'
2つ目が * を使う理由です。clamp(5, 0, 100, True) はコードレビューで「この True は何?」と聞かれる書き方なので、関数側で拒否します。使い分けは次のとおりです。
| やりたいこと | 書き方 | 理由 |
|---|---|---|
| 引数名に意味がなく、後で変えるかもしれない | def f(x, /) |
名前を変えても呼び出し側が壊れない |
| フラグやオプション | def f(x, *, verbose=False) |
呼び出し側に verbose=True と書かれて読みやすい |
| 別の関数を包んで引数をそのまま渡す | def f(*args, **kwargs) |
デコレータ・ラッパー(第4回) |
| ふつうの引数 | def f(a, b=1) |
基本の選択 |
よくある失敗:ミュータブルなデフォルト引数
Python でいちばん有名な罠で、第1回の「別名」の問題が姿を変えたものです。
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
print(add_item("a"))
print(add_item("b"))
print(add_item("c", []))
print(add_item("d"))
print("defaults:", add_item.__defaults__)
['a']
['a', 'b']
['c']
['a', 'b', 'd']
defaults: (['a', 'b', 'd'],)
実際に起きていること: [] という式は def 文が実行されたときに1回だけ評価され、そのリストは関数オブジェクトの __defaults__ に保存されます。bucket を省略した呼び出しは、すべて同じリストを受け取ります。自分でリストを渡すと("c")正しく動くので単体テストでは見つからず、本番でサーバー起動以降のすべてのリクエストのデータが1つのリストにたまる、という形で表に出ます。
対策: None を目印にして、関数の中でオブジェクトを作ります。
def add_item_fixed(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(add_item_fixed("a"), add_item_fixed("b"))
['a'] ['b']
計算で決まるデフォルト値はすべて同じルールに引っかかります。def log(msg, when=time.time()) は、モジュールを import した時刻 を記録します。1回呼んで1.1秒待ち、もう一度呼ぶと、2回とも同じタイムスタンプでした。夜間バッチのログが全部同じ時刻になっていたら、まずここを疑ってください。when=None にして中で time.time() を計算します。
スコープ:LEGB と UnboundLocalError
関数の中で名前を使うと、Python は4か所を順に探し、最初に見つかった所で止まります。Local(ローカル)、Enclosing(外側の関数)、Global(モジュール)、Builtins(組み込み)です。
x = "global"
def outer():
x = "enclosing"
def inner():
print("inner sees:", x)
inner()
outer()
inner sees: enclosing
外側の名前を読むのは自由です。問題は代入です。処理件数を数えるつもりで書いたこのカウンタは、一見問題なさそうです。
count = 0
def hit():
count += 1
return count
hit()
Python 3.9:
Traceback (most recent call last):
File "~/demo/unbound.py", line 7, in <module>
hit()
File "~/demo/unbound.py", line 4, in hit
count += 1
UnboundLocalError: local variable 'count' referenced before assignment
Python 3.11 では文言が変わり、新しい文言のほうが本当の原因に近くなっています。
count += 1
^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
「count はローカルか」は、関数をコンパイルするときに本体全体の代入を調べて決まります。count += 1 は代入なので、hit の中の count は同じ行の右辺も含めてすべてローカルです。さらに紛らわしいのは、代入が後ろにある場合です。
total = 100
def report():
print("before:", total) # ここで失敗
total = 0
report()
print("before:", total)
UnboundLocalError: local variable 'total' referenced before assignment
下に total = 0 を1行足しただけで、上の行が壊れます。
global と nonlocal
count = 0
def hit():
global count
count += 1
return count
hit(); hit()
print("count =", count)
def make_counter():
n = 0
def step():
nonlocal n
n += 1
return n
return step
c1, c2 = make_counter(), make_counter()
print(c1(), c1(), c1(), "|", c2())
count = 2
1 2 3 | 1
global はモジュールレベルの名前を、nonlocal はいちばん近い外側の関数の名前を束縛し直します。make_counter を呼ぶたびに新しい n ができるので、c1 と c2 は別々に数えます。
| 場面 | おすすめ |
|---|---|
| 関数が値を必要とする | 引数で受け取る |
| 関数が新しい値を作る | return する |
| 呼び出し間で保つ小さな状態 | クロージャ + nonlocal(またはクラス) |
| 起動時に一度決めるモジュール全体の設定 | global、最小限に |
| 共有のリストや辞書の変更 | キーワード不要(.append は代入ではない)。ただし一度立ち止まって考える |
クロージャと遅延束縛の罠
クロージャは、自分を作った関数の変数にアクセスし続けられる内側の関数です。
def make_multiplier(k):
return lambda v: v * k
double, triple = make_multiplier(2), make_multiplier(3)
print(double(5), triple(5))
print("cell:", c1.__closure__[0].cell_contents)
10 15
cell: 3
捕まえた変数はセルに入っていて、前の節の c1 は 3 まで数えています。罠は、クロージャが作成時の値ではなく変数そのものを保持することです。
fns = [lambda: i for i in range(3)]
print("late binding:", [f() for f in fns])
fns = [lambda i=i: i for i in range(3)]
print("default fix :", [f() for f in fns])
from functools import partial
def show(i): return i
fns = [partial(show, i) for i in range(3)]
print("partial fix :", [f() for f in fns])
late binding: [2, 2, 2]
default fix : [0, 1, 2]
partial fix : [0, 1, 2]
3つの lambda はどれも呼ばれたときに i を読みますが、そのときループはもう終わっていて i == 2 です。i=i が効くのは、先ほどのデフォルト引数のルールのおかげです。デフォルト値は定義時に1回だけ評価されるので、ここではまさに望みどおりの動きになります。tkinter などの GUI では「どのボタンを押しても同じ動きをする」という形で現れます。
buttons = {}
for name in ("save", "load"):
buttons[name] = lambda: f"clicked {name}"
print(buttons["save"]())
clicked load
型ヒントは強制されない
def area(w: float, h: float) -> float:
return w * h
print(area("ab", 3))
print(area.__annotations__)
ababab
{'w': <class 'float'>, 'h': <class 'float'>, 'return': <class 'float'>}
エラーになりません。CPython はヒントを __annotations__ に保存するだけで、実行には使いません。"ab" * 3 は文字列の繰り返しなので、黙って ababab が返ります。ヒントは mypy などのツール、エディタの補完、そしてドキュメントとして役立ちます。実行時に必ず確かめたい値は自分で検査します(if not isinstance(w, (int, float)): raise TypeError(...))。Java や C# から来た人がいちばん誤解しやすい点です。
match(Python 3.10+)
構造的パターンマッチは switch ではありません。データの形を照合します。Python 3.11 で実行しました。
def handle(cmd):
match cmd.split():
case ["quit" | "exit"]:
return "bye"
case ["go", direction] if direction in ("north", "south"):
return f"moving {direction}"
case ["go", _]:
return "unknown direction"
case ["drop", *items]:
return f"dropping {len(items)} item(s): {items}"
case _:
return f"??? {cmd!r}"
def status(resp):
match resp:
case {"status": 200, "data": data}:
return f"ok: {data}"
case {"status": 404}:
return "not found"
case {"status": int(code)} if code >= 500:
return f"server error {code}"
case _:
return "unexpected"
quit -> bye
go north -> moving north
go up -> unknown direction
drop key lamp -> dropping 2 item(s): ['key', 'lamp']
dance -> ??? 'dance'
{'status': 200, 'data': [1, 2]} -> ok: [1, 2]
{'status': 404, 'msg': 'x'} -> not found
{'status': 503} -> server error 503
{'code': 1} -> unexpected
辞書パターンは余ったキー(上の 'msg')を無視するので、API の JSON 応答の振り分けに便利です。同じファイルを Python 3.9 で動かすと、起動すらしません。
File "~/demo/l2_match.py", line 2
match cmd.split():
^
SyntaxError: invalid syntax
社内の古いサーバーや macOS 付属の Python(3.9)でも動く必要があるコードなら、if/elif を使います。
match で初心者がはまる罠:case に書いた素の名前は比較ではなくキャプチャです。
NOT_FOUND = 404
def check(code):
match code:
case NOT_FOUND:
return "not found"
case 200:
return "ok"
case NOT_FOUND:
^^^^^^^^^
SyntaxError: name capture 'NOT_FOUND' makes remaining patterns unreachable
このエラーが出るならまだ幸運です。case が1つだけだとコンパイルが通り、どんな値にもマッチして、その値を NOT_FOUND に代入してしまいます。200 で呼ぶと matched; NOT_FOUND inside = 200 が返りました。ドット付きの名前(IntEnum で作った case HTTP.NOT_FOUND: は 404 だけにマッチし、200 は素通りしました)かリテラルを使います。
実測:関数呼び出し1回のコスト
100万個の i について i * i を足す処理を6通りに書きました(timeit、7回の最速値、Python 3.9、Apple M1 Pro。6つとも合計値は同じ)。
import timeit
N = 1_000_000
def square(x): return x * x
def run_inline():
t = 0
for i in range(N): t += i * i
return t
def run_call():
t = 0
for i in range(N): t += square(i)
return t
# run_lambda: sq = lambda x: x * x のあと t += sq(i)
# run_kw: t += square(x=i)
def run_comp(): return sum([i * i for i in range(N)])
def run_map(): return sum(map(square, range(N)))
for f in (run_inline, run_call, run_comp, run_map):
print(f.__name__, min(timeit.repeat(f, number=1, repeat=7)))
| 書き方 | 1回目 | 2回目 | 1反復あたり |
|---|---|---|---|
リスト内包表記 + sum |
42.3 ms | 41.0 ms | 約42 ns |
| インラインのループ | 44.2 ms | 44.8 ms | 約44 ns |
sum(map(square, …)) |
50.2 ms | 50.6 ms | 約50 ns |
| ループ内で lambda 呼び出し | 80.4 ms | 79.2 ms | 約80 ns |
ループ内で def 関数呼び出し |
82.9 ms | 84.0 ms | 約83 ns |
キーワードで呼ぶ square(x=i) |
92.2 ms | 91.1 ms | 約92 ns |
Python 3.9 では、関数呼び出しは処理そのものに加えて約40 nsかかり、キーワード引数でさらに約9 ns 増えます。lambda だから def より速いということはありません。両者は同じ種類のオブジェクトです。中身がこれほど軽いループでは呼び出しで時間が2倍になりますが、DB 問い合わせ・JSON の解析・正規表現のように実際の仕事をする関数なら、40 ns は誤差です。関数は読みやすさのために分け、計測してホットだと確認したループの中だけインライン化しましょう(プロファイリングは第6回)。Python 3.11 では呼び出しコストがかなり下がりましたが、この Mac の 3.11 はエミュレーションで動いているため計測していません。
正直な限界とバージョンメモ
- すべてのコードは Python 3.9.6 と Python 3.11.4 で実行しました。時間の計測は 3.9 だけです。
/による位置専用引数は Python 3.8(PEP 570)で追加されました。3.9 と 3.11 で動かし、3.8 では動かしていません。matchは Python 3.10(PEP 634)で追加されました。3.11 で実行し、3.9 でのSyntaxErrorを確認しました。3.10 そのものでは動かしていません。UnboundLocalErrorのメッセージは、上で見たとおり 3.11 で変わりました。メッセージ文字列で分岐するコードはバージョンが変わると壊れるので、例外の型で捕まえてください。- Python 3.12 の
type文と PEP 695 のジェネリクス構文(def first[T](xs: list[T]) -> T)は説明だけです。3.11 はこの構文を受け付けないので、実行していません。
次の講座
- 前へ:Python 講座 1/6:型・ミュータブル・is と ==
- 次へ:Python 講座 3/6:イテレータ・ジェネレータ・メモリ
- クロージャと
*args, **kwargsは第4回 デコレータとコンテキストマネージャで、呼び出しコストは第6回 プロファイリングと最適化で再び登場します。 - 他の言語の同じテーマ:VB.NET 講座2:ByVal・ByRef と制御フロー、C# ラムダ式、C# メソッド
よくある質問
Python の関数が前回の呼び出しのリストを覚えているのはなぜ?
デフォルト値は呼び出しのたびではなく、def 文が実行されたときに1回だけ評価されます。def add_item(item, bucket=[]) は呼び出しをまたいで1つのリストを共有し、2回目の呼び出しは ['a', 'b'] を返しました。bucket=None にして関数の中でリストを作ってください。
UnboundLocalError: local variable referenced before assignment の原因は?
関数内のどこかで名前に代入すると(count += 1 も含む)、その名前は関数全体でローカル変数になるため、代入より前に読むと失敗します。本当に外側の変数を変えたいなら global か nonlocal を宣言し、できれば値を引数で受け取って新しい値を return します。
ループで作った lambda が全部最後の値を返します。
クロージャは作ったときではなく、呼ばれたときに変数を読みます。[lambda: i for i in range(3)] は [2, 2, 2] を返しました。lambda i=i: i のようにデフォルト引数で今の値を束縛するか、functools.partial を使うと [0, 1, 2] になりました。