剧照 | 《三国秘史:深渊潜龙》
2016 年,Linux 操作系统之父 Linus 参加了一档 TED 脱口秀节目[1],节目的前半部分,他主要讲述了自己如何赤膊在家编写 Linux,而没有涉及太多编程相关的东西。
不过在采访的最后,却突然出现了一个有趣的部分,主持人问 Linus:“你曾经说过,你更喜欢和有良好代码品味的人一起工作,那么在你看来,什么才是良好的代码品味呢?”
为了解释这一点,Linus 在大屏幕上展示了一段代码。我将其复制在下面。
remove_list_entry(entry) {
prev = NULL;
walk = head;
// 遍历链表
while (walk != entry) {
prev = walk;
walk = walk->next;
}
// 关键:当要删除时,判断当前位置是否在链表头部进行不同的动作
if (!prev)
head = entry->next;
else
prev->next = entry->next;
}
该函数的主要功能是通过遍历链表来删除成员。但是,此代码中存在一个边缘情况[2]。
在编程中,“边缘情况”是指仅在极端情况下才会发生的情况。例如,在上面的代码中,当我们要查找的元素位于链表的头部时,它就是一种边缘情况。为了处理它,该函数在删除它之前会执行 if / else 检查。
Linus 认为这个 if 语句是整段代码“坏味道”的来源,写这个语句的人代码品味不好。那么,一个品味高一点的人该怎么写呢?很快,第二段代码出现在屏幕上。
remove_list_entry(entry) {
indirect = &head
// 遍历链表过程代码已省略
// 当要删除时,直接进行指针操作删除
*indirect = entry->next
}
在新代码中,函数充分利用了 C 语言中指针的特性,彻底消除了之前的 if/else,无论删除目标是在链表的头部还是中间,函数都能平等地完成删除操作,之前的边界情况消失了。
看到这里你是不是有些疑惑:没有指针,那你告诉我这么多指针和非指针干嘛?虽然没有指针,但是我觉得这个例子给我们提供了一个非常有趣的话题,那就是在编码时如何充分利用语言特性来更好地处理边缘情况。
我认为好的代码在处理边缘情况时应该简洁且“沉默”。就像上面的例子一样,边缘情况可以融入代码的主流。在编写时,有很多编码技巧和惯例可以帮助我们做到这一点。让我们来看看。
第 1 课:使用分支还是异常?
今天是周末,你打算参加朋友组织的聚餐。临走时,你突然想起现在是雨季。于是你拿出手机,打开天气应用程序,看看今天会不会下雨。如果下雨,你就带上雨伞再出门。
如果把“今天下雨”比喻成编程中的边界情况,那么“查看天气预报+带伞”就是我们的边界处理代码。这种如果下雨就带伞的分支判断,基本是一种直觉的思维本能。因此,当我们在编程中发现边界情况时,我们的第一反应往往是:“搞个if分支来遮一下!”
例如以下代码:
def counter_ap(l):
"""计算列表里面每个元素出现的数量"""
result = {}
for key in l:
# 主流程:累加计数器
if key in result:
result[key] += 1
# **边界情况:当元素第一次出现时,先初始化值为 1**
else:
result[key] = 1
return result
# 执行结果:
print(counter_ap(['apple', 'banana', 'apple']))
{'apple': 2, 'banana': 1}
上面的循环中,代码的主要流程是“将每个键的计数器加 1”。但是,当字典中没有键元素时,不能直接进行累加操作(会抛出)。
>>> result = {}
>>> result['foo'] += 1
Traceback (most recent call last):
File "
", line 1, in KeyError: 'foo'
因此出现了一个边界情况:当一个元素第一次出现时,我们需要初始化该值。
因此,我编写了一个 if 语句来处理这种极端情况。代码很简单,不需要太多解释。但你可能不知道的是,实际上有一个术语可以描述这种编程风格:“(LBYL) Look You Leap”。
“LBYL”这个缩写不太好翻译,说白了就是在执行操作前对可能出现的边界情况进行条件判断,根据判断结果来决定是否处理边界情况或者继续执行主流程。
前面提到过,使用“LBYL”处理边缘情况几乎是直观的。“如果有边缘情况,就增加一个 if 分支”就像“如果天气预报说会下雨,我就带伞出门”,是一种基本不需要思考的操作。
除了LBYL之外,还有一种与之形成鲜明对比的风格:“EAFP(to Ask for than)”。
获得宽恕比获得许可更容易(EAFP)
“EAFP”通常被翻译为“获得原谅比获得许可更容易”。如果我们以雨为例,EAFP 的做法类似于“出门前不要查看天气预报。如果淋了雨,回家后立即洗澡并服用感冒药。”
采用EAFP风格的代码如下:
def counter_af(l):
result = {}
for key in l:
try:
# 总是直接执行主流程:累加计数器
result[key] += 1
except KeyError:
# 边界情况:当元素第一次出现时会报错 KeyError,此时进行初始化
result[key] = 1
return result
EAFP的编程风格相比LBYL更加简单粗暴,总是直奔主流程,在异常处理的try块中消化掉边界条件。
如果你问我,“这两种编程风格哪一种更好?”,我只能说社区明显偏爱“异常捕获-请求-宽恕”(EAFP)编程风格。这有很多原因。
首先,与很多其他编程语言不同,抛出异常是一个非常轻量级的操作,即使程序抛出并捕获了大量的异常,使用EAFP也不会给程序带来额外的负担。
其次,“请求原谅”通常在性能方面更好,因为程序总是直接进入主流程,只有在极少数情况下才需要处理边缘情况。以上面的例子为例,第二个代码通常比第一个代码更快,因为它不必在每次循环时都进行额外的成员检查。
提示:如果你想了解更多有关此内容的信息,建议阅读:写:使用 [3]
因此,每当您想要编写 if else 语句来处理边缘情况时,请考虑使用 try 来捕获异常是否更合适。毕竟,我们总是更喜欢“吃感冒药”而不是“查看天气预报”。
当容器内容不存在时
内置的容器类型有很多,比如字典、列表、集合等。在操作容器时,经常会出现一些边缘情况。其中,“访问的内容不存在”是最常见的一种:
•操作字典时,如果访问的键不存在,则会抛出异常。 •操作列表或元组时,如果访问的索引不存在,则会抛出异常。
对于这样的边缘情况,除了有针对性地捕获相应的异常之外,还有许多其他的方法来处理。
使用重写的示例
在前面的例子中,我们使用try语句来处理“key第一次出现”这种边缘情况。虽然我说使用try的代码比if更好,但这并不意味着它是惯用的代码。
为什么?因为如果你想计算列表的元素数量,你只需使用 . 即可:
from collections import defaultdict
def counter_by_collections(l):
result = defaultdict(int)
for key in l:
result[key] += 1
return result
这样的代码不需要“获得许可”或者“请求原谅”,整个函数只有一个主流程,代码更加清晰自然。
为什么可以让这个边缘情况消失呢?因为之前的代码缺少“key 不存在”的默认处理逻辑。所以,当我们声明如何处理这个边缘情况时,原本需要人工判断的部分就消失了。
提示:对于上面的例子,使用 .[4] 也可以达到同样的目的。
使用值并修改
有时候,我们需要对字典中的某个值进行操作,但该值可能不存在。例如,在下面的例子中:
# 往字典的 values 键追加新值,假如不存在,先以列表初始化
try:
d['values'].append(value)
except KeyError:
d['values'] = [value]
这种情况下,我们可以使用d.(key, =None)方法来简化边界处理逻辑,直接替换上面的异常捕获语句:
# 如果 setdefault 指定的 key(此处为 "values")不存在,以 [] 初始化,否则返回已存在
# 的值。
d.setdefault('values', []).append(value)
提示:使用(list)也可以巧妙地解决这个问题。
使用 dict.pop 删除不存在的键
如果我们想从字典中删除一个键,通常使用 del 关键字。但是当该键不存在时,删除操作将引发异常。
因此,如果您想安全地删除一个键,您必须添加一些异常捕获逻辑。
try:
del d[key]
except KeyError:
# 忽略 key 不存在的情况
pass
但假设你只想删除一个键,而不关心它是否存在。使用 dict.pop(key, ) 方法就足够了。
只要在调用dict.pop方法时传入一个默认值,那么即使键不存在也不会抛出异常。
# 使用 pop 方法,指定 default 值为 None,当 key 不存在时,不会报错
d.pop(key, None)
提示:严格来说,pop方法的主要目的不是删除一个键,而是检索一个键对应的值。不过,我认为偶尔用它来做删除操作是无害的。
当列表切片超出范围时
大家都知道,当你的列表(或元组)只有 3 个元素,而你尝试访问第 4 个元素时,解释器会报错。我们通常将这种错误称为“数组越界”。
>>> l = [1, 2, 3]
>>> l[2]
3
>>> l[3]
Traceback (most recent call last):
File "
", line 1, in IndexError: list index out of range
但你可能不知道的是,如果你请求的是某个范围的切片而不是单个元素,那么不管你指定的范围是否有效,程序都只会返回一个空列表 [] 而不会抛出任何错误:
>>> l = []
>>> l[1000:1001]
[]
了解了这一点,您会发现像下面这样的边框处理代码是不必要的:
def sum_list(l, limit):
"""对列表的前 limit 个元素求和
"""
# 如果 limit 过大,设置为数组长度避免越界
if limit > len(l):
limit = len(l)
return sum(l[:limit])
由于切片不会引发任何错误,因此无需检查限制是否超出范围,您可以直接执行求和运算:
def sum_list(l, limit):
return sum(l[:limit])
利用这个特性,我们还可以简化一些特定的边界处理逻辑。例如,安全地删除列表的一个元素:
# 使用异常捕获安全删除列表的第 5 个元素
try:
l.pop(5)
except IndexError:
pass
# 删除从 5 开始的长度为 1 的切片,不需要捕获任何异常
del l[5:6]
有用但危险的“或”运算符
or 是几乎所有编程语言中都存在的运算符。它通常与 and 一起使用来执行布尔逻辑运算。例如:
>>> False or True
True
但是 or 也有一个有趣的特性,即短路求值。例如,在下面的例子中,1 / 0 永远不会被执行(这意味着它不会抛出):
>>> True or (1 / 0)
True
在很多场景下,我们可以利用 or 的特性来简化一些边界处理逻辑。看下面的例子:
context = {}
# 仅当 extra_context 不为 None 时,将其追加进 context 中
if extra_context:
context.update(extra_context)
在这段代码中, 的值通常是一个字典,但有时也可能是 None。所以我添加了一个条件语句,只有当它的值不为 None 时才执行操作。
如果我们使用或运算符,我们可以使上述语句更加简洁:
context.update(extra_context or {})
因为像 a or b or c or ... 这样的表达式会返回这些变量中第一个 true 的布尔值,直到最后一个。所以 None or {} 实际上等于 {},所以当值为 None 时,我们的 or 表达式会把它变成一个空字典。前面的条件判断可以简化成 or 表达式。
用 a 或 b 来表达“当 a 为空时,用 b 替换 a”并不新鲜,在各种编程语言和框架源码中都能找到。但这种写法其实有一个陷阱。
因为或运算计算的是变量的布尔真或假值。因此,不仅是 None,所有 0、[]、{}、set() 以及所有其他会被判断为布尔假的值在或运算中都将被忽略。
# 所有的 0、空列表、空字符串等,都是布尔假值
>>> bool(None), bool(0), bool([]), bool({}), bool(''), bool(set())
(False, False, False, False, False, False)
如果你忘记了 or 的这个特性,你可能会遇到一些奇怪的问题。例如,以下代码:
timeout = config.timeout or 60
虽然上述代码的目的是确定当 . 为 None 时,使用 60 作为默认值,但是如果主动将 . 的值配置为 0 秒,则会由于上述 0 or 60 = 60 的操作而重新赋值为 60,因此会忽略正确的配置。
因此,有时使用以下方法进行精确的边界处理更为安全:
if config.timeout is None:
timeout = 60
不要手动执行数据验证
无数前辈的经验告诉我们:“不要相信任何用户输入。”这意味着所有存在用户输入的地方都要进行验证。那些无效的、危险的用户输入值就是我们需要处理的边界情况。
假设我正在编写一个小型命令行程序,需要要求用户输入一个介于 0 到 100 之间的数字。如果用户的输入无效,则要求他们重新输入。
该程序大概是这样的:
def input_a_number():
"""要求用户输入一个 0-100 的数字,如果无效则重新输入
"""
while True:
number = input('Please input a number (0-100): ')
# 此处往下的三条 if 语句都是输入值的边界校验代码
if not number:
print('Input can not be empty!')
continue
if not number.isdigit():
print('Your input is not a valid number!')
continue
if not (0 <= int(number) <= 100):
print('Please input a number between 0 and 100!')
continue
number = int(number)
break
print(f'Your number is {number}')
执行效果如下:
Please input a number (0-100):
Input can not be empty!
Please input a number (0-100): foo
Your input is not a valid number!
Please input a number (0-100): 65
Your number is 65
这个函数有 14 行有效代码,其中有 3 个 if 块,共 9 行代码,全部都是边界值检查代码。你可能觉得这样的检查很正常,但想象一下,如果有多个输入需要检查,并且检查逻辑比这更复杂,那这些边界值检查代码就会变得冗长乏味。
如何改进这些代码?将它们提取出来,作为验证功能从核心逻辑中分离出来是一个好主意。但更重要的是,我们应该把“输入数据验证”视为一个独立的职责和领域,并使用更合适的模块来完成这项工作。
在数据验证中,[5]模块是一个不错的选择,如果用于验证,代码可以简化如下:
from pydantic import BaseModel, conint, ValidationError
class NumberInput(BaseModel):
# 使用类型注解 conint 定义 number 属性的取值范围
number: conint(ge=0, le=100)
def input_a_number_with_pydantic():
while True:
number = input('Please input a number (0-100): ')
# 实例化为 pydantic 模型,捕获校验错误异常
try:
number_input = NumberInput(number=number)
except ValidationError as e:
print(e)
continue
number = number_input.number
break
print(f'Your number is {number}')
在日常编码中,我们应该尽量避免手动进行数据验证,而是使用(或实现)合适的第三方验证模块,将这部分边界处理工作抽象出来,简化主流程代码。
提示:如果你正在开发一个 Web 应用程序,数据验证部分通常相当简单。例如,框架有自己的表单模块,Flask 也可以使用它进行数据验证。
别忘了做数学题
很多年前,当我刚开始做网页开发的时候,我想学习如何实现一个简单的文本滚动动画。如果你不知道什么是“滚动动画”,我可以简单解释一下。“滚动动画”就是让一段文字从页面的左侧循环滚动到右侧。十几年前,这种动画在网站上特别流行。
我记得有一段逻辑是这样的:控制文本不断向右移动,当横坐标超出页面宽度时,重新设置坐标,继续。我当时写的代码翻译过来是这样的:
while True:
if element.position_x > page_width:
# 边界情况:当对象位置超过页面宽度时,重置位置到最左边
element.position_x -= page_width
# 元素向右边滚动一个单位宽度
element.position_x += width_unit
看起来不错,对吧?我第一次写的时候就这么想。但是后来有一天,我再次看它时发现了一些奇怪的东西。
在上面的代码中,我需要在主循环中确保“不会超出页面宽度”。所以我写了一个 if 语句来处理超出页面宽度的情况。
但是,如果您想确保某个累计数字()不超过另一个数字(),那么直接使用 % 进行模运算不是更好吗?
while True:
# 使用 % page_with 控制不要超过页面宽度
element.position_x = (element.position_x + width_unit) % page_with
这样写的话,代码中的边界情况就会随着 if 语句一起消失。
类似取模的运算还有很多,比如 abs(),math.floor() 等等,切记不要写 if value < 0: value = -value 这样的“边界判断代码”,直接用 abs(value) 就行,不要再去搞什么绝对值运算了。
总结
“边缘情况”是我们日常编码的老朋友了。但是它们并不那么受欢迎。毕竟我们都希望我们的代码始终只有一个主流程,没有太多的条件判断和异常捕获。
但边缘情况是不可避免的,只要有代码,它们就会存在。所以如果我们能更好地处理它们,我们的代码就会变得更清晰、更易读。
除了上面介绍的想法之外,还有许多其他方法可以帮助我们处理边缘情况,比如利用面向对象的多态性、使用空对象模式[6]等等。
最后我们来总结一下:
• 条件判断和异常捕获都可以用来处理边界情况。 • 在本文中,我们更倾向于使用基于异常捕获的 EAFP 风格。 • //pop 可以巧妙地处理键不存在时的边界情况。 • 对不存在范围的列表进行切片不会抛出异常。 • 使用 or 可以简化默认值边界处理逻辑,但要注意不要掉入陷阱。 • 不要手动进行数据验证,使用 or 其他数据验证模块。 • 使用取模、绝对值计算等,可以简化一些特定的边界处理逻辑。
阅读本文后,您还有什么意见吗?请留言或在条目[7]中告诉我。
附录
扫一扫在手机端查看
我们凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求,请立即点击咨询我们或拨打咨询热线: 13761152229,我们会详细为你一一解答你心中的疑难。