Dart 之集合元素

0 阅读16分钟

集合元素知识导图:写入单位、展开已有内容、条件选择、循环生成、空值处理,以及构造时的规则

前言

准备周末出行时,行李清单通常不是一开始就确定的。衣服要带,预报有雨才加雨伞;已经整理好的洗漱用品清单,也可以直接合并进来,不必重新抄一遍。

程序创建集合时,也常需要这样组织内容:有些值固定放入,有些值满足条件才放入,有些则来自另一组数据。如果先创建空集合,再不断添加,清单本身和决定清单内容的规则就散开了。

Dart 允许把这些规则直接写进集合字面量。看起来是一张列表或一组键值对,里面却可以包含展开、判断和循环。理解它们,关键是看清每一段写法最终向集合中加入了什么。

一、集合里的一个位置,不一定只产生一个值

集合字面量,是直接用方括号或花括号写出集合内容的表达式。 例如,['衣服', '充电器'] 创建 List,{'衣服', '充电器'} 创建 Set,{'衣服': 2} 创建 Map。

List 和 Set 接收单个值,Map 接收键值对。它们也可以在创建时计算这些值:

void main() {
  final extra = 2;
  final quantities = <int>[1, extra + 1];
  final name = '衣服';
  final luggage = <String, int>{name: extra + 1};

  print(quantities); // [1, 3]
  print(luggage); // {衣服: 3}
}

列表中的 extra + 1 是一个表达式,计算结果 3 被放进列表。Map 中,冒号左侧的 name 计算得到键“衣服”,右侧计算得到值 3,两者组成一个条目。

集合元素,是集合字面量内部用来产生内容的语法单元。 普通表达式产生一个值;一个 if 可能什么也不产生;一个 for 则可能产生很多值。因此,源代码里写了几段,不一定就得到几个集合成员。

它们在创建集合时执行,执行后留下的是集合中的值,不是一段日后自动重跑的规则。List 仍然允许重复,Set 仍然保持元素唯一,Map 仍然要求每个键只对应一个值;集合元素没有改变这些基本规则。

二、展开:把已有内容逐项放进来

2.1 放进一个列表,与放进列表里的内容

把一袋洗漱用品放进行李箱,与把袋里的用品逐件拿出来放进去,是两种组织方式。代码中的区别也很直接:

void main() {
  final toiletries = <String>['牙刷', '毛巾'];

  final nested = <Object>['衣服', toiletries];
  final flat = <String>['衣服', ...toiletries, '充电器'];

  print(nested); // [衣服, [牙刷, 毛巾]]
  print(flat); // [衣服, 牙刷, 毛巾, 充电器]
}

第一种写法把 toiletries 这张列表当作一个值,因此 nested 的第二项仍然是一张列表。这里用 Object 容纳字符串和列表两种对象,并不表示它们变成了同一种数据。

展开元素 ... 会遍历来源,将其中的内容逐项加入正在创建的集合。 flat 在“衣服”后面加入“牙刷”和“毛巾”,再加入“充电器”,不会把 toiletries 本身作为一项放进去。

List 和 Set 中的展开来源需要实现 Iterable,也就是能够按顺序逐项取值的对象,List、Set 都属于这一类。Map 中的展开来源则是 Map,加入的是键值对:

void main() {
  final defaults = <String, int>{'fontSize': 14, 'timeout': 10};
  final custom = <String, int>{'timeout': 30};
  final settings = <String, int>{...defaults, ...custom};
  final tags = <String>{'Dart', ...<String>['Dart', 'Flutter']};

  print(settings); // {fontSize: 14, timeout: 30}
  print(tags); // {Dart, Flutter}
}

settings 先接收默认设置,再接收自定义设置。同一个 timeout 键再次写入时,后面的值 30 替换前面的 10。tags 中重复的“Dart”则按 Set 的规则只保留一份。

Map 不能直接展开进 List。如果需要 Map 的键、值或条目,应明确选择 keys、values 或 entries,它们提供可迭代的内容。反过来,一张保存 MapEntry 对象的 List 也不是 Map,不能直接用 Map 展开语法代替 Map.fromEntries。

2.2 展开不会递归复制内部对象

展开创建了一个新集合,但新集合中放入的对象仍可能与原来共享:

void main() {
  final row = <int>[1];
  final source = <List<int>>[row];
  final result = <List<int>>[...source];

  source.add(<int>[2]);
  row.add(9);

  print(source.length); // 2
  print(result.length); // 1
  print(result); // [[1, 9]]
}

source 后来增加一张列表,不会让 result 跟着增加一项,因为两张外层列表已经分开。但 result 的第一项仍是原来的 row,所以修改 row 后,result 中也能看到 [1, 9]。

这叫浅复制:复制一层集合中的元素引用,不递归复制这些元素内部的数据。展开一张包含列表的列表,也只展开外面一层,不会自动把所有数字压成一张平坦列表。集合展开

三、if:决定某段内容是否加入

3.1 条件控制的是紧随其后的元素

集合 if 元素根据条件选择内部元素;有 else 时,选择其中一支;没有 else 且条件不成立时,什么也不加入。 条件需要得到 bool 值。

void main() {
  final hasRain = true;
  final isCold = false;
  final luggage = <String>[
    '衣服',
    if (hasRain) '雨伞',
    if (isCold) '厚外套' else '薄外套',
    '充电器',
  ];

  print(luggage); // [衣服, 雨伞, 薄外套, 充电器]
}

hasRain 为 true,所以加入“雨伞”。isCold 为 false,所以第二个 if 选择 else 后面的“薄外套”。最后的“充电器”不受这两个条件控制,仍然加入。

这里没有往列表里塞一个 if 对象,也没有为不成立的分支留下空位置。判断只是决定要不要执行对应的加入过程。

这也不同于用条件表达式选择 null:hasRain ? '雨伞' : null 无论走哪一支,都会计算出一个值;如果外层类型允许 null,条件不成立时它仍会占据列表的一项。

集合 if 的选择流程:固定内容先加入,条件成立加入雨伞,不成立直接继续,两条路径最后都加入充电器

图中的两条路径在判断后汇合。“不加入”不会生成 null,也不会生成空字符串;结果中确实少了这一项。

if 也可以用在 Set 和 Map 中。Map 的分支需要产生键值对,例如条件成立时加入一项 'mode': 'debug',而不是只放入一个孤立的字符串。

3.2 一个条件需要加入多项时,组合展开

void main() {
  final isCamping = true;
  final luggage = <String>[
    '水杯',
    if (isCamping) ...['帐篷', '睡袋'],
    '证件',
  ];

  print(luggage); // [水杯, 帐篷, 睡袋, 证件]
}

if 后面跟着的是一个展开元素。条件成立,就展开两件用品;不成立,就完全跳过这段展开。

如果去掉 ...,写成 if (isCamping) ['帐篷', '睡袋'],加入的会是一个列表值,而不是两个字符串;当前声明要求 List<String>,这种写法还会产生类型错误。

不能把这里当作普通 if 语句,随手用花括号包住多行。集合字面量中的花括号还可能表示 Set 或 Map;要加入多项,应明确写出要展开的集合。if 可以接 else if,不过分支很长时,单独的函数或普通语句通常更容易读清楚。

四、for:从一组输入生成集合内容

4.1 每次循环计算要加入的值

集合 for 元素在每次迭代时执行内部元素,把它产生的内容加入外层集合。 它与普通循环共享循环规则,只是结果直接构成集合。

void main() {
  final names = <String>['林', '周', '吴'];
  final labels = <String>[
    for (final name in names) '同学:$name',
  ];
  final seatNumbers = <int>[
    for (var number = 1; number <= 3; number++) number,
  ];

  print(labels); // [同学:林, 同学:周, 同学:吴]
  print(seatNumbers); // [1, 2, 3]
}

第一个 for 依次取得姓名,每取得一次,就计算一个标签并放入 labels。第二个 for 使用初始化、继续条件和更新三个部分,从 1 开始,每轮加一,直到 number 大于 3。

把第一段改成创建空列表,再在普通 for 循环里调用 add,也能实现同样的内容生成。集合写法的价值在于把“结果包含什么”和“怎样生成它”放在一起,并不表示循环没有执行,也不意味着它必然更快。

4.2 在循环中筛选,再生成结果

void main() {
  final temperatures = <int>[18, 31, 27, 35];
  final alerts = <String>[
    for (final temperature in temperatures)
      if (temperature >= 30) '高温:$temperature℃',
  ];

  print(alerts); // [高温:31℃, 高温:35℃]
}

这段代码有两层关系:for 负责逐个取得温度,if 负责决定当前温度是否产生一条提醒。18 和 27 也经过判断,只是没有向结果加入内容。

集合 for 与 if 的流程:依次读取18、31、27、35,逐个判断是否达到30度,仅为31和35生成提醒并按输入顺序加入结果

图中只对符合条件的值生成提醒文字。如果输入为空,循环一次也不执行,结果就是空列表;如果所有温度都低于 30,也会得到空列表,而不是四个 null。

4.3 Map 条目、模式与多层循环

循环不只会生成列表。拿到一组商品和价格,也可以直接构造 Map:

void main() {
  final products = <(String, int)>[('笔', 3), ('本子', 8)];
  final prices = <String, int>{
    for (final (name, price) in products) name: price,
  };

  print(prices); // {笔: 3, 本子: 8}
}

products 的每项是一个包含字符串和整数的记录。(name, price) 是模式,把当前记录的两个值拆到变量里;冒号左边的 name 成为键,右边的 price 成为值。循环中的变量只在相应的作用域里使用,不会因为构造了 Map 就在外面留下 name 变量。

集合 if 也支持 if-case,用模式检查值,并在匹配成功后取值:

void main() {
  final values = <Object?>[3, '4', null, 7];
  final doubled = <int>[
    for (final value in values)
      if (value case int number) number * 2,
  ];

  print(doubled); // [6, 14]
}

int number 表示“匹配一个 int,并把它取名为 number”。只有 3 和 7 匹配成功,才执行乘法。字符串 '4' 不会自动转换成整数,null 也不匹配;这里是在筛选已有的类型,不是在做数据解析。if-case 从 Dart 3.0 起支持。集合中的条件与循环

需要遍历两层输入时,for 还可以嵌套。例如,把二维列表中的整数收集到一张列表:

void main() {
  final rows = <List<int>>[
    [1, 2],
    [3],
  ];
  final flattened = <int>[
    for (final row in rows)
      for (final number in row) number,
  ];

  print(flattened); // [1, 2, 3]
}

外层先取得第一行,内层取完 1、2,再轮到第二行的 3。只写 ...rows 得到的仍是两张内层列表;这里通过第二层循环,明确说明还要再取一层内容。

if、for 和展开可以相互嵌套,但层级增加后,缩进必须能让人看清谁控制谁。如果同一段还包含复杂校验、日志、异常处理或提前退出,把过程移到普通函数中通常更清楚;不必为了缩短代码把所有逻辑挤进字面量。

五、空值处理:跳过一个值,还是跳过整组内容

5.1 ...? 检查的是展开来源

普通展开要求来源非空。来源可能不存在时,可以使用空感知展开:

List<String?> buildNames(List<String?>? extras) {
  return <String?>['林', ...?extras];
}

void main() {
  print(buildNames(null)); // [林]
  print(buildNames(<String?>['周', null])); // [林, 周, null]
}

空感知展开 ...? 在来源为 null 时不加入任何内容;来源非空时,照常展开其中所有元素。 第二次调用中,extras 是一张实际存在的列表,因此其中的 null 也被加入了。

它没有“清洗列表”的能力。如果想排除列表内部的 null,需要另外筛选,或者在逐项生成时使用空感知元素。

5.2 ?value 检查的是当前值

空感知元素在表达式结果非 null 时加入该值,为 null 时跳过。 它从 Dart 3.8 起支持,可用于 List、Set,也可用于 Map 条目的键和值。

List<int> collect(int? first, int? second) {
  return <int>[0, ?first, ?second];
}

void main() {
  print(collect(null, 3)); // [0, 3]

  final values = <int?>[1, null, 3];
  final kept = <int>[for (final value in values) ?value];
  print(kept); // [1, 3]
}

collect 中,?first 跳过空值,?second 加入 3。第二个例子逐个读取 values,再由 ?value 判断当前项是否加入,因而排除了内部的 null。

空感知元素与空感知展开对比:展开包含1、null、3的列表会保留内部null;循环后对每一项使用空感知元素,只保留1和3;来源本身为null时空感知展开不加入内容

? 放在值前面,表达“这个值为空就不加入”;...? 放在集合前面,表达“这组来源为空就不展开”。两者检查的对象不同,不能互相替代。空感知集合元素

项目需要同时满足 SDK 与语言版本要求。包的默认语言版本通常由 pubspec.yaml 中 environment.sdk 的下限决定;使用 ?value 时,下限应至少为 3.8.0,实际运行的 SDK 也必须支持它。已有文件若显式选择了更低语言版本,还要相应调整。...? 并不是 3.8 才加入的语法;不要因为两者都有问号,就把版本要求混为一谈。

5.3 Map 中跳过的是整条键值对

Map<String, Object> buildProfile(String? nickname, String? note) {
  return <String, Object>{
    'id': 7,
    'nickname': ?nickname,
    'note': ?note,
  };
}

void main() {
  print(buildProfile(null, '')); // {id: 7, note: }
}

nickname 为 null,于是整条 nickname 键值对被跳过;不会留下一个没有值的键。note 是空字符串,不是 null,所以它仍然加入,只是输出时冒号后没有可见文字。

0、false 和空字符串都不会被空感知元素过滤,因为它们都有实际的值。直接写 'nickname': nickname,则是把 null 作为值保留下来,还需要 Map 的值类型允许 null。

问号也可以放在键前面:?key: value。键为空就跳过该条目;键和值都需要检查时,可以写 ?key: ?value。键先计算,如果空感知键已经得到 null,后面的值表达式就不执行:

int readScore() {
  print('读取成绩');
  return 90;
}

Map<String, int> buildScore(String? name) {
  return <String, int>{?name: readScore()};
}

void main() {
  print(buildScore(null)); // {}
  print(buildScore('林')); // 先输出“读取成绩”,再输出 {林: 90}
}

第一次调用只有 {},没有“读取成绩”,说明值表达式确实被跳过。若只在值前放问号,键表达式仍然先执行;问号不会让已经执行的计算撤销。

六、写在一起后,还要遵守哪些构造规则

6.1 按写入位置执行,只在创建时执行

集合字面量按源码中的元素顺序求值。if 只执行选中的分支,for 按迭代过程生成内容。没有被选中的分支,不会先执行其中的函数再丢掉结果:

int record(int value) {
  print('计算 $value');
  return value;
}

void main() {
  final enabled = false;
  final values = <int>[
    record(1),
    if (enabled) record(2),
    record(3),
  ];

  print(values);
}

输出依次为“计算 1”、“计算 3”和 [1, 3],没有“计算 2”。这既影响结果,也影响函数里的日志、请求等实际操作;不要把不想执行的操作提前算好,再期待 if 替你撤销它。

创建完成以后,条件变化不会自动更新已有集合:

void main() {
  var includeHelp = false;
  final items = <String>['首页', if (includeHelp) '帮助'];
  includeHelp = true;
  final rebuilt = <String>['首页', if (includeHelp) '帮助'];

  print(items); // [首页]
  print(rebuilt); // [首页, 帮助]
}

items 保存的是第一次构造的结果。重新执行字面量才得到 rebuilt。Flutter 中重新执行 build 时,相关列表可能重新构造,但那是框架重新调用了构建代码,并不是集合 if 自己持续监听条件。

6.2 元素要符合外层集合的类型

if、for 和展开不会绕开类型检查。List<int> 中生成的值必须能作为 int 使用;Map<String, int> 的键和值也分别要符合 String 和 int。

void main() {
  final values = <num>[1, ...<double>[2.5]];
  final emptySet = <String>{};
  final emptyMap = <String, int>{};

  print(values); // [1, 2.5]
  print(emptySet is Set<String>); // true
  print(emptyMap is Map<String, int>); // true
}

num 可以接收 int 和 double,所以展开 double 列表能够成立。若把外层改为 List<int>,2.5 就不满足要求;即使把错误元素藏在永远不成立的 if 分支里,也不能依靠“不执行”免除静态类型检查。

花括号同时用于 Set 和 Map,空字面量尤其容易误认。没有其他类型上下文时,var value = {} 创建的是 Map,不是空 Set。明确写出 <String>{} 或 <String, int>{},能让类型和意图都更清楚。

6.3 final 与 const 各限制什么

final 限制变量不能改指向另一个集合,不会让展开、if、for 生成的普通集合自动不可修改。

const 集合则要求内容能够在编译期确定。常量来源的展开、常量条件的 if,可以用于 const 字面量:

void main() {
  const includeHelp = true;
  const basic = <String>['首页'];
  const items = <String>[
    ...basic,
    if (includeHelp) '帮助',
  ];

  print(items); // [首页, 帮助]
}

这里的来源与条件都是常量,能够提前求出结果。运行时才取得的列表或条件不能直接放进 const 字面量;集合 for 也不允许用于 const 字面量,即使循环范围看起来固定。空感知元素同样需要满足相应的常量表达式规则,并不是加了问号就能使用运行时数据。

常量集合还有更严格的重复检查:const Map 不能靠重复键实现“后写覆盖”,const Set 也不允许重复元素。因此,合并运行时配置的普通 Map 写法,不能不加检查就改成 const。常量集合中的元素要求

不需要编译期常量时,用普通集合配合 final 即可;是否允许后续修改,是另一个需要明确决定的问题。

七、总结

集合元素把“怎样产生内容”写进集合字面量。普通表达式放入一个值,Map 条目放入一对键和值;展开把已有内容逐项加入,if 决定是否加入或选择哪一支,for 则重复生成内容。

它们可以组合,但每一层都应能回答:当前处理哪份输入,产生什么,加入哪一层集合。需要一组内容就展开,需要逐项转换就循环,需要筛选就加条件;嵌套太深时,拆回普通函数比继续压缩更容易理解。

空值处理要区分对象:...? 跳过为空的展开来源,?value 跳过当前的空值;Map 中任一指定的空感知部分为空,会跳过整个条目。集合只在构造时执行这些规则,不会自动随条件变化;展开也不会递归复制内部对象。

八、测试题

  1. 为什么 ['衣服', toiletries] 与 ['衣服', ...toiletries] 的结果层级不同?如果 toiletries 中还有一张列表,展开是否会自动把那张列表也拆开?
  2. 先写 ...defaults,再写 ...custom,两份 Map 中同名的 timeout 最终取哪个值?交换展开顺序会怎样?
  3. if (hasRain) '雨伞' 的条件为 false 时,结果中会留下 null 或空位置吗?同一条件要加入“雨衣”和“雨鞋”,应怎样写?
  4. 对 [18, 31, 27, 35] 使用 for 和 if,生成所有大于等于 30 的温度对应的提醒,哪些输入经过判断,哪些输入产生结果?如果没有符合条件的温度呢?
  5. ...?values 与 for (final value in values) ?value 分别检查什么?当 values 为 [1, null, 3] 时各得到什么?后一种写法是否能直接遍历一个为 null 的来源?
  6. 在 Map 中写 'note': ?note,note 为 null 和空字符串时分别怎样?如果写 ?name: readScore(),name 为 null 时,readScore 是否执行?
  7. if (value case int number) number * 2 会把字符串 '4' 变成 8 吗?为什么?
  8. 一张列表使用 if (enabled) 创建后,把 enabled 从 false 改成 true,原列表会自动增加元素吗?要得到新的构造结果需要做什么?
  9. 通过展开复制外层列表后,向原外层列表添加一项,与修改双方共享的内层列表,为什么影响不同?
  10. 已安装新 SDK,但包的语言版本仍为 3.6,能否直接使用 ?value?const 字面量中能否使用常量展开、常量条件 if 和集合 for?