IID.systems
プロフィール事業紹介形式手法AIアライメントエッセイ書籍プログラミング教室GitHubEnglish
English

再利用性とクロージャ:メモ化を「部品」にする

関数を作って返す関数 — JavaScript のクロージャが状態を閉じ込める仕組みと、初期値も漸化式も差し替えられる memoizer の設計

再利用性という設計目標

前のページ(関数型言語入門)で作ったメモ化つきフィボナッチには、ひとつ物足りない点がありました。表もルールも fib 専用に書かれていて、別の数列を扱いたければ、ほぼ同じコードをもう一度書くしかないのです。メモ化という仕組み自体は、どの数列でも変わらないにもかかわらず、です。

プログラムの再利用性とは、この「変わらない部分」と「変わる部分」を切り分けることです。変わらないのはメモ化の仕組み。変わるのは数列の初期値と漸化式。前者を独立した部品にして、後者を外から差し込めるようにできれば、同じ部品で、この形の漸化式(直前のいくつかの項を参照する形)で書ける数列なら何でも作れるはずです。


道具:クロージャ

そのための道具が JavaScript のクロージャ(closure)です。クロージャとは、関数が「自分の生まれた場所の変数」を覚え続ける仕組みのこと。関数を作って返す関数を書くと、返された関数は、外側の関数の変数をいつまでも参照できます。

const makeCounter = () => { let count = 0 // この変数は、返された関数だけが触れる return () => ++count } const next = makeCounter() next() // 1 next() // 2 — count はクロージャの中で生き続けている

count は makeCounter の外からは一切触れません。返された関数だけが触れる、私有の状態です。クロージャを使うと、このように「状態を関数の中に閉じ込めて、決まった操作だけを外に見せる」ことができます。


memoizer:メモ化を部品にする

この道具を使って、前のページのメモ化を独立した部品にしたのが、次の memoizer です。

const memoizer = inits => rule => { const memo = [...inits] // 初期値をコピーして私有の表に const elem = n => (n >= memo.length) ? memo[n] = rule(elem)(n) : memo[n] // 表にあれば、それを返す return elem }

3 つの層に分けて読んでみましょう。まず inits => rule => ... という形。引数を 1 つずつ受け取る関数の連鎖で、この形をカリー化(currying)と呼びます。memoizer(inits) の時点で「初期値を覚えた、ルール待ちの関数」が生まれます。

次に const memo = [...inits]。初期値をコピーして私有の表を作ります。この memo こそ、クロージャに閉じ込められる状態です。返された elem だけが表に触れます。

最後に elem。n が表の範囲内ならそのまま返し(メモ化の読み出し)、なければ rule(elem)(n) で計算して表に書き込みます(JavaScript では代入式 memo[n] = ... が代入した値を返すので、これが 1 つの式で書けます)。注目すべきは rule に elem 自身を渡していることです。漸化式が参照する「自分自身」を、部品の外から注入できる形にしてあるのです。


実演:初期値も漸化式も差し替える

使うときは、初期値の配列と漸化式のルールを渡すだけです。

// フィボナッチ: F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2) const fibo = memoizer( [0, 1] )( a => n => a(n-1) + a(n-2) ) // 初期値も漸化式も違う数列 — memoizer はそのまま再利用 const newFibo = memoizer( [2, 1, 5] )( a => n => a(n-1) + a(n-2) + a(n-3) ) fibo(10) // 55 newFibo(10) // 561

fibo と newFibo は、初期値も漸化式も違う別の数列です。しかし memoizer 本体は 1 文字も書き直していません。変わる部分(初期値・漸化式)だけを引数として渡し、変わらない部分(メモ化の仕組み)は部品として再利用する — 最初に立てた設計目標が、そのまま形になっています。

もうひとつ大事な性質があります。fibo と newFibo は、それぞれ自分専用の memo を持っています。rule を渡して関数を作るたびに(memoizer(inits)(rule) と呼び切るたびに)新しい表が作られ、別々のクロージャに閉じ込められるからです。同じ部品から作られた 2 つの関数が、互いに干渉しない — この分離もクロージャが保証しています。


動きを追う

newFibo(4) の動き(memo = [2, 1, 5] から開始): elem(4): 4 >= 3 なので rule で計算 elem(3): 3 >= 3 なので rule で計算 elem(2) -> 5(表から) elem(1) -> 1(表から) elem(0) -> 2(表から) memo[3] = 5 + 1 + 2 = 8 elem(2) -> 5(表から) elem(1) -> 1(表から) memo[4] = 8 + 5 + 1 = 14 表は [2, 1, 5, 8, 14] に育った 2 回目の newFibo(4) は、表から即座に 14 を返す

newFibo(4) の動きを追うと、表が育っていく様子が見えます。2 回目以降の呼び出しは、育った表から即座に返ります。


まとめ

再利用性とは「変わらない部分」と「変わる部分」の切り分け。変わらない仕組みを部品に、変わる値を引数にする。

クロージャは「生まれた場所の変数を関数が覚え続ける」仕組み。外から触れない私有の状態(ここでは memo の表)を作れる。

カリー化された memoizer(inits)(rule) の 2 段の引数が、「初期値」と「漸化式」という 2 つの変わる部分をそのまま形にしている。

rule に elem 自身を渡すことで、漸化式は「自分自身」を外から受け取る。同じ部品から作った関数どうしは別々の表を持ち、干渉しない。