コンテキスト、スコープ、クロージャについて

本投稿の前提

Trailheadの英語版をChatGPT3.5(以下、ChatGPT)に投げて返ってきた内容を載せています。もともとはTrailheadのみで学習していたのですが、不自然な日本語や、JavaScript初学者に理解しづらい用語・概念を手っ取り早く調べるとためにChatGPTとやり取りしながらの学習に切り替えました。
ChatGPTに聞くとおおよそ同じような答えが返ってきますが、毎回聞くのも手間ですし、用語なども追加で聞いていることもあるので備忘録を兼ねて残します。
僕と同じようなJavaScript初学者の方に参考になれば幸いです。

対象のTrailhead↓
コンテキスト、スコープ、クロージャについて
https://trailhead.salesforce.com/ja/content/learn/modules/javascript-essentials-salesforce-developers/context-scope-closures

学習の目的

このユニットを終えると、以下のことができるようになります:

  • JavaScriptで変数がどのようにスコープされるかを特定することができます。
  • 関数がどこで呼び出されるかによって、スコープがどのように変化するかを説明できます。
  • クロージャを使用して、関数内の変数への参照をキャプチャすることができます。

プログラミング言語を理解するためには、変数の利用可能性、状態の維持方法、およびその状態へのアクセス方法を理解することが非常に重要です。

JavaScriptでは、変数の利用可能性と可視性をスコープと呼びます。スコープは、変数が宣言された場所によって決まります。

コンテキストは、コードの現在の実行状態です。これは、thisポインタを介してアクセスされます。

変数のスコープ

JavaScriptにおける変数は、varlet、またはconstのキーワードを使って宣言されます。キーワードを呼び出す場所によって、作成される変数のスコープが決まります。

これらの3つの違いを理解するには、2つの要因があります:代入の可変性と非関数ブロックスコープのサポートです。代入の可変性については、こちらで取り上げました。次に、スコープについて説明しましょう。

スコープの面白さ

変数または引数が宣言されたコードブロックによって、そのスコープが決まります。ただし、varは非関数のコードブロックを認識しません。つまり、ifブロックやループブロックでvarを呼び出すと、変数は最も近い囲んでいる関数のスコープに割り当てられます。この機能を巻き上げと呼びます。

一方、letconstを使用する場合、引数または変数のスコープは常に宣言された実際のブロックになります。これを示すための古典的な思考実験があります。

function countToThree() {
  // iはcountToThree関数のスコープにある
  for (var i = 0; i < 3; i++){
    console.log(i); // iteration 1: 0
    // iteration 2: 1
    // iteration 3: 2
  }
  console.log(i); // これは何を出力する?
}

forループ内のconsole.logの出力は、各反復ごとにiの値を出力することは当然のことです。もっと驚くかもしれないのは、最後のconsole.log文です。これは3を出力します。iforループのスコープ内で宣言されていると思われるため、エラーが発生することが予想されます。しかし、巻き上げにより、iは実際にはcountToThree関数のスコープに属しています。

巻き上げ自体は必ずしも悪いわけではありませんが、しばしば誤解され、変数が再度宣言された場合に変数の漏出を引き起こすか、偶然の上書きを引き起こすことがあります。これらの誤解を解決するために、letconstが言語に追加され、ブロックレベルのスコープで変数を作成することができるようになりました。思考実験をもう一度見てみましょう。

for (let j = 0; j < 3; j++){
  console.log(j); // 0
  // 1
  // 2
}
console.log(j); // エラー

varの代わりにletを使うことで、forループのコンテキストでのみ存在する変数ができました。ループが閉じた後にアクセスしようとすると、エラーが発生します。

コンテキストと this

JavaScriptはオブジェクト指向言語であり、コードをオブジェクトとして扱います。JavaScriptにおいては、関数もまたオブジェクトの一種であるため、関数が呼び出されると、その関数の周りには常にオブジェクトのコンテナが存在します。

このオブジェクトコンテナは、関数が呼び出されたコンテキストを表し、thisキーワードはそのコンテキストを指します。つまり、関数が定義された時には、thisの値は決まりません。代わりに、関数が呼び出される場所によって、thisの値が変わります。

たとえば、オブジェクトのメソッドとして関数を定義した場合、その関数内でthisを使用すると、そのメソッドを呼び出したオブジェクト自体を参照することができます。しかし、関数をグローバルスコープで呼び出した場合、thisの値はグローバルオブジェクトを指します。

実際のコードで上記の説明を確かめていきます。

var obj = {
  aValue: 0,
  increment: function(incrementBy) {
    this.aValue = this.aValue + incrementBy;
  }
}

次に、increment関数にアクセスすると、予想どおり動作します。

obj.increment(2);
console.log(obj.aValue); // 2` 

しかし、その関数を別の変数に割り当てて、どのように動作するか見てみましょう。

// 関数を変数に割り当てる
var newIncrement = obj.increment;
// 新しいポインタを介して呼び出す
newIncrement(2);
console.log(obj.aValue); // 2のまま、4にならない

変数をnewIncrementに割り当てることで、関数は異なるコンテキストで実行されるようになりました。具体的には、この場合はグローバルコンテキストで実行されます。

このように、thisキーワードの値は、関数が呼び出されたコンテキストに依存するため、適切に使用する必要があります。thisの値を明示的に指定することもできますが、Function.apply()Function.call()、およびFunction.bind()のようなメソッドを使用して、関数を特定のコンテキストで実行することもできます。

グローバルオブジェクト

JavaScriptが、開発者が記述したオブジェクトを持たずに実行される場合、グローバルオブジェクトで実行されます。そのため、そこで呼び出される関数はグローバルコンテキストで実行されていると言われ、thisを参照するとグローバルオブジェクトを指します。

ブラウザでは、グローバルコンテキストはwindowオブジェクトです。次のコードをブラウザの開発者ツールで実行することで、これを簡単にテストすることができます。

this === window; // true

incrementの例では、increment関数をnewIncrement変数に割り当てることで、呼び出される場所でコンテキストが変わります。具体的には、グローバルオブジェクトに移動します。これは簡単にデモンストレーションできます。

console.log(this.aValue); // NaN
console.log(window.aValue); // NaN
console.log(typeof window.aValue); // number

新しいコンテキストでthis.aValueに代入しようとすると、JavaScriptオブジェクトの可変性が影響してきます。新しい未初期化のaValueプロパティがthisに追加されます。初期化されていない変数に対する演算は失敗するため、NaN値が返されます。ただし、aValuewindowに存在し、実際には数値であることがわかります。

オブジェクトのあるコンテキスト

incrementの例では、increment関数がobjをドット記法で呼び出す限り、thisobjを指します。つまり、一般的には、someObject.function()のように関数を呼び出す場合、ドットの左側のものがその関数が呼び出されるコンテキストになります。

Bikeの例を考えてみましょう。Bikeのコンストラクタは、this参照を使用していくつかのプロパティを定義します。また、thisを参照するプロトタイプに関数が割り当てられています。

const Bike = function(frontIndex, rearIndex){
  this.frontGearIndex = frontIndex || 0;
  this.rearGearIndex = rearIndex || 0;
  ...
}
...
Bike.prototype.calculateGearRatio = function(){
  let front = this.transmission.frontGearTeeth[this.frontGearIndex],
  rear = this.transmission.rearGearTeeth[this.rearGearIndex];
  if (front && rear) {
    return (front / rear) ;
  } else {
    return 0;
  }
};

次に、newキーワードを使用してBikeを呼び出します。

const bike = new Bike(1,2);
console.log(bike.frontGearIndex); // 1
console.log(bike.rearGearIndex); // 2

これはBikeコンストラクタをグローバルコンテキストで呼び出しているように見えますが、newキーワードにより、コンテキスト(およびthisポインター)は代入文の左側の新しいオブジェクトに移動します。

関数を呼び出すとき、それらはbikeオブジェクトのメンバーとして実行されるため、bikeオブジェクトがコンテキストとして使用されます。

let gearRatio = bike.calculateGearRatio();
console.log(gearRatio); // 3

コンストラクタを誤った方法で呼び出すことは簡単です。ここで問題が発生する可能性があります。

const badBike = Bike(1,2);
console.log(badBike.frontGearIndex); // error
console.log(window.frontGearIndex); // 1

newを使用し忘れると、Bikeは通常の関数として呼び出され、windowから新しく作成されたオブジェクトへのthisの重要な移行が失敗します。オブジェクトの可変性が介入し、frontGearIndexプロパティはwindowに追加されます。

JavaScriptのクラス構文では、newキーワードを使用してコンストラクタを呼び出す必要があるため、コンテキストを誤った方向に向けることはできません。

クロージャ

※この項はわかりにくかったのでChatGPTにクロージャについて聞いた内容をそのまま記載しています。

クロージャとは、関数が宣言された時のスコープを覚えていて、そのスコープ内にある変数や関数を参照することができる機能です。

例えば、以下のようなコードを考えてみましょう。

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

この例では、makeAdder 関数が定義されています。この関数は、引数 x を受け取り、x と y の和を返す関数を返します。

makeAdder 関数を実行すると、内部で関数が生成され、その関数が返されます。この関数は、引数 y を受け取り、x と y の和を返します。

この戻り値を add5 変数に代入すると、add5 変数には、x に 5 を設定した関数が代入されます。同様に、x に 10 を設定した関数を add10 変数に代入します。

add5(2) を実行すると、内部で x が 5 である関数が実行され、その戻り値と引数 y の和が返されます。つまり、5 + 2 = 7 が表示されます。

同様に、add10(2) を実行すると、内部で x が 10 である関数が実行され、その戻り値と引数 y の和が返されます。つまり、10 + 2 = 12 が表示されます。

このように、クロージャを使うことで、関数が宣言された時のスコープを覚えていて、そのスコープ内にある変数や関数を参照することができます。この機能をうまく活用することで、より柔軟なプログラムを実現することができます。

Next Post 前の記事
No Comment
コメントする
comment url