查看: 144|回复: 0

Python可变与不可变类型:赋值传参陷阱与id验

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
同样是把一个变量赋值给另一个变量,整数和列表的表现却完全不同:前者互不影响,后者会跟着一起变。这不是 Python 的 bug,而是可变类型与不可变类型这套机制在起作用。本文从 id() 验证入手,讲清赋值、函数传参、默认参数、is 与 ==、可哈希性等几个最容易踩坑的地方,并给出可落地的规避写法。

先看两段现象相反的代码:
  1. a = 100
  2. b = a
  3. a = 200
  4. print(b)  # 100
复制代码
  1. lst1 = [1, 2, 3]
  2. lst2 = lst1
  3. lst1.append(4)
  4. print(lst2)  # [1, 2, 3, 4]
复制代码

第一段里 b 没有跟着变,第二段里 lst2 跟着变了。原因在于 Python 的变量不是装数据的盒子,而是贴在对象上的标签。赋值 a = obj 只是把标签贴到 obj 上,不拷贝数据。后续操作到底是“换了一个对象”还是“在同一个对象上改”,决定了另一个标签看到的内容会不会变。

一、用 id() 判断对象是否被重建

id() 返回对象的唯一标识(CPython 中就是内存地址),是观察对象有没有被重新创建的最直接工具。
  1. x = 100
  2. print(f'x={x}, id={id(x)}')
  3. x = x + 1
  4. print(f'x={x}, id={id(x)}')
复制代码

执行后会发现两次 id 不同。x + 1 创建了一个新整数对象,x 只是转而指向了它,原来的 100 对象并没有被“改”成 101。
  1. lst = [1, 2, 3]
  2. print(f'lst={lst}, id={id(lst)}')
  3. lst.append(4)
  4. print(f'lst={lst}, id={id(lst)}')
复制代码

这次 id 没变。append 直接在原列表上追加,没有创建新列表。字符串的“修改”同样会生成新对象:
  1. s = 'hello'
  2. print(f'原始: id={id(s)}, s={s}')
  3. s = s + ' world'
  4. print(f'拼接后: id={id(s)}, s={s}')
  5. s = s.upper()
  6. print(f'大写后: id={id(s)}, s={s}')
复制代码

三次输出的 id 都不一样。字符串的拼接、upper()、replace()、切片等操作,返回的都是新字符串,原字符串保持不变。

二、Python 中两类对象的具体划分

不可变类型包括:int、float、complex、bool(int 的子类)、str、bytes、tuple、frozenset、NoneType。可变类型包括:list、dict、set、bytearray,以及自定义类的实例(默认可变)。

对不可变类型做下标赋值或原地修改会直接报错,这一点可以用来确认类型归属:
  1. s = 'hello'
  2. s[0] = 'H'   # TypeError: 'str' object does not support item assignment
  3. b = b'hello'
  4. b[0] = 72    # TypeError: 'bytes' object does not support item assignment
  5. t = (1, 2, 3)
  6. t[0] = 100   # TypeError: 'tuple' object does not support item assignment
  7. fs = frozenset([1, 2, 3])
  8. fs.add(4)    # AttributeError: 'frozenset' object has no attribute 'add'
复制代码

可变类型则可以原地改:
  1. lst = [1, 2, 3]
  2. lst[0] = 100
  3. lst.append(4)
  4. d = {'a': 1}
  5. d['a'] = 2
  6. d['b'] = 3
  7. s = {1, 2, 3}
  8. s.add(4)
  9. s.remove(1)
  10. ba = bytearray(b'hello')
  11. ba[0] = 72
  12. print(ba)  # bytearray(b'Hello')
  13. class Person:
  14.     def __init__(self, name):
  15.         self.name = name
  16. p = Person('小明')
  17. p.name = '小红'
复制代码

从可否作为字典键的角度看:int、float、complex、bool、str、bytes、frozenset、NoneType 不可变且可哈希,可以直接当键;tuple 本身不可变,但只有元素全部可哈希时整体才可哈希;list、dict、set、bytearray 可变更不可哈希,不能当键;自定义类实例默认可变、默认可哈希,能否当键取决于 __hash__ 的实现。

三、不可变类型的“修改”到底发生了什么

字符串方法都是返回新对象,原对象不受影响:
  1. original = 'Hello'
  2. upper_version = original.upper()
  3. print(original, upper_version)  # Hello HELLO
  4. replaced = original.replace('H', 'J')
  5. print(original, replaced)  # Hello Jello
  6. sliced = original[1:4]
  7. print(sliced)  # ell
复制代码

原字符串始终没变。这也解释了新手最常见的一个无效操作:
  1. text = ' hello '
  2. text.strip()
  3. print(f'无效strip后: "{text}"')  # 还是 ' hello '
  4. text = text.strip()
  5. print(f'有效strip后: "{text}"')  # 'hello'
复制代码

strip() 返回的是新字符串,不接住返回值等于白做。整数同理,x += 5 等价于 x = x + 5,每次都会产生新对象。需要注意的是小整数缓存:Python 会缓存 -5 到 256 的整数,所以在这个范围内值相同的两个变量用 is 比较可能得到 True,超出范围通常为 False。
  1. a = 256
  2. b = 256
  3. print(a is b)  # True,缓存范围内
  4. a = 257
  5. b = 257
  6. print(a is b)  # 通常 False,超出缓存范围
复制代码

元组的不可变是浅层的,这一点最容易误解:
  1. t2 = ([1, 2], [3, 4], 5)
  2. print(f'修改前: {t2}, id={id(t2)}')
  3. t2[0].append(999)
  4. print(f'修改后: {t2}, id={id(t2)}')
复制代码

元组本身的 id 不变,但里面的列表内容变了。元组保证的是 t2[0] 永远指向同一个列表对象,并不保证那个列表的内容不变。也正因为如此,包含可变元素的元组不可哈希,拿它当字典键会抛出 TypeError: unhashable type: 'list'。

四、可变类型的原地修改与 += 的差异

列表的常用操作几乎都是原地完成,id 全程不变:
  1. lst = [1, 2, 3]
  2. original_id = id(lst)
  3. lst.append(4)
  4. lst.extend([5, 6])
  5. lst.insert(0, 0)
  6. lst.remove(2)
  7. lst.pop()
  8. lst.sort()
  9. lst.reverse()
  10. lst[0] = 100
  11. lst[1:3] = [20, 30]
  12. del lst[2]
  13. print(id(lst) == original_id)  # True
复制代码

但 + 和 += 的行为并不一样:
  1. lst2 = [1, 2, 3]
  2. original_id = id(lst2)
  3. lst2 = lst2 + [4]
  4. print(id(lst2) != original_id)  # True,新建了列表
  5. lst3 = [1, 2, 3]
  6. original_id = id(lst3)
  7. lst3 += [4]
  8. print(id(lst3) != original_id)  # False,等价于 extend,原地修改
复制代码

字典和集合也是同样的规则,赋值、update、del、pop、add、remove、discard 都不改变对象 id:
  1. d = {'a': 1, 'b': 2}
  2. original_id = id(d)
  3. d['c'] = 3
  4. d['a'] = 100
  5. d.update({'d': 4})
  6. del d['b']
  7. d.pop('c')
  8. print(id(d) == original_id)  # True
  9. s = {1, 2, 3}
  10. original_id = id(s)
  11. s.add(4)
  12. s.remove(1)
  13. s.update({5, 6})
  14. s.discard(2)
  15. print(id(s) == original_id)  # True
复制代码

五、赋值与函数传参:贴标签与对象引用传递

理解赋值行为的关键是“贴标签”模型:
  1. a = 100
  2. b = a
  3. a = 200
  4. print(b)  # 100
  5. lst1 = [1, 2]
  6. lst2 = lst1
  7. lst1.append(3)
  8. print(lst2)  # [1, 2, 3]
复制代码

对不可变对象,a = 200 只是让 a 改贴到新对象上,b 仍然指向 100。对可变对象,lst1 和 lst2 是同一张标签贴在同一个列表上,通过任意一个标签做原地修改,另一个都会看到。

函数传参也是同一套机制。Python 既不是值传递也不是引用传递,而是对象引用传递(pass by object reference),也叫 call by sharing。参数拿到的是对象的引用,但参数本身和外层变量是两个独立的标签。因此:
  1. def modify_int(n):
  2.     n = 999
  3.     print(f'函数内: n={n}, id={id(n)}')
  4. x = 100
  5. modify_int(x)
  6. print(f'调用后: x={x}, id={id(x)}')  # 100,不受影响
复制代码
  1. def modify_list(lst):
  2.     lst.append(999)
  3. my_list = [1, 2, 3]
  4. modify_list(my_list)
  5. print(my_list)  # [1, 2, 3, 999],被改了
复制代码

真正的分水岭是“重新赋值”还是“原地修改”:
  1. def reassign(lst):
  2.     lst = [4, 5, 6]
  3.     lst.append(7)
  4. original = [1, 2, 3]
  5. reassign(original)
  6. print(original)  # [1, 2, 3],参数重新赋值后切断了联系
  7. def mutate(lst):
  8.     lst.append(4)
  9.     lst[0] = 100
  10. original = [1, 2, 3]
  11. mutate(original)
  12. print(original)  # [100, 2, 3, 4],原地修改对外可见
  13. def mixed(lst):
  14.     lst.append(99)
  15.     lst = ['新', '列表']
  16.     lst.append('更多')
  17. original = [1, 2, 3]
  18. mixed(original)
  19. print(original)  # [1, 2, 3, 99],只看到第一步行内修改的效果
复制代码

默认参数陷阱就建立在这套规则上:
  1. def add_user_bad(name, roles=[]):
  2.     roles.append('user')
  3.     roles.append(name)
  4.     return roles
  5. print(add_user_bad('Alice'))  # ['user', 'Alice']
  6. print(add_user_bad('Bob'))    # ['user', 'Alice', 'user', 'Bob']
复制代码

默认值 [] 只在函数定义时求值一次,所有不传 roles 的调用共享同一个列表,上一次的数据就串到了下一次。正确做法是用 None 作为哨兵,在函数体内创建新列表:
  1. def add_user_good(name, roles=None):
  2.     if roles is None:
  3.         roles = []
  4.     roles.append('user')
  5.     roles.append(name)
  6.     return roles
  7. print(add_user_good('Alice'))  # ['user', 'Alice']
  8. print(add_user_good('Bob'))    # ['user', 'Bob']
复制代码

结论很直接:不要把可变对象当作函数默认参数。

六、is 与 ==,以及可变对象为什么不能当字典键

== 比较值是否相等,is 比较是不是同一个对象(id 是否相同)。
  1. a = 'hello world'
  2. b = 'hello world'
  3. print(a == b)  # True
  4. print(a is b)  # 可能 True 可能 False,取决于字符串驻留
  5. lst1 = [1, 2, 3]
  6. lst2 = [1, 2, 3]
  7. print(lst1 == lst2)  # True
  8. print(lst1 is lst2)  # False
  9. x = None
  10. print(x is None)  # True,None 是单例
复制代码

不可变类型值相同不一定是同一对象,存在字符串驻留和小整数缓存;可变类型即使值相同也几乎一定不是同一对象。判断 None 一律推荐用 is None。

可变对象不能作为字典键:
  1. d = {[1, 2]: 'value'}  # TypeError: unhashable type: 'list'
复制代码

原因是字典依赖哈希值定位条目。如果列表能当键,先存入 [1, 2],再用同一个对象 append(3) 改成 [1, 2, 3],哈希值随之变化,原来的条目就再也找不到了。不可变类型没有这个问题:
  1. d = {(1, 2): 'value'}
  2. d = {frozenset([1, 2]): 'value'}
  3. d = {'key': 'value'}
  4. d = {42: 'value'}
复制代码

七、对外暴露可变数据时返回副本

类内部持有的列表如果直接返回引用,外部代码就能绕过类的方法改掉内部状态:
  1. class Team:
  2.     def __init__(self):
  3.         self._members = []
  4.     def get_members_bad(self):
  5.         return self._members
  6.     def get_members_good(self):
  7.         return self._members.copy()
  8.     def add_member(self, name):
  9.         self._members.append(name)
  10. team = Team()
  11. team.add_member('Alice')
  12. members = team.get_members_bad()
  13. members.append('Hacker')
  14. print(team._members)  # ['Alice', 'Hacker'],内部状态被污染
  15. team2 = Team()
  16. team2.add_member('Alice')
  17. members = team2.get_members_good()
  18. members.append('Hacker')
  19. print(team2._members)  # ['Alice'],内部状态安全
复制代码

只读数据则优先用不可变类型传递。用元组或 frozenset 传配置,任何试图修改的操作都会抛 TypeError,这比在函数里到处加防御性拷贝更省事,也更容易在早期暴露越界修改。

八、Python 这样设计的取舍

不可变类型的实际优势主要有三点。第一是可哈希,能作为字典键和集合元素,这是缓存、去重等场景的基础。第二是线程安全,多个线程同时读取同一个不可变对象无需加锁。第三是行为可预测,函数拿到一份元组配置可以放心使用,不必担心被“远程修改”,字符串驻留等解释器优化也依赖不可变性。

可变类型的优势则是内存效率:需要大批量增删改时,原地操作不必反复创建新对象,列表、字典、集合的常用方法都是为此设计的。

落到日常编码,几条规则值得固定下来:判断值相等用 ==,判断是否同一对象用 is,判断 None 用 is None;函数默认参数不要写 []、{} 或 set(),统一用 None 哨兵;返回类内部可变属性时给副本;传递且不应被修改的数据用元组或 frozenset;对共享的列表优先使用 append、extend 等原地方法,避免无意间用 + 或重新赋值切断引用关系。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-22 13:47 , Processed in 0.023367 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部