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つのリストにたまる、という形で表に出ます。

2段の図。上段:def 文は1回だけ実行され、デフォルトのリストを1つ作って add_item.__defaults__ に保存する。1回目の add_item('a') はそこに追加して ['a'] を返し、2回目の add_item('b') も同じリストに追加して ['a', 'b'] を返す。下段は対策:デフォルトを bucket=None にし、None のときだけ関数の中で新しいリストを作れば、呼び出しごとに新しいリストが返る。
bucket=[] が呼び出しをまたいで残る理由。デフォルト値は def が実行されたときに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(組み込み)です。

名前解決の流れ図。関数内で使った名前は、まず Local(その関数自身の変数)で探す。なければ Enclosing(外側の関数の変数。nonlocal で再代入できる)、次に Global(モジュールのトップレベル。global で再代入できる)、最後に Builtins(len や print など)を探す。どこにもなければ NameError になる。
LEGB:名前は 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
1回あたりのナノ秒を示す棒グラフ。リスト内包表記+sum:42 ns。インラインのループ:44 ns。sum(map(square, …)):50 ns。ループ内で lambda を呼ぶ:80 ns。def 関数の呼び出し:83 ns。キーワード引数での呼び出し square(x=i):92 ns。
100万回のループで t += i*i を書き方別に計測した1回あたりの時間。timeit 7回の最速値、Python 3.9、Apple M1 Pro。中身が軽いループでは、関数呼び出しでコストがほぼ2倍になる。

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 の関数が前回の呼び出しのリストを覚えているのはなぜ?

デフォルト値は呼び出しのたびではなく、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] になりました。

  • #Python
  • #初級
  • #関数
  • #スコープ
  • #クロージャ
  • #match

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑