动手理解Python的引用、赋值、拷贝

动手理解Python的引用、赋值、拷贝

Python是一门面向对象的高级编程语言,相较于其他面向对象的编程语言,Python在面向对象的路上走得更远,即,“一切皆是对象”,因此Python里的某些概念和行为呈现出跟其他高级语言不一样的效果。比如Python的引用、赋值、拷贝这几个概念曾经就让我十分迷糊,直到最近才搞清楚了一些。

I. 变量、对象和引用

先上结论:变量对象 之间的关系为 引用

1. 此“变量”非彼“变量”

Python里的“变量”跟C/C++里的“变量”有很大不同。

在C/C++里,借用一个很形象的说法,变量和对象的关系就像是“箱子”和“物体”的关系:初始化 (声明并赋值) 一个变量时会声明一块存储空间并写入一个值,相当于创造一个“箱子”并放入一个“物体”;而改变这一变量的值时会直接修改这一存储空间的内容,相当于用另外一个“物体”替换“箱子”里原有的“物体”;而把一个变量赋值给另一变量时,相当于把“箱子”里的“物体”复制一份儿然后放到另一个“箱子”里,之后两个“箱子”各自独立,互不影响。结合代码如下:

int a = 100 // 在地址1处创建一个箱子a并放入物体100

a = 200 // 箱子a里的物体100被替换为物体200

int b = a // 在另一个地址2处创建一个箱子b,之后把箱子a中的物体复制一份放入箱子b中;在这之后箱子a和b是相互独立、互不影响的两个箱子

可以体会到,“物体”跟“箱子”是紧密关联在同一块儿存储空间的,同一个“物体”不可能同时装在两个“箱子”里。

在Python里,“一切皆是对象”,变量是对内存中对象的一个引用,两者的关系相对独立且有各自的存储空间,借用一个很形象的说法,Python里变量和对象的关系就像是“标签”和“商品”的关系:初始化 (声明并赋值) 一个变量时,Python会首先在一块儿存储空间写入一个对象,这一步相当于创造了一个“商品”,之后Python会声明另一块儿存储空间为变量,这一步相当于创造了一个“标签”,最后,Python会把对象的地址写入变量,这一过程叫做引用,相当于把“标签”挂到“商品”上去,后面人们就可以通过“标签”来找到“商品”;对对象的操作需要通过变量实现,这一过程相对复杂,放到下一节;在Python里,把一个变量赋值给另一变量时,并不会复制对象,而只是简单添加一条引用,相当于在原来的“商品”上新挂一个“标签”,两个“标签”共享同一个“商品”。结合代码如下:

a = 100 % 在地址1处写入100并把地址1写入变量a,即,把标签a挂在商品100上

a = 200 % 在地址2处写入200并用地址2覆盖变量a原来存储的地址1,即把标签a挂在另一个商品200上

b = a % 在商品200上再挂上一个标签 b

% 至于原来的商品100,因为没有其他标签挂在上面,会被送去销毁 (Python的垃圾回收机制)

2. id() 和 is, is not

  • id(obj) 函数返回对象的唯一标识符,标识符是一个整数,也可以认为是对象的内存地址

  • is 用于判断两个变量引用对象是否为同一个,即判断 id(a) == id(b) 是否成立,若两个变量引用对象标识符相同,则返回 True,反之,则返回 False,也就是说,a is b 相当于 id(a) == id(b)

  • is notis 的逆操作

  • == 用于判断两个变量引用对象的值是否相等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> a = 1000
>>> b = a
>>> id(a) == id(b)
True
>>> a is b
True
>>> a is not b
False
>>> a == b
True
>>> a = 1000
>>> b = 1000
>>> id(a) == id(b)
False
>>> a is b
False
>>> a is not b
True
>>> a == b
True

II. 赋值!赋值!还是赋值!

明白了Python里面变量跟对象的引用关系,我们可以来到本文最重要的一节,理解Python的赋值操作,这对于理解Python一些看似迷糊的行为至关重要!

1. 狡猾的 “=”

Python里的赋值操作是一个等号“=”,由于从小接受数学的熏陶,等号“=”这一表象成为了理解赋值操作的绝佳陷阱。要理解Python里的赋值操作,第一件事就是在看到 A = B 时在脑子里自动转化为 B --> A,这么做的原因就在于可以突出赋值操作的方向性! “=”的迷惑性在于它是一个很对称的符号,只看“=”是看不出方向性的,等号在数学上表达的是两边相等,自然不需要方向性,但是Python里的赋值是有方向性的,即从右边的对象到左边的变量;更糟糕的是,我们大都习惯了从左向右阅读,这跟赋值的方向性是相悖的,可恶啊,狡猾的“=”。

A = BB --> A

A = BB --> A

A = BB --> A

2. 可变对象和不可变对象

Python里的对象分为可变对象和不可变对象。顾名思义,可变对象就是可以修改的对象,不可变对象就是不可修改的对象。套用之前形象的说法,可变对象就是出厂后用户可以 DIY 的商品;不可变对象就是出厂后直接“焊死”的商品,厂家态度强硬,用户原封不动地用可以,改我的商品没门!

Python里常见的可变对象有列表 list、字典 dict、集合 set,不可变对象有数值类型 (int、float 等)、字符串 str、元组 tuple。

3. 结合代码理解赋值

基于以上内容,下面通过几个简单的代码示例来深入理解Python的引用和赋值机制。

示例 (1)

1
2
3
4
5
6
7
a = "hello world"
b = a
a = "hello python"

print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

输出如下:

1
2
3
2087479844912 || a: hello python
2087479809520 || b: hello world
a is b: False
  1. a = "hello world":即 "hello world" --> a,首先创建不可变字符串对象“hello world”,再创建变量 a,并把 a 指向字符串对象“hello world”

  2. b = a:即 a --> b,创建变量 b,并把 b 指向 a 指向的字符串对象“hello world”,在这之后,a 和 b 指向同一个对象

  3. a = "hello python":即 "hello python" --> a,首先会创建新字符串“hello python”,然后把新字符串“hello python”的地址赋予 a (即,a 指向字符串“hello python”),此时,b 依然指向原字符串“hello world”,即,a 和 b 指向不同的对象

Fig. II-3-(1). 示例 (1)

示例 (2)

1
2
3
4
5
6
7
a = [1, 2, 3]
b = a
a = [4, 5, 6]

print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

输出如下:

1
2
3
2700523754376 || a: [4, 5, 6]
2700523753864 || b: [1, 2, 3]
a is b: False
  1. a = [1, 2, 3]:即 [1, 2, 3] --> a,首先创建可变列表对象 [1, 2, 3] ,再创建变量 a,并把 a 指向列表对象 [1, 2, 3]

  2. b = a:即 a --> b,创建变量 b,并把 b 指向 a 指向的列表对象 [1, 2, 3],在这之后,a 和 b 指向同一个对象

  3. a = [4, 5, 6]:即 [4, 5, 6] --> a,首先会创建新列表 [4, 5, 6],然后把新列表 [4, 5, 6]的地址赋予 a (即,a 指向列表 [4, 5, 6]),此时,b 依然指向原列表 [1, 2, 3],即,a 和 b 指向不同的对象

注:示例 (2) 中把 a = [4, 5, 6] 替换为 a = [1, 2, 3] 有同样的效果。

Fig. II-3-(2). 示例 (2)

示例 (3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a = "hello world"
b = a

print("Before adding")
print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

a = "hello world" + " python"

print("After adding")
print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

输出如下:

1
2
3
4
5
6
7
8
Before adding
2017334072880 || a: hello world
2017334072880 || b: hello world
a is b: True
After adding
2017334844192 || a: hello world python
2017334072880 || b: hello world
a is b: False
  1. a = "hello world":即 "hello world" --> a,首先创建不可变字符串对象“hello world”,再创建变量 a,并把 a 指向字符串对象“hello world”

  2. b = a:即 a --> b,创建变量 b,并把 b 指向 a 指向的字符串对象“hello world”,在这之后,a 和 b 指向同一个对象

  3. a = "hello world" + " python":即 "hello world" + " python" --> a,这一行代码实际上做了两件事,“+”代表的字符串连接操作,和“=”代表的赋值操作。首先执行的是“+”,字符串连接不会直接更改原字符串,而是新创建一个字符串保存连接后的结果;“+”之后,内存中有两个字符串对象“hello world”和“hello world python”,以及两个变量 a 和 b,并且此时 a 和 b 都指向字符串对象“hello world”;之后是赋值操作“=”,把新字符串“hello world python”的地址赋予 a (即,a 指向字符串“hello world python”),此时,b 依然指向原字符串“hello world”,即,a 和 b 指向不同的对象

Fig. II-3-(3). 示例 (3)

示例 (4)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a = [1, 2, 3]
b = a

print("Before a.append()")
print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

a = a + [4]

print("After a.append()")
print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

输出如下:

1
2
3
4
5
6
7
8
9
Before a.append()
Before a.append()
1555554062728 || a: [1, 2, 3]
1555554062728 || b: [1, 2, 3]
a is b: True
After a.append()
1555561513992 || a: [1, 2, 3, 4]
1555554062728 || b: [1, 2, 3]
a is b: False
  1. a = [1, 2, 3]:即 [1, 2, 3] --> a,首先创建可变列表对象 [1, 2, 3] ,再创建变量 a,并把 a 指向列表对象 [1, 2, 3]

  2. b = a:即 a --> b,创建变量 b,并把 b 指向 a 指向的列表对象 [1, 2, 3],在这之后,a 和 b 指向同一个对象

  3. a = a + [4]:即 a + [4] --> a,这一行代码实际上做了两件事,“+”代表的列表连接操作,和“=”代表的赋值操作。首先执行的是“+”,列表连接不会直接更改原列表,而是新创建一个列表保存连接后的结果;“+”之后,内存中有两个列表对象 [1, 2, 3] 和 [1, 2, 3, 4],以及两个变量 a 和 b,并且此时 a 和 b 都指向列表对象 [1, 2, 3];之后是赋值操作“=”,把新字符串 [1, 2, 3, 4] 的地址赋予 a (即,a 指向字符串 [1, 2, 3, 4]),此时,b 依然指向原字符串 [1, 2, 3],即,a 和 b 指向不同的对象

Fig. II-3-(4). 示例 (4)

示例 (5)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a = "hello world"
b = a

print("Before adding")
print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

a.replace('world', 'python')

print("After adding")
print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

输出如下:

1
2
3
4
5
6
7
8
Before adding
2303166191152 || a: hello world
2303166191152 || b: hello world
a is b: True
After adding
2303166191152 || a: hello world
2303166191152 || b: hello world
a is b: True
  1. a = "hello world":即 "hello world" --> a,首先创建不可变字符串对象“hello world”,再创建变量 a,并把 a 指向字符串对象“hello world”

  2. b = a:即 a --> b,创建变量 b,并把 b 指向 a 指向的字符串对象“hello world”,在这之后,a 和 b 指向同一个对象

  3. a.replace('world', 'python'):这一步不是赋值操作,由于字符串对象不可改变,这一操作会在新的地址新建一个字符串来保存字符串替代后的结果“hello python”,但是由于没有赋值操作,这一结果的地址并没有保存在某个变量里,此时 a 和 b 依然指向原不可变字符串“hello world”

Fig. II-3-(5). 示例 (5)

示例 (6)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a = [1, 2, 3]
b = a

print("Before a.append()")
print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

a.append(4)

print("After a.append()")
print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

输出如下:

1
2
3
4
5
6
7
8
Before a.append()
1464960569736 || a: [1, 2, 3]
1464960569736 || b: [1, 2, 3]
a is b: True
After a.append()
1464960569736 || a: [1, 2, 3, 4]
1464960569736 || b: [1, 2, 3, 4]
a is b: True
  1. a = [1, 2, 3]:即 [1, 2, 3] --> a,首先创建可变列表对象 [1, 2, 3] ,再创建变量 a,并把 a 指向列表对象 [1, 2, 3]

  2. b = a:即 a --> b,创建变量 b,并把 b 指向 a 指向的列表对象 [1, 2, 3],在这之后,a 和 b 指向同一个对象

  3. a.append(4):这一步不是赋值操作,a.append() 会直接修改变量 a 指向的可变对象 [1, 2, 3],修改之后,变量 a 指向的对象变为 [1, 2, 3, 4],由于 a 和 b 指向同一个可变对象,b 指向的对象自然也变为 [1, 2, 3, 4]

Fig. II-3-(6). 示例 (6)

小节

可以看到,可变对象和不可变对象的赋值操作有较大差异,但是归根结底还是有迹可循的,即,赋值操作“=”会不会“连带”改变多个变量,取决于“=”右边的操作是直接修改原对象还是创建新的对象来保存表达式结果!如果“=”右边直接修改原对象,则指向这一对象的所有变量都会发生同样的改变;如果“=”右边创建了新的对象保存结果,则只会改变“=”左边的那个变量,其他变量依然指向原对象。

具体来说,不可变对象由于不可改变,对于指向其的变量的任何操作都会新建一个新的对象并修改原变量的引用;可变对象由于可以改变,对于指向其的变量的操作则要稍显复杂,如果是修改原可变对象,则会连带改变指向这一可变对象的所有变量,如果是新建一个对象,则不会在多个变量之间“共享”操作。

继续套用之前形象的说法,我们有两个标签 a, b 挂在同一个商品 β\beta 上,此时,标签 a 和标签 b 都代表商品 β\beta

这时一个人说“我要修改标签 a 对应的商品”,他的做法是,首先顺藤摸瓜找到了标签 a 对应的商品 β\beta,然后对着 β\beta 一顿敲打,好家伙,失手了,商品贬值。因为标签 b 也挂在同一个商品 β\beta 上,b 自然也跟 a 一样共享了变化。

同样的情况,另一个人说“我要修改标签 a 对应的商品”,他的做法是新造了另一个商品 γ\gamma,然后把标签 a 挂到商品 γ\gamma 上了,这种情况下,不管 γ\gammaβ\beta 的衍生产品还是另一个完全不同的新产品,标签 b 由于依然挂在 β\beta 上,自然不会跟标签 a 一样改变。

4. Python中的参数传递

首先,Python中的方法或者说函数的主要作用在于代码复用,这样可以避免重复代码并提高代码可读性。比如,以下两段代码等价

1
2
3
4
5
6
7
8
9
def fn(a, b):
return ((a - b) / (a + b)) * a - b

r1 = fn(1, 2)
r2 = fn(40, 60)
r3 = fn(12, 512)
r1 = fn(18, 25)
r2 = fn(28, 38)
r3 = fn(78, 5)
1
2
3
4
5
6
7
8
9
10
11
12
a, b = 1, 2
r1 = ((a - b) / (a + b)) * a - b
a, b = 40, 60
r2 = ((a - b) / (a + b)) * a - b
a, b = 12, 512
r3 = ((a - b) / (a + b)) * a - b
a, b = 18, 25
r1 = ((a - b) / (a + b)) * a - b
a, b = 28, 38
r2 = ((a - b) / (a + b)) * a - b
a, b = 78, 5
r3 = ((a - b) / (a + b)) * a - b

所谓的函数参数传递也就是把实参赋值给形参,本质上也是赋值操作“=”,因此,大家同样用看待赋值的方法看待Python的传参即可,这里不再赘述,仅提供几个简单的代码示例便于理解。

示例 (7)

1
2
3
4
5
6
7
8
9
10
def fn(var):
print("{value}, {identity}".format(value=var, identity=id(var)))
var = 2
print("{value}, {identity}".format(value=var, identity=id(var)))


a = 1
print("{value}, {identity}".format(value=a, identity=id(a)))
fn(a)
print("{value}, {identity}".format(value=a, identity=id(a)))

输出如下:

1
2
3
4
1, 140703503524256
1, 140703503524256
2, 140703503524288
1, 140703503524256

这段代码把 a 作为参数传递给函数,这时 a 和 var 都指向内存中值为 1 的对象。然后在函数中 var = 2 时,因为 int 对象不可改变,于是创建一个新的 int 对象 (值为2 ) 并且令 var 指向它。而 a 仍然指向原来的值为 1 的 int 对象,所以函数没有改变变量 a

示例 (8)

1
2
3
4
5
6
7
8
9
10
def fn(var):
print("{value}, {identity}".format(value=var, identity=id(var)))
var.append(1)
print("{value}, {identity}".format(value=var, identity=id(var)))


b = []
print("{value}, {identity}".format(value=b, identity=id(b)))
fn(b)
print("{value}, {identity}".format(value=b, identity=id(b)))

输出如下:

1
2
3
4
[], 2886722054152
[], 2886722054152
[1], 2886722054152
[1], 2886722054152

这段代码把 b 作为参数传递给函数,这时 b 和 var 都会指向同一个值为 [] 的 list 类型的对象。因为 list 对象是可以改变的,函数中使用 append() 在其末尾添加了一个元素,list 对象的内容发生了改变,但是 b 和 var 仍然是指向这一个 list 对象,所以变量 b 的内容也发生了改变

示例 (9)

1
2
3
4
5
6
7
8
9
10
def fn(var):
print("{value}, {identity}".format(value=var, identity=id(var)))
var = var + [1]
print("{value}, {identity}".format(value=var, identity=id(var)))


c = []
print("{value}, {identity}".format(value=c, identity=id(c)))
fn(c)
print("{value}, {identity}".format(value=c, identity=id(c)))

输出如下:

1
2
3
4
# [], 2539980280200
# [], 2539980280200
# [1], 2539984060808
# [], 2539980280200

这段代码把 c 作为参数传递给函数,这时 c 和 var 都会指向同一个值为 [] 的 list 类型的对象。再看函数中的赋值操作 var = var + [1],“=”右边会先创建一个新的list类型的对象 (值为 [1] )并且令 var 指向它,而 c 仍然指向原来的值为 [] 的 list 对象,所以函数没有改变变量 c

综上,Python的传参不像C++,既不是严格的值传递,也不是严格的引用传递,要结合Python的赋值操作来理解。

5. Python的对象复用机制

以下是一段和 示例 (1) 类似的代码,但是这段代码呈现不同的结果

1
2
3
4
5
6
7
a = "abc"
b = a
a = "abc"

print("{id} || a: {val}".format(id=id(a), val=a))
print("{id} || b: {val}".format(id=id(b), val=b))
print("a is b: {}".format(a is b))

输出如下

1
2
3
1270154034160 || a: abc
1270154034160 || b: abc
a is b: True

我们可以发现,第二次出现的 a = "abc" 并没有新建一个字符串,而是复用了之前的字符串,两者相同的标识符可以证明这一点;把字符串替换为某些整形数字也有相同的发现,但是 示例 (2) 中我提到“注:示例 (2) 中把 a = [4, 5, 6] 替换为 a = [1, 2, 3] 有同样的效果”,难道Python只会复用不可变对象?

不完全对,正确的说法是,在Python中,对于部分不可变对象存在着复用机制。具体来说,处于 -5~256 之间的整型 (int) 数据对象以及部分字符串对象有着复用机制,同样是不可变对象的元组则没有复用机制。之所以说只有部分字符串有复用机制是,因为有一些特例,比如当字符串足够长的时候,Python在赋值时不会复用字符串;还有字符串有空格的话,Python也不会复用这一字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> a = -5
>>> b = -5
>>> a is b
True
>>> a = 256
>>> b = 256
>>> a is b
True
>>> a = -6
>>> b = -6
>>> a is b
False
>>> a = 1000
>>> b = 1000
>>> a is b
False
1
2
3
4
5
6
7
8
9
10
11
12
>>> a = "abc"
>>> b = "abc"
>>> a is b
True
>>> a = "abc def"
>>> b = "abc def"
>>> a is b
False
>>> a = "abc" * 100
>>> b = "abc" * 100
>>> a is b
False

我认为,既然不可变对象不可改变,那么不修改对象而仅仅是使用对象时,只要对象的值相同则,无所谓到底是同一个对象还是两个同值的对象;而对不可变对象的修改必然会创建新的对象,也就不会再存在两个变量引用同一个不可变对象,也不必担心因此可能产生的 bug。所以我觉得Python对于部分不可变对象的复用机制了解即可,绝大多数情况下这一机制不会造成运算结果的不同。以上仅是我个人的看法,目前我没有深入探究这一机制,感兴趣的可以检索关键词“Python 整数对象池”,欢迎讨论。

注:本节关于Python中不可变对象复用机制的讨论以及代码演示均基于Python的交互模式。实践中发现,无论是在PyCharm中执行脚本时,还是直接用解释器运行脚本,复用机制的复用范围均有所扩大,故而部分代码演示呈现出不同的结果,这与代码执行时的加载方式有关,此处不再展开,特此提醒。

III. 赋值、浅拷贝和深度拷贝

直接上思维导图

Fig. III. 赋值、浅拷贝和深度拷贝

示例程序如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import copy

a = [1, 2, 3, 4, ['a', 'b']]

b = a
c = copy.copy(a)
d = copy.deepcopy(a)

a.append(5)
a[4].append('c')

print('a = ', a)
print('b = ', b)
print('c = ', c)
print('d = ', d)

输出如下:

1
2
3
4
a =  [1, 2, 3, 4, ['a', 'b', 'c'], 5]
b = [1, 2, 3, 4, ['a', 'b', 'c'], 5]
c = [1, 2, 3, 4, ['a', 'b', 'c']]
d = [1, 2, 3, 4, ['a', 'b']]

参考


动手理解Python的引用、赋值、拷贝
https://zray111.github.io/2022/11/10/动手理解Python的引用、赋值、拷贝/
作者
ZRay
发布于
2022年11月10日
许可协议