免费编程软件「python+pycharm」 链接:pan.quark.cn/s/48a86be2f...
一个让我困惑了三年的问题
刚学Python那年,我写了这样一段代码:
css
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4] 什么?a也变了?
print(b) # [1, 2, 3, 4]
我当时的心态是:我只改了b,为什么a也跟着变了?
更让我崩溃的是这个:
ini
x = 5
y = x
y = y + 1
print(x) # 5 ------ 这次x没变
print(y) # 6
同样都是赋值,为什么一种情况变了,另一种没变?
我当时在网上搜了很久,得到的答案都是"可变对象和不可变对象的区别"。但这个解释太抽象了,我背下来也理解不了。
直到有一天,我明白了Python赋值的本质:变量不是盒子,是标签。
这个比喻改变了一切。今天我把这个理解的过程分享给你。
重新理解"赋值":不是装东西,是贴标签
大多数人学编程时,脑子里都有一个"变量是盒子"的模型:
css
a = 5 # 把5放进a这个盒子里
b = a # 把a盒子里的5复制一份,放进b盒子
这个模型在大部分情况下能工作,但遇到列表、字典时就翻车了。
用"盒子模型"解释不了为什么b = a之后改b会影响a。
正确的理解方式:
- Python的变量不是盒子,而是便利贴(标签)
- 赋值不是在盒子里装东西,而是把标签贴到对象上
来看这个:
css
a = [1, 2, 3]
这句话的意思是:创建一个列表对象[1, 2, 3],然后把标签a贴在这个对象上。
css
b = a
这句话的意思是:把标签b也贴到a当前贴的那个对象上。
现在,a和b两个标签贴在同一个对象上。
css
b.append(4)
append是找到b标签贴着的那个对象,然后往里面加个4。因为a也贴在这个对象上,所以用a去看的时候,自然也能看到那个4。
这就是为什么改b会影响a------不是b和a有什么关系,而是它们本来贴的就是同一个东西。
验证一下:用id()看看对象地址
Python里有个内置函数id(),可以返回对象的唯一标识(可以理解为内存地址)。
我们来验证一下:
bash
a = [1, 2, 3]
print(id(a)) # 比如输出 140234567890
b = a
print(id(b)) # 输出同样的数字,说明指向同一个对象
b.append(4)
print(id(a)) # 还是那个数字,a还是贴在同一个对象上
再看不可变对象:
bash
x = 5
print(id(x)) # 比如 140234567123
y = x
print(id(y)) # 同样的地址
y = y + 1
print(id(y)) # 新的地址!跟x不一样了
print(id(x)) # 没变,还是原来的地址
关键区别在于:
- 列表是可变的,你可以修改对象本身(往里面加东西),对象还是那个对象,地址不变
- 整数是不可变的,
y = y + 1不是修改了原来的对象,而是创建了一个新对象(值为6),然后把标签y贴到这个新对象上
一句话总结:变量是标签,不是盒子。赋值就是贴标签。
这个理解能解释什么?
解释1:为什么函数参数传递这么"奇怪"
很多人觉得Python的函数参数传递很诡异,有时候函数内部能修改外部变量,有时候不能。
用"标签模型"一下就明白了:
python
def add_one(n):
n = n + 1
print(id(n))
x = 5
print(id(x)) # 地址A
add_one(x) # 地址B(新对象)
print(x) # 还是5
流程是这样的:
- 调用
add_one(x),参数n被贴上x当前贴的对象(值为5的那个对象) n = n + 1,计算5+1=6,创建新对象6,然后把标签n贴到新对象上- 函数结束,标签
n消失 - 标签
x从头到尾还是贴在5上,没有动过
再看列表的情况:
python
def add_item(lst):
lst.append(4)
print(id(lst))
my_list = [1, 2, 3]
print(id(my_list)) # 地址C
add_item(my_list) # 地址C(同一个对象)
print(my_list) # [1, 2, 3, 4] 变了
流程:
- 参数
lst贴上my_list当前贴的对象(列表[1,2,3]) lst.append(4),找到这个对象,往里面加东西- 对象还是那个对象,地址没变
- 函数结束,
lst标签消失,但my_list还是贴在同一个对象上,所以能看到修改
核心:函数参数传递的是对象的地址(标签的复制),不是对象的复制。
这就是为什么很多人说Python是"传对象引用"。
解释2:为什么两个列表的修改会互相影响
ini
original = [1, 2, 3]
copy = original # 这不是复制!是贴了两个标签
copy.append(4)
print(original) # [1, 2, 3, 4]
如果你想要真正的复制,需要创建一个新对象:
scss
original = [1, 2, 3]
copy = original[:] # 切片创建新列表
# 或者 copy = original.copy()
# 或者 copy = list(original)
copy.append(4)
print(original) # [1, 2, 3] ------ 没变
print(copy) # [1, 2, 3, 4]
切片original[:]创建了一个新的列表对象,里面的元素是原列表元素的引用(对于不可变对象没问题,对于嵌套列表要小心------这就是浅拷贝的问题)。
解释3:为什么a = b = 1能用
你可能写过这样的代码:
css
a = b = 0
用标签模型很好理解:创建对象0,然后把标签a和标签b都贴上去。
等价于:
css
a = 0
b = a # 把b也贴到a贴的那个对象上
解释4:为什么a, b = b, a能交换值
Python的交换写法很优雅:
css
a = 10
b = 20
a, b = b, a
print(a, b) # 20 10
背后发生了什么?
b, a先创建了一个元组(20, 10)(这是个临时对象),然后把这个元组里的值依次贴给a和b。
用标签模型理解:右边的表达式先计算出右边的对象(元组),然后把左边的标签一个个贴到对应的对象上。
所以交换不需要临时变量,因为本质是贴标签,不是倒腾盒子里的东西。
可变 vs 不可变:到底谁变了?
回到开头的困惑:为什么改b会影响a?
关键在于对象的类型:
| 类型 | 可变性 | 例子 | 修改对象本身 |
|---|---|---|---|
| 列表 | 可变 | [1,2,3] |
append(), extend(), pop(), 索引赋值 |
| 字典 | 可变 | {'a':1} |
dict['key']=value, update() |
| 集合 | 可变 | {1,2,3} |
add(), remove() |
| 整数 | 不可变 | 5 |
没有修改方法 |
| 浮点数 | 不可变 | 3.14 |
没有修改方法 |
| 字符串 | 不可变 | "hello" |
没有修改方法 |
| 元组 | 不可变 | (1,2,3) |
没有修改方法 |
| 布尔 | 不可变 | True |
没有修改方法 |
对于可变对象,你可以修改对象本身。贴在这个对象上的所有标签都会"看到"这个变化。
对于不可变对象 ,你无法修改对象本身。x = x + 1会创建新对象,然后把标签贴过去。其他标签不受影响。
有个办法能立刻判断:看操作有没有改变对象的内存地址(用id())。地址变了就是创建了新对象,地址没变就是修改了原对象。
bash
# 可变对象------原地修改
lst = [1,2,3]
print(id(lst))
lst.append(4)
print(id(lst)) # 一样的地址
# 不可变对象------创建新对象
s = "hello"
print(id(s))
s = s + " world"
print(id(s)) # 不一样的地址
几个让人防不胜防的坑
坑1:默认参数的陷阱
python
def add_item(item, my_list=[]):
my_list.append(item)
return my_list
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] ------ 意外!
print(add_item(3)) # [1, 2, 3]
你期望每次调用都是一个新的空列表,但实际用的是同一个列表对象。
原因:默认参数的值在函数定义时就被创建了。之后每次调用不传参数时,默认参数用的就是那个提前创建好的对象。
正确做法:
python
def add_item(item, my_list=None):
if my_list is None:
my_list = []
my_list.append(item)
return my_list
坑2:浅拷贝 vs 深拷贝
ini
original = [[1, 2], [3, 4]]
shallow = original[:] # 浅拷贝
shallow[0].append(99)
print(original[0]) # [1, 2, 99] ------ 里面的列表还是同一个!
浅拷贝只复制了外层容器,里面的元素还是原来的标签。如果你有一个嵌套结构(列表套列表),浅拷贝只解决一层。
深拷贝才能完全独立:
go
import copy
deep = copy.deepcopy(original)
deep[0].append(88)
print(original[0]) # 不受影响
经验法则:如果你的数据结构里只有不可变对象,浅拷贝够用。如果有嵌套的可变对象,考虑深拷贝。
坑3:把可变对象当字典的键
ini
d = {}
lst = [1, 2]
d[lst] = "value" # TypeError: unhashable type: 'list'
字典的键必须是不可变的(可哈希的)。因为如果键是可变的,改了它之后,字典就找不到这个键了。
所以列表不能当字典的键,但元组可以:
ini
d = {(1, 2): "value"} # 元组不可变,可以
一个面试题测试你的理解
猜猜下面这段代码的输出:
ini
def test(a, b):
a = a + 1
b.append(4)
return
x = 10
y = [1, 2, 3]
test(x, y)
print(x, y)
答案是:10 [1, 2, 3, 4]
x是整数(不可变),a = a + 1创建了新对象,不影响外面的xy是列表(可变),b.append(4)修改了对象本身,外面的y能看到变化
如果这个答案你想对了,恭喜你,你已经理解了Python赋值的本质。
一张图总结
想象你有一个白板,上面写着一个数字5和一张清单[1,2,3]。
不可变对象(整数5) :
- 你贴标签
x指向5 - 你贴标签
y也指向5 - 你让
y指向6(新建的)------x还是指向5,不受影响
可变对象(列表1,2,3) :
- 你贴标签
a指向这张清单 - 你贴标签
b也指向同一张清单 - 你在清单上加了一项
4------不管你用a还是b看清单,都能看到4
最后再说一句
我第一次理解"变量是标签"这个概念时,有种豁然开朗的感觉。之前觉得Python的赋值行为很"诡异",现在觉得它其实很一致、很简单。
所有Python的赋值都是贴标签,没有例外。
- 整数、字符串、列表、字典、对象------贴的规则都一样
- 区别在于你贴的对象是否允许被修改
- 可变对象可以原地改,不可变对象不能
这个理解能帮你少写无数个bug。
下次你再写b = a的时候,心里想的不应该是"把a的值复制给b",而是 "把b也贴到a贴的那个东西上" 。
就这一念之差,能救你无数次。