関数の中で関数を定義して、それをreturnする。学習を進めていると、どこかでこの書き方に出会います。
初めて見たとき、何のためにこんな遠回りをするのかわからなかった。そう感じた方は、けっして少数派ではありません。
この書き方には名前があります。クロージャと呼ばれる仕組みです。
今回は、クロージャが何を解決してくれるのかを、動かせるコードで順番に見ていきます。デコレータの正体もここにあるので、理解しておくと先の景色が変わります。
クロージャとは、外の変数を覚えたまま持ち歩く関数のこと¶
まず、言葉の意味から整理させてください。
Pythonでは、関数の中で作った変数は、その関数が終わると消えます。処理が終われば片づけられる、一時的な置き場所だからです。
ところが、内側の関数を外へ返すと話が変わります。返された関数が外側の変数を使っていた場合、その変数だけは消えずに残るのです。
クロージャは、外側の変数を覚えたまま持ち歩く関数です。 買い物メモを持ったまま家を出るようなもの、と考えるとイメージしやすいかもしれません。
言葉だけではつかみにくいので、税込み価格を計算する関数で試してみます。
def make_multiplier(rate):
def apply(price):
return int(price * rate)
return apply
with_tax = make_multiplier(1.1)
print(with_tax(1000)) # 1100
print(with_tax(2500)) # 2750
make_multiplierはすでに終わっています。それなのに、with_taxはrateが1.1だったことを覚えています。
このrateのように、内側の関数から見て外側にある変数を自由変数と呼びます。クロージャは、この自由変数をいっしょに抱えている関数だと考えてください。
同じ関数から、別々の記憶を持つ関数を作れる¶
クロージャのうれしさは、同じ型の関数をいくつも量産できるところにあります。
さきほどのmake_multiplierを、もう一度別の値で呼んでみます。
with_tax = make_multiplier(1.1)
member_price = make_multiplier(0.8)
print(with_tax(1000)) # 1100
print(member_price(1000)) # 800
ふたつの関数は、同じ設計図から生まれています。けれど覚えている数字は違います。
同じ関数から作っても、記憶はそれぞれ別々です。 ここが、あとで出てくるクラスとの共通点でもあります。
引数で毎回渡すのと、何が違うのか¶
price * rateを計算したいだけなら、引数を2つ受け取る関数でも書けます。ではなぜ、わざわざ入れ子にするのでしょうか。
理由は、呼び出す側の負担が減るからです。税率のように何度も同じ値を渡す場面では、先に設定を埋め込んだ専用の関数を作っておくほうが、書き間違いも減ります。
設定を一度だけ決めて、あとは本来の引数に集中する。この形は、あとで紹介するfunctools.partialとも発想が同じです。
nonlocalがないと、覚えた数を更新できない¶
次は、覚えた値を書き換えたい場合です。呼ばれた回数を数えるカウンターを作ってみます。
素直に書くと、たいていの人はここでつまずきます。
def make_counter():
count = 0
def increment():
count += 1 # ここでエラーになる
return count
return increment
counter = make_counter()
print(counter())
実行するとUnboundLocalErrorが出ます。エラーの文言はバージョンによって少し違いますが、countがまだ値を持っていない、という趣旨の内容です。
外側にcount = 0があるのに、なぜでしょうか。Pythonは、関数の中で代入されている名前を、その関数の中だけの変数だと判断するからです。
つまりcount += 1と書いた時点で、countは内側の関数のローカル変数になります。まだ何も入っていないローカル変数を増やそうとして、エラーになるわけです。
これを解決するのがnonlocalです。
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter(), counter(), counter()) # 1 2 3
other = make_counter()
print(other()) # 1
nonlocalは、代入先を外側の変数に向け直す宣言です。 新しく作らず、すでにある変数を書き換えてほしいとPythonに伝えています。
新しく作ったotherが1から始まっている点にも注目してください。記憶は関数ごとに独立しています。
globalとnonlocalは、指す先が違う¶
似た役割のglobalと混同しやすいので、ここで整理しておきます。
どちらも代入の行き先を変える宣言ですが、向かう方向が違います。
| 書き方 | 代入の行き先 | 主な使いどころ |
|---|---|---|
| 何も書かない | その関数の中の新しい変数 | ふつうの処理 |
nonlocal x |
一つ外側の関数にある変数 | クロージャで状態を更新する |
global x |
モジュール全体で共有する変数 | 基本的に避けたい |
globalは、どこからでも書き換えられる変数を作ってしまいます。プログラムが大きくなるほど、誰がいつ変えたのか追えなくなります。
その点nonlocalが届く範囲は、外側の関数の中だけです。影響範囲が閉じているぶん、ずっと安全に扱えます。変数がどの範囲で生きているかという話は、こちらの記事でも整理しました。【関連記事】Pythonの名前空間を理解するとコードがスッキリ!初心者にもわかりやすく解説
関数はどこに記憶を置いているのか¶
ここまで読んで、覚えているという表現がふわっとしていると感じた方もいると思います。実は、記憶の置き場所はPythonから覗けます。
関数オブジェクトには__closure__という属性があり、抱えている自由変数がそこに入っています。
def make_multiplier(rate):
def apply(price):
return int(price * rate)
return apply
with_tax = make_multiplier(1.1)
print(with_tax.__code__.co_freevars) # ('rate',)
print(with_tax.__closure__[0].cell_contents) # 1.1
co_freevarsは、外から借りている変数の名前の一覧です。__closure__のほうには、その中身が入った箱が並んでいます。
この箱はセルと呼ばれ、値そのものではなく、値への参照を持っています。地味な違いに見えますが、次の落とし穴の原因はここにあります。
ふだんの開発でこの属性を触る場面はほとんどありません。ただ、クロージャが魔法ではないと確認できるので、一度は覗いてみてください。
ループの中で作ると、全部同じ値になる¶
クロージャでいちばん有名な落とし穴を紹介します。ループの中で関数を作る場面です。
まず、期待どおりに動かない例から見てください。
funcs = []
for i in range(3):
funcs.append(lambda: i)
print([f() for f in funcs]) # [2, 2, 2]
0と1と2が並ぶはずが、2が3つ並びます。初めて見ると、バグを疑いたくなる結果です。
原因は、セルが値ではなく変数を指していることにあります。クロージャが覚えているのは、そのときの値ではなく変数そのものです。
関数が呼ばれるのはループが終わったあとです。そのときiを見に行くので、最後の値である2が3回返ってきます。
対処はいくつかありますが、いちばん短いのはデフォルト引数を使う方法です。
funcs = []
for i in range(3):
funcs.append(lambda i=i: i)
print([f() for f in funcs]) # [0, 1, 2]
デフォルト引数は、関数を作るその瞬間に評価されます。だから、そのときのiの値が関数ごとに焼き付きます。
この評価のタイミングは、別の有名な落とし穴の原因にもなっています。あわせて読むと、理解が立体的になります。【関連記事】Pythonのデフォルト引数の落とし穴とは?空のリストを指定した関数が壊れる理由を初心者向けに解説
なお、短い関数をその場で作る書き方そのものに自信がない方は、こちらもどうぞ。【関連記事】ラムダ式(無名関数)を使いこなす。コードを極限までシンプルにする方法を解説
クロージャで足りる場面、クラスに切り替える場面¶
状態を持ちたいだけなら、クラスでも同じことができます。どちらを選ぶか迷ったときの目安を出しておきます。
先ほどのカウンターをクラスで書くと、こうなります。
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
return self.count
counter = Counter()
print(counter.increment(), counter.increment()) # 1 2
行数は増えましたが、countの存在がはっきり見えます。この見えやすさが、そのまま選びかたの基準になります。
| 観点 | クロージャ | クラス |
|---|---|---|
| 書く量 | 少ない | 多い |
| 持てる状態 | 1つか2つが限界 | いくつでも |
| 外から値を見る | 見にくい | 属性で見える |
| 向いている場面 | 設定を固定した小さな関数 | 育っていく機能 |
状態が2つ以上に増えたら、クラスへ切り替えるサインです。 クロージャに詰め込みすぎると、あとから中身を確認できずに苦労します。
デコレータの正体も、クロージャ¶
ここまで理解できていれば、デコレータはもう半分わかっています。
デコレータは、関数を受け取って、その前後に処理を足した別の関数を返す仕組みです。返される関数が元の関数を覚えている。つまり、クロージャそのものです。
実行時間を測るデコレータを書いてみます。
import time
def timed(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f'{func.__name__}: {time.perf_counter() - start:.3f}秒')
return result
return wrapper
@timed
def heavy():
return sum(range(3_000_000))
heavy()
wrapperは、外側からfuncを覚えています。だからheavyが何だったかを知ったまま動けます。
記法の意味そのものが気になる方は、こちらで詳しく解説しています。【関連記事】Pythonのデコレータ(@)って何?関数の前後で処理を追加する不思議な記法の正体
また、設定を固定した関数を作るだけなら、標準ライブラリに専用の道具もあります。【関連記事】Pythonのfunctoolsとは?lru_cacheやpartialで関数を速く短くする使い方を初心者向けに解説
実務で気をつけていること¶
最後に、現場での感覚を正直に書いておきます。
私は10年ほどエンジニアとして開発に関わってきましたが、クロージャで状態を持たせたコードは、あとから読む人を確実に悩ませます。以前、リトライ回数をクロージャの中で数える処理を書いたことがあり、本番で回数が想定と合わなかったときに、数字を確認する手段がなくて原因の切り分けに半日かかりました。
クラスにしておけば、属性を1行出力するだけで済んだ話です。それ以来、外から中身を確認したくなりそうな状態は、クロージャに入れないようにしています。
逆に、設定を固定した小さな関数を作る用途では今でもよく使います。書き換えない値をひとつ抱えるだけなら、読みやすさを損ないません。
迷ったら、まず引数で素直に渡せないかを考えてみてください。 それで書けるなら、たいていはそのほうが親切なコードになります。
まとめと、次の一歩¶
クロージャは、外側の変数を覚えたまま持ち歩く関数でした。
覚えることは多くありません。値を書き換えたいときはnonlocal、ループの中で作るときは値の焼き付けに注意する。この2つを押さえておけば、実用の場面はほぼカバーできます。
次の一歩として、手元でmake_counterを書いて動かしてみてください。nonlocalを消したときのエラーまで自分の目で見ておくと、記憶への残りかたがまったく違います。
そのうえでデコレータのコードを読み直すと、以前は魔法に見えた記法が、ただの関数の組み合わせとして見えてくるはずです。
ここまでお読みいただきありがとうございました。