スコープとミュータブルなデフォルト値
名前がどこに存在し、誰から見えるか。外側の名前を読むことと書き換えることの違い、UnboundLocalError、そして Python で最も有名な落とし穴 — デフォルト値としての空のリスト。
どうやって学びますか?
解決する問題
第19章の「うまくいかないとき」の一覧に、未解決のまま残った1行がありました:「関数の中の変数は外からは見えない — これは想定どおりで、その理由が第21章のテーマです。」
この章がその答えです。
def add_tax(price):
tax = price * 0.15
return price + tax
print(add_tax(100))
print(tax)115.0
NameError: name 'tax' is not defined. Did you mean: 'max'?1行目は動いたので、tax は存在していたはずです。ところが外側では存在しません。(エラーの行の前にも Python はトレースバックの行をいくつか表示しますが、この章では最後の行だけを示します。)
これは制限ではなく、保護です。50 個の関数があるプログラムで、内部の名前がすべて外に漏れてしまうと、2 つの関数の total 変数が互いに上書きし合ってしまいます — しかも誰も気づきません。関数の中の名前は中にとどまります。 だから 1 つの関数を書くのに、ほかの 49 個の関数の名前をすべて知っている必要はありません。
この章では、このルールと、そこから生じる Python で最も有名な落とし穴を扱います。
この章を終える頃には
- 名前がどこに「存在」し、誰から見えるかを説明できる
- 関数の内側から外側の名前を読めるが、書き換えられない理由を説明できる
UnboundLocalErrorを読んで、何が起きたかを判断できる- 関数に渡したリストが変更されて戻ってくる理由を説明できる
- Python で最も有名な落とし穴、ミュータブルなデフォルト値を見分けて避けられる
前提条件: 関数の実引数 — 名前、デフォルト、アスタリスク。
内側は内側にとどまる
関数の中で作られた名前はすべて — 仮引数を含めて — その呼び出しの間だけ存在します。関数が終わると、それらの名前は消えます。
これをスコープと呼びます:名前が有効な範囲のことです。
外側の名前を読むことはできる
RATE = 0.15
def add_tax(price):
return price + price * RATE
print(add_tax(100))115.0RATE は関数の中にないので、Python は外側を探して見つけます。関数の中から外側の名前を読むのは問題ありません。
全部大文字の名前は Python の規則ではなく慣習です — 「これは定数で、値は変わらない」と読み手に伝えるものです。そう使えば、外側の名前は最も安全な状態にあります。
しかし書き換えはできない
count = 10
def bump():
count = 99
return count
print(bump())
print(count)99
10内側の count = 99 は、外側の count にはまったく触れませんでした。関数の内側にだけ存在する新しいローカルな名前を作り、その間、外側の名前を隠したのです。
これがルールであり、意図された設計です:関数が外側のものを誤って変えてしまうことはありません。
そしてそこから、奇妙に見えるエラーが生まれます:
count = 10
def bump():
count = count + 1
return count
print(bump())UnboundLocalError: cannot access local variable 'count' where it is not associated with a value1行目は、外側の count を取って 1 を足しているように読めます。しかし Python は実行前に関数全体を調べ、count に何かを代入していることに気づくと、この関数の中では最初から最後まで count はローカルだと判断します。
そのため、右辺の count + 1 を評価するときに、ローカルの count を探しますが、まだ値が与えられていません — そこで止まります。
エラーメッセージは、珍しいほど正確です:名前はローカルであり、まだ何にも結び付けられていない、と言っているのです。
修正方法はほぼ常に同じで、global ではありません:値を引数として受け取り、結果を return する。
def bump(count):
return count + 1
count = bump(count)Python には global キーワードがありますが、この講座では教えません。それを使いたくなる問題には、より良い答えがあるからです。外側の状態を変える関数は、それが呼び出された場所からは理解できません — それこそが、テストしにくく、信頼しにくいコードの定義です。
でも渡したものは変わることがある
スコープが保護するのは名前であって、オブジェクトではありません。この違いは微妙で、重要です。
def add_item(items):
items.append("pen")
return items
basket = []
add_item(basket)
add_item(basket)
print(basket)['pen', 'pen']return で受け取らなかったにもかかわらず、外側の basket が変わりました。
理由は第12章と同じです:名前 items は新しいものですが、同じリストを指しています。append がそのリストを変更するため、両方の名前にその変化が見えます。
比べてみましょう:
def replace(items):
items = ["new"]
return items
basket = ["old"]
result = replace(basket)
print(basket)
print(result)['old']
['new']ここでは items = [...] が新しい値を代入し、ローカルな名前を別のリストに向け直しました。外側の basket は元のままです。
ルールを一文で言うと:オブジェクトを変更する(append、sort、[i] =)と、その変化は外から見えます。名前を別のオブジェクトに向け直す(=)と、外からは見えません。
Python で最も有名な落とし穴
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("pen"))
print(add_item("bag"))
print(add_item("ink"))['pen']
['pen', 'bag']
['pen', 'bag', 'ink']3 回の別々の呼び出しですが、それぞれが前の呼び出しの残したものを保持しています。
理由を理解すれば、決して忘れません:デフォルト値は、関数が呼ばれたときではなく、def の行が実行されたときに一度だけ評価されます。 その空のリストは関数に付いた 1 つのリストで、すべての呼び出しがそれを共有します。
修正方法は慣習的でシンプルです:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item("pen"))
print(add_item("bag"))['pen']
['bag']None はイミュータブルなので、共有しても問題ありません。新しいリストは呼び出しのたびに新しく作られます。
ルールとしては:リスト、辞書、集合をデフォルト値として書いてはいけません。 数値、文字列、True/False、None は安全です — まさに変更できないからです。
なぜ== Noneではなくis Noneなのか?isは「これがまさに同じオブジェクトか?」と尋ねます。プログラム全体でNoneは 1 つしかありません。==は等しいかどうかを比べ、型によっては驚くような定義をしていることがあります。Noneについては、isが慣習的であり、かつ確実です。
完全な例
basket.py:
# Where a name lives decides who can see it and who can change it
TAX_RATE = 0.15
def line_total(price, quantity):
"""Pure: takes values, returns a value, touches nothing outside."""
return round(price * quantity * (1 + TAX_RATE), 2)
def add_line(basket, name, price, quantity):
"""Impure on purpose: it changes the basket it was handed."""
basket.append({"name": name, "total": line_total(price, quantity)})
return basket
def summarise(basket, note=None):
"""`None` as the default, so no list or dict is shared between calls."""
notes = [] if note is None else [note]
total = sum(line["total"] for line in basket)
notes.append(f"{len(basket)} lines, {total:.2f} in total")
return notes
shopping = []
add_line(shopping, "pen", 15.0, 3)
add_line(shopping, "bag", 850.0, 1)
for line in shopping:
print(f"{line['name']:<6} {line['total']:>8.2f}")
print()
for note in summarise(shopping, "morning order"):
print(note)
print()
print("basket outside the function:", len(shopping), "lines")pen 51.75
bag 977.50
morning order
2 lines, 1029.25 in total
basket outside the function: 2 lines注目すべきなのは、外の世界との関係がそれぞれ違う 3 つの関数です。
line_total は何にも触れません。 値を受け取り、値を返すだけです。TAX_RATE を読みますが、変更はしません。こうした関数は純粋関数(pure)と呼ばれます — 同じ入力は常に同じ出力を返し、どこにも副作用がありません。テストが最も簡単で、間違えるのが最も難しい種類の関数です。
add_line は、外側の何かをあえて変更します。 リストを受け取り、そこに追加します — そして最後の行が、外側の shopping に本当に 2 行入っていることを示しています。それ自体に問題はありません。ただし名前がそのことを示していればの話です。add_line を読めば、何かが追加されるのだと分かります。
summarise はデフォルト値として None を使います。 もし note=[] と書いていたら、すべての呼び出しが 1 つのリストを共有し、2 回目のレポートに 1 回目の注釈が残ってしまうでしょう。
うまくいかないとき
NameError: name 'tax' is not defined 関数の内側の名前が、外側で使われました。外側で必要なものは return してください。
UnboundLocalError: cannot access local variable ... where it is not associated with a value 関数の中で名前に代入していて、かつその代入より前に読んでいます。Python はそれをローカルだと判断したのです。値を引数として受け取り、結果を return してください。
渡したリストが関数に入れたあとで変わった それは想定どおりの動作です — 名前は新しく、リストは同じものです。避けたい場合は、関数の先頭で items = items.copy() と書くか、新しいリストを作って return してください。
関数が前回の結果を覚えている ミュータブルなデフォルト値です。def f(x, acc=[]) ではなく、def f(x, acc=None) と書き、内側で if acc is None: acc = [] としてください。
2 つの関数で同じ名前を使いました — 問題ですか? 問題ではありません。それこそがスコープの目的です。2 つの関数の total 変数は、完全に別物です。
ステップ 4 / 6 — 予想する
理解度をチェック
関数の中で count に値が代入されています。2行は何を表示するでしょう?
count = 10
def bump():
count = 99
return count
print(bump())
print(count)- A10 10
- B99 10
- C`UnboundLocalError`
- D99 99
関数に return がないのに、外側のリストが表示されます。何が現れるでしょう?
def add_item(items):
items.append("pen")
basket = []
add_item(basket)
add_item(basket)
print(basket)- A[]
- B['pen']
- CNone
- D['pen', 'pen']
2回の別々の呼び出しで、デフォルトは空のリストです。何が表示されるでしょう?
def collect(item, box=[]):
box.append(item)
return box
print(collect("pen"))
print(collect("bag"))- A['pen', 'bag'] ['pen', 'bag']
- B['pen'] ['bag']
- C['pen'] ['pen', 'bag']
- D['pen'] ['pen']
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
cart.py という名前のファイルを作り、3 つの関数を書きましょう:
line_total(price, quantity, tax_rate=0.15)— 純粋関数で、何にも触れないadd_to(cart, name, price, quantity)— 渡された cart に追加するreceipt(cart, header=None)— 行のリストを返す。ヘッダーが渡されなければ、適当なヘッダーを作る
そして、空の cart を作り、いくつか追加してから、レシートを表示しましょう。
最後に、3 つの実験を行いましょう。どれも自分の目で見る価値があります:
receiptのデフォルトをheader=[]に変えて、関数を2 回呼び出し、両方の結果を表示しましょう。2 回目には何が見えますか?add_toの先頭の行にcart = cart.copy()を追加して、外側の cart を表示しましょう。そこには何個入っていて、なぜでしょう?- 関数の中で
TAX_RATE = 0.2と代入し、関数の外でTAX_RATEを表示しましょう。どちらの値が見えますか?
この 3 つの答えを合わせたものが、この章の内容そのものです:どの名前がどこに存在し、どの変化が外に届くのか。