Q1countDownload() を 2 回呼び、一番外側で total を表示すると何が起きますか?
クロージャ — 外側の変数を覚える関数
関数が外側のスコープの変数を覚えるクロージャを扱います。呼び出しをまたいで残るカウンター、設定値を覚えた関数を返す関数ファクトリ、ループの中で作る関数まで確かめます。
ページの表示回数のように、呼ぶたびに増えていく値を関数に持たせたいことがあります。ふつうの変数で数えると、値が残らないか、どの行からでも書き換えられるかのどちらかになります。
この記事では、外側のスコープの変数を覚えている関数であるクロージャと、設定値を覚えた関数を返す関数ファクトリを扱います。
呼び出しをまたいで値を残す — クロージャの基本
資料のダウンロード数を数えたいとします。数える関数の中で let を宣言すると、呼び出しのたびに 0 に戻ります。一番外側で宣言すれば値は残りますが、どの行からでも書き換えられる場所に置くことになります。
クロージャ(外側のスコープの変数を覚えている関数)を使うと、値を呼び出しをまたいで残しながら、外の行からは書き換えられないようにできます。関数の中で変数を宣言し、その変数を読み書きする関数を return で返すと、返された関数はその変数を覚えたまま外へ渡ります。
function makeDownloadCounter() {
let total = 0; // makeDownloadCounter の中だけの変数
const countUp = () => {
total = total + 1; // 外側の total を書き換える
return total;
};
return countUp; // 関数そのものを返す
}
const countDownload = makeDownloadCounter(); // カウンターを 1 つ作る
console.log(countDownload()); // 1
console.log(countDownload()); // 2
console.log(countDownload()); // 3(前の値から続く)
// console.log(total); // ReferenceError: total is not defined
const countDownload = makeDownloadCounter()— 返ってきた関数が入るconsole.log(total)— 名前が見えずReferenceError
let total = 0—countDownload()を呼んでも 0 には戻らないreturn countUp— 値ではなく関数そのものを返す
total = total + 1— 外側の total を書き換える- 外側の実行が終わったあとも、この total を読み書きできる
countUp は外側の total を覚えたまま一番外側へ渡ります。関数を抜けても、覚えられた変数だけは残ります。中で countUp と名付けた関数を、外では countDownload という名前で呼んでいます。return で渡るのは関数そのものなので、受け取る変数には使う場面に合った名前を付けられます。下の表は、数える値を置く場所ごとに、値が残るかと書き換えられるかを並べたものです。
| 数える値を置く場所 | 呼び出しをまたいだときの値 | 外の行から書き換えられるか |
|---|---|---|
| 数える関数の中で let を宣言する | 呼び出しが終わると消え、次は 0 から | 名前が見えないので書き換えられない |
| 一番外側で let を宣言する | そのまま残る | どの行からでも書き換えられる |
| 外側の関数で let を宣言し、内側の関数を返す | 返された関数を呼ぶたびに前の値から続く | 返された関数を通したときだけ変わる |
設定値を覚えた関数を作る — 関数ファクトリ
会員ランクごとにポイントの付与率が違うとします。計算のたびに率を引数で渡す書き方だと、呼び出し側がランクを取り違える余地が残り、同じ率を何度も書くことになります。率だけを先に決めておきたい場面です。
関数ファクトリ(設定値を受け取って、その値を覚えた関数を返す関数)を作ると、率を渡すのは 1 度だけで済みます。引数もクロージャが覚える対象なので、返された関数は自分が作られたときの rate を読み続けます。
function makePointCalculator(rate) {
// rate は引数。返した関数がこの rate を覚え続ける
return (amount) => Math.floor(amount * rate);
}
const regularPoint = makePointCalculator(0.01); // 通常会員は 1%
const goldPoint = makePointCalculator(0.05); // ゴールド会員は 5%
const platinumPoint = makePointCalculator(0.1); // プラチナ会員は 10%
console.log(regularPoint(3000)); // 30
console.log(goldPoint(3000)); // 150
console.log(platinumPoint(3000)); // 300
// 変数に入れず、括弧を 2 組続けてその場で計算する
console.log(makePointCalculator(0.05)(5000)); // 250
rate だけです。渡した rate は、あとから書き換えない限り同じ値のまま読まれます。makePointCalculator(0.05)(5000) では、1 組目の括弧で率を覚えた関数を作り、2 組目の括弧で金額を渡してその場で計算しています。括弧が 1 組だけのときに返るのは、計算結果の数値ではなく関数です。
作るたびに変数が増える — ファクトリと let
商品ごとに在庫の残数を減らしていきたいとします。減算に使う変数を 1 つだけ用意して共有すると、シャツを予約した数がキャップの残数にも響きます。商品ごとに別の値を持たせる必要があります。
最初に作った makeDownloadCounter のように、関数を返す関数の中で let を宣言すると、その変数は外側の関数を呼び出すたびに新しく作られます。返された関数どうしは変数を共有しないため、同じ定義から作っても、それぞれが自分の変数だけを読み書きします。
function makeStockReserver(stock) {
let rest = stock; // 呼び出しごとに新しく作られる
return (count) => {
rest = rest - count;
return rest;
};
}
const reserveShirt = makeStockReserver(10);
const reserveCap = makeStockReserver(4);
console.log(reserveShirt(3)); // 7
console.log(reserveCap(1)); // 3
console.log(reserveShirt(2)); // 5(シャツの残りだけが減る)
console.log(reserveCap(1)); // 2
rest が用意されます。片方を減らしても、もう片方の残数は動きません。3 つ目の console.log にある reserveShirt(2) はシャツの残数だけを 7 から 5 に減らし、キャップ側は 3 のままです。次の reserveCap(1) が 2 を返すので、キャップの残数も自分の値を覚え続けていると分かります。
作る呼び出しを毎回書くと値は続きません
makeStockReserver(10)(3) を 2 回書くと、どちらも 7 が返ります。呼び出しのたびに別の rest が 10 から作られるためです。減らした残数を次の予約に引き継ぐには、返ってきた関数を reserveShirt のように変数に入れて、その変数を呼び出します。
ループの中で関数を作る — 周ごとに別の let
ログの種類ごとに、出力用の関数をまとめて用意したいとします。ループの中で関数を作って配列に貯めると、呼び出すのはループが終わったあとです。このとき、関数がどの周の値を覚えているかは、ループ変数をどう宣言したかで変わります。
var はこの講座では使いませんが、let との違いがはっきりするので、ここでは並べて見ます。for の丸括弧で let を使うと、繰り返しの周ごとに新しい変数が作られ、その周に作った関数はその変数を覚えます。var は周をまたいで 1 つの変数を共有するため、すべての関数がループ後の値を読みます。
const levels = ["info", "warn", "error"];
// var は 1 つの変数をすべての周で共有する
const varLoggers = [];
for (var i = 0; i < levels.length; i++) {
varLoggers.push(() => `[${levels[i]}]`);
}
console.log(varLoggers[0]()); // [undefined]
// let は周ごとに新しい変数を作る
const letLoggers = [];
for (let j = 0; j < levels.length; j++) {
letLoggers.push(() => `[${levels[j]}]`);
}
console.log(letLoggers[0]()); // [info]
console.log(letLoggers[2]()); // [error]
var の側は 3 つの関数が同じ変数を読み、let の側は自分の周の値を読みます。覚える対象は、周ごとに作られるかどうかで変わります。var の側が [undefined] になるのは、ループを抜けた時点の i が 3 で、levels[3] が無いからです。let の側でも levels[j] を読むのは呼び出したときですが、読む j がその周に作られた変数なので、周ごとの値が残っています。
覚えるのは作ったときの値ではありません
クロージャが覚えているのは変数そのもので、読むのは呼び出した時点の値です。let rate = 0.05; を読む関数を作ったあとで rate = 0.1; と再代入すると、その関数も 0.1 で計算します。値を固定したい設定は、makePointCalculator(0.05) のようにファクトリの引数として渡します。
理解度チェック
まずは1問ずつ答えてみましょう。
Q2残数 4 の reserveCap に、reserveShirt で 3 を減らしたあと 1 を渡すと何が返りますか?
Q3for (var i = 0; ...) の中で作った関数を、ループが終わったあとに呼び出すと i は何になっていますか?