【python】读书笔记之常见陷阱(八)

八 常见的Python陷阱

8.1 循环列表的同时不要增删其中的元素

使用forwhile在列表上进行循环(也就是迭代)的同时增删其中的元素很可能会导致错误。想想这种情况:你要迭代一个包含各种描述衣服的字符串的列表,希望每次发现'sock'时插入另一个'sock',从而保证列表中的'sock'数是偶数。这个任务看起来很简单:遍历列表中的字符串,当发现某个字符串包含'sock'时(比如'red sock'),在列表后添加另外一个'red sock'字符串。

但以下这段代码并不奏效。它陷入了死循环,必须使用CTRL-C才能中断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> clothes = ['skirt', 'red sock']
>>> for clothing in clothes: # 迭代列表
... if 'sock' in clothing: # 寻找含有'sock'的字符串
... clothes.append(clothing) # 添加'sock'
... print('Added a sock:', clothing) # 告知用户
...
Added a sock: red sock
Added a sock: red sock
Added a sock: red sock
--snip--
Added a sock: red sock
Traceback (most recent call last):
File "<stdin>", line 3, in <module>
KeyboardInterrupt

  问题在于,当你把'red sock'追加到clothes列表中时,列表多出了第3项,需要被迭代的列表变成了['skirt', 'red sock', 'red sock']。在下一次迭代中,for循环遍历到了第2个'red sock',同样的故事继续重演,它又追加了一个'red sock'字符串,列表变为['skirt', 'red sock', 'red sock','red sock']。这样一来,Python又需要多遍历一个字符串,如图8-1所示。这就是为什么我们看到控制台总是输出"Added a sock: red sock"。这种循环只有在计算机内存耗尽以至于程序崩溃或者按CTRL-C中断时才会停止。

for循环的每一次迭代中,都有一个新的'red sock'被追加到列表中,作为下一次迭代的指向,形成死循环

  要点是不要在迭代列表的时候将元素添加进列表。应该使用一个单独的列表作为修改后的列表,比如这个例子中的newClothes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> clothes = ['skirt', 'red sock', 'blue sock']
>>> newClothes = []
>>> for clothing in clothes:
... if 'sock' in clothing:
... print('Appending:', clothing)
... newClothes.append(clothing) # 我们修改newClothes列表,而非clothes列表
...
Appending: red sock
Appending: blue sock
>>> print(newClothes)
['red sock', 'blue sock']
>>> clothes.extend(newClothes) # 将newClothes中的项添加到clothes中
>>> print(clothes)
['skirt', 'red sock', 'blue sock', 'red sock', 'blue sock']

当循环删除'mello'时,列表中的元素索引减1,导致索引i跳过了'yello'

  正确的做法是复制不想删除的项到一个新的列表,再用它替换原来的列表。在交互式shell中输入以下代码,可以得到前一个例子的正确版本:

1
2
3
4
5
6
7
8
9
>>> greetings = ['hello', 'hello', 'mello', 'yello', 'hello']
>>> newGreetings = []
>>> for word in greetings:
... if word == 'hello': # 复制是'hello'的每一项
... newGreetings.append(word)
...
>>> greetings = newGreetings # 替换原来的列表
>>> print(greetings)
['hello', 'hello', 'hello']

  这段代码只是一个用来创建列表的简单循环,通过列表推导式也可以做到。列表推导式在运行速度和内存使用上并不占优势,但可以在不损失可读性的前提下尽可能缩短打字时间。在交互式shell中输入上一个示例的等效代码:

1
2
3
4
>>> greetings = ['hello', 'hello', 'mello', 'yello', 'hello']
>>> greetings = [word for word in greetings if word == 'hello']
>>> print(greetings)
['hello', 'hello', 'hello']

  列表推导式不仅更简洁,还避免了在迭代时修改列表所引发的“陷阱”

引用、内存使用和sys.getsizeof()

  创建一个新列表看起来比修改原有列表更消耗内存。但是,正如变量包含的只是值的引用而非实际值,列表包含的也是值的引用。在前面的代码中,newGreetings.append(word)并没有复制word变量中的字符串,而只是复制了对该字符串的引用。相比之下,这个引用比实际的字符串小很多。

  使用sys.getsizeof()函数可以证实这一点。该函数的返回值是参数对象在内存中占用的字节数。在这个交互式shell示例中,可以看到短字符串'cat'占用了52字节,而长字符串占用了85字节:

1
2
3
4
5
>>> import sys
>>> sys.getsizeof('cat')
52
>>> sys.getsizeof('a much longer string than just "cat"')
85

  在我使用的Python版本中,字符串对象固有的开销占49字节,而字符串中每个实际字符再占用1字节。但包含了任意字符串的列表只占用72字节,无论字符串有多长。

1
2
3
4
>>> sys.getsizeof(['cat'])
72
>>> sys.getsizeof(['a much longer string than just "cat"'])
72

  原因在于,从技术原理上讲,列表并不包含字符串,而只包含对字符串的引用。无论引用的数据有多大,引用本身的大小是一样的。像newGreetings.append(word)这样的代码并不是在复制word中的字符串,而是复制对字符串的引用。Python核心开发者Raymond Hettinger编写了一个函数,可以用来了解一个对象及其引用的全部对象占用的内存。

  所以你不必觉得与迭代列表时对其进行修改相比,创建新列表会浪费内存。即使在修改迭代列表时不出错,它也可能隐藏着某些不易察觉的bug,需要很长时间才能排查和修复。程序员的时间要比计算机的内存值钱得多。

  虽然在迭代列表(或者任何可迭代对象)时不应该从中增删元素,但修改列表的某项内容是被允许的。比如有一个字符串形式的数字列表['1', '2', '3', '4', '5'],我们可以在迭代列表的同时将其转换成整数列表:

1
2
3
4
5
6
>>> numbers = ['1', '2', '3', '4', '5']
>>> for i, number in enumerate(numbers):
... numbers[i] = int(number)
...
>>> numbers
[1, 2, 3, 4, 5]

  修改列表中的元素是可以的,改变列表中的元素个数才容易导致问题。

8.2 复制可变值时务必使用copy.copy()copy.deepcopy()

  最好把变量看作指向对象的标签或者代号,而不是包含对象的盒子。这种模型在涉及修改可变对象时特别有用,比如列表、字典和集合等对象的值是可以改变的。一个常见的错误是复制指向某个对象的变量到另一个变量时,误以为是在复制实际的对象。在Python中,赋值语句从来都不会复制对象,而只会复制对象的引用。关于这个问题,Python开发者Ned Batchelder在PyCon 2015上发表了一场精彩的演讲,题为“Facts and Myths about Python Names and Values”(关于Python名称和值的事实与谬误)。

  来看一个示例。在交互式shell中输入以下代码,需要注意的是,尽管我们只改变了spam变量,但cheese变量也发生了变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> spam = ['cat', 'dog', 'eel']
>>> cheese = spam
>>> spam
['cat', 'dog', 'eel']
>>> cheese
['cat', 'dog', 'eel']
>>> spam[2] = 'MOOSE'
>>> spam
['cat', 'dog', 'MOOSE']
>>> cheese
['cat', 'dog', 'MOOSE']
>>> id(cheese), id(spam)
2356896337288, 2356896337288

  如果误认为cheese = spam复制了list对象,那么你可能会惊讶于尽管只修改了spam,但cheese也发生了变化。赋值语句从来不复制对象,而只复制对象的引用。赋值语句cheese = spam使cheese在计算机内存中引用了与spam相同的列表对象,而非复制了列表对象。这就是改变了spamcheese也随之改变的原因:两个变量指向了同一个列表对象。

  同样的原则也适用于作为函数调用参数的可变对象。在交互式shell中输入以下内容,注意全局变量spam和局部变量,参数(参数是在函数的def语句中定义的变量)theList指向同一个对象:

1
2
3
4
5
6
7
8
>>> def printIdOfParam(theList):
... print(id(theList))
...
>>> eggs = ['cat', 'dog', 'eel']
>>> print(id(eggs))
2356893256136
>>> printIdOfParam(eggs)
2356893256136

  注意id(eggs)id(theList)返回的身份是一样的,意味着这两个变量指向同一个列表对象。eggs变量对应的列表对象并没有被复制到theList中,而是复制了对象的引用。一个引用只有几字节大小。假设Python复制了列表而非引用,会如何呢?如果eggs不止3项,而有10亿项,那么把它传递给printIdOfParam()时需要复制这个巨大的列表。仅仅是一个简单的函数调用,就将消耗几千兆字节的内存。这就是Python赋值只复制引用而不复制对象的原因。

  如果你真的想复制这个列表对象(而不仅仅是引用),那么一个可行的方案是使用copy.copy()函数复制对象。在交互式shell中输入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
>>> import copy
>>> bacon = [2, 4, 8, 16]
>>> ham = copy.copy(bacon)
>>> id(bacon), id(ham)
(2356896337352, 2356896337480)
>>> bacon[0] = 'CHANGED'
>>> bacon
['CHANGED', 4, 8, 16]
>>> ham
[2, 4, 8, 16]
>>> id(bacon), id(ham)
(2356896337352, 2356896337480)

  ham变量指向了复制的列表对象,而非bacon所指向的原始列表对象,所以不会因为复制引用而导致错误。

  但正如之前的比喻所解释的,变量像是标签而非包含对象的盒子,列表包含的也是指向对象的标签而非实际的对象。如果列表中嵌套了其他列表,那么copy.copy()只会复制被嵌套列表项的引用。在交互式shell中输入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> import copy
>>> bacon = [[1, 2], [3, 4]]
>>> ham = copy.copy(bacon)
>>> id(bacon), id(ham)
(2356896466248, 2356896375368)
>>> bacon.append('APPENDED')
>>> bacon
[[1, 2], [3, 4], 'APPENDED']
>>> ham
[[1, 2], [3, 4]]
>>> bacon[0][0] = 'CHANGED'
>>> bacon
[['CHANGED', 2], [3, 4], 'APPENDED']
>>> ham
[['CHANGED', 2], [3, 4]]
>>> id(bacon[0]), id(ham[0])
(2356896337480, 2356896337480)

  虽然baconham是不同的列表对象,但它们都包含了两个同样的内部列表[1, 2][3, 4]。使用copy.copy()并不能处理这种情况1,正确的方法是使用copy.deepcopy(),它将复制列表对象内部的子列表对象(假如子列表还有子列表,也会层层递归)。在交互式shell中输入以下内容:

1它只能返回最外层对象的副本。——译者注

8.3 不要用可变值作为默认参数

Python允许为函数设置默认参数。当用户没有明确设定参数时,函数将使用默认参数执行。当函数的大多数调用使用相同的参数时,默认参数很有用,因为用户在某些情况下可以不必填写参数。比如在split()方法中传递None会使它在遇到空白字符时进行分割。None也是split()函数的默认参数:调用'cat dog'.split()等效于调用'cat dog'.split(None)。如果调用者不传入参数,函数就会使用对应的默认参数。

  但永远不要把一个可变对象(比如列表或者字典)设置为默认参数,因为它很容易导致错误。下面的例子进行了说明。这段代码定义了一个addIngredient()函数,它的作用是为一个代表三明治的列表添加某种调料字符串。因为这个列表的最前和最后一项通常是'bread',所以它使用了可变列表['bread', 'bread']作为默认参数:

1
2
3
4
5
6
7
>>> def addIngredient(ingredient, sandwich=['bread', 'bread']):
... sandwich.insert(1, ingredient)
... return sandwich
...
>>> mySandwich = addIngredient('avocado')
>>> mySandwich
['bread', 'avocado', 'bread']

  但使用像['bread', 'bread']列表这种可变对象作为默认参数会导致一个不易察觉的问题:这个列表是在函数的def语句执行时被创建的2,而不是在每次函数调用时被创建的。这意味着只有一个['bread', 'bread']列表对象被创建,因为我们只定义了一次addIngredient()函数。每次调用函数都会重复使用同一个列表,这就导致了意想不到的行为,比如下面这种情况:

1
2
3
4
5
6
>>> mySandwich = addIngredient('avocado')
>>> mySandwich
['bread', 'avocado', 'bread']
>>> anotherSandwich = addIngredient('lettuce')
>>> anotherSandwich
['bread', 'lettuce', 'avocado', 'bread']

  因为addIngredient('lettuce')使用了与之前调用相同的默认参数列表,而这个默认参数已经加入了'avocado',所以这个函数返回的并不是预想的['bread', 'lettuce', 'bread'],而是['bread', 'lettuce', 'avocado', 'bread']'avocado'字符串在新的调用结果中又出现了,因为sandwich参数的列表与上一次调用时是同一个。由于def语句只执行了一次,所以['bread', 'bread']列表只被创建了一次,而非每次函数调用时都会创建一个新的列表。

当需要列表或者字典作为默认参数时,Python的解决方法是将默认参数设置为None,然后通过代码检查参数是否为None,如果是,则在函数每次调用时提供一个新的列表或者字典。这么做可以确保每次调用函数时都创建一个新的可变对象,而不是只在定义时创建一次,比如下面这个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> def addIngredient(ingredient,sandwich=None):
... if sandwich is None:
... sandwich = ['bread','bread']
... sandwich.insert(1,ingredient)
... return sandwich
...
>>> firstSandwich = addIngredient('cranberries')
>>> firstSandwich
['bread', 'cranberries', 'bread']
>>> secondSandwich = addIngredient('lettuce')
>>> secondSandwich
['bread', 'lettuce', 'bread']
>>> id(firstSandwich) == id(secondSandwich)
False

  注意,firstSandwichsecondSandwich并不共享相同的列表引用❶,这是因为sandwich = ['bread', 'bread']这行代码在每次调用addIngredient()时都会创建一个新的列表对象,而不是仅在函数定义时创建一次。

  可变数据类型包括列表、字典、集合,以及由类语句创建的对象,不要把这些类型的对象作为def语句中的默认参数。

8.4 不要通过字符串连接创建字符串

在Python中,字符串是不可变的对象。这意味着字符串值不能被改变,那些看起来像是修改字符串的代码实际上都是在创建一个新的字符串对象。比如,下面的操作改变了spam变量的内容,并非是通过改变字符串的值,而是用一个新的具有不同身份的字符串值替换了它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> spam = 'Hello'
>>> id(spam), spam
(38330864, 'Hello')
>>> spam = spam + ' world!'
>>> id(spam), spam
(38329712, 'Hello world!')
>>> spam = spam.upper()
>>> id(spam), spam
(38329648, 'HELLO WORLD!')
>>> spam = 'Hi'
>>> id(spam), spam
(38395568, 'Hi')
>>> spam = f'{spam} world!'
>>> id(spam), spam
(38330864, 'Hi world!')

  注意,每次调用id(spam)都返回了一个不同的身份,因为spam中的字符串对象并不是被修改了,而是被一个全新的、拥有不同身份的字符串对象取代了。通过使用f-string、字符串的format()方法或者%s格式标识符跟使用+进行字符串拼接一样,都会创建新的字符串对象。这一点通常无关紧要。Python是一种高级程序语言,它处理了许多类似的细节,以便你专注程序开发而非技术细节。

但使用大量的字符串连接构建字符串会使你的程序变慢。循环的每一次迭代都会创建一个新的字符串对象,并丢弃旧的字符串对象。这种代码一般是forwhile循环内的字符串连接,如下所示:

1
2
3
4
5
6
>>> finalString = ''
>>> for i in range(100000):
... finalString += 'spam '
...
>>> finalString
spam spam spam spam spam spam spam spam spam spam spam spam --snip--

  因为finalString += 'spam'在循环中重复执行了100 000次,所以Python进行了100 000次字符串连接。CPU需要做的工作是:连接当前finalString'spam'以创建一个临时字符串,将其放在内存里,在下一个迭代中又立即丢掉它。这做了大量的无用功,因为我们只关心最终的字符串,并不关心临时字符串。

通过append形成列表的方法要比字符串连接的方法快10倍(第13章介绍了如何测量程序的运行速度)。for循环的迭代次数越多,两者的速度差异就越大。当range(100000)改为range(100)测量时,尽管字符串连接法还是比列表添加法慢,但差异微乎其微。不必每次都坚决避免使用字符串连接、f-string、字符串format()方法或%s格式标识符。只有在进行大量的字符串连接时,使用它们才会明显变慢。

  Python能够让程序员不必考虑底层细节,从而能更快地编写软件。正如前面提到的,程序员的时间比CPU的时间宝贵得多。但在有些情况下,了解一些细节(比如了解不可变的字符串和可变的列表之间的区别)是有必要的,它可以避免你坠入“陷阱”,比如使用连接方法来创建字符串这种并不聪明的做法。

8.5 不要指望sort()按照字母顺序排序

美国信息交换标准代码(ASCII,发音为ask-ee)是文本字符与数字代码(被称为码位或序号)之间的映射。sort()方法采用ASCII-betical排序方法(一个常见术语,意为按照序号排序)而非字母序。在ASCII系统中,“A”用码位65表示,“B”用66表示,以此类推,直到“Z”用90表示。小写字母“a”则由码位97表示,“b”由98表示,以此类推,直到“z”由122表示。当按ASCII进行排序时,大写字母“Z”(码位90)排在小写字母“a”(码位97)之前。

8.6 不要假设浮点数是完全准确的

计算机只能存储二进制数字,也就是1和0。为了表示人类熟悉的十进制数字,我们需要将3.14这类数字转换成二进制的0和1的序列。根据电气电子工程师学会(IEEE,发音为eye triple-ee)发布的IEEE 754标准,计算机将做这样的转换。为了方便使用,这些细节对程序员是隐藏的,可以直接输入带有小数点的十进制数字,不必关心十进制数字到二进制数字的转换过程:

1
2
>>> 0.3
0.3

  浮点数的IEEE 754表示法并不总是与十进制的数字完全等同。虽然对具体示例进行详细讲解超出了本书的范围,但还是在这里举一个经典示例——0.1问题:

1
2
3
4
>>> 0.1 + 0.1 + 0.1
0.30000000000000004
>>> 0.3 == (0.1 + 0.1 + 0.1)
False

  这个和正确结果存在细微差异的奇怪的结果是计算机表示和处理浮点数的方式存在舍入误差造成的。这不是Python的问题,而是IEEE 754标准的问题,它是一个在CPU浮点电路中实现的硬件标准,而非某种语言的软件标准。使用C++或JavaScript,甚至所有在使用IEEE 754标准的CPU上运行的语言(实际上世界上所有的CPU都遵循这一标准),都会得到相同的结果。

果需要做到准确,例如进行科学计算或者金融计算,那么可以使用Python内置的decimal模块。尽管十进制对象的计算速度比较慢,但好处是能够精确地替代浮点值。比如decimal.Decimal('0.1')可以创建一个精确表示数字0.1的值,不会存在浮点数的舍入错误。

8.7 不要使用链式!=运算符

链式比较运算符,如18 < age < 35,或链式赋值运算符,比如six = halfDozen = 6,分别是(18 < age) and (age < 35)以及six = 6; halfDozen = 6的简写。

  但不要使用链式比较运算符!=。你也许以为下面这段代码是在检查3个变量是否有不同的值,因为它的结果为True

1
2
3
4
5
>>> a = 'cat'
>>> b = 'dog'
>>> c = 'moose'
>>> a != b != c
True

  但实际上,它相当于(a != b) and (b != c),这意味着即使a等于c,表达式a != b != c的结果仍然为True

1
2
3
4
5
>>> a = 'cat'
>>> b = 'dog'
>>> c = 'cat'
>>> a != b != c
True

  这个错误很难排查,代码也很误导人,所以最好彻底避免使用链式!=运算符。

8.8 不要忘记在仅有一项的元组中添加逗号

在代码中写元组字面量时要记住,务必为仅有一项的元组添加拖尾逗号。(42,)是一个包含整数42的元组,而(42)只是整数42。(42)中的括号类似于表达式(20 + 1) * 2中的括号,是一个添加了括号的表达式,它的值为整数42。

九 Python的奇特难懂之处

9.1 为什么256是256,而257不是257

  ==运算符比较两个对象的值是否相等,is运算符则比较它们的身份是否相同。尽管整数42和浮点数42.0的值是相等的,但它们是不同的对象,保存在计算机内存的不同位置。通过id()函数检查它们的ID是否相同可以确认这一点:

当然,现实中的程序一般只会用到整数的值,不会在乎它的身份,根本不会使用is运算符比较整数、浮点数、字符串、布尔型或者其他简单数据类型的值。有个例外:当判断None时要使用is None而非== None,正如6.4.3节所提到的。除此之外,你几乎不会遇到这个问题。

9.4 传递空列表给all()

内置函数all()接受一个序列值,比如列表。如果该序列中的所有值都是真值,则返回True,如果其中存在假值,则返回False。可以认为all([False,True,True])等效于表达式False and True and True

  你可以将all()和列表推导式结合起来使用。首先基于列表创建一个布尔值的列表,然后使用all()计算。比如,在交互式shell中输入以下内容:

最好把all([])看作在计算“这个列表中不存在假值”,而不是“这个列表中都是真值”,否则会得到一些奇怪的结果。在交互式shell中输入以下内容:

9.5 布尔值是整数值

Python认为浮点数42.0等于整数值42,同时也认为布尔值TrueFalse分别等于1和0。在Python中,bool数据类型是int数据类型的一个子类(第16章将讨论类和子类)。你可以使用int()将布尔值转换为整数:


【python】读书笔记之常见陷阱(八)
http://example.com/2024/01/08/618python读书笔记之常见陷阱(八)/
作者
Wangxiaowang
发布于
2024年1月8日
许可协议