JavaScript関数入門:thisとクロージャをjscで実行して理解
JavaScript講座2。関数宣言とアロー関数、thisが決まる4つのルール、クロージャ、ループ内varの罠、TDZのReferenceError、メモ化の実測までjscで実行して解説。
シリーズ:JavaScript 講座 2/6(初級)。 数行の JavaScript は書ける(第1回を参照)けれど、実際のコードで this・アロー関数・コールバックに出会って戸惑っている人向けです。読み終えると、関数の書き方を使い分け、this が何になるか予測し、クロージャを意図して使い、ループ内の var と TDZ のエラーを見た瞬間に原因が分かるようになります。
JavaScript が Java や Python と違って見え始めるのは関数からです。同じメソッドでも呼ばれ方によって this が変わり、アロー関数は function と静かに違う動きをし、タイマーを3つ仕掛けたループが 3 3 3 と表示します。現場のレガシーコードを開くと var self = this; や .bind(this) が大量に出てきますが、それがなぜ必要だったのかもこの回で分かります。こうしたバグは本当の原因から遠く離れた場所で TypeError: undefined is not an object として現れるのが厄介です。以下はすべて JavaScriptCore の jsc シェル(macOS 26.6、Apple Silicon M1 Pro) で実行しました。jsc のエイリアスは第1回のとおりに設定してください。Node.js とブラウザは実行していません。違いが出るところはその都度書きます。
関数の4つの書き方
print(add(2, 3)); // 動く:関数宣言は巻き上げられる
function add(a, b) { return a + b; }
const mul = function (a, b) { return a * b; };
const sq = x => x * x;
const greet = (name = "guest", ...rest) => `hi ${name} (+${rest.length})`;
print(mul(2, 3), sq(4), greet(), greet("Mina", 1, 2));
print(add(1), add(1, 2, 3), add.length, greet.length);
const point = (x, y) => ({ x, y });
const broken = (x, y) => { x, y };
print(JSON.stringify(point(1, 2)), broken(1, 2));
5
6 16 hi guest (+0) hi Mina (+2)
NaN 3 2 0
{"x":1,"y":2} undefined
用語を整理します。
- 関数宣言(
function add(…) {…}):名前付きの関数を作る文です。丸ごと巻き上げ(hoisting)られるので、書いた行より上から呼べます。 - 関数式(
const mul = function …):変数に入れた関数です。先に存在するのは変数だけで、関数そのものではありません。 - アロー関数(
x => x * x):ES2015 で入った短い書き方です。=>の後が式1つなら自動的に return されます。thisもargumentsも持たず、newもできません。 - デフォルト引数(
name = "guest")と残余引数(...rest、余った引数を配列で受け取る)。
出力には驚きが3つあります。add(1) はエラーになりません。足りない b は undefined で、1 + undefined は NaN です。余分な引数は黙って無視されます(add(1, 2, 3) は 3)。そして broken は undefined を返します。=> の後の { x, y } はオブジェクトではなく文のブロックとして読まれるからで、オブジェクトを返すなら point のように丸括弧で囲みます。
古いコードは arguments で引数を読みますが、アロー関数には存在しません。
$ jsc arrowargs.js # const f = () => arguments.length; f(1, 2);
Exception: ReferenceError: Can't find variable: arguments
f@arrowargs.js:1:26
global code@arrowargs.js:1:36
代わりに残余引数を使います。
巻き上げ:先に呼べるのは関数宣言だけ
var に入れた関数を、その行より前で呼ぶと、
print(typeof early); // undefined
early();
var early = function () { print("never"); };
undefined
Exception: TypeError: early is not a function. (In 'early()', 'early' is undefined)
global code@hoistfn.js:2:6
const で同じことをすると、
$ jsc hoistfn2.js # later(); const later = () => print("never");
Exception: ReferenceError: Cannot access 'later' before initialization.
global code@hoistfn2.js:1:6
同じ間違いでもエラーの種類が違います。var では名前が存在して中身が undefined なので「undefined を呼んだ」TypeError、const では名前が TDZ の中にあるので ReferenceError です。
| 場面 | 書き方 | 理由 |
|---|---|---|
| ファイル直下の再利用する名前付き関数 | function name() {} |
巻き上げられ、スタックトレースも読みやすい |
コールバック(map・setTimeout・イベント) |
アロー x => … |
短く、外側の this を保つ |
| オブジェクトやクラスのメソッド | メソッド短縮記法 name() {} |
呼び出し元から this を受け取る |
| コンストラクタ | class(第4回) |
アロー関数は new できない |
| 引数をすべて受け取りたい | 残余引数 ...args |
アロー関数に arguments はない |
スコープと TDZ
スコープは名前が見える範囲です。let と const はブロックスコープ(一番近い { } の中だけ)、var は関数スコープです。
if (true) { var v = 1; let l = 2; const c = 3; }
print(v, typeof l, typeof c); // 1 undefined undefined
function f() { var inside = 1; }
f();
print(typeof inside); // undefined
var v は if ブロックの外に漏れ、l と c は漏れませんでした。typeof は未知の名前でも例外を出さない唯一の演算子なので、エラーではなく undefined が出ています。
第1回の TDZ は、関数の中に入ると分かりにくくなります。
let x = "outer";
function show() {
print(x); // どの x?
let x = "inner";
}
show();
Exception: ReferenceError: Cannot access 'x' before initialization.
show@tdz.js:3:10
global code@tdz.js:6:5
outer が出ると思う人が多いのですが、内側の let x は関数本体全体で外側の x を隠し(シャドーイング)、宣言の行より前では未初期化です。ここでは typeof さえ例外を投げます。print(typeof later); let later = 1; も同じ ReferenceError で、一方 typeof notDeclared は undefined でした。直すには内側の変数名を変えるか、宣言を上に移します。
this の正体(4つのルールをすべて実行)
this は隠れた引数で、その値は関数をどこに書いたかではなくどう呼んだかで決まります。例外はアロー関数で、自分の this を持たず外側の this を使います。1つのファイルで全部確かめます(this1.js、strict モード)。
"use strict";
const counter = {
count: 0,
inc() { this.count++; return this.count; },
incArrow: () => typeof this,
later() { setTimeout(function () { print("function cb this:", this); }, 0);
setTimeout(() => { this.count++; print("arrow cb count:", this.count); }, 0); },
};
print(counter.inc(), counter.inc());
print("arrow method this:", counter.incArrow());
counter.later();
const inc = counter.inc;
const bound = counter.inc.bind(counter);
print("bound:", bound());
try { inc(); } catch (e) { print(e.name + ": " + e.message); }
1 2
arrow method this: object
bound: 3
TypeError: undefined is not an object (evaluating 'this.count')
function cb this: undefined
arrow cb count: 4
1行ずつ読みます。
counter.inc()はメソッド呼び出しで、thisはドットの前のオブジェクトです。incArrowはアロー関数なので、thisはスクリプト最上位のthis(ここではグローバルオブジェクト)で、counterではありません。アロー関数はメソッドに向きません。bindはthisを固定した新しい関数を返します(callとapplyは1回の呼び出しだけ指定)。- 同じ関数を
inc()と単なる関数呼び出しにすると、strict モードではthisがundefinedで、this.countがTypeErrorになります。 setTimeoutに渡したfunctionのコールバックもjscではundefinedでした。アロー関数のコールバックはcounterのthisを保ちました。(ブラウザはタイマーのコールバックをthis=windowで呼び、Node はTimeoutオブジェクトを渡します。ここでは未実行。どちらにも頼らないでください。)
"use strict" がなければ単なる関数呼び出しでも例外は出ませんが、もっと悪い結果になります。
const counter = { count: 0, inc() { this.count++; return this.count; } };
const inc = counter.inc;
print(inc(), globalThis.count); // NaN NaN
undefined + 1 で NaN になり、副作用としてグローバル変数 count まで作られました。エラーは出ず、間違った数値とグローバルの汚染だけが残ります。
よくある間違い:コールバックで this を失う
間違い。 メソッドの中で forEach・setTimeout・イベントリスナーに普通の function を渡し、その中で this を使う。
class Timer {
constructor() { this.ticks = 0; }
start() { [1, 2, 3].forEach(function () { this.ticks++; }); }
}
new Timer().start();
実際に起きること。 コールバックは forEach から単なる関数として呼ばれるので、this は undefined です(class の中は常に strict モード)。
Exception: TypeError: undefined is not an object (evaluating 'this.ticks')
@this3.js:3:49
forEach@[native code]
start@this3.js:3:30
global code@this3.js:5:18
スタックトレースが手がかりになります。一番上のフレームには名前がなく(無名関数)、それを forEach が呼び、forEach を start が呼んでいます。Chrome や Node では Cannot read properties of undefined (reading 'ticks') という文言になります(ここでは未実行)。
直し方。 start の this を引き継ぐアロー関数にします:[1, 2, 3].forEach(() => { this.ticks++; });。2015 年以前の書き方である .bind(this) や const self = this; も動きます。レガシーコードで var self = this; を見かけたら、それはこの問題への昔の対処です。
クロージャ:覚えている関数
クロージャとは、関数と、その関数が作られたときに見えていた変数の組です。外側の関数が return した後も、関数が生きている限りその変数も生き続けます。
function makeCounter(start = 0) {
let n = start; // makeCounter が終わっても残る
return { next: () => ++n, peek: () => n };
}
const a = makeCounter(), b = makeCounter(100);
a.next(); a.next();
print(a.next(), b.next(), a.peek(), b.peek(), a.n); // 3 101 3 101 undefined
makeCounter を呼ぶたびに新しい n ができるので、a と b は干渉しません。そして a.n は undefined です。next と peek を通す以外に n に触る方法はありません。クラスに #private フィールド(第4回)が入るまでの約20年、JavaScript はこの方法で非公開の状態を隠してきました。
設定値を覚えさせる使い方も実務でよく見ます。税率を覚えた関数を作ってみます。
function makeTaxIncluded(ratePercent) {
return yen => Math.floor(yen * (100 + ratePercent) / 100);
}
const standard = makeTaxIncluded(10), reduced = makeTaxIncluded(8);
print(standard(1980), reduced(1980), standard(100)); // 2178 2138 110
ratePercent は呼び出しごとに閉じ込められ、標準税率と軽減税率の関数が別々にできました。第1回の消費税の罠を避けるため、税率は整数のまま掛けています。
ループ内の var の罠
クロージャのバグで一番有名なものを実際に動かします。
for (var i = 0; i < 3; i++) setTimeout(() => print("var i =", i), 0);
for (let j = 0; j < 3; j++) setTimeout(() => print("let j =", j), 0);
const fns = [];
for (var k = 0; k < 3; k++) fns.push(() => k);
print("after loop, k =", k, "fns:", fns.map(f => f()).join(","));
after loop, k = 3 fns: 3,3,3
var i = 3
var i = 3
var i = 3
let j = 0
let j = 1
let j = 2
ループ全体で var i は1つしかありません。3つのアロー関数はすべて同じ変数を閉じ込めていて、実行される頃には(setTimeout は今のスクリプトが終わるまで待つので、ループの後)3 になっています。for の見出しに書いた let は特別で、反復ごとに新しい変数が作られるため、各アロー関数は自分の j を持ちます。ループの後でも k が見えているのも var の漏れです。2015 年以前は即時実行関数 ((m) => setTimeout(() => print(m), 0))(m) で回避しており、これも 0, 1, 2 と出ましたが、今は let と書くだけで済みます。
実測:クロージャでメモ化する
クロージャは罠だけではありません。代表的な使い道がメモ化、つまり結果を非公開の Map に覚えておくラッパーです。
let calls = 0;
function fib(n) { calls++; return n < 2 ? n : fib(n - 1) + fib(n - 2); }
function memoize(fn) {
const cache = new Map(); // このクロージャだけが触れる
return function (n) {
if (!cache.has(n)) cache.set(n, fn(n));
return cache.get(n);
};
}
const mfib = memoize(n => n < 2 ? n : mfib(n - 1) + mfib(n - 2));
memo.js の結果です(Apple M1 Pro、macOS 26.6 の jsc、performance.now() で計測)。
fib(25) = 75025: 242785 calls, 1.70 ms
fib(30) = 832040: 2692537 calls, 7.90 ms
fib(32) = 2178309: 7049155 calls, 17.88 ms
run 1: plain fib(30) 6.52 ms, memoized 0.220 ms
run 2: plain fib(30) 6.36 ms, memoized 0.020 ms
run 3: plain fib(30) 6.00 ms, memoized 0.020 ms
run 4: plain fib(30) 6.12 ms, memoized 0.160 ms
run 5: plain fib(30) 6.22 ms, memoized 0.020 ms
832040 memo body calls: 31
second call body calls: 0
素朴な版は fib(30) で 270 万回呼び出し、n が1増えるごとに呼び出し回数が約1.6倍(黄金比)、fib(25) から fib(30) で 11 倍になります。メモ化版は各値を1回ずつしか計算しないので 0〜30 で 31 回、2回目の呼び出しでは全部キャッシュにあるので 0 回です。各回で新しいキャッシュを作っているので、0.02 ms は 31 回の計算の本当のコストです。1回目と4回目の 0.16〜0.22 ms はウォームアップやガベージコレクションの揺れで、だから5回とも載せています。代償はメモリで、キャッシュは mfib が生きている限り残ります。長時間動くサーバーで忘れないキャッシュはメモリリークと同じなので、実務では上限を設けます。
正直な限界とバージョンメモ
- エンジン: macOS 26.6.2 同梱の JavaScriptCore(フレームワーク 21624.5.1.11.3)を
jscで実行。Node.js・Deno・ブラウザは未実行です。巻き上げ・TDZ・this・クロージャ・反復ごとのletは ECMAScript の仕様で決まっていてどこでも同じですが、エラーメッセージの文言はエンジンごとに違います。 - タイマー:
setTimeoutはjscが用意しているもので、言語仕様の一部ではありません。ブラウザと Node はコールバックのthisやタイミングについて独自のルールで実装しています(説明のみ、未実行)。 - バージョン: アロー関数、ブロックスコープの
let/const、デフォルト引数・残余引数、Mapは ES2015 で入りました。それ以前はvarと即時実行関数しかなかったので、古いコードや古い Q&A にはそれが多く残っています。 - 時間は1台での値です。呼び出し回数は正確で、エンジンに依存しません。
練習問題
1. once(fn) を書いてください。返す関数は、最初の1回だけ fn を呼び、以降は fn を呼ばずに最初の結果を返します。
2. 出力を予想してから実行し、最初の値を説明してください。
const obj = { name: "box", greet: () => "hi " + this.name, greet2() { return "hi " + this.name; } };
print(obj.greet(), "|", obj.greet2());
3. makeCounter に reset() を足して、c.next(); c.next(); c.reset(); c.next() が 1 を返すようにしてください。
解答
1.
function once(fn) {
let done = false, result;
return (...args) => { if (!done) { done = true; result = fn(...args); } return result; };
}
const init = once(() => { print("initialising"); return 42; });
print(init(), init(), init());
initialising が1回だけ出て、続いて 42 42 42 でした。done と result はクロージャの中にあります。
2. 実際の出力は hi undefined | hi box です。greet はアロー関数なので this はスクリプト最上位の this(name を持たないグローバルオブジェクト)で、obj ではありません。greet2 はメソッド短縮記法なので this は obj です。
3. function makeCounter() { let n = 0; return { next: () => ++n, reset() { n = 0; } }; } で 1 が出ました。2つの関数が同じ n を閉じ込めています。
次回
第3回はオブジェクトと配列です。map/filter/reduce、スプレッド構文と分割代入、浅いコピーの罠、そして [10, 9, 1].sort() が 1,10,9 になる理由を扱います。
よくある質問
var と setTimeout のループで同じ数字が3回出るのはなぜ?
var はループ全体(関数全体)で変数を1つしか作らないからです。コールバックはループが終わってから実行されるので、全員が最後の値を読みます。jsc で for (var i = 0; i < 3; i++) setTimeout(() => print(i)) を実行すると 3, 3, 3 でした。let なら反復ごとに別の変数ができ、0, 1, 2 と出ました。
アロー関数と普通の function で this はどう違う?
普通の関数の this は呼ばれ方で決まります。obj.method() なら obj、単なる関数呼び出しなら strict モードで undefined です。アロー関数は自分の this を持たず、外側のコードの this をそのまま使います。そのためメソッド内のコールバックにはアロー関数が向き、オブジェクトのメソッド自体には向きません。
「Cannot access 'x' before initialization」の意味は?
let や const の変数を、宣言の行が実行される前(TDZ の中)で読んだという意味です。関数の中に後から let x があって外側の x を隠している場合によく起きます。関数本体全体が内側の x を指すようになるからです。宣言を上に移すか、名前を変えます。