23

跳躍來回

我們腦海中想像的秩序就像一張網,或像一個梯子,為了達到某個目的而建造。但在那之後你必須把梯子丟掉,因為你會發現,即使它曾經有用,它也是沒有意義的。

翁貝托·艾可,《玫瑰之名》

花了一段時間才走到這裡,但我們終於準備好為我們的虛擬機器添加控制流程了。在我們為 jlox 建構的樹狀遍歷直譯器中,我們使用 Java 的方式來實作 Lox 的控制流程。為了執行 Lox 的 if 語句,我們使用 Java 的 if 語句來執行選擇的分支。這可行,但並不完全令人滿意。JVM 本身或原生 CPU 是如何以魔法實作 if 語句的?現在我們有了自己的位元組碼 VM 可以來 hack,我們可以回答這個問題了。

當我們談論「控制流程」時,我們指的是什麼?「流程」指的是執行在程式碼文本中移動的方式。幾乎就像電腦內部有一個小機器人,在我們的程式碼中遊蕩,這裡執行一點,那裡執行一點。流程是那個機器人採取的路徑,而透過控制機器人,我們驅動它執行哪些程式碼片段。

在 jlox 中,機器人的注意力焦點目前的程式碼片段是隱含地根據儲存在各種 Java 變數中的 AST 節點以及我們正在執行的 Java 程式碼來決定的。在 clox 中,它更加明確。VM 的 ip 欄位儲存目前位元組碼指令的位址。該欄位的值正好代表「我們在程式中的位置」。

執行通常透過遞增 ip 來進行。但是我們可以隨意變更該變數。為了實作控制流程,所有需要做的就是以更有趣的方式變更 ip。最簡單的控制流程結構是沒有 else 子句的 if 語句

if (condition) print("condition was truthy");

VM 會評估條件表達式的位元組碼。如果結果為真值,則它會繼續執行,並執行主體中的 print 語句。有趣的情況是當條件為假值時。當這種情況發生時,執行會跳過 then 分支,並繼續執行下一條語句。

為了跳過一段程式碼,我們只需將 ip 欄位設定為該程式碼之後的位元組碼指令的位址。為了有條件地跳過一些程式碼,我們需要一個指令來檢視堆疊頂端的值。如果它是假值,它會將一個給定的偏移量加到 ip,以跳過一段指令。否則,它什麼都不做,讓執行像往常一樣繼續執行下一個指令。

當我們編譯成位元組碼時,程式碼中明確的巢狀區塊結構會消失,只留下一系列扁平的指令。Lox 是一種結構化程式設計語言,但 clox 位元組碼不是。正確的或錯誤的,取決於你如何看待它位元組碼指令集可能會跳到區塊的中間,或從一個作用域跳到另一個作用域。

VM 會很樂意執行它,即使結果讓堆疊處於未知、不一致的狀態。因此,即使位元組碼是非結構化的,我們也會小心確保我們的編譯器只生成乾淨的程式碼,以維持 Lox 本身的結構和巢狀。

這正是真實 CPU 的行為方式。即使我們可能會使用強制結構化控制流程的高階語言來編寫它們的程式,編譯器也會將其降級為原始跳轉。在底層,結果證明 goto 是唯一真正的控制流程。

總之,我並不是要變得太過哲學。重要的是,如果我們有一個條件跳轉指令,就足以實作 Lox 的 if 語句,只要它沒有 else 子句。因此,讓我們繼續開始吧。

23.1If 語句

經過這麼多章節,你應該知道流程了。任何新功能都從前端開始,並一路通過整個管道。一個 if 語句,嗯,它是一個語句,所以這就是我們將它掛鉤到剖析器的地方。

  if (match(TOKEN_PRINT)) {
    printStatement();
compiler.c
statement() 中
  } else if (match(TOKEN_IF)) {
    ifStatement();
  } else if (match(TOKEN_LEFT_BRACE)) {
compiler.c,在 statement() 中

當我們看到一個 if 關鍵字時,我們將編譯工作轉交給這個函式

compiler.c
expressionStatement() 之後添加
static void ifStatement() {
  consume(TOKEN_LEFT_PAREN, "Expect '(' after 'if'.");
  expression();
  consume(TOKEN_RIGHT_PAREN, "Expect ')' after condition."); 

  int thenJump = emitJump(OP_JUMP_IF_FALSE);
  statement();

  patchJump(thenJump);
}
compiler.c,在 expressionStatement() 之後添加

首先,我們編譯以括號括住的條件表達式。在執行時,這會將條件值留在堆疊的頂端。我們會使用它來決定是否執行 then 分支或跳過它。

然後我們發出一個新的 OP_JUMP_IF_FALSE 指令。它有一個運算元,表示要偏移 ip 多少要跳過多少個程式碼位元組。如果條件為假值,它會將 ip 調整那個量。類似這樣

Flowchart of the compiled bytecode of an if statement.

但我們有一個問題。當我們正在編寫 OP_JUMP_IF_FALSE 指令的運算元時,我們如何知道要跳多遠?我們尚未編譯 then 分支,因此我們不知道它包含多少位元組碼。

為了修正這個問題,我們使用一個稱為回溯修補的經典技巧。我們首先發出跳轉指令,並使用一個佔位符偏移量運算元。我們追蹤那個半成品指令的位置。接下來,我們編譯 then 主體。完成後,我們就知道要跳多遠了。因此,我們回頭將該佔位符偏移量替換為現在可以計算的實際偏移量。有點像在編譯程式碼的現有結構上縫上一個補丁。

A patch containing a number being sewn onto a sheet of bytecode.

我們將這個技巧編碼為兩個輔助函式。

compiler.c
emitBytes() 之後添加
static int emitJump(uint8_t instruction) {
  emitByte(instruction);
  emitByte(0xff);
  emitByte(0xff);
  return currentChunk()->count - 2;
}
compiler.c,在 emitBytes() 之後添加

第一個會發出一個位元組碼指令,並為跳轉偏移量寫入一個佔位符運算元。我們將操作碼作為引數傳遞,因為稍後我們會有兩個不同的指令使用這個輔助函式。我們使用兩個位元組來表示跳轉偏移量運算元。一個 16 位元的 offset 允許我們跳過最多 65,535 個位元組的程式碼,這對我們來說應該足夠了。

該函式會傳回發出指令在區塊中的偏移量。編譯 then 分支之後,我們會取得該偏移量,並將其傳遞給這個

compiler.c
emitConstant() 之後添加
static void patchJump(int offset) {
  // -2 to adjust for the bytecode for the jump offset itself.
  int jump = currentChunk()->count - offset - 2;

  if (jump > UINT16_MAX) {
    error("Too much code to jump over.");
  }

  currentChunk()->code[offset] = (jump >> 8) & 0xff;
  currentChunk()->code[offset + 1] = jump & 0xff;
}
compiler.c,在 emitConstant() 之後添加

這個函式會回到位元組碼,並將給定位置的運算元替換為計算的跳轉偏移量。我們在發出我們希望跳轉到的下一個指令之前立即呼叫 patchJump(),因此它會使用目前的位元組碼計數來決定要跳多遠。就 if 語句而言,這表示在我們編譯 then 分支之後,且在我們編譯下一條語句之前。

這就是我們在編譯時所需的一切。讓我們定義新的指令。

  OP_PRINT,
chunk.h
在 enum OpCode
  OP_JUMP_IF_FALSE,
  OP_RETURN,
chunk.h,在 enum OpCode

在 VM 中,我們讓它像這樣工作

        break;
      }
vm.c
run() 中
      case OP_JUMP_IF_FALSE: {
        uint16_t offset = READ_SHORT();
        if (isFalsey(peek(0))) vm.ip += offset;
        break;
      }
      case OP_RETURN: {
vm.c,在 run() 中

這是我們新增的第一個需要 16 位元運算元的指令。為了從區塊中讀取它,我們使用一個新的巨集。

#define READ_CONSTANT() (vm.chunk->constants.values[READ_BYTE()])
vm.c
run() 中
#define READ_SHORT() \
    (vm.ip += 2, (uint16_t)((vm.ip[-2] << 8) | vm.ip[-1]))
#define READ_STRING() AS_STRING(READ_CONSTANT())
vm.c,在 run() 中

它會從區塊中取出下兩個位元組,並用它們建構一個 16 位元的無號整數。像往常一樣,當我們完成時,我們會清除我們的巨集。

#undef READ_BYTE
vm.c
run() 中
#undef READ_SHORT
#undef READ_CONSTANT
vm.c,在 run() 中

讀取偏移量之後,我們檢查堆疊頂端的條件值。 如果它是假值,我們將此跳轉偏移量套用至 ip。否則,我們讓 ip 保持不動,執行會自動繼續執行跳轉指令後的下一個指令。

在條件為假值的情況下,我們不需要做任何其他工作。我們已經偏移了 ip,因此當外部指令分派迴圈再次轉動時,它將會在新的指令處開始執行,越過 then 分支中的所有程式碼。

請注意,跳轉指令不會將條件值從堆疊中彈出。因此,我們並沒有完全完成這裡,因為這會在堆疊上留下一個額外的值。我們很快就會清除它。暫時忽略這一點,我們現在確實有一個在 Lox 中運作的 if 語句,只需要一個小指令即可在 VM 中執行時支援它。

23.1.1Else 子句

一個不支援 else 子句的 if 語句就像沒有高魔子的魔蒂西亞·阿達姆斯。因此,在我們編譯 then 分支之後,我們會尋找一個 else 關鍵字。如果我們找到一個,我們會編譯 else 分支。

  patchJump(thenJump);
compiler.c
ifStatement() 中
  if (match(TOKEN_ELSE)) statement();
}
compiler.c,在 ifStatement() 中

當條件為假值時,我們會跳過 then 分支。如果有一個 else 分支,ip 將會落在它的程式碼的開頭。但是,這還不夠。以下是導致以下結果的流程

Flowchart of the compiled bytecode with the then branch incorrectly falling through to the else branch.

如果條件為真值,我們會像我們想要的那樣執行 then 分支。但是在那之後,執行會直接滾動到 else 分支中。糟糕!當條件為真時,在我們執行 then 分支後,我們需要跳過 else 分支。這樣,無論在哪種情況下,我們都只執行單一分支,就像這樣

Flowchart of the compiled bytecode for an if with an else clause.

為了實作這一點,我們需要另一個從 then 分支末尾開始的跳轉。

  statement();

compiler.c
ifStatement() 中
  int elseJump = emitJump(OP_JUMP);

  patchJump(thenJump);
compiler.c,在 ifStatement() 中

我們在 else 主體的末尾修補該偏移量。

  if (match(TOKEN_ELSE)) statement();
compiler.c
ifStatement() 中
  patchJump(elseJump);
}
compiler.c,在 ifStatement() 中

在執行 then 分支之後,這個跳轉會跳到 else 分支後的下一個語句。與另一個跳轉不同,這個跳轉是無條件的。我們總是採取它,因此我們需要另一個表達這個的指令。

  OP_PRINT,
chunk.h
在 enum OpCode
  OP_JUMP,
  OP_JUMP_IF_FALSE,
chunk.h,在 enum OpCode

我們這樣解讀它

        break;
      }
vm.c
run() 中
      case OP_JUMP: {
        uint16_t offset = READ_SHORT();
        vm.ip += offset;
        break;
      }
      case OP_JUMP_IF_FALSE: {
vm.c,在 run() 中

這裡沒有太多令人驚訝的事情唯一的區別是它不檢查條件,而是始終套用偏移量。

我們現在有了 then 和 else 分支,所以快完成了。最後一點是要清除我們留在堆疊上的條件值。記住,每個陳述式都必須有零堆疊效應在陳述式執行完成後,堆疊的高度應該和執行前一樣。

我們可以讓 OP_JUMP_IF_FALSE 指令自行彈出條件值,但很快我們就會將同一個指令用於邏輯運算子,而在那裡我們不希望條件值被彈出。相反地,我們會在編譯 if 陳述式時讓編譯器發出幾個明確的 OP_POP 指令。我們需要確保通過產生程式碼的每個執行路徑都會彈出條件值。

當條件為真時,我們會在 then 分支內的程式碼之前立即彈出它。

  int thenJump = emitJump(OP_JUMP_IF_FALSE);
compiler.c
ifStatement() 中
  emitByte(OP_POP);
  statement();
compiler.c,在 ifStatement() 中

否則,我們會在 else 分支的開頭彈出它。

  patchJump(thenJump);
compiler.c
ifStatement() 中
  emitByte(OP_POP);
  if (match(TOKEN_ELSE)) statement();
compiler.c,在 ifStatement() 中

這個小指令也意味著每個 if 陳述式都有一個隱含的 else 分支,即使使用者沒有撰寫 else 子句。在他們省略它的情況下,該分支所做的只是丟棄條件值。

完整的正確流程如下所示

Flowchart of the compiled bytecode including necessary pop instructions.

如果你追蹤一下,你會看到它總是執行單一分支並確保條件值先被彈出。剩下的只是少許的反組譯器支援。

      return simpleInstruction("OP_PRINT", offset);
debug.c
disassembleInstruction() 中
    case OP_JUMP:
      return jumpInstruction("OP_JUMP", 1, chunk, offset);
    case OP_JUMP_IF_FALSE:
      return jumpInstruction("OP_JUMP_IF_FALSE", 1, chunk, offset);
    case OP_RETURN:
debug.c, 在 disassembleInstruction() 中

這兩個指令具有帶有 16 位元運算元的新格式,因此我們新增一個新的工具函式來反組譯它們。

debug.c
byteInstruction() 之後新增
static int jumpInstruction(const char* name, int sign,
                           Chunk* chunk, int offset) {
  uint16_t jump = (uint16_t)(chunk->code[offset + 1] << 8);
  jump |= chunk->code[offset + 2];
  printf("%-16s %4d -> %d\n", name, offset,
         offset + 3 + sign * jump);
  return offset + 3;
}
debug.c, 在 byteInstruction() 之後新增

好了,這是一個完整的控制流程結構。如果這是一部 80 年代的電影,蒙太奇音樂就會開始播放,剩下的控制流程語法就會自行完成。唉,80 年代早已過去,所以我們必須自己努力完成。

23.2邏輯運算子

你可能還記得 jlox 中的內容,但邏輯運算子 andor 不只是像 +- 這樣的一對二元運算子。因為它們會短路,而且可能會根據左運算元的值而不評估其右運算元,所以它們更像控制流程表達式。

它們基本上是帶有 else 子句的 if 陳述式的一個小變體。解釋它們最簡單的方法是直接向你展示編譯器程式碼以及它在產生的位元組碼中產生的控制流程。從 and 開始,我們在這裡將它掛接到表達式解析表中

  [TOKEN_NUMBER]        = {number,   NULL,   PREC_NONE},
compiler.c
取代 1 行
  [TOKEN_AND]           = {NULL,     and_,   PREC_AND},
  [TOKEN_CLASS]         = {NULL,     NULL,   PREC_NONE},
compiler.c,取代 1 行

這會轉交給一個新的解析器函式。

compiler.c
defineVariable() 之後新增
static void and_(bool canAssign) {
  int endJump = emitJump(OP_JUMP_IF_FALSE);

  emitByte(OP_POP);
  parsePrecedence(PREC_AND);

  patchJump(endJump);
}
compiler.c, 在 defineVariable() 之後新增

在呼叫此函式時,左側表達式已經被編譯。這意味著在執行階段,其值將位於堆疊頂部。如果該值為假,那麼我們就知道整個 and 必須為假,因此我們跳過右運算元,並將左側值保留為整個表達式的結果。否則,我們將丟棄左側值,並評估右運算元,這將成為整個 and 表達式的結果。

這四行程式碼正是產生這個結果。流程如下所示

Flowchart of the compiled bytecode of an 'and' expression.

現在你可以看到為什麼 OP_JUMP_IF_FALSE 保留堆疊頂部的值。當 and 的左側為假時,該值會保留下來成為整個表達式的結果。

23.2.1邏輯 or 運算子

or 運算子稍微複雜一些。首先,我們將它新增到解析表中。

  [TOKEN_NIL]           = {literal,  NULL,   PREC_NONE},
compiler.c
取代 1 行
  [TOKEN_OR]            = {NULL,     or_,    PREC_OR},
  [TOKEN_PRINT]         = {NULL,     NULL,   PREC_NONE},
compiler.c,取代 1 行

當該解析器消耗一個中綴 or 符號時,它會呼叫這個函式

compiler.c
number() 之後新增
static void or_(bool canAssign) {
  int elseJump = emitJump(OP_JUMP_IF_FALSE);
  int endJump = emitJump(OP_JUMP);

  patchJump(elseJump);
  emitByte(OP_POP);

  parsePrecedence(PREC_OR);
  patchJump(endJump);
}
compiler.c, 在 number() 之後新增

or 表達式中,如果左側為,那麼我們就跳過右運算元。因此,當值為真時,我們需要跳轉。我們可以新增一個單獨的指令,但為了展示我們的編譯器如何自由地將語言的語義對應到它想要的任何指令序列,我根據我們已經擁有的跳轉指令實作了它。

當左側為假時,它會在下一個陳述式上執行一個小的跳轉。該陳述式是無條件跳轉到右運算元的程式碼之上。這種小技巧有效地在值為真時進行跳轉。流程如下所示

Flowchart of the compiled bytecode of a logical or expression.

如果我對你坦誠,這不是執行此操作的最佳方式。有更多的指令要分派和更多的開銷。or 沒有很好的理由應該比 and 慢。但看到有可能在不新增任何新指令的情況下實作這兩個運算子,也很有趣。請原諒我的放縱。

好的,這些是 Lox 中的三個分支結構。我的意思是,這些是控制流程功能,它們只在程式碼上向前跳轉。其他語言通常具有某種多路分支陳述式,例如 switch,以及像 ?: 這樣的條件表達式,但 Lox 將其保持簡單。

23.3While 陳述式

這將我們帶到迴圈陳述式,它們會向後跳轉,以便程式碼可以執行多次。Lox 只有兩種迴圈結構,whileforwhile 迴圈 (簡單得多),所以我們從那裡開始。

    ifStatement();
compiler.c
statement() 中
  } else if (match(TOKEN_WHILE)) {
    whileStatement();
  } else if (match(TOKEN_LEFT_BRACE)) {
compiler.c,在 statement() 中

當我們遇到 while 符號時,我們會呼叫

compiler.c
printStatement() 之後新增
static void whileStatement() {
  consume(TOKEN_LEFT_PAREN, "Expect '(' after 'while'.");
  expression();
  consume(TOKEN_RIGHT_PAREN, "Expect ')' after condition.");

  int exitJump = emitJump(OP_JUMP_IF_FALSE);
  emitByte(OP_POP);
  statement();

  patchJump(exitJump);
  emitByte(OP_POP);
}
compiler.c, 在 printStatement() 之後新增

這大部分反映了 if 陳述式我們編譯由強制性括號括住的條件表達式。接著是一個跳轉指令,如果條件為假,則跳過後續的主體陳述式。

我們在編譯主體後修補跳轉,並注意從任一路徑上的堆疊彈出條件值。與 if 陳述式唯一的不同是迴圈。它看起來像這樣

  statement();
compiler.c
whileStatement() 中
  emitLoop(loopStart);
  patchJump(exitJump);
compiler.c, 在 whileStatement() 中

在主體之後,我們呼叫此函式來發出「迴圈」指令。該指令需要知道要跳轉多遠。當向前跳轉時,我們必須分兩個階段發出指令,因為我們在發出跳轉指令之前不知道要跳轉多遠。我們現在沒有這個問題。我們已經編譯了我們想要跳回的程式碼點它就在條件表達式之前。

我們需要做的就是捕獲該位置,因為我們在編譯它。

static void whileStatement() {
compiler.c
whileStatement() 中
  int loopStart = currentChunk()->count;
  consume(TOKEN_LEFT_PAREN, "Expect '(' after 'while'.");
compiler.c, 在 whileStatement() 中

在執行 while 迴圈的主體之後,我們會跳回到條件之前。這樣,我們會在每次迭代時重新評估條件表達式。我們將區塊的目前指令計數儲存在 loopStart 中,以記錄我們即將編譯的條件表達式之前的位元組碼偏移量。然後,我們將該值傳遞到這個輔助函式

compiler.c
emitBytes() 之後添加
static void emitLoop(int loopStart) {
  emitByte(OP_LOOP);

  int offset = currentChunk()->count - loopStart + 2;
  if (offset > UINT16_MAX) error("Loop body too large.");

  emitByte((offset >> 8) & 0xff);
  emitByte(offset & 0xff);
}
compiler.c,在 emitBytes() 之後添加

它有點像 emitJump()patchJump() 的組合。它發出一個新的迴圈指令,該指令會無條件地向後跳轉給定的偏移量。與跳轉指令一樣,之後我們有一個 16 位元運算元。我們計算從我們目前所在的指令到我們想要跳回的 loopStart 點的偏移量。+ 2 是為了考慮 OP_LOOP 指令本身的運算元大小,我們也需要跳過這些運算元。

從 VM 的角度來看,OP_LOOPOP_JUMP 之間確實沒有語義上的差異。兩者都只是將偏移量新增到 ip。我們可以為兩者使用單一指令,並為其提供一個帶正負號的偏移量運算元。但我認為,避開手動將帶正負號的 16 位元整數打包到兩個位元組中所需的煩人位元處理會稍微容易一些,而且我們有可用的運算碼空間,所以為什麼不使用它呢?

新的指令在這裡

  OP_JUMP_IF_FALSE,
chunk.h
在 enum OpCode
  OP_LOOP,
  OP_RETURN,
chunk.h,在 enum OpCode

在 VM 中,我們這樣實作它

      }
vm.c
run() 中
      case OP_LOOP: {
        uint16_t offset = READ_SHORT();
        vm.ip -= offset;
        break;
      }
      case OP_RETURN: {
vm.c,在 run() 中

OP_JUMP 唯一的不同是減法而不是加法。反組譯也很相似。

      return jumpInstruction("OP_JUMP_IF_FALSE", 1, chunk, offset);
debug.c
disassembleInstruction() 中
    case OP_LOOP:
      return jumpInstruction("OP_LOOP", -1, chunk, offset);
    case OP_RETURN:
debug.c, 在 disassembleInstruction() 中

這是我們的 while 陳述式。它包含兩個跳轉一個有條件的向前跳轉,以便在不滿足條件時跳出迴圈,以及一個無條件的向後迴圈,在我們執行主體之後。流程如下所示

Flowchart of the compiled bytecode of a while statement.

23.4For 陳述式

Lox 中的另一個迴圈陳述式是源自 C 的可敬的 for 迴圈。與 while 迴圈相比,它還有更多的事情要處理。它有三個子句,所有這些子句都是可選的

在 jlox 中,解析器將 for 迴圈分解為 while 迴圈的合成 AST,其中在主體之前和末尾有一些額外的東西。我們將做類似的事情,儘管我們不會經過任何類似 AST 的東西。相反地,我們的位元組碼編譯器將使用我們已經擁有的跳轉和迴圈指令。

我們將逐步實作,從 for 關鍵字開始。

    printStatement();
compiler.c
statement() 中
  } else if (match(TOKEN_FOR)) {
    forStatement();
  } else if (match(TOKEN_IF)) {
compiler.c,在 statement() 中

它會呼叫一個輔助函式。如果我們只支援帶有空子句的 for 迴圈,例如 for (;;),那麼我們可以像這樣實作它

compiler.c
expressionStatement() 之後添加
static void forStatement() {
  consume(TOKEN_LEFT_PAREN, "Expect '(' after 'for'.");
  consume(TOKEN_SEMICOLON, "Expect ';'.");

  int loopStart = currentChunk()->count;
  consume(TOKEN_SEMICOLON, "Expect ';'.");
  consume(TOKEN_RIGHT_PAREN, "Expect ')' after for clauses.");

  statement();
  emitLoop(loopStart);
}
compiler.c,在 expressionStatement() 之後添加

頂部有一堆強制性的標點符號。然後我們編譯主體。就像我們對 while 迴圈所做的那樣,我們記錄主體頂部的位元組碼偏移量,並發出一個迴圈以在之後跳回該點。我們現在有一個 無限迴圈的運作實作。

23 . 4 . 1初始化子句

現在我們要加入第一個子句,即初始化子句。它只在迴圈主體之前執行一次,因此編譯過程很簡單。

  consume(TOKEN_LEFT_PAREN, "Expect '(' after 'for'.");
compiler.c
forStatement() 中
取代 1 行
  if (match(TOKEN_SEMICOLON)) {
    // No initializer.
  } else if (match(TOKEN_VAR)) {
    varDeclaration();
  } else {
    expressionStatement();
  }
  int loopStart = currentChunk()->count;
compiler.c,在 forStatement() 中,替換 1 行

語法有點複雜,因為我們允許使用變數宣告或表達式。我們使用 var 關鍵字的存在來判斷是哪一種。對於表達式的情況,我們呼叫 expressionStatement() 而不是 expression()。這會尋找分號,我們在這裡也需要分號,並發出 OP_POP 指令來捨棄值。我們不希望初始化子句在堆疊上留下任何東西。

如果 for 語句宣告了一個變數,則該變數的作用域應限定在迴圈主體內。我們透過將整個語句包裝在一個作用域中來確保這一點。

static void forStatement() {
compiler.c
forStatement() 中
  beginScope();
  consume(TOKEN_LEFT_PAREN, "Expect '(' after 'for'.");
compiler.c,在 forStatement() 中

然後我們在結尾關閉它。

  emitLoop(loopStart);
compiler.c
forStatement() 中
  endScope();
}
compiler.c,在 forStatement() 中

23 . 4 . 2條件子句

接下來,是可以用於退出迴圈的條件表達式。

  int loopStart = currentChunk()->count;
compiler.c
forStatement() 中
取代 1 行
  int exitJump = -1;
  if (!match(TOKEN_SEMICOLON)) {
    expression();
    consume(TOKEN_SEMICOLON, "Expect ';' after loop condition.");

    // Jump out of the loop if the condition is false.
    exitJump = emitJump(OP_JUMP_IF_FALSE);
    emitByte(OP_POP); // Condition.
  }

  consume(TOKEN_RIGHT_PAREN, "Expect ')' after for clauses.");
compiler.c,在 forStatement() 中,替換 1 行

由於子句是可選的,我們需要查看它是否實際存在。如果省略了子句,則下一個標記必須是分號,因此我們尋找分號來判斷。如果沒有分號,則必須有一個條件表達式。

在這種情況下,我們編譯它。然後,就像 while 迴圈一樣,我們發出一個條件跳轉,如果條件為假,則退出迴圈。由於跳轉會將值留在堆疊上,因此我們在執行主體之前將其彈出。這確保了當條件為真時我們捨棄該值。

在迴圈主體之後,我們需要修補該跳轉。

  emitLoop(loopStart);
compiler.c
forStatement() 中
  if (exitJump != -1) {
    patchJump(exitJump);
    emitByte(OP_POP); // Condition.
  }

  endScope();
}
compiler.c,在 forStatement() 中

我們僅在有條件子句時才執行此操作。如果沒有條件子句,則沒有跳轉需要修補,也沒有條件值在堆疊上需要彈出。

23 . 4 . 3遞增子句

我把最好的留到了最後,即遞增子句。它非常複雜。它在文字上出現在主體之前,但在主體之後執行。如果我們解析為 AST 並在單獨的遍歷中生成程式碼,我們可以簡單地遍歷並編譯 for 語句 AST 的主體欄位,然後再編譯其遞增子句。

不幸的是,我們不能稍後編譯遞增子句,因為我們的編譯器只對程式碼進行一次遍歷。相反,我們將跳過遞增,執行主體,跳遞增,執行它,然後進入下一次迭代。

我知道,有點奇怪,但是嘿,它勝過在 C 中手動管理記憶體中的 AST,對吧?這是程式碼

  }

compiler.c
forStatement() 中
取代 1 行
  if (!match(TOKEN_RIGHT_PAREN)) {
    int bodyJump = emitJump(OP_JUMP);
    int incrementStart = currentChunk()->count;
    expression();
    emitByte(OP_POP);
    consume(TOKEN_RIGHT_PAREN, "Expect ')' after for clauses.");

    emitLoop(loopStart);
    loopStart = incrementStart;
    patchJump(bodyJump);
  }
  statement();
compiler.c,在 forStatement() 中,替換 1 行

同樣,它是可選的。由於這是最後一個子句,因此省略時,下一個標記將是右括號。當存在遞增時,我們現在需要編譯它,但它不應立即執行。因此,首先,我們發出一個無條件跳轉,跳過遞增子句的程式碼,到達迴圈主體。

接下來,我們編譯遞增表達式本身。這通常是一個賦值。無論它是什麼,我們只為其副作用執行它,因此我們也發出一個 pop 來捨棄其值。

最後一部分有點棘手。首先,我們發出一個迴圈指令。這是主要的迴圈,將我們帶回到 for 迴圈的頂部如果有的話,就在條件表達式之前。該迴圈發生在遞增之後,因為遞增在每個迴圈迭代的結尾執行。

然後我們更改 loopStart 以指向遞增表達式開始的偏移量。稍後,當我們在主體語句之後發出迴圈指令時,這將導致它跳轉到遞增表達式,而不是像沒有遞增時一樣跳轉到迴圈的頂部。這就是我們如何將遞增編織到在主體之後執行的方式。

這很複雜,但一切都很好。包含所有子句的完整迴圈編譯為如下流程

Flowchart of the compiled bytecode of a for statement.

與在 jlox 中實作 for 迴圈一樣,我們不需要修改執行時間。它都被編譯為 VM 已經支援的基本控制流程操作。在本章中,我們向前邁進了一大clox 現在是圖靈完備的。我們還涵蓋了相當多的新語法:三個語句和兩種表達式形式。即便如此,它只需要三個新的簡單指令。對於我們的 VM 架構來說,這是一個相當不錯的努力與回報比率。

挑戰

  1. 除了 if 語句之外,大多數 C 系列語言都有多路 switch 語句。將一個新增到 clox。語法是

    switchStmt"switch" "(" expression ")"
                     "{" switchCase* defaultCase? "}" ;
    switchCase"case" expression ":" statement* ;
    defaultCase"default" ":" statement* ;
    

    要執行 switch 語句,首先計算帶括號的 switch 值表達式。然後走訪 case。對於每個 case,計算其值表達式。如果 case 值等於 switch 值,則執行 case 下的語句,然後退出 switch 語句。否則,嘗試下一個 case。如果沒有 case 匹配,且存在 default 子句,則執行其語句。

    為了保持簡單,我們省略了 fallthrough 和 break 語句。每個 case 在其語句執行完畢後都會自動跳轉到 switch 語句的結尾。

  2. 在 jlox 中,我們在新增對 break 語句的支援時遇到了一個挑戰。這次,我們來做 continue

    continueStmt"continue" ";" ;
    

    continue 語句直接跳轉到最近的封閉迴圈的頂部,跳過迴圈主體的其餘部分。在 for 迴圈內,如果存在 continue,則跳轉到遞增子句。如果 continue 語句未封閉在迴圈中,則會出現編譯時錯誤。

    請務必考慮作用域。當執行 continue 時,在迴圈主體內或迴圈內巢狀區塊中宣告的局部變數應該會發生什麼事?

  3. 自 Algol 68 以來,控制流程結構大多沒有改變。此後的語言演變側重於使程式碼更具宣告性和高階性,因此命令式控制流程沒有受到太多關注。

    為了好玩,請嘗試為 Lox 發明一個有用的新穎控制流程功能。它可以是對現有形式的改進,也可以是全新的東西。在實踐中,很難在這種低表達程度下提出足夠有用的東西,以抵消強制使用者學習不熟悉的符號和行為的成本,但這是一個練習設計技能的好機會。

設計註解:考慮到 Goto 的危害

發現我們在 Lox 中所有漂亮的結構化控制流程實際上都被編譯為原始的非結構化跳轉,就像史酷比撕下怪物面具的那一刻。原來一直是 goto!只是在這種情況下,怪物面具。我們都知道 goto 是邪惡的。但是 . . .為什麼?

確實可以使用 goto 寫出令人難以置信的難以維護的程式碼。但我認為當今大多數程式設計師都沒有親眼見過。自那種風格盛行以來已經很久了。如今,它成了我們在營火邊講恐怖故事時會提到的惡魔。

我們很少親自面對那個怪物的原因是 Edsger Dijkstra 用他著名的信件「Go To Statement Considered Harmful」殺死了它,該信件發表在ACM 通訊(1968 年 3 月)。關於結構化程式設計的辯論已經激烈了一段時間,雙方都有擁護者,但我認為 Dijkstra 最值得稱讚的是有效地終結了它。如今,大多數新語言都沒有非結構化跳轉語句。

一份只有一頁半的信件幾乎憑一己之力摧毀了一個語言功能,這一定非常令人印象深刻。如果您還沒有讀過,我鼓勵您去讀。它是計算機科學史上的重要文獻,是我們族群的祖傳歌曲之一。此外,它也是練習閱讀學術 CS 寫作的好方法,這是一項值得培養的有用技能。

我讀過很多次,還有一些評論、回應和評論。我最終的心情很複雜,充其量只能說複雜。從非常高的層面來說,我同意他的觀點。他的總體論點大致如下

  1. 作為程式設計師,我們編寫程式靜態文字但我們關心的是實際運行的程式它的動態行為。

  2. 我們更擅長推理靜態事物,而不是動態事物。(他沒有提供任何證據來支持這種說法,但我接受它。)

  3. 因此,我們越能使程式的動態執行反映其文字結構,就越好。

這是一個好的開始。將我們的注意力吸引到我們編寫的程式碼與它在機器內部運行時的程式碼之間的分離是一個有趣的見解。然後,他試圖定義程式文字和執行之間的「對應關係」。對於一個將他整個職業生涯都花在提倡程式設計更大嚴謹性的人來說,他的定義相當含糊。他說

現在讓我們考慮一下如何描述進程的進展。(您可以非常具體地思考這個問題:假設一個進程,被視為一系列動作的時間順序,在任意動作後停止,我們必須固定哪些資料才能將該進程重做到完全相同的位置?)

想像一下這樣。您有兩台電腦,它們運行著完全相同的輸入上的相同程式因此完全是確定性的。您在執行過程中的任意點暫停其中一台電腦。您需要將哪些資料傳送到另一台電腦,才能使其停止到與第一台電腦完全相同的位置?

如果您的程式僅允許使用簡單語句(例如賦值),那就很容易。您只需要知道執行最後一條語句後的位置。基本上是一個斷點,我們 VM 中的 ip,或錯誤訊息中的行號。新增分支控制流程(例如 ifswitch)不會對此新增任何內容。即使標記指向分支內部,我們仍然可以知道我們的位置。

一旦新增函式呼叫,您就需要更多的東西。您可能在函式中間暫停了第一台電腦,但是該函式可能會從多個地方呼叫。要在整個程式的執行過程中將第二台機器暫停在完全相同的位置,您需要將其暫停在對該函式的正確呼叫上。

因此,您不僅需要知道目前的語句,還需要知道尚未傳回的函式呼叫的呼叫位置。換句話說,呼叫堆疊,儘管我不認為 Dijkstra 在寫這篇文章時存在這個詞。太棒了。

他指出迴圈使事情變得更困難。如果您在迴圈主體中間暫停,您不知道執行了多少次迭代。因此,他說您還需要保留一個迭代計數。並且,由於迴圈可以巢狀,您需要這些迴圈的堆疊(大概與呼叫堆疊指標交錯,因為您也可以在外層呼叫中的迴圈中)。

這就是它變得奇怪的地方。所以我們現在真的在構建某些東西,您期望他解釋 goto 如何破壞這一切。相反,他只是說

不受約束地使用 go to 語句會立即導致很難找到一組有意義的坐標來描述進程進度。

他沒有證明這很難,也沒有說明原因。他只是這樣說。他說其中一種方法令人不滿意

當然,使用 go to 敘述,人們仍然可以透過一個計數器來獨特地描述進度,這個計數器會計算程式啟動以來執行的動作次數(也就是一種標準化的時鐘)。困難之處在於,這樣一個座標雖然是獨一無二的,卻完全沒有幫助。

但是 . . . ,這實際上就是迴圈計數器所做的事情,而他對此並沒有意見。並不是每個迴圈都是簡單的「對於從 0 到 10 的每個整數」的遞增計數。許多是帶有複雜條件的 while 迴圈。

以一個貼近我們生活經驗的例子來說,想想 clox 核心的位元組碼執行迴圈。Dijkstra 認為該迴圈是可追蹤的,因為我們可以簡單地計算迴圈運行的次數來推斷其進度。但是,該迴圈會針對某些使用者編譯的 Lox 程式中執行的每個指令運行一次。知道它執行了 6,201 個位元組碼指令真的能告訴我們 VM 維護人員關於直譯器狀態的任何有用的資訊嗎?

事實上,這個特定的例子指向一個更深層的真理。Böhm 和 Jacopini 證明了,任何使用 goto 的控制流程都可以轉換為僅使用序列、迴圈和分支的控制流程。我們的位元組碼直譯器迴圈就是這個證明的活生生的例子:它實現了 clox 位元組碼指令集的不結構化控制流程,而自身沒有使用任何 goto。

這似乎提供了對 Dijkstra 主張的反駁:你可以透過將使用 goto 的程式轉換為不使用 goto 的程式,然後使用該程式的對應關係來為使用 goto 的程式定義對應關係,根據他的說法,這是可以接受的,因為它只使用分支和迴圈。

但老實說,我這裡的論點也很薄弱。我認為我們兩個基本上都是在做假裝的數學,並使用虛假的邏輯來支持一個應該是經驗性的、以人為中心的論點。Dijkstra 說某些使用 goto 的程式碼真的很糟糕是對的。其中大部分可以而且應該透過使用結構化控制流程轉換為更清晰的程式碼。

透過從語言中完全消除 goto,你絕對可以防止編寫使用 goto 的不良程式碼。強迫使用者使用結構化控制流程,並且讓使用這些結構編寫類似 goto 的程式碼變得困難,這可能對我們所有人的生產力來說是一個淨收益。

但我有時會懷疑我們是否連同洗澡水把嬰兒都倒掉了。在沒有 goto 的情況下,我們常常會採用更複雜的結構化模式。「迴圈內的 switch」是一個典型的例子。另一個例子是使用守衛變數來退出一系列巢狀迴圈。

// See if the matrix contains a zero.
bool found = false;
for (int x = 0; x < xSize; x++) {
  for (int y = 0; y < ySize; y++) {
    for (int z = 0; z < zSize; z++) {
      if (matrix[x][y][z] == 0) {
        printf("found");
        found = true;
        break;
      }
    }
    if (found) break;
  }
  if (found) break;
}

這樣真的比較好嗎?

for (int x = 0; x < xSize; x++) {
  for (int y = 0; y < ySize; y++) {
    for (int z = 0; z < zSize; z++) {
      if (matrix[x][y][z] == 0) {
        printf("found");
        goto done;
      }
    }
  }
}
done:

我想我真正不喜歡的是,我們今天做出的語言設計和工程決策是基於恐懼。今天很少有人對 goto 的問題和好處有任何細微的理解。相反,我們只是認為它「被認為是有害的」。就我個人而言,我從未發現教條是優質創意工作的良好起點。