流畅的Python读书笔记(四)序列:序列的运算及陷阱

文章目录

  • 流畅的Python读书笔记(四)序列:序列的运算及陷阱
  • `+`、`*`运算
  • `+`运算
  • `*`运算
  • `*`序列运算的陷阱
  • 建立由列表构成的列表
  • 序列的增量赋值:增强运算符`+=``*=`
  • 不可变序列中含有可变序列——`+=`谜团
  • 透过字节码分析代码运行逻辑
  • 小结
  • 参考资料

本篇笔记记录了序列的+*+=*=运算的使用以及细节。着重介绍了关于+=的一个谜题:t=(1,2,[3, 4]); t[2] += [50, 60],这条python语句会抛出异常,但是能够成功执行。

+*运算

Python程序员默认序列是支持+*操作的。这两种运算都是非常简单的,所以不会过多介绍。

首先明确一点,对于+*这类运算符,作用于序列时,都不会修改原序列,而是会新创建序列。即序列a,ba + ba * 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是不可变的元组。

小结

  1. 不要把可变对象放在元组里面。
  2. 使用*作用于序列时,需要确保原序列中的元素不是引用类型。
  3. 应该通过列表推导式来构建嵌套序列
  4. 在有需要时,可以通过dis库来查看代码的字节码

参考资料