Python 进阶指南(编程轻松进阶):八、常见的 Python 陷阱

原文:http://inventwithpython.com/beyond/chapter8.html

虽然 Python 是我最喜欢的编程语言,但它也不是没有缺陷。每种语言都有缺点(有些比其他的多),Python 也不例外。新的 Python 程序员必须学会避免一些常见的“陷阱”程序员学习这类知识是随机的,来自经验,但本章把它收集在一个地方。了解这些陷阱背后的编程知识可以帮助您理解为什么 Python 有时行为怪异。

这一章解释了当你修改列表和字典等可变对象的内容时,它们的行为会如何异常。您将了解到sort()方法是如何不按照字母顺序对项目进行排序的,以及浮点数是如何产生舍入误差的。当你将不等式操作符!=链接在一起时,它们会有不寻常的行为。并且在编写包含单个项目的元组时,必须使用尾随逗号。本章告诉你如何避免这些常见的陷阱。

不要在遍历列表时添加或删除项目

在用forwhile循环遍历(即迭代)列表时,从列表中添加或删除项目很可能会导致 bug。考虑这样一个场景:您想要遍历一个描述衣服的字符串列表,并通过每次在列表中找到一只袜子时插入一只匹配的袜子来确保有偶数只袜子。这个任务看起来很简单:遍历列表中的字符串,当在一个字符串中找到'sock',比如'red sock',将另一个'red sock'字符串追加到列表中。

但是这个代码不行。它陷入了一个无限循环,你必须按下Ctrl+C来中断它:

>>> clothes = ['skirt', 'red sock']
>>> for clothing in clothes:  # Iterate over the list.
...    if 'sock' in clothing:  # Find strings with 'sock'.
...        clothes.append(clothing)  # Add the sock's pair.
...        print('Added a sock:', clothing)  # Inform the user.
...
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

你会在autbor.com/addingloop看到这段代码的可视化执行。

问题是,当您将'red sock'追加到clothes列表中时,列表现在有了一个新的第三项,它必须迭代:['skirt', 'red sock', 'red sock']for循环在下一次迭代中到达第二个'red sock',因此它追加另一个'red sock'字符串。这使得列表['skirt', 'red sock', 'red sock', 'red sock']成为 Python 迭代的另一个字符串。这将继续发生,如图 8-1 中的所示,这就是为什么我们会看到永不停止的'Added a sock.'消息流。只有当计算机耗尽内存并使 Python 程序崩溃时,或者直到您通过按下Ctrl+C来中断它,循环才会停止。

f08001

图 8-1:在for循环的每一次迭代中,一个新的'red sock'被添加到列表中,clothing在下一次迭代中引用它。这个循环永远重复。

要点是不要在遍历列表时向列表中添加条目。取而代之的是,为新的、修改过的列表的内容使用一个单独的列表,比如本例中的newClothes

>>> clothes = ['skirt', 'red sock', 'blue sock']
>>> newClothes = []
>>> for clothing in clothes:
...    if 'sock' in clothing:
...        print('Appending:', clothing)
...        newClothes.append(clothing) # We change the newClothes list, not clothes.
...
Appending: red sock
Appending: blue sock
>>> print(newClothes)
['red sock', 'blue sock']
>>> clothes.extend(newClothes)  # Appends the items in newClothes to clothes.
>>> print(clothes)
['skirt', 'red sock', 'blue sock', 'red sock', 'blue sock']

这段代码的可视化执行在autbor.com/addingloopfixed进行。

我们的for循环遍历了clothes列表中的条目,但是没有修改循环内部的clothes。而是改了一个单独的列表,newClothes。然后,在循环之后,我们通过用newClothes的内容扩展来修改clothes。你现在有了一个匹配袜子的clothes列表。

同样,你不应该在遍历列表时删除列表中的条目。考虑这样一段代码,在这段代码中,我们想要从列表中移除任何不是'hello'的字符串。最简单的方法是遍历列表,删除不匹配的条目:

>>> greetings = ['hello', 'hello', 'mello', 'yello', 'hello']
>>> for i, word in enumerate(greetings):
...    if word != 'hello':  # Remove everything that isn't 'hello'.
...        del greetings[i]
...
>>> print(greetings)
['hello', 'hello', 'yello', 'hello']

这段代码的可视化执行在autbor.com/deletingloop进行。

名单里好像还剩下'yello'。原因是当for循环检查索引2时,它从列表中删除了'mello'。但是这将列表中所有剩余的条目下移一个索引,将'yello'从索引3移到索引2。循环的下一次迭代检查索引3,它现在是最后一个'hello',如图 8-2 中的所示。那根'yello'字符串浑浑噩噩的溜走了!不要在遍历列表的时候从列表中删除项目。

f08002

图 8-2:当循环删除'mello'时,列表中的项目下移一个索引,导致i跳过'yello'

相反,创建一个新列表,复制除要删除的项目之外的所有项目,然后替换原始列表。对于前一个示例的无错误等效物,请在交互式 Shell 中输入以下代码。

>>> greetings = ['hello', 'hello', 'mello', 'yello', 'hello']
>>> newGreetings = []
>>> for word in greetings:
...    if word == 'hello':  # Copy everything that is 'hello'.
...        newGreetings.append(word)
...
>>> greetings = newGreetings  # Replace the original list.
>>> print(greetings)
['hello', 'hello', 'hello']

这段代码的可视化执行在autbor.com/deletingloopfixed进行。

请记住,因为这段代码只是一个创建列表的简单循环,所以您可以用列表推导式来替换它。列表推导式不会运行得更快或使用更少的内存,但它更短,但不会失去太多的可读性。在交互式 Shell 中输入以下内容,这相当于前面示例中的代码:

>>> 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 个字节:

>>> import sys
>>> sys.getsizeof('cat')
52
>>> sys.getsizeof('a much longer string than just "cat"')
85

(在我使用的 Python 版本中,string 对象的开销占用 49 个字节,而字符串中的每个实际字符占用 1 个字节。)但是包含这些字符串中任何一个的列表都要占用 72 个字节,不管字符串有多长:

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

原因是,从技术上讲,列表不包含字符串,而只是对字符串的引用,无论引用的数据大小如何,引用的大小都是一样的。类似于newGreetings.append(word)的代码并没有复制word中的字符串,而是复制了对该字符串的引用。如果你想知道一个对象及其引用的所有对象占用了多少内存,Python 核心开发者 Raymond Hettinger 为此编写了一个函数,你可以在code.activestate.com/recipes/577504-compute-memory-footprint-of-an-object-and-its-cont访问这个函数。

所以你不应该觉得创建一个新的列表而不是在迭代时修改原来的列表是在浪费内存。即使您的列表修改代码看似有效,它也可能是需要很长时间才能发现和修复的细微错误的来源。浪费一个程序员的时间远比浪费一台计算机的内存更昂贵。


尽管在遍历列表(或任何可迭代对象)时不应该添加或删除列表中的项目,但是修改列表的内容是很好的。例如,我们有一个字符串形式的数字列表:['1', '2', '3', '4', '5']。我们可以在遍历列表时将这个字符串列表转换成整数列表[1, 2, 3, 4, 5]

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

这段代码的可视化执行在autbor.com/covertstringnumbers进行。修改列表中的项目就可以了;它改变了列表中容易出错的条目的数量。

在列表中安全地添加或删除条目的另一种可能的方法是从列表的末尾向后迭代到开头。这样,您可以在遍历列表时从列表中删除项,或者向列表中添加项,只要将它们添加到列表的末尾。例如,输入下面的代码,它从someInts列表中删除偶数整数。

>>> someInts = [1, 7, 4, 5]
>>> for i in range(len(someInts)):
...
...    if someInts[i] % 2 == 0:
...        del someInts[i]
...
Traceback (most recent call last):
 File "<stdin>", line 2, in <module>
IndexError: list index out of range
>>> someInts = [1, 7, 4, 5]
>>> for i in range(len(someInts) - 1, -1, -1):
...    if someInts[i] % 2 == 0:
...        del someInts[i]
...
>>> someInts
[1, 7, 5]

这段代码之所以有效,是因为循环将来迭代的所有项的索引都没有改变。但是在删除的值之后,值的重复上移使得这种技术对于长列表来说效率很低。这段代码的可视化执行在autbor.com/iteratebackwards1进行。你可以在图 8-3 中看到向前迭代和向后迭代的区别。

f08003

图 8-3:向前(左)和向后(右)迭代时从列表中删除偶数

类似地,当您向后遍历列表时,您可以将项目添加到列表的末尾。在交互式 Shell 中输入以下内容,它会将someInts列表中任何偶数的副本附加到列表的末尾:

>>> someInts = [1, 7, 4, 5]
>>> for i in range(len(someInts) - 1, -1, -1):
...    if someInts[i] % 2 == 0:
...        someInts.append(someInts[i])
...
>>> someInts
[1, 7, 4, 5, 4]

这段代码的可视化执行在autbor.com/iteratebackwards2进行。通过向后迭代,我们可以在列表中添加或删除条目。但是这可能很难做到正确,因为对这一基本技术的微小改变最终可能会引入错误。创建新列表比修改原始列表简单得多。正如 Python 核心开发者 Raymond Hettinger 所说:

  1. 问:循环遍历列表时修改列表的最佳实践是什么?
  2. 答:不要这么做。

不要在不使用copy.copy()copy.deepcopy()的情况下复制可变值

最好将变量视为引用对象的标签或名称标记,而不是包含对象的盒子。这个心智模型在修改可变对象时特别有用:列表、字典和集合等对象,它们的值可以发生变化(即改变)。当将引用可变对象的一个变量复制到另一个变量,并认为正在复制实际的对象时,会出现一个常见的问题。在 Python 中,赋值语句从不复制对象;它们只复制对一个对象的引用。(Python 开发者 Ned Batchelder 在 PyCon 2015 上有一个关于这个想法的精彩演讲,题目是“关于 Python 名称和值的事实和误解”在youtu.be/_AEJHKGk9ns观看。)

例如,在交互式 Shell 中输入以下代码,注意,即使我们只更改了spam变量,cheese变量也会更改:

>>> 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

这段代码的可视化执行在autbor.com/listcopygotcha1进行。如果你认为cheese = spam复制了列表对象,你可能会惊讶于cheese似乎已经改变了,尽管我们仅仅是修改了spam。但是赋值语句从不复制对象,只复制对象的引用。赋值语句cheese = spam使cheese 引用spam在计算机内存中相同的列表对象。它不会复制列表对象。这就是为什么改变spam也会改变cheese:两个变量引用同一个列表对象。

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

>>> def printIdOfParam(theList):
...    print(id(theList))
...
>>> eggs = ['cat', 'dog', 'eel']
>>> print(id(eggs))
2356893256136
>>> printIdOfParam(eggs)
2356893256136

这段代码的可视化执行在autbor.com/listcopygotcha2进行。注意,id()eggstheList返回的标识是相同的,这意味着这些变量引用同一个列表对象。eggs变量的列表对象没有复制到theList;相反,引用是复制的,这就是为什么两个变量引用同一个列表。一个引用的大小只有几个字节,但是想象一下如果 Python 复制了整个列表而不仅仅是引用。如果eggs包含十亿个条目而不是三个,那么将它传递给printIdOfParam()函数将需要复制这个巨大的列表。仅仅是做一个简单的函数调用,就要消耗掉千兆字节的内存!这就是为什么 Python 赋值只复制引用,从不复制对象。

防止这种情况的一种方法是用copy.copy()函数复制列表对象(不仅仅是引用)。在交互式 Shell 中输入以下内容:

>>> 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)

这段代码的可视化执行在autbor.com/copycopy1上。ham变量引用一个复制的列表对象,而不是由bacon引用的原始列表对象,所以它不会受到这个问题的影响。

但是就像变量像标签或名字标签而不是包含对象的盒子一样,列表也包含引用对象而不是实际对象的标签或名字标签。如果您的列表包含其他列表,copy.copy()仅复制对这些内部列表的引用。在交互式 Shell 中输入以下内容来查看该问题:

>>> 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)

这段代码的可视化执行在autbor.com/copycopy2进行。虽然baconham是两个不同的列表对象,但是它们引用相同的[1, 2][3, 4]内部列表,所以对这些内部列表的更改会在两个变量中得到反映,即使我们使用了copy.copy()。解决方案是使用copy.deepcopy(),它将复制被复制的列表对象中的任何列表对象(以及那些列表对象中的任何列表对象,等等)。在交互式 Shell 中输入以下内容:

>>> import copy
>>> bacon = [[1, 2], [3, 4]]
>>> ham = copy.deepcopy(bacon)
>>> id(bacon[0]), id(ham[0])
(2356896337352, 2356896466184)
>>> bacon[0][0] = 'CHANGED'
>>> bacon
[['CHANGED', 2], [3, 4]]
>>> ham
[[1, 2], [3, 4]]

这段代码的可视化执行在autbor.com/copydeepcopy进行。虽然copy.deepcopy()copy.copy()稍微慢一点,但是如果你不知道被复制的列表是否包含其他列表(或者其他可变对象,比如字典或者集合),那么使用它会更安全。我的一般建议是总是使用copy.deepcopy():它可能会防止细微的错误,并且你的代码可能不会被察觉。

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

Python 允许您为您定义的函数中的参数设置默认参数。如果用户没有显式设置参数,函数将使用默认参数执行。当对函数的大多数调用使用相同的参数时,这很有用,因为默认的参数使参数成为可选的。例如,为split()方法传递None会使其在空白字符上分割,但None也是默认参数:调用'cat dog'.split()与调用'cat dog'.split(None)做同样的事情。该函数使用默认参数作为参数的参数,除非调用方传入一个参数。*

但是你不应该设置一个可变对象,比如一个列表或者字典,作为默认参数。要了解这是如何导致错误的,请看下面的例子,它定义了一个addIngredient()函数,将一个配料字符串添加到一个代表三明治的列表中。因为这个列表的第一项和最后一项通常是'bread',所以可变列表['bread', 'bread']被用作默认参数:

>>> def addIngredient(ingredient, sandwich=['bread', 'bread']):
...    sandwich.insert(1, ingredient)
...    return sandwich
...
>>> mySandwich = addIngredient('avocado')
>>> mySandwich
['bread', 'avocado', 'bread']

但是使用一个可变的对象,比如像['bread', 'bread']这样的列表作为默认参数有一个微妙的问题:列表是在函数的def语句执行时创建的,而不是在每次调用函数时创建的。这意味着只创建了一个['bread', 'bread']列表对象,因为我们只定义了一次函数。但是每个函数调用addIngredient()都会重用这个列表。这会导致意外的行为,如下所示:

>>> 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']。因为sandwich参数列表与最后一次函数调用相同,所以'avocado'字符串再次出现。只创建了一个['bread', 'bread']列表,因为函数的def语句只执行一次,而不是每次调用函数时都执行。这段代码的可视化执行在autbor.com/sandwich进行。

如果需要使用列表或字典作为默认参数,Python 风格的解决方案是将默认参数设置为None。然后编写代码来检查这一点,并在调用该函数时提供新的列表或字典。这确保了每次调用函数时,函数都会创建一个新的可变对象,而不是在定义函数时只调用一次函数,如下例所示:

>>> 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 # 1

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

可变数据类型包括列表、字典、集合和由class语句构成的对象。不要将这些类型的对象作为默认参数放在def语句中。

不要用字符串连接来构建字符串

在 Python 中,字符串是不可变对象。这意味着字符串值不能改变,任何看似修改字符串的代码实际上都是在创建一个新的字符串对象。例如,以下每个操作都改变了spam变量的内容,不是通过改变字符串值,而是通过用具有新标识的新字符串值替换它:

>>> 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 字符串、format()字符串方法或者%s格式说明符来创建新的字符串也会创建新的字符串对象,就像字符串连接一样。通常情况下,这个技术细节无关紧要。Python 是一种高级语言,可以为您处理许多这些细节,因此您可以专注于创建您的程序。

但是通过大量的字符串连接来构建字符串会降低程序的速度。循环的每次迭代都会创建一个新的字符串对象并丢弃旧的字符串对象:在代码中,这看起来像是在一个forwhile循环中的连接,如下所示:

>>> 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 '来创建这些中间字符串值,将它们放入内存,然后在下一次迭代中几乎立即丢弃它们。这是一种浪费,因为我们只关心最后一个字符串。

构建字符串的 Python 风格方式是将较小的字符串追加到一个列表中,然后将列表连接成一个字符串。这个方法仍然创建 100,000 个字符串对象,但是它只执行一次字符串连接,当它调用join()时。例如,下面的代码产生了等效的finalString,但是没有中间字符串连接:

>>> finalString = []
>>> for i in range(100000):
...    finalString.append('spam ')
...
>>> finalString = ''.join(finalString)
>>> finalString
spam spam spam spam spam spam spam spam spam spam spam spam --snip--

当我在我的机器上测量这两段代码的运行时,列表追加方法比字符串连接方法快 10 倍。(第 13 章描述了如何测量你的程序运行的速度。)循环进行的迭代次数越多,这种差异就越大。但是当您将range(100000)改为range(100)时,尽管连接仍然比列表追加慢,但是速度差异可以忽略不计。你不需要在任何情况下都过分避免字符串连接、F 字符串、format()字符串方法或%s格式说明符。只有在执行大量字符串连接时,速度才会显著提高。

Python 让你不必考虑许多底层细节。这使得程序员可以快速编写软件,正如前面提到的,程序员的时间比 CPU 的时间更宝贵。但是在有些情况下,理解细节是有好处的,比如不可变字符串和可变列表之间的区别,这样可以避免陷入困境,比如通过连接构建字符串。

不要期望sort()按字母顺序排序

理解排序算法是计算机科学教育的一个重要基础,排序算法是按照某种既定顺序系统地排列值的算法。但这不是一本计算机科学的书;我们不需要知道这些算法,因为我们可以直接调用 Python 的sort()方法。然而,您会注意到sort()有一些奇怪的排序行为,将大写的Z放在小写的a之前:

>>> letters = ['z', 'A', 'a', 'Z']
>>> letters.sort()
>>> letters
['A', 'Z', 'a', 'z']

美国信息交换标准码(ASCII,读作“ask-ee”)是数字码(称为码点普通码)和文本字符之间的映射。sort()方法使用 ASCII 码位排序(一个通用术语,意思是按序数排序)而不是字母排序。在 ASCII 系统中,A用码位 65 表示,B用 66 表示,依此类推,直到Z用 90 表示。小写的a用码位 97 表示,b用 98 表示,依此类推,直到z用 122 表示。按 ASCII 排序时,大写的Z (码位 90)排在小写的a (码位 97)之前。

尽管在 20 世纪 90 年代之前和整个 20 世纪 90 年代,ASCII 在西方计算中几乎是通用的,但它只是一个美国标准:美元符号有一个码位,$(码位 36),但英镑符号没有码位。ASCII 在很大程度上已被 Unicode 取代,因为 Unicode 包含了 ASCII 的所有代码点和 100,000 多个其他代码点。

通过将字符传递给ord()函数,可以获得字符的码位或序号。您可以反过来将一个序数整数传递给chr()函数,该函数返回一个字符串。例如,在交互式 Shell 中输入以下内容:

>>> ord('a')
97
>>> chr(97)
'a'

如果你想进行字母排序,将str.lower方法传递给key参数。这将对列表进行排序,就好像对这些值调用了lower()字符串方法一样:

>>> letters = ['z', 'A', 'a', 'Z']
>>> letters.sort(key=str.lower)
>>> letters
['A', 'a', 'z', 'Z']

注意,列表中的实际字符串没有转换成小写;它们只是按原样排序。Ned Batchelder 在他的演讲“实用 Unicode,或者,我如何停止痛苦?”中提供了更多关于 Unicode 和码位的信息在nedbatchelder.com/text/unipain.html

顺带一提,Python 的sort()方法使用的排序算法是 Timsort,由 Python 核心开发者、《Python 之禅》作者 Tim Peters 设计。它是合并排序和插入排序算法的混合,在en.wikipedia.org/wiki/Timsort有描述。

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

计算机只能存储二进制数字系统的数字,即 1 和 0。为了表示我们熟悉的十进制数,我们需要将像3.14这样的数字翻译成一系列二进制的 1 和 0。计算机根据电气和电子工程师协会(IEEE,发音为“eye-triple-ee”)发布的 IEEE 754 标准来实现这一点。为简单起见,这些细节对程序员是隐藏的,允许您键入带小数点的数字,并忽略十进制到二进制的转换过程:

>>> 0.3
0.3

尽管具体情况的细节超出了本书的范围,但是浮点数的 IEEE 754 表示并不总是与十进制数完全匹配。一个众所周知的例子是0.1

>>> 0.1 + 0.1 + 0.1
0.30000000000000004
>>> 0.3 == (0.1 + 0.1 + 0.1)
False

这个奇怪的、略微不准确的总和是由计算机表示和处理浮点数的方式引起的舍入误差的结果。这不是 Python 陷阱;IEEE 754 标准是一种直接在 CPU 浮点电路中实现的硬件标准。在使用 IEEE 754 的 CPU(实际上是世界上的每一个 CPU)上运行的 C++、JavaScript 和其他任何语言都会得到相同的结果。

IEEE 754 标准也因为超出本书范围的技术原因,不能代表所有大于2 ** 53的整数值。例如,2 ** 532 ** 53 + 1作为浮点值,都四舍五入到9007199254740992.0

>>> float(2**53) == float(2**53) + 1
True

只要您使用浮点数据类型,就没有办法解决这些舍入误差。但是不用担心。除非你是在为一个银行、一个核反应堆或者一个银行的核反应堆编写软件,否则舍入误差很小,对你的程序来说可能不是一个重要的问题。通常,您可以使用较小面值的整数来解决它们:例如,用133美分代替1.33美元,或者用200毫秒代替0.2秒。这样,10 + 10 + 10加起来就是30分或毫秒,而不是0.1 + 0.1 + 0.1加起来就是0.30000000000000004美元或秒。

但是如果你需要精确的精度,比如说科学或金融计算,使用 Python 的内置decimal模块,该模块在docs.python.org/3/library/decimal.html有记载。虽然它们比较慢,但是Decimal对象是浮点值的精确替代。例如,decimal.Decimal('0.1')创建一个对象,它表示精确的数字 0.1,而不像0.1浮点值那样不精确。

将浮点值0.1传递给decimal.Decimal()会创建一个与浮点值具有相同不精确性的Decimal对象,这就是为什么最终的Decimal对象并不完全是Decimal('0.1')。相反,将浮点值的字符串传递给decimal.Decimal()。为了说明这一点,请在交互式 Shell 中输入以下内容:

>>> import decimal
>>> d = decimal.Decimal(0.1)
>>> d
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> d = decimal.Decimal('0.1')
>>> d
Decimal('0.1')
>>> d + d + d
Decimal('0.3')

整数没有舍入误差,所以传递给decimal.Decimal()总是安全的。在交互式 Shell 中输入以下内容:

>>> 10 + d
Decimal('10.1')
>>> d * 3
Decimal('0.3')
>>> 1 - d
Decimal('0.9')
>>> d + 0.1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'

但是Decimal对象没有无限精度;它们只是有一个可预测的、已建立的精确水平。例如,考虑以下操作:

>>> import decimal
>>> d = decimal.Decimal(1) / 3
>>> d
Decimal('0.3333333333333333333333333333')
>>> d * 3
Decimal('0.9999999999999999999999999999')
>>> (d * 3) == 1 # d is not exactly 1/3
False

表达式decimal.Decimal(1) / 3的计算结果不是三分之一。但默认情况下,它会精确到 28 位有效数字。您可以通过访问decimal.getcontext().prec属性来找出decimal模块使用了多少有效数字。(技术上来说,precgetcontext()返回的Context对象的一个属性,不过放在一行比较方便。)您可以更改该属性,以便之后创建的所有Decimal对象都使用这个新的精度级别。以下交互式 Shell 示例将精度从最初的 28 位有效数字降低到 2 位:

>>> import decimal
>>> decimal.getcontext().prec
28
>>> decimal.getcontext().prec = 2
>>> decimal.Decimal(1) / 3
Decimal('0.33')

decimal模块为您提供了对数字如何相互作用的精细控制。decimal模块在 https://docs.python.org/3/library/decimal.html 的有完整的文档。

不要链接不相等运算符!=

18 < age < 35这样的链接比较操作符或者像six = halfDozen = 6这样的链接赋值操作符分别是(18 < age) and (age < 35)six = 6; halfDozen = 6的便捷快捷方式。

但是不要链接!=比较操作符。您可能认为下面的代码检查了所有三个变量是否具有彼此不同的值,因为下面的表达式计算结果为True

>>> a = 'cat'
>>> b = 'dog'
>>> c = 'moose'
>>> a != b != c
True

但这个链条其实相当于(a != b) and (b != c)。这意味着a仍然可以与c相同,并且a != b != c表达式仍然是True

>>> a = 'cat'
>>> b = 'dog'
>>> c = 'cat'
>>> a != b != c
True

这个 bug 很微妙,代码也有误导性,所以最好避免使用链式!=操作符。

不要忘记单项目元组中的逗号

在代码中编写元组值时,请记住,即使元组只包含一项,也仍然需要一个尾随逗号。虽然值(42, )是一个包含整数42的元组,但是值(42)仅仅是整数42(42)中的圆括号类似于表达式(20 + 1) * 2中使用的圆括号,其计算结果为整数值42。忘记逗号会导致这样的结果:

>>> spam = ('cat', 'dog', 'moose')
>>> spam[0]
'cat'
>>> spam = ('cat')
>>> spam[0] # 1
'c'
>>> spam = ('cat', ) # 2
>>> spam[0]
'cat'

如果没有逗号,('cat')计算为字符串值,这就是为什么spam[0]计算为字符串的第一个字符,'c' 1 。要将括号识别为一个元组值 2 ,需要使用尾随逗号。在 Python 中,逗号构成的元组比括号多。

总结

每种语言都会出现沟通错误,甚至在编程语言中也是如此。Python 有几个陷阱会让粗心的人上当。即使它们很少出现,也最好了解它们,这样您就可以快速识别和调试它们可能导致的问题。

尽管在遍历列表时可以添加或删除列表中的条目,但这是潜在的错误来源。更安全的做法是迭代列表的副本,然后对原始列表进行修改。当你复制一个列表(或任何其他可变对象)时,记住赋值语句只复制对对象的引用,而不是实际的对象。您可以使用copy.deepcopy()函数复制对象(以及它引用的任何对象的副本)。

您不应该在def语句中使用可变对象作为默认参数,因为它们是在运行def语句时创建的,而不是在每次调用函数时创建的。一个更好的主意是设置默认参数None,然后添加代码来检查None,并在调用函数时创建一个可变对象。

一个微妙的问题是在一个循环中用+操作符将几个较小的字符串连接起来。对于少量迭代,这种语法很好。但是在幕后,Python 在每次迭代中不断地创建和销毁字符串对象。更好的方法是将较小的字符串追加到一个列表中,然后调用join()操作符来创建最终的字符串。

sort()方法按数字代码点排序,这与字母顺序不同:大写的Z排在小写的a之前。

浮点数有轻微的舍入误差,这是它们表示数字的方式的副作用。对于大多数程序来说,这并不重要。但是如果这对你的程序有影响,你可以使用 Python 的decimal模块。

永远不要把!=操作符串在一起,因为像'cat' != 'dog' != 'cat'这样的表达式会令人困惑地计算为True

尽管本章描述了您最有可能遇到的 Python 陷阱,但它们在大多数真实代码中并不经常出现。Python 在减少程序中可能出现的意外方面做得很好。在下一章,我们将涵盖一些更加罕见和完全怪异的陷阱。如果不去寻找,你几乎不可能遇到这些奇怪的 Python 语言,但是探索它们存在的原因会很有趣。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/9445.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

JavaScript 中问号的三种用法 ??和?.以及?: 您知道吗?

最近看了一些关于JavaScript的测试脚本&#xff0c;觉得JS 中问号的用法还是蛮有意思的&#xff0c;于是做了一下总结&#xff0c;在这里分享给大家&#xff01;JS中的问号大概有三种用法&#xff0c;分别是&#xff1a;空值合并操作符、可选链操作符和三目运算。 问号问号&…

如何使用手机远程锁定电脑?

​“有时我已经到家了&#xff0c;却忘记锁上我的公司的电脑。每次我都害怕我电脑上的数据丢失。我可以在手机上远程锁定我的Windows计算机以避免这个问题吗&#xff1f;” 答案是肯定的&#xff01;很多人可能会遇到同样的下班不锁电脑的问题&#xff0c;有的人可能尝…

新规拉开中国生成式AI“百团大战”序幕?

AI将走向何方&#xff1f; ChatGPT在全球范围掀起的AI热潮正在引发越来越多的讨论&#xff0c;AI该如何管理&#xff1f;AI该如何发展&#xff1f;一系列问题都成为人们热议的焦点。此前&#xff0c;马斯克等海外名人就在网络上呼吁OpenAI暂停ChatGPT的模型训练和迭代&#xf…

算法套路八——二叉树深度优先遍历(前、中、后序遍历)

算法套路八——二叉树深度优先遍历&#xff08;前、中、后序遍历&#xff09; 算法示例&#xff1a;LeetCode98&#xff1a;验证二叉搜索树 给你一个二叉树的根节点 root &#xff0c;判断其是否是一个有效的二叉搜索树。 有效 二叉搜索树定义如下&#xff1a; 节点的左子树只…

Shell脚本之免交互

一、Here Document免交互 1、 概念 Here Document使用I/O重定向的方式将命令列表提供给交互式程序或命令&#xff0c;比如 ftp、cat 或 read 命令。 是标准输入的一种替代品可以帮助脚本开发人员不必使用临时文件来构建输入信息&#xff0c;而是直接就地生产出一个"文件…

No.033<软考>《(高项)备考大全》【第17章】战略管理

【第17章】战略管理1 章节相关2 战略管理2.1 组织战略管理2.1 组织战略类型和层次2.1.1 组织事业战略类型2.1.2 组织事业战略类型2.1.3 组织完整的战略包括三个层次2.1.4 组织战略从层次分为组织层战略、事业层战略、职能层战略等2.1.5 平横计分卡2.1.6 项目组合管理3 练习题参…

Leetcode.111 二叉树的最小深度

题目链接 Leetcode.111 二叉树的最小深度 easy 题目描述 给定一个二叉树&#xff0c;找出其最小深度。 最小深度是从 根节点 到 最近叶子节点 的 最短路径上的节点数量。 说明: 叶子节点是指没有子节点的节点。 示例 1&#xff1a; 输入&#xff1a;root [3,9,20,null,nul…

车载网络 - Autosar网络管理 - 网络管理简介

一、什么是CAN网络管理及它的作用 现在的车辆是由大量的ECU节点组成的&#xff0c;为了能使各ECU能够正确并及时地进行CAN通信&#xff0c;需要有一套机制来统一协调总线上各节点的休眠唤醒&#xff0c;这套机制就是CAN网络管理&#xff08;NM&#xff09;。 网络管理的目的是保…

项目2:后端管理员项目结构初始化

项目2&#xff1a;后端管理员项目结构初始化 1.创建数据库和表 2.初始化父项目 3.初始化项目模块 4.初始化core核心模块&#xff08;代码生成器&#xff09; 项目2&#xff1a;后端管理员项目结构初始化 1.创建表 创建数据库 编码使用utf-8 sql语句 /*Navicat Premium …

18_I.MX6ULL_I2C实验

目录 I2C简介 起始位 停止位 数据传输 应答信号 I2C写时序 I2C读时序 I2C多字节读写时序 相关寄存器 AP3216C简介 实验源码 I2C简介 I2C是最常用的通信接口,众多的传感器都会提供I2C接口来和主控相连,比如陀螺仪、加速度计、触摸屏等等。所以I2C是做嵌入式开发必须…

【高项】项目人力资源管理,沟通管理与干系人管理(十大管理)

【高项】项目人力资源管理&#xff0c;沟通管理与干系人管理&#xff08;十大管理&#xff09; 文章目录1、人力资源管理1.1 什么是人力资源管理&#xff1f;1.2 如何进行人力资源管理&#xff1f;&#xff08;过程&#xff09;1.3 人力资源管理工具1.4 人力资源管理文件2、沟通…

语雀笔记备份导出

参考: https://www.cnblogs.com/ssslinppp/p/17020303.htmlhttps://github.com/yuque/yuque-exporterhttps://zhuanlan.zhihu.com/p/582287220https://www.yuque.com/duzh929/blog/ocffqghttps://www.yuque.com/hijiaobu/datalife/onf6sy#BKajf 现在需要超级管理员,若是没有超级…

【华为机试真题详解JAVA实现】—整数与IP地址间的转换

目录 一、题目描述 二、解题代码 一、题目描述 原理:ip地址的每段可以看成是一个0-255的整数,把每段拆分成一个二进制形式组合起来,然后把这个二进制数转变成 一个长整数。 举例:一个ip地址为10.0.3.193 每段数字 相对应的二进制数 10 000…

GDPU C语言 天码行空6

1. 数组顺序查找 ⭐ 语法题 #include<stdio.h>int main() {int n,x,i;int a[102];scanf("%d", &n);for (i 0; i < n; i){scanf("%d", &a[i]);}scanf("%d", &x);int idx -1;//记录x的最大下标int max 0;// 记录大于x的数…

如何写一个优质高效的网络项目实施方案?这篇文章值得收藏!

随着互联网技术的不断发展&#xff0c;网络项目的实施成为了许多企业和组织的重要任务。网络项目实施方案是指在进行网络项目实施时&#xff0c;为了保障项目的顺利进行&#xff0c;达到项目目标和交付要求&#xff0c;所制定的详细计划和操作指南。一个好的网络项目实施方案对…

Unity Game FrameWork—模块使用—对象池分析

官方说明&#xff1a;提供对象缓存池的功能&#xff0c;避免频繁地创建和销毁各种游戏对象&#xff0c;提高游戏性能。除了 Game Framework 自身使用了对象池&#xff0c;用户还可以很方便地创建和管理自己的对象池。 下图是Demo中用到的对象池&#xff0c;所有的实体以及UI都使…

C++11多线程:原子操作std::automic-用于多个线程之间共享的变量。

系列文章目录 文章目录系列文章目录前言一、std::automic二、使用步骤1.代码案例总结前言 原子操作std::automic的基本概念和用法。 一、std::automic std::atomic来代表原子操作&#xff0c;std::automic是个类模板。其实std::atomic这个东西是用来封装某个类型的值的。 1.1…

echarts tooltip文字太长换行

tooltip文字太长换行&#xff0c;设置了宽度也没有换行&#xff0c;加上一句&#xff1a; extraCssText: ‘max-width:300px; white-space:pre-wrap’, 没加之前是这样&#xff1a; 加上之后 extraCssText: ‘max-width:300px; white-space:pre-wrap’, tooltip: {trigger: &…

Mybatis(六)缓存

缓存是Mybatis中非常重要的特性&#xff0c;Mybatis的一级缓存基于SqlSession实现&#xff0c;二级缓存基于Mapper实现。 一、缓存的使用 一级缓存默认开启&#xff0c;Mybatis提供了一个配置参数localCacheScope来控制一级缓存的级别&#xff0c;该参数的取值可以是session、…

主动配电网故障恢复的重构与孤岛划分统一模型研究【升级版本】(Matlab代码实现)

&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;欢迎来到本博客❤️❤️&#x1f4a5;&#x1f4a5; &#x1f3c6;博主优势&#xff1a;&#x1f31e;&#x1f31e;&#x1f31e;博客内容尽量做到思维缜密&#xff0c;逻辑清晰&#xff0c;为了方便读者。 ⛳️座右铭&a…
最新文章