事实上,在中我介绍过Python中的变量概念,但那更多的是以一个刚从其他语言转Python的程序员的角度,现在看来颇为粗浅,很多地方并不准确。
所以这里写文进行梳理。
同样的,本文的主要观点和内容组织结构都来自《Fluent Python》一书。
变量不是盒子
新手学习一门编程语言最困难的就是对变量的理解了,对此我深有体会。
对此,最常用的一种解释是变量是一个盒子,可以把常量装入其中,也可以对常量进行替换操作,如果你把这些盒子堆叠起来,就会变成数组或其它容器。
这很直观,符合一般思维,但遗憾的是Python中的变量并非如此。
a = 1
b = 1
print(id(a))
print(id(b))
# 1229568305456
# 1229568305456
a
和b
明明是两个不同的变量,为何是同样的地址?
我之前曾解释过,Python中所有的一切都是变量,这里的1
也是,所以这里其实是a
和b
两个引用指向同一个变量。但这其实并不准确,准确的讲,1
是字面量,和其它表达式一样,当执行a=1
的时候的确会创建一个变量存放1,但这个变量并非a
本身,a
只是一个指向其的引用,而之所以b=1
没有创建一个新的变量被b
引用而是复用了前边的变量,这涉及到解释器的驻留技术,在文章的最后会提到。
乍一看两者的解释好像没什么区别,其实在细节上区别很大,前者是用Java的字符串常量池的思想,后者却是Python的本来面目。
那么Python变量的本来面目是什么?
所有的变量皆为引用。
如果你一时不能理解,不用担心,往后看。
标识、相等性和别名
id
之前我们提到过,id()
可以视作变量的内存地址。
这句话没有错,但并不准确。准确地来讲,id()
获取到的,是一个变量的唯一标识符。这个标识符可以唯一地标识一个变量,在具体实现上,不同的Python解释器有所区别,在CPython中就是内存地址。
所以我们可以通过id()
来确定两个变量是否为同一个变量,也就是确定其中一个是否是另一个的“别名”。
当然,具体操作中我们很少直接用id()
的值来比较,更多的是使用is
。
==
和is
==
在Python中很常见,用于比较两个变量的值是否相同。而is
用于比较两个变量是否是同一个变量的“别名”,或者说是指向同一个变量的不同引用。
a = [1,2,3]
b = [1,2,3]
c = b
print(a == b)
print(a is b)
print(b is c)
# True
# False
# True
可以看到,a
和b
是两个内容相同的不同列表,而c
则是b
的别名。
特别的,==
和is
在针对“常量”,比如int
\tuple
\str
时是等效的,比如:
a = (1, 2, 3)
b = (1, 2, 3)
c = b
print(a == b)
print(a is b)
print(b is c)
# True
# True
# True
着同样是因为解释器的驻留技术在作怪,稍后会解释。
我们还需要注意的是,对同样可以被视作“常量”的None
,同样两者是等效的,但这里更推荐使用a is None
而非a == None
,因为==
运算符是可以被__eq__
重载的,而is
不行,所以is
的执行效率更高。
元组的相对不可变性
之前在中我们其实已经见识过元组的相对不可变性了。
对于只包裹“原子”元素的情况,元组的确是不可变的,但一旦包含可变容器,元组就显得并不那么可靠了。
这其中的差异现在用变量皆是别名就很好理解,所谓的元组不可变性,只是针对直接包含在元组中的别名,至于这些别名指向的其它可变容器里的内容,就不在元组的管辖范围内了。
a = (1, 2, 3, ['a', 'b'], ('a', 'b'))
b = a
a[-2].append('c')
print(a)
print(b)
# (1, 2, 3, ['a', 'b', 'c'], ('a', 'b'))
# (1, 2, 3, ['a', 'b', 'c'], ('a', 'b'))
通过这个图表就能很好理解,我们并不能修改元组的“浅层”元素,但可以修改其中引用的其它容器。
这里的图表通过生成,这个网站可以图示化地展示变量结构的变化,非常有用。
这里我们遇到一个问题,如果我们只希望a
改变,而b
不变,需要如何处理?
这就涉及到变量“拷贝”。
浅拷贝和深拷贝
如果你了解变量拷贝,应该知道分为浅拷贝和深拷贝两种。
浅拷贝就是只拷贝表层数据,比如上边例子中的a
和b
,如果是一个浅拷贝,那b
只会拷贝浅层的1,2,3,list,tuple
,其中后两个只会拷贝别名,引用将和a
保持一致。
如果是深拷贝,则会完整拷贝整个结构。
我们通过具体示例来说明。
默认
其实拷贝一个容器最简单明了的方式是使用list()\set()
等函数:
a = [1, 2, 3]
b = list(a)
a[-1] = 5
print(a)
print(b)
# [1, 2, 5]
# [1, 2, 3]
b
并没有随着a
的改变而改变,这是因为b
是一个拷贝,而非a
的引用。
我们看一个更复杂点的例子:
a = [1, 2, 3, ['a', 'b']]
b = list(a)
a[-1].append('c')
a[-2] = 5
print(a)
print(b)
# [1, 2, 5, ['a', 'b', 'c']]
# [1, 2, 3, ['a', 'b', 'c']]
a
中嵌套的list改变后b
中的list也同样改变,这说明通过这种方式构建的拷贝是一个“浅拷贝”。
通过图示我们可以很清楚观察到这一点:
此外,通过切片操作也可以进行浅拷贝:
a = [1, 2, 3, ['a', 'b']]
b = a[:]
a[-1].append('c')
a[-2] = 5
print(a)
print(b)
# [1, 2, 5, ['a', 'b', 'c']]
# [1, 2, 3, ['a', 'b', 'c']]
通过这几个例子我们可以知道,Python中默认的拷贝方式是浅拷贝,如果要进行深拷贝,要通过copy
模块。
copy模块
import copy
a = [1, 2, 3, ['a', 'b']]
b = copy.copy(a)
c = copy.deepcopy(a)
a[-1].append('c')
a[-2] = 5
print(a)
print(b)
print(c)
# [1, 2, 5, ['a', 'b', 'c']]
# [1, 2, 3, ['a', 'b', 'c']]
# [1, 2, 3, ['a', 'b']]
copy模块同时提供浅拷贝函数copy.copy
和深拷贝函数copy.deepcopy
。
通过图示我们可以很容易观察其差异:
需要说明的是,深拷贝并不像我们想的那样简单,有一些实际问题需要考虑,比如循环嵌套的数据:
import copy
a = [1,2]
b = [a,1,2]
a.insert(1, b)
print(a)
c = copy.deepcopy(a)
print(c)
# [1, [[...], 1, 2], 2]
# [1, [[...], 1, 2], 2]
或者是我们不希望拷贝层次太过深入等,在那种情况下我们就需要考虑实现魔术方法__copy__
和__deepcopy__
。
参数传递
参数传递是一个复杂而有趣的问题,我们在学习一门新的语言的时候会很习惯性地和已经学过的语言做对比,比如我在学习Java的时候,就注意到Java的基本数据类型和C++中的值传递一致,而对象的引用传递则与C++中的指针一致。
我在之前介绍函数的时候也武断地下结论说Python的参数传递与Java中的引用传递一致,现在来看,这是不正确的。
举个PHP中的例子:
<?php
function changeNum(&$a){
$a = 2;
}
$a = 1;
changeNum($a);
echo $a;
// 2
这是PHP中很常见的引用传参,通过这种方式我们可以在函数内部修改外部变量。
但如果用同样的方式在Python中:
def changeNum(num):
num = 2
num = 1
changeNum(num)
print(num)
# 1
这会让人困惑,不是我们已经说过Python中所有的变量都是别名,是引用了吗,为什么会这样。
很简单,变量是引用不代表传递的参数是类似指针的东西,事实上,Python中传递的参数是引用的复制。
def changeList(lst: list):
lst = [1, 2, 3]
def changeList2(lst: list):
lst.clear()
lst.extend([1, 2, 3])
lst = [4, 5, 6]
changeList(lst)
print(lst)
changeList2(lst)
print(lst)
这个例子说明了,我们通过复制的引用可以修改其内部结构,但是我们不能直接通过赋值操作改变外部变量,因为我们有的只是引用复制,而非真正的变量指针。
不要使用可变类型作为参数默认值
我们来看一个诡异的例子:
class Bus():
def __init__(self, people: list = []):
self.people = people
def up(self, newOne: str):
self.people.append(newOne)
def down(self, leaved: str):
self.people.remove(leaved)
def __repr__(self):
return "bus->%r"%self.people
bus1 = Bus(["Jack Chen","Brus Lee","Xiao Min"])
bus2 = Bus(["Han Meimei","Li Lei"])
bus1.down("Jack Chen")
bus2.up("Jack Chen")
print(bus1)
print(bus2)
# bus->['Brus Lee', 'Xiao Min']
# bus->['Han Meimei', 'Li Lei', 'Jack Chen']
这是一个简单的公交车例子,Bus
可以简单地实现上下车,看似没有任何问题,但是我们看下边:
class Bus():
def __init__(self, people: list = []):
self.people = people
def up(self, newOne: str):
self.people.append(newOne)
def down(self, leaved: str):
self.people.remove(leaved)
def __repr__(self):
return "bus->%r"%self.people
bus1 = Bus()
bus2 = Bus()
bus1.up("Jack Chen")
bus1.up("Han Meimei")
bus1.up("Brus Lee")
print(bus1)
print(bus2)
# bus->['Jack Chen', 'Han Meimei', 'Brus Lee']
# bus->['Jack Chen', 'Han Meimei', 'Brus Lee']
当bus1
和bus2
都使用默认值空的list进行参数化的时候,出现了很诡异的现象,对bus1
的操作会影响到bus2
。
这其实是因为,Python解释器在解析类定义的时候。
def __init__(self, people: list = []):
self.people = people
会将以上的代码处理为“将self.people绑定到默认参数,而默认参数是一个空的列表”。
乍看好像也没有什么问题,但要知道,在Python中默认参数是确确实实记录在函数属性中的一个变量。
print(dir(Bus.__init__))
print(Bus.__init__.__defaults__)
# ['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
# (['Jack Chen', 'Han Meimei', 'Brus Lee'],)
这其中的Bus.__init__.__defaults__[0]
正是我们的默认参数lst=[]
,可以看到其已经从空列表变为有三个元素的列表。
相应的,所有通过默认参数构建的Bus
对象的self.people
属性必然会指向这同一个列表,这也很容易证实:
print(bus1.people is bus2.people)
# True
所以我们应该尽量避免使用可变类型作为函数参数的默认值。
对于这个例子,我们可以这样修改:
class Bus():
def __init__(self, people: list = None):
if (people is None):
self.people = []
else:
self.people = people
def up(self, newOne: str):
self.people.append(newOne)
def down(self, leaved: str):
self.people.remove(leaved)
def __repr__(self):
return "bus->%r"%self.people
bus1 = Bus()
bus2 = Bus()
bus1.up("Jack Chen")
bus1.up("Han Meimei")
bus1.up("Brus Lee")
print(bus1)
print(bus2)
print(bus1.people is bus2.people)
# bus->['Jack Chen', 'Han Meimei', 'Brus Lee']
# bus->[]
# False
防御可变参数
所谓的“防御可变参数”,是《Fluent Python》推荐的一种类方法处理参数的态度,即除非你确定需要改变外部数据,否则尽量将数据“本地化”,即进行拷贝后使用。
比如这样:
class Bus():
def __init__(self, people: list = None):
if (people is None):
self.people = []
else:
self.people = list(people)
但我并不同意这个观点,因为之所以Java将引用传递作为对象的默认传参方式不是没有道理的,这是因为大量使用引用而非拷贝,可以极大程度地节省空间开支,而相比可能的对外部变量的意料之外的修改,反而是一种小概率事件,我们完全可以在遇到这种情况的时候再考虑深拷贝或是浅拷贝。
垃圾回收
所有的非编译型语言都需要考虑垃圾回收,因为他们无法在编译阶段实现变量创建和销毁。
Python的垃圾回收是和Python解释器密切相关的,不同的解释器的垃圾回收机制也不同。其中CPython解释器使用的是引用计数的方式。
这是一种最简单明了的机制,即通过记录一个变量的引用数量,来判断这个变量是否还处于活动状态,如果引用计数为零,则说明该变量已经无用,可以回收。
我们可以通过弱引用来观察变量在何时销毁。
弱引用
弱引用与普通的引用相比,当我们对一个变量添加一个弱引用的时候,并不会让目标的引用计数加一。这就意味着不会影响到垃圾回收机制,在缓存等领域我们会使用到弱引用。
我记得Android程序后台对前台UI的引用也是弱引用。
import weakref
a = {1, 2, 3}
b = a
def varDisabled():
print('var is disabled')
varRef = weakref.finalize(a, varDisabled)
del(a)
print(varRef.alive)
del(b)
print(varRef.alive)
# True
# var is disabled
# False
weakref.finalize
可以在创建弱引用的同时把一个函数绑定为回调,当变量被垃圾回收机制触发的时候就会触发这个回调函数。
需要明确的是,创建的弱引用是针对的a
指向的变量,而非a
这个引用。
事实上并不建议通过weakref.ref
或者weakref.finalize
的方式创建引用,更多的是使用weakref.WeakValueDictionary
或者WeakKeyDictionary,WeakSet
这样的容器。
WeakValueDictionary
顾名思义,WeakValueDictionary
是一个值为弱引用的字典,如果哪个值被回收了,那相应的键会自动删除。
我们来看一个示例:
from weakref import WeakValueDictionary,WeakKeyDictionary,WeakSet
class Person():
def __init__(self, name: str):
self.name = name
def __repr__(self):
return "Person(%s)" % self.name
people = {'Jack Chen': Person('Jack Chen'), 'Brus Lee': Person(
'Brus Lee'), 'Han Meimei': Person('Han Meimei')}
wvDict = WeakValueDictionary(people)
print(sorted(wvDict.keys()))
for k, v in wvDict.items():
print("%s:%r" % (k, v))
del people
print(sorted(wvDict.keys()))
print(v)
del v
print(sorted(wvDict.keys()))
# ['Brus Lee', 'Han Meimei', 'Jack Chen']
# Jack Chen:Person(Jack Chen)
# Brus Lee:Person(Brus Lee)
# Han Meimei:Person(Han Meimei)
# ['Han Meimei']
# Person(Han Meimei)
# []
这段代码中比较奇怪的地方在于,我们明明已经删除了引用的字典,但是依然存在一个键:['Han Meimei']
。仔细观察就能知道,这是因为我们在for循环中通过v
持有一个对Person('Han Meimei')
的引用,所以才会出现这样的情况。
在Java或者C++中,
v
会被视为局部变量,作用域只存在for循环内,循环结束就会被回收,但Python中有所不同,v
是全局变量,并不会随着for循环的结束而被回收。
弱引用的局限性
弱引用尤其局限性,比如int\list\tuple\list\str
等都不能被弱引用,但是集合可以,所以之前的例子中会使用set
。
但是我们可以通过继承相应数据类型的方式来创建弱引用。
驻留技术
我们之前提到过,Python中有一些和我们认知不同的事情,比如:
a = 1
b = 1
print(a is b)
a = (1, 2, 3)
b = (1, 2, 3)
print(a is b)
# True
# True
还有更奇怪的:
a = (1,2,3)
b = tuple(a)
print(a is b)
# True
所有的这些,其实都是一种Python解释器的优化机制,它会使用类似Java中字符串常量池的做法,把对不可变对象的引用指向相同的一个对象,避免重复构建不必要的对象,这样做会避免不必要的空间开销。这种做法在Python中称为“驻留”。
需要说明的是,驻留是属于Python解释器的实现细节,普通开发者无需细究,也不能依赖于这一特性,因为实际运行中,相同的不可变元素的引用是否一定会指向同一个变量,那取决于Python解释器。
我们也不必担心可能出现的bug,比如:
a = (1,2,3,[1,2])
b = (1,2,3,[1,2])
print(a is b)
# False
当元组包含可变容器的时候,解释器很聪明地没有使用同一引用。
好了,关于变量的遗留问题讨论完毕,谢谢阅读。
文章评论