万物皆对象-可变与不可变-参数传递
万物皆对象。
万物皆对象
怎么理解?就是在Python中,你能想到的所有的东西它都是一个对象:整型、浮点数、字符串、元组、列表、字典、集合,甚至函数、类等等,都是对象(object)。对象之间是怎么传递内容的呢?引用,这点和C/C++的指针很像,相当于两个不同名字的指针指向同一个内存位置。将Python的赋值操作和C/C++中的(void*)
指针对比是比较合理的。
这同样也意味着在编写代码时,同一个变量名字可以赋予不同类型的对象。而加上Python是门动态语言,它在运行的时候会去适配合适的类型,因此代码执行起来就不会有问题。
和C/C++不同,在Python中赋值符号=
表示的就是引用引用的概念。最简单的:
a = 1
首先这个数字1
也是对象,由于Python的内存池机制的存在,Python会对小于256字节的对象使用pymalloc
方法分配内存,这是题外话,重点是最终对象1
会有一个地址,而这里是把这个地址给了变量a
。这就相当于C中的a = (void*)1
,怎么证明?
>>> type(1)
<class 'int'> # 类型是class,
>>> int.__bases__
(<class 'object'>,) # int类的父类是object类
>>> id(1) # 查看对象1的内存地址
1912499216
>>> a = 1
>>> id(a) # a和1的对象地址是一样的
1912499216
再看一个例子:
>>> a = [[2,1], 2, 3]
>>> id(a)
1894079804680
>>> id(a[0]), id(a[1]), id(a[2])
(1894079807304, 1912499248, 1912499280)
>>> id(a[0][0]), id(a[0][1])
(1912499248, 1912499216)
有几点观察:
-
a
和a[0]
引用的是两个列表对象,它们的内存地址明显比其他int
型对象大。内存池? -
a[1]
和a[0][0]
引用的都是对象2
,它们的内存地址是一样的。 -
a[2]
和a[1]
的内存地址相差32
,有没有想到点什么?
这几点想明白之后,“万物皆对象“这句话就可以理解一大半了。
对象的三要素
每一种对象都会对应三种属性:
-
identity, 相当于对象的唯一标识,用
id()
可以获得,可以简单认为是这个对象的内存地址。 - type, 表示对象中的保存内容是什么类型的,向上面的例子:
>>> type(1)
<class 'int'> # 整型
>>> type(a)
<class 'list'> # 列表
>>> type(type)
<class 'type'> # type()也是对象,同理id()也是
-
value,这个就是值,一般直接
print (ojb)
即可。
判断两个对象是否一样使用的是is
关键词,而判断两个对象的值是不是一样使用的是==
操作符。
除了这个三要素,对象也可以拥有方法。这是另一个话题。
赋值、浅拷贝和深拷贝
先考虑如下代码:
a = [1, ['a', 'b', 'c'], 3]
b = a
c = ['a', 'b', 'c']
assert (a is b) == True
assert (a[1] is c) == False # 注意这条,考虑下为什么
b[0] = 2
print (a) # 输出[2, ['a', 'b', 'c'], 3]
如果b
修改了其列表中的值,那么a
中的也会变,这点不难理解。但是如果我想要要的是a
的一份拷贝呢?
a = [1, ['a', 'b', 'c'], 3]
b = a.copy()
print (id(a),[id(x) for x in a])
print (id(b),[id(x) for x in b])
# 输出
# 3082815203464 [1912499216, 3082815204040, 1912499280]
# 3082815172104 [1912499216, 3082815204040, 1912499280]
细心的话会发现,虽然a
和b
的地址已经变了,但是a[1]
和b[1]
的内存地址是一样的,也就是说修改b[1]
仍然会影响到a
中的内容,当然这种结果有时候是可以接受的。
如果不接受呢?就是要给a
中的所有对象,包括对象中包含的对象都重新申请内存,这里要用到copy
模块:
import copy
a = [1, ['a', 'b', 'c'], 3]
b = copy.deepcopy(a)
print (id(a),[id(x) for x in a])
print (id(b),[id(x) for x in b])
# 输出
# 1596348426376 [1912499216, 1596348426952, 1912499280]
# 1596348395016 [1912499216, 1596348394696, 1912499280]
除了这些,关于列表复制也可以这么做:
a = [1, ['a', 'b', 'c'], 3]
b = a[:]
那么这属于哪种拷贝呢?
可变对象和不可变对象
Python中有一个概念,它有的对象内容会有两种可变的和不可变的。怎么理解呢?回到第一个例子:
a = 1
print (id(a), id(1), id(2)) # 1912499216 1912499216 1912499248
a = 2
print (id(a), id(1), id(2)) # 1912499248 1912499216 1912499248
a
这里是可变的,它的值从1912499216
变成了1912499248
,这不重要。
这里需要理解的是,整型对象1
是不可变的,它的地址永远是1912499216
。而且似乎也没什么办法能修改这个值。
按照这种逻辑,Python中的对象可以分为两类:
- 不可变对象:数值型对象(int, float, decimal, complex)、bool型、字符串(string)、元组(tuple)、range、frozenset、bytes;
- 可变对象:列表(list)、字典(dict)、集合(set)、字节数组(bytearray)、自定义类。
关于可变对象和不可变对象其实没有太多可说。但是如果你了解这个细节,有时候写代码的时候会无形中提高代码的效率。
不可变的字符串
最有可能遇到的场景是关于字符串的操作,因为在Python中字符串是不可变对象,也就是说字符串"abc"
和"abcd"
是不同的对象,这和我们的原始认知可能有点不协调。考虑如下代码:
print ([id("abc"), id("abcd")])
a = "abc"
print (id(a))
a += a
print (id(a))
b = "abc" + "d"
e = "a" + "b" + "c" + "d"
print (id(b), id(e))
# 输出
# [2840292183040, 2840294857784]
# 2840292183040
# 2840294857672
# 2840294857784 2840294857784
可以看到:
- 在使用
+=
操作符时,会产生一个新的对象(原先的对象会被当成垃圾处理掉)。 - 不同组合的子字符串连接成相同的字符串,产生的对象是固定的。仔细想一想,这其实是字符串是不可变对象的一个很好的例子。
接下来的问题就是,这对我们写代码有什么帮助?那么再考虑一个问题,如何将一个列表中的所有字符串串联起来?不难想到,两种方法:
slist = ['I', 'am', 'Groot']
def fn1():
r = ''
for i in slist:
r += i + ' '
return r[:-1]
def fn2():
return " ".join(slist)
哪种好呢?其实是第二种,我们可以简单测试一下两个函数的效率:
import time
slist = []
for i in range(1000000):
slist.append('1')
def fn1():
...
def fn2():
...
t1 = time.time()
fn1()
t2 = time.time()
fn2()
t3 = time.time()
print (t2-t1, t3-t2)
# 输出
# 0.36515259742736816 0.013962268829345703
可以看到使用join
的话效率明显好,而且还省了很多资源。理由其实很简单,就是fn1
里面新建了很多对象。换句话说,join
的实现里应该没有这些额外的对象,它是怎么实现的呢?用C,申请一段内存,不断地将列表中的字符串拷贝到对应的位置。能不能用Python实现?不能,因为不知道怎么动态申请内存。证明一下?
slist = ['I', 'am', 'Groot']
def op(slist):
print ('-----')
res = ''
print (id(res))
for s in slist:
res += s
print (id(res))
print ('-----')
return res
print (id(slist))
print (id(op(slist)))
# 输出
# 3103558232392
# -----
# 3103550569136
# 3103557636368
# 3103558269056
# 3103558266160
# -----
# 3103558266160
可以看到res
的地址一直在变,从整体看,传入op
的列表和从op
传出的列表已经不是同一个了。这里其实不一定要是字符串,任何可迭代的对象都可以使用join
连接,鸭子类型了解一下。
可变的默认参数
有时候函数是有默认参数的,Python文档中明确说了不推荐使用可变对象作为函数的默认参数。然而不推荐不代表不可以,那么如果使用了可变对象作为默认参数会导致什么问题呢?考虑如下代码:
def fn(default=[]):
default.append(1)
return default
print (fn())
print (fn())
# 输出
# [1]
# [1, 1]
为什么?这是因为函数fn
也是类,在定义函数生成fn
这个类时,Python会把它的一些信息下来,这里涉及到的两个内置变量是__defaults__
和__code__
。我们将它们打印出来看看:
def fn(default=[]):
default.append(1)
return default
p1 = fn()
p2 = fn()
print(id(p1), id(p2))
print (fn.__defaults__)
print (id(fn.__defaults__[0]))
print (dir(fn.__code__))
print (fn.__code__.co_varnames)
# 输出
# 2328737311048 2328737311048
# ([1, 1],)
# 2328737311048
# ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_kwonlyargcount', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_stacksize', 'co_varnames']
# ('default',)
同样,这里有几点可以说说:
- 两次调用
fn()
返回的对象其实是同一个,也就是默认参数default
。 -
__defaults__
中存了所有的默认参数的值,也就是函数最后返回的对象。 -
__code__
是一个列表,存了很多有用的信息,比如说co_varnames
存了参数的名字。
这样一看其实就明朗了,对于可变参数,如果在函数实现中对其进行了修改,那么就会对应的修改这个函数的meta信息;显然,不可变参数就不存在这个问题。
a += b 和 a = a + b哪个好?
在不可变的字符串里的例子中已经可以证明,对于不可变对象(字符串)a
:a += '1'
和a = a + '1'
都会产生额外的新对象,那么如果对象是可变的呢?
slist = ['I', 'am', 'Groot']
print (id(slist))
slist.append('!')
print (id(slist))
slist += ['?']
print (id(slist))
slist = slist + ['!']
print (id(slist))
因此答案是+=
好。
参数传递
再说说Python中参数的传递过程,我看到很多网上的文章会将参数传递的方式分为传值和传引用,但是根据我自己的理解,我以为这两种说法其实说的是一回事。
先说我的观点:在Python中,根本没有传值,或者每次都是传值。因为每次传递的其实就是对象的引用,而传递的值就是该对象对应的内存地址,即C中的(void*)
,这也呼应了文章开头的那句:万物皆为对象。
现在我找了两个网上常用的例子,很多作者在讲述参数传递的时候就用这两个例子作为切入点,然后先把问题讲复杂了,然后在引出可变对象和不可变对象之类的概念,显得特别高大上。
其实完全没必要,我以为如果你能理解万物皆对象,就能理解参数传递,它和可变不可变其实没什么关系。先来看这两个例子:
def foo(var):
var = 2
print(var) # 2
a = 1
foo(a)
print(a) # 1
def bar(var):
var.append(1)
b = []
bar(b)
print(b) # [1]
我们换个角度看这个问题。
- 在第一个例子中,当传入参数
a
的时候,实际上传入的是对象1
的引用,也就是刚进入函数时var
的初始值。后来函数foo
将对象2
的引用赋予了var
,也就是说从此刻起var
和a
表示的已经不是同一个东西了。因此不管之后对var
怎么做修改,都不会影响a
的值。-
注意, 如果这里不是用的
var
作为变量名,而是好巧不好的用了a
,也就是说传入的参数名字和函数里使用的参数名字是一样的。这里涉及到的一个新问题是作用域,两个a
表示的不是同一个东西,你可以简单把函数里的a
理解成foo__a
。
-
注意, 如果这里不是用的
- 在看第二个例子,如果你调用
append
的时候,var
指向的仍然是列表b
的位置,自然你的操作会对这个列表产生影响。这就好比是两个指针指向了同一个内存,不管那个指针修改了那块内存都会在另一个指针访问这个内存是表现出来。- 而在第一个例子中,第二根指针已经指向其他地方了。
再看一个我从网上找来的例子:
def change(val):
val.append(100)
val = ['T', 'Z', 'Y']
nums = [0, 1]
change(nums)
print(nums) # [0, 1, 100]
这就很容易理解了,在执行append
的时候,val
指向的仍然是nums
指向的列表,因此100
能成功添加到nums
列表中。而之后val
被赋予了一个新的列表对象['T', 'Z', 'Y']
,而这和nums
没有任何关系。
因此,我最后想重新提一下,其实传什么在Python中只有一种形式,不需要和C/C++中的传参传值相类比。同样,传什么与对象是不是可变的什么关系。关键看它们的对象引用指向了哪块内存。