【转载】Python 对象引用与可变性
原文地址:https://mp.weixin.qq.com/s/20FP3gqwdJrxcCENVs115w
Python 对象引用与可变性
Python 中的变量都是引用式的,这个概念很容易在写代码的时候引入 bug,还不易察觉。这篇文章就是讲述 Python 中对象的引用和可变性,然而首先要抛弃变量是存储数据的盒子的传统观念。
变量不是盒子,是标签
Python 中对变量有一个形象的比喻:变量不是盒子,是标签。也就是说变量名都是对象的标注,不是一个盒子装着对象,贴了再多的标签,对象也只有一个。
用 c++ 的思想理解起来就是:Python 中对变量的赋值都是引用传递,而不是值传递。比如:
-
In [1]: aa = [ "a", "b", "c" ] -
-
In [2]: bb = aa -
-
In [3]: bb.append("d") -
-
In [4]: bb -
Out[4]: [ 'a', 'b', 'c', 'd' ] -
-
In [5]: aa -
Out[5]: [ 'a', 'b', 'c', 'd' ]
is 和 ==
Python 中的 is 比较的是对象的标识,可以理解成对象的地址;而 == 比较的是对象的值,可以理解成对象指向的值。
Python 中 is 运算符比 == 速度快,这是因为 is 不能重载,Python 不必寻找并调用其他特殊方法,直接比较对象的 id ;而 == 是语法糖,调用了 __eq__ 方法,所以 a == b 等同于 a.__eq__(b)。看个:
-
In [1]: a = [1, 2, 3] -
-
In [2]: b = a -
-
In [3]: c = [1, 2, 3] -
-
In [4]: a is b -
Out[4]: True -
-
In [5]: a is c -
Out[5]: False -
-
In [6]: a == b -
Out[6]: True -
-
In [7]: a == c -
Out[7]: True -
-
In [8]: id(a) -
Out[8]: 4553282696 -
-
In [9]: id(b) -
Out[9]: 4553282696 -
-
In [10]: id(c) -
Out[10]: 4553460264
在写代码的日常中,我们关注的大多是值,而不是标识,所以 == 的使用频率比 is 多得多。然而在变量和单例值比较时,推荐使用 is。所以在判断某变量的值是否为 None 时,推荐的写法是 a is None ,否定的写法是 a is not None 。
元组的相对不变性
Python 中元组的存在是以其不可变性为特征,一旦创建不可修改。但元组和其他集合一样保存的是对象的引用,也就是说虽然元组本身不可变,但若其保存的对象是可变的,元组内的元素就是可变的。所以,元组的相对不可变性指的就是, tuple 数据结构的物理内容(即保存的引用)不变,与引用的对象无关。
看个就知道了:
-
In [1]: a = (1, [1, 2]) -
-
In [2]: b = (1, [1, 2]) -
-
In [3]: a == b -
Out[3]: True -
-
In [4]: id(a[-1]) -
Out[4]: 4445861072 -
-
In [5]: a[-1].append(3) -
-
In [6]: id(a[-1]) -
Out[6]: 4445861072 -
-
In [7]: a == b -
Out[7]: False
深拷贝和浅拷贝
在 Python 中经常有拷贝的操作,然而这就是坑开始的地方。列表复制通常使用内置的 list 方法或直接 a = b[:] ,但默认的都是浅拷贝。浅拷贝的定义是:复制了最外层容器,副本中的元素是源容器中元素的引用。也就是说副本中的元素只是给原本中的元素贴上了标签,用的都是引用传递,无论原本还是副本中的对象有了修改,都会影响另一方。
用说话:
-
In [1]: father = [1, 2, [3, 4]] -
-
In [2]: sun = list(father) -
-
In [3]: father.append(5) -
-
In [4]: father -
Out[4]: [1, 2, [3, 4], 5] -
-
In [5]: sun -
Out[5]: [1, 2, [3, 4]] -
-
In [6]: father[2].append(6) -
-
In [7]: father -
Out[7]: [1, 2, [3, 4, 6], 5] -
-
In [8]: sun -
Out[8]: [1, 2, [3, 4, 6]] -
-
In [9]: sun.append(7) -
-
In [10]: father -
Out[10]: [1, 2, [3, 4, 6], 5] -
-
In [11]: sun -
Out[11]: [1, 2, [3, 4, 6], 7]
上面的中,
sun 是 father 的副本,但是只拷贝了 0 ~ 2 位,可以看出只有这三位的元素互相影响。
由于上面中拷贝的三位元素都是可变的,我们再看一个不可变的
:
-
In [1]: father = [1, 2, (3, 4)] -
-
In [2]: sun = list(father) -
-
In [3]: id(father[2]) -
Out[1]: 4397476104 -
-
In [4]: id(sun[2]) -
Out[2]: 4397476104 -
-
In [5]: father[2] += (5, 6) -
-
In [6]: id(father[2]) -
Out[3]: 4398347376 -
-
In [7]: id(sun[2]) -
Out[4]: 4397476104 -
-
In [8]: father -
Out[5]: [1, 2, (3, 4, 5, 6)] -
-
In [9]: sun -
Out[6]: [1, 2, (3, 4)]
看第 5 行,元组中 += 运算符相当于创建一个新的元组,然后赋值给 father,所以这时候两个列表中第 2 位的元素不再是同一个对象。
再说深拷贝,深拷贝的定义是:副本不共享内部对象的引用。一看定义就知道,这是我们大多情况需要的,而 copy 模块中的 copy 和 deepcopy 函数就是实现浅拷贝和深拷贝的。还用 father & sun 做:
-
In [1]: import copy -
-
In [2]: father = [1, 2, [3, 4]] -
-
In [3]: sun1 = copy.copy(father) -
-
In [4]: sun2 = copy.deepcopy(father) -
-
In [5]: father[2].append(5) -
-
In [6]: father -
Out[6]: [1, 2, [3, 4, 5]] -
-
In [7]: sun1 -
Out[7]: [1, 2, [3, 4, 5]] -
-
In [8]: sun2 -
Out[8]: [1, 2, [3, 4]] -
-
In [9]: sun2[2].append(6) -
-
In [10]: father -
Out[10]: [1, 2, [3, 4, 5]] -
-
In [11]: sun1 -
Out[11]: [1, 2, [3, 4, 5]] -
-
In [12]: sun2 -
Out[12]: [1, 2, [3, 4, 6]]
sun2 是 father 的深拷贝,可以看出是一个独立的对象,不跟 father 共享任何元素。
函数的传参
再看函数的传参, c++ 中函数的传参方式分值传递、引用传递和指针传递,而 Python 中函数的传参方式只有一种:共享传参,也就是说函数内部的形参是实参的别名。
那么坑来了。
坑
我们有时候会给函数加一个有默认值的参数,这是为了很好的向后兼容。如果不小心把这个默认值设成了可变参数,连怎么掉进坑的都不知道。看个:
-
class Father: -
sex = "male" -
-
def __init__(self, name, age, money=[]): -
self._name = name -
self.__age = age -
self.money = money -
-
class Sun(Father): -
def __init__(self, name, age): -
super(Sun, self).__init__(name, age) -
-
if __name__ == "__main__": -
f = Father("f_name", 23) -
s = Sun("s_name", 3) -
print("father's default money: {}".format(f.money)) -
f.money.append(100) -
print("father put 100 in money: {}".format(f.money)) -
print("sun's default money: {}".format(s.money)) -
s.money.append(10) -
print("sun put 10 in money: {}".format(s.money)) -
print("now, father's money: {}".format(f.money))
上面类 Father 的 __init__ 函数的参数 money 设置了默认值 [] 。类 Sun 是继承自 Father 的,如果 father 在自己的钱包里放了 100 块钱,这时候 sun 的口袋却默认有了 100 块钱。看运行结果:
-
father's default money: [] -
father put 100 in money: [100] -
sun's default money: [100] -
sun put 10 in money: [100, 10] -
now, father's money: [100, 10]
这样一来,爸爸和儿子的钱包就成了共享的了,很容易引发家庭矛盾。出现这个问题的根源在于,默认值在定义函数时计算(通常在加载模块时),因此默认值变成了函数对象的属性。我们可以通过下面这段代码验证:
-
print(Father.__init__.__defaults__[0] is f.money) -
print(f._name is s._name) -
print(f.money is s.money)
运行结果:
-
True -
False -
True
因此,如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响。
防坑
为了防止踩到上面这样的坑,我们需要将函数默认值设成不可变的,如 None。上面的就需要改成这样:
-
class Father: -
sex = "male" -
-
def __init__(self, name, age, money=None): -
self._name = name -
self.__age = age -
if money: -
self.money = list(money) -
else: -
self.money = [] -
-
-
class Sun(Father): -
def __init__(self, name, age): -
super(Sun, self).__init__(name, age)
运行结果:
-
father's default money: [] -
father put 100 in money: [100] -
sun's default money: [] -
sun put 10 in money: [10] -
now, father's money: [100]
由运行结果可以看出,这时候父子两人的钱包就是独立的,互不影响了。
Python 驻留机制
不知道玩蛇的同学有没有发现一个问题,如下:
-
In [1]: a = 1 -
-
In [2]: b = 1 -
-
In [3]: a is b -
Out[3]: True -
-
In [4]: a = 10000 -
-
In [5]: b = 10000 -
-
In [6]: a is b -
Out[6]: False
这个问题十分有趣,但是不影响平时写代码,只是 CPython 的细节实现,不知道完全没关系。
Python 有个驻留机制,即共享字符串字面量,是一种优化措施,防止重复创建热门数字。但 CPython 不会驻留所有字符串和整数,驻留的条件是实现细节,而且没有文档说明。
字符串驻留的范围: 0 ~ 9A ~ Z_a ~ z;
整数的驻留范围: -5 ~ 256 。
如果字符串或整数不在上述范围,Python 就会创建一个新的对象。
我们可以查看 id 来证明:
-
In [7]: a = 1 -
-
In [8]: b = 1 -
-
In [9]: id(a) -
Out[9]: 140387930888536 -
-
In [10]: id(b) -
Out[10]: 140387930888536 -
-
In [11]: c = 257 -
-
In [12]: d = 257 -
-
In [13]: id(c) -
Out[13]: 140387934343656 -
-
In [14]: id(d) -
Out[14]: 140387934343848
浙公网安备 33010602011771号