news 2026/10/5 4:09:22

Dart循环与集合类型详解:List/Set/Map遍历及避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Dart循环与集合类型详解:List/Set/Map遍历及避坑指南

Dart学到第6天,终于把循环和集合类型放到一起讲了。这两个知识点拆开看都很简单:循环无非是 for、while 那几套写法,集合无非是 List、Set、Map 三种容器。但真正写起代码来你会发现,它们几乎总是成对出现——集合装数据,循环处理数据。没有循环,集合就是一堆躺在内存里没人碰的东西;没有集合,循环也找不到用武之地。

这篇文章适合正在学 Dart 的初学者,也适合从 Java、JavaScript 转过来想快速上手 Dart 的人。我会把三种集合类型各自的特点、四种循环写法的适用场景、以及我在实际练习中踩过的坑一起记录下来,代码可以直接照着敲。

1. 为什么循环和集合类型要放在一起学

1.1 循环解决的从来不是"会不会写",而是"怎么批量处理"

我见过不少人学循环时只是跟着教程打印 1 到 10、打印九九乘法表,然后觉得自己会了。但真正到项目里,循环处理的很少是数字,而是数据集合——数组里的用户记录、列表里的订单、Map 里的配置项。今天要处理的真实问题形如:有一万个用户,我要找出所有积分超过 500 的;有一批订单,我要统计每个月的销售额。这些事靠手工一条条写是不现实的,靠循环遍历集合才是常规解法。

所以说,循环本身不是目的,批量处理和自动化才是。把循环和集合类型放在同一天学,是因为它们天然是一体的:循环负责反复执行某段逻辑,集合负责存放被反复处理的数据。理解了这层关系,后面看 Flutter 里大量的列表渲染、数据过滤、状态管理等场景会顺畅很多。

1.2 三种集合类型的定位差异

Dart 里最常见的集合类型就是 List、Set、Map,它们都是collection体系里的核心成员,但在行为上有明显差别:

集合类型是否有序是否允许重复底层模型典型场景
List有序允许重复索引数组 / 链表排行榜、消息列表、任何按位置存取的数据
Set无序不允许重复哈希表去重、成员判断、标签集合
Map键值有序(按插入序)key 不允许重复,value 可以哈希表配置文件、字典映射、SQL 查询结果映射

这个表格是我自己整理的,建议你先记住"List 有序可重复""Set 无序去重""Map 键值映射"这三句话。后面循环遍历时,它们的差异会直接影响你怎么写代码——比如遍历 Map 时你拿到的是键值对,遍历 Set 时没有索引概念,这些细节我会在第三节展开。

2. 动手前先吃透三种集合类型

2.1 List:最常用的"有序列表"

List 是 Dart 里最直观的集合类型,创建方式很灵活:

// 字面量创建 List<int> scores = [88, 95, 76, 92, 87, 85]; // 生成式创建:长度为 10,每个元素是索引的平方 List<int> squares = List.generate(10, (index) => index * index); // 填充式创建:长度为 5,每个元素都是 0 List<int> zeros = List.filled(5, 0); // 从另一个集合转换 List<double> fromSet = Set<double>.of([1.5, 2.5, 1.5]).toList();

访问 List 元素靠索引,索引从 0 开始:scores[0]是第一个元素。需要留意的是,Dart 的 List 在运行时如果访问了越界索引(比如长度为 6 的列表访问scores[10]),会直接抛出RangeError,不会像某些语言那样返回 undefined 或者 null。这个特性其实挺友好,能帮你尽早发现索引计算的错误。

List 常用的方法还有add、removeAt、insert、indexOf、sublist,不过我今天主要关注它与循环的结合。有一个经验供参考:如果事先知道列表规模,用List.generate或者先算好长度再填充,比反复add更能减少扩容开销;写练习时无所谓,但到了 Flutter 列表需要频繁重建的场景,这个习惯能帮你少踩性能坑。

2.2 Set:去重这件事交给它

Set 和 List 最大的区别有两个:元素唯一、不保证顺序。举个例子,假设你收集到一串用户 ID,里面有很多重复的,用 List 你得自己写循环去重,用 Set 则直接转换就行:

List<int> rawIds = [101, 102, 103, 101, 104, 102, 105]; Set<int> uniqueIds = rawIds.toSet(); print(uniqueIds); // {101, 102, 103, 104, 105}

Set 也支持集合运算,这在处理交集、并集、差集时特别方便:

Set<String> 前端组 = {'张三', '李四', '王五'}; Set<String> 后端组 = {'李四', '赵六', '孙七'}; // 交集:同时出现在两个组里的人 Set<String> 全栈 = 前端组.intersection(后端组); print(全栈); // {李四} // 并集 Set<String> 全体 = 前端组.union(后端组); // 差集:只在 前端组 而不在 后端组 Set<String> 纯前端 = 前端组.difference(后端组);

这里有个细节值得说清楚:Set 判断元素是否重复,依赖的是对象的==和hashCode。对基本类型(int、String)来说,值相同就算同一个元素;但对自定义对象,默认比较的是引用地址,如果你希望两个内容相同的对象去重,需要重写==和hashCode。

2.3 Map:键值对应的"配置字典"

Map 存储的是键值对,创建方式同样很灵活:

// 字面量创建 Map<String, int> stockMap = { '苹果': 12, '香蕉': 8, '橘子': 5, }; // 动态创建 Map<String, double> priceMap = {}; priceMap['鼠标'] = 199.0; priceMap['键盘'] = 399.0; // 从可迭代对象构造 Map<int, String> codeMap = Map.fromEntries( [1, 2, 3].map((e) => MapEntry(e, '编号$e')), );

从集合里读取值用键:stockMap['苹果']。如果键不存在,返回 null——注意是 null 而不是 0,所以做数值累加时先判断或者用putIfAbsent来补默认值。

今天重点不是讲 Map 的所有 API,而是强调一个概念:Map 的遍历对象有三个维度——keys遍历键、values遍历值、entries遍历键值对。写循环时,想清楚你到底需要哪个维度,就不会写出"遍历键然后又用键去查值"这种绕弯子的代码了。

3. 四种循环写法的选用指南

3.1 传统 for 循环:索引在手,灵活性最大

传统 for 循环是绝大多数人最先接触的写法,格式是for (初始化; 条件; 步进):

List<String> fruits = ['苹果', '香蕉', '橘子', '西瓜']; for (int i = 0; i < fruits.length; i++) { print('第 $i 个水果是 ${fruits[i]}'); }

这个写法最大的优势是:你能拿到索引i,既能访问当前元素,也能根据索引进行前后的定位。比如在循环中需要同时访问当前元素和下一个元素时,用索引就非常自然:

for (int i = 0; i < list.length - 1; i++) { if (list[i] > list[i + 1]) { print('相邻元素前大后小:${list[i]} > ${list[i + 1]}'); } }

但这种写法也有明显的缺陷:你需要手动控制边界。只要条件写错一个符号,要么少遍历一个元素,要么越界。我建议把传统 for 循环用在"确实需要索引或需要做位置计算"的场景,而不是所有遍历都无脑用这个。

3.2 for-in 循环:最推荐的首选迭代方式

如果只是按顺序访问集合里的每一个元素,不考虑索引,我会优先推荐 for-in:

List<String> fruits = ['苹果', '香蕉', '橘子', '西瓜']; for (String fruit in fruits) { print('当前水果是 $fruit'); }

这段代码读起来几乎和人话一样:"对于 fruits 里的每一个 fruit,打印它。" 不需要管索引从哪里开始到哪里结束,从集合里拿出多少个元素就遍历多少个,天然规避了越界风险。

for-in 同样适用于 Set 和 Map 的键集合:

Set<int> ids = {1, 2, 3}; for (int id in ids) { print('ID: $id'); } Map<String, int> ages = {'张三': 25, '李四': 30}; for (String name in ages.keys) { print('$name 的年龄是 ${ages[name]}'); }

这里有个微小但重要的坑:Dart 的 Map 遍历keys得到的是一个Iterable,它按插入顺序返回键。你可以在遍历过程中读取ages[name],此时读取操作本身不会报错,但如果你在遍历的同时向ages里添加或移除键,会抛出ConcurrentModificationError,这点在第五节我会详细展开。

3.3 while 和 do-while:面向"条件成立"而不是"元素个数"

for 循环适合你明确知道要遍历多少次的情况;while 循环则适合你不知道具体次数、只知道什么时候该停的场景。

int count = 0; while (count < 5) { print('第 $count 次'); count++; }

在实际项目里,while 循环常见用法是配合"读取状态"或"等待某个条件"来使用,比如消费队列消息直到队列为空:

Queue<String> messageQueue = Queue.from(['msg1', 'msg2', 'msg3']); while (messageQueue.isNotEmpty) { String msg = messageQueue.removeFirst(); print('处理消息:$msg'); }

do-while 与 while 的唯一区别是先执行一次循环体再判断条件,因此无论条件是否成立,循环体至少会执行一次。这个特性在某些必须"先做一步再判断"的场景里很实用,但也有不少人因为没注意到这个差异,写出了多执行一次的 Bug。我的习惯是:只有明确需要"先执行后判断"时才用 do-while,否则一律用 while。

3.4 break、continue、标签:控制循环的"刹车和油门"

循环写多了,一定会遇到需要提前终止或跳过某次的情况。

  • break:结束整个循环
  • continue:跳过本次循环,进入下一个元素
  • 标签(label):在嵌套循环中指定要跳出哪一层
// 找到第一个大于 90 的成绩就停止 List<int> scores = [88, 95, 76, 92, 87, 85]; for (int score in scores) { if (score > 90) { print('第一个大于 90 的成绩是 $score'); break; } } // 跳过所有低于 60 的成绩,只打印及格的 for (int score in scores) { if (score < 60) continue; print('及格成绩:$score'); }

标签在嵌套循环里比较少见但很有用。比如你有一个二维列表,想找到第一个负数:

List<List<int>> matrix = [ [1, 2, 3], [4, -5, 6], [7, 8, 9], ]; outerLoop: for (int row = 0; row < matrix.length; row++) { for (int col = 0; col < matrix[row].length; col++) { if (matrix[row][col] < 0) { print('找到负数:第 $row 行第 $col 列'); break outerLoop; // 跳出两层循环 } } }

不写标签的话,内层 break 只能跳出内层 for,外层循环还会继续执行,很多初学者在这里会困惑为什么没达到预期效果。

4. 集合的高级玩法:函数式遍历方法

4.1 forEach、map、where、reduce:让代码更短也更安全

Dart 的集合类型自带了很多函数式方法,它们本质上也是循环,只不过把"遍历"和"如何处理元素"分离了。先看一个对比:

// 传统循环:找出所有大于 90 的成绩 List<int> scores = [88, 95, 76, 92, 87, 85]; List<int> highScores = []; for (int score in scores) { if (score > 90) { highScores.add(score); } } // 函数式写法 List<int> highScores2 = scores.where((s) => s > 90).toList();

同样的逻辑,函数式写法更紧凑,更重要的是它强调了"要做什么"而不是"怎么做"。我把几个常用的方法整理一下:

方法作用返回类型注意点
forEach对每个元素执行一次操作void循环里不能用 break / continue
map把每个元素转换成另一种形态Iterable(惰性)如需 List 要加.toList()
where筛选满足条件的元素Iterable(惰性)相当于 filter
any是否存在满足条件的元素bool短路,找到即停
every是否全部满足条件bool短路,遇到不满足即停
reduce把元素两两合并成一个值与元素同类型空集合调用会报错
fold类似 reduce 但可以指定初始值任意类型推荐优先用 fold

使用map时有个容易踩坑的地方:map返回的是Iterable,是惰性求值的,如果你不加.toList(),它不会立即执行。这在大型集合上可能造成"以为已经转换完了,实际上还没执行"的错觉。实际编码中,凡是要把结果存起来或者传给需要 List 的接口,记得加.toList()。

4.2 案例:构建一个成绩统计分析流水线

把几种方法串起来做一个完整的小案例。假设我们有一组学生成绩,需要完成三个需求:筛选出及格成绩、把成绩换算成等级、统计平均分。

void main() { List<int> scores = [88, 59, 76, 92, 47, 85, 61, 70]; // 1. 筛选出及格成绩(>= 60) List<int> passed = scores.where((s) => s >= 60).toList(); // 2. 把成绩映射成等级 List<String> grades = passed.map((s) { if (s >= 90) return 'A'; if (s >= 80) return 'B'; if (s >= 70) return 'C'; return 'D'; }).toList(); // 3. 计算及格平均分 double average = passed.fold(0.0, (sum, s) => sum + s) / passed.length; print('及格人数:${passed.length}'); print('等级分布:$grades'); print('及格平均分:${average.toStringAsFixed(1)}'); }

这个例子虽然简单,但完全复刻了真实项目里的数据处理链条:筛选 -> 映射 -> 聚合。在实际业务里,你可能把scores换成从接口拿到的用户列表,把where换成复杂条件过滤,把map换成 DTO 转换,把fold换成汇总统计。循环的基本功没变,变的是处理的复杂度和语法粒度。

5. 实操中的常见问题与避坑记录

5.1 在循环中删除元素:一个几乎人人都踩过的坑

我在练习时写过这么一段代码,本意是删除列表中所有小于 60 的成绩:

List<int> scores = [88, 59, 76, 47, 85, 61]; for (int i = 0; i < scores.length; i++) { if (scores[i] < 60) { scores.removeAt(i); // 危险! } } print(scores);

这段代码的结果可能完全出乎你的意料。原因很简单:每次removeAt(i)后,列表长度减少 1,后面的元素会往前移,而循环的i依然按原来的节奏递增,就会跳过被前移的元素。而且scores.length每次循环都会重新取值,边界条件也在变化,最终结果完全不可控。

正确的做法有三种:

方式一:倒序遍历。从后往前删,前面的元素移动不会影响后续未访问的索引。

for (int i = scores.length - 1; i >= 0; i--) { if (scores[i] < 60) { scores.removeAt(i); } }

方式二:用removeWhere,一句话搞定,这也是我最推荐的方式。

scores.removeWhere((s) => s < 60);

方式三:筛出要保留的元素,重新赋值。

scores = scores.where((s) => s >= 60).toList();

如果你是刚学,可能觉得方式一大费周章,不如方式二方便。但在面试或底层实现里,理解"为什么倒序删除可行"是很有价值的——它背后是数组元素移位、索引失效这些通用原理,换到 Java 的ArrayList、JavaScript 的splice里是同样的逻辑。

5.2 遍历 Map 时动态修改键值:并发修改异常

Map 的 for-in 或 forEach 遍历过程中,如果你直接添加或删除键,Dart 会抛出ConcurrentModificationError。常见场景是:遍历配置项时想过滤掉某些键。

Map<String, int> stockMap = {'苹果': 12, '香蕉': 0, '橘子': 5}; // 这样写会报错 ConcurrentModificationError // stockMap.forEach((key, value) { // if (value == 0) { // stockMap.remove(key); // } // });

正确的做法是先把要删除的键收集起来,遍历结束后统一删除:

List<String> keysToRemove = []; stockMap.forEach((key, value) { if (value == 0) { keysToRemove.add(key); } }); for (String key in keysToRemove) { stockMap.remove(key); }

或者直接用removeWhere:

stockMap.removeWhere((key, value) => value == 0);

这个经验提示我们:凡是能在集合方法层面解决的问题,尽量不要手动写遍历加增删。集合类自带的方法往往已经处理好了并发修改、索引移动等隐藏细节。

5.3 循环性能与编码习惯的三个建议

第一,不要在循环条件里写耗时操作。例如for (int i = 0; i < list.length; i++)中的list.length每次循环都会调用一次。对 List 来说它只是读取一个字段,开销可以忽略;但如果条件是一个会执行复杂计算的方法,就要小心了。更稳妥的做法是先用变量缓存结果:

int targetCount = someExpensiveFunction(); for (int i = 0; i < targetCount; i++) { // 业务逻辑 }

第二,循环体越短越好,把复杂逻辑提取成方法。很多人写循环时什么都往里塞,循环体动辄几十行,出了 Bug 很难定位。我的做法是,循环体内尽量只做"取数据、判断、调方法"这几件事,具体逻辑放进单独函数里,方便单测也方便排查。

第三,选择合适的集合类型能省掉很多不必要的循环。如果你需要频繁判断"某个元素是否在集合中",用 Set 的contains通常比 List 的contains快得多,因为 Set 走哈希查找,时间复杂度平均是 O(1),而 List 最坏情况下要遍历整个列表。现实中我见过有人用一个几百项的 List 反复做 contains,结果页面卡顿,换成 Set 后立竿见影。

5.4 一个小技巧:用循环和 Map 做计数器

最后分享一个我经常用的套路。假设有一串颜色列表,想统计每种颜色出现了几次,用 Map 加循环几行就能搞定:

List<String> colors = ['red', 'blue', 'red', 'green', 'blue', 'red']; Map<String, int> countMap = {}; for (String color in colors) { countMap[color] = (countMap[color] ?? 0) + 1; } print(countMap); // {red: 3, blue: 2, green: 1}

这段代码里的关键是??运算符:当 Map 里没有这个键时返回 0,有则返回原值,然后加 1。这个模式在处理日志分类、投票统计、词频统计时特别顺手,推荐你把它记下来,写 Flutter 后台接口数据分组时也会用到。

如果你已经能熟练掌握前文的 for-in 和 forEach,再配合 Map 做这种聚合统计,基本上就具备了处理大部分日常数据逻辑的能力了。


我自己在敲这一天的练习时最大的体会是:循环语法再多,真正重要的其实是"数据结构选对、遍历边界想清楚、增删操作谨慎处理"这三件事。Dart 的集合类型设计得比较直观,和 Java 很像,但又有 JS 的灵活性,只要把 List、Set、Map 各自的特性和循环的几种写法对应上,后面学 Flutter 列表组件、状态管理时就不会发怵。这篇文章里的示例代码建议你原封不动敲一遍,然后自己改写几个小场景,比如把统计成绩换成统计一周的天气情况,把列表去重换成关键词过滤,这样记忆会扎实很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 4:08:04

两电平VSC的αβ变换电流反馈与实时无功-有功控制仿真详解

做VSC变流器仿真的工程师基本都遇到过类似场景&#xff1a;想复现论文里的“实时无功-有功控制器”&#xff0c;结果自己搭的模型要么功率纹波大到没法看&#xff0c;要么电流波形畸变得离谱。尤其是那种采用αβ变换做电流反馈的拓扑&#xff0c;很多教程只是给了个Simulink截…

作者头像 李华
网站建设 2026/10/5 4:07:58

Gromacs性能优化:WSL2、虚拟机与双系统Linux环境配置全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 4:07:48

OpenShell 完全指南:Windows 11 开始菜单高效定制与排坑实战

最近不少朋友问我&#xff0c;Windows 11 用下来最大的槽点是什么&#xff1f;我的答案永远是开始菜单。不是因为它不能用&#xff0c;而是它把以前两三步能完成的操作硬生生变成了四步五步。折腾了一圈第三方工具之后&#xff0c;我现在的主力方案是 OpenShell——也就是老玩家…

作者头像 李华
网站建设 2026/10/5 4:07:02

YOLOv11改进模型实战:野生动物监测中的小目标与光照优化

简介&#xff1a;该资源是一份围绕YOLOv11改进模型在野生动物种群监测中应用的完整技术文档&#xff0c;共33页PDF&#xff0c;适合环保监测领域的研究人员、算法工程师及计算机视觉学习者阅读。内容从环保监测与种群监测背景切入&#xff0c;系统介绍YOLOv11的架构设计与工作原…

作者头像 李华
网站建设 2026/10/5 4:07:00

F钉自动排钉机机械设计全解析:从振动盘定向到胶带贴合的系统方案

干机械设计这些年&#xff0c;最磨人的不是画图&#xff0c;而是设备装出来之后能不能在车间里一天24小时稳定跑。F钉自动排钉机这个项目&#xff0c;我前后做了近五个月&#xff0c;从方案评审到样机稳定运行&#xff0c;踩了不少坑&#xff0c;也把整套机械系统的设计逻辑摸透…

作者头像 李华
网站建设 2026/10/5 4:06:42

莉莉丝Golang后端中台面试复盘:从GMP到分布式设计

针对莉莉丝游戏Golang后端中台部门的面试复盘&#xff0c;我整理了一份很完整的经验总结。从岗位拆解、一面二面高频考点、中台场景设计题到踩坑记录&#xff0c;基本覆盖了准备这类面试需要的核心内容。如果你是准备Golang后端跳槽、或者想了解游戏公司中台技术栈&#xff0c;…

作者头像 李华