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}

自检三题

  1. a = (1, 2, [3]); a[2].append(4) 会报错吗?tuple 到底保证了什么?(答:不报错。tuple 保证的是它存的引用不变,引用指向的对象本身可变就照样能改。)
  2. list.copy() 和 copy.deepcopy() 的区别用一句话说清。
  3. 为什么可变默认参数的 bug 不会出现在 def f(x, y=0) 这种默认值上?

下一章

02 章讲函数:参数怎么传、作用域怎么查(LEGB)、以及「函数是对象」为什么是 Python 一切高级写法的地基。

进入 keel 阅读