Python 的内置数据结构看起来简单,但真正写代码时,不少细节会悄悄绊倒你——字符串不可变、列表浅拷贝、字典键的哈希要求、集合的元素约束、bytes 与 str 的边界……这些知识点在面试和日常调试中反复出现。本文围绕 strings、lists、tuples、dicts、sets、sorting、bytes 七个板块,用可运行的代码把常见陷阱逐一拆开。
字串与元组:不可变的"坑"
字符串和元组都是不可变类型,这带来两个常见误解:
误解一:以为拼接不会创建新对象。
s = "hello"
print(id(s)) # 记下原始 id
s += " world"
print(id(s)) # id 变了——新对象,旧对象仍在内存中等待 GC
每次 += 都生成一个新字符串。在循环里大量拼接时,用 ''.join(parts) 比 s += chunk 快得多,因为前者只分配一次内存。
误解二:元组不可变,所以里面的内容也不会变。
t = (1, [2, 3], "a")
t[1].append(4) # 完全合法!元组持有的是列表的引用,引用没变,列表内容变了
print(t) # (1, [2, 3, 4], 'a')
元组不可变的是它持有的引用,而非引用指向的对象。如果元素本身是可变容器,内容可以随意修改——这也是为什么元组不能当字典键(除非所有元素也都是不可变类型)。
列表:拷贝与排序的暗角
浅拷贝 vs 深拷贝
original = [[1, 2], [3, 4]]
copy = original[:] # 浅拷贝——等价于 list(original)
copy[0].append(5)
print(original) # [[1, 2, 5], [3, 4]] ← 内层列表被共享了!
切片 [:] 和 list() 都是浅拷贝,只复制外层容器,内层对象仍然是同一引用。需要真正独立副本时:
import copy
deep = copy.deepcopy(original)
deep[0].append(5)
print(original) # [[1, 2], [3, 4]] ← 不受影响
排序的稳定性与 key 函数
Python 的 list.sort() 和 sorted() 都是稳定排序——相等元素的相对顺序不变。利用这一点可以做多关键字排序:
records = [
{"name": "alice", "score": 90},
{"name": "bob", "score": 90},
{"name": "carol", "score": 85},
]
# 先按 score 降序,再按 name 升序——利用稳定性,反着排
records.sort(key=lambda r: r["name"]) # 第二关键字先排
records.sort(key=lambda r: r["score"], reverse=True) # 第一关键字后排
print(records)
# [{'name': 'alice', 'score': 90}, {'name': 'bob', 'score': 90}, {'name': 'carol', 'score': 85}]
也可以一步到位:
records.sort(key=lambda r: (-r["score"], r["name"]))
但这种方式要求 score 是数值类型(才能取负),字符串关键字就不能用 - 技巧了,此时稳定性排序是更通用的方案。
字典:键的约束与视图行为
什么能当键?
字典键必须可哈希——即对象生命周期内哈希值不变,且实现了 __eq__。常见可哈希类型:int、float、str、frozenset、元素全为可哈希类型的 tuple。不可哈希:list、dict、set。
d = {}
d[(1, 2)] = "ok" # 元组做键,合法
d[[1, 2]] = "nope" # TypeError: unhashable type: 'list'
视图不是快照
d = {"a": 1, "b": 2}
keys = d.keys()
d["c"] = 3
print(list(keys)) # ['a', 'b', 'c'] ← 视图是动态的,不是拷贝
dict.keys() / values() / items() 返回的是视图对象,会随字典变化实时更新。如果需要一个固定快照,显式 list(d.keys())。
集合:去重与运算的实用场景
集合的核心价值:去重 + 数学运算(交集、并集、差集、对称差集)。
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a & b) # {3, 4} 交集
print(a | b) # {1, 2, 3, 4, 5, 6} 并集
print(a - b) # {1, 2} 差集(a 中有但 b 中没有)
print(a ^ b) # {1, 2, 5, 6} 对称差集
一个实战场景——找出两份日志中独有的 IP:
ips_today = set(line.split()[0] for line in open("log_today.txt"))
ips_yesterday = set(line.split()[0] for line in open("log_yesterday.txt"))
new_ips = ips_today - ips_yesterday # 今天新出现的 IP
gone_ips = ips_yesterday - ips_today # 昨天有但今天消失的 IP
注意:集合元素也必须可哈希,所以不能把列表放进集合,但可以用 frozenset 嵌套。
bytes 与 str:编码边界
Python 3 中 str 是 Unicode 文本,bytes 是原始二进制。两者不能隐式混用:
text = "你好"
b = text.encode("utf-8") # bytes: b'\xe4\xbd\xa0\xe5\xa5\xbd'
decoded = b.decode("utf-8") # str: '你好'
# 以下操作会报错:
# text + b # TypeError: can't concat str to bytes
# b[0] == "你" # False——b[0] 是整数 228,不是字符
常见陷阱:从网络或文件读到 bytes,忘了解码就当字符串用。反过来,往文件写文本时忘了编码:
# 写入二进制文件必须用 bytes
with open("data.bin", "wb") as f:
f.write(b"\x00\x01\x02")
# 写入文本文件可以指定编码
with open("data.txt", "w", encoding="utf-8") as f:
f.write("你好")
综合练习:自测脚本
把上面提到的要点合并成一个可运行的诊断脚本,逐项验证你的理解:
"""Python 数据结构要点自测——逐项运行,观察输出是否符合预期"""
import copy
# 1. 字串拼接的 id 变化
s = "hello"
s_id = id(s)
s += " world"
assert id(s) != s_id, "字符串拼接应创建新对象"
# 2. 元组内可变元素
t = (1, [2, 3], "a")
t[1].append(4)
assert t == (1, [2, 3, 4], "a"), "元组内列表内容可变"
# 3. 浅拷贝共享内层
original = [[1, 2], [3, 4]]
shallow = original[:]
shallow[0].append(5)
assert original[0] == [1, 2, 5], "浅拷贝共享内层可变对象"
# 4. 深拷贝独立
deep = copy.deepcopy(original)
deep[0].append(6)
assert 6 not in original[0], "深拷贝不共享内层对象"
# 5. 字典键可哈希约束
d = {}
d[(1, 2)] = "tuple_key_ok"
try:
d[[1, 2]] = "list_key_fail"
assert False, "列表做键应抛 TypeError"
except TypeError:
pass # 预期行为
# 6. 字典视图动态性
d = {"a": 1}
keys_view = d.keys()
d["b"] = 2
assert list(keys_view) == ["a", "b"], "视图随字典实时更新"
# 7. 集合运算
a, b = {1, 2, 3}, {2, 3, 4}
assert a & b == {2, 3}
assert a | b == {1, 2, 3, 4}
assert a - b == {1}
# 8. bytes 与 str 边界
text = "hi"
b = text.encode()
assert b[0] == ord("h"), "bytes 索引返回整数"
assert b.decode() == text, "decode 还原为 str"
print("全部自测通过 ✓")
运行方式:
python selftest_datastructures.py
如果某条 assert 报错,说明你对那个点的理解和 Python 实际行为有偏差——这正是值得深挖的地方。
采纳建议与排查清单
在日常开发中,围绕数据结构的 bug 通常集中在几类模式。下面是一份快速排查清单:
| 场景 | 检查项 |
|---|---|
| 列表/字典被意外修改 | 是否用了浅拷贝?函数参数是否共享了可变默认值? |
| 字典键报 TypeError | 键是否为 list/dict/set?改用 tuple 或 frozenset |
| 字符串循环拼接慢 | 改用 ''.join() |
| bytes 索引得到整数而非字符 | 记住 bytes 是字节序列,先 .decode() 再操作文本 |
| 集合去重失败 | 元素是否可哈希?嵌套列表改用 frozenset |
| 排序结果不符合预期 | key 函数返回值是否一致?多关键字时利用稳定性 |
核心原则:可变与不可变的边界、浅拷贝与深拷贝的区别、哈希约束对键和集合元素的影响——这三条线贯穿了 Python 数据结构的大部分陷阱。弄清它们,20 道测验题和日常编码中的隐患都能迎刃而解。