如何避免 "布尔陷阱"

155 阅读5分钟

"布尔陷阱 "是一种反编程模式,其中一个布尔参数会转换行为,导致混乱。 在这篇文章中,我们将更详细地研究这个陷阱,以及在Python中避免它的几种方法,并通过类型提示增加安全性。

陷阱

以这个函数定义为例。

def round_number(value: float, up: bool) -> float:
    ...

该函数将对数字进行四舍五入,可以是向上,也可以是向下。我们可以通过传递up=Trueup=False 来分别选择方向。

陷阱有三个方面。

首先,调用地点不明确。round_number(value, True) 没有说明四舍五入的方向。读者可能会假设错误的方向而感到困惑。

我们可以通过使up 成为一个仅有关键字的参数来避免这第一个问题。我们只需要添加一个* 参数分隔符。

def round_number(value: float, *, up: bool) -> float:
    ...

现在调用站点必须声明up=Trueup=False 。但这导致我们......

第二,四舍五入要求我们传递up=False 。这并不明显意味着 "向下"--它只说 "不向上"。 它可能意味着 "侧向 "吗?

即使在有两个明确选项的领域,像 "不上升 "这样的双重否定词仍然需要脑力劳动来破译。

第三,这个论点没有扩展的余地。 虽然作者当时可能不知道,但还有很多四舍五入的方法,包括 "向上一半"、"向下一半 "和 "离偶数一半"(关于深入的指南,见Real Python这篇文章)。

如果我们以后需要添加一个新的四舍五入方法,我们不能坚持使用up 参数。要么我们替换up ,这需要我们更新所有的调用者,要么我们添加更多的参数,这就变得不容易了。

解决方案

让我们来看看四种可以避免这个陷阱的替代设计。

我一般倾向于在简单的情况下使用多个函数(#1),而在其他情况下使用字符串字面(#4)。

1.多个函数

我们可以为每个行为制定一个函数。

def round_up(value: float) -> float:
  ...


def round_down(value: float) -> float:
  ...

这样的API值得称道,更加明确,对于行为少的简单情况来说是个好主意。 但对于行为多的情况,有一些缺点。

首先,可能很难将代码作为独立的函数。 我们可以通过让我们的公共函数调用一个带有行为切换参数的共享私有函数来避免重复。 这又给我们带来了布尔陷阱。

第二,我们可能需要大量的函数。 每一个布尔标志的避免都会使所需的函数数量增加一倍。 我们最终可能会有许多名字很长的函数,如round_up_approximately_as_int

第三,我们放弃了行为切换参数的便利性。 调用者现在可能需要自己进行切换,比如。

if round_up:
    rounded = round_up(value)
else:
    rounded = round_down(value)

2.相互排斥的标志

我们可以坚持使用布尔参数,但每个行为都有一个。 我们将使参数只包含关键字,默认为False ,并检查是否确切地设置为True 。这样,调用站点将保持明确,如round_number(x, up=True)round_number(y, down=True)

有了类型提示和运行时检查,这将看起来像。

from __future__ import annotations

from typing import Literal, overload


@overload
def round_number(
    value: float, *, up: Literal[True], down: Literal[False] = False
) -> float:
    ...


@overload
def round_number(
    value: float, *, up: Literal[False] = False, down: Literal[True]
) -> float:
    ...


def round_number(
    value: float,
    *,
    up: bool = False,
    down: bool = False,
) -> float:
    behaviours = [x for x in [up, down] if x]
    if len(behaviours) != 1:
        raise TypeError("Exactly one rounding behaviour must be specified.")
    ...

为了拼出允许的调用格式,我们需要使用 @overload 装饰器,用Literal我们在运行时检查这些值,假定不是所有的调用者都使用类型检查。

这种设计对于调用站点来说是很好的,但是写起来就不那么有趣了。

这里最显著的缺点是冗长的代码。 为了增加一个新的行为,我们必须写一个新的@overload 案例,在其他所有的案例中把参数定义为Literal[False] ,并把参数添加到基函数中。

这种繁琐的做法也会出现在我们的文档中,我们需要列出和解释来说明这些参数。

另一个不利因素是,和多函数设计一样,我们放弃了行为切换参数的便利性。

3. Enum 论据

我们可以使用一个枚举类型作为我们的行为切换参数。

from enum import Enum


class RoundingMethod(Enum):
    UP = 1
    DOWN = 2


def round_number(value: float, method: RoundingMethod) -> float:
    ...

这个版本使调用看起来像round_number(x, RoundingMethod.UP) 。我们不需要使用只用关键字的参数,因为 "方法 "这个词就在枚举的名称中。

添加一个新的行为很容易--我们只需要在枚举中添加一行,然后定义行为。 我们还可以使用穷举检查来确保我们涵盖所有的行为。

唯一的缺点是有一点额外的啰嗦:调用者需要导入枚举,而RoundingMethod.UP ,并不是最短的。

4.字符串参数与Literal

我们可以使用一个字符串参数来切换行为。

from typing import Literal

RoundingMethod = Literal["up", "down"]


def round_number(value: float, *, method: RoundingMethod) -> float:
    ...

我们可以像round_number(1.5, method="up") ,使用这个版本。

这个选项与使用Enum 非常相似,但代码更少。

通过使RoundingMethod 类型的别名,我们使该类型可被导入任何类型的客户代码检查。