Local Variables
Và khi trí tưởng tượng hiện hình
Những hình thái của điều chưa biết, ngòi bút thi nhân
Biến chúng thành hình dạng và trao cho hư vô
Một chốn trú ngụ và một cái tên.William Shakespeare, Giấc mộng đêm hè
Chương trước đã giới thiệu biến trong clox, nhưng mới chỉ ở dạng global. Trong chương này, ta sẽ mở rộng để hỗ trợ block, block scope và biến local. Trong jlox, ta gói gọn cả phần này và biến global trong một chương. Với clox, đây sẽ là khối lượng công việc của hai chương, một phần vì, thật lòng mà nói, mọi thứ trong C đều tốn nhiều công sức hơn.
Nhưng lý do quan trọng hơn là cách tiếp cận với biến local của ta sẽ khác hẳn so với khi hiện thực biến global. Trong Lox, biến global được late bound. “Late” ở đây nghĩa là “được resolve sau khi compile xong”. Điều này tốt cho việc giữ compiler đơn giản, nhưng không tốt cho hiệu năng. Biến local là một trong những thành phần được dùng nhiều nhất trong một ngôn ngữ. Nếu biến local chậm, mọi thứ sẽ chậm. Vậy nên ta muốn một chiến lược cho biến local hiệu quả nhất có thể.
May mắn thay, lexical scoping sẽ giúp ta. Như tên gọi, lexical scope nghĩa là ta có thể resolve một biến local chỉ bằng cách nhìn vào văn bản chương trình — biến local không phải late bound. Bất kỳ xử lý nào ta làm ở compiler là công việc ta không phải làm ở runtime, nên việc hiện thực biến local sẽ tận dụng tối đa compiler.
22 . 1Biểu diễn biến Local
Điều tuyệt vời khi “mổ xẻ” một ngôn ngữ lập trình thời nay là ta có cả một lịch sử dài các ngôn ngữ khác để học hỏi. Vậy C và Java quản lý biến local thế nào? Tất nhiên là trên stack! Chúng thường dùng cơ chế stack gốc do chip và hệ điều hành hỗ trợ. Điều đó hơi quá thấp tầng với ta, nhưng trong thế giới ảo của clox, ta có stack riêng để dùng.
Hiện tại, ta mới chỉ dùng nó để giữ temporaries — những dữ liệu sống ngắn mà ta cần nhớ trong khi tính toán một biểu thức. Miễn là không cản trở chúng, ta có thể “nhét” biến local vào stack luôn. Điều này rất tốt cho hiệu năng. Cấp phát chỗ cho một biến local mới chỉ cần tăng con trỏ stackTop
, và giải phóng cũng chỉ cần giảm nó. Truy cập một biến ở vị trí stack đã biết chỉ là một phép truy cập mảng theo chỉ số.
Tuy nhiên, ta cần cẩn thận. VM kỳ vọng stack hoạt động đúng nghĩa “stack”. Ta chỉ được phép cấp phát biến local mới ở đỉnh stack, và chỉ được bỏ một biến local khi không còn gì nằm trên nó. Ngoài ra, cần đảm bảo temporaries không gây cản trở.
May mắn là thiết kế của Lox rất ăn khớp với các ràng buộc này. Biến local mới luôn được tạo bởi câu lệnh khai báo. Câu lệnh không lồng bên trong biểu thức, nên sẽ không bao giờ có temporary trên stack khi một câu lệnh bắt đầu execute. Các block được lồng chặt chẽ. Khi một block kết thúc, nó luôn “mang theo” các biến local được khai báo gần nhất, bên trong nhất. Vì đó cũng là những biến vào scope sau cùng, chúng sẽ nằm trên đỉnh stack — đúng vị trí ta cần.
Hãy bước qua ví dụ chương trình này và quan sát cách các biến local xuất hiện và biến mất khỏi scope:

Thấy chúng khớp với mô hình stack hoàn hảo chứ? Có vẻ stack sẽ hoạt động tốt để lưu biến local ở runtime. Nhưng ta còn có thể tiến xa hơn. Không chỉ biết rằng chúng sẽ nằm trên stack, ta còn có thể xác định chính xác vị trí của chúng trên stack. Vì compiler biết chính xác biến local nào đang trong scope tại mọi thời điểm, nó có thể mô phỏng stack trong quá trình compile và ghi chú vị trí của từng biến trong stack.
Ta sẽ tận dụng điều này bằng cách dùng các stack offset này làm toán hạng cho bytecode instruction đọc và ghi biến local. Điều này khiến việc làm việc với biến local nhanh đến “ngon lành” — đơn giản như truy cập một phần tử mảng.
Có khá nhiều trạng thái mà ta cần phải theo dõi trong compiler để mọi thứ vận hành, nên hãy bắt đầu từ đây. Trong jlox, ta dùng một chuỗi liên kết các HashMap “environment” để theo dõi biến local nào đang nằm trong scope. Đây là cách khá kinh điển, kiểu “sách giáo khoa” để biểu diễn lexical scope. Với clox, như thường lệ, ta sẽ tiến gần hơn tới “kim loại” (low-level). Tất cả trạng thái sẽ nằm trong một struct mới.
} ParseRule;
add after struct ParseRule
typedef struct { Local locals[UINT8_COUNT]; int localCount; int scopeDepth; } Compiler;
Parser parser;
Ta có một mảng phẳng, đơn giản chứa tất cả các biến local đang trong scope tại mỗi thời điểm trong quá trình compile. Chúng được sắp xếp trong mảng theo thứ tự khai báo xuất hiện trong code. Vì toán hạng instruction mà ta dùng để mã hóa một biến local chỉ là một byte, VM của ta có một giới hạn cứng về số lượng biến local có thể cùng nằm trong scope. Điều đó cũng có nghĩa là ta có thể đặt kích thước cố định cho mảng locals.
#define DEBUG_TRACE_EXECUTION
#define UINT8_COUNT (UINT8_MAX + 1)
#endif
Quay lại struct Compiler, trường localCount
theo dõi số lượng biến local đang trong scope — tức là số slot trong mảng đang được dùng. Ta cũng theo dõi “scope depth”. Đây là số lượng block bao quanh đoạn code hiện tại đang compile.
Interpreter Java của ta dùng một chuỗi các map để tách biến của từng block khỏi các block khác. Lần này, ta sẽ đơn giản đánh số biến theo mức độ lồng nhau nơi chúng xuất hiện. Zero là global scope, một là block cấp cao nhất, hai là bên trong nó, bạn hiểu ý rồi đấy. Ta dùng thông tin này để biết mỗi biến local thuộc block nào, từ đó biết biến nào cần bỏ khi block kết thúc.
Mỗi biến local trong mảng là một struct như sau:
} ParseRule;
add after struct ParseRule
typedef struct { Token name; int depth; } Local;
typedef struct {
Ta lưu tên của biến. Khi resolve một identifier, ta so sánh lexeme của identifier với tên của từng biến local để tìm khớp. Khá khó để resolve một biến nếu bạn không biết tên của nó. Trường depth
ghi lại scope depth của block nơi biến local được khai báo. Đó là tất cả trạng thái ta cần lúc này.
Đây là một cách biểu diễn rất khác so với jlox, nhưng nó vẫn cho phép ta trả lời tất cả các câu hỏi mà compiler cần hỏi về lexical environment. Bước tiếp theo là tìm cách để compiler truy cập trạng thái này. Nếu là những kỹ sư nguyên tắc, ta sẽ truyền một tham số trỏ tới Compiler vào mỗi hàm ở front end. Ta sẽ tạo một Compiler lúc bắt đầu và cẩn thận “luồn” nó qua từng lời gọi hàm… nhưng điều đó sẽ đòi hỏi rất nhiều thay đổi nhàm chán vào code đã viết, nên thay vào đó, ta dùng một biến global:
Parser parser;
add after variable parser
Compiler* current = NULL;
Chunk* compilingChunk;
Đây là một hàm nhỏ để khởi tạo compiler:
add after emitConstant()
static void initCompiler(Compiler* compiler) { compiler->localCount = 0; compiler->scopeDepth = 0; current = compiler; }
Khi VM khởi động lần đầu, ta gọi nó để đưa mọi thứ về trạng thái sạch.
initScanner(source);
in compile()
Compiler compiler; initCompiler(&compiler);
compilingChunk = chunk;
Compiler của ta đã có dữ liệu cần thiết, nhưng chưa có thao tác trên dữ liệu đó. Chưa có cách để tạo và hủy scope, hoặc thêm và resolve biến. Ta sẽ bổ sung những thứ đó khi cần. Trước hết, hãy bắt đầu xây dựng một số tính năng ngôn ngữ.
22 . 2Câu lệnh Block
Trước khi có biến local, ta cần có local scope. Chúng đến từ hai thứ: thân hàm và block. Hàm là một khối lượng công việc lớn mà ta sẽ xử lý trong một chương sau, nên bây giờ ta chỉ làm block. Như thường lệ, ta bắt đầu với cú pháp. Grammar mới sẽ là:
statement → exprStmt | printStmt | block ; block → "{" declaration* "}" ;
Block là một loại statement, nên rule cho nó nằm trong production statement
. Code compile tương ứng trông như sau:
if (match(TOKEN_PRINT)) { printStatement();
in statement()
} else if (match(TOKEN_LEFT_BRACE)) { beginScope(); block(); endScope();
} else {
Sau khi parse dấu ngoặc nhọn mở, ta dùng hàm helper này để compile phần còn lại của block:
add after expression()
static void block() { while (!check(TOKEN_RIGHT_BRACE) && !check(TOKEN_EOF)) { declaration(); } consume(TOKEN_RIGHT_BRACE, "Expect '}' after block."); }
Hàm này tiếp tục parse các declaration và statement cho đến khi gặp dấu ngoặc nhọn đóng. Như với bất kỳ vòng lặp nào trong parser, ta cũng kiểm tra xem đã hết token chưa. Bằng cách này, nếu chương trình bị lỗi thiếu dấu ngoặc nhọn đóng, compiler sẽ không bị kẹt trong vòng lặp.
Execute một block đơn giản là execute các câu lệnh bên trong nó, lần lượt, nên compile chúng cũng không có gì phức tạp. Điều thú vị về mặt ngữ nghĩa mà block làm là tạo scope. Trước khi compile thân block, ta gọi hàm này để vào một local scope mới:
add after endCompiler()
static void beginScope() { current->scopeDepth++; }
Để “tạo” một scope, tất cả những gì ta làm là tăng depth hiện tại. Cách này chắc chắn nhanh hơn jlox, vốn cấp phát hẳn một HashMap mới cho mỗi scope. Với beginScope()
, bạn có thể đoán endScope()
làm gì.
add after beginScope()
static void endScope() { current->scopeDepth--; }
Vậy là xong phần block và scope — gần như thế — giờ ta đã sẵn sàng để đưa biến vào trong chúng.
22 . 3Khai báo biến Local
Thông thường ở đây ta sẽ bắt đầu với parsing, nhưng compiler của ta vốn đã hỗ trợ parse và compile khai báo biến. Ta đã có câu lệnh var
, biểu thức identifier và gán giá trị. Chỉ là compiler hiện tại giả định tất cả biến đều là global. Vậy nên ta không cần thêm hỗ trợ parsing mới, chỉ cần kết nối ngữ nghĩa scoping mới vào code hiện có.

Quá trình parse khai báo biến bắt đầu trong varDeclaration()
và dựa vào một vài hàm khác. Đầu tiên, parseVariable()
tiêu thụ token identifier cho tên biến, thêm lexeme của nó vào constant table của chunk dưới dạng string, rồi trả về chỉ số trong constant table nơi nó được thêm vào. Sau đó, khi varDeclaration()
compile xong phần initializer, nó gọi defineVariable()
để phát sinh bytecode lưu giá trị của biến vào bảng băm biến global.
Cả hai helper này cần một vài thay đổi để hỗ trợ biến local. Trong parseVariable()
, ta thêm:
consume(TOKEN_IDENTIFIER, errorMessage);
in parseVariable()
declareVariable(); if (current->scopeDepth > 0) return 0;
return identifierConstant(&parser.previous);
Trước hết, ta “declare” biến. Tôi sẽ giải thích “declare” nghĩa là gì ngay sau đây. Sau đó, nếu đang ở trong local scope, ta thoát khỏi hàm. Ở runtime, biến local không được tra cứu theo tên. Không cần nhét tên biến vào constant table, nên nếu khai báo nằm trong local scope, ta trả về một chỉ số giả trong bảng.
Bên phía defineVariable()
, nếu đang ở trong local scope, ta cần phát sinh code để lưu biến local. Nó trông như thế này:
static void defineVariable(uint8_t global) {
in defineVariable()
if (current->scopeDepth > 0) { return; }
emitBytes(OP_DEFINE_GLOBAL, global);
Khoan, vậy thôi sao? Đúng vậy. Không có code nào để “tạo” biến local ở runtime cả. Hãy nghĩ về trạng thái của VM lúc này: nó đã execute xong code của phần initializer (hoặc nil
ngầm định nếu người dùng bỏ qua initializer), và giá trị đó đang nằm trên đỉnh stack như temporary duy nhất còn lại. Ta cũng biết biến local mới được cấp phát ở đỉnh stack… đúng vị trí giá trị đó đang ở. Vậy nên chẳng cần làm gì thêm. Temporary đó đơn giản trở thành biến local. Khó mà hiệu quả hơn được nữa.

OK, vậy “declare” ở đây là gì? Đây là những gì nó làm:
add after identifierConstant()
static void declareVariable() { if (current->scopeDepth == 0) return; Token* name = &parser.previous; addLocal(*name); }
Đây là lúc compiler ghi nhận sự tồn tại của biến. Ta chỉ làm điều này cho biến local, nên nếu đang ở global scope cấp cao nhất, ta bỏ qua. Vì biến global là late bound, compiler không theo dõi các khai báo của chúng.
Nhưng với biến local, compiler cần nhớ rằng biến tồn tại. Đó chính là việc “declare” — thêm biến vào danh sách biến trong scope hiện tại của compiler. Ta hiện thực điều này bằng một hàm mới khác:
add after identifierConstant()
static void addLocal(Token name) { Local* local = ¤t->locals[current->localCount++]; local->name = name; local->depth = current->scopeDepth; }
Hàm này khởi tạo Local tiếp theo còn trống trong mảng biến của compiler. Nó lưu tên biến và độ sâu scope sở hữu biến đó.
Cách hiện thực này ổn với một chương trình Lox hợp lệ, nhưng còn code không hợp lệ thì sao? Ta nên hướng tới sự “chắc chắn”. Lỗi đầu tiên cần xử lý thực ra không phải lỗi của người dùng, mà là giới hạn của VM. Các instruction làm việc với biến local tham chiếu chúng bằng slot index. Chỉ số này được lưu trong một toán hạng một byte, nghĩa là VM chỉ hỗ trợ tối đa 256 biến local trong scope cùng lúc.
Nếu vượt quá, không chỉ không thể tham chiếu chúng ở runtime, mà compiler còn ghi đè chính mảng locals của nó. Hãy ngăn điều đó:
static void addLocal(Token name) {
in addLocal()
if (current->localCount == UINT8_COUNT) { error("Too many local variables in function."); return; }
Local* local = ¤t->locals[current->localCount++];
Trường hợp tiếp theo phức tạp hơn. Xem ví dụ:
{ var a = "first"; var a = "second"; }
Ở cấp cao nhất, Lox cho phép khai báo lại biến cùng tên với một khai báo trước đó vì điều này hữu ích cho REPL. Nhưng bên trong local scope, đó là một điều khá kỳ quặc. Nhiều khả năng đây là lỗi, và nhiều ngôn ngữ, bao gồm Lox của chúng ta, coi đây là lỗi.
Lưu ý rằng chương trình trên khác với chương trình này:
{ var a = "outer"; { var a = "inner"; } }
Việc có hai biến cùng tên trong các scope khác nhau là OK, ngay cả khi các scope này chồng lấn và cả hai cùng hiển thị tại một thời điểm. Đó là shadowing, và Lox cho phép. Chỉ khi có hai biến cùng tên trong cùng một local scope mới là lỗi.
Ta phát hiện lỗi này như sau:
Token* name = &parser.previous;
in declareVariable()
for (int i = current->localCount - 1; i >= 0; i--) { Local* local = ¤t->locals[i]; if (local->depth != -1 && local->depth < current->scopeDepth) { break; } if (identifiersEqual(name, &local->name)) { error("Already a variable with this name in this scope."); } }
addLocal(*name); }
Biến local được thêm vào cuối mảng khi chúng được khai báo, nghĩa là scope hiện tại luôn nằm ở cuối mảng. Khi ta khai báo một biến mới, ta bắt đầu từ cuối và duyệt ngược lại, tìm một biến đã tồn tại có cùng tên. Nếu tìm thấy một biến trong scope hiện tại, ta báo lỗi. Ngược lại, nếu ta đi đến đầu mảng hoặc gặp một biến thuộc scope khác, thì ta biết mình đã kiểm tra hết tất cả biến trong scope đó.
Để xem hai identifier có giống nhau không, ta dùng hàm sau:
add after identifierConstant()
static bool identifiersEqual(Token* a, Token* b) { if (a->length != b->length) return false; return memcmp(a->start, b->start, a->length) == 0; }
Vì ta biết độ dài của cả hai lexeme, ta kiểm tra điều đó trước. Điều này sẽ loại nhanh nhiều chuỗi không bằng nhau. Nếu độ dài giống nhau, ta kiểm tra các ký tự bằng memcmp()
. Để dùng memcmp()
, ta cần include thêm.
#include <stdlib.h>
#include <string.h>
#include "common.h"
Với điều này, ta đã có thể “tạo ra” biến. Nhưng giống như những bóng ma, chúng vẫn “lảng vảng” sau khi scope nơi chúng được khai báo đã kết thúc. Khi một block kết thúc, ta cần “an táng” chúng.
current->scopeDepth--;
in endScope()
while (current->localCount > 0 && current->locals[current->localCount - 1].depth > current->scopeDepth) { emitByte(OP_POP); current->localCount--; }
}
Khi ta pop một scope, ta duyệt ngược qua mảng local để tìm bất kỳ biến nào được khai báo ở scope depth vừa rời khỏi. Ta loại bỏ chúng đơn giản bằng cách giảm độ dài mảng.
Có một phần liên quan đến runtime ở đây. Biến local chiếm các slot trên stack. Khi một biến local ra khỏi scope, slot đó không còn cần thiết và nên được giải phóng. Vì vậy, với mỗi biến bị loại bỏ, ta cũng phát sinh một lệnh OP_POP
để pop nó khỏi stack.
22 . 4Sử dụng biến Local
Giờ ta đã có thể compile và execute khai báo biến local. Ở runtime, giá trị của chúng nằm đúng vị trí trên stack. Hãy bắt đầu sử dụng chúng. Ta sẽ làm cả truy cập và gán biến cùng lúc vì chúng dùng chung các hàm trong compiler.
Ta đã có code để lấy và gán biến global, và — như những kỹ sư phần mềm tốt bụng — ta muốn tái sử dụng càng nhiều code hiện có càng tốt. Đại loại như thế này:
static void namedVariable(Token name, bool canAssign) {
in namedVariable()
replace 1 line
uint8_t getOp, setOp; int arg = resolveLocal(current, &name); if (arg != -1) { getOp = OP_GET_LOCAL; setOp = OP_SET_LOCAL; } else { arg = identifierConstant(&name); getOp = OP_GET_GLOBAL; setOp = OP_SET_GLOBAL; }
if (canAssign && match(TOKEN_EQUAL)) {
Thay vì hardcode các bytecode instruction được phát sinh cho việc truy cập và gán biến, ta dùng một vài biến C. Đầu tiên, ta thử tìm một biến local với tên đã cho. Nếu tìm thấy, ta dùng instruction dành cho biến local. Nếu không, ta giả định đó là biến global và dùng instruction bytecode hiện có cho biến global.
Ngay bên dưới, ta dùng các biến đó để phát sinh instruction phù hợp. Với gán giá trị:
if (canAssign && match(TOKEN_EQUAL)) { expression();
in namedVariable()
replace 1 line
emitBytes(setOp, (uint8_t)arg);
} else {
Và với truy cập:
emitBytes(setOp, (uint8_t)arg); } else {
in namedVariable()
replace 1 line
emitBytes(getOp, (uint8_t)arg);
}
Phần cốt lõi của chương này, nơi ta resolve một biến local, nằm ở đây:
add after identifiersEqual()
static int resolveLocal(Compiler* compiler, Token* name) { for (int i = compiler->localCount - 1; i >= 0; i--) { Local* local = &compiler->locals[i]; if (identifiersEqual(name, &local->name)) { return i; } } return -1; }
Nhìn chung, nó khá đơn giản. Ta duyệt danh sách các biến local hiện đang trong scope. Nếu một biến có cùng tên với token identifier, thì identifier đó chắc chắn tham chiếu tới biến này. Ta đã tìm thấy nó! Ta duyệt mảng ngược lại để tìm biến được khai báo sau cùng với identifier đó. Điều này đảm bảo rằng biến local bên trong sẽ shadow đúng cách biến local cùng tên ở scope bao ngoài.
Ở runtime, ta load và store biến local bằng chỉ số slot trên stack, nên đó là thứ compiler cần tính toán sau khi resolve biến. Mỗi khi một biến được khai báo, ta thêm nó vào mảng locals trong Compiler. Điều đó có nghĩa là biến local đầu tiên ở index 0, biến tiếp theo ở index 1, và cứ thế. Nói cách khác, mảng locals trong compiler có bố cục chính xác như stack của VM ở runtime. Chỉ số của biến trong mảng locals cũng chính là slot của nó trên stack. Thật tiện lợi!
Nếu ta duyệt hết mảng mà không tìm thấy biến có tên đã cho, thì chắc chắn nó không phải biến local. Trong trường hợp đó, ta trả về -1
để báo rằng không tìm thấy và giả định nó là biến global.
22 . 4 . 1Execute biến local
Compiler của ta đang phát sinh hai instruction mới, nên hãy khiến chúng hoạt động. Đầu tiên là load một biến local:
OP_POP,
in enum OpCode
OP_GET_LOCAL,
OP_GET_GLOBAL,
Và phần hiện thực của nó:
case OP_POP: pop(); break;
in run()
case OP_GET_LOCAL: { uint8_t slot = READ_BYTE(); push(vm.stack[slot]); break; }
case OP_GET_GLOBAL: {
Nó nhận một toán hạng một byte cho slot trên stack nơi biến local nằm. Nó lấy giá trị từ chỉ số đó rồi push nó lên đỉnh stack để các instruction tiếp theo có thể tìm thấy.
Tiếp theo là gán giá trị:
OP_GET_LOCAL,
in enum OpCode
OP_SET_LOCAL,
OP_GET_GLOBAL,
Chắc bạn cũng đoán được phần hiện thực.
}
in run()
case OP_SET_LOCAL: { uint8_t slot = READ_BYTE(); vm.stack[slot] = peek(0); break; }
case OP_GET_GLOBAL: {
Nó lấy giá trị được gán từ đỉnh stack và lưu vào slot trên stack tương ứng với biến local. Lưu ý rằng nó không pop giá trị đó khỏi stack. Hãy nhớ rằng, gán là một biểu thức, và mọi biểu thức đều tạo ra một giá trị. Giá trị của một biểu thức gán chính là giá trị được gán, nên VM chỉ việc để nguyên giá trị đó trên stack.
Disassembler của ta sẽ chưa hoàn chỉnh nếu không hỗ trợ hai instruction mới này.
return simpleInstruction("OP_POP", offset);
in disassembleInstruction()
case OP_GET_LOCAL: return byteInstruction("OP_GET_LOCAL", chunk, offset); case OP_SET_LOCAL: return byteInstruction("OP_SET_LOCAL", chunk, offset);
case OP_GET_GLOBAL:
Compiler compile biến local thành truy cập trực tiếp slot. Tên của biến local không bao giờ rời khỏi compiler để xuất hiện trong chunk. Điều này rất tốt cho hiệu năng, nhưng không tốt cho việc introspection. Khi disassemble các instruction này, ta không thể hiển thị tên biến như với biến global. Thay vào đó, ta chỉ hiển thị số slot.
add after simpleInstruction()
static int byteInstruction(const char* name, Chunk* chunk, int offset) { uint8_t slot = chunk->code[offset + 1]; printf("%-16s %4d\n", name, slot); return offset + 2; }
22 . 4 . 2Một edge case khác về scope
Ta đã dành thời gian xử lý một vài edge case kỳ lạ quanh scope. Ta đảm bảo shadowing hoạt động đúng. Ta báo lỗi nếu hai biến trong cùng một local scope có cùng tên. Vì lý do nào đó mà tôi cũng không hoàn toàn rõ, scoping của biến dường như luôn có nhiều “nếp nhăn” kiểu này. Tôi chưa từng thấy ngôn ngữ nào mà nó cảm giác hoàn toàn mượt mà.
Ta còn một edge case nữa cần xử lý trước khi kết thúc chương này. Hãy nhớ lại “con quái vật” kỳ lạ mà ta từng gặp trong phần hiện thực variable resolution của jlox:
{ var a = "outer"; { var a = a; } }
Ta đã “hạ gục” nó khi đó bằng cách tách khai báo biến thành hai giai đoạn, và ta sẽ làm lại điều đó ở đây:

Ngay khi khai báo biến bắt đầu — tức là trước phần initializer — tên biến được khai báo trong scope hiện tại. Biến tồn tại, nhưng ở trạng thái đặc biệt “chưa khởi tạo”. Sau đó ta compile phần initializer. Nếu ở bất kỳ điểm nào trong biểu thức đó, ta resolve một identifier trỏ lại biến này, ta sẽ thấy nó chưa được khởi tạo và báo lỗi. Sau khi compile xong initializer, ta đánh dấu biến là đã khởi tạo và sẵn sàng sử dụng.
Để hiện thực điều này, khi khai báo một biến local, ta cần biểu thị trạng thái “chưa khởi tạo” bằng cách nào đó. Ta có thể thêm một trường mới vào Local, nhưng hãy tiết kiệm bộ nhớ hơn một chút. Thay vào đó, ta sẽ đặt scope depth của biến thành một giá trị sentinel đặc biệt, -1
.
local->name = name;
in addLocal()
replace 1 line
local->depth = -1;
}
Sau đó, khi phần initializer của biến đã được compile, ta đánh dấu nó là đã khởi tạo.
if (current->scopeDepth > 0) {
in defineVariable()
markInitialized();
return; }
Phần này được hiện thực như sau:
add after parseVariable()
static void markInitialized() { current->locals[current->localCount - 1].depth = current->scopeDepth; }
Vậy đây mới thực sự là ý nghĩa của “declare” và “define” một biến trong compiler. “Declare” là khi biến được thêm vào scope, và “define” là khi nó sẵn sàng để sử dụng.
Khi ta resolve một tham chiếu tới biến local, ta kiểm tra scope depth để xem nó đã được định nghĩa đầy đủ chưa.
if (identifiersEqual(name, &local->name)) {
in resolveLocal()
if (local->depth == -1) { error("Can't read local variable in its own initializer."); }
return i;
Nếu biến có depth là giá trị sentinel, thì chắc chắn đó là một tham chiếu tới biến trong chính initializer của nó, và ta báo lỗi.
Vậy là xong chương này! Ta đã thêm block, biến local, và lexical scoping “xịn”. Mặc dù ta đã giới thiệu một cách biểu diễn biến ở runtime hoàn toàn khác, nhưng lượng code phải viết không nhiều. Phần hiện thực cuối cùng khá gọn gàng và hiệu quả.
Bạn sẽ nhận thấy gần như toàn bộ code ta viết nằm ở compiler. Còn ở runtime, chỉ có hai instruction nhỏ. Bạn sẽ thấy đây là một xu hướng tiếp diễn trong clox so với jlox. Một trong những “vũ khí” lớn nhất trong hộp công cụ của optimizer là kéo công việc về phía compiler để không phải làm ở runtime. Trong chương này, điều đó có nghĩa là resolve chính xác slot trên stack mà mỗi biến local chiếm. Nhờ vậy, ở runtime, không cần lookup hay resolve gì nữa.
22 . 5Thử thách
-
Mảng local đơn giản của ta giúp việc tính toán stack slot của mỗi biến local trở nên dễ dàng. Nhưng điều đó cũng có nghĩa là khi compiler resolve một tham chiếu tới biến, ta phải quét tuyến tính qua mảng.
Hãy nghĩ ra một cách hiệu quả hơn. Bạn có cho rằng độ phức tạp tăng thêm là xứng đáng không?
-
Các ngôn ngữ khác xử lý đoạn code như thế này thế nào:
var a = a;
Nếu đây là ngôn ngữ của bạn, bạn sẽ làm gì? Tại sao?
-
Nhiều ngôn ngữ phân biệt giữa biến có thể gán lại và biến không thể gán lại. Trong Java, từ khóa
final
ngăn bạn gán lại cho biến. Trong JavaScript, biến khai báo bằnglet
có thể gán lại, nhưng biến khai báo bằngconst
thì không. Swift coilet
là biến chỉ gán một lần và dùngvar
cho biến có thể gán lại. Scala và Kotlin dùngval
vàvar
.Hãy chọn một từ khóa cho dạng biến chỉ gán một lần để thêm vào Lox. Giải thích lý do chọn, rồi hiện thực nó. Nếu cố gán cho một biến được khai báo bằng từ khóa mới này, compiler phải báo lỗi.
-
Mở rộng clox để cho phép nhiều hơn 256 biến local cùng nằm trong scope tại một thời điểm.