Python 进阶指南(编程轻松进阶):五、发现代码异味
原文:http://inventwithpython.com/beyond/chapter5.html
导致程序崩溃的代码显然是错误的,但是崩溃并不是发现程序问题的唯一手段。其他迹象可能表明程序存在更微妙的错误或不可读的代码。就像气体的味道可以指示气体泄漏或者烟雾的味道可以指示火灾一样,代码异味是指示潜在错误的源代码模式。代码异味并不一定意味着存在问题,但它确实意味着您应该关注您的程序。
本章列出了几种常见的代码异味。预见一个 bug 比以后遇到、理解并修复一个 bug 花费的时间和精力要少得多。每个程序员都有这样的故事:花了几个小时调试,却发现修复只需要修改一行代码。出于这个原因,即使是一点潜在的错误也应该让你停下来,提醒你再次检查,排除你代码潜在的问题。
当然,代码异味不一定是问题。最终,是解决还是忽略代码异味您自己拿捏。
重复代码
最常见的代码异味就是重复代码。重复代码是您通过将一些其他代码复制并粘贴到程序中。例如,这个短程序包含重复的代码。请注意,它三次询问用户的感受:
print('Good morning!')
print('How are you feeling?')
feeling = input()
print('I am happy to hear that you are feeling ' + feeling + '.')
print('Good afternoon!')
print('How are you feeling?')
feeling = input()
print('I am happy to hear that you are feeling ' + feeling + '.')
print('Good evening!')
print('How are you feeling?')
feeling = input()
print('I am happy to hear that you are feeling ' + feeling + '.')
重复代码是一个问题,因为它使更改代码变得困难;您对重复代码的一个副本所做的更改必须适用于程序中的每个副本。如果你忘记在某个地方做一个改变,或者如果你对不同的副本做了不同的改变,你的程序很可能会以错误告终。
复制代码的解决方案是对其去重;也就是说,通过将代码放在函数或循环中,使它在程序中出现一次。在下面的例子中,我将重复的代码移动到一个函数中,然后重复调用该函数:
def askFeeling():
print('How are you feeling?')
feeling = input()
print('I am happy to hear that you are feeling ' + feeling + '.')
print('Good morning!')
askFeeling()
print('Good afternoon!')
askFeeling()
print('Good evening!')
askFeeling()
在下一个例子中,我将重复的代码移到了一个循环中:
for timeOfDay in ['morning', 'afternoon', 'evening']:
print('Good ' + timeOfDay + '!')
print('How are you feeling?')
feeling = input()
print('I am happy to hear that you are feeling ' + feeling + '.')
您也可以结合这两种技术,使用函数和循环:
def askFeeling(timeOfDay):
print('Good ' + timeOfDay + '!')
print('How are you feeling?')
feeling = input()
print('I am happy to hear that you are feeling ' + feeling + '.')
for timeOfDay in ['morning', 'afternoon', 'evening']:
askFeeling(timeOfDay)
请注意,产生“早上好/下午好/晚上好!”消息相似但不完全相同。在程序的第三个改进中,我对代码进行了参数化,以消除相同部分的重复数据。同时,timeOfDay
参数和timeOfDay
循环变量替换不同的部分。现在,我已经通过删除额外的副本对该代码进行了重复数据删除,我只需要在一个地方进行任何必要的更改。
与所有代码异味一样,避免重复代码并不是一个必须始终遵循的硬性规则。一般来说,重复代码段越长,或者程序中出现的重复副本越多,就越有必要进行重复数据删除。我不介意复制粘贴一次甚至两次代码。但是,当我的程序中存在三个或四个副本时,我通常会考虑对代码进行重复数据删除。
有时候,代码不值得去重复。将本节中的第一个代码示例与最新的代码示例进行比较。虽然重复的代码更长,但它简单明了。经过重复数据删除的示例做了同样的事情,但是涉及到一个循环、一个新的timeOfDay
循环变量和一个新的函数,该函数带有一个名为timeOfDay
的参数。
重复代码是一种代码异味,因为它使您的代码更难一致地更改。如果程序中有几个重复的代码,解决方法是将代码放在一个函数或循环中,这样它只出现一次。
魔术数字
编程涉及数字并不奇怪。但是你的源代码中出现的一些数字可能会让其他程序员感到困惑(或者让你在编写它们几周后感到困惑)。例如,考虑下面一行中的数字604800
:
expiration = time.time() + 604800
time.time()
函数返回一个表示当前时间的整数。我们可以假设expiration
变量将代表 604,800 秒后的某个时刻。但是604800
很神秘:这个截止日期有什么意义?注释有助于澄清:
expiration = time.time() + 604800 # Expire in one week.
这是一个很好的解决方案,但更好的方案是用常量替换这些“魔术”数字。常量是变量,其名称以大写字母书写,表示其值在初始赋值后不应改变。通常,常量在源代码文件的顶部被定义为全局变量:
# Set up constants for different time amounts:
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY
`--snip--`
expiration = time.time() + SECONDS_PER_WEEK # Expire in one week.
即使魔术数字是相同的,也应该为不同用途的魔术数字使用不同的常量。例如,一副扑克牌中有 52 张牌,一年有 52 周。但是如果你的程序中有这两个量,你应该做如下的事情:
NUM_CARDS_IN_DECK = 52
NUM_WEEKS_IN_YEAR = 52
print('This deck contains', NUM_CARDS_IN_DECK, 'cards.')
print('The 2-year contract lasts for', 2 * NUM_WEEKS_IN_YEAR, 'weeks.')
当您运行此代码时,输出将如下所示:
This deck contains 52 cards.
The 2-year contract lasts for 104 weeks.
使用单独的常量允许您在将来独立地更改它们。请注意,在程序运行时,常量变量不应该改变值。但这并不意味着程序员永远不能在源代码中更新它们。例如,如果未来版本的代码包含一张百搭牌,您可以在不影响weeks
常量的情况下更改cards
常量:
NUM_CARDS_IN_DECK = 53
NUM_WEEKS_IN_YEAR = 52
术语魔术数字也适用于非数值。例如,您可以使用字符串值作为常量。考虑下面的程序,它要求用户输入一个方向,如果方向是北,就显示一个警告。一个'nrth'
输入错误导致程序无法显示警告:
while True:
print('Set solar panel direction:')
direction = input().lower()
if direction in ('north', 'south', 'east', 'west'):
break
print('Solar panel heading set to:', direction)
if direction == 'nrth': # 1
print('Warning: Facing north is inefficient for this panel.')
这个错误很难被发现:字符串'nrth'
中的错别字, 因为这段程序仍然是语法正确的 Python 代码。程序不会崩溃,并且很容易忽略没有警告信息。但是如果我们使用常量并犯了同样的错误,这个错误会导致程序崩溃,因为 Python 会注意到一个NRTH
常量并不存在:
# Set up constants for each cardinal direction:
NORTH = 'north'
SOUTH = 'south'
EAST = 'east'
WEST = 'west'
while True:
print('Set solar panel direction:')
direction = input().lower()
if direction in (NORTH, SOUTH, EAST, WEST):
break
print('Solar panel heading set to:', direction)
if direction == NRTH: # 1
print('Warning: Facing north is inefficient for this panel.')
由带有NRTH
错别字的代码行引发的NameError
异常使得当您运行该程序时,该错误立即变得明显:
Set solar panel direction:
west
Solar panel heading set to: west
Traceback (most recent call last):
File "panelset.py", line 14, in <module>
if direction == NRTH:
NameError: name 'NRTH' is not defined
魔术数字是一种代码异味,因为它们没有传达它们的目的,使你的代码可读性更差,更难更新,并且容易出现不可察觉的拼写错误。解决方法是使用常量变量。
注释掉的代码和僵尸代码
注释掉代码使其不运行作为一种临时措施是好的。您可能希望跳过一些行来测试其他功能,将它们注释掉便于以后添加回去。但是,如果注释掉的代码仍然存在,那么它为什么被删除以及在什么情况下可能会再次需要它就完全是个谜了。看看下面的例子:
doSomething()
#doAnotherThing()
doSomeImportantTask()
doAnotherThing()
这段代码提示了许多没有答案的问题:为什么doAnotherThing()
被注释掉了?我们还会把它包括进来吗?为什么第二次调用doAnotherThing()
没有被注释掉?最初为什么调用doAnotherThing()
两次,还是有一次doSomeImportantTask()
之后被注释了?我们有理由不删除被注释掉的代码吗?这些问题没有现成的答案。
僵尸代码是不可达或者逻辑上永远无法运行的代码。例如,在一个函数内部但是在一个return
语句之后的代码,在一个具有总是False
条件的if
语句块中的代码,或者在一个从来没有被调用的函数中的代码都是僵尸代码。要在实践中看到这一点,请在交互式 Shell 中输入以下内容:
>>> import random
>>> def coinFlip():
... if random.randint(0, 1):
... return 'Heads!'
... else:
... return 'Tails!'
... return 'The coin landed on its edge!'
...
>>> print(coinFlip())
Tails!
return 'The coin landed on its edge!'
这一行是僵尸代码,因为if
和else
块中的代码在执行到达该行之前返回。僵尸代码具有误导性,因为阅读它的程序员认为它是程序的有效部分,而实际上它与注释掉的代码是一样的。
桩是这些代码异味的一个例外。这些是未来代码的占位符,比如尚未实现的函数或类。代替真正的代码,桩包含一个pass
语句,它什么也不做。(也称无操作)只有pass
语句,因此您可以在语言语法需要一些代码的地方创建桩:
>>> def exampleFunction():
... pass
...
调用此函数时,它不执行任何操作。相反,它只是想表示代码日后终将被添加进来。
或者,为了避免意外调用一个未实现的函数,您可以用一个raise NotImplementedError
语句将其桩化。这将立即表明该函数还没有准备好被调用:
>>> def exampleFunction():
... raise NotImplementedError
...
>>> exampleFunction()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in exampleFunction
NotImplementedError
每当你的程序意外调用一个桩函数或方法时,抛出一个NotImplementedError
将会警告你。
注释掉的代码和僵尸代码都是代码异味,因为它们会误导程序员认为代码是程序的可执行部分。相反,删除它们并使用版本控制系统,如 Git 或 Subversion,来跟踪变更。版本控制包含在第 12 章中。有了版本控制,您可以从程序中删除代码,如果需要,以后可以很容易地将代码添加回去。
打印调试
打印调试是在程序中放置临时print()
调用来显示变量值,然后重新运行程序的做法。该过程通常遵循以下步骤:
- 注意你程序中的一个错误。
- 使用
print()
来查看一些变量值。 - 重新运行程序。
- 再加一些
print()
,因为之前的没有显示足够的信息。 - 重新运行程序。
- 在最终找出错误之前,重复前面的两个步骤几次。
- 重新运行程序。
- 意识到你忘了移除一些
print()
,并移除它们。
打印调试看似快速简单。但是在显示修复 bug 所需的信息之前,通常需要多次重复运行程序。解决方案是使用 debug 或为程序设置日志文件。通过使用 debug,您可以一次运行一行代码并检查任何变量。使用 debug 可能看起来比简单地插入一个print()
调用要慢,但是从长远来看,它可以节省您的时间。
日志文件可以记录你的程序的大量信息,这样你就可以比较它的一次运行和以前的运行。在 Python 中,内置的logging
模块提供了只需使用三行代码就能轻松创建日志文件的功能:
import logging
logging.basicConfig(filename='log_filename.txt', level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
logging.debug('This is a log message.')
导入logging
模块并设置其基本配置后,您可以调用logging.debug()
将信息写入文本文件,而不是使用print()
将其显示在屏幕上。与打印调试不同,调用logging.debug()
可以很明显地看出什么输出是调试信息,什么输出是程序正常运行的结果。你可以在(Automate the Boring Stuff with Python)第 11 章找到更多关于调试的信息,你可以在autbor.com/2e/c11
在线阅读。
带有数字后缀的变量
编写程序时,您可能需要多个存储同一种数据的变量。在这些情况下,您可能会尝试通过在变量名后面添加数字后缀来重用它。例如,如果您正在处理一个要求用户输入两次密码以防止输入错误的注册表单,您可以将这些密码字符串存储在名为password1
和password2
的变量中。这些数字后缀不能很好地描述变量包含的内容或它们之间的差异。他们也没有指出这些变量有多少:是有一个password3
还是一个password4
?尝试创建不同的名称,而不是懒洋洋地添加数字后缀。对于这个密码例子来说,更好的名字应该是password
和confirm_password
。
让我们看另一个例子:如果你有一个处理起点和终点坐标的函数,你可能有参数x1
、y1
、x2
和y2
。但是数字后缀的名字并不像名字start_x
、start_y
、end_x
和end_y
传达那么多信息。与x1
和y1
相比,start_x
和start_y
变量相互关联,这一点也更清楚。
如果您的数字后缀超过 2,您可能希望使用列表或集合数据结构将数据存储为集合。例如,您可以将pet1Name
、pet2Name
、pet3Name
等的值存储在一个名为petNames
的列表中。
这种代码异味并不适用于每一个仅仅以数字结尾的变量。例如,有一个名为enableIPv6
的变量完全没问题,因为“6”是“IPv6”专有名称的一部分,而不是数字后缀。但是,如果您对一系列变量使用数字后缀,请考虑用一种数据结构(如列表或字典)来替换它们。
类中应该只有函数或模块
使用 Java 等语言的程序员习惯于创建类来组织他们的程序代码。例如,让我们看看这个示例Dice
类,它有一个roll()
方法:
>>> import random
>>> class Dice:
... def __init__(self, sides=6):
... self.sides = sides
... def roll(self):
... return random.randint(1, self.sides)
...
>>> d = Dice()
>>> print('You rolled a', d.roll())
You rolled a 1
这看起来像是组织良好的代码,但是想想我们的实际需求是什么:1 到 6 之间的随机数。我们可以用一个简单的函数调用替换整个类:
>>> print('You rolled a', random.randint(1, 6))
You rolled a 6
与其他语言相比,Python 使用一种随意的方法来组织代码,因为它的代码不需要存在于类或其他样板结构中。如果您发现创建对象只是为了进行单个函数调用,或者如果您编写的类只包含静态方法,这些代码异味表明您可能更适合编写函数。
在 Python 中,我们使用模块而不是类来将函数组合在一起。因为类无论如何都必须在一个模块中,所以将代码放在类中只会给代码增加一个不必要的组织层。第 15 章到 17 章更详细地讨论了这些面向对象的设计原则。Jack Diederich 的 PyCon 2012 演讲“停止编写类”涵盖了其他可能比较复杂 Python 代码的方式。
理解嵌套列表
列表是一种表达复杂数值列的简洁方法。例如,要为数字 0 到 100 创建一个数字串列表,排除所有 5 的倍数,通常需要一个for
循环:
>>> spam = []
>>> for number in range(100):
... if number % 5 != 0:
... spam.append(str(number))
...
>>> spam
['1', '2', '3', '4', '6', '7', '8', '9', '11', '12', '13', '14', '16', '17',
`--snip--`
'86', '87', '88', '89', '91', '92', '93', '94', '96', '97', '98', '99']
或者,您可以使用列表推导语法在一行代码中创建相同的列表:
>>> spam = [str(number) for number in range(100) if number % 5 != 0]
>>> spam
['1', '2', '3', '4', '6', '7', '8', '9', '11', '12', '13', '14', '16', '17',
`--snip--`
'86', '87', '88', '89', '91', '92', '93', '94', '96', '97', '98', '99']
Python 也可以用集合和字典推导列表:
>>> spam = {str(number) for number in range(100) if number % 5 != 0} # 1
>>> spam
{'39', '31', '96', '76', '91', '11', '71', '24', '2', '1', '22', '14', '62',
`--snip--`
'4', '57', '49', '51', '9', '63', '78', '93', '6', '86', '92', '64', '37'}
>>> spam = {str(number): number for number in range(100) if number % 5 != 0} # 2
>>> spam
{'1': 1, '2': 2, '3': 3, '4': 4, '6': 6, '7': 7, '8': 8, '9': 9, '11': 11,
`--snip--`
'92': 92, '93': 93, '94': 94, '96': 96, '97': 97, '98': 98, '99': 99}
集合定义用大括号代替方括号,产生一个集合值。字典产生一个字典值,并使用冒号来分隔列表中的键和值。
这些推导式是简洁的,可以使你的代码更具可读性。但是请注意,推导式基于一个可迭代对象(在本例中是由range(100)
调用返回的range
对象)生成一个列表、集合或字典。列表、集合和字典都是可迭代对象,这意味着您可以将列表嵌套在列表中,如下例所示:
>>> nestedIntList = [[0, 1, 2, 3], [4], [5, 6], [7, 8, 9]]
>>> nestedStrList = [[str(i) for i in sublist] for sublist in nestedIntList]
>>> nestedStrList
[['0', '1', '2', '3'], ['4'], ['5', '6'], ['7', '8', '9']]
但是嵌套的列表推导式(或者嵌套的集合和字典推导式)将大量的复杂性塞进了少量的代码中,使得你的代码难以阅读。最好将列表推导式扩展成一个或多个for
循环:
>>> nestedIntList = [[0, 1, 2, 3], [4], [5, 6], [7, 8, 9]]
>>> nestedStrList = []
>>> for sublist in nestedIntList:
... nestedStrList.append([str(i) for i in sublist])
...
>>> nestedStrList
[['0', '1', '2', '3'], ['4'], ['5', '6'], ['7', '8', '9']]
推导式也可以包含多个for
表达式,尽管这也容易产生不可读的代码。例如,下面的列表推导式从嵌套列表中生成一个扁平列表:
>>> nestedList = [[0, 1, 2, 3], [4], [5, 6], [7, 8, 9]]
>>> flatList = [num for sublist in nestedList for num in sublist]
>>> flatList
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
这个列表推导式包含两个for
表达式,但是即使是有经验的 Python 开发人员也很难理解。扩展的表单使用了两个for
循环,创建了相同的扁平列表,但是更容易阅读:
>>> nestedList = [[0, 1, 2, 3], [4], [5, 6], [7, 8, 9]]
>>> flatList = []
>>> for sublist in nestedList:
... for num in sublist:
... flatList.append(num)
...
>>> flatList
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
列表在句法上的表达是简洁的,可以产生简洁的代码,但是不要走极端,把它们嵌套在一起。
空的异常捕捉块
捕捉异常是确保程序即使在出现问题时也能继续运行的主要方法之一。当出现一个异常,但没有except
块来处理它时,Python 程序会立即停止运行而崩溃。这可能会导致未保存的工作丢失或文件处于半成品状态。
您可以通过提供一个包含处理错误的代码的except
块来防止崩溃。但是很难决定如何处理一个错误,程序员可能会简单地用一个pass
语句将except
块留空。例如,在下面的代码中,我们使用pass
创建一个except
块,它什么也不做:
>>> try:
... num = input('Enter a number: ')
... num = int(num)
... except ValueError:
... pass
...
Enter a number: forty two
>>> num
'forty two'
当'forty two'
被传递给int()
时,这段代码不会崩溃,因为int()
引发的ValueError
是由except
语句处理的。但是对错误无所作为可能比崩溃更糟糕。程序崩溃,这样它们就不会继续带着坏数据或在不完整的状态下运行,这可能会导致以后更糟糕的错误。当输入非数字字符时,我们的代码不会崩溃。但是现在num
变量包含一个字符串而不是一个整数,这可能会在使用num
变量时引起问题。我们的except
语句与其说是处理错误,不如说是隐藏错误。
处理带有糟糕错误消息的异常是另一种代码异味。看看这个例子:
>>> try:
... num = input('Enter a number: ')
... num = int(num)
... except ValueError:
... print('An incorrect value was passed to int()')
...
Enter a number: forty two
An incorrect value was passed to int()
这段代码不会崩溃,这很好,但是它没有给用户足够的信息来知道如何修复这个问题。错误信息是给用户看的,不是给程序员看的。这个错误信息不仅包含用户无法理解的技术细节,比如对int()
函数的引用,而且没有告诉用户如何修复这个问题。错误消息应该解释发生了什么,以及用户应该做些什么。
对于程序员来说,更容易快速地编写一个单一的、没有帮助的描述,而不是用户可以用来解决问题的详细步骤。但是请记住,如果你的程序不能处理所有可能出现的异常,那么它就是一个未完成的程序。
代码异味误解
有些代码异味根本不是真正的代码异味。编程充满了不太为人所知的坏建议,这些建议被断章取义,或者在它们失去效用后仍然存在。我责怪那些好为人师的科技书籍作者。
你可能已经被告知这些实践中的一些是代码异味,但是它们大部分是好的。我称之为代码异味误解:它们是你可以也应该忽略的警告。让我们来看看其中的几个。
误解:函数的末尾应该只有一个return
语句
“一进一出”的想法来自于汇编和 FORTRAN 语言编程时代被误解的建议。这些语言允许你在任何时候进入一个子程序(一个类似于函数的结构),包括在它的中间,这使得很难调试子程序的哪个部分被执行了。函数没有这个问题(执行总是从函数的开头开始)。但是这个建议一直存在,变成了“函数和方法应该只有一个return
语句,应该在函数或方法的末尾。”
试图为每个函数或方法实现单个return
语句通常需要一系列错综复杂的if-else
语句,这比拥有多个return
语句更令人困惑。在一个函数或方法中有多个return
语句是没问题的。
误解:函数应该最多有一个try
语句
“函数和方法应该做一件事”通常是很好的建议。但是将此理解为异常处理应该在一个单独的函数中进行就有些过分了。例如,让我们看一个指示我们想要删除的文件是否已经不存在的函数:
>>> import os
>>> def deleteWithConfirmation(filename):
... try:
... if (input('Delete ' + filename + ', are you sure? Y/N') == 'Y'):
... os.unlink(filename)
... except FileNotFoundError:
... print('That file already did not exist.')
...
这个代码片段的支持者认为,因为函数应该总是只做一件事,而错误处理是一件事,所以我们应该把这个函数分成两个函数。他们认为,如果你使用一个try-except
语句,它应该是函数中的第一个语句,并封装函数的所有代码,如下所示:
>>> import os
>>> def handleErrorForDeleteWithConfirmation(filename):
... try:
... _deleteWithConfirmation(filename)
... except FileNotFoundError:
... print('That file already did not exist.')
...
>>> def _deleteWithConfirmation(filename):
... if (input('Delete ' + filename + ', are you sure? Y/N') == 'Y'):
... os.unlink(filename)
...
这是不必要的复杂代码。_deleteWithConfirmation()
函数现在用下划线前缀_
标记为私有,以表明它不应该被直接调用,只能通过调用handleErrorForDeleteWithConfirmation()
来间接调用。这个新函数的名字很别扭,因为我们称它为意图删除文件,而不是处理删除文件的错误。
你的函数应该小而简单,但这并不意味着它们应该总是局限于做“一件事”(无论你如何定义)。如果您的函数有不止一个try-except
语句,并且这些语句没有包含函数的所有代码,这也没什么。
误解:标志参数是不好的
函数或方法调用的布尔参数有时被称为标志参数。在编程中,标志是一个表示二进制设置的值,如“启用”或“禁用”,它通常由布尔值表示。我们可以将这些设置描述为启用(即True
)或停用(即False
)。
认为函数调用的标志参数不好的错误观点是基于这样一种主张,即根据标志值的不同,函数会做两件完全不同的事情,如下例所示:
def someFunction(flagArgument):
if flagArgument:
# Run some code...
else:
# Run some completely different code...
事实上,如果你的函数看起来像这样,你应该创建两个独立的函数,而不是用一个参数来决定运行哪一半的函数代码。但是大多数带标志参数的函数不这么做。例如,您可以为sorted()
函数的reverse
关键字参数传递一个布尔值来确定排序顺序。将函数分成两个名为sorted()
和reverseSorted()
的函数并不能改进代码(同时也增加了所需文档的数量)。因此,标志参数总是不好的想法是一个代码异味的误解。
误解:全局变量是不好的
函数和方法就像程序中的迷你程序:它们包含代码,包括函数返回时被遗忘的局部变量。这类似于程序终止后变量被遗忘的情况。函数是独立的:它们的代码要么正确执行,要么有错误,这取决于调用它们时传递的参数。
但是使用全局变量的函数和方法失去了一些有用的隔离。你在函数中使用的每一个全局变量实际上都变成了函数的另一个输入,就像参数一样。更多的参数意味着更多的复杂性,这反过来意味着更高的错误可能性。如果由于全局变量中的错误值而导致函数中出现 bug,那么这个错误值可能被设置在程序中的任何地方。要搜索这个错误值的可能原因,不能只分析函数中的代码或调用函数的代码行;您必须查看整个程序的代码。因此,您应该限制全局变量的使用。
例如,让我们看看一个虚构的partyPlanner.py
程序中的calculateSlicesPerGuest()
函数,它有几千行长。我已经包括了行号,以便让您对程序的大小有所了解:
1504\. def calculateSlicesPerGuest(numberOfCakeSlices):
1505\. global numberOfPartyGuests
1506\. return numberOfCakeSlices / numberOfPartyGuests
假设当我们运行这个程序时,我们遇到了下面的异常:
Traceback (most recent call last):
File "partyPlanner.py", line 1898, in <module>
print(calculateSlicesPerGuest(42))
File "partyPlanner.py", line 1506, in calculateSlicesPerGuest
return numberOfCakeSlices / numberOfPartyGuests
ZeroDivisionError: division by zero
程序有一个由行return numberOfCakeSlices / numberOfPartyGuests
引起的零除错误。必须将变量numberOfPartyGuests
设置为0
才能导致这种情况,但是numberOfPartyGuests
是从哪里获得这个值的呢?因为它是一个全局变量,它可能发生在这个程序的数千行中的任何地方!从回溯信息中,我们知道calculateSlicesPerGuest()
在我们虚构的程序的第 1898 行被调用。如果我们查看第 1898 行,我们可以找到为numberOfCakeSlices
参数传递了什么参数。但是numberOfPartyGuests
全局变量可以在函数调用之前的任何时候被设置。
注意,全局常量并不被认为是糟糕的编程实践。因为它们的值永远不会改变,所以它们不会像其他全局变量那样给代码带来复杂性。当程序员提到“全局变量不好”时,他们指的不是常量变量。
全局变量增加了调试的工作量,以找到可能设置了导致异常的值的位置。这使得大量使用全局变量成为一个坏主意。但是认为所有的全局变量都是坏的是一个代码异味的定理。全局变量在较小的程序中或者在跟踪应用于整个程序的设置时非常有用。如果你可以避免使用全局变量,那就意味着你应该避免使用全局变量。但是“全局变量是坏的”是一种过于简单化的观点。
误解:注释是不必要的
糟糕的注释确实比完全没有注释更糟糕。带有过时或误导信息的注释会给程序员带来更多的麻烦,而不是更好的理解。但是这个潜在的问题有时被用来宣告所有的注释都是不好的。这种观点认为每一个注释都应该用可读性更好的代码来代替,以至于程序根本就不应该有注释。
注释是用英语(或者程序员说的任何一种语言)写的,这允许它们在某种程度上传达变量、函数和类名所不能传达的信息。但是写简洁有效的注释是很难的。注释和代码一样,需要重写和多次迭代才能正确。我们在写完代码后马上就理解了它,所以写注释看起来像是无意义的额外工作。结果,程序员们倾向于接受“注释是不必要的”这一观点。
更常见的经验是程序的注释太少或没有,而不是太多或误导性的注释。拒绝注释就像说,“乘坐客机飞越大西洋只有 99.999991%的安全,所以我要游过去。”
第 10 章有更多关于如何写有效注释的信息。
总结
代码异味表明可能有更好的方式来编写代码。他们不一定要求改变,但他们应该让你再看一眼。最常见的代码异味是重复代码,这可能意味着有机会将代码放在函数或循环中。这确保了未来的代码更改只需要在一个地方进行。其他代码异味包括魔术数字,魔术数字是代码中无法解释的值,可以用具有描述性名称的常量来替换。类似地,注释掉的代码和僵尸代码永远不会被计算机运行,可能会误导后来阅读程序代码的程序员。如果您以后需要将它们添加回您的程序中,最好将它们移除,并依靠像 Git 这样的源代码控制系统。
打印调试使用print()
调用来显示调试信息。尽管这种调试方法很容易,但从长远来看,依靠调试和日志来诊断错误通常更快。
带有数字后缀的变量,比如x1
、x2
、x3
等等,通常最好用包含列表的单个变量来替换。与 Java 等语言不同,在 Python 中,我们使用模块而不是类来将函数组合在一起。包含单个方法或仅包含静态方法的类是一种代码异味,建议您应该将代码放入模块而不是类中。尽管列表表达式是一种创建列表值的简洁方法,但是嵌套的列表推导式通常是不可读的。
此外,任何用空的except
块处理的异常都是一种代码异味,你只是在消除错误,而不是处理它。一条简短、隐晦的错误消息对用户来说就像没有错误消息一样毫无用处。
伴随着这些代码异味定理:不再有效的编程建议,或者随着时间的推移,已经证明适得其反。这些包括在每个函数中只放一个return
语句或try-except
块,从不使用标志参数或全局变量,并且认为注释是不必要的。
当然,和所有编程建议一样,本章描述的代码异味可能适用于也可能不适用于您的项目或个人偏好。最佳实践不是一个客观的衡量标准。随着您获得更多的经验,您会对什么代码是可读的或可靠的得出不同的结论,但是本章中的建议概述了需要考虑的问题。