從上下文,到作用域(彩蛋:理解閉包)

天方夜發表於2017-10-14

前言

近幾天在程式設計群中的聊天,讓我發現了很多人並不清楚什麼是上下文(context)、什麼是作用域(scope),而且糾結在其中。我當初對這兩個概念也只有粗淺的理解,不過我從一開始就不怎麼困惑,因為我清楚自己對這一問題的認識邊界。現在,我對它們的認識也只加深了一點點。不過,群聊中小夥伴的熱情鼓舞了我——很多最最初學的小夥伴,想到和思考的是很多我從沒考慮過的問題,小夥伴們真是達到了“進一寸有一寸的歡喜”這一境界。見賢思齊,我決定把這一點點進步記錄下來。

上下文與作用域的關係

很多人弄不清除,原因當然是既不瞭解上下文,也不瞭解作用域——我是說,幾乎沒有人明白上下文是什麼而不明白作用域是什麼,反之亦然。上下文(context)作用域(scope)都是編譯原理的知識,具體程式語言有具體的實現規則,本文關注 JavaScript 語言的實現。首先需要關注的是,這兩個概念的關係非常密切,所以先了解它們的關係,有助於理解它們到底是什麼。

上下文(context)作用域(scope)的關係:

上下文是一段程式執行所需要的最小資料集合;作用域是當前上下文中,按照具體規則能夠訪問到的識別符號(變數)的範圍。

後文是對上下文和作用域更詳細的解釋,知道了上面指出的關係,往下閱讀時就可以加深對這一關係的理解了。

上下文

上下文(context)是一段程式執行所需要的最小資料集合。我們可以從上下文交換(context switch)來理解上下文,在多程式或多執行緒環境中,任務切換時首先要中斷當前的任務,將計算資源交給下一個任務。因為稍後還要恢復之前的任務,所以中斷的時候要儲存現場,即當前任務的上下文,也可以叫做環境。即上下文就是恢復現場所需的最小資料集合。容易把人弄暈的一點是,我們這裡說的上下文環境有時候也稱作作用域(scope),即這兩個概念有時候是混用的。不過,它們有不同的側重點,下一節將會說明。

另外,JavaScript 中常見的情形是一個方法/函式的執行。從一段程式的角度看,這段程式執行所需的所有變數,就是它的上下文。

作用域

作用域(scope)是識別符號(變數)在程式中的可見性範圍。作用域規則是按照具體規則維護識別符號的可見性,以確定當前執行的程式碼對這些識別符號的訪問許可權。作用域(scope)是在具體的作用域規則之下確定的。

前面說過,有時候上下文、環境、作用域是同義詞;不過,上下文(context)指代的是整體環境,作用域關注的是識別符號(變數)的可訪問性(可見性)。上下文確定了,根據具體程式語言的作用域規則,作用域也就確定了。這就是上下文與作用域的關係

寫 JavaScript 程式碼時,如果 Function 作為引數,可以指定它在具體物件上呼叫時,這個物件常常叫做 context:

function callWithContext(fn, context) {
  return fn.call(context);
}

const apple = {
  name: "Apple"
};

const orange = {
  name: "Orange"
};

function echo() {
  console.log(this.name);
}

callWithContext(echo, apple);  // Apple
callWithContext(echo, orange); // Orange

為什麼將這個引數叫做 context?因為它關係到呼叫環境,指定了它,就指定了函式的呼叫上下文。再加上具體的作用域規則,作用域也確定了。

在 JavaScript 中,這個具體的作用域規則就是詞法作用域(lexical scope),也就是 JavaScript 中的作用域鏈的規則。詞法作用域是的變數在編譯時(詞法階段)就是確定的,所以詞法作用域又叫靜態作用域(static scope),與之相對的是動態作用域(dynamic scope)

You Don't Know JS: Scope & Closures 用簡單例子解釋過動態作用域,下面用一個類似的例子說明一下:

function foo() {
  console.log(a);
}

function bar() {
  let a = 3;
  foo();
}

let a = 2;

bar(); // 2

有一定 JavaScript 程式設計經驗的人都能看出,這段程式會輸出 2,但如果在動態作用域的規則下,應該輸出 3,即 a 的引用不再是編譯時確定,而是呼叫時確定的。這有點像 JavaScript 中的 this,所以 MDN 中,function.bind 的方法簽名中第一個形參名稱用的是 thisArg 這一更科學的名字:

fun.bind(thisArg[, arg1[, arg2[, ...]]])

同樣情況的還可見於 Lodash 的文件:

_.bind(func, thisArg, [partials])

彩蛋:理解閉包

上一節中的程式碼中,之所以輸出 2,是因為 foo 是一個閉包函式。如果從本文中理解了上下文和作用域的概念,對於閉包是什麼這一問題是不是感到豁然開朗?

前面說過,詞法作用域也叫靜態作用域,變數在詞法階段確定,也就是定義時確定。雖然在 bar 內呼叫,但由於 foo 是閉包函式,即使它在自己定義的詞法作用域以外的地方執行,它也一直保持著自己的作用域。所謂閉包函式,即這個函式封閉了它自己的定義時的環境,形成了一個閉包,所以 foo 並不會從 bar 中尋找變數,這就是靜態作用域的特點。

一個更加典型的例子是:

function fn() {
  let a = 0;
  function func() {
    console.log(a);
  }
  return func;
}

let a = 1;
let sub = fn();

sub(); // 0;

sub 就是 func 這一返回值,func 定義在 fn 內部並且被傳遞出來了,所以 fn 執行之後垃圾回收器依然沒有回收它的內部作用域,因為 func/sub 在使用。sub 依然持有 func 定義時的作用域的引用,而這個引用就叫作閉包。呼叫 sub 時,它可以訪問 func 定義時的詞法作用域,因此找到的 a 是 fn 內部的變數 a,它的值是 0。

參考資料

You Don't Know JS: Scope & Closures
Context (computing)
Scope (computer science)
Function.prototype.bind()
Function _.bind()

相關文章