跳到主要内容

Classes(类)

类(class)用于描述一类对象共同拥有的数据和行为,对象(object)则是类在运行时创建的实例。Dart 中能够赋给变量的值都是对象,每个对象都是某个类的实例;数字、字符串、函数以及 null 也都是对象。

学习 Dart 类时,重点需要掌握以下问题:

  • 如何声明类并创建对象?
  • 实例变量和实例方法分别负责什么?
  • 构造函数如何初始化不可空字段?
  • this、getter、setter 和私有成员如何使用?
  • 实例成员和静态成员有什么区别?
  • 两个内容相同的对象为什么不一定相等?

声明类并创建对象

使用 class 声明类。类中可以定义保存状态的实例变量,以及操作这些状态的实例方法:

class Rectangle {
double width;
double height;

Rectangle(this.width, this.height);

double area() {
return width * height;
}
}

void main() {
final rectangle = Rectangle(4, 3);

print(rectangle.width); // 4.0
print(rectangle.area()); // 12.0
}

上例中:

  • Rectangle 是类名。
  • widthheight 是每个实例各自保存的实例变量。
  • Rectangle(this.width, this.height) 是构造函数,负责初始化实例变量。
  • area() 是实例方法,可以直接访问当前对象的实例变量。
  • Rectangle(4, 3) 调用构造函数并创建一个 Rectangle 对象。

Dart 2 以后,调用构造函数时通常不需要写 new。即使写上 new Rectangle(4, 3) 仍是合法语法,实际代码一般使用更简洁的形式。

TypeScript 对比:两种语言都使用 class 声明类,并通过点号访问实例成员。Dart 创建对象时直接调用类的构造函数,通常不写 TypeScript 必需的 new

实例变量

实例变量也称为字段(field)。每创建一个对象,都会得到一组属于该对象的实例变量:

class Counter {
int value = 0;
}

void main() {
final first = Counter();
final second = Counter();

first.value++;

print(first.value); // 1
print(second.value); // 0
}

修改 first.value 不会影响 second.value,因为两个对象保存的是不同状态。

初始化不可空字段

启用健全空安全后,不可空实例变量必须在对象创建完成前得到值。常用方式包括在声明处初始化,或通过构造函数初始化:

class User {
String name;
bool isActive = true;

User(this.name);
}

如果一个字段确实可以暂时没有值,应把类型声明为可空类型:

class User {
String name;
String? nickname;

User(this.name);
}

不要仅为了消除初始化错误就随意使用 latelate 表示开发者保证在读取前完成赋值;如果实际读取时尚未初始化,程序会在运行时抛出错误。

final 实例变量

对象创建后不应重新赋值的字段可以声明为 final,并由构造函数初始化:

class Article {
final String title;
final DateTime publishedAt;

Article(this.title, this.publishedAt);
}

final 只限制字段不能再次指向其他值,不会自动让字段引用的对象变成不可变对象。例如,final List<String> 仍然可以添加元素。

构造函数

构造函数与类同名,没有返回类型。前面的 Rectangle(this.width, this.height) 使用了初始化形式参数(initializing formal parameter),它会把传入的参数直接赋给同名实例变量。

普通写法与简写形式表达相同的初始化意图:

class Point {
final double x;
final double y;

Point(double x, double y)
: x = x,
y = y;
}

class Offset {
final double x;
final double y;

Offset(this.x, this.y);
}

当参数只用于初始化同名字段时,优先使用 this.x 这种简写。

命名构造函数

Dart 不支持按参数签名重载构造函数,但可以通过命名构造函数表达不同的创建方式:

class Point {
final double x;
final double y;

Point(this.x, this.y);

Point.origin()
: x = 0,
y = 0;
}

void main() {
final point = Point.origin();
print('(${point.x}, ${point.y})'); // (0.0, 0.0)
}

Point.origin() 的名称直接说明了创建意图。一个类可以声明多个命名构造函数,但每个名称必须唯一。

const 构造函数

当类的所有实例变量都是 final,并且构造过程可以在编译期完成时,可以声明 const 构造函数:

class Color {
final int red;
final int green;
final int blue;

const Color(this.red, this.green, this.blue);
}

void main() {
const first = Color(255, 0, 0);
const second = Color(255, 0, 0);

print(identical(first, second)); // true
}

在常量上下文中,参数完全相同的常量对象会被规范化为同一个实例。const 构造函数允许创建常量对象,但调用处是否产生编译时常量,还取决于调用是否处于常量上下文或是否显式使用 const

实例方法与 this

实例方法在某个对象上调用,可以读取和修改该对象的实例变量:

class BankAccount {
double balance;

BankAccount(this.balance);

void deposit(double amount) {
if (amount <= 0) {
throw ArgumentError.value(amount, 'amount', 'must be positive');
}

balance += amount;
}
}

void main() {
final account = BankAccount(100);
account.deposit(50);

print(account.balance); // 150.0
}

在实例方法中,未加限定的 balance 会解析为当前对象的字段。this 表示当前对象;只有在需要消除名称歧义或明确传递当前对象时才需要显式写出:

class Profile {
String name;

Profile(this.name);

void rename(String name) {
this.name = name;
}
}

这里参数和字段都叫 name,因此使用 this.name 指向实例变量。

Getter 与 setter

Dart 会为普通实例变量隐式提供 getter,并为非 final 变量隐式提供 setter。也可以使用 getset 定义计算属性或集中校验赋值:

class Temperature {
double _celsius;

Temperature(this._celsius);

double get celsius => _celsius;

double get fahrenheit => _celsius * 9 / 5 + 32;

set celsius(double value) {
if (value < -273.15) {
throw ArgumentError('Temperature is below absolute zero.');
}
_celsius = value;
}
}

void main() {
final temperature = Temperature(20);

print(temperature.fahrenheit); // 68.0
temperature.celsius = 25;
print(temperature.celsius); // 25.0
}

调用 getter 和 setter 时使用属性语法,不添加方法调用括号。getter 适合读取计算结果,setter 适合为赋值增加校验;包含明显动作或成本较高的操作通常应使用普通方法。

库级私有成员

Dart 没有 private 关键字。标识符以下划线 _ 开头时,它对当前库(library)私有:

class ApiToken {
final String _value;

ApiToken(this._value);

String get masked => '${_value.substring(0, 4)}****';
}

这里的 _value 只能在声明它的库中直接访问。私有边界是库,而不是类;同一个库中的其他声明仍然可以访问 _value。一个 Dart 文件通常就是一个库,但通过 part 组合的多个文件属于同一个库。

TypeScript 对比:TypeScript 可以使用 private 或 JavaScript 的 #field 表示不同形式的私有成员。Dart 的 _name 是库级私有,没有完全等价的类级私有语法。

静态成员

使用 static 声明属于类本身的成员。静态成员通过类名访问,不依赖某个实例:

class Circle {
static const double pi = 3.141592653589793;

final double radius;

Circle(this.radius);

double area() => pi * radius * radius;

static bool isValidRadius(double radius) => radius > 0;
}

void main() {
print(Circle.pi);
print(Circle.isValidRadius(2)); // true

final circle = Circle(2);
print(circle.area());
}

实例方法可以访问静态成员,但静态方法没有当前实例,不能直接访问 radius 或使用 this。与对象状态无关、并且在概念上属于该类的常量或工具方法,才适合声明为静态成员。

对象的类型与成员访问

没有显式声明父类的自定义类会隐式继承 Object,其实例可以使用 Object 提供的成员,例如 runtimeTypetoString()hashCode

class Course {
final String title;

Course(this.title);
}

void main() {
final course = Course('Dart classes');

print(course.runtimeType); // Course
print(course is Course); // true
print(course.toString()); // 默认输出类似 Instance of 'Course'
}

需要根据类型分支时使用 is;不要依赖 runtimeType.toString() 的文本结果实现业务逻辑。

可空对象的成员访问

可空对象可能为 null,因此不能直接访问实例成员。可以使用空判断操作符 ?.

class User {
final String name;

User(this.name);
}

void main() {
User? currentUser;

print(currentUser?.name); // null
print(currentUser?.name ?? 'Guest'); // Guest
}

currentUser?.name 在对象非空时读取 name,为空时返回 null。只有在程序逻辑已经保证对象非空时才应使用非空断言 !

对象相等性

默认情况下,自定义类的 == 判断两个引用是否指向同一个对象。字段内容相同的两个普通实例不一定相等:

class Point {
final int x;
final int y;

Point(this.x, this.y);
}

void main() {
final first = Point(1, 2);
final second = Point(1, 2);

print(first == second); // false
print(identical(first, second)); // false
}

如果业务语义要求按字段值比较,需要同时重写 ==hashCode

class Point {
final int x;
final int y;

const Point(this.x, this.y);


bool operator ==(Object other) {
return identical(this, other) ||
other is Point && x == other.x && y == other.y;
}


int get hashCode => Object.hash(x, y);
}

void main() {
final first = Point(1, 2);
final second = Point(1, 2);

print(first == second); // true
}

相等的对象必须返回相同的 hashCode,否则它们在 SetMap 键等哈希集合中的行为会不一致。类中包含可变字段时,应谨慎使用这些字段计算相等性和哈希值,因为对象加入哈希集合后再改变哈希相关字段会破坏查找结果。

常见误区

误区一:认为 final 对象不能修改

final 限制变量不能重新赋值,不会冻结对象:

class Counter {
int value = 0;
}

void main() {
final counter = Counter();
counter.value++; // 合法:修改对象内部状态

// counter = Counter(); // 编译错误:不能重新赋值 final 变量
}

误区二:让不可空字段保持未初始化

不可空字段必须在声明处、初始化列表或构造函数初始化形式参数中完成初始化。字段确实可缺失时使用 T?;能够保证稍后初始化时才考虑 late

误区三:把 _name 理解为类级私有

以下划线开头的名称对库私有,同一个库中的其他类仍然可以访问它。需要设计封装边界时,应同时考虑库和文件的组织方式。

误区四:通过实例访问静态成员

静态成员属于类,应写成 Circle.pi,而不是通过 circle 实例访问。这样也能让调用处明确表达成员不依赖对象状态。

误区五:只重写 == 而忽略 hashCode

自定义值相等语义时必须同时维护 ==hashCode 的一致性。否则对象作为 Set 元素或 Map 键时可能出现难以理解的结果。

小结

  • 类定义对象的数据和行为,对象是类在运行时创建的实例。
  • 实例变量属于具体对象;不可空字段必须在对象创建完成前初始化。
  • 构造函数与类同名,初始化形式参数可以简化字段赋值;命名构造函数用于表达不同创建方式。
  • const 构造函数可以创建编译时常量对象,但要求实例状态满足常量约束。
  • 实例方法通过当前对象工作;this 只在消除歧义等场景下需要显式书写。
  • getter 和 setter 使用属性访问语法;以下划线开头的名称对当前库私有。
  • 静态成员属于类本身,静态方法不能直接访问实例状态。
  • 默认相等性通常比较对象身份;按字段比较时必须同时重写 ==hashCode