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

本投稿の前提
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における変数は、var
、let
、またはconst
のキーワードを使って宣言されます。キーワードを呼び出す場所によって、作成される変数のスコープが決まります。
これらの3つの違いを理解するには、2つの要因があります:代入の可変性と非関数ブロックスコープのサポートです。代入の可変性については、こちらで取り上げました。次に、スコープについて説明しましょう。
スコープの面白さ
変数または引数が宣言されたコードブロックによって、そのスコープが決まります。ただし、var
は非関数のコードブロックを認識しません。つまり、if
ブロックやループブロックでvar
を呼び出すと、変数は最も近い囲んでいる関数のスコープに割り当てられます。この機能を巻き上げと呼びます。
一方、let
やconst
を使用する場合、引数または変数のスコープは常に宣言された実際のブロックになります。これを示すための古典的な思考実験があります。
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
を出力します。i
はfor
ループのスコープ内で宣言されていると思われるため、エラーが発生することが予想されます。しかし、巻き上げにより、i
は実際にはcountToThree
関数のスコープに属しています。
巻き上げ自体は必ずしも悪いわけではありませんが、しばしば誤解され、変数が再度宣言された場合に変数の漏出を引き起こすか、偶然の上書きを引き起こすことがあります。これらの誤解を解決するために、let
とconst
が言語に追加され、ブロックレベルのスコープで変数を作成することができるようになりました。思考実験をもう一度見てみましょう。
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
値が返されます。ただし、aValue
がwindow
に存在し、実際には数値であることがわかります。
オブジェクトのあるコンテキスト
increment
の例では、increment
関数がobj
をドット記法で呼び出す限り、this
はobj
を指します。つまり、一般的には、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 が表示されます。
このように、クロージャを使うことで、関数が宣言された時のスコープを覚えていて、そのスコープ内にある変数や関数を参照することができます。この機能をうまく活用することで、より柔軟なプログラムを実現することができます。