同じ計算を何度も繰り返しているコードを眺めていて、なんだかもったいないと感じたことはありませんか。
引数の一部がいつも同じ関数を、呼び出すたびに長々と書いている。自作のデコレータを付けたら、なぜか関数の名前が消えてしまった。
こうした細かい不便をまとめて引き受けてくれるのが、標準ライブラリのfunctoolsです。追加のインストールは要りません。
この記事では、functoolsの中でも特に出番の多い機能を、手元で動かせるコードと並べながら順番に見ていきます。
functoolsは、関数そのものを扱うための道具箱¶
functoolsという名前は、function(関数)とtools(道具)を組み合わせたものです。関数を材料にして、別の関数を組み立てるための機能が集まっています。
公式ドキュメントでは、高階関数を扱うモジュールとして紹介されています。高階関数とは、関数を引数として受け取ったり、関数を返したりする関数のことです。
言葉にすると難しく感じますが、やっていることは単純です。すでにある関数に、あとから性能や振る舞いを足すだけ。
その足し算は、たいていデコレータの形で書きます。@から始まるあの記法に不安が残っている方は、先にこちらを読んでおくと理解がぐっと早くなります。【関連記事】Pythonのデコレータ(@)って何?関数の前後で処理を追加する不思議な記法の正体
いちばん出番が多いのはlru_cache¶
functoolsで最初に覚える価値があるのは、間違いなくlru_cacheです。一度計算した結果を覚えておき、同じ引数で呼ばれたら計算せずに返してくれます。
この仕組みはメモ化と呼ばれます。効き目がわかりやすいのは、同じ計算を何度も踏む処理です。
たとえばフィボナッチ数を素直に再帰で書くと、同じ値を何度も計算し直してしまいます。
import functools
@functools.lru_cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(30))
デコレータを1行足しただけで、中身の書き方は何も変わっていません。それでも速度はまるで別物になります。
私の手元の環境で試したところ、キャッシュなしのfib(30)は0.1秒ほどかかりました。lru_cacheを付けた版は、計測値が桁違いに小さくなって比較の意味を失うほどです。
なぜここまで差が出るのかは、計算量の考え方を知っているとすっきり理解できます。【関連記事】「実行時間が終わらない…」を卒業する!あなたのコードを100倍速くする計算量の考え方
cache_infoで効いているかを確かめる¶
キャッシュは目に見えないので、本当に効いているのか不安になります。そんなときはcache_infoを呼んでください。
import functools
@functools.lru_cache
def double(n):
return n * 2
double(1)
double(1)
double(2)
print(double.cache_info())
# CacheInfo(hits=1, misses=2, maxsize=128, currsize=2)
hitsがキャッシュで済んだ回数、missesが実際に計算した回数です。currsizeはいま覚えている件数を表します。
hitsがいつまでも0のままなら、その関数はキャッシュが効く呼ばれ方をしていません。入れて満足せず、cache_infoで確認するのが確実です。
覚えた内容を捨てたいときは、cache_clearを呼べば空になります。テストで前の結果が残って混乱するのを防ぐのに便利です。
引数はハッシュ可能でなければならない¶
lru_cacheには、知らないと必ず一度はぶつかる制約があります。引数がハッシュ可能でなければ使えません。
ハッシュ可能とは、辞書のキーにできる値のことだと考えてください。数値や文字列やタプルは大丈夫ですが、リストや辞書は該当しません。
import functools
@functools.lru_cache
def total(numbers):
return sum(numbers)
total([1, 2, 3])
# TypeError: unhashable type: 'list'
リストを渡した瞬間にTypeErrorで落ちます。渡す側でタプルに変えてしまえば、この問題は解決します。
maxsizeを指定するか、cacheを使うか¶
lru_cacheのmaxsizeは、初期値が128です。覚えられるのは128件までで、それを超えると古いものから捨てられます。
このLRUという言葉は、Least Recently Used(最も長く使われていないもの)の頭文字です。あふれたときに何から捨てるかのルールを指しています。
上限なしで覚えさせたい場合は、Python 3.9で追加されたcacheが使えます。両者の違いを表にまとめました。
| 書き方 | 覚える上限 | 向いている場面 |
|---|---|---|
@functools.cache |
なし(増え続ける) | 引数の種類が限られる短命な処理 |
@functools.lru_cache |
128件(初期値) | 引数の種類が読めないとき |
@functools.lru_cache(maxsize=1024) |
指定した件数 | メモリと速度を自分で調整したいとき |
cacheは捨てる判断をしないぶん、lru_cacheより軽く動きます。ただし引数の種類が増え続ける関数に付けると、メモリを静かに食い続けます。
長く動かし続けるWebアプリやバッチでは、上限のあるlru_cacheのほうが安全です。ここは処理の性格で選び分けてください。
なお、括弧なしで@lru_cacheと書けるようになったのはPython 3.8からです。それ以前は@lru_cache()と括弧が必要でした。
partialで引数を先に埋めておく¶
いつも同じ値を渡している引数があるなら、partialの出番です。関数の引数を一部だけ埋めた、新しい関数を作ってくれます。
2進数の文字列を数値に変換する場面で考えてみましょう。int関数にbase=2を毎回書くのは、地味に面倒です。
import functools
to_int2 = functools.partial(int, base=2)
print(to_int2("1011")) # 11
print(to_int2("1111")) # 15
base=2を先に埋めた、専用の関数ができあがりました。呼び出し側は文字列を渡すだけで済みます。
似たことはラムダ式でも書けますが、partialのほうが意図が伝わりやすい場面が多いです。ラムダ式との使い分けが気になる方は、こちらもあわせてどうぞ。【関連記事】ラムダ式(無名関数)を使いこなす。コードを極限までシンプルにする方法を解説
自作デコレータにはwrapsを付ける¶
ここからは、少し実務寄りの話に進みます。デコレータを自分で書くと、ある落とし穴が待っています。
包んだ関数の名前や説明文が、外から見えなくなってしまうのです。実際に確かめてみましょう。
import functools
def log_call(fn):
@functools.wraps(fn)
def inner(*args, **kwargs):
print("呼ばれました:", fn.__name__)
return fn(*args, **kwargs)
return inner
@log_call
def greet(name):
"""あいさつを返す。"""
return f"hello {name}"
print(greet.__name__) # greet
print(greet.__doc__) # あいさつを返す。
functools.wrapsを外すと、greet.__name__はinnerになり、__doc__はNoneになります。中身の関数の身分証が、包み紙に置き換わってしまうわけですね。
wrapsは名前や説明文をコピーし、元の関数を__wrapped__という属性から辿れるようにもしてくれます。デコレータを書くときの決まり文句だと思ってください。
コード内のargsと*kwargsの意味があいまいな方は、先に整理しておくと読みやすくなります。【関連記事】argsとkwargs とは?「可変長引数」を正しく理解しよう!
名前が消えると何が困るのか¶
見た目の問題だけなら、放っておいても動きます。困るのは、エラーが出たときと道具を使うときです。
ログに関数名を出す仕組みは、たいてい__name__を読んでいます。ここが全部innerになると、どの処理で落ちたのかログから追えなくなります。
私は10年ほどエンジニアとして開発に関わってきましたが、wrapsの付け忘れは今でもレビューで見かけます。動作は正常なので気づきにくく、障害調査の当日にログを見て青くなる類のミスです。
cached_propertyで重い計算を一度だけにする¶
クラスの中で、計算に時間のかかる属性を持たせたい場面があります。呼ばれるたびに計算し直すのは、さすがに無駄です。
Python 3.8で追加されたcached_propertyを使うと、最初の1回だけ計算して結果を持ち続けてくれます。
import functools
class Report:
def __init__(self, rows):
self.rows = rows
@functools.cached_property
def total(self):
print("集計しました")
return sum(self.rows)
r = Report([1, 2, 3])
print(r.total) # 集計しました → 6
print(r.total) # 6(もう集計しない)
2回目の呼び出しでは、集計しましたが表示されません。結果はインスタンスの属性として保存されているからです。
注意点は、元になるデータを書き換えても結果が更新されないことです。途中で中身が変わる値には向きません。
reduceは今も使えるが、出番は限られる¶
functoolsにはreduceも入っています。並んだ値を、左から順に1つの値へ畳み込む関数です。
Python 2では組み込み関数でしたが、Python 3で標準の場所から外れ、functoolsへ移りました。使うにはimportが必要です。
import functools
print(functools.reduce(lambda a, b: a * b, [1, 2, 3, 4])) # 24
掛け算の総積のように、標準の関数がない計算では今も便利です。ただし合計ならsum、最大値ならmaxのほうが読みやすくなります。
素直な組み込み関数で書けるならそちらを選ぶ。これがPythonらしい判断だと考えています。
functoolsの主な顔ぶれを表で確認する¶
ここまでに出てきた機能を、使いどころから引ける形でまとめておきます。
| 機能 | やってくれること | 追加された版 |
|---|---|---|
| lru_cache | 結果を上限つきで覚える | 3.2 |
| cache | 結果を上限なしで覚える | 3.9 |
| cached_property | 属性の計算を1回だけにする | 3.8 |
| partial | 引数を先に埋めた関数を作る | 以前から |
| wraps | 包んだ関数の名前や説明を保つ | 以前から |
| reduce | 並んだ値を1つに畳み込む | 以前から |
全部を今日覚える必要はありません。lru_cacheとwrapsの2つだけでも、書けるコードの質が変わります。
同じ標準ライブラリの中では、collectionsも日常の出番が多い部類です。【関連記事】Pythonのcollectionsとは?Counterやdefaultdictで毎日のコードが短くなる使い方を解説
実務で気をつけていること¶
最後に、現場で意識している点を少しだけ共有します。どれも難しい話ではありません。
まず、キャッシュを付ける前に、本当に同じ引数で何度も呼ばれているのかを確かめること。呼ばれ方によっては、メモリを使うだけで終わります。
次に、外部の状態を読む関数にlru_cacheを付けないこと。ファイルやデータベースの中身が変わっても、古い結果を返し続けてしまいます。
以前、設定ファイルを読む関数に何気なくキャッシュを付けたことがありました。設定を直したのに反映されないと小一時間悩み、原因が自分の付けたデコレータだと気づいたときの脱力感は忘れられません。
そして、キャッシュはあくまで最後の手段だということ。処理の書き方そのものを直せるなら、たいていそちらが正解です。
functoolsは、覚えた分だけコードが静かに短くなる種類のライブラリです。まずはfib関数に@cacheを付けて、速さが変わる瞬間を体験してみてください。
ここまでお読みいただきありがとうございました。