0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptのクロージャを「変数への参照」から理解する

0
Posted at

クロージャを「関数が外側の値を覚える仕組み」とだけ理解すると、値をコピーして保存しているように見えます。そのため、「なぜ更新後の値が見えるのか」「なぜ呼び出すたびに別の状態になるのか」「メモリリークになるのか」で混乱しがちです。

先に結論を述べると、クロージャとは、関数と、その関数が定義されたレキシカル環境との組み合わせです。内側の関数は値のコピーではなく、識別子を解決するための外側の環境へ到達できます。その環境が到達可能である間、必要な変数も保持されます。

クロージャは特別な構文ではありません。JavaScriptの関数を定義すれば日常的に生まれます。重要なのは、状態を安全に閉じ込めたり設定済みの関数を作ったりできる一方、寿命の長いコールバックから大きなデータを参照すると、不要なメモリまで保持し得る点です。

この記事では、買い物かごを作る例を育てながら、仕組み、実務での設計、非同期処理、よくある誤解、デバッグ方法まで整理します。

最小例で「参照」を確かめる

まずはカウンターです。createCounter の実行が終わっても、返された increment はその呼び出しで作られた count へアクセスできます。

function createCounter() {
  let count = 0;

  return function increment() {
    count += 1;
    return count;
  };
}

const counter = createCounter();

console.log(counter()); // 1
console.log(counter()); // 2

ここで保持されるものを「count の値0」と考えるのは不正確です。increment は、count という識別子を外側の環境で解決します。その同じbindingを読み書きするため、2回目は更新後の1から始まります。

外側の関数自身が値を更新しても、内側の関数から更新後の値が見えます。これはコピーではなく、同じbindingを参照していることの分かりやすい証拠です。

function createStatus() {
  let status = "idle";

  return {
    start() {
      status = "running";
    },
    read() {
      return status;
    },
  };
}

const task = createStatus();
console.log(task.read()); // idle
task.start();
console.log(task.read()); // running

status は返されたオブジェクトのプロパティではないため、外部から task.status = ... としても内部状態は変わりません。公開した関数を経由した操作だけを許せます。これがクロージャによるカプセル化の基本です。

レキシカルスコープと環境の連鎖

JavaScriptはレキシカルスコープを採用しています。変数をどこから参照できるかは、原則として関数を呼んだ場所ではなく、ソースコード上で定義した場所によって決まります。

const currency = "JPY";

function createFormatter() {
  const locale = "ja-JP";

  return function format(amount) {
    return new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
    }).format(amount);
  };
}

const formatPrice = createFormatter();
console.log(formatPrice(1200));

format から locale を探すときは、まず自身の環境、次に createFormatter の環境、その次に外側の環境という順でたどります。呼び出し元に同名変数があっても、探索経路へ割り込めません。

仕様上は、関数オブジェクトが作られるときに、内部スロット [[Environment]] へ現在のLexical Environmentが設定されます。普段この内部スロットを直接読むことはできませんが、「関数が定義時の環境へつながる」と考える根拠になります。

クロージャは、内側に関数を書いた瞬間だけ作られる魔法ではありません。モジュール直下の変数を参照する関数、イベントハンドラー、map に渡すアロー関数も同じレキシカルな規則に従います。ただし、外側の変数を一切参照しない関数では、実装が保持すべき環境を最適化できる場合があります。

呼び出しごとに独立した環境ができる

同じファクトリー関数を2回呼ぶと、呼び出しごとに別の実行環境が作られます。したがって、返されたクロージャ同士は状態を共有しません。

const first = createCounter();
const second = createCounter();

console.log(first());  // 1
console.log(first());  // 2
console.log(second()); // 1

この性質を使うと、グローバル変数を増やさず、インスタンスごとに独立した状態を持たせられます。買い物かごへ発展させてみます。

function createCart() {
  const items = new Map();

  function add({ id, name, price }, quantity = 1) {
    if (!Number.isInteger(quantity) || quantity <= 0) {
      throw new TypeError("quantityには正の整数が必要です");
    }

    const current = items.get(id);
    items.set(id, {
      id,
      name,
      price,
      quantity: (current?.quantity ?? 0) + quantity,
    });
  }

  function total() {
    return [...items.values()].reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    );
  }

  return { add, total };
}

const personalCart = createCart();
const businessCart = createCart();

personalCartbusinessCart は同じ実装を使いますが、別々の items を参照します。クラスの new と似た役割を持つものの、利用側は内部表現が Map であることを知りません。

状態を隠すだけでなく不変条件を守る

クロージャの価値は「外から見えなくする」ことだけではありません。状態を変更する経路を限定し、「数量は正の整数」「価格は商品登録時の値を使う」といった不変条件を一か所で守れます。

読み取り用データを返すときは、内部の可変オブジェクトをそのまま公開しないことも重要です。参照を返すと、利用側が検証を通らずに内部状態を変更できるからです。

function createCart() {
  const items = new Map();

  function add(product, quantity = 1) {
    if (!Number.isInteger(quantity) || quantity <= 0) {
      throw new TypeError("quantityには正の整数が必要です");
    }

    const current = items.get(product.id);
    items.set(product.id, {
      id: product.id,
      name: product.name,
      price: product.price,
      quantity: (current?.quantity ?? 0) + quantity,
    });
  }

  function getSnapshot() {
    return [...items.values()].map((item) => ({ ...item }));
  }

  function total() {
    return getSnapshot().reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    );
  }

  return Object.freeze({ add, getSnapshot, total });
}

配列だけを新しくしても、中のオブジェクトが同じなら変更は漏れます。この例では各要素も浅くコピーしています。入れ子が深いデータなら、そもそも公開用の値へ変換する、イミュータブルな値を採用するなど、境界を明確にします。Object.freeze も返したAPIオブジェクトを固定するだけで、クロージャ内部を自動的に深く凍結する機能ではありません。

設定済みの関数を作る

クロージャは可変状態だけでなく、設定を固定した関数にも向いています。毎回同じ引数を渡す代わりに、最初に依存を受け取り、用途に特化した関数を返します。

function createPriceFormatter({ locale, currency }) {
  const formatter = new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  });

  return (amount) => formatter.format(amount);
}

const formatYen = createPriceFormatter({
  locale: "ja-JP",
  currency: "JPY",
});

console.log(formatYen(1234));

設定値と重い初期化結果を再利用でき、呼び出し側の引数も減ります。依存を明示的に注入できるので、テストでは偽物へ差し替えやすくなります。

function createCartService({ save, now }) {
  return async function persistCart(cart) {
    const record = {
      savedAt: now().toISOString(),
      items: cart.getSnapshot(),
      total: cart.total(),
    };

    await save(record);
    return record;
  };
}

const saved = [];
const persistCart = createCartService({
  save: async (record) => saved.push(record),
  now: () => new Date("2026-01-01T00:00:00Z"),
});

これは高階関数であり、同時にクロージャでもあります。「高階関数」は関数を受け取る、または返すという役割を表し、「クロージャ」は定義時の環境へアクセスできる性質を表します。同じ関数を別の観点から説明しているため、どちらか一方に分類する必要はありません。

コールバックと非同期処理

イベントハンドラーやタイマーは、登録した関数が後で実行されます。そのときも定義時の環境を参照できるため、注文IDなどの文脈を引数へ毎回詰め直さずに保持できます。

function bindCheckout(button, cart, submit) {
  async function handleClick() {
    button.disabled = true;

    try {
      await submit({
        items: cart.getSnapshot(),
        total: cart.total(),
      });
    } finally {
      button.disabled = false;
    }
  }

  button.addEventListener("click", handleClick);
  return () => button.removeEventListener("click", handleClick);
}

handleClickbuttoncartsubmit を参照します。返した解除関数も同じ handleClick を参照するため、正しくイベントを解除できます。登録時と解除時に別のアロー関数を書くと、関数オブジェクトが異なるので解除できません。

非同期だから値が自動的にコピーされるわけではありません。コールバックが同じbindingを参照していれば、実行時点の値を読みます。実行開始時点の値を固定したい場合は、意図的に別の const へ保存します。

let selectedId = "A";

setTimeout(() => {
  console.log(selectedId); // 実行前に変更されていれば、その値
}, 0);

const idAtSchedule = selectedId;
setTimeout(() => {
  console.log(idAtSchedule); // 登録時点の "A"
}, 0);

selectedId = "B";

「最新値」と「その操作を始めた時点の値」のどちらが必要かを先に決めます。通信開始時の商品IDを結果処理でも使うならスナップショット、常に最新の選択を参照するなら同じbindingが適します。

可変状態と非同期処理の競合を設計する

クロージャへ閉じ込めた状態は外部から直接見えませんが、自動的に安全な更新になるわけではありません。await の前に値を読み、再開後に書き戻す処理を複数開始すると、後から始めた処理より古い結果が最後に反映されることがあります。

検索欄のように「最後に開始した要求だけを採用したい」なら、クロージャ内に世代番号を持たせる方法があります。

function createLatestLoader(load, render) {
  let latestVersion = 0;

  return async function loadLatest(query) {
    const myVersion = ++latestVersion;
    const result = await load(query);

    if (myVersion !== latestVersion) {
      return { status: "ignored" };
    }

    render(result);
    return { status: "rendered", result };
  };
}

const search = createLatestLoader(fetchSearchResults, renderResults);
void search("java").catch(renderError);
void search("javascript").catch(renderError);

ここでは各呼び出しの myVersion は別の関数呼び出し環境にあり、latestVersion はすべての呼び出しが共有します。比較によって古い応答を無視できます。ただし通信自体は続いています。帯域やサーバー処理も止めたいなら、前回の AbortController をクロージャに保持し、新しい要求でabortする設計が適します。

在庫数や残高のような業務データでは、クライアント内のクロージャだけで競合を防げません。複数タブ、複数端末、サーバー上の同時処理があるためです。サーバー側のトランザクション、バージョン、排他制御を使います。クロージャは一つのJavaScript実行環境における所有範囲を作るもので、分散した状態の整合性を保証する機能ではありません。

キャッシュの寿命を閉じ込める

同じ入力に対する高価な計算結果を再利用するmemoizationも、クロージャの代表的な用途です。キャッシュをグローバルへ置かず、memoizeした関数ごとに所有できます。

function memoize(fn) {
  const cache = new Map();

  return function memoized(key) {
    if (cache.has(key)) return cache.get(key);

    const value = fn(key);
    cache.set(key, value);
    return value;
  };
}

const formatProduct = memoize((productId) => {
  return buildExpensiveProductView(productId);
});

この最小実装は、引数が一つで、同じkeyが同じ意味を持ち、結果を無期限に再利用できる場合だけ正しいものです。オブジェクトをkeyにすると参照が同じ場合しか一致せず、keyが増え続ければcacheも解放されません。非同期関数ではrejectedなPromiseを残すか削除するか、同時要求で同じPromiseを共有してよいかも決める必要があります。

キャッシュには、最大件数、期限、明示的な clear、弱参照に適したkeyなら WeakMap など、寿命の方針を持たせます。「外部から見えない」ことと「管理しなくてよい」ことは別です。クロージャはキャッシュの所有者を明確にしますが、無効化規則そのものは設計者が決めます。

非公開は秘密情報の保護とは違う

クロージャ内の変数は通常のプロパティアクセスでは取り出せないため、API利用者の誤操作を防ぐのに役立ちます。しかし、ブラウザーへ配信したコードやデータを、利用者から安全に隠す仕組みではありません。DevTools、通信内容、メモリ解析などから観測される可能性があります。

アクセストークンの権限や秘密鍵の保護を、クロージャの「privateらしさ」へ頼ってはいけません。クライアントへ渡してはいけない秘密はサーバーに置き、短命な認証情報にも適切な権限と失効手段を設けます。クロージャによるカプセル化は保守性の境界であり、セキュリティ境界ではないと区別します。

ループで起きるvarの問題

古典的な例が、var を使ったループです。var はブロックスコープではなく、すべてのコールバックが同じ i を参照します。タイマーが実行される時点ではループが終わり、i は3になっています。

for (var i = 0; i < 3; i += 1) {
  setTimeout(() => console.log(i), 0);
}
// 3, 3, 3

for (let j = 0; j < 3; j += 1) {
  setTimeout(() => console.log(j), 0);
}
// 0, 1, 2

let を使った for では、仕様上、反復ごとに新しいレキシカル環境が作られます。各コールバックは別の j を参照します。現代のコードでは、単に let または配列メソッドの引数を使えば十分です。即時関数で値を閉じ込める古い回避策を新規コードへ持ち込む必要はほぼありません。

「古い値を閉じ込めた」の正体

クロージャが古い値を返すように見えるときは、何を参照したかを分けます。次の message は作成時に一度だけ計算された文字列です。count が更新されても、message は再計算されません。

function createCounterMessage() {
  let count = 0;
  const message = `count: ${count}`;

  return {
    increment: () => {
      count += 1;
    },
    readOldMessage: () => message,
    readCurrentMessage: () => `count: ${count}`,
  };
}

クロージャが count の古いコピーを持ったのではなく、更新されない別のbindingである message を参照しています。画面ライブラリではレンダーごとに新しい環境とコールバックが作られ、古いコールバックが古い環境を参照し続けることもあります。対策はライブラリごとに異なりますが、まず「どの関数オブジェクトが、どの呼び出しで作られた環境を参照しているか」を確認します。

メモリは「閉じた変数」ではなく到達可能性で考える

クロージャがあるだけでメモリリークになるわけではありません。ガベージコレクターは、実行中のコードから到達できなくなったオブジェクトを回収できます。問題は、寿命の長いイベントリスナーやタイマーがクロージャを保持し、そのクロージャが不要な大きなデータへ到達できる場合です。

function mountPreview(button, records) {
  const summary = records.map(({ id, name }) => ({ id, name }));

  function showPreview() {
    renderPreview(summary);
  }

  button.addEventListener("click", showPreview);

  return function unmount() {
    button.removeEventListener("click", showPreview);
  };
}

この例では巨大な records そのものを参照せず、必要な summary だけを閉じ込めています。さらにアンマウント時にリスナーを解除します。実際の保持状況は、ブラウザーDevToolsのMemoryパネルでヒープスナップショットやRetainersを確認します。コードを見ただけで「クロージャだからリーク」と断定しないことが大切です。

クラス・モジュール・private fieldとの選び方

小さな状態と少数の操作を一緒に返すなら、クロージャによるファクトリーは簡潔です。生成ごとに完全に非公開な状態を持ち、依存注入もしやすくなります。一方、多数のインスタンスを作り、共通メソッドをprototypeで共有したい、継承や instanceof が設計上必要という場合はクラスが読みやすいことがあります。

モジュール内で一つだけ共有する状態なら、モジュールスコープとexportした関数で十分です。クラスの #privateField は言語レベルで外部アクセスを拒否でき、状態の所在も明示的です。どの方式でも、目的は「隠すこと」ではなく、所有者、寿命、変更経路を分かりやすくすることです。

クロージャの入れ子が深く、複数の関数が多数の可変変数を暗黙に共有し始めたら、状態を明示的なオブジェクトへまとめる合図です。参照元を探すために何段も外側へ目を移すコードは、動いても保守しやすいとは限りません。

デバッグとテストの確認順

予想外の値を読むクロージャは、次の順で調べると原因を絞れます。

  1. 対象の関数がどこで定義されたかを確認する。
  2. 関数が作られる外側の関数を、何回呼んだか確認する。
  3. 参照しているのが元の変数か、途中で計算した別の定数かを確認する。
  4. 同じ関数オブジェクトが登録・解除に使われているか確認する。
  5. 非同期処理では、登録時点と実行時点のどちらの値が必要か確認する。
  6. メモリ問題では、Memoryパネルで実際の保持経路を確認する。

カプセル化した状態は、公開APIから振る舞いをテストします。内部変数を無理に取り出すテストは、実装変更を難しくします。

const cart = createCart();
cart.add({ id: "book", name: "", price: 1200 }, 2);

console.assert(cart.total() === 2400);

const snapshot = cart.getSnapshot();
snapshot[0].quantity = 99;
console.assert(cart.total() === 2400);

実際のプロジェクトではテストランナーの expect などを使います。このテストが保証したいのは items の実装ではなく、合計計算と、取得したスナップショットから内部状態を壊せないという公開上の契約です。

まとめ

クロージャは「値を記憶する箱」ではありません。関数が定義時のレキシカル環境へつながり、必要なbindingを解決できる仕組みです。この見方をすると、更新後の値が見える理由、ファクトリーの呼び出しごとに状態が分かれる理由、非同期処理で値が変化する理由を一つの原理で説明できます。

  • 値のコピーではなく、識別子を解決する環境へのつながりとして理解する。
  • 呼び出しごとの環境を使い、独立した状態と操作を作れる。
  • 内部参照を直接返さず、公開APIで不変条件を守る。
  • 非同期処理では、最新値と登録時点のスナップショットを意識して選ぶ。
  • リスナーやタイマーは解除し、必要以上に大きな値を参照しない。
  • 複雑になったらクラス、private field、モジュール、明示的な状態オブジェクトも検討する。

クロージャを使うこと自体を目的にせず、「誰が状態を所有し、いつまで生き、どの操作だけを許すか」を表現できているかで判断すると、実務でも読みやすい設計になります。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?