12

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:

  1. Cung cấp một constructor để tạo và khởi tạo instance mới của class
  2. Cung cấp cách lưu trữ và truy cập field trên instance
  3. Đị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.

declarationclassDecl
               | 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ớ:

functionIDENTIFIER "(" parameters? ")" block ;
parametersIDENTIFIER ( "," 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",
tool/GenerateAst.java
in main()
      "Class      : Token name, List<Stmt.Function> methods",
      "Expression : Expr expression",
tool/GenerateAst.java, in main()

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 {
lox/Parser.java
in declaration()
      if (match(CLASS)) return classDeclaration();
      if (match(FUN)) return function("function");
lox/Parser.java, in declaration()

Lệnh này gọi tới:

lox/Parser.java
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);
  }
lox/Parser.java, add after declaration()

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.

lox/Resolver.java
add after visitBlockStmt()
  @Override
  public Void visitClassStmt(Stmt.Class stmt) {
    declare(stmt.name);
    define(stmt.name);
    return null;
  }
lox/Resolver.java, add after visitBlockStmt()

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.

lox/Interpreter.java
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;
  }
lox/Interpreter.java, add after visitBlockStmt()

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:

lox/LoxClass.java
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;
  }
}
lox/LoxClass.java, create new file

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;

lox/LoxClass.java
replace 1 line
class LoxClass implements LoxCallable {
  final String name;
lox/LoxClass.java, replace 1 line

Việc implement interface này yêu cầu hai phương thức.

lox/LoxClass.java
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;
  }
lox/LoxClass.java, add after toString()

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.

lox/LoxInstance.java
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";
  }
}
lox/LoxInstance.java, create new file

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:

callprimary ( "(" 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",
tool/GenerateAst.java
in main()
      "Get      : Expr object, Token name",
      "Grouping : Expr expression",
tool/GenerateAst.java, in main()

Theo grammar, code parse mới sẽ nằm trong method call() hiện có.

    while (true) { 
      if (match(LEFT_PAREN)) {
        expr = finishCall(expr);
lox/Parser.java
in call()
      } else if (match(DOT)) {
        Token name = consume(IDENTIFIER,
            "Expect property name after '.'.");
        expr = new Expr.Get(expr, name);
      } else {
        break;
      }
    }
lox/Parser.java, in call()

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:

Parsing một chuỗi biểu thức '.' và '()' thành AST.

Các instance của node Expr.Get mới sẽ được đưa vào resolver.

lox/Resolver.java
add after visitCallExpr()
  @Override
  public Void visitGetExpr(Expr.Get expr) {
    resolve(expr.object);
    return null;
  }
lox/Resolver.java, add after visitCallExpr()

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.

lox/Interpreter.java
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.");
  }
lox/Interpreter.java, add after visitCallExpr()

Đầ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;
lox/LoxInstance.java
in class LoxInstance
  private final Map<String, Object> fields = new HashMap<>();

  LoxInstance(LoxClass klass) {
lox/LoxInstance.java, in class LoxInstance

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:

lox/LoxInstance.java
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 + "'.");
  }
lox/LoxInstance.java, add after LoxInstance()

Một trường hợp biên thú vị cần xử lý là khi instance không 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ụ:

breakfast.omelette.filling.meat = ham

Lưu ý rằng chỉ phần cuối cùng, .meat, mới là setter. Các phần .omelette.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",
tool/GenerateAst.java
in main()
      "Set      : Expr object, Token name, Expr value",
      "Unary    : Token operator, Expr right",
tool/GenerateAst.java, in main()

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);
lox/Parser.java
in assignment()
      } else if (expr instanceof Expr.Get) {
        Expr.Get get = (Expr.Get)expr;
        return new Expr.Set(get.object, get.name, value);
      }
lox/Parser.java, in assignment()

Vậy là xong phần parse cú pháp. Ta đưa node này qua resolver.

lox/Resolver.java
add after visitLogicalExpr()
  @Override
  public Void visitSetExpr(Expr.Set expr) {
    resolve(expr.value);
    resolve(expr.object);
    return null;
  }
lox/Resolver.java, add after visitLogicalExpr()

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.

lox/Interpreter.java
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;
  }
lox/Interpreter.java, add after visitLogicalExpr()

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.

lox/LoxInstance.java
add after get()
  void set(Token name, Object value) {
    fields.put(name.lexeme, value);
  }
lox/LoxInstance.java, add after get()

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.

Cây cú pháp cho 'object.method(argument)

Đ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ì .() trong một lời gọi method 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àybound 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);
lox/Resolver.java
in visitClassStmt()

    for (Stmt.Function method : stmt.methods) {
      FunctionType declaration = FunctionType.METHOD;
      resolveFunction(method, declaration); 
    }

    return null;
lox/Resolver.java, in visitClassStmt()

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,
lox/Resolver.java
in enum FunctionType
add “,” to previous line
    METHOD
  }
lox/Resolver.java, in enum FunctionType, add “,” to previous line

Đ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);
lox/Interpreter.java
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);
lox/Interpreter.java, in visitClassStmt(), replace 1 line

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;
lox/LoxClass.java
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() {
lox/LoxClass.java, in class LoxClass, replace 4 lines

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);
    }

lox/LoxInstance.java
in get()
    LoxFunction method = klass.findMethod(name.lexeme);
    if (method != null) return method;

    throw new RuntimeError(name, 
        "Undefined property '" + name.lexeme + "'.");
lox/LoxInstance.java, in get()

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:

lox/LoxClass.java
add after LoxClass()
  LoxFunction findMethod(String name) {
    if (methods.containsKey(name)) {
      return methods.get(name);
    }

    return null;
  }
lox/LoxClass.java, add after LoxClass()

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:

Closure ban đầu cho method.

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.

Closure mới bind 'this'.

Đâ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.

Gọi bound method và tạo environment mới cho thân method.

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",
tool/GenerateAst.java
in main()
      "This     : Token keyword",
      "Unary    : Token operator, Expr right",
tool/GenerateAst.java, in main()

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);
    }
lox/Parser.java
in primary()

    if (match(THIS)) return new Expr.This(previous());

    if (match(IDENTIFIER)) {
lox/Parser.java, in primary()

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.

lox/Resolver.java
add after visitSetExpr()
  @Override
  public Void visitThisExpr(Expr.This expr) {
    resolveLocal(expr, expr.keyword);
    return null;
  }

lox/Resolver.java, add after visitSetExpr()

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);

lox/Resolver.java
in visitClassStmt()
    beginScope();
    scopes.peek().put("this", true);

    for (Stmt.Function method : stmt.methods) {
lox/Resolver.java, in visitClassStmt()

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 đó.

    }

lox/Resolver.java
in visitClassStmt()
    endScope();

    return null;
lox/Resolver.java, in visitClassStmt()

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);
lox/LoxInstance.java
in get()
replace 1 line
    if (method != null) return method.bind(this);

    throw new RuntimeError(name, 
        "Undefined property '" + name.lexeme + "'.");
lox/LoxInstance.java, in get(), replace 1 line

Lưu ý lời gọi mới tới bind(). Nó trông như sau:

lox/LoxFunction.java
add after LoxFunction()
  LoxFunction bind(LoxInstance instance) {
    Environment environment = new Environment(closure);
    environment.define("this", instance);
    return new LoxFunction(declaration, environment);
  }
lox/LoxFunction.java, add after LoxFunction()

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.

lox/Interpreter.java
add after visitSetExpr()
  @Override
  public Object visitThisExpr(Expr.This expr) {
    return lookUpVariable(expr.keyword, expr);
  }
lox/Interpreter.java, add after visitSetExpr()

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.

  }
lox/Resolver.java
add after enum FunctionType

  private enum ClassType {
    NONE,
    CLASS
  }

  private ClassType currentClass = ClassType.NONE;

  void resolve(List<Stmt> statements) {
lox/Resolver.java, add after enum FunctionType

Đú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) {
lox/Resolver.java
in visitClassStmt()
    ClassType enclosingClass = currentClass;
    currentClass = ClassType.CLASS;

    declare(stmt.name);
lox/Resolver.java, in visitClassStmt()

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();

lox/Resolver.java
in visitClassStmt()
    currentClass = enclosingClass;
    return null;
lox/Resolver.java, in visitClassStmt()

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) {
lox/Resolver.java
in visitThisExpr()
    if (currentClass == ClassType.NONE) {
      Lox.error(expr.keyword,
          "Can't use 'this' outside of a class.");
      return null;
    }

    resolveLocal(expr, expr.keyword);
lox/Resolver.java, in visitThisExpr()

Đ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:

  1. 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.

  2. 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);
lox/LoxClass.java
in call()
    LoxFunction initializer = findMethod("init");
    if (initializer != null) {
      initializer.bind(instance).call(interpreter, arguments);
    }

    return instance;
lox/LoxClass.java, in call()

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() {
lox/LoxClass.java
in arity()
replace 1 line
    LoxFunction initializer = findMethod("init");
    if (initializer == null) return 0;
    return initializer.arity();
  }
lox/LoxClass.java, in arity(), replace 1 line

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ý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;
    }
lox/LoxFunction.java
in call()

    if (isInitializer) return closure.getAt(0, "this");
    return null;
lox/LoxFunction.java, in call()

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;

lox/LoxFunction.java
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;
lox/LoxFunction.java, in class LoxFunction, replace 1 line

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ôngthis để 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) {
lox/Interpreter.java
in visitFunctionStmt()
replace 1 line
    LoxFunction function = new LoxFunction(stmt, environment,
                                           false);
    environment.define(stmt.name.lexeme, function);
lox/Interpreter.java, in visitFunctionStmt(), replace 1 line

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) {
lox/Interpreter.java
in visitClassStmt()
replace 1 line
      LoxFunction function = new LoxFunction(method, environment,
          method.name.lexeme.equals("init"));
      methods.put(method.name.lexeme, function);
lox/Interpreter.java, in visitClassStmt(), replace 1 line

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);
lox/LoxFunction.java
in bind()
replace 1 line
    return new LoxFunction(declaration, environment,
                           isInitializer);
  }
lox/LoxFunction.java, in bind(), replace 1 line

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,
lox/Resolver.java
in enum FunctionType
    INITIALIZER,
    METHOD
lox/Resolver.java, in enum FunctionType

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;
lox/Resolver.java
in visitClassStmt()
      if (method.name.lexeme.equals("init")) {
        declaration = FunctionType.INITIALIZER;
      }

      resolveFunction(method, declaration); 
lox/Resolver.java, in visitClassStmt()

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) {
lox/Resolver.java
in visitReturnStmt()
      if (currentFunction == FunctionType.INITIALIZER) {
        Lox.error(stmt.keyword,
            "Can't return a value from an initializer.");
      }

      resolve(stmt.value);
lox/Resolver.java, in visitReturnStmt()

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) {
lox/LoxFunction.java
in call()
      if (isInitializer) return closure.getAt(0, "this");

      return returnValue.value;
lox/LoxFunction.java, in call()

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

  1. 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ừa LoxInstance và bắt đầu từ đó.

  2. 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".
    
  3. 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ư privatepublic để 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, LoxClassLoxInstance. 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 prototypedự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 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.