Classes
Không ai có quyền yêu hay ghét bất cứ điều gì nếu chưa hiểu tường tận bản chất của nó. Tình yêu lớn nảy sinh từ sự hiểu biết sâu sắc về đối tượng được yêu, và nếu bạn chỉ biết chút ít, bạn sẽ chỉ có thể yêu nó một chút hoặc chẳng yêu chút nào.
Leonardo da Vinci
Chúng ta đã đi đến chương thứ mười một, và interpreter đang nằm trên máy bạn giờ đây gần như đã là một ngôn ngữ scripting hoàn chỉnh. Nó có thể cần thêm vài cấu trúc dữ liệu dựng sẵn như list và map, và chắc chắn cần một thư viện lõi cho file I/O, nhập liệu từ người dùng, v.v. Nhưng bản thân ngôn ngữ thì đã đủ dùng. Chúng ta đang có một ngôn ngữ thủ tục nhỏ, cùng “dòng họ” với BASIC, Tcl, Scheme (trừ macro), và những phiên bản đầu của Python và Lua.
Nếu đây là thập niên 80, có lẽ ta sẽ dừng lại ở đây. Nhưng ngày nay, nhiều ngôn ngữ phổ biến hỗ trợ “lập trình hướng đối tượng”. Thêm tính năng này vào Lox sẽ mang đến cho người dùng một bộ công cụ quen thuộc để viết các chương trình lớn hơn. Ngay cả khi cá nhân bạn không thích OOP, chương này và chương tiếp theo sẽ giúp bạn hiểu cách người khác thiết kế và xây dựng hệ thống đối tượng.
12 . 1OOP & Class
Có ba hướng tiếp cận chính với lập trình hướng đối tượng: class, prototype, và multimethod. Class xuất hiện đầu tiên và là phong cách phổ biến nhất. Với sự trỗi dậy của JavaScript (và ở mức độ nhỏ hơn là Lua), prototype được biết đến rộng rãi hơn trước đây. Tôi sẽ nói thêm về chúng sau. Với Lox, chúng ta sẽ chọn cách tiếp cận… cổ điển.
Vì bạn đã cùng tôi viết khoảng cả nghìn dòng Java, tôi sẽ giả định rằng bạn không cần một phần giới thiệu chi tiết về lập trình hướng đối tượng. Mục tiêu chính là gói dữ liệu cùng với code xử lý nó. Người dùng làm điều đó bằng cách khai báo một class:
- Cung cấp một constructor để tạo và khởi tạo instance mới của class
- Cung cấp cách lưu trữ và truy cập field trên instance
- Định nghĩa một tập method được chia sẻ bởi tất cả instance của class, hoạt động trên trạng thái của từng instance
Đó là mức tối giản nhất. Hầu hết các ngôn ngữ hướng đối tượng, từ thời Simula, cũng có tính năng kế thừa để tái sử dụng hành vi giữa các class. Chúng ta sẽ thêm phần đó ở chương sau. Ngay cả khi bỏ qua nó, vẫn còn khá nhiều việc phải làm. Đây là một chương lớn và mọi thứ sẽ chỉ thực sự “ăn khớp” khi ta có đủ các mảnh ghép trên, nên hãy chuẩn bị tinh thần.
12 . 2Khai báo Class
Như thường lệ, ta sẽ bắt đầu với cú pháp. Một câu lệnh class
giới thiệu một tên mới, nên nó nằm trong luật ngữ pháp declaration
.
declaration → classDecl | funDecl | varDecl | statement ; classDecl → "class" IDENTIFIER "{" function* "}" ;
Luật classDecl
mới này dựa vào luật function
mà ta đã định nghĩa trước đó. Nhắc lại cho bạn nhớ:
function → IDENTIFIER "(" parameters? ")" block ; parameters → IDENTIFIER ( "," IDENTIFIER )* ;
Nói đơn giản, một khai báo class gồm từ khóa class
, theo sau là tên class, rồi đến phần thân trong dấu ngoặc nhọn. Bên trong là danh sách các khai báo method. Khác với khai báo hàm, method không có từ khóa fun
ở đầu. Mỗi method gồm tên, danh sách tham số và phần thân. Ví dụ:
class Breakfast { cook() { print "Eggs a-fryin'!"; } serve(who) { print "Enjoy your breakfast, " + who + "."; } }
Giống như hầu hết các ngôn ngữ dynamic typing, field không được liệt kê rõ ràng trong khai báo class. Instance chỉ là những “túi dữ liệu” lỏng lẻo và bạn có thể tự do thêm field vào chúng tùy ý bằng code mệnh lệnh thông thường.
Trong trình tạo AST của chúng ta, luật ngữ pháp classDecl
có riêng một statement node.
"Block : List<Stmt> statements",
in main()
"Class : Token name, List<Stmt.Function> methods",
"Expression : Expr expression",
Node này lưu tên class và các method bên trong phần thân. Method được biểu diễn bằng class Stmt.Function
hiện có mà ta dùng cho các node AST khai báo hàm. Điều đó cho chúng ta đầy đủ các thành phần trạng thái cần thiết cho một method: tên, danh sách tham số và phần thân.
Một class có thể xuất hiện ở bất kỳ đâu mà một khai báo có tên được phép, được kích hoạt bởi từ khóa class
ở đầu.
try {
in declaration()
if (match(CLASS)) return classDeclaration();
if (match(FUN)) return function("function");
Lệnh này gọi tới:
add after declaration()
private Stmt classDeclaration() { Token name = consume(IDENTIFIER, "Expect class name."); consume(LEFT_BRACE, "Expect '{' before class body."); List<Stmt.Function> methods = new ArrayList<>(); while (!check(RIGHT_BRACE) && !isAtEnd()) { methods.add(function("method")); } consume(RIGHT_BRACE, "Expect '}' after class body."); return new Stmt.Class(name, methods); }
Phần này “nặng” hơn hầu hết các phương thức parse khác, nhưng về cơ bản vẫn bám sát grammar. Chúng ta đã đọc từ khóa class
, nên tiếp theo sẽ tìm tên class như mong đợi, rồi đến dấu ngoặc nhọn mở. Khi đã vào bên trong phần thân, ta tiếp tục parse các khai báo method cho đến khi gặp dấu ngoặc nhọn đóng. Mỗi khai báo method được parse bằng lời gọi tới function()
, hàm mà ta đã định nghĩa trong chương giới thiệu hàm.
Như mọi vòng lặp mở trong parser, ta cũng kiểm tra xem có chạm tới cuối file không. Điều này sẽ không xảy ra trong code đúng, vì một class phải có dấu ngoặc nhọn đóng ở cuối, nhưng nó đảm bảo parser không bị kẹt trong vòng lặp vô hạn nếu người dùng mắc lỗi cú pháp và quên kết thúc phần thân class.
Chúng ta gói tên và danh sách method vào một node Stmt.Class
và xong. Trước đây, ta sẽ nhảy thẳng sang interpreter, nhưng giờ cần đưa node này qua resolver trước.
add after visitBlockStmt()
@Override public Void visitClassStmt(Stmt.Class stmt) { declare(stmt.name); define(stmt.name); return null; }
Chúng ta chưa cần xử lý việc resolve các method, nên hiện tại chỉ cần khai báo class bằng tên của nó. Việc khai báo class như một biến local không phổ biến, nhưng Lox cho phép, nên ta cần xử lý đúng.
Giờ ta interpret khai báo class.
add after visitBlockStmt()
@Override public Void visitClassStmt(Stmt.Class stmt) { environment.define(stmt.name.lexeme, null); LoxClass klass = new LoxClass(stmt.name.lexeme); environment.assign(stmt.name, klass); return null; }
Phần này trông giống cách ta execute khai báo hàm. Ta khai báo tên class trong environment hiện tại. Sau đó, ta biến syntax node của class thành một LoxClass
, tức là biểu diễn runtime của class. Tiếp theo, ta quay lại và lưu object class vào biến vừa khai báo. Quy trình binding biến hai bước này cho phép tham chiếu tới class bên trong chính các method của nó.
Chúng ta sẽ tinh chỉnh nó trong suốt chương, nhưng bản nháp đầu tiên của LoxClass
trông như sau:
create new file
package com.craftinginterpreters.lox; import java.util.List; import java.util.Map; class LoxClass { final String name; LoxClass(String name) { this.name = name; } @Override public String toString() { return name; } }
Về cơ bản chỉ là một lớp bọc quanh tên. Chúng ta thậm chí chưa lưu method. Không hữu ích lắm, nhưng nó có phương thức toString()
để ta có thể viết một script đơn giản và kiểm tra rằng object class thực sự được parse và execute.
class DevonshireCream { serveOn() { return "Scones"; } } print DevonshireCream; // In ra "DevonshireCream".
12 . 3Tạo Instance
Chúng ta đã có class, nhưng chúng chưa làm được gì. Lox không có method “static” để gọi trực tiếp trên class, nên nếu không có instance thực sự, class là vô dụng. Vì vậy, bước tiếp theo là instance.
Mặc dù một số cú pháp và ngữ nghĩa khá tiêu chuẩn giữa các ngôn ngữ OOP, cách tạo instance mới thì không. Ruby, theo Smalltalk, tạo instance bằng cách gọi một method trên chính object class — một cách tiếp cận đệ quy duyên dáng. Một số ngôn ngữ như C++ và Java có từ khóa new
dành riêng cho việc “sinh” object mới. Python thì “gọi” class như một hàm. (JavaScript, vốn kỳ quặc, thì kiểu như làm cả hai.)
Tôi chọn cách tiếp cận tối giản cho Lox. Chúng ta đã có object class, và đã có lời gọi hàm, nên sẽ dùng biểu thức gọi (call expression) trên object class để tạo instance mới. Giống như một class là một hàm nhà máy tạo ra instance của chính nó. Cách này vừa gọn gàng, vừa tránh phải thêm cú pháp như new
. Do đó, ta có thể bỏ qua phần front end và đi thẳng vào runtime.
Hiện tại, nếu bạn thử:
class Bagel {} Bagel();
Bạn sẽ gặp runtime error. visitCallExpr()
kiểm tra xem object được gọi có implement LoxCallable
không và báo lỗi vì LoxClass
chưa làm điều đó. Chưa thôi.
import java.util.Map;
replace 1 line
class LoxClass implements LoxCallable {
final String name;
Việc implement interface này yêu cầu hai phương thức.
add after toString()
@Override public Object call(Interpreter interpreter, List<Object> arguments) { LoxInstance instance = new LoxInstance(this); return instance; } @Override public int arity() { return 0; }
Phương thức thú vị là call()
. Khi bạn “gọi” một class, nó sẽ tạo một LoxInstance
mới cho class được gọi và trả về. Phương thức arity()
giúp interpreter xác nhận rằng bạn truyền đúng số lượng argument cho callable. Hiện tại, ta sẽ quy định là không được truyền argument nào. Khi đến phần constructor do người dùng định nghĩa, ta sẽ quay lại đây.
Điều đó dẫn ta đến LoxInstance
, biểu diễn runtime của một instance thuộc class Lox. Một lần nữa, bản cài đặt đầu tiên sẽ rất đơn giản.
create new file
package com.craftinginterpreters.lox; import java.util.HashMap; import java.util.Map; class LoxInstance { private LoxClass klass; LoxInstance(LoxClass klass) { this.klass = klass; } @Override public String toString() { return klass.name + " instance"; } }
Giống như LoxClass
, nó khá sơ sài, nhưng chúng ta mới chỉ bắt đầu. Nếu muốn thử, bạn có thể chạy script sau:
class Bagel {} var bagel = Bagel(); print bagel; // In ra "Bagel instance".
Chương trình này chưa làm được nhiều, nhưng ít nhất nó đã bắt đầu làm được gì đó.
12 . 4Thuộc tính (Properties) trên Instance
Chúng ta đã có instance, vậy giờ hãy làm cho chúng hữu ích. Ở đây ta đứng trước một ngã rẽ: có thể thêm hành vi trước — tức method — hoặc bắt đầu với trạng thái — tức property. Chúng ta sẽ chọn cách thứ hai, vì như sẽ thấy, hai thứ này gắn bó với nhau theo một cách thú vị, và sẽ dễ hiểu hơn nếu ta làm property hoạt động trước.
Lox xử lý trạng thái giống JavaScript và Python. Mỗi instance là một tập hợp mở các giá trị có tên. Method trong class của instance có thể truy cập và thay đổi property, nhưng code bên ngoài cũng có thể làm vậy. Property được truy cập bằng cú pháp dấu .
.
someObject.someProperty
Một biểu thức theo sau bởi .
và một identifier sẽ đọc property có tên đó từ object mà biểu thức trả về. Dấu chấm này có độ ưu tiên ngang với dấu ngoặc đơn trong lời gọi hàm, nên ta đưa nó vào grammar bằng cách thay thế luật call
hiện tại bằng:
call → primary ( "(" arguments? ")" | "." IDENTIFIER )* ;
Sau một biểu thức primary, ta cho phép một chuỗi bất kỳ kết hợp giữa lời gọi có ngoặc và truy cập property bằng dấu chấm. “Property access” nghe hơi dài dòng, nên từ đây ta sẽ gọi chúng là “get expression”.
12 . 4 . 1Get expression
Node cây cú pháp là:
"Call : Expr callee, Token paren, List<Expr> arguments",
in main()
"Get : Expr object, Token name",
"Grouping : Expr expression",
Theo grammar, code parse mới sẽ nằm trong method call()
hiện có.
while (true) {
if (match(LEFT_PAREN)) {
expr = finishCall(expr);
in call()
} else if (match(DOT)) { Token name = consume(IDENTIFIER, "Expect property name after '.'."); expr = new Expr.Get(expr, name);
} else { break; } }
Vòng lặp while
bên ngoài tương ứng với dấu *
trong luật grammar. Chúng ta “lướt” qua các token, xây dựng một chuỗi các lời gọi và get khi gặp dấu ngoặc hoặc dấu chấm, như thế này:

Các instance của node Expr.Get
mới sẽ được đưa vào resolver.
add after visitCallExpr()
@Override public Void visitGetExpr(Expr.Get expr) { resolve(expr.object); return null; }
OK, không có gì nhiều ở đây. Vì property được tra cứu một cách động, chúng không được resolve. Trong quá trình resolve, ta chỉ đệ quy vào biểu thức bên trái dấu chấm. Việc truy cập property thực sự diễn ra trong interpreter.
add after visitCallExpr()
@Override public Object visitGetExpr(Expr.Get expr) { Object object = evaluate(expr.object); if (object instanceof LoxInstance) { return ((LoxInstance) object).get(expr.name); } throw new RuntimeError(expr.name, "Only instances have properties."); }
Đầu tiên, ta đánh giá biểu thức có property đang được truy cập. Trong Lox, chỉ instance của class mới có property. Nếu object là kiểu khác như số, việc gọi getter trên nó là một runtime error.
Nếu object là LoxInstance
, ta yêu cầu nó tra cứu property. Đã đến lúc cho LoxInstance
có trạng thái thực sự. Một map là đủ.
private LoxClass klass;
in class LoxInstance
private final Map<String, Object> fields = new HashMap<>();
LoxInstance(LoxClass klass) {
Mỗi key trong map là tên property và giá trị tương ứng là giá trị của property đó. Để tra cứu property trên một instance:
add after LoxInstance()
Object get(Token name) { if (fields.containsKey(name.lexeme)) { return fields.get(name.lexeme); } throw new RuntimeError(name, "Undefined property '" + name.lexeme + "'."); }
Một trường hợp biên thú vị cần xử lý là khi instance không có property với tên đã cho. Ta có thể âm thầm trả về một giá trị giả như nil
, nhưng kinh nghiệm của tôi với các ngôn ngữ như JavaScript cho thấy hành vi này thường che giấu bug hơn là mang lại điều gì hữu ích. Thay vào đó, ta sẽ biến nó thành runtime error.
Vì vậy, việc đầu tiên là kiểm tra xem instance thực sự có field với tên đó không. Chỉ khi đó ta mới trả về. Nếu không, ta báo lỗi.
Hãy để ý cách tôi chuyển từ “property” sang “field”. Có một sự khác biệt tinh tế giữa hai khái niệm này. Field là những phần trạng thái có tên được lưu trực tiếp trong instance. Property là những “thứ” có tên mà một get expression có thể trả về. Mọi field đều là property, nhưng như ta sẽ thấy sau này, không phải mọi property đều là field.
Về lý thuyết, giờ ta có thể đọc property trên object. Nhưng vì chưa có cách nào để thực sự nhét trạng thái vào một instance, nên chẳng có field nào để truy cập. Trước khi thử đọc, ta phải hỗ trợ việc ghi đã.
12 . 4 . 2Biểu thức Set
Setter dùng cùng cú pháp với getter, chỉ khác là chúng xuất hiện ở phía bên trái của một phép gán.
someObject.someProperty = value;
Trong phần grammar, chúng ta mở rộng luật cho assignment để cho phép các identifier có dấu chấm ở phía bên trái.
assignment → ( call "." )? IDENTIFIER "=" assignment | logic_or ;
Không giống getter, setter không thể chain. Tuy nhiên, tham chiếu tới call
cho phép bất kỳ biểu thức có độ ưu tiên cao nào trước dấu chấm cuối cùng, bao gồm cả một số lượng getter bất kỳ, như trong ví dụ:

Lưu ý rằng chỉ phần cuối cùng, .meat
, mới là setter. Các phần .omelette
và .filling
đều là get expression.
Cũng giống như chúng ta có hai node AST riêng biệt cho việc truy cập biến và gán biến, ta cần một node setter thứ hai để bổ sung cho node getter.
"Logical : Expr left, Token operator, Expr right",
in main()
"Set : Expr object, Token name, Expr value",
"Unary : Token operator, Expr right",
Nếu bạn không nhớ, cách chúng ta xử lý assignment trong parser hơi đặc biệt. Chúng ta không thể dễ dàng biết được một chuỗi token là phía bên trái của một phép gán cho đến khi gặp dấu =
. Giờ đây, khi luật grammar của assignment có call
ở bên trái, mà call
có thể mở rộng thành những biểu thức lớn tùy ý, thì dấu =
cuối cùng có thể cách rất xa điểm mà ta cần biết mình đang parse một assignment.
Thay vào đó, mẹo của chúng ta là parse phía bên trái như một biểu thức bình thường. Sau đó, khi bắt gặp dấu bằng phía sau nó, ta lấy biểu thức đã parse và biến nó thành node cây cú pháp phù hợp cho assignment.
Chúng ta thêm một nhánh nữa vào quá trình biến đổi đó để xử lý việc chuyển một biểu thức Expr.Get
ở bên trái thành Expr.Set
tương ứng.
return new Expr.Assign(name, value);
in assignment()
} else if (expr instanceof Expr.Get) { Expr.Get get = (Expr.Get)expr; return new Expr.Set(get.object, get.name, value);
}
Vậy là xong phần parse cú pháp. Ta đưa node này qua resolver.
add after visitLogicalExpr()
@Override public Void visitSetExpr(Expr.Set expr) { resolve(expr.value); resolve(expr.object); return null; }
Tương tự như Expr.Get
, bản thân property được đánh giá một cách động, nên không có gì để resolve ở đây. Tất cả những gì cần làm là đệ quy vào hai biểu thức con của Expr.Set
: object có property đang được gán, và giá trị sẽ gán cho nó.
Tiếp theo là interpreter.
add after visitLogicalExpr()
@Override public Object visitSetExpr(Expr.Set expr) { Object object = evaluate(expr.object); if (!(object instanceof LoxInstance)) { throw new RuntimeError(expr.name, "Only instances have fields."); } Object value = evaluate(expr.value); ((LoxInstance)object).set(expr.name, value); return value; }
Chúng ta đánh giá object có property đang được gán và kiểm tra xem nó có phải là LoxInstance
không. Nếu không, đó là runtime error. Nếu có, ta đánh giá giá trị cần gán và lưu nó vào instance. Việc này dựa vào một method mới trong LoxInstance
.
add after get()
void set(Token name, Object value) { fields.put(name.lexeme, value); }
Không có gì “ma thuật” ở đây. Chúng ta đưa thẳng giá trị vào Java map nơi các field được lưu. Vì Lox cho phép tự do tạo field mới trên instance, nên không cần kiểm tra xem key đã tồn tại hay chưa.
12 . 5Method trong Class
Bạn có thể tạo instance của class và nhét dữ liệu vào chúng, nhưng bản thân class thì chưa thực sự làm gì cả. Instance hiện tại chỉ như những map, và tất cả instance đều na ná nhau. Để khiến chúng thực sự mang cảm giác là instance của class, chúng ta cần hành vi — tức method.
Parser của chúng ta vốn đã parse được khai báo method, nên phần đó ổn. Chúng ta cũng không cần thêm hỗ trợ parser mới cho lời gọi method. Ta đã có .
(getter) và ()
(lời gọi hàm). Một “lời gọi method” đơn giản chỉ là kết hợp hai thứ đó lại.

Điều này dẫn đến một câu hỏi thú vị: chuyện gì xảy ra nếu tách hai biểu thức đó ra? Giả sử method
trong ví dụ này là một method của class của object
chứ không phải một field trên instance, thì đoạn code sau sẽ làm gì?
var m = object.method; m(argument);
Chương trình này “tra cứu” method và lưu kết quả — bất kể nó là gì — vào một biến, rồi gọi object đó sau. Điều này có được phép không? Bạn có thể coi method như một hàm trên instance không?
Còn chiều ngược lại thì sao?
class Box {} fun notMethod(argument) { print "called function with " + argument; } var box = Box(); box.function = notMethod; box.function("argument");
Chương trình này tạo một instance rồi lưu một hàm vào một field của nó. Sau đó, nó gọi hàm đó bằng cùng cú pháp như gọi method. Điều này có hoạt động không?
Các ngôn ngữ khác nhau có câu trả lời khác nhau cho những câu hỏi này. Người ta có thể viết hẳn một bài luận về nó. Với Lox, chúng ta sẽ nói rằng câu trả lời cho cả hai là “có, hoạt động”. Chúng ta có vài lý do để biện minh cho điều đó. Với ví dụ thứ hai — gọi một hàm được lưu trong field — ta muốn hỗ trợ vì hàm là first-class và việc lưu chúng trong field là điều hoàn toàn bình thường.
Ví dụ đầu tiên thì mơ hồ hơn. Một lý do là người dùng thường kỳ vọng có thể “tách” một biểu thức con ra thành biến local mà không làm thay đổi ý nghĩa chương trình. Bạn có thể viết:
breakfast(omelette.filledWith(cheese), sausage);
Và biến nó thành:
var eggs = omelette.filledWith(cheese); breakfast(eggs, sausage);
Và nó vẫn làm đúng như vậy. Tương tự, vì .
và ()
trong một lời gọi method là hai biểu thức riêng biệt, nên có vẻ hợp lý khi bạn có thể tách phần lookup ra thành một biến rồi gọi nó sau. Chúng ta cần suy nghĩ kỹ về “thứ” mà bạn nhận được khi tra cứu một method là gì, và nó hoạt động thế nào, kể cả trong những trường hợp kỳ quặc như:
class Person { sayName() { print this.name; } } var jane = Person(); jane.name = "Jane"; var method = jane.sayName; method(); // ?
Nếu bạn lấy một method từ một instance và gọi nó sau đó, nó có “nhớ” instance mà nó được lấy ra không? this
bên trong method đó có còn trỏ tới object gốc không?
Đây là một ví dụ “hack não” hơn:
class Person { sayName() { print this.name; } } var jane = Person(); jane.name = "Jane"; var bill = Person(); bill.name = "Bill"; bill.sayName = jane.sayName; bill.sayName(); // ?
Dòng cuối sẽ in “Bill” vì đó là instance mà ta gọi method thông qua, hay “Jane” vì đó là instance mà ta lấy method lần đầu?
Code tương đương trong Lua và JavaScript sẽ in “Bill”. Các ngôn ngữ đó thực ra không có khái niệm “method” rõ ràng. Mọi thứ đều kiểu như “hàm trong field”, nên không rõ jane
“sở hữu” sayName
hơn bill
ở điểm nào.
Nhưng Lox có cú pháp class thực sự, nên ta biết rõ cái gì là method và cái gì là function. Vì vậy, giống như Python, C# và một số ngôn ngữ khác, chúng ta sẽ để method “bind” this
với instance gốc khi method được lấy ra lần đầu. Python gọi những thứ này là bound method.
Trên thực tế, đó thường là điều bạn muốn. Nếu bạn lấy một tham chiếu tới một method trên một object nào đó để dùng làm callback sau này, bạn sẽ muốn “ghi nhớ” instance mà nó thuộc về, ngay cả khi callback đó tình flag được lưu trong một field của một object khác.
Rồi, vậy là bạn vừa phải nạp khá nhiều khái niệm ngữ nghĩa vào đầu. Tạm quên những trường hợp biên đi, chúng ta sẽ quay lại sau. Bây giờ, hãy làm cho lời gọi method cơ bản hoạt động trước đã. Chúng ta đã parse được các khai báo method bên trong thân class, nên bước tiếp theo là resolve chúng.
define(stmt.name);
in visitClassStmt()
for (Stmt.Function method : stmt.methods) { FunctionType declaration = FunctionType.METHOD; resolveFunction(method, declaration); }
return null;
Chúng ta lặp qua các method trong thân class và gọi hàm resolveFunction()
mà ta đã viết để xử lý khai báo hàm. Điểm khác biệt duy nhất là ta truyền vào một giá trị enum FunctionType mới.
NONE,
FUNCTION,
in enum FunctionType
add “,” to previous line
METHOD
}
Điều này sẽ quan trọng khi chúng ta resolve các biểu thức this
. Còn bây giờ thì chưa cần lo. Phần thú vị nằm ở interpreter.
environment.define(stmt.name.lexeme, null);
in visitClassStmt()
replace 1 line
Map<String, LoxFunction> methods = new HashMap<>(); for (Stmt.Function method : stmt.methods) { LoxFunction function = new LoxFunction(method, environment); methods.put(method.name.lexeme, function); } LoxClass klass = new LoxClass(stmt.name.lexeme, methods);
environment.assign(stmt.name, klass);
Khi chúng ta execute một câu lệnh khai báo class, ta biến biểu diễn cú pháp của class — node AST của nó — thành biểu diễn runtime. Giờ, ta cũng cần làm điều đó cho các method chứa trong class. Mỗi khai báo method sẽ “nở” thành một object LoxFunction
.
Chúng ta gom tất cả chúng lại và bọc vào một map, với key là tên method. Map này được lưu trong LoxClass
.
final String name;
in class LoxClass
replace 4 lines
private final Map<String, LoxFunction> methods; LoxClass(String name, Map<String, LoxFunction> methods) { this.name = name; this.methods = methods; }
@Override public String toString() {
Nếu instance lưu trữ trạng thái, thì class lưu trữ hành vi. LoxInstance
có map các field, còn LoxClass
có map các method. Dù method thuộc về class, chúng vẫn được truy cập thông qua các instance của class đó.
Object get(Token name) { if (fields.containsKey(name.lexeme)) { return fields.get(name.lexeme); }
in get()
LoxFunction method = klass.findMethod(name.lexeme); if (method != null) return method;
throw new RuntimeError(name,
"Undefined property '" + name.lexeme + "'.");
Khi tra cứu một property trên instance, nếu không tìm thấy field trùng tên, ta sẽ tìm method có tên đó trong class của instance. Nếu tìm thấy, ta trả về method đó. Đây chính là lúc sự khác biệt giữa “field” và “property” trở nên có ý nghĩa. Khi truy cập một property, bạn có thể nhận được một field — một phần trạng thái được lưu trên instance — hoặc có thể gặp một method được định nghĩa trên class của instance.
Việc tra cứu method được thực hiện bằng hàm sau:
add after LoxClass()
LoxFunction findMethod(String name) { if (methods.containsKey(name)) { return methods.get(name); } return null; }
Bạn có thể đoán rằng hàm này sẽ trở nên thú vị hơn sau này. Hiện tại, chỉ cần một lần tra cứu map đơn giản trên bảng method của class là đủ để bắt đầu. Hãy thử nhé:
class Bacon { eat() { print "Crunch crunch crunch!"; } } Bacon().eat(); // Prints "Crunch crunch crunch!".
12 . 6This
Chúng ta có thể định nghĩa cả hành vi lẫn trạng thái trên object, nhưng chúng vẫn chưa được gắn kết với nhau. Bên trong một method, ta chưa có cách nào để truy cập các field của object “hiện tại” — tức instance mà method được gọi trên đó — cũng như không thể gọi các method khác trên cùng object đó.
Để truy cập được instance đó, nó cần một tên. Smalltalk, Ruby và Swift dùng “self”. Simula, C++, Java và nhiều ngôn ngữ khác dùng “this”. Python dùng “self” theo thông lệ, nhưng về mặt kỹ thuật bạn có thể gọi nó là gì cũng được.
Với Lox, vì chúng ta thường bám theo phong cách giống Java, nên sẽ dùng từ khóa "this"
. Bên trong thân một method, một biểu thức this
sẽ được đánh giá thành instance mà method đó được gọi trên. Hoặc, nói chính xác hơn, vì method được truy cập và sau đó mới được gọi qua hai bước, nên this
sẽ tham chiếu tới object mà method được truy cập từ đó.
Điều này khiến công việc của chúng ta khó hơn. Xem ví dụ:
class Egotist { speak() { print this; } } var method = Egotist().speak; method();
Ở dòng áp chót, chúng ta lấy một tham chiếu tới method speak()
từ một instance của class. Điều đó trả về một hàm, và hàm này cần phải “ghi nhớ” instance mà nó được lấy ra, để sau này, ở dòng cuối cùng, nó vẫn có thể tìm lại instance đó khi hàm được gọi.
Chúng ta cần lấy this
tại thời điểm method được truy cập và gắn nó vào hàm theo cách nào đó để nó tồn tại lâu như ta cần. Hmm… một cách để lưu trữ thêm dữ liệu đi kèm với một hàm và tồn tại cùng nó, nghe rất giống với closure, đúng không?
Nếu chúng ta định nghĩa this
như một biến ẩn trong một environment bao quanh hàm được trả về khi tra cứu method, thì các lần sử dụng this
trong thân hàm sẽ có thể tìm thấy nó sau này. LoxFunction
vốn đã có khả năng giữ lại environment bao quanh, nên chúng ta đã có sẵn cơ chế cần thiết.
Hãy đi qua một ví dụ để xem nó hoạt động thế nào:
class Cake { taste() { var adjective = "delicious"; print "The " + this.flavor + " cake is " + adjective + "!"; } } var cake = Cake(); cake.flavor = "German chocolate"; cake.taste(); // Prints "The German chocolate cake is delicious!".
Khi chúng ta lần đầu đánh giá định nghĩa class, ta tạo một LoxFunction
cho taste()
. Closure của nó là environment bao quanh class, trong trường hợp này là environment toàn cục. Vì vậy, LoxFunction
mà ta lưu trong method map của class trông như sau:

Khi chúng ta đánh giá biểu thức get cake.taste
, ta tạo một environment mới, bind this
tới object mà method được truy cập từ đó (ở đây là cake
). Sau đó, ta tạo một LoxFunction
mới với cùng phần code như bản gốc nhưng sử dụng environment mới này làm closure.

Đây là LoxFunction
được trả về khi đánh giá biểu thức get cho tên method. Khi hàm này sau đó được gọi bởi một biểu thức ()
, ta tạo một environment cho thân method như bình thường.

Parent của environment thân hàm chính là environment mà ta đã tạo trước đó để bind this
tới object hiện tại. Do đó, mọi lần sử dụng this
bên trong thân hàm đều sẽ được resolve thành instance đó.
Việc tái sử dụng code environment để triển khai this
cũng xử lý tốt các trường hợp thú vị khi method và function tương tác với nhau, ví dụ:
class Thing { getCallback() { fun localFunction() { print this; } return localFunction; } } var callback = Thing().getCallback(); callback();
Trong JavaScript chẳng hạn, việc trả về một callback từ bên trong method là khá phổ biến. Callback đó có thể muốn giữ lại và truy cập vào object gốc — giá trị this
— mà method gắn liền với nó. Hỗ trợ hiện tại của chúng ta cho closure và chuỗi environment sẽ xử lý đúng tất cả những điều này.
Giờ hãy bắt tay vào code. Bước đầu tiên là thêm cú pháp mới cho this
.
"Set : Expr object, Token name, Expr value",
in main()
"This : Token keyword",
"Unary : Token operator, Expr right",
Việc parse khá đơn giản vì đây chỉ là một token duy nhất mà lexer của chúng ta đã nhận diện là một từ khóa dành riêng.
return new Expr.Literal(previous().literal); }
in primary()
if (match(THIS)) return new Expr.This(previous());
if (match(IDENTIFIER)) {
Bạn sẽ bắt đầu thấy this
hoạt động giống như một biến khi chúng ta đến phần resolver.
add after visitSetExpr()
@Override public Void visitThisExpr(Expr.This expr) { resolveLocal(expr, expr.keyword); return null; }
Chúng ta resolve nó giống hệt như bất kỳ biến local nào khác, dùng "this"
làm tên cho “biến” đó. Tất nhiên, hiện tại điều này sẽ không hoạt động, vì "this"
không được khai báo trong bất kỳ scope nào. Hãy sửa điều đó trong visitClassStmt()
.
define(stmt.name);
in visitClassStmt()
beginScope(); scopes.peek().put("this", true);
for (Stmt.Function method : stmt.methods) {
Trước khi bắt đầu resolve thân các method, chúng ta đẩy một scope mới và định nghĩa "this"
trong đó như thể nó là một biến. Sau đó, khi xong việc, ta loại bỏ scope bao quanh đó.
}
in visitClassStmt()
endScope();
return null;
Giờ đây, bất cứ khi nào gặp một biểu thức this
(ít nhất là bên trong method) nó sẽ được resolve thành một “biến local” được định nghĩa trong một scope ẩn ngay bên ngoài block của thân method.
Resolver có một scope mới cho this
, nên interpreter cần tạo một environment tương ứng cho nó. Hãy nhớ rằng, chúng ta luôn phải giữ cho chuỗi scope của resolver và chuỗi environment liên kết của interpreter đồng bộ với nhau. Tại runtime, ta tạo environment sau khi tìm thấy method trên instance. Ta thay dòng code trước đây chỉ đơn giản trả về LoxFunction
của method bằng đoạn này:
LoxFunction method = klass.findMethod(name.lexeme);
in get()
replace 1 line
if (method != null) return method.bind(this);
throw new RuntimeError(name,
"Undefined property '" + name.lexeme + "'.");
Lưu ý lời gọi mới tới bind()
. Nó trông như sau:
add after LoxFunction()
LoxFunction bind(LoxInstance instance) { Environment environment = new Environment(closure); environment.define("this", instance); return new LoxFunction(declaration, environment); }
Không có gì phức tạp ở đây. Chúng ta tạo một environment mới lồng bên trong closure gốc của method. Kiểu như một closure-bên-trong-closure. Khi method được gọi, environment này sẽ trở thành parent của environment thân method.
Chúng ta khai báo "this"
như một biến trong environment đó và bind nó với instance được truyền vào — instance mà method được truy cập từ đó. Et voilà, LoxFunction
được trả về giờ đây mang theo “thế giới nhỏ” của riêng nó, nơi "this"
được bind với object.
Nhiệm vụ còn lại là interpret các biểu thức this
. Giống như resolver, nó tương tự như interpret một biểu thức biến.
add after visitSetExpr()
@Override public Object visitThisExpr(Expr.This expr) { return lookUpVariable(expr.keyword, expr); }
Hãy thử ngay với ví dụ cake ở phần trước. Chỉ với chưa đến hai mươi dòng code, interpreter của chúng ta xử lý this
bên trong method, kể cả trong những tình huống phức tạp khi nó tương tác với class lồng nhau, function bên trong method, handle tới method, v.v.
12 . 6 . 1Các cách dùng this
không hợp lệ
Khoan đã. Chuyện gì xảy ra nếu bạn cố dùng this
bên ngoài một method? Ví dụ:
print this;
Hoặc:
fun notAMethod() { print this; }
Sẽ không có instance nào để this
trỏ tới nếu bạn không ở trong một method. Chúng ta có thể gán cho nó một giá trị mặc định như nil
hoặc biến nó thành một runtime error, nhưng rõ ràng người dùng đã mắc lỗi. Càng phát hiện và sửa lỗi sớm, họ sẽ càng vui.
Bước resolve là nơi lý tưởng để phát hiện lỗi này một cách tĩnh. Nó vốn đã phát hiện các câu lệnh return
bên ngoài hàm. Chúng ta sẽ làm điều tương tự cho this
. Theo phong cách của enum FunctionType
hiện có, ta định nghĩa thêm một enum ClassType
mới.
}
add after enum FunctionType
private enum ClassType { NONE, CLASS } private ClassType currentClass = ClassType.NONE;
void resolve(List<Stmt> statements) {
Đúng là nó có thể chỉ là một Boolean. Khi chúng ta đến phần kế thừa, nó sẽ có thêm giá trị thứ ba, nên giờ dùng enum là hợp lý. Ta cũng thêm một trường tương ứng, currentClass
. Giá trị của nó cho biết ta hiện đang ở bên trong một khai báo class khi duyệt cây cú pháp hay không. Ban đầu nó là NONE
, nghĩa là ta không ở trong class nào.
Khi bắt đầu resolve một khai báo class, ta thay đổi giá trị đó.
public Void visitClassStmt(Stmt.Class stmt) {
in visitClassStmt()
ClassType enclosingClass = currentClass; currentClass = ClassType.CLASS;
declare(stmt.name);
Giống như với currentFunction
, ta lưu giá trị trước đó của trường này vào một biến local. Cách này cho phép ta tận dụng JVM để giữ một stack các giá trị currentClass
. Nhờ vậy, ta không bị mất dấu giá trị trước đó nếu một class lồng bên trong class khác.
Khi các method đã được resolve xong, ta “pop” stack đó bằng cách khôi phục giá trị cũ.
endScope();
in visitClassStmt()
currentClass = enclosingClass;
return null;
Khi resolve một biểu thức this
, trường currentClass
cung cấp thông tin cần thiết để báo lỗi nếu biểu thức đó không nằm bên trong thân một method.
public Void visitThisExpr(Expr.This expr) {
in visitThisExpr()
if (currentClass == ClassType.NONE) { Lox.error(expr.keyword, "Can't use 'this' outside of a class."); return null; }
resolveLocal(expr, expr.keyword);
Điều này sẽ giúp người dùng sử dụng this
đúng cách, và giúp chúng ta không phải xử lý việc dùng sai ở runtime trong interpreter.
12 . 7Constructor & Initializer
Giờ chúng ta đã có thể làm gần như mọi thứ với class, và khi sắp kết thúc chương này, thật kỳ lạ là chúng ta lại tập trung vào… phần khởi đầu. Method và field cho phép ta đóng gói trạng thái và hành vi lại với nhau để một object luôn duy trì ở trạng thái hợp lệ. Nhưng làm sao để đảm bảo một object mới tinh bắt đầu ở trạng thái tốt?
Để làm được điều đó, ta cần constructor. Theo tôi, đây là một trong những phần khó thiết kế nhất của một ngôn ngữ, và nếu bạn quan sát kỹ hầu hết các ngôn ngữ khác, bạn sẽ thấy những “vết nứt” quanh quá trình tạo object, nơi mà các mảnh ghép thiết kế không hoàn toàn khớp nhau. Có lẽ khoảnh khắc “chào đời” vốn dĩ đã lộn xộn.
“Xây dựng” (construct) một object thực ra gồm hai bước:
-
Runtime cấp phát bộ nhớ cần thiết cho một instance mới. Trong hầu hết các ngôn ngữ, thao tác này nằm ở tầng rất thấp, bên dưới những gì code của người dùng có thể truy cập.
-
Sau đó, một đoạn code do người dùng cung cấp sẽ được gọi để khởi tạo object còn “thô” này.
Bước thứ hai này mới là điều chúng ta thường nghĩ đến khi nghe từ “constructor”, nhưng ngôn ngữ thường đã làm một số việc chuẩn bị trước khi đến đó. Thực tế, interpreter Lox của chúng ta đã lo xong phần này khi tạo một object LoxInstance
mới.
Giờ chúng ta sẽ làm phần còn lại — khởi tạo do người dùng định nghĩa. Các ngôn ngữ có nhiều cách ký hiệu khác nhau cho đoạn code thiết lập object mới của một class. C++, Java và C# dùng một method có tên trùng với tên class. Ruby và Python gọi nó là init()
. Cách sau ngắn gọn và dễ nhớ, nên ta sẽ dùng nó.
Trong phần cài đặt LoxCallable
của LoxClass
, ta thêm vài dòng code.
List<Object> arguments) { LoxInstance instance = new LoxInstance(this);
in call()
LoxFunction initializer = findMethod("init"); if (initializer != null) { initializer.bind(instance).call(interpreter, arguments); }
return instance;
Khi một class được gọi, sau khi LoxInstance
được tạo, ta tìm method "init"
. Nếu tìm thấy, ta lập tức bind và gọi nó như một lời gọi method bình thường. Danh sách đối số được truyền tiếp nguyên vẹn.
Điều này có nghĩa là ta cũng cần chỉnh lại cách class khai báo arity.
public int arity() {
in arity()
replace 1 line
LoxFunction initializer = findMethod("init"); if (initializer == null) return 0; return initializer.arity();
}
Nếu có initializer, arity của method đó sẽ quyết định số lượng đối số bạn phải truyền khi gọi class. Tuy nhiên, ta không bắt buộc class phải có initializer. Nếu không có, arity mặc định vẫn là 0.
Về cơ bản, chỉ vậy thôi. Vì ta bind method init()
trước khi gọi, nó sẽ có quyền truy cập this
bên trong thân hàm. Điều đó, cùng với các đối số truyền vào class, là tất cả những gì bạn cần để thiết lập instance mới theo ý muốn.
12 . 7 . 1Gọi trực tiếp init()
Như thường lệ, việc khám phá vùng ngữ nghĩa mới này lại khơi ra vài tình huống kỳ lạ. Xem ví dụ:
class Foo { init() { print this; } } var foo = Foo(); print foo.init();
Bạn có thể “khởi tạo lại” một object bằng cách gọi trực tiếp method init()
của nó không? Nếu có, nó sẽ trả về gì? Một câu trả lời hợp lý là nil
, vì đó là những gì phần thân hàm trả về.
Tuy nhiên — và tôi thường không thích phải thỏa hiệp chỉ để chiều theo phần cài đặt — sẽ dễ dàng hơn nhiều cho việc triển khai constructor trong clox nếu ta quy định rằng method init()
luôn trả về this
, ngay cả khi được gọi trực tiếp. Để giữ cho jlox tương thích với điều này, ta thêm một chút code đặc biệt vào LoxFunction
.
return returnValue.value; }
in call()
if (isInitializer) return closure.getAt(0, "this");
return null;
Nếu hàm là một initializer, chúng ta sẽ ghi đè giá trị trả về thực tế và buộc nó trả về this
. Điều này dựa vào một trường mới isInitializer
.
private final Environment closure;
in class LoxFunction
replace 1 line
private final boolean isInitializer; LoxFunction(Stmt.Function declaration, Environment closure, boolean isInitializer) { this.isInitializer = isInitializer;
this.closure = closure; this.declaration = declaration;
Chúng ta không thể chỉ đơn giản kiểm tra xem tên của LoxFunction
có phải là "init"
hay không, vì người dùng có thể định nghĩa một hàm với tên đó. Trong trường hợp đó, sẽ không có this
để trả về. Để tránh trường hợp biên kỳ quặc này, chúng ta sẽ lưu trực tiếp thông tin về việc LoxFunction
có đại diện cho một method initializer hay không. Điều này có nghĩa là ta cần quay lại và sửa những chỗ tạo LoxFunction
.
public Void visitFunctionStmt(Stmt.Function stmt) {
in visitFunctionStmt()
replace 1 line
LoxFunction function = new LoxFunction(stmt, environment, false);
environment.define(stmt.name.lexeme, function);
Với các khai báo hàm thông thường, isInitializer
luôn là false
. Với method, ta sẽ kiểm tra tên.
for (Stmt.Function method : stmt.methods) {
in visitClassStmt()
replace 1 line
LoxFunction function = new LoxFunction(method, environment, method.name.lexeme.equals("init"));
methods.put(method.name.lexeme, function);
Và trong bind()
, nơi chúng ta tạo closure bind this
vào một method, ta truyền kèm giá trị gốc của method đó.
environment.define("this", instance);
in bind()
replace 1 line
return new LoxFunction(declaration, environment, isInitializer);
}
12 . 7 . 2Trả về từ init()
Chúng ta vẫn chưa xong. Từ trước đến giờ, ta giả định rằng một initializer do người dùng viết sẽ không trả về giá trị một cách tường minh, vì hầu hết constructor đều không làm vậy. Nhưng nếu người dùng thử:
class Foo { init() { return "something else"; } }
Chắc chắn nó sẽ không làm điều họ mong muốn, nên tốt nhất là biến nó thành một lỗi tĩnh. Quay lại resolver, ta thêm một trường hợp mới vào FunctionType
.
FUNCTION,
in enum FunctionType
INITIALIZER,
METHOD
Chúng ta dùng tên của method đang được duyệt để xác định xem mình có đang resolve một initializer hay không.
FunctionType declaration = FunctionType.METHOD;
in visitClassStmt()
if (method.name.lexeme.equals("init")) { declaration = FunctionType.INITIALIZER; }
resolveFunction(method, declaration);
Khi sau đó duyệt vào một câu lệnh return
, ta kiểm tra trường này và báo lỗi nếu trả về một giá trị từ bên trong method init()
.
if (stmt.value != null) {
in visitReturnStmt()
if (currentFunction == FunctionType.INITIALIZER) { Lox.error(stmt.keyword, "Can't return a value from an initializer."); }
resolve(stmt.value);
Nhưng vẫn chưa xong. Chúng ta đã chặn việc trả về một giá trị từ initializer, nhưng bạn vẫn có thể dùng một return
sớm rỗng:
class Foo { init() { return; } }
Thực tế, đôi khi điều này cũng hữu ích, nên ta không muốn cấm hoàn toàn. Thay vào đó, nó nên trả về this
thay vì nil
. Đây là một chỉnh sửa đơn giản trong LoxFunction
.
} catch (Return returnValue) {
in call()
if (isInitializer) return closure.getAt(0, "this");
return returnValue.value;
Nếu chúng ta đang ở trong một initializer và execute câu lệnh return
, thay vì trả về giá trị (luôn là nil
), ta sẽ trả về this
.
Phù! Đó là cả một danh sách việc phải làm, nhưng phần thưởng là interpreter nhỏ bé của chúng ta đã có thêm cả một mô hình lập trình hoàn chỉnh: class, method, field, this
, và constructor. Ngôn ngữ “bé con” của chúng ta giờ trông đã rất trưởng thành.
12 . 8Thử thách
-
Chúng ta đã có method trên instance, nhưng chưa có cách định nghĩa method “static” có thể được gọi trực tiếp trên object class. Hãy thêm hỗ trợ cho chúng. Dùng từ khóa
class
đứng trước method để chỉ ra đây là static method gắn với object class.class Math { class square(n) { return n * n; } } print Math.square(3); // Prints "9".
Bạn có thể giải quyết theo cách mình muốn, nhưng “metaclass” được Smalltalk và Ruby sử dụng là một cách tiếp cận đặc biệt tinh tế. Gợi ý: Cho
LoxClass
kế thừaLoxInstance
và bắt đầu từ đó. -
Hầu hết các ngôn ngữ hiện đại hỗ trợ “getter” và “setter” — các thành viên trong class trông giống như đọc/ghi field nhưng thực tế lại execute code do người dùng định nghĩa. Hãy mở rộng Lox để hỗ trợ getter method. Chúng được khai báo không có danh sách tham số. Phần thân getter sẽ được execute khi một property có tên đó được truy cập.
class Circle { init(radius) { this.radius = radius; } area { return 3.141592653 * this.radius * this.radius; } } var circle = Circle(4); print circle.area; // Prints roughly "50.2655".
-
Python và JavaScript cho phép bạn tự do truy cập field của object từ bên ngoài method của nó. Ruby và Smalltalk thì đóng gói trạng thái instance: chỉ method trong class mới có thể truy cập field thô, và class sẽ quyết định trạng thái nào được lộ ra. Hầu hết các ngôn ngữ static typing cung cấp các modifier như
private
vàpublic
để kiểm soát phần nào của class có thể truy cập từ bên ngoài, theo từng thành viên.Những đánh đổi giữa các cách tiếp cận này là gì và tại sao một ngôn ngữ có thể ưu tiên cách này hơn cách kia?
12 . 9Ghi chú thiết kế: Prototype & Sức mạnh
Trong chương này, chúng ta đã giới thiệu hai thực thể runtime mới, LoxClass
và LoxInstance
. Cái đầu tiên là nơi chứa hành vi của object, còn cái thứ hai là nơi lưu trữ trạng thái. Vậy nếu bạn có thể định nghĩa method ngay trên một object đơn lẻ, bên trong LoxInstance
thì sao? Khi đó, chúng ta sẽ chẳng cần LoxClass
nữa. LoxInstance
sẽ là một gói hoàn chỉnh để định nghĩa cả hành vi lẫn trạng thái của một object.
Chúng ta vẫn sẽ muốn có cách — không cần class — để tái sử dụng hành vi giữa nhiều instance. Ta có thể cho phép một LoxInstance
delegate trực tiếp tới một LoxInstance
khác để dùng lại field và method của nó, kiểu như kế thừa.
Người dùng sẽ mô hình hóa chương trình của mình như một chòm sao các object, một số trong đó delegate cho nhau để phản ánh những điểm chung. Các object được dùng làm delegate sẽ đại diện cho những object “chuẩn” hoặc “mẫu” mà các object khác tinh chỉnh lại. Kết quả là một runtime đơn giản hơn, chỉ với một cấu trúc nội bộ duy nhất: LoxInstance
.
Đó chính là nguồn gốc của tên gọi prototype cho mô hình này. Nó được David Ungar và Randall Smith phát minh trong một ngôn ngữ tên là Self. Họ bắt đầu từ Smalltalk và thực hiện bài tập tư duy ở trên để xem có thể lược giản nó đến mức nào.
Prototype đã từng là một khái niệm học thuật trong thời gian dài — hấp dẫn, tạo ra nhiều nghiên cứu thú vị nhưng không tạo được ảnh hưởng lớn trong thế giới lập trình — cho đến khi Brendan Eich nhét prototype vào JavaScript, và rồi nó nhanh chóng thống trị thế giới. Rất nhiều (rất nhiều) bài viết đã được viết về prototype trong JavaScript. Liệu điều đó chứng tỏ prototype là một ý tưởng xuất sắc hay gây rối — hay cả hai — vẫn là một câu hỏi bỏ ngỏ.
Tôi sẽ không bàn về việc prototype có phải là một ý tưởng hay cho một ngôn ngữ hay không. Tôi đã từng tạo ra những ngôn ngữ dựa trên prototype và dựa trên class, và quan điểm của tôi về cả hai đều phức tạp. Điều tôi muốn nói ở đây là vai trò của sự đơn giản trong một ngôn ngữ.
Prototype đơn giản hơn class — ít code hơn cho người triển khai ngôn ngữ phải viết, và ít khái niệm hơn cho người dùng phải học và hiểu. Điều đó có khiến chúng tốt hơn không? Những người mê ngôn ngữ như chúng ta thường có xu hướng “tôn thờ” chủ nghĩa tối giản. Cá nhân tôi nghĩ sự đơn giản chỉ là một phần của phương trình. Điều chúng ta thực sự muốn mang lại cho người dùng là sức mạnh, mà tôi định nghĩa như sau:
power = breadth × ease ÷ complexity
Không yếu tố nào trong số này là thước đo số học chính xác. Tôi dùng toán học ở đây như một phép ẩn dụ, không phải để định lượng thực sự.
-
Breadth (độ bao quát) là phạm vi các thứ khác nhau mà ngôn ngữ cho phép bạn biểu đạt. C có độ bao quát rất lớn — nó được dùng cho mọi thứ từ hệ điều hành, ứng dụng người dùng, đến trò chơi. Các ngôn ngữ chuyên biệt như AppleScript và Matlab có độ bao quát nhỏ hơn.
-
Ease (độ dễ dàng) là mức độ ít nỗ lực cần bỏ ra để khiến ngôn ngữ làm điều bạn muốn. “Usability” (tính khả dụng) có thể là một thuật ngữ khác, dù nó mang nhiều hàm ý hơn tôi muốn. Các ngôn ngữ “bậc cao” thường có độ dễ dàng cao hơn các ngôn ngữ “bậc thấp”. Hầu hết ngôn ngữ đều có một “hướng” tự nhiên, nơi một số việc cảm thấy dễ diễn đạt hơn những việc khác.
-
Complexity (độ phức tạp) là kích thước của ngôn ngữ (bao gồm runtime, thư viện lõi, công cụ, hệ sinh thái, v.v.). Người ta thường nói về số trang trong đặc tả ngôn ngữ, hoặc số lượng từ khóa nó có. Nó là lượng kiến thức mà người dùng phải nạp vào “bộ não ướt” của mình trước khi có thể làm việc hiệu quả. Đây là phản nghĩa của sự đơn giản.
Giảm độ phức tạp có làm tăng sức mạnh. Mẫu số càng nhỏ, giá trị kết quả càng lớn, nên trực giác của chúng ta rằng sự đơn giản là tốt là hoàn toàn đúng. Tuy nhiên, khi giảm độ phức tạp, ta phải cẩn thận không hy sinh độ bao quát hoặc độ dễ dàng, nếu không tổng sức mạnh có thể giảm. Java sẽ là một ngôn ngữ đơn giản hơn nếu bỏ đi string, nhưng khi đó nó sẽ không xử lý tốt các tác vụ xử lý văn bản, và cũng không dễ để hoàn thành công việc.
Nghệ thuật ở đây là tìm ra những phần phức tạp ngẫu nhiên có thể bỏ đi — những tính năng và tương tác của ngôn ngữ không xứng đáng với chi phí mà chúng mang lại về độ bao quát hoặc độ dễ dàng.
Nếu người dùng muốn biểu đạt chương trình của họ theo các nhóm đối tượng, thì việc tích hợp class vào ngôn ngữ sẽ tăng độ dễ dàng khi làm điều đó, hy vọng là đủ nhiều để bù cho độ phức tạp tăng thêm. Nhưng nếu đó không phải là cách người dùng sử dụng ngôn ngữ của bạn, thì cứ mạnh dạn bỏ class đi.