Python仙术 第7期 Python中的可变对象

在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 = }")

可变对象的原地修改

原地修改,指的是在不改变别名引用的内存地址的情况下,直接修改此对象的内存。其特点包括:

  1. 修改前后,对象ID不变
  2. 修改前后,对象的内存发生变化
  3. 影响此对象的所有引用

列表的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))
27 Likes

前排围观

1 Like

来学习

1 Like

这个点发让人怎么学习

4 Likes

感谢分享 对于我这样的小白很受教

1 Like

砂糖教室,围观给推
基础打好很重要
往往百思不得其解的bug就从这儿来

1 Like

砂糖佬课堂开课啦 :star_struck:

1 Like

学习使我快乐

来学习

来力

好,学习了

前来学习

22 Likes

python 的内置电池 太牛了。扩展语法糖

又学习到了新知识

@Reno 你的Python教程更新了

2 Likes

马什么梅?什么冬梅?

1 Like

抓紧学习

论坛是不是可以搞一个作者专栏,让楼主更好的归类自己发的贴子。

python真好

1 Like

看了=会了