刚入群时有个朋友问我“为什么我在函数里给列表append了一个元素外面的列表也跟着变了Python 不是说参数是拷贝吗” 这个问题我听了不下二十遍。很多人学 Python 学到“可变与不可变对象”这一块靠的是背结论列表是可变的元组是不可变的。背完就忘实战里继续翻车。其实只要从内存模型这个底层视角去看这些问题根本不需要背。id()、is、copy这三个工具就能让你把绝大多数坑踩得明明白白。这篇博文我会把可变与不可变对象从底层内存机制开始拆开讲再配合实战里的最佳实践和排查记录希望能帮你彻底打通这一块。1. 一切从“名字和对象”开始Python 的内存模型1.1 变量不是盒子是便利贴我在带新人时发现一个特别有意思的现象大家都默认变量是“盒子”a 1就是把数字 1 装进一个叫a的盒子里。这个类比在多数语言里没问题但在 Python 里几乎是在给自己埋雷。Python 里更准确的比喻是对象住在堆内存里变量只是一张贴在对象上的“便利贴”。每次你执行a 1Python 的底层干的事情其实是在内存中创建一个 PyObject 结构体这个结构体里存着对象的类型、引用计数和值把变量名a作为一个引用reference绑定到这个对象上。所以a 1和a b 1的本质都是让多个“便利贴”贴到同一个对象上。只要对象还在引用计数不为 0它就不会被垃圾回收GC清理。这也是 Python 和 C/C 的思维方式差异最大的一点。C 里的变量名对应一块真实的内存空间你把a赋值给b是把数据“复制”了一份过去Python 里的赋值只会增加一个指向同一对象的引用不会复制底层数据。1.2 用 id() 和 is 直接“看见”内存地址Python 官方文档里说id()返回对象的“身份”identity这个身份在对象存活期间是唯一的。到 CPython 层面id()返回的就是对象在堆内存中的地址。这个函数简直是教学神器。你可以做一个简单的实验a [1, 2, 3] b a print(id(a), id(b), a is b) # 两个地址完全相同返回 True c [1, 2, 3] print(id(a), id(c), a is c) # 地址不同返回 False输出会显示a和b贴在了同一个列表对象上而c是在内存另一处新建的列表。这就是“同一性”identity和“相等性”equality的区别比较两个对象的值是否相等is比较两个变量是不是指向同一个对象。实战里如果你需要用is去判断对象身份常见的是与None比较if data is None: pass因为None是单例对象全程序只有一个用is判断既快又安全。注意新手最容易犯的错误是拿is去判断整数、字符串的相等关系。由于 Python 对小整数和部分字符串做了缓存驻留机制有时a is b会意外返回True但这不保证永远如此不能依赖这个行为做业务判断。2. 不可变对象拆解从整数缓存到字符串驻留2.1 小整数缓存池为什么 256 和 257 的世界完全不同先看一个经典的新手学 Python 必然见过的场景a 256 b 256 print(a is b) # True c 257 d 257 print(c is d) # False很多初学者看到这个结果会觉得 Python “不讲武德”。其实道理不复杂CPython 在启动时会在内存里预先创建好[-5, 256]这一串小整数对象所以你在任何地方写256拿到的都是同一个对象的引用id当然相同。257不属于缓存范围Python 会在运行到赋值语句时新建一个对象所以c和d是两个不同的“便利贴”贴在不同对象上。这给实际开发带来的启示是什么呢一句话不要依赖整数对象的身份判断业务逻辑。比如你写if a is 257:在小整数范围内可能歪打正着换成 257 就随时可能返回False。老老实实用判断值才是正解。2.2 字符串驻留机制为什么有些字符串“相等即相同”字符串也有类似机制叫“字符串驻留”intern。当你写a hello的时候如果这个字符串是“适合驻留”的简单来说就是符合标识符命名规则的字符串字面量CPython 不会直接构造新对象而是先去驻留池里找一遍找到就直接复用。所以下面的代码会输出Truea hello_world b hello_world print(a is b) # 极大概率是 True但如果你这样写a hello * 3 b hello * 3 print(a is b) # 往往是 False拼接出来的字符串不再走驻留机制就会各自创建对象。从这里能看到一个很关键的实战原则永远不要用is判断字符串内容。因为驻留机制完全不透明不同长度、不同内容、不同拼接方式都会导致行为变化。你只要用判断内容就够了。2.3 元组和 frozenset不可变容器却可能装着会变的东西数字、字符串、布尔值这些“扁平”不可变对象好理解真正让很多人栽跟头的是元组tuple和 frozenset。元组本身不可变它一旦创建就不能增加、删除、修改元素。但是注意如果元组里装的可是一个列表那这个列表的内容可以被改变。t ([1, 2], 3) t[0].append(4) print(t) # ([1, 2, 4], 3)元组没有变但元组“指向”的列表变了。Python 的元组存的是元素的引用它保证的是这个引用指向什么对象不再改变却不保证这个对象自身不会变化。这个细节对设计模式很重要。比如你想用一个元组作为字典的 key这个元组里就不能包含任何可变对象否则哈希值可能会变字典 lookup 就会出问题。经验提醒面试里很多人回答“元组不可变”时只说第一个层面如果能补一句“元组的不可变是指引用不可变不是引用对象的深层内容不可变”会瞬间拉高你的深度。3. 可变对象的内部世界列表、字典、集合它们为什么“难搞”3.1 引用结构列表其实是一个“引用数组”列表的底层结构在 CPython 里是一个PyObject**数组——经历过 C 语言的人看到这个应该秒懂。列表本身是一个容器它里面存储的并不是数据本体而是一串指向真实对象的指针。这就解释了列表的两个经典表现第一当你append一个变量时你放进列表的是引用不是副本。所以后续修改变量列表里的元素也会跟着变。x [1] lst [] lst.append(x) x.append(2) print(lst) # [[1, 2]]第二当你把一个列表赋值给另一个变量时两个变量共享同一个列表对象。修改任意一个另一个也受影响。这种事情在 C 语言里叫“指针共享”在 Java 里叫“引用共享”到了 Python 里很多人却忘了这回事。3.2 “就地修改”和“重新绑定”的区别可变对象之所以能在很多场景下提升效率是因为它支持就地修改in-place。list.append()、dict.update()、list.sort()这些操作不会创建新对象直接在原对象的内存空间里调整内容。这里必须区分两个方法很多经验不足的开发者在这里踩过坑a [3, 2, 1] print(id(a)) a.sort() # 就地排序id 不变 print(id(a)) # 地址一样 b sorted(a) # 排序后返回一个新列表id 变了 print(id(b)) # 新的地址同理list.sort()是就地操作sorted()返回新列表对列表是就地扩展对字符串和整数则会产生新对象。小白经常混淆是因为“同一个语法符号”在不同类型上表现完全不同。这正是不可变对象和可变对象的分水岭不可变对象的所有操作都会生成新对象可变对象的很多操作可以在原对象上完成。3.3 函数参数的真相传的是对象引用不是值也不是原变量再回到开头那个 append 问题。Python 的函数参数传递既不是经典的“值传递”也不是 C 那种“引用传递”严格来说叫“对象引用传递”pass by object reference或“传对象”。解释清楚这个概念只需要一个例子def add_one(x): x x 1 def append_item(lst): lst.append(4) a 1 add_one(a) print(a) # 1数字不可变函数里重新绑定不影响外部 b [1, 2, 3] append_item(b) print(b) # [1, 2, 3, 4]列表可变函数里就地修改影响外部第一函数里x x 1用新对象替换了参数 x 的绑定外部的a始终指向原来的数字 1。第二个函数里参数lst和外部b贴的是同一个列表对象就地修改当然会传导出去。这个理解至关重要。比如你要写一个“把列表里所有偶数翻倍”的函数如果你用就地修改那外部变量会跟着变如果你不希望影响外部就得新建列表返回。4. 共享、拷贝与赋值边界模糊是坑的重灾区4.1 共享引用的连锁反应我见过最经典的“共享引用事故”是初始化嵌套列表matrix [[]] * 3 matrix[0].append(1) print(matrix) # [[1], [1], [1]]很多人第一反应会以为这是 Python 的 bug。其实[[]] * 3是把同一个空列表对象的引用复制了三次三个位置指向同一个列表。给一个位置 append看起来三个位置都变了。正确的写法是matrix [[] for _ in range(3)]列表推导式每次循环都会创建一个新列表对象三个位置指向三个独立对象。这一行代码的差异经常就是生产环境线上事故的源头。4.2 浅拷贝和深拷贝两个工具的工作边界完全不同拷贝这个动作如果不刻意指定Python 不会自动发生。b a是共享不是拷贝。如果你确实想得到一个独立的副本需要用到copy模块import copy a [[1, 2], [3, 4]] b a.copy() # 浅拷贝 c copy.deepcopy(a) # 深拷贝 a[0].append(99) print(b) # [[1, 2, 99], [3, 4]] —— 外层独立了内层还在共享 print(c) # [[1, 2], [3, 4]] —— 完全独立浅拷贝只复制最外层容器容器内的元素仍然指向原来的对象深拷贝会递归复制所有层级的对象。判断该用哪个有一条经验法则列表里的元素是简单不可变对象数字、字符串浅拷贝通常就够了列表里嵌套了可变对象而且你会去修改内层结构那就必须深拷贝。深拷贝有成本也有潜在风险比如对象里有循环引用、有不允许拷贝的句柄资源所以在工程里不是越深越好而是要看清楚你到底需要哪一层独立。4.3 赋值、比较与身份三个概念别混成一团我在代码评审里经常看到有人写if result ! None:这不是语法错误但确实没用is更符合 Python 的习惯。而且更重要的问题在“比较”和“身份”的混淆。举例来说class User: def __init__(self, name): self.name name u1 User(alice) u2 User(alice) u1 u2 # False默认比较的是对象身份如果不重写__eq__两个内容完全一样的对象用比较也返回False因为默认就是is的同义替换。要为一个业务类定义相等语义你需要重写__eq__甚至契约上还要重写__hash__。这里我不展开但它提醒我们相同的值不一定是同一个对象同一个对象一定具有相同的值。5. 实战最佳实践用好可变与不可变的边界5.1 默认参数陷阱为什么不能用可变对象当默认值Python 函数定义时的def f(lst[])是一个著名的陷阱但有多少人真的理解它背后的机制函数定义是一个“执行”过程默认参数在定义时就被计算并绑定到函数对象上了。所以lst[]创建的那个空列表会成为函数对象的属性被所有不传该参数的调用共享。def add_item(item, lst[]): lst.append(item) return lst print(add_item(1)) # [1] print(add_item(2)) # [1, 2]第二个调用拿到了上一次的列表这符合我们前面讲的“默认参数对象只创建一次”违反直觉但机制清楚。标准写法是def add_item(item, lstNone): if lst is None: lst [] lst.append(item) return lst这里None是不可变单例对象安全无害然后每次调用在函数内部创建一个新列表。可以说不可变对象当默认值是一个默认的、几乎不假思索的纪律。5.2 类变量与实例变量可变对象放在类上等于全局共享Python 类的属性和实例属性也存在类似边界问题class Dog: tricks [] # 类变量 def add_trick(self, trick): self.tricks.append(trick) d1 Dog() d2 Dog() d1.add_trick(roll over) print(d2.tricks) # [roll over]所有Dog实例共享同一个tricks列表因为类属性是通过类对象访问的实例属性查找链路会先从实例命名空间找找不到再往类命名空间找。如果希望每个实例独有一份应该放在__init__里通过self.tricks []初始化。这个模式还有一个好处避免以后你在某个实例上盲目赋值self.tricks覆盖掉类变量时意外改变所有实例的行为。牢记一句话可变对象不要直接放在类变量里当共享状态除非你有意实现单例、注册表或全局配置。5.3 字符串拼接的性能陷阱不可变对象拼接为什么会慢字符串是不可变的因此每次用拼接都会创建一个全新的字符串对象。这在循环里会带来灾难性的时间复杂度。s for i in range(10000): s str(i) # 每次循环都新建一个字符串这段代码的时间复杂度近似 O(n²)因为字符串长度在变大每次都要复制之前的所有字符。更推荐的做法是使用列表收集再 joinparts [] for i in range(10000): parts.append(str(i)) s .join(parts)原因也不难解释join会预先计算所有字符串的总长度一次性分配内存然后逐个填充而每次都要重新分配、复制。这个优化不是说让你在每处拼接都用join小规模拼接用完全没毛病。但它背后反映的正是不可变对象的本质不可变对象每一次“修改”都是在制造新对象大量制造新对象就会带来分配和复制的开销。5.4 字典 Key 选择不可变对象为什么是“天然”的键字典的 key 需要被哈希哈希功能严重依赖对象的不可变性如果对象在成为 key 之后发生了变化它的哈希值也会变字典将无法通过原来的哈希值找到这个条目破坏字典的“key 不重复”约束。所以 Python 要求字典的 key 必须是可哈希的可变对象默认不可哈希。元组是可哈希的因此可以作为 key但元组里若是嵌套了列表照样不能哈希。列表、字典、集合则直接不可哈希。这在工程上的指导意义需要把复杂数据当 key 时把它转成元组或frozenset再进字典。比如把“一组标签”作为 key不应该用set而应该用frozensetcache {} tags frozenset([python, memory]) cache[tags] some data这类代码在缓存设计、数据去重、记忆化递归里都特别常见。5.5 防御外部输入对外提供数据时默认拷贝引用吗写 API 或模块时经常需要把内部列表返回给调用方。如果没有刻意复制外部调用方可以直接append或修改元素污染内部状态。class DataStore: def __init__(self): self._items [] def get_items(self): return list(self._items) # 返回副本保护内部数据如果在性能敏感的场景里你觉得拷贝太贵那至少要在文档里明确声明“返回的是内部引用请勿修改”。但更稳妥的做法永远是返回副本或者返回copy.deepcopy。这种“所有权意识”很像 Rust 语言里的 Borrow 和 OwnershipPython 虽然没有编译器强制但你写出这种边界意识代码会明显更健壮。6. 常见问题与排查记录自查表6.1 灵活用 id() 快速判断共享还是新对象排查可变/不可变问题第一个手段永远是打印id()。看到两个变量id一样它们就是共享不一样就是两个对象再怎么改也互不影响。我自己的排查套路一般是三步打印关键对象的id()定位到底是几个对象观察是否发生了就地修改找出是哪个函数对对象做了 append/update/sort判断这个就地修改是有意还是无意如果是要保持外部不可变就改用新建对象的方式。6.2 几个高频问题对照表常见问题根因推荐方案函数里 append 影响外部列表对象引用传递 就地修改用list()复制再处理或改用新建列表返回[[]] * 3产生三个嵌套空列表浅层引用复制三个位置指向同一个列表对象用列表推导式[[] for _ in range(3)]默认参数在多次调用间保留了上次结果默认参数在函数定义时创建一次被所有调用共享默认参数改为None函数内部再初始化浅拷贝后内层列表仍是共享的浅拷贝只复制外层容器需要完全独立时用copy.deepcopy字符串循环拼接越拼越慢不可变对象每次拼接都生成新对象改用list.append.join元组里的列表修改后整个元组不能哈希元组“引用不可变”不等于“内容不可变”内部也使用不可变对象如嵌套元组而不是列表6.3 业务场景里的判断准则在日常编码里我会用一个非常粗但有效的心智模型做判断如果一段数据在生命周期内不会变化优先用不可变对象如果它需要频繁增删改或者会作为“共享容器”被多处代码持有那要明确可变对象的共享边界。比如配置类数据、常量映射可以使用MappingProxyType或元组来保证不被修改状态列表、临时缓存则用列表和字典但通过深拷贝或返回副本的方式控制引用泄出。6.4 三个排查快捷键分享几个我长期使用的排查经验忘了某个方法会不会就地修改对象时直接打开 REPL 试一下打印修改前后的id()一秒钟出答案。遇到“多个地方的列表莫名其妙一起变”优先检查是不是哪个深处发生了浅拷贝复制而不是先怀疑并发。Python 解释器里很多“神秘共享”都是copy()或*操作引发的。在代码注释里明确标记“这里的列表会被外部共享修改勿随意 append”防止几个月后的自己踩坑。这种注释不是啰嗦是给未来的自己留路。写在最后我在项目里的一个体会我得承认可变与不可变对象这个概念学第一遍的时候我也觉得“不过如此”。真正把它刻进脑子是在一次线上数据错乱的排查里。当时一个配置列表被传给多个模块每个模块都往里追加了自己的配置项最后所有配置混在一起查了两天才定位到一个“只用 复制却没深拷贝”的操作。那之后我的习惯变了所有向外暴露的可变对象一律先拷一份再交出去能写成元组的地方尽量不用列表默认参数一律 None类变量永远不放可变容器。有些做法看起来多几行代码但换来的是不用在凌晨三点通过钉钉语音排查数据异常这笔账怎么算都划算。如果你读到这里建议你打开 REPL亲手做一下id()、is、浅深拷贝这几个实验再回去看看自己项目里那些列表和函数参数大概率能逮住一两条潜伏的“共享引用”bug。那可能就是这篇博文最大的价值了。
