在Python中,有这么一类对象,当它被创建后,内存就无法被改变,直到引用计数归零,并被回收和释放,它们被称为不可变对象,整数、浮点数、字符串和元组都是典型的不可变对象;而另一类对象在被创建后,其内存依旧可以被修改,它们被称为可变对象,常见的可变对象包括字典、列表、集合、函数、类等。
如果你并不清楚什么是对象的“引用”,那么请打开
这是我们所熟知的“赋值”方法:
woo = "小A"
假如,我是说假如。
你叫做woo
,上一句话中,你绑定了你的对象"小A"
,当有人呼唤你时,你就会把你的对象找出来。
print(woo)
那么某一天,你移情别恋了,想把对象换成"小B"
,那么这时候,你使用了这一条语句重新赋值:
woo = "小B"
这句话意味着原来的"小A"被转换成了"小B"吗?并不是,而是之前的"小A"被你抛弃了,你又重新找了一个新的对象"小B"。
此时有人呼唤你,你就不会再找原来的对象了,而是去找新对象"小B"。
在这个过程中,你(也就是woo
)只是一个名称,一个引用,而"小A"和"小B"才是我们真正要找的对象。
小Tip
一个别名只能同时引用一个对象,而一个对象却可能同时拥有无数个别名。
相信你已经能够理解“重新赋值”的本质了。
那么一定也不难理解,以下案例中,a和b指向的,实际上是同一个对象。这一点,可以比较二者id
,或者使用is
进行判断。
a = [1, 2, 3]
b = a
print(f"{b =}\n"
f"{a =}\n"
f"{id(a) =}, {id(b) =}, {id(a) == id(b)}\n"
f"{a is b = }")
可变对象的原地修改
原地修改,指的是在不改变别名引用的内存地址的情况下,直接修改此对象的内存。其特点包括:
- 修改前后,对象ID不变
- 修改前后,对象的内存发生变化
- 影响此对象的所有引用
列表的append
方法、字典的update
方法都是典型的原地修改。
a = [1, 2, 3]
b = a
a.append(4) # 最常见的原地修改操作
print(f"{b =}\n"
f"{a =}\n"
f"{id(a) = }, {id(b) = }, {id(a) == id(b)}\n"
f"{a is b = }")
c = {"a": 1}
d = c
c["b"] = 2
c.update({"c": 3})
c.setdefault("d", 4)
del c["a"]
print(f"\n{d =}\n"
f"{c =}\n"
f"{id(c) = }, {id(d) = }, {id(c) == id(d)}\n"
f"{c is d = }")
a = [1, 2, 3]
b = a
a += [4]
a.extend([5])
a.insert(0, 0)
print(f"{b =}\n"
f"{a =}\n"
f"{id(a) = }, {id(b) = }, {id(a) == id(b)}\n"
f"{a is b = }")
请注意,这里的+=
也是原地修改,它和extend
方法实际等价,而不同于以下展示的重新赋值方法
b = b + [4]
实际上创建了新的列表对象并绑定到b
;而上文的a += [4]
则是原地修改:这是一个非常容易产生误解的地方,即误认为+=
是重新赋值。
a = [1, 2, 3]
b = a
b = b + [4]
print(f"{b =}\n"
f"{a =}\n"
f"{id(a) = }, {id(b) = }, {id(a) == id(b)}\n"
f"{a is b = }")
无限递归
众所周知,列表、字典等对象内可以引用其他对象。如果此时引入的“其他”对象就是自身,那么会发生什么事情呢?
a = []
a.append(a)
print(f'{a = }\n'
f'{a[0] = }\n'
f'{a[0] is a = }')
这个例子中,我先初始化了一个空列表,并绑定到a
,此时我再进行原地修改,将a
,也就是这个对象直接插入到这个对象中。这就导致了无限递归的情况,即此列表中的元素就是自身。
类似的,多个别名循环引用也会造成无限递归
a = []
b = [a]
a.append(b)
print(f'{a = }\n'
f'{b = }\n'
f'{a[0] = }\n'
f'{a[0] is b = }')
随堂小测:这样创建出来的无限循环的列表是否会导致内存泄漏?为什么?
对象拷贝:浅拷贝与深拷贝
从上面的例子中,我们可以直观看到,直接使用a = b
,实际上是继承了引用关系,很多时候我们希望的并不是给原有对象一个别名,而是复制一个新的对象,以防止后续操作产生意料之外的影响,这时候就需要用到拷贝操作,克隆出一个新的对象。
例如,我希望b能够获得a的值,并且对b的操作不影响a:
a = [1, 2, 3]
b = a.copy()
print(f"{b =}\n"
f"{a =}\n"
f"{id(a) = }, {id(b) = }, {id(a) == id(b)}\n"
f"{a is b = }")
b.append(4)
print(f"\n{b =}\n"
f"{a =}")
拷贝操作分为浅拷贝与深拷贝。它们的区别就是,浅拷贝只负责拷贝原有对象本身,至于此对象可能引用的其他对象,浅拷贝完全保留原有的引用关系;而深拷贝会递归地将每一层的引用都拷贝出一个新的对象,使原有引用关系失效。
浅拷贝:只有最外层的对象的id变化,而内部的所有引用依然关联。
a = [1, 2, 3]
b = {'a': a}
c = b.copy()
print(f"{c =}\n"
f"{b =}\n"
f"{id(b) = }, {id(c) = }, {id(b) == id(c)}\n"
f"{b is c = }")
a.append(4)
print(f"\n{c =}\n"
f"{b =}")
深拷贝:在所有层次都进行拷贝,包括内部的引用,对内部可变对象的修改不再互相影响。
import copy
a = [1, 2, 3]
b = {'a': a}
c = copy.deepcopy(b)
print(f"{c =}\n"
f"{b =}\n"
f"{id(b) = }, {id(c) = }, {id(b) == id(c)}\n"
f"{b is c = }")
a.append(4)
print(f"\n{c =}\n"
f"{b =}\n"
f"{b['a'] is c['a'] = }")
不要将可变实参作为函数的默认实参
Python函数支持默认实参(点名Rust)确实很方便,但是有一点必须注意,那就是,除非有明确的特别的理由,请务必不要将可变实参作为函数的默认实参。这是因为默认实参对象是在函数初始化阶段创建的,因此,只要函数已经初始化,那么无论函数被调用多少次,所有的调用都会共用这个已经创建的对象。假如将可变对象作为默认实参,那么假如任何一次函数执行过程中该对象发生了原地修改,那么这个修改将会一直存在并且影响这个函数的每次调用。
以下是一个非常浅显的例子,前一次调用原地修改了默认实参,并且将这个修改保留到了下一次调用。
def append_element(element, x=[]):
x.append(element)
return x
print(append_element(1))
print(append_element(2))
如果需要让默认实参为可变类型的对象,那么正确的做法是使用None
作为默认实参,然后在调用期对实参进行初始化:
def append_element(element, x=None):
if x is None:
x = []
x.append(element)
return x
print(append_element(1))
print(append_element(2))