可变性、容器、树与链表
前面几篇,我刻意没让你碰"改掉一个已有的值"。因为**可变性(mutability)**是 CS61A 乃至整个编程里"便利"和"灾难"同一个源头。
它能让代码更短更自然,也能让 bug 在最意想不到的地方炸——而且炸得毫无痕迹。
本篇先把"可变 vs 不可变"这条生死线划清,再看别名、容器,最后动手实现 CS61A 两尊经典递归结构:Link与Tree。
一、可变 vs 不可变:一条分界生死线
是什么:
- 不可变(immutable):创建后内容不能改。
int、float、str、tuple、frozenset都在此列。你做的任何"修改"其实是生成新对象。 - 可变(mutable):创建后内部可改。
list、dict、set、以及你自己写的类实例都在此列。改的是"同一个对象"。
1 | s = "abc" |
输出:
1 | [99, 2, 3] |
坑(最反直觉的一点):字符串"看起来"被改了,其实是返回了新字符串。下面这段代码暴露了无数人的误解:
1 | def bad(s): |
upper() 不改原串,只是返回新串。忘记"接住返回值"是字符串处理里最高频的 bug。正确写法是 s = s.upper() 或 return s.upper()。
本质一句话:不可变 = 改即新建;可变 = 改即原地。分清两者,就避开了大半"值怎么不对了"的灵异事件。
二、别名与共享引用:盒子与指针模型
是什么:可变对象最坑的特性是别名(aliasing)——两个名字指向同一个对象。改其中一个,另一个"看"到的也变了,因为它们本来就是一个东西。
1 | a ──┐ |
1 | a = [1, 2, 3] |
输出:
1 | [1, 2, 3, 4] |
判定是否共享:用 a is b(比身份/内存地址)才是真问"是不是同一个对象";== 只比值是否相等。两个内容相同的不同列表,== 为真但 is 为假。
坑:函数参数也是按"引用"传的(准确说传的是对象)。你把一个列表传进函数,函数里对它 append,调用者的原列表也被改了——这常被当成"凭空出现的改动"。若不想被改,进函数时先 .copy()。
本质一句话:可变对象的别名 = 多个名字共享同一块内存;is 看身份、== 看值,想独立就得显式复制。
三、容器:dict 与 set
是什么:dict 是键值对(哈希表),set 是无序不重复集合,二者都是可变容器。
1 | d = {"name": "Ada", "age": 36} |
输出:
1 | Ada |
坑:dict 用 [] 访问不存在的键会抛 KeyError。安全取法是用 d.get(key, default),键不存在时返回默认值而非崩溃。这是写健壮代码的基本功。
四、Link:CS61A 经典递归链表
是什么:Link 是 CS61A 自造的递归链表——每个节点由 first(首元素)和 rest(指向下一个 Link,或 Link.empty 表示结尾)组成。它和篇二讲的内置 list 不同,是自己定义的数据结构。
1 | class Link: |
输出:
1 | Link(1, Link(2, Link(3))) |
它是递归数据结构:每个 rest 又是一个 Link,天然和递归遍历配对。比如"把链表里每个数翻倍":
1 | def double(ll): |
坑:判断"链表到头"必须用 is Link.empty(身份比较),不能用 == 或判断 None——empty 是个哨兵对象,语义上是"类型层面"的结束标记。
本质一句话:Link 是"首元素 + 指向下一个的递归结构",遍历用递归最自然,结尾用哨兵 empty 标识。
五、Tree:分支的递归结构
是什么:Tree 有一个 label(根的值)和若干 branches(子树的列表),是比链表更一般的递归结构。
1 | class Tree: |
输出:
1 | 3 [1, 2] |
坑:__init__ 的默认参数 branches=() 用了不可变元组——这是对的。若写成 branches=[](可变默认参数),所有没传分支的 Tree 会共享同一个列表,造成跨实例污染(参见篇二 lambda 之外的另一个"可变默认参数"大坑)。
本质一句话:Tree = 根标签 + 子树列表的递归结构;可变默认参数要用不可变占位,防共享污染。
小结
- 不可变(int/str/tuple):改即新建;可变(list/dict/set/实例):改即原地。
- 别名让多个名字共享同一对象,改一处影响多处;
is比身份、==比值,独立需显式复制。 - dict 用
get防KeyError;set 自动去重。 - Link / Tree 是递归结构,遍历用递归最自然;结尾哨兵用
is判断,默认参数用不可变。

