KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
00 · 名字与对象 — keel 龙骨
类型:主题概念 · 应用课对应:01 章 · 数据结构:怎么选,以及可变性这颗雷
类型:主题概念 · 应用课对应:01 章 · 数据结构:怎么选,以及可变性这颗雷
这一页只回答四件事:它为什么会出现、它是什么、它长什么样、它怎么工作。
一、它为什么会出现
两个现象,都指向同一件事:
① b = a 之后改了 b,a 也跟着变了 —— 明明我只写了一次赋值
② 一个函数「什么都没传」,第二次调用却带着上一次的结果回来
程序里到处都要「给某个东西起个名字」:变量、参数、属性、容器里的元素。
如果每次赋值都把内容复制一份,两个名字之间就没有任何关系,上面两个现象都不该出现。
问题在于:Python 的 = 从来不是「把值放进去」,而是「让这个名字指向那个对象」。
复制还是不复制,由对象自己的性质决定,不由赋值这个动作决定。
二、它是什么
一句话:名字是贴在对象上的标签;赋值是把标签换个对象贴,不搬内容。
由此有两个直接后果,日常都要用:
=只改「谁指向谁」。 想让两个名字各拿一份内容,必须显式复制(list(x)、copy.deepcopy)。- 可变与不可变的分界,决定「改」会发生什么。 就地改(
append、sort)改的是那个对象,
所有指向它的名字都看得到;重绑(s = s + "d")只是换了对象,别的名字毫发无伤。
它不是这三样东西——三个都会给出错误预测:
- 不是「变量是盒子」。 盒子里装着值、换值只影响这个盒子。这个心智模型是上面两个现象的唯一来源。
- 不是「
is和==可以互相代替」。is问「是不是同一个对象」,==问「内容像不像」;
两个空列表可以相等,却永远不是同一个对象。 - 不是「不可变对象改不动」。 不可变说的是没有就地改的接口;
t += (3,)不报错,但它悄悄换了对象。
三、它长什么样
以下是 CPython 3.13.14(Windows)上的输出:
>>> a = [1, 2]
>>> b = a
>>> b.append(3)
>>> a, b
([1, 2, 3], [1, 2, 3])
>>> a is b
True
>>> c = [1, 2, 3]
>>> d = list(c)
>>> c is d, c == d
(False, True)
>>> s = "abc"
>>> id(s) == id(s + "d") # 字符串:重绑,换了对象
False
>>> lst = [1]
>>> before = id(lst)
>>> lst.append(2)
>>> id(lst) == before # 列表:就地改,还是同一个对象
True
>>> t = (1, 2)
>>> before = id(t)
>>> t += (3,) # 元组:看着像「改」,其实是重绑
>>> id(t) == before
False
读它有两个工具:id(obj) 问「这个对象此刻的编号」,obj is other 问「两个名字是不是同一个对象」。
用 is,不要用 id 比较。 对象一旦没人引用就会被回收,编号会被后来的对象拿去复用;is 由语言直接回答「是不是同一个」,不受回收影响。
四、它怎么工作
从一次赋值到「改出效果」,是四步:
① 造对象 先算出等号右边的对象(可能复用已有的,也可能是新建的)
② 绑名字 让左边的名字指向它;这个名字原有的绑定被替换掉
③ 就地改 对可变对象调用改内容的方法(append / update / sort)——绑定没动
④ 重绑 对名字再赋一次值,或写成 s = s + x / lst = lst + [y] 这类「造新对象」的写法
判断一件事会不会波及别人,只需要问一句:这一步落在 ③ 还是 ④?
可变默认参数踩的全是 ③:默认值在函数定义时就造了一个对象,绑在函数上,
之后每次调用拿到的都是同一个标签指向的那个对象。
>>> def bad(acc=[]):
... acc.append(1)
... return acc
>>> bad(), bad(), bad()
([1, 1, 1], [1, 1, 1], [1, 1, 1])
三次返回的是同一个列表,所以打印出来三份长得一样,而不是 [1]、[1, 1]、[1, 1, 1]。
五、它会带出哪些概念
- 可变性:可变才谈得上「就地改」,才有上面 ③ 与 ④ 的分叉。→ 应用课 01 章
- 默认参数:默认值只在定义时求值一次,所以可变默认值被所有调用共享。→ 应用课 02 章
- 浅拷贝与深拷贝:
list(x)只重绑最外面一层,内层对象仍然共享。→ 应用课 01 章 - 属性字典:类的属性存类上、实例的属性存自己的字典里——同样是「共享还是各一份」。→ 02 页
六、回到应用课
| 应用课里的那句话 | 现在你知道它为什么成立 |
|---|---|
| "可变类型当默认参数会被所有调用共享" | 默认值在定义时就造了一个对象并绑在函数上,每次调用拿到的是同一个对象 |
"改嵌套结构要用 deepcopy" |
list(x) 只重绑外面那层,内层还是同一批对象,改内层谁都能看见 |
| "元组是不可变的" | 准确说法是没有就地改的接口;t += (3,) 不报错,因为它做的是重绑不是改 |
这一页改变了你的哪个判断:以后看到「改了一处、另一处也变了」,第一件事是问这两个名字是不是指向同一个对象(is),而不是去怀疑缓存或全局状态;看到 def f(x=[])、def f(x={}),第一件事就是把它改掉。
自测题
a = [1]、b = a、b += [2]之后a是什么?把[1]换成(1,)再算一次,结果为什么不同?- 为什么
bad()连续三次返回同一个列表?要让每次调用都拿一份新的,有哪两种改法?