流畅的Python读书笔记(四)序列:序列的运算及陷阱
文章目录
- 流畅的Python读书笔记(四)序列:序列的运算及陷阱
- `+`、`*`运算
- `+`运算
- `*`运算
- `*`序列运算的陷阱
- 建立由列表构成的列表
- 序列的增量赋值:增强运算符`+=``*=`
- 不可变序列中含有可变序列——`+=`谜团
- 透过字节码分析代码运行逻辑
- 小结
- 参考资料
本篇笔记记录了序列的+
、*
、+=
、*=
运算的使用以及细节。着重介绍了关于+=
的一个谜题:t=(1,2,[3, 4]); t[2] += [50, 60]
,这条python语句会抛出异常,但是能够成功执行。
+
、*
运算
Python程序员默认序列是支持+
和*
操作的。这两种运算都是非常简单的,所以不会过多介绍。
首先明确一点,对于+
,*
这类运算符,作用于序列时,都不会修改原序列,而是会新创建序列
。即序列a
,b
。a + b
和a * b
,这两个表达式都不会修改a
,b
。
+
运算
- 用途:拼接序列。
- 示例:
a = [1, 2]
b = [i for i in range(10)]
c = a + b
print(c)
#结果为:
#[1, 2, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
*
运算
- 用途:复制序列并将复制的元素拼接
- 示例:
a = [1, 2]
a2 = a * 2
print('a2=', a2)
b = [3, 4]
b3 = b * 3
print('b3=', b3)
#结果为:
#a2= [1, 2, 1, 2]
#b3= [3, 4, 3, 4, 3, 4]
这里提一下关于*
运算作用于序列上时的陷阱。
*
序列运算的陷阱
有时,想要复制序列并拼接,最简单的方法就是使用*
。
>>> a = [1, 2]
>>> a = a * 2
>>> a
[1, 2, 1, 2]
>>> a[3] = 100 #修改一个值
>>> a
[1, 2, 1, 100] #只有一个值被改动
以上代码简单易懂。但是如果我们操作的序列中的元素本身又是序列类型呢?
>>> a = [[1, 2], [3, 4]] #a是由两个列表构成的列表,简称为嵌套列表
>>> a = a * 2 #复制并拼接
>>> a
[[1, 2], [3, 4], [1, 2], [3, 4]]
>>> a[0][1] = 100 #修改一个值
>>> a
[[1, 100], [3, 4], [1, 100], [3, 4]] #有两个发生了改动,不符合预期
上述代码不符合预期。本来只是想改动一个值,但是却改动了两个。
根本原因是:列表是引用数据类型。
注:下面说明时,地址和引用没有区分开来,实际上二者在不同语言中有较大区别。但是地址和引用的作用有点类似,下面使用地址说明,更便于理解。实际实现过程,需要看Python源码。在流畅的Python书中,关于本章的内容,还只有介绍如何使用,相关实现在书后面的章节再做介绍。
同时,对于有过其他编程语言(比如C/C++)学习经历的读者,可以参看文末的两篇关于Python中引用
的文章,能够加深理解。
比如:a = [1, 2]
这句Python语句中,变量a
中存储的并不是1
,2
这两个值,而是存放[1,2]
的引用或者说是指向存储[1,2]
的内存空间的地址。
所以,对于a = [[1, 2], [3, 4]]
,变量a
指向的空间中实际存储的是两个地址,抽象一下,a = [地址值1,地址值2]
,那么在复制时,a = a * 2
,Python会拷贝a
指向的空间中的值,然后拼接到a
的后面。所以,现在a 等于 [地址值1,地址值2, 地址值1, 地址值2]
。a[0],a[2]
指向相同的空间,因此当修改a[0]
指向的空间时,a[2]
指向的空间也就被修改了。
建立由列表构成的列表
根据以上介绍,我们知道了,对于嵌套列表,使用*
运算,会到达预料之外的效果。那么如何构建嵌套列表呢?或者说如何构建嵌套序列呢?
书中推荐的方法是:使用列表推导式。
>>> x = [[i] for i in range(10)]
>>> x
[[0], [1], [2], [3], [4], [5], [6], [7], [8], [9]]
>>> for i in range(10):\
... x[i][0] += 1
...
>>> x
[[1], [2], [3], [4], [5], [6], [7], [8], [9], [10]]
# 每个元素都加了1,说明列表x中每个列表元素都是互不相关的
序列的增量赋值:增强运算符+=``*=
增量赋值运算符 += 和 *= 的表现取决于它们的第一个操作对象。
由于+=
和*=
的运算过程类似,只介绍+=
。
+= 背后的特殊方法是
__iadd__
(用于“就地加法”)。但是如果一个类没有实现这个方法的话,Python 会退一步调用__add__
。也就是说,如果运算对象实现了
__iadd__
,那么就调用该方法,这样该运算就转换成了对象的方法调用,对象会就地修改。如果运算对象没有实现__iadd__
的话,a += b
这个表达式的效果就变成了a = a + b
,这时,就是调用__add__
方法了,先计算a+b
,然后将结果赋值给a
。对于*=
运算,其背后的特殊方法为__imul__
,调用过程与+=
基本一致,可以类推。
下面给出书中的例子,加深印象:
>>> l = [1, 2, 3]
>>> id(l)
4311953800 ①
>>> l *= 2
>>> l
[1, 2, 3, 1, 2, 3]
>>> id(l)
4311953800 ②
>>> t = (1, 2, 3)
>>> id(t)
4312681568 ③
>>> t *= 2
>>> id(t)
4301348296 ④
① 刚开始时列表的 ID。
② 运用增量乘法后,列表的 ID 没变,新元素追加到列表上。
③ 元组最开始的 ID。
④ 运用增量乘法后,新的元组被创建。
对不可变序列进行重复拼接操作的话,效率会很低,因为每次都有一个新对象,而解释器需要把原来对象中的元素先复制到新的对象里,然后再追加新的元素。但是str 是一个例外,因为对字符串做 += 实在是太普遍了,所以 CPython 对它做了优化。为 str 初始化内存的时候,程序会为它留出额外的可扩展空间,因此进行增量操作的时候,并不会涉及复制原有字符串到新位置这类操作。
不可变序列中含有可变序列——+=
谜团
先看看下面这段代码,预测其运行结果:
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
如果你有了自己的预测,可以输入以上代码进行验证。
最后的结果是,解释器抛出异常,但是t
被修改。即:
t 变成
(1, 2, [30, 40, 50, 60])
。tuple
不支持对它的元素赋值,所以会抛出TypeError
异常。
结果:
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> t
(1, 2, [30, 40, 50, 60])
透过字节码分析代码运行逻辑
这里分析一下表达式s[a] += b
的执行过程,对上面那个例子会有更好的理解。
这里笔者水平有限,暂时看不懂Python字节码。
下面的例子源自书中。
>>> import dis
>>> dis.dis('s[a] += b')
1 0 LOAD_NAME 0 (s)
2 LOAD_NAME 1 (a)
4 DUP_TOP_TWO
6 BINARY_SUBSCR #①
8 LOAD_NAME 2 (b)
10 INPLACE_ADD #②
12 ROT_THREE
14 STORE_SUBSCR #③
16 LOAD_CONST 0 (None)
18 RETURN_VALUE
① 将 s[a]
的值存入 TOS
(Top Of Stack,栈的顶端)。
② 计算 TOS += b
。这一步能够完成,是因为 TOS
指向的是一个可变对象,也就是t[2]
③ s[a] = TOS
赋值。这一步失败,是因为s
是不可变的元组。
小结
- 不要把可变对象放在元组里面。
- 使用
*
作用于序列时,需要确保原序列中的元素不是引用类型。 - 应该通过列表推导式来构建嵌套序列
- 在有需要时,可以通过
dis
库来查看代码的字节码
参考资料