跳到主要内容

Functions(函数)

函数把一段逻辑封装成可以重复调用的代码。Dart 中的函数也是对象,可以保存到变量中、作为参数传递,或从其他函数返回。

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

  • 如何声明函数并指定参数与返回值?
  • 位置参数、命名参数和可选参数分别如何使用?
  • => 简写适用于什么函数?
  • 如何把函数作为值传递?
  • 闭包为什么能够记住外层作用域中的变量?

声明与调用函数

函数声明通常由返回类型、函数名、参数列表和函数体组成:

int add(int left, int right) {
return left + right;
}

void main() {
final result = add(2, 3);
print(result); // 5
}

上例中:

  • int 表示 add() 返回整数。
  • leftright 是两个必需的位置参数,调用时必须按顺序传入。
  • return 结束当前函数,并把计算结果交给调用方。

函数参数和返回值都属于函数公开契约的一部分。即使分析器能够推断返回类型,公开函数通常也应明确写出返回类型,让调用方式更容易理解。

不返回结果的函数

只执行操作、不向调用方返回结果的函数使用 void

void greet(String name) {
print('Hello, $name!');
}

void main() {
greet('Dart'); // Hello, Dart!
}

void 表示调用方不应使用该函数的返回结果,而不是要求函数体必须为空。

表达式函数体

函数体只有一个表达式时,可以使用 => 简写:

int square(int value) => value * value;

void main() {
print(square(4)); // 16
}

=> expression 等价于 { return expression; }。箭头后面只能写表达式,不能直接放 iffor 等语句。逻辑包含多条语句时,应使用普通函数体。

TypeScript 对比:两种语言都支持箭头写法,但 Dart 的 => 只是单表达式函数体的简写,不负责声明函数值。Dart 匿名函数的参数列表写在 => 前,例如 (value) => value * 2

位置参数

普通参数是必需的位置参数。实参的含义由调用位置决定:

String describeUser(String name, int age) {
return '$name is $age years old.';
}

void main() {
print(describeUser('Ada', 36));
}

调用 describeUser() 时必须提供两个参数,而且顺序必须与声明一致。适合少量、含义直观的参数;参数较多或多个参数类型相同时,命名参数通常更易读。

命名参数

命名参数放在 {} 中,调用时通过名称传值,因此顺序可以改变:

String createLabel({required String text, required String color}) {
return '$text ($color)';
}

void main() {
print(createLabel(color: 'blue', text: 'Save')); // Save (blue)
}

命名参数默认是可选的。添加 required 后,调用方必须提供该参数。required 只要求调用时出现该名称;参数值是否允许为 null,仍由参数类型决定:

void showMessage({required String? message}) {
print(message ?? 'No message');
}

void main() {
showMessage(message: null); // No message
// showMessage(); // 编译错误:缺少 message
}

可选命名参数与默认值

可选命名参数必须允许“调用方没有传值”这种状态。可以把它声明为可空类型,也可以提供默认值:

String formatName({String? prefix, String separator = ' '}) {
final name = prefix ?? 'Guest';
return '$name${separator}User';
}

void main() {
print(formatName()); // Guest User
print(formatName(prefix: 'Dart', separator: '-')); // Dart-User
}

默认参数值必须是编译时常量。调用方省略 separator 时,函数使用 ' ';显式传入其他值时,则使用调用方提供的值。

TypeScript 对比:TypeScript 没有 Dart 这种内建的命名实参语法。常见替代方式是接收一个对象并进行解构;Dart 的命名参数则直接属于函数签名,分析器会检查参数名称和 required 约束。

function createLabel(options: { text: string; color: string }): string {
return `${options.text} (${options.color})`
}

createLabel({ color: 'blue', text: 'Save' })

可选位置参数

放在 [] 中的位置参数可以省略,但仍然按照位置传递:

String joinWords(String first, [String second = '', String third = '']) {
return [first, second, third].where((word) => word.isNotEmpty).join(' ');
}

void main() {
print(joinWords('Learn')); // Learn
print(joinWords('Learn', 'Dart')); // Learn Dart
print(joinWords('Learn', 'Dart', 'Functions')); // Learn Dart Functions
}

必需位置参数必须写在可选位置参数之前。可选位置参数如果没有默认值,其类型通常需要允许 null

void printScore(String name, [int? score]) {
print(score == null ? '$name: no score' : '$name: $score');
}

一个参数列表可以使用可选位置参数或命名参数,但不能同时使用两种可选参数形式。参数含义不容易从位置看出时,优先选择命名参数。

返回值与类型推断

Dart 可以根据函数体推断返回类型:

multiply(int left, int right) => left * right;

这里返回类型会被推断为 int,但省略返回类型也可能让接口意图变得不清楚。尤其是公开 API,推荐显式写成:

int multiply(int left, int right) => left * right;

声明了不可空返回类型的函数,所有正常结束的执行路径都必须返回相应类型的值:

String scoreLevel(int score) {
if (score >= 90) {
return 'A';
}
if (score >= 60) {
return 'B';
}
return 'C';
}

如果最后一个 return 'C'; 缺失,函数就可能在没有返回 String 的情况下结束,无法满足声明的返回类型。

函数是一等对象

Dart 把函数当作一等对象(first-class object):函数可以赋给变量,也可以作为参数和返回值使用。

int add(int left, int right) => left + right;

void main() {
final operation = add;
print(operation(2, 3)); // 5
}

这里的 operation 保存的是函数本身,operation(2, 3) 才是调用函数。直接引用已有函数而不调用它,称为函数撕取(function tear-off)。

明确声明函数类型

函数类型描述参数和返回值的契约:

int calculate(int left, int right, int Function(int, int) operation) {
return operation(left, right);
}

int add(int left, int right) => left + right;
int subtract(int left, int right) => left - right;

void main() {
print(calculate(8, 3, add)); // 11
print(calculate(8, 3, subtract)); // 5
}

int Function(int, int) 表示“接收两个 int 并返回 int 的函数”。相比只写宽泛的 Function,具体函数类型能够让分析器检查参数和返回值,也能让调用方直接看懂契约。

复杂或重复使用的函数类型可以通过 typedef 命名,详见 Typedefs(类型别名)

TypeScript 对比:Dart 的 int Function(int, int) 可以类比为 TypeScript 的 (left: number, right: number) => number。两者都会约束函数的参数和返回值。

匿名函数与回调

没有名称的函数称为匿名函数。它常作为回调函数直接传给另一个函数:

void main() {
final numbers = [1, 2, 3];
final doubled = numbers.map((number) {
return number * 2;
});

print(doubled.toList()); // [2, 4, 6]
}

匿名函数也可以使用表达式函数体:

void main() {
final numbers = [1, 2, 3];
final doubled = numbers.map((number) => number * 2);

print(doubled.toList()); // [2, 4, 6]
}

map() 接收一个函数,并在遍历时调用它。这里的 (number) => number * 2 是回调;它描述“每个元素应该如何转换”,而 map() 负责遍历。

当已有函数的签名正好匹配时,可以直接传递函数撕取,避免再包一层匿名函数:

String normalize(String value) => value.trim().toLowerCase();

void main() {
final topics = [' Dart ', ' FUNCTIONS '];

print(topics.map(normalize).toList()); // [dart, functions]
}

词法作用域

Dart 使用词法作用域(lexical scope):变量是否可访问,由函数在源代码中的嵌套位置决定。

String language = 'Dart';

void main() {
const topic = 'Functions';

void printTopic() {
print('$language: $topic');
}

printTopic(); // Dart: Functions
}

内层函数 printTopic() 可以访问自己的作用域、外层 main() 的作用域以及顶层作用域。外层作用域不能反过来访问只在内层函数中声明的变量。

当内外层出现同名变量时,距离当前代码最近的声明会遮蔽外层声明:

const label = 'top level';

void main() {
const label = 'main';

void showLabel() {
const label = 'local';
print(label);
}

showLabel(); // local
}

过多的同名遮蔽会增加理解成本,实际代码中应优先使用能够表达含义的不同名称。

闭包

闭包(closure)是能够访问并保留其词法作用域中变量的函数。即使外层函数已经执行结束,返回的内层函数仍然可以使用被捕获的变量:

int Function() createCounter() {
var count = 0;

return () {
count++;
return count;
};
}

void main() {
final next = createCounter();

print(next()); // 1
print(next()); // 2
print(next()); // 3
}

createCounter() 返回后,局部变量 count 仍被返回的函数捕获。每次调用 next() 都会读取并更新同一个 count

每次调用外层函数都会创建独立的闭包状态:

void main() {
final firstCounter = createCounter();
final secondCounter = createCounter();

print(firstCounter()); // 1
print(firstCounter()); // 2
print(secondCounter()); // 1
}

TypeScript 对比:Dart 和 TypeScript 的闭包都能捕获外层词法作用域中的变量。理解 Dart 闭包时可以沿用这一思路,但变量仍受 Dart 静态类型和空安全规则约束。

入口函数 main()

每个 Dart 程序都从顶层 main() 函数开始执行。最简单的入口不接收参数:

void main() {
print('Hello, Dart!');
}

命令行程序需要读取启动参数时,可以声明 List<String> 参数:

void main(List<String> arguments) {
print('Arguments: $arguments');
}

arguments 只包含传给程序的命令行参数,不包含可执行文件名称。

Dart 不按参数签名重载函数

Dart 不支持在同一作用域中仅凭参数数量或参数类型声明多个同名函数:

// 不能同时声明这两个同名函数。
// String format(int value) => '$value';
// String format(double value) => value.toStringAsFixed(2);

通常可以使用不同名称、共同的父类型、命名参数或可选参数来表达不同调用方式:

String formatNumber(num value, {int fractionDigits = 0}) {
return value.toStringAsFixed(fractionDigits);
}

void main() {
print(formatNumber(12)); // 12
print(formatNumber(12.345, fractionDigits: 2)); // 12.35
}

TypeScript 对比:TypeScript 可以为一个实现声明多个重载签名,Dart 没有直接对应的函数重载语法。迁移这类 API 时,通常需要重新设计名称或参数形式。

常见误区

误区一:忘记命名参数默认可选

不可空命名参数如果没有默认值,通常需要添加 required

void openPage({required String path}) {
print(path);
}

required 表示调用方必须传入,不能只依赖不可空类型表达这一要求。

误区二:把函数传递写成函数调用

final values = [' Dart ', ' Functions '];

values.map(normalize); // 传递函数
// values.map(normalize()); // 错误:这里会先尝试调用 normalize

需要回调时传递函数本身;只有确实需要立即执行时才写括号。

误区三:使用宽泛的 Function 类型

void run(Function operation) {
operation();
}

Function 没有准确说明参数和返回值。已知签名时,应写出具体类型:

void run(void Function() operation) {
operation();
}

误区四:在 => 后书写语句

=> 后只能放表达式。需要条件分支、多条语句或局部变量时,改用 {} 函数体。

误区五:用过多位置参数降低可读性

当多个参数类型相同,createUser('Ada', 'admin', 'active') 很难直接看出每个值的含义。使用命名参数可以让调用处表达意图,并减少顺序错误。

如何选择参数形式

可以按照以下思路选择:

  1. 参数数量少、含义明确且每次调用都需要:使用必需位置参数。
  2. 参数较多、布尔值较多或调用处需要表达含义:使用命名参数。
  3. 命名参数每次都必须提供:添加 required
  4. 参数可以省略:根据调用可读性选择可选命名参数或可选位置参数,并提供默认值或使用可空类型。
  5. 函数需要接收另一个函数:使用具体的 返回类型 Function(参数类型) 声明回调契约。

小结

  • Dart 函数可以显式声明参数类型和返回类型;不返回结果时使用 void
  • => 是单表达式函数体的简写,不适合多条语句。
  • 普通参数按位置传递;{} 声明命名参数,[] 声明可选位置参数。
  • 命名参数默认可选,必须传入时使用 required;默认参数值必须是编译时常量。
  • 函数是对象,可以保存、传递和返回;已知签名时应使用具体函数类型,而不是宽泛的 Function
  • 匿名函数常用于回调,已有函数签名匹配时可以直接传递函数撕取。
  • 闭包能够捕获并保留外层词法作用域中的变量。
  • Dart 不支持按参数签名重载函数,通常通过命名参数、可选参数或不同函数名表达不同调用方式。