☰
Python字典完全指南:键值对、遍历嵌套与避坑技巧
2026/9/26 7:33:05 网站建设 项目流程

我在带新手学 Python 的时候,发现一个很有规律的现象:很多人学到列表(list)时觉得 Python 太好用了,但一碰到字典(dict)就开始发懵。其实字典一点儿都不难,难的是大家没想明白一个关键问题——字典到底解决了列表解决不了的那件什么事。这篇文章准备用最直白的例子,把 Python 字典的创建、读取、修改、遍历、嵌套和常见坑全部过一遍,非常适合刚学完列表、元组,准备接触 dict 的初学者。就算你已经写过一阵子 Python,但心里总觉得"字典用过但没真正弄懂",这篇文章也能帮你把概念理顺。

学字典最好的方法,不是背 API,而是先理解它的底层逻辑:它是一种"键值对"结构,一切操作都在围绕着"给一个名字,快速找到对应的值"展开。下面我就从一个真实需求出发,一步步拆给你看。

1. 列表解决不了的"按名字找值",正是字典诞生的理由

先看一个特别常见的场景:现在要记录三个同学的成绩,名字分别是 alice、bob、carol,分数分别是 85、92、78。

用列表存的话,第一反应可能是:

scores = [85, 92, 78]

这样存没问题,但你想取出 alice 的分数时,尴尬就来了:scores[0]确实是 85,可 85 是 alice 的吗?你得在旁边再维护一个同学名单:

names = ["alice", "bob", "carol"] scores = [85, 92, 78]

然后你每次查一个人的分数,都得先在names里找到他的下标,再去scores里取对应位置的数。这种两个列表按下标对齐的方案被称为"并行列表反模式",只要中间删掉一个人,整个对齐关系就崩了。而字典就是专门来解决这个问题的:

score_dict = {"alice": 85, "bob": 92, "carol": 78} print(score_dict["alice"]) # 85

score_dict["alice"]一句话定位,不用数下标,不用维护对齐关系。这才是字典存在的根本动机:它是"名字到值"的映射表。

1.1 字典的三个核心概念

字典由一个个"键值对"组成,叫 entry。键就是那个"名字",值就是"名字对应的数据"。这里有三条底层规则你必须刻在脑子里:

  • 键必须唯一。同一个键重复出现时,后面的值会把前面的值覆盖掉,而且不报错。
  • 键必须不可变。字符串、数字、元组都可以当键,列表这种可变对象不行,因为字典靠键的哈希值来存储定位,键一变,位置就乱了。
  • 值可以是任何东西。数字、字符串、列表、元组、另一个字典,都可以作为值。

用一个生活类比帮助理解:字典就是手机通讯录。你输入联系人姓名(键),立刻得到电话号码(值)。你在通讯录里不可能存两个完全相同的名字,名字本身也不太会频繁修改,这些特征和 Python 字典的约束完全一致。

1.2 创建字典的三种常见方式

了解了为什么需要字典之后,直接上手。创建字典最常用的三招分别是空字典、直接写键值对、用dict()从别的结构转换:

# 方式一:空字典 empty1 = {} empty2 = dict() # 方式二:最常见的字面量写法 phone = {"alice": "13800001111", "bob": "13900002222"} # 方式三:用 dict() 接收一个二元组列表 phone2 = dict([("alice", "13800001111"), ("bob", "13900002222")])

第一行{}是空字典,注意它和空集合set()完全是两码事。{"a": 1}是字典,{"a"}是集合,新手经常在这里混淆。方式三用得相对少,但在从数据库查询结果、配置文件转成字典时特别好用。

2. 创建、读取、修改、删除:四板斧跑通最小例子

任何容器类型的基本功都是增删改查,字典也不例外。这一节我会用很短的例子,把四个操作一次讲清楚。

2.1 读取值:方括号和 get 方法的本质差异

先设一个最小字典:

scores = {"alice": 85, "bob": 92}

读取可以直接用方括号:

print(scores["alice"]) # 85

这里有个新手最容易踩的坑:如果访问一个不存在的键,Python 会直接抛异常,程序当场崩掉。

scores["tom"] # KeyError: 'tom'

为了避免这种崩溃,字典提供了get方法:

score = scores.get("tom") # 返回 None,不报错 score = scores.get("tom", 0) # 返回默认值 0

get的第二个参数就是"查不到时用的兜底值"。方括号是硬取,必须保证键存在;get是软取,适合预期可能查不到的场景。两者的取舍原则很简单:确定键一定存在,用方括号;不能确定,用get。

2.2 修改与新增:同一套写法

字典的写操作有一个非常友好的特性:不管键存不存在,都是等号赋值。键存在就是修改,键不存在就是新增:

scores["bob"] = 95 # 把 bob 的分数改成 95 scores["dave"] = 88 # 新增 dave print(scores) # {'alice': 85, 'bob': 95, 'dave': 88}

一次更新多个键值对,用update方法:

scores.update({"alice": 99, "eve": 70}) # 也可以传入二元组列表 scores.update([("alice", 100), ("frank", 60)])

update的语义是"把传入字典里的每一对,都批量执行修改或新增操作"。相当于一个循环赋值,但性能更好、写法也更干净。

2.3 删除:del、pop、popitem、clear 四个工具的取舍

删除操作有四种常见方式,初学者容易混:

# 1. del:按键删除,不存在会 KeyError del scores["bob"] # 2. pop:删除并返回被删的值,可以给默认值 score = scores.pop("alice", 0) # 返回 85,键不存在时返回 0 # 3. popitem:删除最后一对,返回 (key, value) 元组 pair = scores.popitem() # 4. clear:清空字典 scores.clear()

我的经验是:需要用到被删掉的“值”时用pop,这样可以顺手把结果存进变量;不需要返回值时用del就够;想把整个字典重置为空,用clear。这里做个直观对比:

操作写错时表现返回值适用场景
方括号d[k]键不存在抛 KeyError对应值确定键一定存在
d.get(k, x)不报错,返回 x对应值或默认值不确定键是否存在
del d[k]键不存在抛 KeyError无删除且不要返回值
d.pop(k, x)不报错,返回 x被删的值删除同时要拿到值

2.4 判断键是否存在:用 in,不是遍历

如果只是想看一下键在不在,用in运算符:

if "alice" in scores: print("alice 的成绩在册")

注意in判断的是"键",不是"值"。如果你想判断某个分数有没有人考过,就得遍历值,那是后面的事情。这一小节总结下来就是:字典的读、写、删,都有对应的确定性 API,真正要用熟,其实写一遍通讯录增删改查就够了。

3. 遍历字典的正确姿势:直接遍历、keys、values、items 到底该用哪个

新手遍历字典时最容易产生一个困惑:for循环里到底拿到的是键还是值?其实规则非常简单。

3.1 直接遍历,拿到的是键

info = {"name": "xiaoming", "age": 18} for item in info: print(item) # 输出: # name # age

for item in info等价于for item in info.keys(),遍历的是所有键。这是很多人第一次见识字典时感到意外的点:咦,不是应该循环出值吗?不是的,键才是字典的"主索引",默认遍历自然从键开始。

3.2 三种视图方法的选择

字典提供三个视图方法:

for k in info.keys(): # 遍历所有键 print(k) for v in info.values(): # 遍历所有值 print(v) for k, v in info.items(): # 同时拿到键和值 print(k, v)

items()是最常用的,因为它一次性把键值对解包成了两个变量。这三个方法返回的不是列表,而是"视图对象"(view object)。视图最鲜明的特点是它会跟着原字典实时变化:你先把视图存下来,之后往字典里加了一个键,再去遍历这个视图,新键也会出现。如果你需要一份"当时那一刻的快照",就套上list():

keys_snapshot = list(info.keys())

3.3 按分数排序输出:遍历和排序的组合

初学者常遇到的需求是把字典按值排序输出。比如把成绩从高到低列出来:

scores = {"alice": 85, "bob": 92, "carol": 78, "dave": 88} for name in sorted(scores, key=scores.get, reverse=True): print(name, scores[name]) # 输出: # bob 92 # dave 88 # alice 85 # carol 78

sorted(scores, key=scores.get)的意思是:对字典的键排序,排序时的依据是每个键在字典中对应的值。reverse=True从大到小。这个写法看起来短,但背后逻辑得想清楚:scores本身就是可迭代的,迭代出来的是键;key参数指定了比较规则。

如果你想按"键"的字母序直接排序,那更简单:

for name in sorted(scores): print(name, scores[name])

3.4 遍历时修改字典:一个臭名昭著的坑

在遍历字典的过程中直接添加或删除键,会立刻引发RuntimeError: dictionary changed size during iteration。举一个很典型的场景——删掉所有不及格的人:

scores = {"alice": 85, "bob": 59, "carol": 78, "dave": 40} for name in scores: if scores[name] < 60: del scores[name] # RuntimeError: dictionary changed size during iteration

正确的做法是先取出所有需要删除的键,存成一个列表,然后再遍历列表去删字典:

for name in [k for k in scores if scores[k] < 60]: del scores[name]

列表推导式先算出"不及格名单",此时字典没有被修改,是安全操作;随后删除发生在循环外,字典大小只在删的那一瞬间变化,而循环已经不再遍历它了。

4. get、setdefault、defaultdict:告别手写"键是否存在"的判断

在真实代码里,最常见的场景并不是简单的存和取,而是"取一个值,如果不存在就初始化,然后立刻做运算"。经典例子是词频统计:统计一句话里每个单词出现了几次。

4.1 最朴素的写法:if 判断

先把最笨的写法摆出来:

text = "apple banana cherry apple banana apple" words = text.split() count = {} for word in words: if word in count: count[word] += 1 else: count[word] = 1 print(count) # {'apple': 3, 'banana': 2, 'cherry': 1}

这个版本逻辑完全正确,但每次都写四行 if-else,很啰嗦。字典的get可以把这段压缩成一行:

for word in words: count[word] = count.get(word, 0) + 1

count.get(word, 0)的语义是:有这个键就返回原值,没有就返回 0。加上 1 后重新赋值。这一行代码是初学者理解"字典聪明用法"的第一步阶梯。

4.2 setdefault:先设默认值,再干活

比get更进一步的是setdefault,它的行为是:如果键不存在,就把键和默认值插入字典,然后返回值;如果键存在,就直接返回原值。用场景拆解一下:

group = {} name = "bob" group.setdefault(name, []).append("Python") group.setdefault(name, []).append("Django") print(group) # {'bob': ['Python', 'Django']}

第一行执行时,bob不存在,于是插入"bob": [],再append("Python"),得到{"bob": ["Python"]}。第二行执行时,bob已经存在,直接返回原来的列表,再append("Django")。这样你就不需要每次都检查键是否存在,代码干净很多。

4.3 defaultdict:把"默认值"变成字典的固有属性

同样是统计词频,用collections.defaultdict还可以更进一步:

from collections import defaultdict count = defaultdict(int) for word in words: count[word] += 1 print(count) # defaultdict(<class 'int'>, {'apple': 3, 'banana': 2, 'cherry': 1})

defaultdict(int)表示这个字典在访问不存在的键时,会自动调用int()生成一个默认值 0。int()是零,list()是空列表,str()是空字符串。同理,用它收集"爱好列表":

hobbies = defaultdict(list) for name, hobby in [("bob", "Python"), ("alice", "游泳"), ("bob", "Django")]: hobbies[name].append(hobby)

注意defaultdict的副作用:它会在你"只是读取"一个不存在的键时,悄悄把这个键创建出来。比如print(count["tom"])之后,tom就真的存在于字典里了。如果你不想让读取产生副作用,还是用普通字典加get更安全。

4.4 再进一步:Counter 一行统计词频

词频统计太常见了,标准库专门准备了一个Counter:

from collections import Counter count = Counter(words) print(count) # Counter({'apple': 3, 'banana': 2, 'cherry': 1}) print(count.most_common(2)) # [('apple', 3), ('banana', 2)]

Counter是字典的子类,本质上就是一个值为计数的 dict,但它多了most_common、elements等专用方法。新学字典时可以直接了解它,因为它能帮你理解"字典只是基础,在其上可以扩展出很多专用结构"。

5. 字典嵌套:学生信息、商品库存这类真实数据是这么组织的

前面讲的都是一层字典,但现实世界的数据很少是"一个名字对应一个数"这种简单关系。比如学生信息,一个学生有姓名、年龄、多科成绩。如果用多个平行字典,代码会变得极其难维护。字典的嵌套能力就是为此准备的。

5.1 学生信息表:value 里放字典

把多个学生的信息放在一个字典里,每个学生的信息内部又是一个字典,这种结构叫"字典的字典":

students = { "001": { "name": "张三", "age": 18, "scores": {"math": 90, "english": 85} }, "002": { "name": "李四", "age": 19, "scores": {"math": 76, "english": 92} } }

要取出 001 号学生的数学成绩,就像剥洋葱一样一层一层往下走:

math_score = students["001"]["scores"]["math"] print(math_score) # 90

遍历整个学生表时,用items()解包外层,再操作内层:

for stu_id, info in students.items(): print(stu_id, info["name"], info["scores"]["math"])

这个例子的价值在于:它让你看到字典的 value 完全可以是另一个字典,嵌套的深度和你业务逻辑的层级一致。学生表有两层(外层是学号,内层是属性),成绩又嵌套了一层。取数时几个方括号连续排列,从左到右逐层深入,逻辑清楚很多。

5.2 商品库存:一个更贴近生活的例子

再做一个小型的商品库存系统:

stock = { "apple": {"price": 5, "quantity": 100}, "banana": {"price": 3, "quantity": 200}, } # 卖出 20 个苹果 stock["apple"]["quantity"] -= 20 # 查看某个商品 def show(name): if name in stock: p = stock[name]["price"] q = stock[name]["quantity"] print(f"{name}: 单价 {p} 元, 剩余 {q} 个") else: print("商品不存在") show("apple")

这类嵌套结构远比"两个平行列表"清晰。把规律说透:当同一个人的多个属性需要一起存储时,就把它放在一个字典里;当这些"多个人"又要按某个编号区分时,再在外面套一层字典。数据结构的选择不是技巧问题,而是真实世界关系的映射。

5.3 嵌套太深时怎么办

不过我也要给一个善意的提醒:嵌套别超过三层。三层以上再想嵌套,你写的时候倒是爽,别人维护时读代码会很痛苦。遇到"字典套字典再套字典"的复杂结构,更合理的做法是定义一个类,或者至少抽象成带默认值的defaultdict以简化初始化。俗话讲,字典是工具,不是炫技场地。

6. 列表、元组和字典互转:zip、**展开与合并的常用姿势

字典和序列类型之间经常要互相转换,尤其是做数据清洗时。这一节把常用转换方法集中讲一遍,属于那种"平时不觉得,一到用时恨不得抄下来"的内容。

6.1 列表、元组转字典

最常见的两种方式,一种是二元组列表配dict():

pairs = [("name", "xiaoming"), ("age", 18)] d = dict(pairs) print(d) # {'name': 'xiaoming', 'age': 18}

另一种是配合zip把两个列表合并成字典:

keys = ["name", "age"] values = ["xiaoming", 18] d = dict(zip(keys, values)) print(d) # {'name': 'xiaoming', 'age': 18}

zip的作用是把两个序列的第 0 个元素组合成一对、第 1 个元素组合成一对,产生一个二元组的可迭代对象,正好可以被dict()消费。这是一种极其常见的"并行列表转映射"手段,比手动循环优雅很多。

6.2 字典转列表、元组

反向转换的语义要记清楚:

d = {"name": "xiaoming", "age": 18} list(d.keys()) # ['name', 'age'] list(d.values()) # ['xiaoming', 18] list(d.items()) # [('name', 'xiaoming'), ('age', 18)]

.items()转成的列表里,每个元素是一个二元组。而list(d)直接得到的是键列表,这是很多人没意识到的快捷方式。

6.3 解包展开:两个字典合并的多种姿势

把字典展开成关键字参数,可以用双星号**:

def show(name, age): print(name, age) info = {"name": "xiaoming", "age": 18} show(**info) # 等价于 show(name="xiaoming", age=18)

用双星号还可以合并字典。三种常用写法如下:

d1 = {"a": 1, "b": 2} d2 = {"b": 3, "c": 4} # 写法一:字典解包合并,适合 Python 3.5+ merged = {**d1, **d2} # 写法二:| 运算符,Python 3.9+ merged = d1 | d2 # 写法三:就地更新 d1.update(d2)

三种方式的结果都是{'a': 1, 'b': 3, 'c': 4}。注意b最终是 3,因为后面的字典覆盖了前面的同名字段。update会直接修改原字典,前两种则生成一个全新的字典。如果你不想动原来的数据,优先用解包方式或者|运算符。

6.4 fromkeys:为多个键批量设置默认值

还有一个看起来冷门但很实用的小工具:

d = dict.fromkeys(["a", "b", "c"], 0) print(d) # {'a': 0, 'b': 0, 'c': 0}

dict.fromkeys接收一个键的列表和同一个默认值,生成一个新字典。适合做初始化,比如给一堆单词初始化计数值。需要提醒的是,默认值如果传可变对象,所有键会共享同一个对象,例如dict.fromkeys(["a", "b"], [])里的两个键都指向同一个空列表,改一个"看起来"会影响另一个——这通常不是你想要的效果。这时最好改用字典推导式:

d = {k: [] for k in ["a", "b"]}

7. 初学者最容易踩的五个坑,每个都给你复现一遍

我见过太多人在这个阶段卡住,不是因为字典难,而是因为踩了下面这类无声无息的坑。把这些坑提前认一遍,能帮你省下大量调试时间。

7.1 用列表当键,直接 TypeError

很多初学者想让字典记录某种列表状态,就会尝试:

d = {} d[[1, 2, 3]] = "test" # TypeError: unhashable type: 'list'

Python 字典靠哈希表实现,键必须是可哈希的,而列表是可变类型,它的"长相"会变,哈希地址也会变,作为键会导致存储位置不稳定。解决方案是改成元组:

d[(1, 2, 3)] = "test" print(d) # {(1, 2, 3): 'test'}

这里也顺便解释了为什么元组可以当键、列表不行:元组不可变,它的哈希值固定,字典内部才能稳定查找。

7.2 重复键不报错,而是静默覆盖

看这个例子:

d = {"a": 1, "a": 2} print(d) # {'a': 2}

不会报错,但后面的值把前面的值覆盖了。这种坑经常出现在把两个配置字典合并时,如果两个来源使用了相同的键,你以为是"追加",实际上被后者覆盖。用{**d1, **d2}合并时尤其要注意键冲突的优先级。

7.3 方括号取不到值,直接让程序崩掉

对字典做读取时,d["不存在"]会抛KeyError,很多新手在写爬虫、处理接口返回数据时被这里搞崩过。解决思路很简单:如果你不能百分百确认键存在,就换d.get("不存在", 默认值);如果后续要对该键做运算,需要考虑用setdefault或者defaultdict。经验法则:读取不确定的键,永远优先 get。

7.4 字典复制:改了一个,另一个也变了

这是经典中的经典:

a = {"list": [1, 2, 3]} b = a b["list"].append(4) print(a) # {'list': [1, 2, 3, 4]}

b = a并没有复制字典,只是让两个变量指向了同一个字典对象。如果你需要复制,用copy()是浅拷贝:

b = a.copy() b["list"].append(4) print(a) # {'list': [1, 2, 3, 4]},还是被改了

因为浅拷贝复制的是最外层,内部的可变对象仍然共享。要真正独立,必须用深拷贝:

import copy b = copy.deepcopy(a) b["list"].append(4) print(a) # {'list': [1, 2, 3]} print(b) # {'list': [1, 2, 3, 4]}

理解浅拷贝和深拷贝的差异,是字典实用道路上的必修课,很多 bug 排查半天最后都发生在这一层。

7.5 对字典顺序的误解

Python 3.7 之后,字典正式保持插入顺序。也就是说:

info = {} info["b"] = 2 info["a"] = 1 print(list(info)) # ['b', 'a']

先插入的键排在前面。但"插入有序"不等于"自动排序"。如果你想按字母序或者按值的大小输出,还是要显式调用sorted,不要指望插入顺序能满足排序需求。比如构造字典时故意按某种业务顺序插入,能增强可读性;但如果你需要按值排序,请继续使用前面讲的sorted(scores, key=scores.get)。

7.6 顺手补充:读写时注意 bool 的坑

最后补一个进阶一点的小坑:True和1在字典里被视为同一个键。

d = {} d[True] = "yes" d[1] = "no" print(d) # {True: 'no'}

因为True == 1且哈希值相同,所以第二次赋值把第一次覆盖了。类似地,False和0也会互相覆盖。这种问题在实际业务中不常见,但一旦出现会很难排查,知道有这么回事就够了。

我用这套方法带过好几届新手,最有效的练习始终是同一个:把你手机里的通讯录、钱包里的账单、游戏里的几个角色属性用字典重新实现一遍。不用背 API,写几个小例子,这些操作自然就长在记忆里了。

如果让我给初学者留一句话:字典最大的价值就是"按名字找值",所有方法都围绕这个核心展开,遇到任何数据结构需求,先问自己一句——这到底是"一个名字对应一个值",还是"一个顺序对应一个位置"?想清楚这个问题,你的数据建模能力就算正式入门了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询