Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

25.閉包 Closures

As the man said, for every complex problem there’s a simple solution, and it’s wrong.

​ ——Umberto Eco, Foucault’s Pendulum

正如那人所說,每一個複雜的問題都有一個簡單的解決方案,而且是錯誤的。(翁貝託·艾柯,《傅科擺》)

Thanks to our diligent labor in the last chapter, we have a virtual machine with working functions. What it lacks is closures. Aside from global variables, which are their own breed of animal, a function has no way to reference a variable declared outside of its own body.

感謝我們在上一章的辛勤勞動,我們得到了一個擁有函式的虛擬機器。現在虛擬機器缺失的是閉包。除了全域性變數(也就是函式的同類)之外,函式沒有辦法引用其函式體之外宣告的變數。

var x = "global";
fun outer() {
  var x = "outer";
  fun inner() {
    print x;
  }
  inner();
}
outer();

Run this example now and it prints “global”. It’s supposed to print “outer”. To fix this, we need to include the entire lexical scope of all surrounding functions when resolving a variable.

現在執行這個示例,它列印的是“global”。但它應該列印“outer”。為瞭解決這個問題,我們需要在解析變數時涵蓋所有外圍函式的整個詞法作用域。

This problem is harder in clox than it was in jlox because our bytecode VM stores locals on a stack. We used a stack because I claimed locals have stack semantics—variables are discarded in the reverse order that they are created. But with closures, that’s only mostly true.

這個問題在clox中比在jlox中更難解決,因為我們的位元組碼虛擬機器將區域性變數儲存在棧中。我們使用堆疊是因為,我聲稱區域性變數具有棧語義——變數被丟棄的順序與建立的順序正好相反。但對於閉包來說,這隻在大部分情況下是正確的。

fun makeClosure() {
  var local = "local";
  fun closure() {
    print local;
  }
  return closure;
}

var closure = makeClosure();
closure();

The outer function makeClosure() declares a variable, local. It also creates an inner function, closure() that captures that variable. Then makeClosure() returns a reference to that function. Since the closure escapes while holding on to the local variable, local must outlive the function call where it was created.

外層函式makeClosure()宣告瞭一個變數local。它還建立了一個內層函式closure(),用於捕獲該變數。然後makeClosure()返回對該內層函式的引用。因為閉包要在保留區域性變數的同時進行退出,所以local必須比建立它的函式呼叫存活更長的時間。

A local variable flying away from the stack.

We could solve this problem by dynamically allocating memory for all local variables. That’s what jlox does by putting everything in those Environment objects that float around in Java’s heap. But we don’t want to. Using a stack is really fast. Most local variables are not captured by closures and do have stack semantics. It would suck to make all of those slower for the benefit of the rare local that is captured.

我們可以透過為所有區域性變數動態地分配記憶體來解決這個問題。這就是jlox所做的,它將所有物件都放在Java堆中漂浮的Environment物件中。但我們並不想這樣做。使用堆疊非常快。大多數區域性變數都不會被閉包捕獲,並且具有棧語義。如果為了極少數被捕獲的區域性變數而使所有變數的速度變慢,那就糟糕了1

This means a more complex approach than we used in our Java interpreter. Because some locals have very different lifetimes, we will have two implementation strategies. For locals that aren’t used in closures, we’ll keep them just as they are on the stack. When a local is captured by a closure, we’ll adopt another solution that lifts them onto the heap where they can live as long as needed.

這意味著一種比我們在Java直譯器中所用的更復雜的方法。因為有些區域性變數具有非常不同的生命週期,我們將有兩種實現策略。對於那些不在閉包中使用的區域性變數,我們將保持它們在棧中的原樣。當某個區域性變數被閉包捕獲時,我們將採用另一種解決方案,將它們提升到堆中,在那裡它們存活多久都可以。

Closures have been around since the early Lisp days when bytes of memory and CPU cycles were more precious than emeralds. Over the intervening decades, hackers devised all manner of ways to compile closures to optimized runtime representations. Some are more efficient but require a more complex compilation process than we could easily retrofit into clox.

閉包早在Lisp時代就已經存在了,當時記憶體位元組和CPU週期比祖母綠還要珍貴。在過去的幾十年裡,駭客們設計了各種各樣的方式來編譯閉包,以最佳化執行時表示2。有些方法更有效,但也需要更復雜的編譯過程,我們無法輕易地在clox中加以改造。

The technique I explain here comes from the design of the Lua VM. It is fast, parsimonious with memory, and implemented with relatively little code. Even more impressive, it fits naturally into the single-pass compilers clox and Lua both use. It is somewhat intricate, though. It might take a while before all the pieces click together in your mind. We’ll build them one step at a time, and I’ll try to introduce the concepts in stages.

我在這裡解釋的技術來自於Lua虛擬機器的設計。它速度快,記憶體佔用少,並且只用相對較少的程式碼就實現了。更令人印象深刻的是,它很自然地適用於clox和Lua都在使用的單遍編譯器。不過,它有些複雜,可能需要一段時間才能把所有的碎片在你的腦海中拼湊起來。我們將一步一步地構建它們,我將嘗試分階段介紹這些概念。

25 . 1 Closure Objects

25.1 閉包物件

Our VM represents functions at runtime using ObjFunction. These objects are created by the front end during compilation. At runtime, all the VM does is load the function object from a constant table and bind it to a name. There is no operation to “create” a function at runtime. Much like string and number literals, they are constants instantiated purely at compile time.

我們的虛擬機器在執行時使用ObjFunction表示函式。這些物件是由前端在編譯時建立的。在執行時,虛擬機器所做的就是從一個常量表中載入函式物件,並將其與一個名稱繫結。在執行時,沒有“建立”函式的操作。與字串和數字字面量一樣,它們是純粹在編譯時例項化的常量3

That made sense because all of the data that composes a function is known at compile time: the chunk of bytecode compiled from the function’s body, and the constants used in the body. Once we introduce closures, though, that representation is no longer sufficient. Take a gander at:

這是有道理的,因為組成函式的所有資料在編譯時都是已知的:根據函式主體編譯的位元組碼塊,以及函式主體中使用的常量。一旦我們引入閉包,這種表示形式就不夠了。請看一下:

fun makeClosure(value) {
  fun closure() {
    print value;
  }
  return closure;
}

var doughnut = makeClosure("doughnut");
var bagel = makeClosure("bagel");
doughnut();
bagel();

The makeClosure() function defines and returns a function. We call it twice and get two closures back. They are created by the same nested function declaration, closure, but close over different values. When we call the two closures, each prints a different string. That implies we need some runtime representation for a closure that captures the local variables surrounding the function as they exist when the function declaration is executed, not just when it is compiled.

makeClosure()函式會定義並返回一個函式。我們呼叫它兩次,得到兩個閉包。它們都是由相同的巢狀函式宣告closure建立的,但關閉在不同的值上。當我們呼叫這兩個閉包時,每個閉包都打印出不同的字串。這意味著我們需要一些閉包執行時表示,以捕獲函式外圍的區域性變數,因為這些變數要在函式宣告被執行時存在,而不僅僅是在編譯時存在。

We’ll work our way up to capturing variables, but a good first step is defining that object representation. Our existing ObjFunction type represents the “raw” compile-time state of a function declaration, since all closures created from a single declaration share the same code and constants. At runtime, when we execute a function declaration, we wrap the ObjFunction in a new ObjClosure structure. The latter has a reference to the underlying bare function along with runtime state for the variables the function closes over.

我們會逐步來捕獲變數,但良好的第一步是定義物件表示形式。我們現有的ObjFunction型別表示了函式宣告的“原始”編譯時狀態,因為從同一個宣告中建立的所有閉包都共享相同的程式碼和常量。在執行時,當我們執行函式宣告時,我們將ObjFunction包裝進一個新的ObjClosure結構體中。後者有一個對底層裸函式的引用,以及該函式關閉的變數的執行時狀態4

An ObjClosure with a reference to an ObjFunction.

We’ll wrap every function in an ObjClosure, even if the function doesn’t actually close over and capture any surrounding local variables. This is a little wasteful, but it simplifies the VM because we can always assume that the function we’re calling is an ObjClosure. That new struct starts out like this:

我們將用ObjClosure包裝每個函式,即使該函式實際上並沒有關閉或捕獲任何外圍區域性變數。這有點浪費,但它簡化了虛擬機器,因為我們總是可以認為我們正在呼叫的函式是一個ObjClosure。這個新結構體是這樣開始的:

object.h,在結構體ObjString後新增程式碼:

typedef struct {
  Obj obj;
  ObjFunction* function;
} ObjClosure;

Right now, it simply points to an ObjFunction and adds the necessary object header stuff. Grinding through the usual ceremony for adding a new object type to clox, we declare a C function to create a new closure.

現在,它只是簡單地指向一個ObjFunction,並添加了必要的物件頭內容。遵循向clox中新增新物件型別的常規步驟,我們宣告一個C函式來建立新閉包。

object.h,在結構體ObjClosure後新增程式碼:

ObjFunction 
// 新增部分開始
ObjClosure* newClosure(ObjFunction* function);
// 新增部分結束
ObjFunction* newFunction();

Then we implement it here:

然後我們在這裡實現它:

object.c,在allocateObject()方法後新增程式碼:

ObjClosure* newClosure(ObjFunction* function) {
  ObjClosure* closure = ALLOCATE_OBJ(ObjClosure, OBJ_CLOSURE);
  closure->function = function;
  return closure;
}

It takes a pointer to the ObjFunction it wraps. It also initializes the type field to a new type.

它接受一個指向待包裝ObjFunction的指標。它還將型別欄位初始為一個新型別。

object.h,在列舉ObjType中新增程式碼:

typedef enum {
  // 新增部分開始
  OBJ_CLOSURE,
  // 新增部分結束
  OBJ_FUNCTION,

And when we’re done with a closure, we release its memory.

以及,當我們用完閉包後,要釋放其記憶體。

memory.c,在freeObject()方法中新增程式碼:

  switch (object->type) {
    // 新增部分開始
    case OBJ_CLOSURE: {
      FREE(ObjClosure, object);
      break;
    }
    // 新增部分結束
    case OBJ_FUNCTION: {

We free only the ObjClosure itself, not the ObjFunction. That’s because the closure doesn’t own the function. There may be multiple closures that all reference the same function, and none of them claims any special privilege over it. We can’t free the ObjFunction until all objects referencing it are gone—including even the surrounding function whose constant table contains it. Tracking that sounds tricky, and it is! That’s why we’ll write a garbage collector soon to manage it for us.

我們只釋放ObjClosure本身,而不釋放ObjFunction。這是因為閉包不擁有函式。可能會有多個閉包都引用了同一個函式,但沒有一個閉包聲稱對該函式有任何特殊的許可權。我們不能釋放某個ObjFunction,直到引用它的所有物件全部消失——甚至包括那些常量表中包含該函式的外圍函式。要跟蹤這個資訊聽起來很棘手,事實也的確如此!這就是我們很快就會寫一個垃圾收集器來管理它們的原因。

We also have the usual macros for checking a value’s type.

我們還有用於檢查值型別的常用宏5

object.h,新增程式碼:

#define OBJ_TYPE(value)        (AS_OBJ(value)->type)
// 新增部分開始
#define IS_CLOSURE(value)      isObjType(value, OBJ_CLOSURE)
// 新增部分結束
#define IS_FUNCTION(value)     isObjType(value, OBJ_FUNCTION)

And to cast a value:

還有值轉換:

object.h,新增程式碼:

#define IS_STRING(value)       isObjType(value, OBJ_STRING)
// 新增部分開始
#define AS_CLOSURE(value)      ((ObjClosure*)AS_OBJ(value))
// 新增部分結束
#define AS_FUNCTION(value)     ((ObjFunction*)AS_OBJ(value))

Closures are first-class objects, so you can print them.

閉包是第一類物件,因此你可以列印它們。

object.c,在printObject()方法中新增程式碼:

  switch (OBJ_TYPE(value)) {
    // 新增部分開始
    case OBJ_CLOSURE:
      printFunction(AS_CLOSURE(value)->function);
      break;
    // 新增部分結束  
    case OBJ_FUNCTION:

They display exactly as ObjFunction does. From the user’s perspective, the difference between ObjFunction and ObjClosure is purely a hidden implementation detail. With that out of the way, we have a working but empty representation for closures.

它們的顯示和ObjFunction一樣。從使用者的角度來看,ObjFunction和ObjClosure之間的區別純粹是一個隱藏的實現細節。有了這些,我們就有了一個可用但空白的閉包表示形式。

25 . 1 . 1 Compiling to closure objects

25.1.1 編譯為閉包物件

We have closure objects, but our VM never creates them. The next step is getting the compiler to emit instructions to tell the runtime when to create a new ObjClosure to wrap a given ObjFunction. This happens right at the end of a function declaration.

我們有了閉包物件,但是我們的VM還從未建立它們。下一步就是讓編譯器發出指令,告訴執行時何時建立一個新的ObjClosure來包裝指定的ObjFunction。這就發生在函式宣告的末尾。

compiler.c,在function()方法中替換1行:

  ObjFunction* function = endCompiler();
  // 替換部分開始
  emitBytes(OP_CLOSURE, makeConstant(OBJ_VAL(function)));
  // 替換部分結束
}

Before, the final bytecode for a function declaration was a single OP_CONSTANT instruction to load the compiled function from the surrounding function’s constant table and push it onto the stack. Now we have a new instruction.

之前,函式宣告的最後一個位元組碼是一條OP_CONSTANT指令,用於從外圍函式的常量表中載入已編譯的函式,並將其壓入堆疊。現在我們有了一個新指令。

chunk.h,在列舉OpCode中新增程式碼:

  OP_CALL,
  // 新增部分開始
  OP_CLOSURE,
  // 新增部分結束
  OP_RETURN,

Like OP_CONSTANT, it takes a single operand that represents a constant table index for the function. But when we get over to the runtime implementation, we do something more interesting.

OP_CONSTANT一樣,它接受一個運算元,表示函式在常量表中的索引。但是等到進入執行時實現時,我們會做一些更有趣的事情。

First, let’s be diligent VM hackers and slot in disassembler support for the instruction.

首先,讓我們做一個勤奮的虛擬機器駭客,為該指令新增反彙編器支援。

debug.c,在disassembleInstruction()方法中新增程式碼:

    case OP_CALL:
      return byteInstruction("OP_CALL", chunk, offset);
    // 新增部分開始  
    case OP_CLOSURE: {
      offset++;
      uint8_t constant = chunk->code[offset++];
      printf("%-16s %4d ", "OP_CLOSURE", constant);
      printValue(chunk->constants.values[constant]);
      printf("\n");
      return offset;
    }
    // 新增部分結束
    case OP_RETURN:

There’s more going on here than we usually have in the disassembler. By the end of the chapter, you’ll discover that OP_CLOSURE is quite an unusual instruction. It’s straightforward right now—just a single byte operand—but we’ll be adding to it. This code here anticipates that future.

這裡做的事情比我們通常在反彙編程式中看到的要多。在本章結束時,你會發現OP_CLOSURE是一個相當不尋常的指令。它現在很簡單——只有一個單位元組的運算元——但我們會增加它的內容。這裡的程式碼預示了未來。

25 . 1 . 2 Interpreting function declarations

25.1.2 解釋函式宣告

Most of the work we need to do is in the runtime. We have to handle the new instruction, naturally. But we also need to touch every piece of code in the VM that works with ObjFunction and change it to use ObjClosure instead—function calls, call frames, etc. We’ll start with the instruction, though.

我們需要做的大部分工作是在執行時。我們必須處理新的指令,這是自然的。但是我們也需要觸及虛擬機器中每一段使用ObjFunction的程式碼,並將其改為使用ObjClosure——函式呼叫、呼叫幀,等等。不過,我們會從指令開始。

vm.c,在run()方法中新增程式碼:

      }
      // 新增部分開始
      case OP_CLOSURE: {
        ObjFunction* function = AS_FUNCTION(READ_CONSTANT());
        ObjClosure* closure = newClosure(function);
        push(OBJ_VAL(closure));
        break;
      }
      // 新增部分結束
      case OP_RETURN: {

Like the OP_CONSTANT instruction we used before, first we load the compiled function from the constant table. The difference now is that we wrap that function in a new ObjClosure and push the result onto the stack.

與我們前面使用的OP_CONSTANT類似,首先從常量表中載入已編譯的函式。現在的不同之處在於,我們將該函式包裝在一個新的ObjClosure中,並將結果壓入堆疊。

Once you have a closure, you’ll eventually want to call it.

一旦你有了一個閉包,你最終就會想要呼叫它。

vm.c,在callValue()方法中替換2行:

    switch (OBJ_TYPE(callee)) {
      // 替換部分開始
      case OBJ_CLOSURE:
        return call(AS_CLOSURE(callee), argCount);
      // 替換部分結束  
      case OBJ_NATIVE: {

We remove the code for calling objects whose type is OBJ_FUNCTION. Since we wrap all functions in ObjClosures, the runtime will never try to invoke a bare ObjFunction anymore. Those objects live only in constant tables and get immediately wrapped in closures before anything else sees them.

我們刪除了呼叫OBJ_FUNCTION型別物件的程式碼。因為我們用ObjClosures包裝了所有的函式,執行時永遠不會再嘗試呼叫原生的ObjFunction。這些原生函式物件只存在於常量表中,並在其它部分看到它們之前立即被封裝在閉包中。

We replace the old code with very similar code for calling a closure instead. The only difference is the type of object we pass to call(). The real changes are over in that function. First, we update its signature.

我們用非常相似的呼叫閉包的程式碼來代替舊程式碼。唯一的區別是傳遞給call()的型別。真正的變化在這個函式中。首先,我們更新它的簽名。

vm.c,在函式call()中,替換1行:

// 替換部分開始
static bool call(ObjClosure* closure, int argCount) {
// 替換部分結束
  if (argCount != function->arity) {

Then, in the body, we need to fix everything that referenced the function to handle the fact that we’ve introduced a layer of indirection. We start with the arity checking:

然後,在主體中,我們需要修正所有引用該函式的內容,以便處理我們引入中間層的問題。首先從元數檢查開始:

vm.c,在call()方法中,替換3行:

static bool call(ObjClosure* closure, int argCount) {
  // 替換部分開始
  if (argCount != closure->function->arity) {
    runtimeError("Expected %d arguments but got %d.",
        closure->function->arity, argCount);
    // 替換部分結束    
    return false;

The only change is that we unwrap the closure to get to the underlying function. The next thing call() does is create a new CallFrame. We change that code to store the closure in the CallFrame and get the bytecode pointer from the closure’s function.

唯一的變化是,我們解開閉包獲得底層函式。call()做的下一件事是建立一個新的CallFrame。我們修改這段程式碼,將閉包儲存在CallFrame中,並從閉包內的函式中獲取位元組碼指標。

vm.c,在call()方法中,替換2行:

  CallFrame* frame = &vm.frames[vm.frameCount++];
  // 替換部分開始
  frame->closure = closure;
  frame->ip = closure->function->chunk.code;
  // 替換部分結束
  frame->slots = vm.stackTop - argCount - 1;

This necessitates changing the declaration of CallFrame too.

這就需要修改CallFrame的宣告。

vm.h,在結構體CallFrame中,替換1行:

typedef struct {
  // 替換部分開始
  ObjClosure* closure;
  // 替換部分結束
  uint8_t* ip;

That change triggers a few other cascading changes. Every place in the VM that accessed CallFrame’s function needs to use a closure instead. First, the macro for reading a constant from the current function’s constant table:

這一更改觸發了其它一些級聯更改。VM中所有訪問CallFrame中函式的地方都需要使用閉包來代替。首先,是從當前函式常量表中讀取常量的宏:

vm.c,在run()方法中,替換2行:

    (uint16_t)((frame->ip[-2] << 8) | frame->ip[-1]))
// 替換部分開始    
#define READ_CONSTANT() \
    (frame->closure->function->chunk.constants.values[READ_BYTE()])
// 替換部分結束    
#define READ_STRING() AS_STRING(READ_CONSTANT())

When DEBUG_TRACE_EXECUTION is enabled, it needs to get to the chunk from the closure.

DEBUG_TRACE_EXECUTION被啟用時,它需要從閉包中獲取位元組碼塊。

vm.c,在run()方法中,替換2行:

    printf("\n");
    // 替換部分開始
    disassembleInstruction(&frame->closure->function->chunk,
        (int)(frame->ip - frame->closure->function->chunk.code));
    // 替換部分結束    
#endif

Likewise when reporting a runtime error:

同樣地,在報告執行時錯誤時也是如此:

vm.c,在runtimeError()方法中,替換1行:

    CallFrame* frame = &vm.frames[i];
    // 替換部分開始
    ObjFunction* function = frame->closure->function;
    // 替換部分結束
    size_t instruction = frame->ip - function->chunk.code - 1;

Almost there. The last piece is the blob of code that sets up the very first CallFrame to begin executing the top-level code for a Lox script.

差不多完成了。最後一部分是用來設定第一個CallFrame以開始執行Lox指令碼頂層程式的程式碼塊。

vm.c,在interpret()方法中,替換1行6

  push(OBJ_VAL(function));
  // 替換部分開始
  ObjClosure* closure = newClosure(function);
  pop();
  push(OBJ_VAL(closure));
  call(closure, 0);
  // 替換部分結束
  return run();

The compiler still returns a raw ObjFunction when compiling a script. That’s fine, but it means we need to wrap it in an ObjClosure here, before the VM can execute it.

編譯指令碼時,編譯器仍然返回一個原始的ObjFunction。這是可以的,但這意味著我們現在(也就是在VM能夠執行它之前),需要將其包裝在一個ObjClosure中。

We are back to a working interpreter. The user can’t tell any difference, but the compiler now generates code telling the VM to create a closure for each function declaration. Every time the VM executes a function declaration, it wraps the ObjFunction in a new ObjClosure. The rest of the VM now handles those ObjClosures floating around. That’s the boring stuff out of the way. Now we’re ready to make these closures actually do something.

我們又得到了一個可以工作的直譯器。使用者看不出有什麼不同,但是編譯器現在生成的程式碼會告訴虛擬機器,為每一個函式宣告建立一個閉包。每當VM執行一個函式宣告時,它都會將ObjFunction包裝在一個新的ObjClosure中。VM的其餘部分會處理那些四處漂浮的ObjClosures。無聊的事情就到此為止吧。現在,我們準備讓這些閉包實際一些事情。

25 . 2 Upvalues

25.2 上值

Our existing instructions for reading and writing local variables are limited to a single function’s stack window. Locals from a surrounding function are outside of the inner function’s window. We’re going to need some new instructions.

我們現有的讀寫區域性變數的指令只限於單個函式的棧視窗。來自外圍函式的區域性變數是在內部函式的視窗之外。我們需要一些新的指令。

The easiest approach might be an instruction that takes a relative stack slot offset that can reach before the current function’s window. That would work if closed-over variables were always on the stack. But as we saw earlier, these variables sometimes outlive the function where they are declared. That means they won’t always be on the stack.

最簡單的方法可能是一條指令,接受一個棧槽相對偏移量,可以訪問當前函式視窗之前的位置。如果閉包變數始終在棧上,這是有效的。但正如我們前面看到的,這些變數的生存時間有時會比宣告它們的函式更長。這意味著它們不會一直在棧中。

The next easiest approach, then, would be to take any local variable that gets closed over and have it always live on the heap. When the local variable declaration in the surrounding function is executed, the VM would allocate memory for it dynamically. That way it could live as long as needed.

然後,次簡單的方法是獲取閉包使用的任意區域性變數,並讓它始終存活在堆中。當執行外圍函式中的區域性變數宣告時,虛擬機器會為其動態分配記憶體。這樣一來,它就可以根據需要長期存活。

This would be a fine approach if clox didn’t have a single-pass compiler. But that restriction we chose in our implementation makes things harder. Take a look at this example:

如果clox不是單遍編譯器,這會是一種很好的方法。但是我們在實現中所選擇的這種限制使事情變得更加困難。看看這個例子:

fun outer() {
  var x = 1;    // (1)
  x = 2;        // (2)
  fun inner() { // (3)
    print x;
  }
  inner();
}

Here, the compiler compiles the declaration of x at (1) and emits code for the assignment at (2). It does that before reaching the declaration of inner() at (3) and discovering that x is in fact closed over. We don’t have an easy way to go back and fix that already-emitted code to treat x specially. Instead, we want a solution that allows a closed-over variable to live on the stack exactly like a normal local variable until the point that it is closed over.

在這裡,編譯器在(1)處編譯了x的宣告,並在(2)處生成了賦值程式碼。這些發生在編譯器到達在(3)處的inner()宣告並發現x實際上被閉包引用之前。我們沒有一種簡單的方法來回溯並修復已生成的程式碼,以特殊處理x。相反,我們想要的解決方案是,在變數被關閉之前,允許它像常規的區域性變數一樣存在於棧中。

Fortunately, thanks to the Lua dev team, we have a solution. We use a level of indirection that they call an upvalue. An upvalue refers to a local variable in an enclosing function. Every closure maintains an array of upvalues, one for each surrounding local variable that the closure uses.

幸運的是,感謝Lua開發團隊,我們有了一個解決方案。我們使用一種他們稱之為上值的中間層。上值指的是一個閉包函式中的區域性變數。每個閉包都維護一個上值陣列,每個上值對應閉包使用的外圍區域性變數。

The upvalue points back into the stack to where the variable it captured lives. When the closure needs to access a closed-over variable, it goes through the corresponding upvalue to reach it. When a function declaration is first executed and we create a closure for it, the VM creates the array of upvalues and wires them up to “capture” the surrounding local variables that the closure needs.

上值指向棧中它所捕獲的變數所在的位置。當閉包需要訪問一個封閉的變數時,它會透過相應的上值(upvalues)得到該變數。當某個函式宣告第一次被執行,而且我們為其建立閉包時,虛擬機器會建立一個上值陣列,並將其與閉包連線起來,以“捕獲”閉包需要的外圍區域性變數。

For example, if we throw this program at clox,

舉個例子,如果我們把這個程式扔給clox

{
  var a = 3;
  fun f() {
    print a;
  }
}

the compiler and runtime will conspire together to build up a set of objects in memory like this:

編譯器和執行時會合力在記憶體中構建一組這樣的物件:

The object graph of the stack, ObjClosure, ObjFunction, and upvalue array.

That might look overwhelming, but fear not. We’ll work our way through it. The important part is that upvalues serve as the layer of indirection needed to continue to find a captured local variable even after it moves off the stack. But before we get to all that, let’s focus on compiling captured variables.

這可能看起來讓人不知所措,但不要害怕。我們會用自己的方式來完成的。重要的部分是,上值充當了中間層,以便在被捕獲的區域性變數離開堆疊後能繼續找到它。但在此之前,讓我們先關注一下編譯捕獲的變數。

25 . 2 . 1 Compiling upvalues

25.2.1 編譯上值

As usual, we want to do as much work as possible during compilation to keep execution simple and fast. Since local variables are lexically scoped in Lox, we have enough knowledge at compile time to resolve which surrounding local variables a function accesses and where those locals are declared. That, in turn, means we know how many upvalues a closure needs, which variables they capture, and which stack slots contain those variables in the declaring function’s stack window.

像往常一樣,我們希望在編譯期間做盡可能多的工作,從而保持執行的簡單快速。由於區域性變數在Lox是具有詞法作用域的,我們在編譯時有足夠的資訊來確定某個函式訪問了哪些外圍的區域性變數,以及這些區域性變數是在哪裡宣告的。反過來,這意味著我們知道閉包需要多少個上值,它們捕獲了哪個變數,以及在宣告函式的棧視窗中的哪個棧槽中包含這些變數。

Currently, when the compiler resolves an identifier, it walks the block scopes for the current function from innermost to outermost. If we don’t find the variable in that function, we assume the variable must be a global. We don’t consider the local scopes of enclosing functions—they get skipped right over. The first change, then, is inserting a resolution step for those outer local scopes.

目前,當編譯器解析一個識別符號時,它會從最內層到最外層遍歷當前函式的塊作用域。如果我們沒有在函式中找到該變數,我們就假定該變數一定是一個全域性變數。我們不考慮封閉函式的區域性作用域——它們會被直接跳過。那麼,第一個變化就是為這些外圍區域性作用域插入一個解析步驟。

compiler.c,在namedVariable()方法中新增程式碼:

  if (arg != -1) {
    getOp = OP_GET_LOCAL;
    setOp = OP_SET_LOCAL;
  // 新增部分開始  
  } else if ((arg = resolveUpvalue(current, &name)) != -1) {
    getOp = OP_GET_UPVALUE;
    setOp = OP_SET_UPVALUE;
  // 新增部分結束  
  } else {

This new resolveUpvalue() function looks for a local variable declared in any of the surrounding functions. If it finds one, it returns an “upvalue index” for that variable. (We’ll get into what that means later.) Otherwise, it returns -1 to indicate the variable wasn’t found. If it was found, we use these two new instructions for reading or writing to the variable through its upvalue:

這個新的resolveUpvalue()函式會查詢在任何外圍函式中宣告的區域性變數。如果找到了,就會返回該變數的“上值索引”。(我們稍後會解釋這是什麼意思)否則,它會返回-1,表示沒有找到該變數。如果找到變數,我們就使用這兩條新指令,透過其上值對變數進行讀寫:

chunk.h,在列舉OpCode中新增程式碼:

  OP_SET_GLOBAL,
  // 新增部分開始
  OP_GET_UPVALUE,
  OP_SET_UPVALUE,
  // 新增部分結束
  OP_EQUAL,

We’re implementing this sort of top-down, so I’ll show you how these work at runtime soon. The part to focus on now is how the compiler actually resolves the identifier.

我們是自上而下實現的,所以我們很快會向你展示這些在執行時是如何工作的。現在要關注的部分是編譯器實際上是如何解析識別符號的。

compiler.c,在resolveLocal()方法後新增程式碼:

static int resolveUpvalue(Compiler* compiler, Token* name) {
  if (compiler->enclosing == NULL) return -1;

  int local = resolveLocal(compiler->enclosing, name);
  if (local != -1) {
    return addUpvalue(compiler, (uint8_t)local, true);
  }

  return -1;
}

We call this after failing to resolve a local variable in the current function’s scope, so we know the variable isn’t in the current compiler. Recall that Compiler stores a pointer to the Compiler for the enclosing function, and these pointers form a linked chain that goes all the way to the root Compiler for the top-level code. Thus, if the enclosing Compiler is NULL, we know we’ve reached the outermost function without finding a local variable. The variable must be global, so we return -1.

在當前函式作用域中解析區域性變數失敗後,我們才會呼叫這個方法,因此我們知道該變數不在當前編譯器中。回顧一下,Compiler中儲存了一個指向外層函式Compiler的指標,這些指標形成了一個鏈,一直到頂層程式碼的根Compiler。因此,如果外圍的Compiler是NULL,我們就知道已經到達最外層的函式,而且沒有找到區域性變數。那麼該變數一定是全域性的7,所以我們返回-1

Otherwise, we try to resolve the identifier as a local variable in the enclosing compiler. In other words, we look for it right outside the current function. For example:

否則,我們嘗試將識別符號解析為一個在外圍編譯器中的區域性變數。換句話說,我們在當前函式外面尋找它。舉例來說:

fun outer() {
  var x = 1;
  fun inner() {
    print x; // (1)
  }
  inner();
}

When compiling the identifier expression at (1), resolveUpvalue() looks for a local variable x declared in outer(). If found—like it is in this example—then we’ve successfully resolved the variable. We create an upvalue so that the inner function can access the variable through that. The upvalue is created here:

當在(1)處編譯識別符號表示式時,resolveUpvalue()會查詢在outer()中定義的區域性變數x。如果找到了(就像本例中這樣),那我們就成功解析了該變數。我們建立一個上值,以便內部函式可以透過它訪問變數。上值是在這裡建立的:

compiler.c,在resolveLocal()方法後新增程式碼:

static int addUpvalue(Compiler* compiler, uint8_t index,
                      bool isLocal) {
  int upvalueCount = compiler->function->upvalueCount;
  compiler->upvalues[upvalueCount].isLocal = isLocal;
  compiler->upvalues[upvalueCount].index = index;
  return compiler->function->upvalueCount++;
}

The compiler keeps an array of upvalue structures to track the closed-over identifiers that it has resolved in the body of each function. Remember how the compiler’s Local array mirrors the stack slot indexes where locals live at runtime? This new upvalue array works the same way. The indexes in the compiler’s array match the indexes where upvalues will live in the ObjClosure at runtime.

編譯器保留了一個上值結構的陣列,用以跟蹤每個函式主體中已解析的封閉識別符號。還記得編譯器的Local陣列是如何反映區域性變數在執行時所在的棧槽索引的嗎?這個新的上值陣列也使用相同的方式。編譯器陣列中的索引,與執行時ObjClosure中上值所在的索引相匹配。

This function adds a new upvalue to that array. It also keeps track of the number of upvalues the function uses. It stores that count directly in the ObjFunction itself because we’ll also need that number for use at runtime.

這個函式向陣列中添加了一個新的上值。它還記錄了該函式所使用的上值的數量。它直接在ObjFunction中儲存了這個計數值,因為我們在執行時也需要使用這個數字8

The index field tracks the closed-over local variable’s slot index. That way the compiler knows which variable in the enclosing function needs to be captured. We’ll circle back to what that isLocal field is for before too long. Finally, addUpvalue() returns the index of the created upvalue in the function’s upvalue list. That index becomes the operand to the OP_GET_UPVALUE and OP_SET_UPVALUE instructions.

index欄位記錄了封閉區域性變數的棧槽索引。這樣,編譯器就知道需要捕獲外部函式中的哪個變數。用不了多久,我們會回過頭來討論isLocal欄位的用途。最後,addUpvalue()返回已建立的上值在函式的上值列表中的索引。這個索引會成為OP_GET_UPVALUEOP_SET_UPVALUE指令的運算元。

That’s the basic idea for resolving upvalues, but the function isn’t fully baked. A closure may reference the same variable in a surrounding function multiple times. In that case, we don’t want to waste time and memory creating a separate upvalue for each identifier expression. To fix that, before we add a new upvalue, we first check to see if the function already has an upvalue that closes over that variable.

這就是解析上值的基本思路,但是這個函式還沒有完全成熟。一個閉包可能會多次引用外圍函式中的同一個變數。在這種情況下,我們不想浪費時間和記憶體來為每個識別符號表示式建立一個單獨的上值。為瞭解決這個問題,在我們新增新的上值之前,我們首先要檢查該函式是否已經有封閉該變數的上值。

compiler.c,在addUpvalue()方法中新增程式碼:

  int upvalueCount = compiler->function->upvalueCount;
  // 新增部分開始
  for (int i = 0; i < upvalueCount; i++) {
    Upvalue* upvalue = &compiler->upvalues[i];
    if (upvalue->index == index && upvalue->isLocal == isLocal) {
      return i;
    }
  }
  // 新增部分結束
  compiler->upvalues[upvalueCount].isLocal = isLocal;

If we find an upvalue in the array whose slot index matches the one we’re adding, we just return that upvalue index and reuse it. Otherwise, we fall through and add the new upvalue.

如果我們在陣列中找到與待新增的上值索引相匹配的上值,我們就返回該上值的索引並複用它。否則,我們就放棄,並新增新的上值。

These two functions access and modify a bunch of new state, so let’s define that. First, we add the upvalue count to ObjFunction.

這兩個函式訪問並修改了一些新的狀態,所以我們來定義一下。首先,我們將上值計數新增到ObjFunction中。

object.h,在結構體ObjFunction中新增程式碼:

  int arity;
  // 新增部分開始
  int upvalueCount;
  // 新增部分結束
  Chunk chunk;

We’re conscientious C programmers, so we zero-initialize that when an ObjFunction is first allocated.

我們是負責的C程式設計師,所以當ObjFunction第一次被分配時,我們將其初始化為0。

object.c,在newFunction()方法中新增程式碼:

  function->arity = 0;
  // 新增部分開始
  function->upvalueCount = 0;
  // 新增部分結束
  function->name = NULL;

In the compiler, we add a field for the upvalue array.

在編譯器中,我們新增一個欄位來儲存上值陣列。

compiler.c,在結構體Compiler中新增程式碼:

  int localCount;
  // 新增部分開始
  Upvalue upvalues[UINT8_COUNT];
  // 新增部分結束
  int scopeDepth;

For simplicity, I gave it a fixed size. The OP_GET_UPVALUE and OP_SET_UPVALUE instructions encode an upvalue index using a single byte operand, so there’s a restriction on how many upvalues a function can have—how many unique variables it can close over. Given that, we can afford a static array that large. We also need to make sure the compiler doesn’t overflow that limit.

為了簡單起見,我給了它一個固定的大小。OP_GET_UPVALUEOP_SET_UPVALUE指令使用一個單位元組運算元來編碼上值索引,所以一個函式可以有多少個上值(可以封閉多少個不同的變數)是有限制的。鑑於此,我們可以負擔得起這麼大的靜態陣列。我們還需要確保編譯器不會超出這個限制。

compiler.c,在addUpvalue()方法中新增程式碼:

    if (upvalue->index == index && upvalue->isLocal == isLocal) {
      return i;
    }
  }
  // 新增部分開始
  if (upvalueCount == UINT8_COUNT) {
    error("Too many closure variables in function.");
    return 0;
  }
  // 新增部分結束
  compiler->upvalues[upvalueCount].isLocal = isLocal;

Finally, the Upvalue struct type itself.

最後,是Upvalue結構體本身。

compiler.c,在結構體Local後新增程式碼:

typedef struct {
  uint8_t index;
  bool isLocal;
} Upvalue;

The index field stores which local slot the upvalue is capturing. The isLocal field deserves its own section, which we’ll get to next.

index欄位儲存了上值捕獲的是哪個區域性變數槽。isLocal欄位值得有自己的章節,我們接下來會講到。

25 . 2 . 2 Flattening upvalues

25.2.2 扁平化上值

In the example I showed before, the closure is accessing a variable declared in the immediately enclosing function. Lox also supports accessing local variables declared in any enclosing scope, as in:

在我之前展示的例子中,閉包訪問的是在緊鄰的外圍函式中宣告的變數。Lox還支援訪問在任何外圍作用域中宣告的區域性變數,如:

fun outer() {
  var x = 1;
  fun middle() {
    fun inner() {
      print x;
    }
  }
}

Here, we’re accessing x in inner(). That variable is defined not in middle(), but all the way out in outer(). We need to handle cases like this too. You might think that this isn’t much harder since the variable will simply be somewhere farther down on the stack. But consider this devious example:

這裡,我們在inner()中訪問x。這個變數不是在middle()中定義的,而是要一直追溯到outer()中。我們也需要處理這樣的情況。你可能認為這並不難,因為變數只是位於棧中更下面的某個位置。但是考慮一下這個複雜的例子:

If you work on programming languages long enough, you will develop a finely honed skill at creating bizarre programs like this that are technically valid but likely to trip up an implementation written by someone with a less perverse imagination than you.

如果你在程式語言方面工作的時間足夠長,你就會開發出一種精細的技能,能夠創造出像這樣的怪異程式,這些程式在技術上是有效的,但很可能會在一個由想象力沒你那麼變態的人編寫的實現中出錯。

fun outer() {
  var x = "value";
  fun middle() {
    fun inner() {
      print x;
    }

    print "create inner closure";
    return inner;
  }

  print "return from outer";
  return middle;
}

var mid = outer();
var in = mid();
in();

When you run this, it should print:

當你執行這段程式碼時,應該打印出來:

return from outer
create inner closure
value

I know, it’s convoluted. The important part is that outer()—where x is declared—returns and pops all of its variables off the stack before the declaration of inner() executes. So, at the point in time that we create the closure for inner(), x is already off the stack.

我知道,這很複雜。重要的是,在inner()的宣告執行之前,outer()x被宣告的地方)已經返回並彈出其所有變數。因此,在我們為inner()建立閉包時,x已經離開了堆疊。

Here, I traced out the execution flow for you:

下面,我為你繪製了執行流程:

Tracing through the previous example program.

See how x is popped before it is captured and then later accessed ? We really have two problems:

看到了嗎,x在被捕獲②之前,先被彈出 ①,隨後又被訪問③?我們確實有兩個問題:

  1. We need to resolve local variables that are declared in surrounding functions beyond the immediately enclosing one.
  2. We need to be able to capture variables that have already left the stack.
  1. 我們需要解析在緊鄰的函式之外的外圍函式中宣告的區域性變數。
  2. 我們需要能夠捕獲已經離開堆疊的變數。

Fortunately, we’re in the middle of adding upvalues to the VM, and upvalues are explicitly designed for tracking variables that have escaped the stack. So, in a clever bit of self-reference, we can use upvalues to allow upvalues to capture variables declared outside of the immediately surrounding function.

幸運的是,我們正在向虛擬機器中新增上值,而上值是明確為跟蹤已退出棧的變數而設計的。因此,透過一個巧妙的自我引用,我們可以使用上值來允許上值捕獲緊鄰函式之外宣告的變數。

The solution is to allow a closure to capture either a local variable or an existing upvalue in the immediately enclosing function. If a deeply nested function references a local variable declared several hops away, we’ll thread it through all of the intermediate functions by having each function capture an upvalue for the next function to grab.

解決方案是允許閉包捕獲區域性變數或緊鄰函式中已有的上值。如果一個深度巢狀的函式引用了幾跳之外宣告的區域性變數,我們讓每個函式捕獲一個上值,供下一個函式抓取,從而穿透所有的中間函式。

An upvalue in inner() points to an upvalue in middle(), which points to a local variable in outer().

In the above example, middle() captures the local variable x in the immediately enclosing function outer() and stores it in its own upvalue. It does this even though middle() itself doesn’t reference x. Then, when the declaration of inner() executes, its closure grabs the upvalue from the ObjClosure for middle() that captured x. A function captures—either a local or upvalue—only from the immediately surrounding function, which is guaranteed to still be around at the point that the inner function declaration executes.

在上面的例子中,middle()捕獲了緊鄰的外層函式outer()中的區域性變數x,並將其儲存在自己的上值中。即使middle()本身不引用x,它也會這樣做。然後,當inner()的宣告執行時,它的閉包會從已捕獲xmiddle()對應的ObjClosure中抓取上值。函式只會從緊鄰的外層函式中捕獲區域性變數或上值,因為這些值在內部函式宣告執行時仍然能夠確儲存在。

In order to implement this, resolveUpvalue() becomes recursive.

為了實現這一點,resolveUpvalue()變成遞迴的。

compiler.c,在resolveUpvalue()方法中新增程式碼:

  if (local != -1) {
    return addUpvalue(compiler, (uint8_t)local, true);
  }
  // 新增部分開始
  int upvalue = resolveUpvalue(compiler->enclosing, name);
  if (upvalue != -1) {
    return addUpvalue(compiler, (uint8_t)upvalue, false);
  }
  // 新增部分結束
  return -1;

It’s only another three lines of code, but I found this function really challenging to get right the first time. This in spite of the fact that I wasn’t inventing anything new, just porting the concept over from Lua. Most recursive functions either do all their work before the recursive call (a pre-order traversal, or “on the way down”), or they do all the work after the recursive call (a post-order traversal, or “on the way back up”). This function does both. The recursive call is right in the middle.

這只是另外加了三行程式碼,但我發現這個函式真的很難一次就正確完成。儘管我並沒有發明什麼新東西,只是從Lua中移植了這個概念。大多數遞迴函式要麼在遞迴呼叫之前完成所有工作(先序遍歷,或“下行”),要麼在遞迴呼叫之後完成所有工作(後續遍歷,或“回退”)。這個函式兩者都是,遞迴呼叫就在中間。

We’ll walk through it slowly. First, we look for a matching local variable in the enclosing function. If we find one, we capture that local and return. That’s the base case.

我們來慢慢看一下。首先,我們在外部函式中查詢匹配的區域性變數。如果我們找到了,就捕獲該區域性變數並返回。這就是基本情況9

Otherwise, we look for a local variable beyond the immediately enclosing function. We do that by recursively calling resolveUpvalue() on the enclosing compiler, not the current one. This series of resolveUpvalue() calls works its way along the chain of nested compilers until it hits one of the base cases—either it finds an actual local variable to capture or it runs out of compilers.

否則,我們會在緊鄰的函式之外尋找區域性變數。我們透過遞迴地對外層編譯器(而不是當前編譯器)呼叫resolveUpvalue()來實現這一點。這一系列的resolveUpvalue()呼叫沿著巢狀的編譯器鏈執行,直到遇見基本情況——要麼找到一個事件的區域性變數來捕獲,要麼是遍歷完了所有編譯器。

When a local variable is found, the most deeply nested call to resolveUpvalue() captures it and returns the upvalue index. That returns to the next call for the inner function declaration. That call captures the upvalue from the surrounding function, and so on. As each nested call to resolveUpvalue() returns, we drill back down into the innermost function declaration where the identifier we are resolving appears. At each step along the way, we add an upvalue to the intervening function and pass the resulting upvalue index down to the next call.

當找到區域性變數時,巢狀最深的resolveUpvalue()呼叫會捕獲它並返回上值的索引。這就會返回到內層函式宣告對應的下一級呼叫。該呼叫會捕獲外層函式中的上值,以此類推。隨著對resolveUpvalue()的每個巢狀呼叫的返回,我們會往下鑽到最內層函式宣告,即我們正在解析的識別符號出現的地方。在這一過程中的每一步,我們都向中間函式新增一個上值,並將得到的上值索引向下傳遞給下一個呼叫10

It might help to walk through the original example when resolving x:

在解析x的時候,走一遍原始的例子可能會有幫助:

Tracing through a recursive call to resolveUpvalue().

Note that the new call to addUpvalue() passes false for the isLocal parameter. Now you see that that flag controls whether the closure captures a local variable or an upvalue from the surrounding function.

請注意,對addUpvalue()的新呼叫為isLocal引數傳遞了false。現在你可以看到,該標誌控制著閉包捕獲的是區域性變數還是來自外圍函式的上值。

By the time the compiler reaches the end of a function declaration, every variable reference has been resolved as either a local, an upvalue, or a global. Each upvalue may in turn capture a local variable from the surrounding function, or an upvalue in the case of transitive closures. We finally have enough data to emit bytecode which creates a closure at runtime that captures all of the correct variables.

當編譯器到達函式宣告的結尾時,每個變數的引用都已經被解析為區域性變數、上值或全域性變數。每個上值可以依次從外圍函式中捕獲一個區域性變數,或者在傳遞閉包的情況下捕獲一個上值。我們終於有了足夠的資料來生成位元組碼,該位元組碼在執行時建立一個捕獲所有正確變數的閉包。

compiler.c,在function()方法中新增程式碼:

  emitBytes(OP_CLOSURE, makeConstant(OBJ_VAL(function)));
  // 新增部分開始
  for (int i = 0; i < function->upvalueCount; i++) {
    emitByte(compiler.upvalues[i].isLocal ? 1 : 0);
    emitByte(compiler.upvalues[i].index);
  }
  // 新增部分結束
}

The OP_CLOSURE instruction is unique in that it has a variably sized encoding. For each upvalue the closure captures, there are two single-byte operands. Each pair of operands specifies what that upvalue captures. If the first byte is one, it captures a local variable in the enclosing function. If zero, it captures one of the function’s upvalues. The next byte is the local slot or upvalue index to capture.

OP_CLOSURE指令的獨特之處在於,它是不定長編碼的。對於閉包捕獲的每個上值,都有兩個單位元組的運算元。每一對運算元都指定了上值捕獲的內容。如果第一個位元組是1,它捕獲的就是外層函式中的一個區域性變數。如果是0,它捕獲的是函式的一個上值。下一個位元組是要捕獲區域性變數插槽或上值索引。

This odd encoding means we need some bespoke support in the disassembly code for OP_CLOSURE.

這種奇怪的編碼意味著我們需要在反彙編程式中對OP_CLOSURE提供一些定製化的支援。

debug.c,在disassembleInstruction()方法中新增程式碼:

      printf("\n");
      // 新增部分開始
      ObjFunction* function = AS_FUNCTION(
          chunk->constants.values[constant]);
      for (int j = 0; j < function->upvalueCount; j++) {
        int isLocal = chunk->code[offset++];
        int index = chunk->code[offset++];
        printf("%04d      |                     %s %d\n",
               offset - 2, isLocal ? "local" : "upvalue", index);
      }
      // 新增部分結束
      return offset;

For example, take this script:

舉例來說,請看這個指令碼:

fun outer() {
  var a = 1;
  var b = 2;
  fun middle() {
    var c = 3;
    var d = 4;
    fun inner() {
      print a + c + b + d;
    }
  }
}

If we disassemble the instruction that creates the closure for inner(), it prints this:

如果我們反彙編為inner()建立閉包的指令,它會列印如下內容:

0004    9 OP_CLOSURE          2 <fn inner>
0006      |                     upvalue 0
0008      |                     local 1
0010      |                     upvalue 1
0012      |                     local 2

We have two other, simpler instructions to add disassembler support for.

我們還有兩條更簡單的指令需要新增反彙編支援。

debug.c,在disassembleInstruction()方法中新增程式碼:

    case OP_SET_GLOBAL:
      return constantInstruction("OP_SET_GLOBAL", chunk, offset);
    // 新增部分開始  
    case OP_GET_UPVALUE:
      return byteInstruction("OP_GET_UPVALUE", chunk, offset);
    case OP_SET_UPVALUE:
      return byteInstruction("OP_SET_UPVALUE", chunk, offset);
    // 新增部分結束  
    case OP_EQUAL:

These both have a single-byte operand, so there’s nothing exciting going on. We do need to add an include so the debug module can get to AS_FUNCTION().

這兩條指令都是單位元組運算元,所有沒有什麼有趣的內容。我們確實需要新增一個標頭檔案引入,以便除錯模組能夠訪問AS_FUNCTION()

debug.c,新增程式碼:

#include "debug.h"
// 新增部分開始
#include "object.h"
// 新增部分結束
#include "value.h"

With that, our compiler is where we want it. For each function declaration, it outputs an OP_CLOSURE instruction followed by a series of operand byte pairs for each upvalue it needs to capture at runtime. It’s time to hop over to that side of the VM and get things running.

有了這些,我們的編譯器就達到了我們想要的效果。對於每個函式宣告,它都會輸出一條OP_CLOSURE指令,後跟一系列運算元位元組對,對應需要在執行時捕獲的每個上值。現在是時候跳到虛擬機器那邊,讓整個程式運轉起來。

25 . 3 Upvalue Objects

25.3 Upvalue物件

Each OP_CLOSURE instruction is now followed by the series of bytes that specify the upvalues the ObjClosure should own. Before we process those operands, we need a runtime representation for upvalues.

現在每條OP_CLOSURE指令後面都跟著一系列位元組,這些位元組指定了ObjClosure應該擁有的上值。在處理這些運算元之前,我們需要一個上值的執行時表示。

object.h,在結構體ObjString後新增程式碼:

typedef struct ObjUpvalue {
  Obj obj;
  Value* location;
} ObjUpvalue;

We know upvalues must manage closed-over variables that no longer live on the stack, which implies some amount of dynamic allocation. The easiest way to do that in our VM is by building on the object system we already have. That way, when we implement a garbage collector in the next chapter, the GC can manage memory for upvalues too.

我們知道上值必須管理已關閉的變數,這些變數不再存活於棧上,這意味著需要一些動態分配。在我們的虛擬機器中,最簡單的方法就是在已有的物件系統上進行構建。這樣,當我們在下一章中實現垃圾收集器時,GC也可以管理上值的記憶體。

Thus, our runtime upvalue structure is an ObjUpvalue with the typical Obj header field. Following that is a location field that points to the closed-over variable. Note that this is a pointer to a Value, not a Value itself. It’s a reference to a variable, not a value. This is important because it means that when we assign to the variable the upvalue captures, we’re assigning to the actual variable, not a copy. For example:

因此,我們的執行時上值結構是一個具有典型Obj頭欄位的ObjUpvalue。之後是一個指向關閉變數的location欄位。注意,這是一個指向Value的指標,而不是Value本身。它是一個變數的引用,而不是一個。這一點很重要,因為它意味著當我們向上值捕獲的變數賦值時,我們是在給實際的變數賦值,而不是對一個副本賦值。舉例來說:

fun outer() {
  var x = "before";
  fun inner() {
    x = "assigned";
  }
  inner();
  print x;
}
outer();

This program should print “assigned” even though the closure assigns to x and the surrounding function accesses it.

這個程式應該列印“assigned”,儘管是在閉包中對x賦值,而在外圍函式中訪問它。

Because upvalues are objects, we’ve got all the usual object machinery, starting with a constructor-like function:

因為上值是物件,我們已經有了所有常見的物件機制,首先是類似構造器的函式:

object.h,在copyString()方法後新增程式碼:

ObjString* copyString(const char* chars, int length);
// 新增部分開始
ObjUpvalue* newUpvalue(Value* slot);
// 新增部分結束
void printObject(Value value);

It takes the address of the slot where the closed-over variable lives. Here is the implementation:

它接受的是封閉變數所在的槽的地址。下面是其實現:

object.c,在copyString()方法後新增程式碼:

ObjUpvalue* newUpvalue(Value* slot) {
  ObjUpvalue* upvalue = ALLOCATE_OBJ(ObjUpvalue, OBJ_UPVALUE);
  upvalue->location = slot;
  return upvalue;
}

We simply initialize the object and store the pointer. That requires a new object type.

我們簡單地初始化物件並儲存指標。這需要一個新的物件型別。

object.h,在列舉ObjType中新增程式碼:

  OBJ_STRING,
  // 新增部分開始
  OBJ_UPVALUE
  // 新增部分結束
} ObjType;

And on the back side, a destructor-like function:

在後面,還有一個類似解構函式的方法:

memory.c,在freeObject()方法中新增程式碼:

      FREE(ObjString, object);
      break;
    }
    // 新增部分開始
    case OBJ_UPVALUE:
      FREE(ObjUpvalue, object);
      break;
    // 新增部分結束  
  }

Multiple closures can close over the same variable, so ObjUpvalue does not own the variable it references. Thus, the only thing to free is the ObjUpvalue itself.

多個閉包可以關閉同一個變數,所以ObjUpvalue並不擁有它引用的變數。因此,唯一需要釋放的就是ObjUpvalue本身。

And, finally, to print:

最後,是列印:

object.c,在printObject()方法中新增程式碼:

    case OBJ_STRING:
      printf("%s", AS_CSTRING(value));
      break;
    // 新增部分開始   
    case OBJ_UPVALUE:
      printf("upvalue");
      break;
    // 新增部分結束  
  }

Printing isn’t useful to end users. Upvalues are objects only so that we can take advantage of the VM’s memory management. They aren’t first-class values that a Lox user can directly access in a program. So this code will never actually execute . . . but it keeps the compiler from yelling at us about an unhandled switch case, so here we are.

列印對終端使用者沒有用。上值是物件,只是為了讓我們能夠利用虛擬機器的記憶體管理。它們並不是Lox使用者可以在程式中直接訪問的一等公民。因此,這段程式碼實際上永遠不會執行……但它使得編譯器不會因為未處理的case分支而對我們大喊大叫,所以我們這樣做了。

25 . 3 . 1 Upvalues in closures

25.3.1 閉包中的上值

When I first introduced upvalues, I said each closure has an array of them. We’ve finally worked our way back to implementing that.

我在第一次介紹上值時,說過每個閉包中都有一個上值陣列。我們終於回到了實現它的道路上。

object.h,在結構體ObjClosure中新增程式碼:

  ObjFunction* function;
  // 新增部分開始
  ObjUpvalue** upvalues;
  int upvalueCount;
  // 新增部分結束
} ObjClosure;

Different closures may have different numbers of upvalues, so we need a dynamic array. The upvalues themselves are dynamically allocated too, so we end up with a double pointer—a pointer to a dynamically allocated array of pointers to upvalues. We also store the number of elements in the array.

不同的閉包可能會有不同數量的上值,所以我們需要一個動態陣列。上值本身也是動態分配的,因此我們最終需要一個二級指標——一個指向動態分配的上值指標陣列的指標。我們還會儲存陣列中的元素數量11

When we create an ObjClosure, we allocate an upvalue array of the proper size, which we determined at compile time and stored in the ObjFunction.

當我們建立ObjClosure時,會分配一個適當大小的上值陣列,這個大小在編譯時就已經確定並儲存在ObjFunction中。

object.c,在newClosure()方法中新增程式碼:

ObjClosure* newClosure(ObjFunction* function) {
  // 新增部分開始
  ObjUpvalue** upvalues = ALLOCATE(ObjUpvalue*,
                                   function->upvalueCount);
  for (int i = 0; i < function->upvalueCount; i++) {
    upvalues[i] = NULL;
  }
  // 新增部分結束
  ObjClosure* closure = ALLOCATE_OBJ(ObjClosure, OBJ_CLOSURE);

Before creating the closure object itself, we allocate the array of upvalues and initialize them all to NULL. This weird ceremony around memory is a careful dance to please the (forthcoming) garbage collection deities. It ensures the memory manager never sees uninitialized memory.

在建立閉包物件本身之前,我們分配了上值陣列,並將其初始化為NULL。這種圍繞記憶體的奇怪儀式是一場精心的舞蹈,為了取悅(即將到來的)垃圾收集器神靈。它可以確保記憶體管理器永遠不會看到未初始化的記憶體。

Then we store the array in the new closure, as well as copy the count over from the ObjFunction.

然後,我們將陣列儲存在新的閉包中,並將計數值從ObjFunction中複製過來。

object.c,在newClosure()方法中新增程式碼:

  closure->function = function;
  // 新增部分開始
  closure->upvalues = upvalues;
  closure->upvalueCount = function->upvalueCount;
  // 新增部分結束
  return closure;

When we free an ObjClosure, we also free the upvalue array.

當我們釋放ObjClosure時,也需要釋放上值陣列。

memory.c,在freeObject()方法中新增程式碼:

    case OBJ_CLOSURE: {
      // 新增部分開始
      ObjClosure* closure = (ObjClosure*)object;
      FREE_ARRAY(ObjUpvalue*, closure->upvalues,
                 closure->upvalueCount);
      // 新增部分結束           
      FREE(ObjClosure, object);

ObjClosure does not own the ObjUpvalue objects themselves, but it does own the array containing pointers to those upvalues.

ObjClosure並不擁有ObjUpvalue本身,但它確實擁有包含指向這些上值的指標的陣列。

We fill the upvalue array over in the interpreter when it creates a closure. This is where we walk through all of the operands after OP_CLOSURE to see what kind of upvalue each slot captures.

當直譯器建立閉包時,我們會填充上值陣列。在這裡,我們會遍歷OP_CLOSURE之後的所有運算元,以檢視每個槽捕獲了什麼樣的上值。

vm.c,在run()方法中新增程式碼:

        push(OBJ_VAL(closure));
        // 新增部分開始
        for (int i = 0; i < closure->upvalueCount; i++) {
          uint8_t isLocal = READ_BYTE();
          uint8_t index = READ_BYTE();
          if (isLocal) {
            closure->upvalues[i] =
                captureUpvalue(frame->slots + index);
          } else {
            closure->upvalues[i] = frame->closure->upvalues[index];
          }
        }
        // 新增部分結束
        break;

This code is the magic moment when a closure comes to life. We iterate over each upvalue the closure expects. For each one, we read a pair of operand bytes. If the upvalue closes over a local variable in the enclosing function, we let captureUpvalue() do the work.

這段程式碼是閉包誕生的神奇時刻。我們遍歷了閉包所期望的每個上值。對於每個上值,我們讀取一對運算元位元組。如果上值在外層函式的一個區域性變數上關閉,我們就讓captureUpvalue()完成這項工作。

Otherwise, we capture an upvalue from the surrounding function. An OP_CLOSURE instruction is emitted at the end of a function declaration. At the moment that we are executing that declaration, the current function is the surrounding one. That means the current function’s closure is stored in the CallFrame at the top of the callstack. So, to grab an upvalue from the enclosing function, we can read it right from the frame local variable, which caches a reference to that CallFrame.

否則,我們從外圍函式中捕獲一個上值。OP_CLOSURE指令是在函式宣告的末尾生成。在我們執行該宣告時,當前函式就是外圍的函式。這意味著當前函式的閉包儲存在呼叫棧頂部的CallFrame中。因此,要從外層函式中抓取上值,我們可以直接從區域性變數frame中讀取,該變數快取了一個對CallFrame的引用。

Closing over a local variable is more interesting. Most of the work happens in a separate function, but first we calculate the argument to pass to it. We need to grab a pointer to the captured local’s slot in the surrounding function’s stack window. That window begins at frame->slots, which points to slot zero. Adding index offsets that to the local slot we want to capture. We pass that pointer here:

關閉區域性變數更有趣。大部分工作發生在一個單獨的函式中,但首先我們要計算傳遞給它的引數。我們需要在外圍函式的棧視窗中抓取一個指向捕獲的區域性變數槽的指標。該視窗起點在frame->slots,指向槽0。在其上新增index偏移量,以指向我們想要捕獲的區域性變數槽。我們將該指標傳入這裡:

vm.c,在callValue()方法後新增程式碼:

static ObjUpvalue* captureUpvalue(Value* local) {
  ObjUpvalue* createdUpvalue = newUpvalue(local);
  return createdUpvalue;
}

This seems a little silly. All it does is create a new ObjUpvalue that captures the given stack slot and returns it. Did we need a separate function for this? Well, no, not yet. But you know we are going to end up sticking more code in here.

這看起來有點傻。它所做的就是建立一個新的捕獲給定棧槽的ObjUpvalue,並將其返回。我們需要為此建一個單獨的函式嗎?嗯,不,現在還不用。但你懂的,我們最終會在這裡插入更多程式碼。

First, let’s wrap up what we’re working on. Back in the interpreter code for handling OP_CLOSURE, we eventually finish iterating through the upvalue array and initialize each one. When that completes, we have a new closure with an array full of upvalues pointing to variables.

首先,來總結一下我們的工作。回到處理OP_CLOSURE的直譯器程式碼中,我們最終完成了對上值陣列的迭代,並初始化了每個值。完成後,我們就有了一個新的閉包,它的陣列中充滿了指向變數的上值。

With that in hand, we can implement the instructions that work with those upvalues.

有了這個,我們就可以實現與這些上值相關的指令。

vm.c,在run()方法中新增程式碼:

      }
      // 新增部分開始
      case OP_GET_UPVALUE: {
        uint8_t slot = READ_BYTE();
        push(*frame->closure->upvalues[slot]->location);
        break;
      }
      // 新增部分結束
      case OP_EQUAL: {

The operand is the index into the current function’s upvalue array. So we simply look up the corresponding upvalue and dereference its location pointer to read the value in that slot. Setting a variable is similar.

運算元是當前函式的上值陣列的索引。因此,我們只需查詢相應的上值,並對其位置指標解引用,以讀取該槽中的值。設定變數也是如此。

vm.c,在run()方法中新增程式碼:

      }
      // 新增部分開始
      case OP_SET_UPVALUE: {
        uint8_t slot = READ_BYTE();
        *frame->closure->upvalues[slot]->location = peek(0);
        break;
      }
      // 新增部分結束
      case OP_EQUAL: {

We take the value on top of the stack and store it into the slot pointed to by the chosen upvalue. Just as with the instructions for local variables, it’s important that these instructions are fast. User programs are constantly reading and writing variables, so if that’s slow, everything is slow. And, as usual, the way we make them fast is by keeping them simple. These two new instructions are pretty good: no control flow, no complex arithmetic, just a couple of pointer indirections and a push().

我們取棧頂的值,並將其儲存的選中的上值所指向的槽中。就像區域性變數的指令一樣,這些指令的速度很重要。使用者程式在不斷的讀寫變數,因此如果這個操作很慢,一切都會很慢。而且,像往常一樣,我們讓它變快的方法就是保持簡單。這兩條新指令非常好:沒有控制流,沒有複雜的算術,只有幾個指標間接引用和一個push()12

This is a milestone. As long as all of the variables remain on the stack, we have working closures. Try this:

這是一個里程碑。只要所有的變數都留存在棧上,閉包就可以工作。試試這個:

fun outer() {
  var x = "outside";
  fun inner() {
    print x;
  }
  inner();
}
outer();

Run this, and it correctly prints “outside”.

執行這個,它就會正確地列印“outside”。

25 . 4 Closed Upvalues

25.4 關閉的上值

Of course, a key feature of closures is that they hold on to the variable as long as needed, even after the function that declares the variable has returned. Here’s another example that should work:

當然,閉包的一個關鍵特性是,只要有需要,它們就會一直保留這個變數,即使宣告變數的函式已經返回。下面是另一個應該有效的例子:

fun outer() {
  var x = "outside";
  fun inner() {
    print x;
  }

  return inner;
}

var closure = outer();
closure();

But if you run it right now . . . who knows what it does? At runtime, it will end up reading from a stack slot that no longer contains the closed-over variable. Like I’ve mentioned a few times, the crux of the issue is that variables in closures don’t have stack semantics. That means we’ve got to hoist them off the stack when the function where they were declared returns. This final section of the chapter does that.

但是如果你現在執行它……天知道它會做什麼?在執行時,他會從不包含關閉變數的棧槽中讀取資料。正如我多次提到的,問題的關鍵在於閉包中的變數不具有棧語義。這意味著當宣告它們的函式返回時,我們必須將它們從棧中取出。本章的最後一節就是實現這一點的。

25 . 4 . 1 Values and variables

25.4.1 值與變數

Before we get to writing code, I want to dig into an important semantic point. Does a closure close over a value or a variable? This isn’t purely an academic question. I’m not just splitting hairs. Consider:

在我們開始編寫程式碼之前,我想深入探討一個重要的語義問題。閉包關閉的是一個還是一個變數?這並不是一個純粹的學術問題13。我並不是在胡攪蠻纏。考慮一下:

var globalSet;
var globalGet;

fun main() {
  var a = "initial";

  fun set() { a = "updated"; }
  fun get() { print a; }

  globalSet = set;
  globalGet = get;
}

main();
globalSet();
globalGet();

The outer main() function creates two closures and stores them in global variables so that they outlive the execution of main() itself. Both of those closures capture the same variable. The first closure assigns a new value to it and the second closure reads the variable.

外層的main()方法建立了兩個閉包,並將它們儲存在全域性變數中,這樣它們的存活時間就比main()本身的執行時間更長。這兩個閉包都捕獲了相同的變數。第一個閉包為其賦值,第二個閉包則讀取該變數的值14

What does the call to globalGet() print? If closures capture values then each closure gets its own copy of a with the value that a had at the point in time that the closure’s function declaration executed. The call to globalSet() will modify set()’s copy of a, but get()’s copy will be unaffected. Thus, the call to globalGet() will print “initial”.

呼叫globalGet()會列印什麼?如果閉包捕獲的是,那麼每個閉包都會獲得自己的a副本,該副本的值為a在執行閉包函式宣告的時間點上的值。對globalSet()的呼叫會修改set()中的a副本,但是get()中的副本不受影響。因此,對globalGet()的呼叫會列印“initial”。

If closures close over variables, then get() and set() will both capture—reference—the same mutable variable. When set() changes a, it changes the same a that get() reads from. There is only one a. That, in turn, implies the call to globalGet() will print “updated”.

如果閉包關閉的是變數,那麼get()set()都會捕獲(引用)同一個可變變數。當set()修改a時,它改變的是get()所讀取的那個a。這裡只有一個a。這意味著對globalGet()的呼叫會列印“updated”。

Which is it? The answer for Lox and most other languages I know with closures is the latter. Closures capture variables. You can think of them as capturing the place the value lives. This is important to keep in mind as we deal with closed-over variables that are no longer on the stack. When a variable moves to the heap, we need to ensure that all closures capturing that variable retain a reference to its one new location. That way, when the variable is mutated, all closures see the change.

到底是哪一個呢?對於Lox和我所知的其它大多數帶閉包的語言來說,答案是後者。閉包捕獲的是變數。你可以把它們看作是對值所在位置的捕獲。當我們處理不再留存於棧上的閉包變數時,這一點很重要,要牢牢記住。當一個變數移動到堆中時,我們需要確保所有捕獲該變數的閉包都保留對其新位置的引用。這樣一來,當變數發生變化時,所有閉包都能看到這個變化。

25 . 4 . 2 Closing upvalues

25.4.2 關閉上值

We know that local variables always start out on the stack. This is faster, and lets our single-pass compiler emit code before it discovers the variable has been captured. We also know that closed-over variables need to move to the heap if the closure outlives the function where the captured variable is declared.

我們知道,區域性變數總是從堆疊開始。這樣做更快,並且可以讓我們的單遍編譯器在發現變數被捕獲之前先生成位元組碼。我們還知道,如果閉包的存活時間超過宣告被捕獲變數的函式,那麼封閉的變數就需要移動到堆中。

Following Lua, we’ll use open upvalue to refer to an upvalue that points to a local variable still on the stack. When a variable moves to the heap, we are closing the upvalue and the result is, naturally, a closed upvalue. The two questions we need to answer are:

跟隨Lua,我們會使用開放上值來表示一個指向仍在棧中的區域性變數的上值。當變數移動到堆中時,我們就關閉上值,而結果自然就是一個關閉的上值。我們需要回答兩個問題:

  1. Where on the heap does the closed-over variable go?
  2. When do we close the upvalue?
  1. 被關閉的變數放在堆中的什麼位置?
  2. 我們什麼時候關閉上值?

The answer to the first question is easy. We already have a convenient object on the heap that represents a reference to a variable—ObjUpvalue itself. The closed-over variable will move into a new field right inside the ObjUpvalue struct. That way we don’t need to do any additional heap allocation to close an upvalue.

第一個問題的答案很簡單。我們在堆上已經有了一個便利的物件,它代表了對某個變數(ObjUpvalue本身)的引用。被關閉的變數將移動到ObjUpvalue結構體中的一個新欄位中。這樣一來,我們不需要做任何額外的堆分配來關閉上值。

The second question is straightforward too. As long as the variable is on the stack, there may be code that refers to it there, and that code must work correctly. So the logical time to hoist the variable to the heap is as late as possible. If we move the local variable right when it goes out of scope, we are certain that no code after that point will try to access it from the stack. After the variable is out of scope, the compiler will have reported an error if any code tried to use it.

第二個問題也很直截了當。只要變數在棧中,就可能存在引用它的程式碼,而且這些程式碼必須能夠正確工作。因此,將變數提取到堆上的邏輯時間越晚越好。如果我們在區域性變數超出作用域時將其移出,我們可以肯定,在那之後沒有任何程式碼會試圖從棧中訪問它。在變數超出作用域之後15,如果有任何程式碼試圖訪問它,編譯器就會報告一個錯誤。

The compiler already emits an OP_POP instruction when a local variable goes out of scope. If a variable is captured by a closure, we will instead emit a different instruction to hoist that variable out of the stack and into its corresponding upvalue. To do that, the compiler needs to know which locals are closed over.

當區域性變數超出作用域時,編譯器已經生成了OP_POP指令16。如果變數被某個閉包捕獲,我們會發出一條不同的指令,將該變數從棧中提取到其對應的上值。為此,編譯器需要知道哪些區域性變數被關閉了。

The compiler already maintains an array of Upvalue structs for each local variable in the function to track exactly that state. That array is good for answering “Which variables does this closure use?” But it’s poorly suited for answering, “Does any function capture this local variable?” In particular, once the Compiler for some closure has finished, the Compiler for the enclosing function whose variable has been captured no longer has access to any of the upvalue state.

編譯器已經為函式中的每個區域性變數維護了一個Upvalue結構體的陣列,以便準確地跟蹤該狀態。這個陣列很好地回答了“這個閉包使用了哪個變數”,但他不適合回答“是否有任何函式捕獲了這個區域性變數?”特別是,一旦某個閉包的Compiler 執行完成,變數被捕獲的外層函式的Compiler就不能再訪問任何上值狀態了。

In other words, the compiler maintains pointers from upvalues to the locals they capture, but not in the other direction. So we first need to add some extra tracking inside the existing Local struct so that we can tell if a given local is captured by a closure.

換句話說,編譯器保持著從上值指向它們捕獲的區域性變數的指標,而沒有相反方向的指標。所以,我們首先需要在現有的Local結構體中新增額外的跟蹤資訊,這樣我們就能夠判斷某個給定的區域性變數是否被某個閉包捕獲。

compiler.c,在Local結構體中新增程式碼:

  int depth;
  // 新增部分開始
  bool isCaptured;
  // 新增部分結束
} Local;

This field is true if the local is captured by any later nested function declaration. Initially, all locals are not captured.

如果區域性變數被後面巢狀的任何函式宣告捕獲,欄位則為true。最初,所有的區域性資料都沒有被捕獲。

compiler.c,在addLocal()方法中新增程式碼:

  local->depth = -1;
  // 新增部分開始
  local->isCaptured = false;
  // 新增部分結束
}

Likewise, the special “slot zero local” that the compiler implicitly declares is not captured.

同樣地,編譯器隱式宣告的特殊的“槽0中的區域性變數”不會被捕獲17

compiler.c,在initCompiler()方法中新增程式碼:

  local->depth = 0;
  // 新增部分開始
  local->isCaptured = false;
  // 新增部分結束
  local->name.start = "";

When resolving an identifier, if we end up creating an upvalue for a local variable, we mark it as captured.

在解析識別符號時,如果我們最終為某個區域性變數建立了一個上值,我們將其標記為已捕獲。

compiler.c,在resolveUpvalue()方法中新增程式碼:

  if (local != -1) {
    // 新增部分開始
    compiler->enclosing->locals[local].isCaptured = true;
    // 新增部分結束
    return addUpvalue(compiler, (uint8_t)local, true);

Now, at the end of a block scope when the compiler emits code to free the stack slots for the locals, we can tell which ones need to get hoisted onto the heap. We’ll use a new instruction for that.

現在,在塊作用域的末尾,當編譯器生成位元組碼來釋放區域性變數的棧槽時,我們可以判斷哪些資料需要被提取到堆中。我們將使用一個新指令來實現這一點。

compiler.c,在endScope()方法中,替換1行:

  while (current->localCount > 0 &&
         current->locals[current->localCount - 1].depth >
            current->scopeDepth) {  
    // 新增部分開始
    if (current->locals[current->localCount - 1].isCaptured) {
      emitByte(OP_CLOSE_UPVALUE);
    } else {
      emitByte(OP_POP);
    }
    // 新增部分結束
    current->localCount--;
  }

The instruction requires no operand. We know that the variable will always be right on top of the stack at the point that this instruction executes. We declare the instruction.

這個指令不需要運算元。我們知道,在該指令執行時,變數一定在棧頂。我們來宣告這條指令。

chunk.h,在列舉OpCode中新增程式碼:

  OP_CLOSURE,
  // 新增部分開始
  OP_CLOSE_UPVALUE,
  // 新增部分結束
  OP_RETURN,

And add trivial disassembler support for it:

併為它新增簡單的反彙編支援:

debug.c,在disassembleInstruction()方法中新增程式碼:

    }
    // 新增部分開始
    case OP_CLOSE_UPVALUE:
      return simpleInstruction("OP_CLOSE_UPVALUE", offset);
    // 新增部分結束
    case OP_RETURN:

Excellent. Now the generated bytecode tells the runtime exactly when each captured local variable must move to the heap. Better, it does so only for the locals that are used by a closure and need this special treatment. This aligns with our general performance goal that we want users to pay only for functionality that they use. Variables that aren’t used by closures live and die entirely on the stack just as they did before.

太好了。現在,生成的位元組碼準確地告訴執行時,每個被捕獲的區域性變數必須移動到堆中的確切時間。更好的是,它只對被閉包使用並需要這種特殊處理的區域性變數才會這樣做。這與我們的總體效能目標是一致的,即我們希望使用者只為他們使用的功能付費。那些不被閉包使用的變數只會出現於棧中,就像以前一樣。

25 . 4 . 3 Tracking open upvalues

25.4.3 跟蹤開放的上值

Let’s move over to the runtime side. Before we can interpret OP_CLOSE_UPVALUE instructions, we have an issue to resolve. Earlier, when I talked about whether closures capture variables or values, I said it was important that if multiple closures access the same variable that they end up with a reference to the exact same storage location in memory. That way if one closure writes to the variable, the other closure sees the change.

讓我們轉到執行時方面。在解釋OP_CLOSE_UPVALUE指令之前,我們還有一個問題需要解決。之前,在談到閉包捕獲的是變數還是值時,我說過,如果多個閉包訪問同一個變數,它們最終將引用記憶體中完全相同的儲存位置,這一點很重要。這樣一來,如果某個閉包對變數進行寫入,另一個閉包就會看到這一變化。

Right now, if two closures capture the same local variable, the VM creates a separate Upvalue for each one. The necessary sharing is missing. When we move the variable off the stack, if we move it into only one of the upvalues, the other upvalue will have an orphaned value.

現在,如果兩個閉包捕獲同一個區域性變數,虛擬機器就會為每個閉包建立一個單獨的Upvalue。必要的共享是缺失的18。當我們把變數移出堆疊時,如果我們只是將它移入其中一個上值中,其它上值就會有一個孤兒值。

To fix that, whenever the VM needs an upvalue that captures a particular local variable slot, we will first search for an existing upvalue pointing to that slot. If found, we reuse that. The challenge is that all of the previously created upvalues are squirreled away inside the upvalue arrays of the various closures. Those closures could be anywhere in the VM’s memory.

為瞭解決這個問題,每當虛擬機器需要一個捕獲特定區域性變數槽的上值時,我們會首先搜尋指向該槽的現有上值。如果找到了,我們就重用它。難點在於,之前建立的所有上值都儲存在各個閉包的上值陣列中。這些閉包可能位於虛擬機器記憶體中的任何位置。

The first step is to give the VM its own list of all open upvalues that point to variables still on the stack. Searching a list each time the VM needs an upvalue sounds like it might be slow, but in practice, it’s not bad. The number of variables on the stack that actually get closed over tends to be small. And function declarations that create closures are rarely on performance critical execution paths in the user’s program.

第一步是給虛擬機器提供它自己的所有開放上值的列表,這些上值指向仍在棧中的變數。每次虛擬機器需要一個上值時,都要搜尋列表,這聽起來似乎很慢,但是實際上,這並沒有那麼壞。棧中真正被關閉的變數的數量往往很少。而且建立閉包的函式宣告很少出現在使用者程式中的效能關鍵執行路徑上19

Even better, we can order the list of open upvalues by the stack slot index they point to. The common case is that a slot has not already been captured—sharing variables between closures is uncommon—and closures tend to capture locals near the top of the stack. If we store the open upvalue array in stack slot order, as soon as we step past the slot where the local we’re capturing lives, we know it won’t be found. When that local is near the top of the stack, we can exit the loop pretty early.

更妙的是,我們可以根據開放上值所指向的棧槽索引對列表進行排序。常見的情況是,某個棧槽還沒有被捕獲(在閉包之間共享變數是不常見的),而閉包傾向於捕獲靠近棧頂的區域性變數。如果我們按照棧槽的順序儲存開放上值陣列,一旦我們越過正在捕獲的區域性變數所在的槽,我們就知道它不會被找到。當這個區域性變數在棧頂時,我們可以很早就退出迴圈。

Maintaining a sorted list requires inserting elements in the middle efficiently. That suggests using a linked list instead of a dynamic array. Since we defined the ObjUpvalue struct ourselves, the easiest implementation is an intrusive list that puts the next pointer right inside the ObjUpvalue struct itself.

維護有序列表需要能高效地在中間插入元素。這一點建議我們使用連結串列而不是動態陣列。因為我們自己定義了ObjUpvalue結構體,最簡單的實現是一個插入式列表,將指向下一元素的指標放在ObjUpvalue結構體本身中。

object.h,在結構體ObjUpvalue中新增程式碼:

  Value* location;
  // 新增部分開始
  struct ObjUpvalue* next;
  // 新增部分結束
} ObjUpvalue;

When we allocate an upvalue, it is not attached to any list yet so the link is NULL.

當我們分配一個上值時,它還沒有附加到任何列表,因此連結是NULL

object.c,在newUpvalue()方法中新增程式碼:

  upvalue->location = slot;
  // 新增部分開始
  upvalue->next = NULL;
  // 新增部分結束
  return upvalue;

The VM owns the list, so the head pointer goes right inside the main VM struct.

VM擁有該列表,因此頭指標放在VM主結構體中。

vm.h,在結構體VM中新增程式碼:

  Table strings;
  // 新增部分開始
  ObjUpvalue* openUpvalues;
  // 新增部分結束
  Obj* objects;

The list starts out empty.

列表在開始時為空。

vm.c,在resetStack()方法中新增程式碼:

  vm.frameCount = 0;
  // 新增部分開始
  vm.openUpvalues = NULL;
  // 新增部分結束
}

Starting with the first upvalue pointed to by the VM, each open upvalue points to the next open upvalue that references a local variable farther down the stack. This script, for example,

從VM指向的第一個上值開始,每個開放上值都指向下一個引用了棧中靠下位置的區域性變數的開放上值。以這個指令碼為例

{
  var a = 1;
  fun f() {
    print a;
  }
  var b = 2;
  fun g() {
    print b;
  }
  var c = 3;
  fun h() {
    print c;
  }
}

should produce a series of linked upvalues like so:

它應該產生如下所示的一系列連結的上值:

Three upvalues in a linked list.

Whenever we close over a local variable, before creating a new upvalue, we look for an existing one in the list.

每當關閉一個區域性變數時,在建立新的上值之前,先在該列表中查詢現有的上值。

vm.c,在captureUpvalue()方法中新增程式碼:

static ObjUpvalue* captureUpvalue(Value* local) {
  // 新增部分開始
  ObjUpvalue* prevUpvalue = NULL;
  ObjUpvalue* upvalue = vm.openUpvalues;
  while (upvalue != NULL && upvalue->location > local) {
    prevUpvalue = upvalue;
    upvalue = upvalue->next;
  }

  if (upvalue != NULL && upvalue->location == local) {
    return upvalue;
  }
  // 新增部分結束
  ObjUpvalue* createdUpvalue = newUpvalue(local);

We start at the head of the list, which is the upvalue closest to the top of the stack. We walk through the list, using a little pointer comparison to iterate past every upvalue pointing to slots above the one we’re looking for. While we do that, we keep track of the preceding upvalue on the list. We’ll need to update that node’s next pointer if we end up inserting a node after it.

我們從列表的頭部開始,它是最接近棧頂的上值。我們遍歷列表,使用一個小小的指標比較,對每一個指向的槽位高於當前查詢的位置的上值進行迭代20。當我們這樣做時,我們要跟蹤列表中前面的上值。如果我們在某個節點後面插入了一個節點,就需要更新該節點的next指標。

There are three reasons we can exit the loop:

我們有三個原因可以退出迴圈:

  1. The local slot we stopped at *is* the slot we’re looking for. We found an existing upvalue capturing the variable, so we reuse that upvalue.

    我們停止時的區域性變數槽是我們要找的槽。我在找到了一個現有的上值捕獲了這個變數,因此我們重用這個上值。

  2. We ran out of upvalues to search. When upvalue is NULL, it means every open upvalue in the list points to locals above the slot we’re looking for, or (more likely) the upvalue list is empty. Either way, we didn’t find an upvalue for our slot.

    我們找不到需要搜尋的上值了。當upvalueNULL時,這意味著列表中每個開放上值都指向位於我們要找的槽之上的區域性變數,或者(更可能是)上值列表是空的。無論怎樣,我們都沒有找到對應該槽的上值。

  3. We found an upvalue whose local slot is *below* the one we’re looking for. Since the list is sorted, that means we’ve gone past the slot we are closing over, and thus there must not be an existing upvalue for it.

    我們找到了一個上值,其區域性變數槽低於我們正查詢的槽位。因為列表是有序的,這意味著我們已經超過了正在關閉的槽,因此肯定沒有對應該槽的已有上值。

In the first case, we’re done and we’ve returned. Otherwise, we create a new upvalue for our local slot and insert it into the list at the right location.

在第一種情況下,我們已經完成並且返回了。其它情況下,我們為區域性變數槽建立一個新的上值,並將其插入到列表中的正確位置。

vm.c,在captureUpvalue()方法中新增程式碼:

  ObjUpvalue* createdUpvalue = newUpvalue(local);
  // 新增部分開始
  createdUpvalue->next = upvalue;

  if (prevUpvalue == NULL) {
    vm.openUpvalues = createdUpvalue;
  } else {
    prevUpvalue->next = createdUpvalue;
  }
  // 新增部分結束
  return createdUpvalue;

The current incarnation of this function already creates the upvalue, so we only need to add code to insert the upvalue into the list. We exited the list traversal by either going past the end of the list, or by stopping on the first upvalue whose stack slot is below the one we’re looking for. In either case, that means we need to insert the new upvalue before the object pointed at by upvalue (which may be NULL if we hit the end of the list).

這個函式的當前版本已經建立了上值,我們只需要新增程式碼將上值插入到列表中。我們退出列表遍歷的原因,要麼是到達了列表末尾,要麼是停在了第一個棧槽低於待查詢槽位的上值。無論哪種情況,這都意味著我們需要在upvalue指向的物件(如果到達列表的末尾,則該物件可能是NULL)之前插入新的上值。

As you may have learned in Data Structures 101, to insert a node into a linked list, you set the next pointer of the previous node to point to your new one. We have been conveniently keeping track of that preceding node as we walked the list. We also need to handle the special case where we are inserting a new upvalue at the head of the list, in which case the “next” pointer is the VM’s head pointer.

正如你在《資料結構101》中所學到的,要將一個節點插入到連結串列中,你需要將前一個節點的next指標指向新的節點。當我們遍歷列表時,我們一直很方便地跟蹤著前面的節點。我們還需要處理一種特殊情況,即我們在列表頭部插入一個新的上值,在這種情況下,“next”指標是VM的頭指標21

With this updated function, the VM now ensures that there is only ever a single ObjUpvalue for any given local slot. If two closures capture the same variable, they will get the same upvalue. We’re ready to move those upvalues off the stack now.

有了這個升級版函式,VM現在可以確保每個指定的區域性變數槽都只有一個ObjUpvalue。如果兩個閉包捕獲了相同的變數,它們會得到相同的上值。現在,我們準備將這些上值從棧中移出。

25 . 4 . 4 Closing upvalues at runtime

25.4.4 在執行時關閉上值

The compiler helpfully emits an OP_CLOSE_UPVALUE instruction to tell the VM exactly when a local variable should be hoisted onto the heap. Executing that instruction is the interpreter’s responsibility.

編譯器會生成一個有用的OP_CLOSE_UPVALUE指令,以準確地告知VM何時將區域性變數提取到堆中。執行該指令是直譯器的責任。

vm.c,在run()方法中新增程式碼:

      }
      // 新增部分開始
      case OP_CLOSE_UPVALUE:
        closeUpvalues(vm.stackTop - 1);
        pop();
        break;
      // 新增部分結束  
      case OP_RETURN: {

When we reach the instruction, the variable we are hoisting is right on top of the stack. We call a helper function, passing the address of that stack slot. That function is responsible for closing the upvalue and moving the local from the stack to the heap. After that, the VM is free to discard the stack slot, which it does by calling pop().

當我們到達該指令時,我們要提取的變數就在棧頂。我們呼叫一個輔助函式,傳入棧槽的地址。該函式負責關閉上值,並將區域性變數從棧中移動到堆上。之後,VM就可以自由地丟棄棧槽,這是透過呼叫pop()實現的。

The fun stuff happens here:

有趣的事情發生在這裡:

vm.c,在captureUpvalue()方法後新增程式碼:

static void closeUpvalues(Value* last) {
  while (vm.openUpvalues != NULL &&
         vm.openUpvalues->location >= last) {
    ObjUpvalue* upvalue = vm.openUpvalues;
    upvalue->closed = *upvalue->location;
    upvalue->location = &upvalue->closed;
    vm.openUpvalues = upvalue->next;
  }
}

This function takes a pointer to a stack slot. It closes every open upvalue it can find that points to that slot or any slot above it on the stack. Right now, we pass a pointer only to the top slot on the stack, so the “or above it” part doesn’t come into play, but it will soon.

這個函式接受一個指向棧槽的指標。它會關閉它能找到的指向該槽或棧上任何位於該槽上方的所有開放上值。現在,我們只傳遞了一個指向棧頂的指標,所以“或其上方”的部分沒有發揮作用,但它很快就會起作用了。

To do this, we walk the VM’s list of open upvalues, again from top to bottom. If an upvalue’s location points into the range of slots we’re closing, we close the upvalue. Otherwise, once we reach an upvalue outside of the range, we know the rest will be too, so we stop iterating.

為此,我們再次從上到下遍歷VM的開放上值列表。如果某個上值的位置指向我們要關閉的槽位範圍,則關閉該上值。否則,一旦我們遇到範圍之外的上值,我們知道其它上值也在範圍之外,所以我們停止迭代。

The way an upvalue gets closed is pretty cool. First, we copy the variable’s value into the closed field in the ObjUpvalue. That’s where closed-over variables live on the heap. The OP_GET_UPVALUE and OP_SET_UPVALUE instructions need to look for the variable there after it’s been moved. We could add some conditional logic in the interpreter code for those instructions to check some flag for whether the upvalue is open or closed.

關閉上值的方式非常酷22。首先,我們將變數的值複製到ObjUpvalue的closed欄位。這就是被關閉的變數在堆中的位置。在變數被移動之後,OP_GET_UPVALUEOP_SET_UPVALUE指令需要在那裡查詢它。我們可以在直譯器程式碼中為這些指令新增一些條件邏輯,檢查一些標誌,以確定上值是開放的還是關閉的。

But there is already a level of indirection in play—those instructions dereference the location pointer to get to the variable’s value. When the variable moves from the stack to the closed field, we simply update that location to the address of the ObjUpvalue’s own closed field.

但是已經有一箇中間層在起作用了——這些指令對location指標解引用以獲取變數的值。當變數從棧移動到closed欄位時,我們只需將location更新為ObjUpvalue自己的closed欄位。

Moving a value from the stack to the upvalue's 'closed' field and then pointing the 'value' field to it.

We don’t need to change how OP_GET_UPVALUE and OP_SET_UPVALUE are interpreted at all. That keeps them simple, which in turn keeps them fast. We do need to add the new field to ObjUpvalue, though.

我們根本不需要改變OP_GET_UPVALUEOP_SET_UPVALUE的解釋方式。這使得它們保持簡單,反過來又使它們保持快速。不過,我們確實需要向ObjUpvalue新增新的欄位。

object.h,在結構體ObjUpvalue中新增程式碼:

  Value* location;
  // 新增部分開始
  Value closed;
  // 新增部分結束
  struct ObjUpvalue* next;

And we should zero it out when we create an ObjUpvalue so there’s no uninitialized memory floating around.

當我們建立一個ObjUpvalue時,應該將其置為0,這樣就不會有未初始化的記憶體了。

object.c,在newUpvalue()方法中新增程式碼:

  ObjUpvalue* upvalue = ALLOCATE_OBJ(ObjUpvalue, OBJ_UPVALUE);
  // 新增部分開始
  upvalue->closed = NIL_VAL;
  // 新增部分結束
  upvalue->location = slot;

Whenever the compiler reaches the end of a block, it discards all local variables in that block and emits an OP_CLOSE_UPVALUE for each local variable that was closed over. The compiler does not emit any instructions at the end of the outermost block scope that defines a function body. That scope contains the function’s parameters and any locals declared immediately inside the function. Those need to get closed too.

每當編譯器到達一個塊的末尾時,它就會丟棄該程式碼塊中的所有區域性變數,併為每個關閉的區域性變數生成一個OP_CLOSE_UPVALUE指令。編譯器不會在定義某個函式主體的最外層塊作用域的末尾生成任何指令23。這個作用域包含函式的形參和函式內部宣告的任何區域性變數。這些也需要被關閉。

This is the reason closeUpvalues() accepts a pointer to a stack slot. When a function returns, we call that same helper and pass in the first stack slot owned by the function.

這就是closeUpvalues()接受一個指向棧槽的指標的原因。當函式返回時,我們呼叫相同的輔助函式,並傳入函式擁有的第一個棧槽。

vm.c,在run()方法中新增程式碼:

        Value result = pop();
        // 新增部分開始
        closeUpvalues(frame->slots);
        // 新增部分結束
        vm.frameCount--;

By passing the first slot in the function’s stack window, we close every remaining open upvalue owned by the returning function. And with that, we now have a fully functioning closure implementation. Closed-over variables live as long as they are needed by the functions that capture them.

透過傳遞函式棧視窗中的第一個槽,我們關閉了正在返回的函式所擁有的所有剩餘的開放上值。有了這些,我們現在就有了一個功能齊全的閉包實現。只要捕獲變數的函式需要,被關閉的變數就一直存在。

This was a lot of work! In jlox, closures fell out naturally from our environment representation. In clox, we had to add a lot of code—new bytecode instructions, more data structures in the compiler, and new runtime objects. The VM very much treats variables in closures as different from other variables.

這是一項艱鉅的工作!在jlox中,閉包很自然地從我們的環境表示形式中分離出來。在clox中,我們必須新增大量的程式碼——新的位元組碼指令、編譯器中的更多資料結構和新的執行時物件。VM在很大程度上將閉包中的變數與其它變數進行區別對待。

There is a rationale for that. In terms of implementation complexity, jlox gave us closures “for free”. But in terms of performance, jlox’s closures are anything but. By allocating all environments on the heap, jlox pays a significant performance price for all local variables, even the majority which are never captured by closures.

這是有道理的。就實現複雜性而言,jlox“免費”為我們提供了閉包。但是就效能而言,jlox的閉包完全不是這樣。由於在堆上分配所有環境,jlox為所有區域性變數付出了顯著的效能代價,甚至是未被閉包捕獲的大部分變數。

With clox, we have a more complex system, but that allows us to tailor the implementation to fit the two use patterns we observe for local variables. For most variables which do have stack semantics, we allocate them entirely on the stack which is simple and fast. Then, for the few local variables where that doesn’t work, we have a second slower path we can opt in to as needed.

在clox中,我們有一個更復雜的系統,但這允許我們對實現進行調整以適應我們觀察到的區域性變數的兩種使用模式。對於大多數具有堆疊語義的變數,我們完全可用在棧中分配,這既簡單又快速。然後,對於少數不適用的區域性變數,我們可以根據需要選擇第二條較慢的路徑。

Fortunately, users don’t perceive the complexity. From their perspective, local variables in Lox are simple and uniform. The language itself is as simple as jlox’s implementation. But under the hood, clox is watching what the user does and optimizing for their specific uses. As your language implementations grow in sophistication, you’ll find yourself doing this more. A large fraction of “optimization” is about adding special case code that detects certain uses and provides a custom-built, faster path for code that fits that pattern.

幸運的是,使用者並不會察覺到這種複雜性。在他們看來,Lox中的區域性變數簡單而統一。語言本身就像jlox一樣簡單。但在內部,clox會觀察使用者的行為,並針對他們的具體用途進行最佳化。隨著你的語言實現越來越複雜,你會發現自己要做的事情越來越多。“最佳化”的很大一部分是關於新增特殊情況的程式碼,以檢測特定的使用,併為符合該模式的程式碼提供定製化的、更快速的路徑。

We have lexical scoping fully working in clox now, which is a major milestone. And, now that we have functions and variables with complex lifetimes, we also have a lot of objects floating around in clox’s heap, with a web of pointers stringing them together. The next step is figuring out how to manage that memory so that we can free some of those objects when they’re no longer needed.

我們現在已經在clox中完全實現了詞法作用域,這是一個重要的里程碑。而且,現在我們有了具有複雜生命週期的函式和變數,我們也要了很多漂浮在clox堆中的物件,並有一個指標網路將它們串聯起來。下一步是弄清楚如何管理這些記憶體,以便我們可以在不再需要這些物件的時候釋放它們。


習題

  1. Wrapping every ObjFunction in an ObjClosure introduces a level of indirection that has a performance cost. That cost isn’t necessary for functions that do not close over any variables, but it does let the runtime treat all calls uniformly.

    Change clox to only wrap functions in ObjClosures that need upvalues. How does the code complexity and performance compare to always wrapping functions? Take care to benchmark programs that do and do not use closures. How should you weight the importance of each benchmark? If one gets slower and one faster, how do you decide what trade-off to make to choose an implementation strategy?

    將每個ObjFunction 包裝在ObjClosure中,會引入一個有效能代價的中間層。這個代價對於那些沒有關閉任何變數的函式來說是不必要的,但它確實讓執行時能夠統一處理所有的呼叫。

    將clox改為只用ObjClosure包裝需要上值的函式。與包裝所有函式相比,程式碼的複雜性與效能如何?請注意對使用閉包和不使用閉包的程式進行基準測試。你應該如何衡量每個基準的重要性?如果一個變慢了,另一個變快了,你決定透過什麼權衡來選擇實現策略?

  2. Read the design note below. I’ll wait. Now, how do you think Lox should behave? Change the implementation to create a new variable for each loop iteration.

    請閱讀下面的設計筆記。我在這裡等著。現在,你覺得Lox應該怎麼做?改變實現方式,為每個迴圈迭代建立一個新的變數。

  3. A famous koan teaches us that “objects are a poor man’s closure” (and vice versa). Our VM doesn’t support objects yet, but now that we have closures we can approximate them. Using closures, write a Lox program that models two-dimensional vector “objects”. It should:

    • Define a “constructor” function to create a new vector with the given x and y coordinates.
    • Provide “methods” to access the x and y coordinates of values returned from that constructor.
    • Define an addition “method” that adds two vectors and produces a third.

    一個著名的公案告訴我們:“物件是簡化版的閉包”(反之亦然)。我們的虛擬機器還不支援物件,但現在我們有了閉包,我們可以近似地使用它們。使用閉包,編寫一個Lox程式,建模一個二維向量“物件”。它應該:

    • 定義一個“構造器”函式,建立一個具有給定x和y座標的新向量。
    • 提供“方法”來訪問建構函式返回值的x和y座標。
    • 定義一個相加“方法”,將兩個向量相加併產生第三個向量。

設計筆記:關閉迴圈變數

Closures capture variables. When two closures capture the same variable, they share a reference to the same underlying storage location. This fact is visible when new values are assigned to the variable. Obviously, if two closures capture different variables, there is no sharing.

閉包捕獲變數。當兩個閉包捕獲相同的變數時,它們共享對相同的底層儲存位置的引用。當將新值賦給該變數時,這一事實是可見的。顯然,如果兩個閉包捕獲不同的變數,就不存在共享。

var globalOne;
var globalTwo;

fun main() {
  {
    var a = "one";
    fun one() {
      print a;
    }
    globalOne = one;
  }

  {
    var a = "two";
    fun two() {
      print a;
    }
    globalTwo = two;
  }
}

main();
globalOne();
globalTwo();

This prints “one” then “two”. In this example, it’s pretty clear that the two a variables are different. But it’s not always so obvious. Consider:

這裡會列印“one”然後是“two”。在這個例子中,很明顯兩個a變數是不同的。但一點這並不總是那麼明顯。考慮一下:

var globalOne;
var globalTwo;

fun main() {
  for (var a = 1; a <= 2; a = a + 1) {
    fun closure() {
      print a;
    }
    if (globalOne == nil) {
      globalOne = closure;
    } else {
      globalTwo = closure;
    }
  }
}

main();
globalOne();
globalTwo();

The code is convoluted because Lox has no collection types. The important part is that the main() function does two iterations of a for loop. Each time through the loop, it creates a closure that captures the loop variable. It stores the first closure in globalOne and the second in globalTwo.

這段程式碼很複雜,因為Lox沒有集合型別。重要的部分是,main()函式進行了for迴圈的兩次迭代。每次迴圈執行時,它都會建立一個捕獲迴圈變數的閉包。它將第一個閉包儲存在globalOne中,並將第二個閉包儲存在globalTwo中。

There are definitely two different closures. Do they close over two different variables? Is there only one a for the entire duration of the loop, or does each iteration get its own distinct a variable?

這無疑是兩個不同的閉包。它們是在兩個不同的變數上閉合的嗎?在整個迴圈過程中只有一個a,還是每個迭代都有自己單獨的a變數?

The script here is strange and contrived, but this does show up in real code in languages that aren’t as minimal as clox. Here’s a JavaScript example:

這裡的指令碼很奇怪,而且是人為設計的,但它確實出現在實際的程式碼中,而且這些程式碼使用的語言並不是像clox這樣的小語言。下面是一個JavaScript的示例:

var closures = [];
for (var i = 1; i <= 2; i++) {
  closures.push(function () { console.log(i); });
}

closures[0]();
closures[1]();

Does this print “1” then “2”, or does it print “3” twice? You may be surprised to hear that it prints “3” twice. In this JavaScript program, there is only a single i variable whose lifetime includes all iterations of the loop, including the final exit.

這裡會列印“1”再列印“2”,還是列印兩次“3”?你可能會驚訝地發現,它列印了兩次“3”24。在這個JavaScript程式中,只有一個i變數,它的生命週期包括迴圈的所有迭代,包括最後的退出。

If you’re familiar with JavaScript, you probably know that variables declared using var are implicitly hoisted to the surrounding function or top-level scope. It’s as if you really wrote this:

如果你熟悉JavaScript,你可能知道,使用var宣告的變數會隱式地被提取到外圍函式或頂層作用域中。這就好像你是這樣寫的:

var closures = [];
var i;
for (i = 1; i <= 2; i++) {
  closures.push(function () { console.log(i); });
}

closures[0]();
closures[1]();

At that point, it’s clearer that there is only a single i. Now consider if you change the program to use the newer let keyword:

此時,很明顯只有一個i。現在考慮一下,如果你將程式改為使用更新的let關鍵字:

var closures = [];
for (let i = 1; i <= 2; i++) {
  closures.push(function () { console.log(i); });
}

closures[0]();
closures[1]();

Does this new program behave the same? Nope. In this case, it prints “1” then “2”. Each closure gets its own i. That’s sort of strange when you think about it. The increment clause is i++. That looks very much like it is assigning to and mutating an existing variable, not creating a new one.

這個新程式的行為是一樣的嗎?不是。在本例中,它會列印“1”然後列印“2”。每個閉包都有自己的i。仔細想想會覺得有點奇怪,增量子句是i++,這看起來很像是對現有變數進行賦值和修改,而不是建立一個新變數。

Let’s try some other languages. Here’s Python:

讓我們試試其它語言。下面是Python:

closures = []
for i in range(1, 3):
  closures.append(lambda: print(i))

closures[0]()
closures[1]()

Python doesn’t really have block scope. Variables are implicitly declared and are automatically scoped to the surrounding function. Kind of like hoisting in JS, now that I think about it. So both closures capture the same variable. Unlike C, though, we don’t exit the loop by incrementing i past the last value, so this prints “2” twice.

Python並沒有真正的塊作用域。變數是隱式宣告的,並自動限定在外圍函式的作用域中。現在我想起來,這有點像JS中的“懸掛”。所以兩個閉包都捕獲了同一個變數。但與C不同的是,我們不會透過增加i超過最後一個值來退出迴圈,所以這裡會列印兩次“2”。

What about Ruby? Ruby has two typical ways to iterate numerically. Here’s the classic imperative style:

那Ruby呢?Ruby有兩種典型的數值迭代方式。下面是典型的命令式風格:

closures = []
for i in 1..2 do
  closures << lambda { puts i }
end

closures[0].call
closures[1].call

This, like Python, prints “2” twice. But the more idiomatic Ruby style is using a higher-order each() method on range objects:

這有點像是Python,會列印兩次“2”。但是更慣用的Ruby風格是在範圍物件上使用高階的each()方法:

closures = []
(1..2).each do |i|
  closures << lambda { puts i }
end

closures[0].call
closures[1].call

If you’re not familiar with Ruby, the do |i| ... end part is basically a closure that gets created and passed to the each() method. The |i| is the parameter signature for the closure. The each() method invokes that closure twice, passing in 1 for i the first time and 2 the second time.

如果你不熟悉Ruby,do |i| ... end部分基本上就是一個閉包,它被建立並傳遞給each()方法。|i|是閉包的引數簽名。each()方法兩次呼叫該閉包,第一次傳入1,第二次傳入2。

In this case, the “loop variable” is really a function parameter. And, since each iteration of the loop is a separate invocation of the function, those are definitely separate variables for each call. So this prints “1” then “2”.

在這種情況下,“迴圈變數”實際上是一個函式引數。而且,由於迴圈的每次迭代都是對函式的單獨呼叫,所以每次呼叫都是單獨的變數。因此,這裡先列印“1”然後列印“2”。

If a language has a higher-level iterator-based looping structure like foreach in C#, Java’s “enhanced for”, for-of in JavaScript, for-in in Dart, etc., then I think it’s natural to the reader to have each iteration create a new variable. The code looks like a new variable because the loop header looks like a variable declaration. And there’s no increment expression that looks like it’s mutating that variable to advance to the next step.

如果一門語言具有基於迭代器的高階迴圈結果,比如C#中的foreach,Java中的“增強型for迴圈”,JavaScript中的for-of,Dart中的for-in等等,那我認為讀者很自然地會讓每次迭代都建立一個新變數。程式碼看起來像一個新變數,是因為迴圈頭看起來像是一個變數宣告。看起來沒有任何增量表達式透過改變變數以推進到下一步。

If you dig around StackOverflow and other places, you find evidence that this is what users expect, because they are very surprised when they don’t get it. In particular, C# originally did not create a new loop variable for each iteration of a foreach loop. This was such a frequent source of user confusion that they took the very rare step of shipping a breaking change to the language. In C# 5, each iteration creates a fresh variable.

如果你在StackOverflow和其它地方挖掘一下,你會發現這正是使用者所期望的,因為當他們沒有看到這個結果時,他們會非常驚訝。特別是,C#最初並沒有為foreach迴圈的每次迭代建立一個新的迴圈變數。這一點經常引起使用者的困惑,所以他們採用了非常罕見的措施,對語言進行了突破性的修改。在C# 5中,每個迭代都會建立一個新的變數。

Old C-style for loops are harder. The increment clause really does look like mutation. That implies there is a single variable that’s getting updated each step. But it’s almost never useful for each iteration to share a loop variable. The only time you can even detect this is when closures capture it. And it’s rarely helpful to have a closure that references a variable whose value is whatever value caused you to exit the loop.

舊的C風格的for迴圈更難了。增量子句看起來像是修改。這意味著每一步更新的是同一個變數。但是每個迭代共享一個迴圈變數幾乎是沒有用的。只有在閉包捕獲它時,你才能檢測到這一現象。而且,如果閉包引用的變數的值是導致迴圈退出的值,那麼它也幾乎沒有幫助。

The pragmatically useful answer is probably to do what JavaScript does with let in for loops. Make it look like mutation but actually create a new variable each time, because that’s what users want. It is kind of weird when you think about it, though.

實用的答案可能是像JavaScript在for迴圈中的let那樣。讓它看起來像修改,但實際上每次都建立一個新變數,因為這是使用者想要的。不過,仔細想想,還是有點奇怪的。


  1. 畢竟,C和Java使用棧來儲存區域性變數是有原因的。

  2. 搜尋“閉包轉換 closure conversion”和“Lambda提升 lambda lifting”就可以開始探索了。

  3. 換句話說,Lox中的函式宣告是一種字面量——定義某個內建型別的常量值的一段語法。

  4. Lua實現中將包含位元組碼的原始函式物件稱為“原型”,這個一個很好的形容詞,只不過這個詞也被過載以指代原型繼承

  5. 或許我應該定義一個宏,以便更容易地生成這些宏。也許這有點太玄了。

  6. 這段程式碼看起來有點傻,因為我們仍然把原始的ObjFunction壓入棧中,然後在建立完閉包之後彈出它,然後再將閉包壓入棧。為什麼要把ObjFunction放在這裡呢?像往常一樣,當你看到奇怪的堆疊操作發生時,它是為了讓即將到來的垃圾回收器知道一些堆分配的物件。

  7. 它最終可能會是一個完全未定義的變數,甚至不是全域性變數。但是在Lox中,我們直到執行時才能檢測到這個錯誤,所以從編譯器的角度看,它是“期望是全域性的”。

  8. 就像常量和函式元數一樣,上值計數也是連線編譯器與執行時的一些小資料。

  9. 當然,另一種基本情況是,沒有外層函式。在這種情況下,該變數不能在詞法上解析,並被當作全域性變數處理。

  10. 每次遞迴呼叫resolveUpvalue()都會走出一層函式巢狀。因此,內部的遞迴呼叫指向的是外部的巢狀宣告。查詢區域性變數的最內層的resolveUpvalue()遞迴呼叫對應的將是最外層的函式,就是實際宣告該變數的外層函式的內部。

  11. 在閉包中儲存上值數量是多餘的,因為ObjClosure引用的ObjFunction也儲存了這個數量。通常,這類奇怪的程式碼是為了適應GC。在閉包對應的ObjFunction已經被釋放後,收集器可能也需要知道ObjClosure對應上值陣列的大小。

  12. 設定指令不會從棧中彈出值,因為,請記住,賦值在Lox中是一個表示式。所以賦值的結果(所賦的值)需要保留在棧中,供外圍的表示式使用。

  13. 如果Lox不允許賦值,這就是一個學術問題。

  14. 我使用了多個全域性變數的事實並不重要。我需要某種方式從一個函式中返回兩個值。而在Lox中沒有任何形式的聚合型別,我的選擇很有限。

  15. 這裡 的“之後”,指的是詞法或文字意義上的——在包含關閉變數的宣告語句的程式碼塊的}之後的程式碼。

  16. 編譯器不會彈出引數和在函式體中宣告的區域性變數。這些我們也會在執行時處理。

  17. 在本書的後面部分,使用者將有可能捕獲這個變數。這裡只是建立一些預期。

  18. 如果某個閉包從外圍函式中捕獲了一個上值,那麼虛擬機器確實會共享上值。巢狀的情況下,工作正常。但是如果兩個同級閉包捕獲了同一個區域性變數,它們會各自建立一個單獨的ObjUpvalue。

  19. 閉包經常在熱迴圈中被呼叫。想想傳遞給集合的典型高階函式,如map()filter()。這應該是很快的。但是建立閉包的函式宣告只發生一次,而且通常是在迴圈之外。

  20. 這是個單連結串列。除了從頭指標開始遍歷,我們沒有其它選擇。

  21. 還有一種更簡短的實現,透過使用一個指向指標的指標,來統一處理更新頭部指標或前一個上值的next指標兩種情況,但這種程式碼幾乎會讓所有未達到指標專業水平的人感到困惑。我選擇了基本的if語句的方法。

  22. 我並不是在自誇。這都是Lua開發團隊的創新。

  23. 沒有什麼阻止我們在編譯器中關閉最外層的函式作用域,並生成OP_POPOP_CLOSE_UPVALUE指令。這樣做只是沒有必要,因為執行時在彈出呼叫幀時,隱式地丟棄了函式使用的所有棧槽。

  24. 你想知道“3”是怎麼出現的嗎?在第二次迭代後,執行i++,它將i增加到3。這就是導致i<=2的值為false並結束迴圈的原因。如果i永遠達不到3,迴圈就會一直執行下去。