9 月 24 号那篇讲 Python 对象模型的文章,技术骨架是干净的。变量是名字绑到对象上,不是盒子装数值——y = x 之后两个名字打印同一个 id(),is 是 True;不可变类型被"修改"时是重绑到新对象,不是原地变更;可变默认参数只在函数定义那一刻创建一次,之后每次调用复用同一个列表对象;引用计数归零立即释放,环状引用交给分代 GC 兜底;-5 到 256 的整数在 CPython 启动时预先建好,全局复用。这些我都在自己机器上跑过,能复现,一条都不用打折。
直到我看见那句顺手带出来的话:错误的变量心智模型,导致了初学者大约 90% 的困惑 bug。
这次要拆的不是它技术部分对不对。这篇文章里同时出现了好几类数字,句法位置差不多,读者用同一种方式接收,但它们的证据等级差得很远。把这几种数字分开,比争论 90% 准不准有用得多。
测试范围先划一下:我不测"这个 90% 对不对",下面会说明为什么它在结构上就不具备被测的条件;我测的是这篇文章里哪些数字能复现、哪些数字连口径都指不出来。
能复现的那一类
整数缓存 -5 到 256,这条我复现了,而且是那种能给你确切答案的复现。逐行输入:
>>> a = 256
>>> b = 256
>>> a is b
True
>>> a = 257
>>> b = 257
>>> a is b
False
边界严丝合缝。但这个数字的口径得补上,不然它会被用坏。如果你把两个 257 写进同一个函数体、或者塞进同一行的多个赋值里,编译器会先做一次常量合并,让这两个名字指到同一个常量对象上,结果反过来变成 True。所以"缓存边界是 257"这句,只在"分开赋值、跨编译单元比较"这个前提下成立。换个写法就不成立。这不是 CPython 在耍人,是常量折叠在编译期先动了一次手,缓存机制还没来得及上场。
举这个例子是想说,一个能跑出确切答案的数字,适用范围依然要写清楚。256 和 257 这两个数我敢给,后面必须跟半句"在下面这种比较方式下"。省掉这半句,它照样骗人。
90% 缺的不是样本量,是分母
先看"困惑 bug"这个集合怎么界定。是所有初学者写出来的 bug 里跟对象模型有关的那部分?还是作者自己见过的、觉得跟心智模型有关的那部分?这两个集合大小能差一个数量级,文章没说是哪个。
更麻烦的是"错误的变量心智模型"这个变量,它待在人的脑子里,从外部不可观测。你能看见的只有一个人把 a = [1,2]; b = a; b.append(3) 之后 print(a) 的输出猜错了,你看不见他脑子里想的是盒子还是标签。拿一个外部不可观测的变量去解释一个可观测的结果,再给这个解释标一个百分比——这个百分比在测量上不成立。⚠️
这里得跟我表里记的那类裸数字区分一下。裸数字的问题通常是样本不够、没跑够次数,但它至少知道自己在测什么,口径清楚,补几次跑测就能往结论那边靠。这个 90% 不是样本不够,是它连测量对象是什么都没法定义。补再多样本也补不出来。
真要有人做这个测量,我会这么设计:先找一组人,用预测题分组——给一段 a = [1,2]; b = a; b.append(3); print(a),答 [1,2] 的归盒子模型组,答 [1,2,3] 的归标签模型组,这个分类外部可判定,不需要读心。然后追踪两组后续写出来的代码,按 bug 类型分桶:可变对象别名、不可变重绑、默认参数、浅深拷贝,各自占比多少。到这一步你才拿到一个能算百分比的东西。人数也得够——几十个人分两组,每个桶里可能只有个位数的事件(样本只有这么点,那个百分比小数点后一位全是噪声,先别当真)。
作者自己讲花了一周读 CPython 源码、翻 PEP、在终端里做实验。这恰恰说明那 90% 是顺手写的修辞,不是研究结论。一个愿意花一周抠源码的人,在这种数字上反而松了手,挺常见的。还有一层是传播路径:作者写"90% 的困惑来自这里"的时候,意思是"这是最主要的原因,你该先学这个",这是修辞。文本一离开作者,修辞就没了。第二手会写成"有研究表明 90% 的初学者 bug 源于错误的变量心智模型"。到这一步它已经变成论据,而且你很难反驳——源头那句话不带任何测量信息,你连它错在哪一步都指不出来。一个数字从修辞变成论据,只需要一次转述。
296 和 48
文章里还有一组:带 __dict__ 的实例,字典本身就占 296 字节,用 __slots__ 的实例整体只有 48 字节;建 100 万个对象,slots 版少用 40% 到 50% 内存。
方向我签收。我这边跑类似的对比,slots 版确实小一大截,量级对得上,光是没有 __dict__ 这一件事就能省掉一个字典的开销。
但绝对值我没复现出来,也不建议你引用我这边跑出来的——我这边也没有一个能交出去的精确数。评论区里有一条提得很准:CPython 3.12+ 之后,专用自适应解释器和内联缓存会改变运行时的内存行为。所以同一段代码在 3.10 和 3.13 上跑出来的字节数不一样;继承链上有没有 __weakref__ 不一样;属性个数不一样、有没有走到解释器的缓存路径,也不一样。296/48 是某个具体版本、某个具体类形状下的一张快照,不是"__slots__ 这个优化"的属性。同一篇文章里 40%-50% 那种区间表述反而是诚实的,它没假装自己有小数点后两位的精度。
还有一层更细的:296 是那个字典对象本身的大小,48 是 slots 实例整体的大小。严格说这不是同一个基准上的对比——一个是容器,一个是对象。方向没错,但两个数字并排写出来,读者脑子里会自动给它们划等号。这是我在这篇文章里唯一觉得数字被顺手放到了一起的地方。
评论区比正文值得读
页面底下 24 条评论。我的偏心话放这儿:评论区质量比正文高。
正文是给初学者铺路的,所以它得把内存清理讲成"引用计数和分代 GC 两种机制协作完成"。这话对,但太软。评论区里有人把循环 GC 的真实做法说清楚了——它不是去找环,而是从被跟踪容器的引用计数副本里,减去来自其他被跟踪容器的引用,剩下还大于零的,就是外部可达的根。这个描述比正文那句准一个档次,因为 CPython 的实现就是这么干的。
另一位 17 年经验的同行纠正了一个更常见的混淆:with open(...) 的清理不依赖引用计数归零,是上下文管理器确定性地触发的。引用计数归零和上下文管理器退出是两条不同的路,到达时机也不同,被初学者模型混在一起的频率,我怀疑比对象模型本身还高。
还有一条提醒 sys.getsizeof() 不递归统计嵌套对象——这句直接决定了上面那串字节数该怎么读。字典本身 296,装在里面那些键值对象不算;想知道一个嵌套结构总共占多少,得自己递归,或者干脆上 tracemalloc。
我自己翻的一次车
上周我写脚本想验证字符串驻留,随手拿了两个运行时拼出来的字符串做 is 比较,跑出来 False。我差点在草稿里写下"驻留对运行时构造的字符串不生效"这个结论。发出去之前回头查了一遍,问题在我自己构造的方式上:字面量在编译期就被折叠了,所以 'hello' is 'hello' 在同一个编译单元里常常是 True,而运行时拼出来的那两条走的是另一条路。换个构造方式,结果就变。
我第一版的问题不是拿错了一个结果,是只拿了一个结果就准备下结论。n=1 的观察配上一个确定性的语气,写出来跟结论长得一模一样。
顺带说一个我觉得最能说明"口径"两个字的例子,还是这篇文里的:sys.getrefcount(x) 返回的数,永远比实际引用数多 1。因为 x 当参数传进去的那一下,栈上又多了一个临时引用。你想数清楚一个东西被引用了几次,得先引用它一次。
这跟 90% 那种"变量压根不可观测"是两种不同的口径难题。90% 是没法测,这个是能测但读数要减一。两种都得在报告里写出来:不写第一种是编数字,不写第二种是数字看着精确、含义是错的。
置信标签。核心那条——"90% 这个数不该被引用"——高置信。这是结构性判断,不靠样本量堆,样本再多也堆不出来。
那组 296/48 和 40%-50%,方向大致对,绝对值别引用;版本、类形状、属性个数都在动,我的表里这一栏到现在也只有一个量级,没有能交出去的精确数字。评论区那几条机制描述比正文概括得准,这条中高置信,但我没逐条核过源码,只核了循环 GC 那条的实现思路,是对的。
这篇文章的技术内容本身建议照读,名字绑定和可变默认参数那两块,写得比我见过的多数教材清楚。里面带出来的百分比,读的时候在脑子里换成"作者见过的情况大概是这样",就差不多了。
那套整数边界和引用计数的脚本我整理一下会放出来。谁跑了数字对不上——尤其是 256/257 那条线在你的 Python 版本上是不是也这么断——告诉我。
