KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 数据结构:怎么选,以及可变性这颗雷 — keel 龙骨
## 这一章解决什么问题
这一章解决什么问题
写 Python 最常做的决定是「这个数据用什么装」。很多人凭感觉:一堆东西就用 list,键值对就用 dict,从来不换。这一章给你一张选型表,然后集中讲一个贯穿全课的主题——可变性(mutability)。它是 Python 一半经典 bug 的根源,也是后续课里「不可变配置对象」的理论起点。
四大结构一张表
| 结构 | 字面量 | 有序? | 可变? | 查找复杂度 | 一句话定位 |
|---|---|---|---|---|---|
| list | [1, 2, 3] |
✅ | ✅ | 按值找 O(n) | 顺序集合,会重复 |
| tuple | (1, 2, 3) |
✅ | ❌ | 同 list | 定了就不改的序列 |
| dict | {"a": 1} |
✅(3.7+) | ✅ | O(1) | 键到值的映射 |
| set | {1, 2, 3} |
❌ | ✅ | O(1) | 去重、成员判断、集合运算 |
几个容易忽略的细节:
1. dict 从 3.7 起保证插入有序。老教程说「dict 无序」,已经过时了。你可以放心依赖「遍历 dict 的顺序就是插入顺序」这个行为,配置项按书写顺序生效就是靠它。
2. set 的去重靠的是 hash,不是相等判断。所以它要求元素可哈希——list 和 dict 放不进 set,tuple(内含的元素也全是可哈希的)可以:
>>> {[1, 2]}
TypeError: unhashable type: 'list'
>>> {(1, 2), (3, 4)}
{(1, 2), (3, 4)}
3. tuple 不是「不可变的 list」,它是「一条记录」。 (user_id, action, timestamp) 这种固定结构用 tuple,语义是「这几个字段合起来描述一件事」;list 的语义是「同类东西的集合,可能增减」。
可变性:同一份数据,多个名字
Python 的赋值从不复制数据,只是给对象贴名字:
>>> a = [1, 2, 3]
>>> b = a # b 和 a 指向同一个 list
>>> b.append(4)
>>> a
[1, 2, 3, 4] # a 也"变"了
不可变对象(int/str/tuple)没这个问题,因为它们根本变不了——任何「修改」都是造新对象。可变对象(list/dict/set/自定义类实例)是原地改,于是所有指向它的名字都看到了变化。
判断两个名字是否指向同一对象用 is,判断值相等用 ==:
>>> a = [1, 2]
>>> b = [1, 2]
>>> a == b # True:值相等
>>> a is b # False:不是同一个对象
浅拷贝与深拷贝
想复制一份可变对象,有三档选择:
>>> import copy
>>> original = [[1, 2], [3, 4]]
>>> shallow = original.copy() # 浅拷贝:只复制最外层
>>> shallow[0].append(99)
>>> original
[[1, 2, 99], [3, 4]] # 内层还是共享的,被改到了
>>> deep = copy.deepcopy(original) # 深拷贝:递归复制每一层
>>> deep[0].append(77)
>>> original
[[1, 2, 99], [3, 4]] # 深拷贝那层随便动,不影响原对象
浅拷贝的方式有三种等价写法:list(x)、x[:]、x.copy()——但都只复制一层。嵌套结构必须 deepcopy。
一条工程经验:函数收到可变参数时,如果要改它,先问自己「调用方愿不愿意看到这个修改」。不愿意就先拷贝,或者更好——不要改,返回新对象。
最经典的雷:可变默认参数
def add_item(item, items=[]): # ❌ 默认值只在函数定义时创建一次
items.append(item)
return items
>>> add_item(1)
[1]
>>> add_item(2)
[1, 2] # 上一次调用的数据留下来了!
默认参数的值在 def 执行那一刻被求值一次,之后所有调用共享同一个 list。正确写法是「None 哨兵」:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
为什么不用 items=[] 就好?因为 [] 每次调用都会新建。None 不可变,所以拿它当哨兵绝对安全。这个模式在标准库和所有成熟代码库里随处可见,见到要能认出来。
顺带一提:函数对象上可以查看这个坑的证据——add_item.__defaults__ 会显示那个被共享的 list。调试时可变默认参数时很有用。
三种推导式:建新结构的惯用法
squares = [x * x for x in range(10)] # list
lookup = {user.id: user for user in users} # dict
seen = {token for token in tokens if token} # set
# 加条件、双循环都行
evens = [x for x in range(20) if x % 2 == 0]
pairs = [(a, b) for a in "xy" for b in (1, 2)]
# 生成器表达式(注意没有方括号)——不占内存,惰性产出
total = sum(x * x for x in range(10_000_000))
推导式读不懂的判断标准:如果你写出了嵌套三层以上的推导式,停下来,改成普通 for 循环。推导式是给「一眼能看穿的变换」用的,不是炫技场。
dict 一个高频惯用法,把对象列表转成索引——后续课程里「按 run_id 查运行态」全是这个形状:
by_id = {item["id"]: item for item in raw_items}
自检三题
a = (1, 2, [3]); a[2].append(4)会报错吗?tuple 到底保证了什么?(答:不报错。tuple 保证的是它存的引用不变,引用指向的对象本身可变就照样能改。)list.copy()和copy.deepcopy()的区别用一句话说清。- 为什么可变默认参数的 bug 不会出现在
def f(x, y=0)这种默认值上?
下一章
02 章讲函数:参数怎么传、作用域怎么查(LEGB)、以及「函数是对象」为什么是 Python 一切高级写法的地基。