【JavaScript基礎】スコープについて

javascript基礎

以下で学んだ事をまとめていきます。

スコープとは

スコープとは、実行中のコードから値と式が参照できる範囲を決めるもの。
スコープの中で定義された変数はスコープの内側でのみ参照でき、スコープの外側からは参照できない。

function fn() {
    const x = 1;
    // fn関数のスコープ内から`x`は参照できる
    console.log(x); // => 1
}
fn();
// fn関数のスコープ外から`x`は参照できないためエラー

スコープの種類

  • グローバルスコープ
  • スクリプトスコープ
  • 関数スコープ
  • ブロックスコープ
  • モジュールスコープ

グローバルスコープ・スクリプトスコープ

以下のコードを実行し、デベロッパーツールのSourcesを開いてみる。

let a = 0;
const b = 0;
var c = 0;
function d() {}
debugger;

※グローバルコンテキスト・・・ブロックなどで囲われていない、まっさらな環境で実行されるコードのこと

グローバルコンテキストでvarやfunctionで宣言を行なった時は、Windowオブジェクト(=グローバルスコープ)に格納される。
また、グローバルコンテキストでletやconstで宣言を行なった時は、Script(スクリプトスコープ)に格納される。
使い勝手の面では、両者とも全く同じ使い方ができる。
ただ、どちらが外か内なのかは理解しておきたい。

window.e = 1;
let e = 2;
console.log(e); //→2が表示される

eという変数名がかぶっているがエラーが表示されない。
これはスコープが違うという事・windowというインスタンスに紐づいたメンバ変数という事であるからなのだが、

var e = 1;
let e = 2;

これはエラーになる。
当たり前の事ではあるが、同じ名前の変数を2度定義している事になるのでエラーになる。
ただ、グローバルコンテキストでvar宣言された変数は後にwindowオブジェクトのプロパティとなるし、varとletではスコープが違うのでこのような疑問が出てくる。

厳密に言えば、windowオブジェクトのプロパティとなるその過程のホイスティングでエラーになっていると思われる。
ホイスティング時に、JSエンジン側で変数宣言が行われている変数名を一覧にした時、letで宣言した変数名と重複する変数宣言があった時にエラーになっている。
ホイスティング(巻き上げ)についてはJSPrimerを参照されたい。
https://jsprimer.net/basic/function-scope/#hoisting-var

ブロックスコープ

// ブロック内で定義した変数はスコープ内でのみ参照できる
{
    const x = 1;
    console.log(x); // => 1
}
// スコープの外から`x`を参照できないためエラー
console.log(x); // => ReferenceError: x is not defined

{}で囲んだ範囲をブロックと呼ぶ。ブロックもスコープを作成する。
ブロックの例として、関数以外のifやforやwhileで使う時など。

ただし、ブロック内でのvarや関数宣言はブロックが無視されるため、注意が必要となる。

{
    var c = 1; 
    function d() {
        console.log('hoge');
    }
}

console.log(c);  //→1が表示される
d();  //→hogeが表示される

これを回避するためには、

{
    const c = 1; 
    const d = function() {
        console.log('hoge');
    }
}

console.log(c);  //ReferenceError
d();  //ReferenceError

constやletを使う事でブロックスコープが有効となる。

関数スコープ

function fn() {
    const x = 1;
    // fn関数のスコープ内から`x`は参照できる
    console.log(x); // => 1
}
fn();
// fn関数のスコープ外から`x`は参照できないためエラー
console.log(x); // => ReferenceError: x is not defined
// スコープ内に同じ"a"を定義すると SyntaxError となる
let a;
let a;
// 異なる関数のスコープには同じ"x"を定義できる
function fnA() {
    let x;
}
function fnB() {
    let x;
}

スコープの中で定義された変数はスコープの内側でのみ参照でき、外側からは参照できない。
また、同じスコープ内に同じ名前の変数は二重に定義できないが、スコープが異なれば宣言できる。


//関数宣言の中に関数宣言をする
function a() {
  function b() {
    console.log("hoge");
  }
}

b(); //→ReferenceError: b is not defined

ブロックスコープでは関数宣言はスコープ外から参照できたが、関数スコープでは参照はできない。

スコープチェーン

あるスコープが他のスコープを含んでいる状態のことをスコープチェーンという。

{
  // 外側のブロックスコープ
  const x = "outer";
  {
      // 内側のブロックスコープ
      const x = "inner";
      // 現在のスコープ(内側のブロックスコープ)にある`x`を参照する
      console.log(x); // => "inner"が表示
  }
  // 現在のスコープ(外側のブロックスコープ)にある`x`を参照する
  console.log(x); // => "outer"が表示
}

外側のブロックスコープでは変数xにouterという値を入れ、内側のブロックスコープではxにinnerという値を格納しており、それぞれのスコープ内で呼び出すと、それぞれで定義した値が呼び出せる。

{
  // 外側のブロックスコープ
  const x = "outer";
  {
      // xがないので、1つ外側のブロックスコープにある`x`を参照する
      console.log(x); // => "outer"が表示
  }
  // 現在のスコープ(外側のブロックスコープ)にある`x`を参照する
  console.log(x); // => "outer"が表示
}

では、内側のブロックスコープにあるxの定義を消すと、どうなるのか。
その場合は内側のxを探した後、1つ外側のブロックスコープにxがあるかどうか探しに行く。
あればそれを表示し、なければまた外側を見に行く。
最終的にグローバルスコープを探し、なかった場合はRefferenceErrorが表示される。

まずは、そのスコープ内を探すため、

{
  // 外側のブロックスコープ
  const x = "outer";
  {
      console.log(x); // => ReferenceError: Cannot access 'x' before initialization
      const x = "inner";
  }
  // 現在のスコープ(外側のブロックスコープ)にある`x`を参照する
  console.log(x); // => "outer"が表示
}{

このような使用箇所より変数宣言が後ろの場合も、外側のブロックスコープに探しに行かずにまずは内側のスコープを探す。
宣言がスコープ内にあるとJSエンジンが認識をし、エラーを発生させる。
“初期化前にxにアクセスできません”というエラー(Temporal Dead Zone(TDZ)ともいうらしい)。

レキシカルスコープ・静的スコープ

実際に存在するスコープというよりは、スコープの概念のようなもので、
コードを記述した時点で決定するスコープのことをレキシカルスコープ、または静的スコープという。
JavaScriptやRuby、Javaをはじめとした多くの言語は静的スコープであるが、動的スコープ(ダイナミックスコープ)というものもある。
また、実行中のコードから見た外部スコープ(外側のスコープ)のこともレキシカルスコープという。
以下、レキシカルスコープとダイナミックスコープはJavaScriptPrimerから引用する。

レキシカルスコープ(静的スコープ)

JavaScriptのスコープには、どの識別子がどの変数を参照するかが静的に決定されるという性質がある。 つまり、コードを実行する前にどの識別子がどの変数を参照しているかがわかる。

const x = 10; // *1

function printX() {
    // この識別子`x`は常に *1 の変数`x`を参照する
    console.log(x); // => 10
}

function run() {
    const x = 20; // *2
    printX(); // 常に10が出力される
}

run();

スコープチェーンの仕組みから、この識別子xは次のように名前解決されてグローバルスコープの変数xを参照することがわかる。

  1. printXの関数スコープに変数xが定義されていない
  2. ひとつ外側のスコープ(グローバルスコープ)を確認する
  3. ひとつ外側のスコープにconst x = 10;が定義されているので、識別子xはこの変数を参照する

つまり、printX関数中に書かれたxという識別子は、run関数の実行とは関係なく、静的に*1で定義された変数xを参照することが決定される。 このように、どの識別子がどの変数を参照しているかを静的に決定する性質をレキシカルスコープまたは静的スコープと呼ぶ。

この静的スコープの仕組みはfunctionキーワードを使った関数宣言、メソッド、Arrow Functionなどすべての関数で共通する性質である。

ダイナミックスコープ(動的スコープ)

JavaScriptは静的スコープである。 しかし、動的スコープという呼び出し元により識別子がどの変数を参照するかが変わる仕組みを持つ言語もある。

次のコードは、動的スコープの動きを説明する疑似的な言語のコード例。 識別子xが呼び出し元のスコープを参照する仕組みである場合には、次のような結果になる。

Ruby
// 動的スコープの疑似的な言語のコード例(JavaScriptではない)
// 変数`x`を宣言
var x = 10;

// `printX`という関数を定義
fn printX() {
    // 動的スコープの言語では、識別子`x`は呼び出し元によってどの変数`x`を参照するかが変わる
    // `print`関数でコンソールへログ出力する
    print(x);
}

fn run() {
    // 呼び出し元のスコープで、変数`x`を定義している
    var x = 20;
    printX();
}

printX(); // ここでは 10 が出力される
run(); // ここでは 20 が出力される

このように関数呼び出し時に呼び出し元のスコープの変数を参照する仕組みを動的スコープと呼ぶ。

JavaScriptは変数や関数の参照先は静的スコープで決まるため、上記のような動的スコープではない。 しかし、JavaScriptでもthisという特別なキーワードだけは、呼び出し元によって動的に参照先が変わる。 thisについてはJSPrimerを参照されたい。

クロージャー

「外部スコープにある変数を関数が参照し、保持できる」という関数が持つ性質のこと。
クロージャーを理解するためには上で説明した「レキシカルスコープ・静的スコープ」と「メモリ管理の仕組み」が重要となってくる。

以下はよくあるクロージャーの例の1つ。
createCounter関数が、関数内で定義したincrement関数を返している。 その返されたincrement関数をmyCounter変数に代入している。このmyCounter変数を実行するたびに1, 2, 3と1ずつ増えた値を返している。
さらに、もう一度createCounter関数を実行して、その返り値をnewCounter変数に代入する。 newCounter変数も実行するたびに1ずつ増えているが、myCounter変数とその値を共有しているわけではないことがわかる。

// `increment`関数を定義して返す関数
function createCounter() {
    let count = 0;
    // `increment`関数は`count`変数を参照
    function increment() {
        count = count + 1;
        return count;
    }
    return increment;
}
// `myCounter`は`createCounter`が返した関数を参照
const myCounter = createCounter();
myCounter(); // => 1
myCounter(); // => 2
// 新しく`newCounter`を定義する
const newCounter = createCounter();
newCounter(); // => 1
newCounter(); // => 2
// `myCounter`と`newCounter`は別々の状態持っている
myCounter(); // => 3
newCounter(); // => 3

このように、まるで関数が状態(ここでは1ずつ増えるcountという値)を持っているように振る舞える仕組みの背景にはクロージャーがある。

メモリ管理の仕組み

JavaScirptでは不必要なデータはガベージコレクションによってメモリから解放されるようになっている。
以下は変数の例。before textをafter textと上書きしている。この時、before textはどこからも参照されなくなり、ガベージコレクションに回収されてメモリ上から解放される。

let x = "before text";
// 変数`x`に新しいデータを代入する
x = "after text";
// このとき"before text"というデータはどこからも参照されなくなる
// その後、ガベージコレクションによってメモリ上から解放される

関数も同じように、参照されなくなった中身の値やデータはガベージコレクションに回収されるが、回収されない例が以下。

function createArray() {
    const tempArray = [1, 2, 3];
    return tempArray;
}
const array = createArray();
console.log(array); // => [1, 2, 3]
// 変数`array`が`[1, 2, 3]`という値を参照している -> 解放されない

このようにcreateArray関数内で配列を参照しているtempArray変数が返り値となり、グローバルスコープ(厳密にはスクリプトスコープ)のarray変数に代入されている。
この場合、createArray関数の実行終了後もarray変数からcreateArray関数の中身が参照され続けている。ひとつでも参照されていれば、データが回収されることはない。

クロージャーがなぜ動くのか

  • 静的スコープ: ある変数がどの値を参照するかは静的に決まる
  • メモリ管理の仕組み: 参照されていればデータが解放されることはない

クロージャーとはこの2つの仕組みを利用して、関数内から特定の変数を参照し続けることで関数が状態を持てる仕組みのことを言う。

最初にクロージャーの例として紹介したcreateCounter関数の例を改めて見てみる。

function createCounter() {
    let count = 0;
    function increment() {
        // `increment`関数は`createCounter`関数のスコープに定義された`変数`count`を参照している
        count = count + 1;
        return count;
    }
    return increment;
}
// createCounter()の実行結果は、内側で定義されていた`increment`関数
const myCounter = createCounter();
// myCounter関数の実行結果は`count`の評価結果
myCounter(); // => 1
myCounter(); // => 2

つまり次のような参照の関係がmyCounter変数とcount変数の間にはあることがわかる。

  • myCounter変数はcreateCounter関数の返り値であるincrement関数を参照している
  • myCounter変数はincrement関数を経由してcount変数を参照している
  • myCounter変数を実行した後もcount変数への参照は保たれている

count変数を参照するものがいるため、count変数は自動的に解放されない。 そのためcount変数の値は保持され続け、myCounter変数を実行するたびに1ずつ大きくなっていく。

このようにcount変数が自動解放されずに保持できているのは「increment関数内から外側のcreateCounter関数スコープにあるcount変数を参照している」ため。 このような性質のことをクロージャー(関数閉包)と呼ぶ。クロージャーは「静的スコープ」と「参照され続けている変数のデータが保持される」という2つの性質によって成り立っている。