Type system trong ngôn ngữ Dart

I. Lời mở đầu

Ngôn ngữ Dart kết hợp kết hợp việc kiểm tra kiểu dữ liệu static và kiểu dữ liệu runtime để đảm bảo giá trị của một biến luôn luôn phù hợp với kiểu dữ liệu static của biến đó, điều này đôi khi được gọi bằng thuật ngữ sound typing . Trong ngôn ngữ Dart, về bản chất một biến bắt buộc phải có kiểu dữ liệu, nhưng lập trình viên đôi khi không nhất thiết phải dùng type annotations để chỉ ra kiểu dữ liệu của biến đó vì Dart có thể tự suy luận ra kiểu dữ liệu. Ta gọi kiểu dữ liệu được suy ra đó là type inference.

Một trong những lợi ích của việc kiểm tra kiểu dữ liệu đó là khả năng phát hiển bug lúc compile time sử dụng static analyzer.

void printInts(List<int> a) => print(a);

void main() {
  var list = [];
  list.add(1);
  list.add("2");
  printInts(list); 
}
// Unhandled exception: type 'List<dynamic>' is not a subtype of type 'List<int>'

Ở đoạn code phía trên, ta thấy biến list không được chỉ ra kiểu dữ liệu cụ thể nên type inferencecủa nó sẽ là List<dynamic>. Việc add các giá trị 1"2" vào là hoàn toàn hợp lệ. Nhưng đến khi truyền list vào hàm printInts thì sẽ bị lỗi vì hàm printInts trong muốn nhận vào tham số có kiểu dữ liệu là List<int>. Nhưng biến list lại có kiểu dữ liệu là List<dynamic> tức không phải là List<int> và cũng không phải là subtypecủa List<int>.

Để sửa lỗi, ta sẽ sử code lại thành như sau:

void printInts(List<int> a) => print(a);

void main() {
  var list = <int>[];
  list.add(1);
  list.add(2);
  printInts(list);
}

II. Soundness là gì?

Soundness là 1 khái niệm nhằm thể hiện rằng mong muốn đảm bảo có chương trình không thể rơi vào trạng thái chứa các giá trị không hợp lệ. Một sound type system có nghĩa là chương trình không bao giờ có trạng thái mà chứ một giá trị không phù hợp với static type của nó. Ví dụ như một expression thể hiện static type của nó là String thì tại thời điểm runtime ta đảm bảo nó chỉ có thể thể vào giá trị là String.

Type system của Dart cũng giống như type system trong các ngôn ngữ như Java, C#, .. chúng là sound. Điều này đảm bảo rằng ta kiểm tra cả dữ liệu kiểu static và kiểu runtime. Ví dụ như việc gán một String cho một int là một compile-time error.

III. Lợi ích của soundness là gì?

  • Phát hiện bug liên quan đến kiểu dữ liệu ở thời điểm compile time. Một sound type system bắt buộc code không được mơ hồ về kiểu dữ liệu của nó. Nên một số bug mà có phát hiện ở runtime có thể được phát hiện từ sớm về dễ dàng ở thời điểm compile time.
  • Code dễ đọc. Code sẽ trở nên dễ đọc vì ta có thể nhìn thấy kiểu dữ liệu của nó là gì.
  • Code dễ được bảo trì. Khi ta sửa 1 phần code nhỏ. type system cá thể cảnh báo rằng việc thay đổi đó có thể làm ảnh hưởng hoặc hư hại để những phần code khác.
  • Thuận lợi hơn cho ahead of time (AOT) compilation. Mặc dù AOT compilation vẫn khả thi khi không có kiểu dữ liệu, nhưng phần code được generate ra sẽ không tối ưu bằng code được generate ra khi có kiểu dữ liệu.

IV. Một số tips để không bị lỗi ở bước static analysis

  1. Sử dụng sound return type khi override phương thức:

Kiểu dữ liệu trả về của phương thức trong subclass phải cùng kiểu hoặc là subtype của phương thức tương ứng trong superclass. Ta xét phương thức getter trong Animal class bên dưới:

class Animal {
  void chase(Animal a) { ... }
  Animal get parent => ...
}

Phương thức getter cha trả về một Animal. Trong lớp con HoneyBadger, bạn có thể thay thế kiểu trả về getter bằng HoneyBadger (hoặc bất kỳ kiểu con nào khác của Animal), nhưng không được phép sử dụng loại không liên quan.

// static analysic: success
class HoneyBadger extends Animal {
  void chase(Animal a) { ... }
  HoneyBadger get parent => ...
}

// static analysic: error/warning
class HoneyBadger extends Animal {
  void chase(Animal a) { ... }
  Root get parent => ...
}
  1. Sử dụng sound parameter type khi override phương thức:

Tham số của phương thức được bị overridden  phải có cùng loại hoặc là supertype của tham số tương ứng trong superclass.

Xét phương thức chase (Animal) bên dưới:

class Animal {
  void chase(Animal a) { ... }
  Animal get parent => ...
}
  1. Không sử dụng dynamic list dưới dạng typed list:

Dynamic list hữu dùng khi bạn muốn có một danh sách với các loại khác nhau trong đó. Tuy nhiên ta không thể sử dụng dynamic list như một typed list

Đoạn code sau tạo một Dynamic list của Dog và gán nó vào danh sách kiểu Cat.

// static analysic: error/warning
class Cat extends Animal { ... }

class Dog extends Animal { ... }

void main() {
  List<Cat> foo = <dynamic>[Dog()]; // Error
  List<dynamic> bar = <dynamic>[Dog(), Cat()]; // OK
}

V. Runtime checks

Các công cụ kiểm tra kiểu dữ liệu tại thời điểm runtime như Dart VMdartdevc có thể bắt được lỗi mà analyzer không bắt được.

// runtime: error
void main() {
  List<Animal> animals = [Dog()];
  List<Cat> cats = animals;
}

VI. Type inference

Analyzer  có thể suy ra các kiểu cho các trường, phương thức, biến cục bộ và hầu hết các generic type argument. Khi analyzer không có đủ thông tin để suy ra một loại cụ thể, nó sẽ sử dụng kiểu dynamic.

Sau đây là một ví dụ thể hiện cách type inference làm việc với generic. Trong ví dụ này, biến arguments có giá trị là map với key kiểu String và với value có nhiều kiểu khác nhau. Nếu ta ghi ra kiểu dữ liệu, code được viết như bên dưới:

Map<String, dynamic> arguments = {'argA': 'hello', 'argB': 42};

Trường hợp ta không muốn phải ghi rõ kiểu dữ liệu mà muốn Dart hỗ trợ ta tự suy ra kiểu dữ liệu thì code sẽ như bên dưới:

var arguments = {'argA': 'hello', 'argB': 42}; // Map<String, Object>
  1. Field and method inference

Một trường hoặc phương thức không có kiểu dữ liệu được chỉ định cụ thể và override trường hoặc phương thức của class cha sẽ kế thừa kiểu dữ liệu tương ứng từ phương thức hoặc trường của class cha

Một trường không có loại khai báo hoặc kế thừa nhưng được khai báo với giá trị ban đầu, sẽ có loại được suy ra dựa trên giá trị ban đầu đó.

  1. Static field inference

Static field và biến sẽ nhận kiểu dữ liệu từ initializer của chúng.

  1. Local variable inference

Kiểu dữ liệu của biến cục bộ được suy ra từ initializer (nếu có). Các phép gán sau đó sẽ không được tính tới. Điều này có nghĩa là nếu như ta biết trước kiểu dữ liệu được suy ra không phù hợp, thì ngay từ đầu ta chỉ định rõ kiểu dữ liệu chứ không phó thác cho Dart làm.

// static analysic: error/warning
var x = 3; // x is inferred as an int
x = 4.0;

// static analysic: success
num y = 3; // a num can be double or int
y = 4.0;

  1. Type argument inference
// Inferred as if you wrote <int>[].
List<int> listOfInt = [];

// Inferred as if you wrote <double>[3.0].
var listOfDouble = [3.0];

// Inferred as Iterable<int>
var ints = listOfDouble.map((x) => x.toInt());

Trong ví dụ trên x có kiểu được suy ra là doubleints có kiểu dữ liệu được suy ra là Iterable<int>

VII. Substituting types

Khi override một phương thức, ta thay thế một số thứ (trong phương thức cũ) thành một thứ khác (trong phương thức mới), tương tự như vậy khi ta truyền tham số cho một hàm, ta thay một thứ có kiểu dữ liệu được khai báo trong định nghĩa hàm bằng 1 thứ có kiểu dữ liệu (có thể khác) tương ứng với giá trị của tham số mà ta truyền vào. Câu hỏi là đặt ra là việc thay thế như vậy khi nào đúng khi nào sai? Kiểu dự liệu nào có thể thay thế cho kiểu dữ liệu nào?

Khi bàn về vấn đề trên, ta thường sẽ sử dụng 2 thuật ngữ là consumerproducer. Consumer là cái mà nhận kiểu dữ liệu và producer là cái mà tạo ra kiểu dữ liệu. Nguyên tắc là ta có thể thay kiểu dữ liệu của comsumer bằng supertype của nó, và có thể thay kiểu dữ liệu của producer bằng subtype của nó.

Xét sơ đồ kế thừa bên trên, ta gán 1 phép gán đơn giản như sau:

Cat c = Cat();

c sẽ đóng vai trò là consumerCat() đóng vai trò là producer.

// static analysic: success
Animal c = Cat();
// static analysic: error/warning
MaineCoon c = Cat();

Tương tự ta có ví dụ như bên dưới:

// static analysic: success
List<Cat> myCats = List<MaineCoon>();

Chuyện gì sẽ xảy ra nên ta gán List<Animal> cho List<Cat> ?

// static analysic: success
List<Cat> myCats = List<MaineCoon>();

Ta thấy vẫn pass được static analysic vì nó được dịch thành code như bên dưới:

List<Cat> myCats = List<Animal>() as List<Cat>;

Tuy nhiên đoạn code trên có thể bị fail tại thời điểm runtime. Để chặn việc ép kiểu ngầm ta có thể setting bằng implicit-casts

VIII. Nguồn tham khảo

https://dart.dev/guides/language/type-system