引言
这是第六章「列表、元组与字典」的第 6 篇,也是把前五篇语法收拢成「组合技能」的一篇。列表、元组、字典、集合各自单独用都很直白,但真实工程里数据几乎从来不是单层的:一张成绩表是「行 × 列」的二维结构,一份订单是「订单 → 商品列表」的嵌套记录,一套服务配置是层层嵌套的字典树。当容器里装容器,才真正开始管理「现实数据」。
本篇解决三个问题:怎么构造多维结构(矩阵、记录集合、配置树)、怎么遍历它们(按层循环、展平、递归),以及怎么安全地访问深层的值(防止 KeyError 半路炸掉程序)。阅读前提:第六章前三篇的容器语法、第三章第 7 篇的推导式(本篇直接复用,不再展开其原理)、第 4 篇的集合去重、第 5 篇的引用与拷贝(本篇陷阱一正是它的直接应用)。另外提前说明:成绩表里的排序、分组等更重的操作留给第 9 篇实战,本篇只做遍历与基础聚合。
概念与原理
嵌套的实质:槽位里装的是另一个容器的引用
所谓嵌套容器(nested collections),就是「容器元素本身还是容器」。第 5 篇讲过:容器的每个槽位存的是引用。所以 matrix = [[1, 2], [3, 4]] 的真实结构是:外层列表的两个槽位,分别指向两个内层列表;内层列表的槽位才指向整数。访问 matrix[0][1] 就是「先取第 0 个槽位的引用(得到内层列表),再取它的第 1 个槽位」——下标从左到右逐层深入,每一层下标的含义由你自己定义。
理解了「槽位是引用」,两条推论自动成立:其一,第 5 篇的拷贝规则直接适用于嵌套结构(浅拷贝只独立外层,深层共享);其二,外层容器的长度可以任意增长,内层容器也可以是「不等长」的——Python 的嵌套列表不要求每行等长,矩阵只是「约定每行等长」的一种用法,不规则嵌套(比如每行长度不同)同样合法。
三种主流的多维组织方式
工程里最常见的三种嵌套模式,恰好对应三种「现实数据」:
- 嵌套列表 = 二维表/矩阵:行是外层元素,列是内层元素。适合「形状规整、每个格子都是同质数据」的场景,比如像素矩阵、棋盘、距离表。访问靠两个下标,
m[i][j]即第 i 行第 j 列。 - 字典列表 = 记录集合:每条记录是一个字典(字段名 → 值),多条记录组成列表。对应数据库表的一行行记录、API 返回的 JSON 数组。访问靠「先定位记录、再取字段」,字段名让数据自带语义,比裸下标可读得多。
- 嵌套字典 = 配置树:键对应的值又是字典,层层嵌套直到叶子值。对应配置文件、级联选项、文档树。访问路径就是键的链条,
config["database"]["host"]读作「数据库段的主机名」。
这三种模式可以互相嵌套组合(字典列表里再挂字典、配置树的叶子值是列表),JSON 数据格式(JavaScript Object Notation,对象表示法)几乎原样映射为「字典 + 列表」的 Python 组合——这也是为什么处理后端接口数据时,嵌套容器是躲不开的主战场。
遍历的思维模型:层数对应循环层数
遍历多维结构的通用思路:循环的层数 = 结构的层数。一维数据一层 for,二维(列表的列表)两层 for,三维三层 for,以此类推。每一层循环拿到的变量是「某一层的切片」:外层 for 拿到「一行」,内层 for 拿到「行里的一个元素」。想「按列看」就得调整内层取 row[i](列下标固定、行下标变化),这是矩阵题的标准手法。当结构变深、循环变多时,可读性会急剧下降,于是有了「展平(flatten)」操作——把多维结构摊成一维序列,用单层循环处理,代价是丢失结构信息。选保持层次还是展平,取决于你要的结构信息还有没有用:算总分只需要元素本身(展平),画表格必须保留行列(保持层次)。
操作与实现
矩阵:访问与长度
先造一个 3×3 矩阵,练习逐层下标:
matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
print(matrix[0][1]) # 第 0 行第 1 列
print(matrix[2][2]) # 第 2 行第 2 列
print([row[0] for row in matrix]) # 第一列的所有元素
print(len(matrix), len(matrix[0]))
输出:
2
9
[1, 4, 7]
3 3
matrix[0][1] 得到 2,matrix[2][2] 得到 9——两个下标合起来唯一确定一个格子。第三行用第三章的推导式「取每一行的第 0 个元素」拼出第一列 [1, 4, 7]:推导式在这里的正确读法是「对 matrix 的每一行 row,产出 row[0]」,列提取不过是「固定下标、遍历行」的自然结果。len(matrix) 是行数,len(matrix[0]) 是第 0 行的长度——列数由任意一行的长度给出,越界访问(超出某行的长度)会抛 IndexError。
逐行遍历与写回
矩阵最常见的消费方式是逐行处理。以「姓名 + 两科成绩」的成绩表为例(每行是一条混合类型的记录):
grades = [
["林晚", 88, 92],
["陈默", 75, 84],
["苏晨", 93, 89],
]
for row in grades:
print(row[0], sum(row[1:]))
grades[0][1] = 90 # 写回:把第 0 行的成绩改掉
print(grades[0])
输出:
林晚 180
陈默 159
苏晨 182
['林晚', 90, 92]
两层结构用一层 for 就够——因为每行本身是「要整体处理」的单元,sum(row[1:]) 把行内的两科成绩一次算完。写回同样要「定位到行、再定位到列」:grades[0][1] = 90。注意这里 row 是「指向那一行的引用」,在循环里直接改 row[0] 也等于改矩阵——这又是第 5 篇引用语义的现场。
展平:两层循环 vs 嵌套推导式
当「行列信息不重要、只要所有元素」,就展平:
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [v for row in matrix for v in row] # 第三章推导式:一行展平
print(flat)
print(sum(flat))
flat_loop = []
for row in matrix: # 等价的两层 for 循环
for v in row:
flat_loop.append(v)
print(flat_loop == flat)
输出:
[1, 2, 3, 4, 5, 6, 7, 8, 9]
45
True
[v for row in matrix for v in row] 的顺序与两层 for 完全一致:先遍历行,再遍历行里的元素(第三章第 7 篇已详解,这里直接复用)。sum(flat) 一步算出所有元素之和 45——展平后可以直接交给 sum、max 这类单序列聚合函数。记住推导式也包不住「要 break/continue/多步处理」的逻辑,需要那种自由时退回两层 for 循环。
字典列表:记录集合的基本操作
把成绩表换成「每条记录一个字典」,字段名让代码直接可读:
students = [
{"name": "林晚", "math": 88, "english": 92},
{"name": "陈默", "math": 75, "english": 84},
{"name": "苏晨", "math": 93, "english": 89},
]
for s in students:
print(s["name"], s["math"] + s["english"])
for s in students:
s["total"] = s["math"] + s["english"] # 写回新字段
print(students[0])
输出:
林晚 180
陈默 159
苏晨 182
{'name': '林晚', 'math': 88, 'english': 92, 'total': 180}
遍历时 s 是「指向记录字典的引用」,所以 s["total"] = ... 直接给原记录增加新字段——第二次循环结束后,每条记录都多了 total 字段。这种「遍历时顺手补字段」是处理接口数据的高频操作(比如把价格从字符串转成数字、把时间戳解析成日期),做一次循环记录集合就整备一新。
聚合:平均分、最高分与筛选
对记录集合做统计,套路是「取字段 → 交给聚合函数或推导式过滤」:
students = [
{"name": "林晚", "math": 88, "english": 92},
{"name": "陈默", "math": 75, "english": 84},
{"name": "苏晨", "math": 93, "english": 89},
]
for s in students:
s["total"] = s["math"] + s["english"]
avg = sum(s["total"] for s in students) / len(students)
print(f"平均总分: {avg:.1f}")
top = max(students, key=lambda s: s["total"])
print(top["name"], top["total"])
worst_subject = "math"
fails = [s["name"] for s in students if s[worst_subject] < 80]
print(fails)
输出:
平均总分: 173.7
苏晨 182
['陈默']
三个统计各用各的「取数姿势」:sum(... for s in students) 是生成器表达式边取边加(第三章推导式篇讲过它的惰性);max(students, key=...) 用 key 参数指定比较依据(匿名函数 lambda 在第四章讲过),拿到的是「总分最高的整条记录」,再从中取字段;筛选用带 if 的推导式,把不达标的姓名收进列表。这里刻意没有碰排序——按总分排名是第 9 篇实战的主角。
配置树:递归遍历嵌套字典
配置树的深度不固定(有的段两层、有的段三层),「把整棵树摊开打印」要写成递归:对每个键值对,值还是字典就往下钻,否则打印。函数在第四章已经正式讲过,这里的递归是它的第一次实战:
config = {
"database": {
"host": "localhost",
"port": 5432,
"options": {"connect_timeout": 5},
},
"cache": {
"enabled": True,
"server": {"host": "redis-01", "port": 6379},
},
}
def collect_keys(node, path=""):
for key, value in node.items():
full = f"{path}.{key}" if path else key
if isinstance(value, dict):
collect_keys(value, full)
else:
print(f"{full} = {value}")
collect_keys(config)
输出:
database.host = localhost
database.port = 5432
database.options.connect_timeout = 5
cache.enabled = True
cache.server.host = redis-01
cache.server.port = 6379
path 参数累积「从根到当前节点的键路径」,isinstance(value, dict) 判断是否还有下一层,没有就用 键 = 值 打印叶子。递归在这里的威力:树多深都能一行不改地铺平——换成手工嵌套循环,四层深就得写四层 for。理解这段「递归展开树」的模式,后续遍历文件目录、解析嵌套 JSON 都会复用。
深层访问的安全姿势:get 链与 walrus
直接 config["database"]["host"] 一旦中间某层键不存在(比如那台机器根本没配 database 段),立刻 KeyError 中断。生产代码更常用的写法是逐层默认值:每一层 .get(key, {}),取不到就当成空字典继续往下,最后一层 .get(key, 默认值) 给出兜底:
config = {
"database": {"host": "localhost", "port": 5432},
"cache": {"server": {"host": "redis-01"}},
}
print(config.get("database", {}).get("host"))
print(config.get("database", {}).get("port"))
print(config.get("cache", {}).get("server", {}).get("port", 6379))
print(config.get("missing", {}).get("anything", "fallback"))
if host := config.get("database", {}).get("host"):
print("连接目标:", host)
else:
print("缺少 host 配置")
输出:
localhost
5432
6379
fallback
连接目标: localhost
关键在第三行:cache.server 里没有 port,整条链在任何中间层都不炸,最后落到默认值 6379。第四行连根键都不存在,一路空字典到底,得到 fallback。最后一段用了海象运算符(walrus operator):=(Python 3.8+,第三章推导式篇提过条件表达式,这里是赋值表达式第一次实战):一边取 host 一边判断有没有值,取到就打印连接目标,取不到走 else 分支——既避免了「先取再判、取两次」的重复,也避免了「判断完又要重新取值」的二次访问。注意这个写法只在 Python 3.8+ 可用,老项目要改成先取再判的两行式。
易错点与陷阱
陷阱一:[[0] * 3] * 3 造出「共享行」矩阵
这是嵌套列表界的头号经典坑。[0] * 3 生成一行,[...] * 3 再复制三份——但复制的是引用,三行指向同一个列表:
bad = [[0] * 3] * 3
print(bad)
bad[0][0] = 1
print(bad) # 三行是同一个列表!
good = [[0] * 3 for _ in range(3)]
good[0][0] = 1
print(good) # 三行彼此独立
row = [0] * 3
bad2 = [row, row, row]
print(bad2[0] is bad2[1]) # True:同一个对象
输出:
[[0, 0, 0], [0, 0, 0], [0, 0, 0]]
[[1, 0, 0], [1, 0, 0], [1, 0, 0]]
[[1, 0, 0], [0, 0, 0], [0, 0, 0]]
True
只改 bad[0][0],三行的第一列全变成 1——因为第 5 篇讲过的浅拷贝语义:* 3 复制的是三个对同一个内层列表的引用。正确写法是 [[0] * 3 for _ in range(3)]:每次循环新建一行(推导式的每次迭代都执行一次 [0] * 3)。第三段指出另一种等价错误:手动建 row 再 [row, row, row],is 直接证明三行同体。凡是要「每行独立」的多行矩阵,一律用推导式造。
陷阱二:深层键缺失的 KeyError「半路炸」
config["cache"]["server"]["port"] 这类连续下标,只要中间任何键缺失就抛 KeyError,而且报错信息不告诉你「是第几层缺的」——手工排查很痛苦。对策二选一:数据「必须有」→ 用 try/except KeyError 捕获并给出清晰报错;数据「可缺失、有默认」→ 用上面操作部分的 get 链。逐层 get 的代价是每一层都多一次字典查找,对热路径上的超深链可以省略中间层默认值,但对常规配置读取完全无所谓,可读性优先。
陷阱三:遍历结构时增删元素
第 4 篇讲过集合「边遍历边删」直接 RuntimeError,列表更阴险:它不报错,但会悄悄跳过元素。在 for row in grades: 里 grades.remove(row),循环内部用下标推进,删除会让后面的行集体前移,于是每隔一个跳过一条。想边遍历边删的先决条件是「先取快照再删」,比如 for row in grades[:]:(切片副本,第 5 篇),或者干脆把「要留下的」收集进新列表再整体替换。凡是对容器结构动手(增/删元素),默认假设「不能边遍历边做」。
陷阱四:多层下标时层数对不上
m[i][j] 写多了容易把 i、j 的角色弄反(把行当列)。越界时 IndexError 还算是幸运的——有些情况下写反了不越界但结果错:非方阵(比如 2 行 5 列)把 m[i][j] 写成 m[j][i],i 一旦超过行数-1 立即 IndexError,可如果刚好不越界,你拿到的是「错误的格子」。调试姿势:先在 REPL 里把形状打出来(len(m) 与 len(m[0])),再固定一行手工核对两个下标的取值;命名上让变量名带语义(row, col 而不是 i, j)能显著降低犯错率。
小结
嵌套容器把「容器装容器」变成数据建模的日常工具:嵌套列表做矩阵(m[i][j] 定位格子)、字典列表做记录集合(字段名自带语义,循环里顺手补字段)、嵌套字典做配置树(递归铺平、get 链安全访问)。遍历的层数等于结构的层数,能展平就展平交给聚合函数,要形状就保留层次用多层 for。三个高频坑必须内化:[[0]*3]*3 的共享行(用推导式造矩阵)、深层键缺失的 KeyError(get 链或 try/except)、遍历时增删元素(先取快照)。下一篇进入 collections 模块——当「计数」「带默认值的字典」这类需求出现时,标准库提供的现成武器。
练习与思考题
- 不用推导式,手写两段代码分别构造「5×5 单位矩阵」(对角线为 1、其余为 0)与「每行递增的阶梯矩阵」
[[1], [1, 2], [1, 2, 3]],体会不规则嵌套。 - 在成绩表
students(见操作部分)上完成:统计 math 的平均分;找出 english 最高分记录的姓名(不许用排序);列出「两科都过 80」的学生名单(提示:all() 或 and 逻辑)。 - 给配置树(块 6 的 config)写一个函数
find_keys(node, target),返回所有值为 target 的「点分路径」列表。 - 思考题:为什么「边遍历边删」对列表是静默跳行、对集合是直接报错?结合两者的迭代器实现差异回答。
- 挑战题:把 9×9 乘法表存成嵌套列表并打印成对齐的表格;再写一个「按列求和」函数返回每列的和(提示:外层循环遍历列下标、内层循环遍历行)。