
前言
准备周末出行时,行李清单通常不是一开始就确定的。衣服要带,预报有雨才加雨伞;已经整理好的洗漱用品清单,也可以直接合并进来,不必重新抄一遍。
程序创建集合时,也常需要这样组织内容:有些值固定放入,有些值满足条件才放入,有些则来自另一组数据。如果先创建空集合,再不断添加,清单本身和决定清单内容的规则就散开了。
Dart 允许把这些规则直接写进集合字面量。看起来是一张列表或一组键值对,里面却可以包含展开、判断和循环。理解它们,关键是看清每一段写法最终向集合中加入了什么。
一、集合里的一个位置,不一定只产生一个值
集合字面量,是直接用方括号或花括号写出集合内容的表达式。 例如,['衣服', '充电器'] 创建 List,{'衣服', '充电器'} 创建 Set,{'衣服': 2} 创建 Map。
List 和 Set 接收单个值,Map 接收键值对。它们也可以在创建时计算这些值:
dart
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 放进一个列表,与放进列表里的内容
把一袋洗漱用品放进行李箱,与把袋里的用品逐件拿出来放进去,是两种组织方式。代码中的区别也很直接:
dart
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,加入的是键值对:
dart
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 展开不会递归复制内部对象
展开创建了一个新集合,但新集合中放入的对象仍可能与原来共享:
dart
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 值。
dart
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,条件不成立时它仍会占据列表的一项。

图中的两条路径在判断后汇合。"不加入"不会生成 null,也不会生成空字符串;结果中确实少了这一项。
if 也可以用在 Set 和 Map 中。Map 的分支需要产生键值对,例如条件成立时加入一项 'mode': 'debug',而不是只放入一个孤立的字符串。
3.2 一个条件需要加入多项时,组合展开
dart
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 元素在每次迭代时执行内部元素,把它产生的内容加入外层集合。 它与普通循环共享循环规则,只是结果直接构成集合。
dart
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 在循环中筛选,再生成结果
dart
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 也经过判断,只是没有向结果加入内容。

图中只对符合条件的值生成提醒文字。如果输入为空,循环一次也不执行,结果就是空列表;如果所有温度都低于 30,也会得到空列表,而不是四个 null。
4.3 Map 条目、模式与多层循环
循环不只会生成列表。拿到一组商品和价格,也可以直接构造 Map:
dart
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,用模式检查值,并在匹配成功后取值:
dart
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 还可以嵌套。例如,把二维列表中的整数收集到一张列表:
dart
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 ...? 检查的是展开来源
普通展开要求来源非空。来源可能不存在时,可以使用空感知展开:
dart
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 条目的键和值。
dart
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。

? 放在值前面,表达"这个值为空就不加入";...? 放在集合前面,表达"这组来源为空就不展开"。两者检查的对象不同,不能互相替代。空感知集合元素
项目需要同时满足 SDK 与语言版本要求。包的默认语言版本通常由 pubspec.yaml 中 environment.sdk 的下限决定;使用 ?value 时,下限应至少为 3.8.0,实际运行的 SDK 也必须支持它。已有文件若显式选择了更低语言版本,还要相应调整。...? 并不是 3.8 才加入的语法;不要因为两者都有问号,就把版本要求混为一谈。
5.3 Map 中跳过的是整条键值对
dart
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,后面的值表达式就不执行:
dart
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 按迭代过程生成内容。没有被选中的分支,不会先执行其中的函数再丢掉结果:
dart
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 替你撤销它。
创建完成以后,条件变化不会自动更新已有集合:
dart
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。
dart
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 字面量:
dart
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 中任一指定的空感知部分为空,会跳过整个条目。集合只在构造时执行这些规则,不会自动随条件变化;展开也不会递归复制内部对象。
八、测试题
- 为什么
['衣服', toiletries]与['衣服', ...toiletries]的结果层级不同?如果 toiletries 中还有一张列表,展开是否会自动把那张列表也拆开? - 先写 ...defaults,再写 ...custom,两份 Map 中同名的 timeout 最终取哪个值?交换展开顺序会怎样?
- if (hasRain) '雨伞' 的条件为 false 时,结果中会留下 null 或空位置吗?同一条件要加入"雨衣"和"雨鞋",应怎样写?
- 对
[18, 31, 27, 35]使用 for 和 if,生成所有大于等于 30 的温度对应的提醒,哪些输入经过判断,哪些输入产生结果?如果没有符合条件的温度呢? ...?values与for (final value in values) ?value分别检查什么?当 values 为[1, null, 3]时各得到什么?后一种写法是否能直接遍历一个为 null 的来源?- 在 Map 中写 'note': ?note,note 为 null 和空字符串时分别怎样?如果写 ?name: readScore(),name 为 null 时,readScore 是否执行?
- if (value case int number) number * 2 会把字符串 '4' 变成 8 吗?为什么?
- 一张列表使用 if (enabled) 创建后,把 enabled 从 false 改成 true,原列表会自动增加元素吗?要得到新的构造结果需要做什么?
- 通过展开复制外层列表后,向原外层列表添加一项,与修改双方共享的内层列表,为什么影响不同?
- 已安装新 SDK,但包的语言版本仍为 3.6,能否直接使用 ?value?const 字面量中能否使用常量展开、常量条件 if 和集合 for?