Wait the light to fall

Dart 可迭代集合

焉知非鱼

Dart可迭代集合。

这个代码实验室教你如何使用实现 Iterable类的集合-例如 ListSet。迭代类是各种 Dart 应用程序的基本构建模块,你可能已经在使用它们,甚至没有注意到。这个代码实验室将帮助你充分利用它们。

使用嵌入式 DartPad 编辑器,你可以通过运行示例代码和完成练习来测试你的知识。

要想从这个 codelab 中获得最大的收获,你应该具备基本的 Dart 语法知识

本课程包括以下内容。

  • 如何读取一个 Iterable 的元素。
  • 如何检查一个 Iterable 的元素是否满足一个条件。
  • 如何过滤一个 Iterable 的内容。
  • 如何将一个 Iterable 的内容映射到不同的值。

估计完成这个代码实验所需的时间: 60分钟。

什么是集合? #

集合是代表一组对象的对象,这些对象称为元素。迭代元素是集合的一种。

集合可以是空的,也可以包含许多元素。根据不同的目的,集合可以有不同的结构和实现。这些是一些最常见的集合类型:

  • List: 用来通过索引读取元素。
  • Set: 用于包含只能出现一次的元素。
  • Map:用于通过键来读取元素。

什么是Iterable? #

Iterable 是一个元素的集合,它可以被依次访问。

在 Dart 中,Iterable 是一个抽象类,这意味着你不能直接实例化它。然而,你可以通过创建一个新的 ListSet 来创建一个新的 Iterable

ListSet 都是 Iterable,所以它们和 Iterable 类有相同的方法和属性。

Map 在内部使用不同的数据结构,这取决于它的实现。例如,HashMap 使用了一个哈希表,其中的元素(也称为值)是通过一个键获得的。通过使用 Mapentriesvalues 属性,Map 的元素也可以作为 Iterable 对象读取。

这个例子显示了一个 intList,它也是一个 intIterable:

Iterable<int> iterable = [1, 2, 3];

List 的区别在于,使用 Iterable,你无法保证按索引读取元素的效率。IterableList 相比,没有 [] 操作符。

例如,考虑以下代码,这是无效的:

Iterable<int> iterable = [1, 2, 3];
int value = iterable[1];

如果你用 [] 读取元素,编译器会告诉你 '[]' 这个运算符没有为 Iterable 类定义,这意味着在这种情况下你不能使用 [index]

你可以用 elementAt() 来读取元素,它可以遍历迭代的元素,直到它到达那个位置。

Iterable<int> iterable = [1, 2, 3];
int value = iterable.elementAt(1);

继续下一节,了解更多关于如何访问 Iterable 的元素。

读取元素 #

你可以使用 for-in 循环,依次读取一个迭代元素。

例子: 使用 for-in 循环 #

下面的例子展示了如何使用 for-in 循环读取元素。

void main() {
  var iterable = ['Salad', 'Popcorn', 'Toast'];
  for (var element in iterable) {
    print(element);
  }
}

在幕后,for-in 循环使用了一个迭代器。然而,你很少看到直接使用迭代器 API,因为 for-in 更容易阅读和理解,而且不容易出错。

关键术语:

  • Iterable: Dart Iterable 类。
  • Iterator: for-in 用来从一个 Iterable 对象中读取元素的对象。
  • for-in 循环: 从一个 Iterable 对象中依次读取元素的简单方法。

例子:使用第一个和最后一个元素 #

在某些情况下,你只想访问一个 Iterable 的第一个或最后一个元素。

Iterable 类中,你不能直接访问元素,所以你不能调用 iterable[0] 来访问第一个元素。相反,你可以使用 first,它可以获取第一个元素。

另外,使用 Iterable 类,你不能使用操作符 [] 来访问最后一个元素,但是你可以使用 last 属性。

因为访问一个 Iterable 的最后一个元素需要踏过所有其他元素,所以 last 可能会很慢。在一个空的 Iterable 上使用 firstlast 会导致一个 StateError

void main() {
  Iterable iterable = ['Salad', 'Popcorn', 'Toast'];
  print('The first element is ${iterable.first}');
  print('The last element is ${iterable.last}');
}

在这个例子中,你看到了如何使用 firstlast 来获得一个 Iterable 的第一个和最后一个元素。也可以找到满足条件的第一个元素。下一节将展示如何使用名为 firstWhere() 的方法来实现这一目标。

例子: 使用 firstWhere() #

你已经看到,你可以依次访问一个 Iterable 的元素,你可以很容易地得到第一个或最后一个元素。

现在,你要学习如何使用 firstWhere() 来寻找满足某些条件的第一个元素。这个方法需要你传递一个谓词,它是一个函数,如果输入满足一定的条件就返回 true。

String element = iterable.firstWhere((element) => element.length > 5);

例如,如果你想找到第一个超过 5 个字符的 String,你必须传递一个当元素大小大于 5 时返回 true 的谓词。

运行下面的例子,看看 firstWhere() 是如何工作的。你认为所有的函数都会给出相同的结果吗?

bool predicate(String element) {
  return element.length > 5;
}

main() {
  var items = ['Salad', 'Popcorn', 'Toast', 'Lasagne'];

  // You can find with a simple expression:
  var element1 = items.firstWhere((element) => element.length > 5);
  print(element1);

  // Or try using a function block:
  var element2 = items.firstWhere((element) {
    return element.length > 5;
  });
  print(element2);

  // Or even pass in a function reference:
  var element3 = items.firstWhere(predicate);
  print(element3);

  // You can also use an `orElse` function in case no value is found!
  var element4 = items.firstWhere(
    (element) => element.length > 10,
    orElse: () => 'None!',
  );
  print(element4);
}

在这个例子中,你可以看到三种不同的方式来写一个谓词。

  • 作为一个表达式: 测试代码中有一行使用了箭头语法(=>)。
  • 作为一个块: 测试代码在括号和返回语句之间有多行。
  • 作为一个函数: 测试代码在一个外部函数中,作为参数传递给 firstWhere() 方法。

没有正确或错误的方式。使用最适合你的方式,并且让你的代码更容易阅读和理解。

在这个例子中,firstWhereWithOrElse() 调用 firstWhere() 时,使用了可选的命名参数 orElse,它在没有找到元素时提供了一个替代方案。在这种情况下,返回文本 “None!",因为没有元素满足提供的条件。

注意:如果没有元素满足测试谓词,并且没有提供 orElse 参数,那么 firstWhere() 会抛出一个 StateError

快速回顾。

  • Iterable 的元素必须按顺序访问。
  • 迭代所有元素的最简单方法是使用 for-in 循环。
  • 你可以使用 firstlast getters 来获取第一个和最后一个元素。
  • 你也可以用 firstWhere() 找到满足条件的第一个元素。
  • 你可以把测试谓词写成表达式、块或函数。

关键术语。

谓词: 当某个条件被满足时,返回 true 的函数。

练习: 练习写一个测试谓词 #

下面的练习是一个失败的单元测试,其中包含一个部分完整的代码片段。你的任务是通过编写代码使测试通过来完成练习。你不需要实现 main()

这个练习介绍了 singleWhere() 这个方法的工作原理类似于 firstWhere(),但在这种情况下,它只期望 Iterable 中的一个元素满足谓词。如果 Iterable 中超过一个或没有元素满足谓词条件,那么该方法会抛出一个 StateError 异常。

singleWhere() 对整个 Iterable 进行步进,直到最后一个元素,如果 Iterable 是无限的或包含一个大的元素集合,这可能会引起问题。

你的目标是实现满足以下条件的 singleWhere() 谓词。

  • 元素包含字符 ‘a’。
  • 该元素以字符 ‘M’ 开头。

测试数据中的所有元素都是字符串,你可以查看类文档以获得帮助。

String singleWhere(Iterable<String> items) {
  return items.singleWhere((element) => element.startsWith('M') && element.contains('a'));
}

检查条件 #

在使用 Iterable 时,有时你需要验证一个集合的所有元素是否满足某些条件。

你可能会想用 for-in 循环来写一个解决方案,比如这个:

for (var item in items) {
  if (item.length < 5) {
    return false;
  }
}
return true;

然而,你可以使用 every() 方法实现同样的目的:

return items.every((element) => element.length >= 5);

使用 every() 方法可以使代码更易读、更紧凑、更不容易出错。

例子: 使用 any() 和 every() #

Iterable 类提供了两个可以用来验证条件的方法。

  • any(): 如果至少有一个元素满足条件,则返回 true。
  • every(): 如果所有元素都满足条件,则返回 true。

运行这个练习来看看它们的作用。

void main() {
  var items = ['Salad', 'Popcorn', 'Toast'];
  
  if (items.any((element) => element.contains('a'))) {
    print('At least one element contains "a"');
  }
  
  if (items.every((element) => element.length >= 5)) {
    print('All elements have length >= 5');
  }
}

在这个例子中,any() 验证了至少一个元素包含字符 a,every() 验证了所有元素的长度等于或大于 5。

运行代码后,尝试更改 any() 的谓词,使其返回 false:

if (items.any((element) => element.contains('Z'))) {
  print('At least one element contains "Z"');
} else {
  print('No element contains "Z"');
}

你也可以使用 any() 来验证一个 Iterable 中没有元素满足某个条件。

练习: 验证一个 Iterable 是否满足一个条件 #

下面的练习提供了使用前面例子中描述的 any()every() 方法的练习。在本例中,你的工作对象是一组用户,由具有成员字段 ageUser 对象表示。

使用 any()every() 实现两个函数。

  • 第1部分:实现 anyUserUnder18()
    • 如果至少有一个用户是17岁或更小,则返回 true。
  • 第2部分:实现 everyUserOver13()
    • 如果所有用户都是14岁或以上,则返回 true。
bool anyUserUnder18(Iterable<User> users) {
  return users.any((user) => user.age < 18);
}

bool everyUserOver13(Iterable<User> users) {
  return users.every((user) => user.age > 13);
}

class User {
  String name;
  int age;

  User(
    this.name,
    this.age,
  );
}

快速回顾:

  • 虽然你可以使用 for-in 循环来检查条件,但还有更好的方法。
  • 方法 any() 可以让你检查任何元素是否满足条件。
  • 方法 every() 可以让你验证所有元素是否满足条件。

过滤 #

前面的章节介绍了 firstWhere()singleWhere() 等方法,这些方法可以帮助你找到满足某个谓词的元素。

但是如果你想找到满足某个条件的所有元素呢?你可以使用 where() 方法来实现。

var evenNumbers = numbers.where((number) => number.isEven);

在这个例子中,numbers 包含一个有多个 int 值的 Iterablewhere() 可以找到所有偶数的数字。

where() 的输出是另一个 Iterable,你可以用它来迭代它或应用其他 Iterable 方法。在下一个例子中,where() 的输出直接在 for-in 循环中使用。

var evenNumbers = numbers.where((number) => number.isEven);
for (var number in evenNumbers) {
  print('$number is even');
}

例子: 使用 where() #

运行这个例子,看看如何将 where() 与其他方法如 any() 一起使用。

main() {
  var evenNumbers = [1, -2, 3, 42].where((number) => number.isEven);

  for (var number in evenNumbers) {
    print('$number is even.');
  }

  if (evenNumbers.any((number) => number.isNegative)) {
    print('evenNumbers contains negative numbers.');
  }

  // If no element satisfies the predicate, the output is empty.
  var largeNumbers = evenNumbers.where((number) => number > 1000);
  if (largeNumbers.isEmpty) {
    print('largeNumbers is empty!');
  }
}

在这个例子中,where() 用于查找所有偶数,然后用 any() 检查结果是否包含负数。

在本例的后面,再次使用 where() 来查找所有大于1000的数字,由于没有,结果是一个空的 Iterable

注意:如果没有元素满足 where() 中的谓词,那么该方法返回一个空的 Iterable。与 singleWhere()firstWhere() 不同,where() 不会抛出 StateError 异常。

例子: 使用 takeWhile #

方法 takeWhile()skipWhile() 也可以帮助你从一个 Iterable 中过滤元素。

运行这个例子,看看 takeWhile()skipWhile() 如何分割一个包含数字的 Iterable

main() {
  var numbers = [1, 3, -2, 0, 4, 5];

  var numbersUntilZero = numbers.takeWhile((number) => number != 0);
  print('Numbers until 0: $numbersUntilZero');

  var numbersAfterZero = numbers.skipWhile((number) => number != 0);
  print('Numbers after 0: $numbersAfterZero');
}

输出如下:

Numbers until 0: (1, 3, -2)
Numbers after 0: (0, 4, 5)

在这个例子中,takeWhile() 返回一个 Iterable,它包含了通往满足谓词的元素的所有元素。另一方面, skipWhile() 返回一个 Iterable,同时跳过满足谓词的元素之前的所有元素。请注意,满足谓词的元素也会被包含在内。

运行该示例后,将 takeWhile() 改为取元素,直到到达第一个负数。

var numbersUntilNegative =
    numbers.takeWhile((number) => !number.isNegative);

注意,条件 number.isNegative 是用 ! 否定的。

练习: 从列表中过滤元素 #

下面的练习提供了使用上一练习中的 User 类的 where() 方法的练习。

使用 where() 实现两个函数。

  • 第1部分:实现 filterUnder21()
    • 返回一个包含所有21岁以上用户的 Iterable
  • 第2部分:实现 findShortNamed()
    • 返回一个包含所有名字长度为 3 或更少的用户的 Iterable
Iterable<User> filterUnder21(Iterable<User> users) {
  return users.where((user) => user.age >= 21);
}

Iterable<User> findShortNamed(Iterable<User> users) {
  return users.where((user) => user.name.length <= 3);
}

class User {
  String name;
  int age;

  User(
    this.name,
    this.age,
  );
}

快速回顾:

  • where() 过滤一个 Iterable 的元素。
  • where() 的输出是另一个 Iterable
  • 使用 takeWhile()skipWhile() 来获取元素,直到满足一个条件或之后。
  • 这些方法的输出可以是一个空的 Iterable

Map #

通过 map() 方法映射 Iterables,你可以在每个元素上应用一个函数,用一个新的元素替换每个元素。

Iterable<int> output = numbers.map((number) => number * 10);

在这个例子中,Iterable 数字的每个元素都被乘以 10。

你也可以使用 map() 将一个元素转换为不同的对象-例如,将所有 int 转换为 String,在下面的例子中可以看到。

Iterable<String> output = numbers.map((number) => number.toString());

注意:map() 返回一个懒惰的 Iterable,这意味着只有在元素被迭代时才会调用所提供的函数。

例子: 使用 map 改变元素 #

运行这个例子,看看如何使用 map() 将一个 Iterable 中的所有元素乘以2,你认为输出会是什么?

main() {
  var numbersByTwo = [1, -2, 3, 42].map((number) => number * 2);
  print('Numbers: $numbersByTwo.');
}

练习: 映射到不同类型 #

在前面的例子中,你把一个 Iterable 的元素乘以2,输入和输出都是 intIterable

在这个练习中,你的代码接收一个 UserIterable,你需要返回一个包含用户名和年龄的字符串的 Iterable

Iterable 中的每个字符串必须遵循这样的格式。'{name} is {age}'-例如 'Alice is 21'

Iterable<String> getNameAndAges(Iterable<User> users) {
  return users.map((user) => '${user.name} is ${user.age}');
}

class User {
  String name;
  int age;

  User(
    this.name,
    this.age,
  );
}

快速回顾:

  • map() 将一个函数应用于一个 Iterable 的所有元素。
  • map() 的输出是另一个 Iterable
  • Iterable 被迭代之前,函数不会被计算。

练习: 把所有的东西放在一起 #

现在是练习所学知识的时候了,在最后一个练习中。

这个练习提供了类 EmailAddress,它有一个构造函数,接收一个字符串。另一个提供的函数是 isValidEmailAddress(),它测试一个电子邮件地址是否有效。

构造函数/函数 类型签名 描述
EmailAddress() EmailAddress(String address) 为指定的地址创建一个 EmailAddress。
isValidEmailAddress() bool isValidEmailAddress(EmailAddress) 如果提供的 EmailAddress 有效,返回 true。

编写以下代码。

第1部分:实现 parseEmailAddresses()

  • 编写函数 parseEmailAddresses(),它接收一个包含电子邮件地址的 Iterable<String>,并返回一个 Iterable<EmailAddress>
  • 使用方法 map()String 映射到 EmailAddress
  • 使用构造函数 EmailAddress(String) 创建 EmailAddress 对象。

第二部分:实现 anyInvalidEmailAddress()

  • 编写函数 anyInvalidEmailAddress(),它接收一个 Iterable<EmailAddress>,并在 Iterable 中的任何 EmailAddress 无效时返回 true。
  • 使用方法 any() 和提供的函 isValidEmailAddress()

第3部分:实现 validEmailAddresses()

  • 编写函数 validEmailAddresses(),它接收一个 Iterable<EmailAddress> 并返回另一个只包含有效地址的 Iterable<EmailAddress>
  • 使用方法 where() 来过滤 Iterable<EmailAddress>
  • 使用提供的函数 isValidEmailAddress() 来评估一个 EmailAddress 是否有效。
Iterable<EmailAddress> parseEmailAddresses(Iterable<String> strings) {
  return strings.map((s) => EmailAddress(s));
}

bool anyInvalidEmailAddress(Iterable<EmailAddress> emails) {
  return emails.any((email) => !isValidEmailAddress(email));
}

Iterable<EmailAddress> validEmailAddresses(Iterable<EmailAddress> emails) {
  return emails.where((email) => isValidEmailAddress(email));
}

class EmailAddress {
  String address;

  EmailAddress(this.address);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
          other is EmailAddress &&
              runtimeType == other.runtimeType &&
              address == other.address;

  @override
  int get hashCode => address.hashCode;

  @override
  String toString() {
    return 'EmailAddress{address: $address}';
  }
}

下一步是什么? #

恭喜你,你完成了 codelab 的学习! 如果你想了解更多,这里有一些下一步的建议。