21

全域變數

如果能發明一種能像香味一樣保存記憶的東西就好了。它永遠不會褪色,也永遠不會變質。然後,當人們想要的時候,就可以打開瓶塞,就像重新活過那一刻一樣。

達芙妮·杜·穆里埃,《蝴蝶夢》

前一章深入探討了一個龐大、深刻、基礎的電腦科學資料結構。充滿了理論和概念。可能還討論了一些大 O 表示法和演算法。本章的知識性要求較低,沒有需要學習的重大概念,只有一些直接的工程任務。一旦我們完成這些任務,我們的虛擬機將會支援變數。

實際上,它只會支援全域變數。區域變數將在下一章中介紹。在 jlox 中,我們設法將它們都塞進同一章,因為我們對所有變數都使用了相同的實作技術。我們建立了一個環境鏈,每個作用域一個,一直延伸到最頂層。這是一種簡單、清晰的方法,可以學習如何管理狀態。

但它也很慢。每次進入區塊或呼叫函式時都分配一個新的雜湊表,這不是實現快速 VM 的方法。考慮到有多少程式碼與使用變數有關,如果變數變慢,一切都會變慢。對於 clox,我們將針對區域變數採用更有效的策略來改進這一點,但全域變數不容易最佳化。

快速複習一下 Lox 的語義:Lox 中的全域變數是「延遲綁定」的,或動態解析的。這表示您可以在定義全域變數之前,編譯引用該變數的程式碼區塊。只要程式碼在定義發生之前沒有執行,一切就沒問題。實際上,這表示您可以在函式主體內引用稍後的變數。

fun showVariable() {
  print global;
}

var global = "after";
showVariable();

像這樣的程式碼可能看起來很奇怪,但它對於定義相互遞迴的函式很方便。它也更適合 REPL。您可以在一行中編寫一個小函式,然後在下一行中定義它使用的變數。

區域變數的工作方式不同。由於區域變數的宣告總是發生在使用之前,因此 VM 可以在編譯時解析它們,即使是在簡單的單遍編譯器中也可以。這將讓我們可以使用更聰明的區域變數表示法。但那是下一章的事情。現在,我們只需擔心全域變數即可。

21 . 1陳述式

變數透過變數宣告來產生,這表示現在也是時候在我們的編譯器中新增陳述式支援了。如果您還記得,Lox 將陳述式分為兩類。「宣告」是那些將新名稱繫結到值的陳述式。其他類型的陳述式控制流程、列印等僅稱為「陳述式」。我們不允許在控制流程陳述式中直接進行宣告,像這樣

if (monday) var croissant = "yes"; // Error.

允許這樣做會引發有關變數範圍的混淆問題。因此,與其他語言一樣,我們透過為控制流程主體內允許的陳述式子集,設置單獨的文法規則來禁止它。

statementexprStmt
               | forStmt
               | ifStmt
               | printStmt
               | returnStmt
               | whileStmt
               | block ;

然後,我們對指令碼的頂層和區塊內使用單獨的規則。

declarationclassDecl
               | funDecl
               | varDecl
               | statement ;

declaration 規則包含宣告名稱的陳述式,並且還包含 statement,以便允許所有陳述式類型。由於 block 本身在 statement 中,您可以透過將它們巢狀在區塊內,來將宣告放在控制流程結構內部

在本章中,我們將僅介紹幾個陳述式和一個宣告。

statementexprStmt
               | printStmt ;

declarationvarDecl
               | statement ;

到目前為止,我們的 VM 將「程式」視為單個運算式,因為這是我們唯一可以解析和編譯的內容。在完整的 Lox 實作中,程式是一系列宣告。我們現在已準備好支援這一點。

  advance();
compiler.c
compile() 中
取代 2 行
  while (!match(TOKEN_EOF)) {
    declaration();
  }

  endCompiler();
compiler.c,在 compile() 中,取代 2 行

我們將繼續編譯宣告,直到到達原始碼檔案的結尾。我們使用此方法編譯單個宣告

compiler.c
expression() 之後新增
static void declaration() {
  statement();
}
compiler.c,在 expression() 之後新增

我們將在本章稍後介紹變數宣告,因此現在,我們只需轉發到 statement()

compiler.c
declaration() 之後新增
static void statement() {
  if (match(TOKEN_PRINT)) {
    printStatement();
  }
}
compiler.c,在 declaration() 之後新增

區塊可以包含宣告,而控制流程陳述式可以包含其他陳述式。這表示這兩個函式最終將會是遞迴的。我們現在不妨寫出前向宣告。

static void expression();
compiler.c
expression() 之後新增
static void statement();
static void declaration();
static ParseRule* getRule(TokenType type);
compiler.c,在 expression() 之後新增

21 . 1 . 1列印陳述式

在本章中,我們需要支援兩種陳述式類型。讓我們從 print 陳述式開始,自然地,它以 print 符號開頭。我們使用此輔助函式偵測到它

compiler.c
consume() 之後新增
static bool match(TokenType type) {
  if (!check(type)) return false;
  advance();
  return true;
}
compiler.c,在 consume() 之後新增

您可能在 jlox 中認得它。如果目前的符號具有給定的類型,我們會使用該符號並傳回 true。否則,我們將保留該符號並傳回 false。此輔助函式是根據此其他輔助函式實作的

compiler.c
consume() 之後新增
static bool check(TokenType type) {
  return parser.current.type == type;
}
compiler.c,在 consume() 之後新增

如果目前的符號具有給定的類型,則 check() 函式會傳回 true。將其封裝在函式中似乎有點,但稍後我們會更多地使用它,而且我認為像這樣簡短的動詞命名函式,會讓剖析器更容易閱讀。

如果我們確實符合 print 符號,那麼我們將在此處編譯陳述式的其餘部分

compiler.c
expression() 之後新增
static void printStatement() {
  expression();
  consume(TOKEN_SEMICOLON, "Expect ';' after value.");
  emitByte(OP_PRINT);
}
compiler.c,在 expression() 之後新增

print 陳述式會評估運算式並列印結果,因此我們首先剖析並編譯該運算式。文法預期後面接著分號,因此我們使用它。最後,我們發出一個新指令來列印結果。

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

在執行階段,我們這樣執行此指令

        break;
vm.c
run() 中
      case OP_PRINT: {
        printValue(pop());
        printf("\n");
        break;
      }
      case OP_RETURN: {
vm.c,在 run() 中

當直譯器到達此指令時,它已經執行了運算式的程式碼,將結果值留在堆疊頂部。現在,我們只需彈出並列印它即可。

請注意,之後我們沒有推送任何其他內容。這是 VM 中運算式和陳述式之間的關鍵差異。每個位元組碼指令都有一個堆疊效應,它描述了指令如何修改堆疊。例如,OP_ADD 會彈出兩個值並推送一個值,使堆疊比之前小一個元素。

您可以將一系列指令的堆疊效果相加,以獲得它們的總效果。當您將從任何完整運算式編譯的一系列指令的堆疊效果相加時,總和將為一。每個運算式在堆疊上留下一個結果值。

整個陳述式的位元組碼的總堆疊效果為零。由於陳述式不產生任何值,因此它最終會使堆疊保持不變,儘管它在執行操作時當然會使用堆疊。這很重要,因為當我們進入控制流程和迴圈時,程式可能會執行一長串陳述式。如果每個陳述式都增加或縮小堆疊,它最終可能會溢位或下溢。

當我們在直譯器迴圈中時,我們應該刪除一些程式碼。

      case OP_RETURN: {
vm.c
run() 中
取代 2 行
        // Exit interpreter.
        return INTERPRET_OK;
vm.c,在 run() 中,取代 2 行

當 VM 僅編譯和評估單個運算式時,我們在 OP_RETURN 中有一些暫時性的程式碼來輸出值。現在我們有了陳述式和 print,我們不再需要它了。我們離 clox 的完整實作又更近了一

與往常一樣,新指令需要在反組譯器中提供支援。

      return simpleInstruction("OP_NEGATE", offset);
debug.c
disassembleInstruction() 中
    case OP_PRINT:
      return simpleInstruction("OP_PRINT", offset);
    case OP_RETURN:
debug.c,在 disassembleInstruction() 中

這就是我們的 print 陳述式。如果您願意,可以試一試

print 1 + 2;
print 3 * 4;

令人興奮!好吧,也許不是令人激動,但我們現在可以建構包含任意多個陳述式的指令碼,這感覺像是進步。

21 . 1 . 2運算式陳述式

等您看到下一個陳述式。如果我們沒有看到 print 關鍵字,那麼我們一定是在查看運算式陳述式。

    printStatement();
compiler.c
statement() 中
  } else {
    expressionStatement();
  }
compiler.c,在 statement() 中

它的剖析方式如下

compiler.c
expression() 之後新增
static void expressionStatement() {
  expression();
  consume(TOKEN_SEMICOLON, "Expect ';' after expression.");
  emitByte(OP_POP);
}
compiler.c,在 expression() 之後新增

「運算式陳述式」僅是一個後面跟著分號的運算式。它們是您在預期陳述式的內容中編寫運算式的方式。通常,這樣做是為了您可以呼叫函式或評估賦值來產生副作用,像這樣

brunch = "quiche";
eat(brunch);

從語義上講,運算式陳述式會評估運算式並捨棄結果。編譯器會直接編碼該行為。它編譯運算式,然後發出一個 OP_POP 指令。

  OP_FALSE,
chunk.h
在 enum OpCode
  OP_POP,
  OP_EQUAL,
chunk.h,在 enum OpCode

顧名思義,該指令會從堆疊中彈出頂部值並忘記它。

      case OP_FALSE: push(BOOL_VAL(false)); break;
vm.c
run() 中
      case OP_POP: pop(); break;
      case OP_EQUAL: {
vm.c,在 run() 中

我們也可以將其拆解。

      return simpleInstruction("OP_FALSE", offset);
debug.c
disassembleInstruction() 中
    case OP_POP:
      return simpleInstruction("OP_POP", offset);
    case OP_EQUAL:
debug.c,在 disassembleInstruction() 中

由於我們還不能建立任何具有副作用的表達式,所以表達式陳述式目前還不太有用,但當我們稍後加入函式時,它們將會是不可或缺的。在像 C 語言這樣的真實程式碼中,大多數陳述式都是表達式陳述式。

21 . 1 . 3錯誤同步

在我們完成編譯器中的初始工作時,我們可以處理之前幾章遺留下來的未完成事項。如同 jlox,clox 使用恐慌模式錯誤恢復,以盡量減少它報告的級聯編譯錯誤數量。編譯器在到達同步點時會退出恐慌模式。對於 Lox,我們選擇陳述式邊界作為該點。既然我們有了陳述式,就可以實作同步。

  statement();
compiler.c
declaration() 中
  if (parser.panicMode) synchronize();
}
compiler.c,在 declaration() 中

如果我們在解析先前的陳述式時遇到編譯錯誤,我們會進入恐慌模式。當這種情況發生時,在陳述式之後,我們會開始同步。

compiler.c
printStatement() 後面新增
static void synchronize() {
  parser.panicMode = false;

  while (parser.current.type != TOKEN_EOF) {
    if (parser.previous.type == TOKEN_SEMICOLON) return;
    switch (parser.current.type) {
      case TOKEN_CLASS:
      case TOKEN_FUN:
      case TOKEN_VAR:
      case TOKEN_FOR:
      case TOKEN_IF:
      case TOKEN_WHILE:
      case TOKEN_PRINT:
      case TOKEN_RETURN:
        return;

      default:
        ; // Do nothing.
    }

    advance();
  }
}
compiler.c,在 printStatement() 後面新增

我們會隨意跳過 token,直到遇到看起來像陳述式邊界的東西。我們透過尋找可以結束陳述式的先前 token(例如分號)來識別邊界。或者我們會尋找開始陳述式的後續 token,通常是控制流程或宣告關鍵字之一。

21 . 2變數宣告

僅僅能夠 print 並不會讓您的語言在程式語言展覽會上贏得任何獎項,所以讓我們繼續進行一些更有雄心的工作,並開始處理變數。我們需要支援三個操作

在我們擁有某些變數之前,我們無法執行後兩項操作,所以我們先從宣告開始。

static void declaration() {
compiler.c
declaration() 中
取代 1 行
  if (match(TOKEN_VAR)) {
    varDeclaration();
  } else {
    statement();
  }
  if (parser.panicMode) synchronize();
compiler.c,在 declaration() 中,取代 1 行

我們為宣告文法規則草擬的佔位符解析函式現在有了一個實際的產生式。如果我們匹配到 var token,我們會跳到這裡

compiler.c
expression() 之後新增
static void varDeclaration() {
  uint8_t global = parseVariable("Expect variable name.");

  if (match(TOKEN_EQUAL)) {
    expression();
  } else {
    emitByte(OP_NIL);
  }
  consume(TOKEN_SEMICOLON,
          "Expect ';' after variable declaration.");

  defineVariable(global);
}
compiler.c,在 expression() 之後新增

關鍵字後面接著變數名稱。這是由 parseVariable() 編譯的,我們稍後會介紹它。然後我們尋找一個 =,後面接著初始化表達式。如果使用者沒有初始化變數,編譯器會透過發出 OP_NIL 指令隱式地將其初始化為 nil。無論如何,我們都希望陳述式以分號終止。

這裡有兩個用於處理變數和識別符號的新函式。這是第一個

static void parsePrecedence(Precedence precedence);

compiler.c
parsePrecedence() 後面新增
static uint8_t parseVariable(const char* errorMessage) {
  consume(TOKEN_IDENTIFIER, errorMessage);
  return identifierConstant(&parser.previous);
}
compiler.c,在 parsePrecedence() 後面新增

它要求下一個 token 是一個識別符號,它會消耗這個 token 並將其傳送到這裡

static void parsePrecedence(Precedence precedence);

compiler.c
parsePrecedence() 後面新增
static uint8_t identifierConstant(Token* name) {
  return makeConstant(OBJ_VAL(copyString(name->start,
                                         name->length)));
}
compiler.c,在 parsePrecedence() 後面新增

此函式會取得給定的 token,並將其詞素以字串的形式新增到區塊的常數表中。然後它會傳回該常數在常數表中的索引。

全域變數會在執行時依名稱查找。這表示 VM(位元組碼直譯器迴圈)需要存取該名稱。整個字串太大了,無法作為運算元塞進位元組碼串流中。相反地,我們將字串儲存在常數表中,然後指令會依據其在表中的索引參照該名稱。

此函式會將該索引一直傳回給 varDeclaration(),後者稍後會將其傳遞到這裡

compiler.c
parseVariable() 後面新增
static void defineVariable(uint8_t global) {
  emitBytes(OP_DEFINE_GLOBAL, global);
}
compiler.c,在 parseVariable() 後面新增

會輸出位元組碼指令,該指令會定義新的變數並儲存其初始值。變數名稱在常數表中的索引是指令的運算元。在基於堆疊的 VM 中,我們通常會最後發出此指令。在執行時,我們會先執行變數初始化器的程式碼。這會在堆疊上留下該值。然後,此指令會取得該值並將其儲存起來以供稍後使用。

在執行時,我們先從這個新指令開始

  OP_POP,
chunk.h
在 enum OpCode
  OP_DEFINE_GLOBAL,
  OP_EQUAL,
chunk.h,在 enum OpCode

感謝我們方便好用的雜湊表,實作並不太難。

      case OP_POP: pop(); break;
vm.c
run() 中
      case OP_DEFINE_GLOBAL: {
        ObjString* name = READ_STRING();
        tableSet(&vm.globals, name, peek(0));
        pop();
        break;
      }
      case OP_EQUAL: {
vm.c,在 run() 中

我們從常數表中取得變數的名稱。然後我們從堆疊頂部取得該值,並將其儲存在一個雜湊表中,該雜湊表的鍵就是該名稱。

此程式碼不會檢查索引鍵是否已存在於表中。Lox 對全域變數相當寬鬆,允許您重新定義它們而不會發生錯誤。這在 REPL 工作階段中很有用,因此 VM 會透過簡單地覆寫值(如果索引鍵恰好已存在於雜湊表中)來支援此功能。

還有另一個小小的輔助巨集

#define READ_CONSTANT() (vm.chunk->constants.values[READ_BYTE()])
vm.c
run() 中
#define READ_STRING() AS_STRING(READ_CONSTANT())
#define BINARY_OP(valueType, op) \
vm.c,在 run() 中

它會從位元組碼區塊中讀取一個位元組的運算元。它將其視為區塊常數表中的索引,並傳回該索引處的字串。它不會檢查該值是否為字串它只是隨意地轉換它。這是安全的,因為編譯器絕不會發出參照非字串常數的指令。

因為我們關心詞法整潔,所以我們也會在 interpret 函式的結尾取消定義此巨集。

#undef READ_CONSTANT
vm.c
run() 中
#undef READ_STRING
#undef BINARY_OP
vm.c,在 run() 中

我一直說「雜湊表」,但我們實際上還沒有雜湊表。我們需要一個地方來儲存這些全域變數。由於我們希望它們在 clox 執行期間持續存在,因此我們將它們直接儲存在 VM 中。

  Value* stackTop;
vm.h
在 struct VM
  Table globals;
  Table strings;
vm.h,在 struct VM

如同我們對字串表所做的一樣,我們需要在 VM 啟動時將雜湊表初始化為有效狀態。

  vm.objects = NULL;
vm.c
initVM() 中
  initTable(&vm.globals);
  initTable(&vm.strings);
vm.c,在 initVM() 中

當我們退出時,我們會拆除它。

void freeVM() {
vm.c
freeVM() 中
  freeTable(&vm.globals);
  freeTable(&vm.strings);
vm.c,在 freeVM() 中

與往常一樣,我們也希望能夠反組譯新的指令。

      return simpleInstruction("OP_POP", offset);
debug.c
disassembleInstruction() 中
    case OP_DEFINE_GLOBAL:
      return constantInstruction("OP_DEFINE_GLOBAL", chunk,
                                 offset);
    case OP_EQUAL:
debug.c,在 disassembleInstruction() 中

有了這個,我們就可以定義全域變數了。並非使用者可以得知他們已經這樣做,因為他們實際上無法使用它們。所以我們接下來要修正這個問題。

21 . 3讀取變數

就像每個程式語言一樣,我們使用變數的名稱來存取變數的值。我們在這裡將識別符號 token 連接到表達式解析器

  [TOKEN_LESS_EQUAL]    = {NULL,     binary, PREC_COMPARISON},
compiler.c
取代 1 行
  [TOKEN_IDENTIFIER]    = {variable, NULL,   PREC_NONE},
  [TOKEN_STRING]        = {string,   NULL,   PREC_NONE},
compiler.c,取代 1 行

這會呼叫這個新的解析器函式

compiler.c
string() 後面新增
static void variable() {
  namedVariable(parser.previous);
}
compiler.c,在 string() 後面新增

就像宣告一樣,這裡有一些微小的輔助函式,現在看起來沒有意義,但在後面的章節中會變得更有用。我保證。

compiler.c
string() 後面新增
static void namedVariable(Token name) {
  uint8_t arg = identifierConstant(&name);
  emitBytes(OP_GET_GLOBAL, arg);
}
compiler.c,在 string() 後面新增

這會呼叫先前的同一個 identifierConstant() 函式,以取得給定的識別符號 token,並將其詞素以字串的形式新增到區塊的常數表中。剩下的就是發出一個載入具有該名稱的全域變數的指令。這是指令

  OP_POP,
chunk.h
在 enum OpCode
  OP_GET_GLOBAL,
  OP_DEFINE_GLOBAL,
chunk.h,在 enum OpCode

在直譯器中,實作與 OP_DEFINE_GLOBAL 相同。

      case OP_POP: pop(); break;
vm.c
run() 中
      case OP_GET_GLOBAL: {
        ObjString* name = READ_STRING();
        Value value;
        if (!tableGet(&vm.globals, name, &value)) {
          runtimeError("Undefined variable '%s'.", name->chars);
          return INTERPRET_RUNTIME_ERROR;
        }
        push(value);
        break;
      }
      case OP_DEFINE_GLOBAL: {
vm.c,在 run() 中

我們從指令的運算元中取出常數表索引,並取得變數名稱。然後我們使用它作為索引鍵來查找全域雜湊表中變數的值。

如果雜湊表中不存在該索引鍵,表示該全域變數從未定義。這是 Lox 中的執行階段錯誤,因此我們會報告錯誤,並在這種情況下退出直譯器迴圈。否則,我們會取得該值並將其推送至堆疊。

      return simpleInstruction("OP_POP", offset);
debug.c
disassembleInstruction() 中
    case OP_GET_GLOBAL:
      return constantInstruction("OP_GET_GLOBAL", chunk, offset);
    case OP_DEFINE_GLOBAL:
debug.c,在 disassembleInstruction() 中

經過一些反組譯後,我們就完成了。我們的直譯器現在能夠執行像這樣的程式碼

var beverage = "cafe au lait";
var breakfast = "beignets with " + beverage;
print breakfast;

只剩下一個操作。

21 . 4賦值

在這本書中,我一直試圖讓您走在一條相當安全且容易的道路上。我並沒有迴避困難的問題,但我會盡量不讓解決方案比它們需要更複雜。唉,我們 位元組碼 編譯器中的其他設計選擇使賦值難以實作。

我們的位元組碼 VM 使用單趟編譯器。它會動態地解析和產生位元組碼,而不會有任何中間 AST。一旦它識別出一段語法,就會為其發出程式碼。賦值不自然地符合這一點。請考慮

menu.brunch(sunday).beverage = "mimosa";

在這個程式碼中,解析器直到到達 = 時才意識到 menu.brunch(sunday).beverage 是賦值的目標,而不是正常的表達式,而此時已是在第一個 menu 之後的許多 token。到那時,編譯器已經發出整個東西的位元組碼了。

不過,問題並不像看起來那麼嚴重。看看解析器如何看待該範例

The 'menu.brunch(sunday).beverage = "mimosa"' statement, showing that 'menu.brunch(sunday)' is an expression.

即使 .beverage 部分不得編譯為 get 表達式,但 . 左側的所有內容都是表達式,具有正常的表達式語意。menu.brunch(sunday) 部分可以像往常一樣編譯和執行。

對我們來說幸運的是,賦值左側唯一的語意差異出現在 token 的最右邊,緊接在 = 之前。即使 setter 的接收器可能是任意長的表達式,但其行為與 get 表達式不同的部分僅為尾隨的識別符號,它就在 = 的正前方。我們不需要太多前瞻就可以意識到 beverage 應該編譯為 set 表達式,而不是 getter。

變數甚至更容易,因為它們只是 = 之前的一個單一裸識別符號。因此,想法是,在編譯也可用作賦值目標的表達式之前,我們會尋找後續的 = token。如果我們看到一個,我們會將其編譯為賦值或 setter,而不是變數存取或 getter。

我們還不需要擔心 setter,因此我們需要處理的只是變數。

  uint8_t arg = identifierConstant(&name);
compiler.c
namedVariable() 中
取代 1 行
  if (match(TOKEN_EQUAL)) {
    expression();
    emitBytes(OP_SET_GLOBAL, arg);
  } else {
    emitBytes(OP_GET_GLOBAL, arg);
  }
}
compiler.c,在 namedVariable() 中,取代 1 行

在識別符號表達式的解析函式中,我們會尋找識別符號後面的等號。如果我們找到一個,我們不會發出變數存取的程式碼,而是會編譯賦值,然後發出賦值指令。

這是我們在本章中需要新增的最後一個指令。

  OP_DEFINE_GLOBAL,
chunk.h
在 enum OpCode
  OP_SET_GLOBAL,
  OP_EQUAL,
chunk.h,在 enum OpCode

正如您所預料的那樣,它的執行階段行為與定義新變數相似。

      }
vm.c
run() 中
      case OP_SET_GLOBAL: {
        ObjString* name = READ_STRING();
        if (tableSet(&vm.globals, name, peek(0))) {
          tableDelete(&vm.globals, name); 
          runtimeError("Undefined variable '%s'.", name->chars);
          return INTERPRET_RUNTIME_ERROR;
        }
        break;
      }
      case OP_EQUAL: {
vm.c,在 run() 中

主要區別在於當索引鍵在全域雜湊表中不存在時會發生什麼。如果變數尚未定義,嘗試賦值給它是一個執行階段錯誤。Lox 不會執行隱式變數宣告

另一個差異在於,設定變數並不會將值從堆疊中彈出。請記住,賦值是一個表達式,所以它需要將該值留在原地,以防賦值嵌套在較大的表達式中。

加入一點反組譯

      return constantInstruction("OP_DEFINE_GLOBAL", chunk,
                                 offset);
debug.c
disassembleInstruction() 中
    case OP_SET_GLOBAL:
      return constantInstruction("OP_SET_GLOBAL", chunk, offset);
    case OP_EQUAL:
debug.c,在 disassembleInstruction() 中

所以我們完成了,對吧?嗯 . . . 不完全是。我們犯了一個錯誤!看看這個

a * b = c + d;

根據 Lox 的語法,= 的優先順序最低,所以它應該大致被解析為

The expected parse, like '(a * b) = (c + d)'.

顯然,a * b 不是一個有效的賦值目標,所以這應該是一個語法錯誤。但這是我們的解析器所做的

  1. 首先,parsePrecedence() 使用 variable() 前綴解析器解析 a
  2. 之後,它進入中綴解析迴圈。
  3. 它到達 * 並呼叫 binary()
  4. 它遞迴地呼叫 parsePrecedence() 來解析右側運算元。
  5. 它再次呼叫 variable() 來解析 b
  6. 在該 variable() 呼叫內部,它會尋找尾隨的 =。它看到一個,因此將該行的其餘部分解析為賦值。

換句話說,解析器將上述程式碼視為

The actual parse, like 'a * (b = c + d)'.

我們搞砸了優先順序處理,因為 variable() 沒有考慮到包含變數的周圍表達式的優先順序。如果變數恰好是中綴運算子的右側,或是一元運算子的運算元,那麼該包含表達式的優先順序太高,不允許出現 =

為了修正這個問題,variable() 應該只在低優先順序表達式的上下文中尋找並消耗 =。邏輯上,知道目前優先順序的程式碼是 parsePrecedence()variable() 函式不需要知道實際的級別。它只關心優先順序是否足夠低以允許賦值,因此我們將這個事實作為布林值傳入。

    error("Expect expression.");
    return;
  }

compiler.c
parsePrecedence() 中
取代 1 行
  bool canAssign = precedence <= PREC_ASSIGNMENT;
  prefixRule(canAssign);
  while (precedence <= getRule(parser.current.type)->precedence) {
compiler.c,在 parsePrecedence() 中,取代 1 行

由於賦值是最低優先順序的表達式,我們只在解析賦值表達式或頂層表達式時允許賦值,例如在表達式語句中。該標誌會傳遞到這裡的解析器函式

compiler.c
函式 variable()
取代 3 行
static void variable(bool canAssign) {
  namedVariable(parser.previous, canAssign);
}
compiler.c,函式 variable(),取代 3 行

這會透過一個新的參數傳遞它

compiler.c
函式 namedVariable()
取代 1 行
static void namedVariable(Token name, bool canAssign) {
  uint8_t arg = identifierConstant(&name);
compiler.c,函式 namedVariable(),取代 1 行

然後最終在這裡使用它

  uint8_t arg = identifierConstant(&name);

compiler.c
namedVariable() 中
取代 1 行
  if (canAssign && match(TOKEN_EQUAL)) {
    expression();
compiler.c,在 namedVariable() 中,取代 1 行

為了讓一個位元的資料正確傳送到編譯器中的正確位置,這需要大量的管道工程,但它已經到達了。如果變數嵌套在具有較高優先順序的表達式中,則 canAssign 將為 false,即使那裡有一個 =,也會忽略它。然後 namedVariable() 返回,並且執行最終會返回到 parsePrecedence()

然後呢?編譯器會如何處理我們之前壞掉的範例?現在,variable() 不會消耗 =,因此它將成為目前的符號。編譯器從 variable() 前綴解析器返回到 parsePrecedence(),然後嘗試進入中綴解析迴圈。沒有與 = 相關聯的解析函式,因此它會跳過該迴圈。

然後 parsePrecedence() 會靜默地返回給呼叫者。這也不對。如果 = 沒有被作為表達式的一部分消耗掉,那麼沒有其他東西會消耗它。這是一個錯誤,我們應該報告它。

    infixRule();
  }
compiler.c
parsePrecedence() 中
  if (canAssign && match(TOKEN_EQUAL)) {
    error("Invalid assignment target.");
  }
}
compiler.c,在 parsePrecedence() 中

有了這個,之前的錯誤程式就能在編譯時正確地得到錯誤。好吧,*現在* 我們完成了嗎?仍然不完全是。你看,我們正在將參數傳遞給其中一個解析函式。但是這些函式儲存在函式指標表中,因此所有解析函式都需要具有相同的類型。即使大多數解析函式不支援被用作賦值目標設定器是唯一另一個我們友善的 C 編譯器要求它們*全部*都接受參數。

所以我們將以一些繁重的工作來完成本章。首先,讓我們將標誌傳遞給中綴解析函式。

    ParseFn infixRule = getRule(parser.previous.type)->infix;
compiler.c
parsePrecedence() 中
取代 1 行
    infixRule(canAssign);
  }
compiler.c,在 parsePrecedence() 中,取代 1 行

我們最終會需要設定器。然後我們將修復函式類型的 typedef。

} Precedence;

compiler.c
在 enum Precedence 之後新增
取代 1 行
typedef void (*ParseFn)(bool canAssign);
typedef struct {
compiler.c,在 enum Precedence 之後新增,取代 1 行

以及一些完全乏味的程式碼,以便在我們現有的所有解析函式中接受此參數。在這裡

compiler.c
函式 binary()
取代 1 行
static void binary(bool canAssign) {
  TokenType operatorType = parser.previous.type;
compiler.c,函式 binary(),取代 1 行

在這裡

compiler.c
函式 literal()
取代 1 行
static void literal(bool canAssign) {
  switch (parser.previous.type) {
compiler.c,函式 literal(),取代 1 行

在這裡

compiler.c
函式 grouping()
取代 1 行
static void grouping(bool canAssign) {
  expression();
compiler.c,函式 grouping(),取代 1 行

在這裡

compiler.c
函式 number()
取代 1 行
static void number(bool canAssign) {
  double value = strtod(parser.previous.start, NULL);
compiler.c,函式 number(),取代 1 行

這裡也是

compiler.c
函式 string()
取代 1 行
static void string(bool canAssign) {
  emitConstant(OBJ_VAL(copyString(parser.previous.start + 1,
compiler.c,函式 string(),取代 1 行

最後

compiler.c
函式 unary()
取代 1 行
static void unary(bool canAssign) {
  TokenType operatorType = parser.previous.type;
compiler.c,函式 unary(),取代 1 行

呼!我們回到了可以編譯的 C 程式。啟動它,現在你可以執行這個

var breakfast = "beignets";
var beverage = "cafe au lait";
breakfast = "beignets with " + beverage;

print breakfast;

它開始看起來像是實際語言的真實程式碼!

挑戰

  1. 每次遇到識別符號時,編譯器都會將全域變數的名稱作為字串新增到常數表。它每次都會建立一個新的常數,即使該變數名稱已經在常數表中的前一個槽中。如果同一個函式多次引用同一個變數,那會很浪費。反過來,這會增加填滿常數表並用完槽的機率,因為我們在單個區塊中只允許 256 個常數。

    最佳化這個。您的最佳化如何影響編譯器的效能,與執行階段相比?這是正確的權衡嗎?

  2. 每次使用全域變數時,都透過雜湊表依名稱查找它,即使使用好的雜湊表,速度也很慢。您是否可以提出更有效率的方式來儲存和存取全域變數,而無需更改語義?

  3. 在 REPL 中執行時,使用者可能會編寫一個引用未知全域變數的函式。然後,在下一行,他們宣告該變數。Lox 應該優雅地處理這個問題,在首次定義函式時不要報告「未知變數」編譯錯誤。

    但是當使用者執行 Lox *腳本* 時,編譯器在任何程式碼執行之前都可以存取整個程式的完整文字。請考慮這個程式

    fun useVar() {
      print oops;
    }
    
    var ooops = "too many o's!";
    

    在這裡,我們可以靜態地判斷 oops 不會被定義,因為程式中任何地方都*沒有*該全域變數的宣告。請注意,useVar() 也從未被呼叫,因此即使該變數未定義,也不會發生執行階段錯誤,因為它也從未使用過。

    我們可以將此類錯誤回報為編譯錯誤,至少在從腳本執行時。您認為我們應該這樣做嗎?為您的答案辯護。您所知道的其他腳本語言會怎麼做?