Python 异常被视为反模式

2025-06-10

Python 异常被视为反模式

最初发表在我的博客https://sobolevn.me/2019/01/simple-dependent-types-in-python

什么是异常?根据名称判断,它代表程序内部发生的某些异常情况。

你可能会好奇,为什么异常是反模式?它和类型有什么关系?好吧,让我们来一探究竟!

异常问题

首先,我们必须证明异常是有缺陷的。嗯,通常很难在你日常使用的东西中发现“问题”,因为它们在某些时候开始看起来像是“特性”。

让我们重新审视一下。

异常很难被注意到

有两种类型的异常:“显式”异常是raise在您正在阅读的代码中使用关键字创建的,“包装”异常是包装在您正在使用的其他函数/类/方法中的。

问题是:很难注意到所有这些“包装”的异常。
我将用这个纯函数来说明我的观点:



def divide(first: float, second: float) -> float:
     return first / second


Enter fullscreen mode Exit fullscreen mode

它所做的就是将两个数字相除。始终返回float。它是类型安全的,可以像这样使用:



result = divide(1, 0)
print('x / y = ', result)


Enter fullscreen mode Exit fullscreen mode

等等,你明白了吗?它print永远不会真正执行。因为1 / 0它是一个不可能的操作,所以ZeroDivisionError会被引发。所以,尽管你的代码是类型安全的,但它并不安全。

你仍然需要具备丰富的经验才能在完美可读且类型规范的代码中发现这些潜在问题。几乎所有代码都python可能因不同类型的异常而失败:除法、函数调用intstr生成器、循环中的可迭代对象for、属性访问、键访问,甚至代码raise something()本身都可能失败。我这里甚至没有讨论 IO 操作。而且,近期内不会支持受检异常。

恢复正常行为是不可能的

嘿,不过我们一直都有except这种情况的案例。我们自己处理吧ZeroDivisionError,这样就安全了!



def divide(first: float, second: float) -> float:
     try:
         return first / second
     except ZeroDivisionError:
         return 0.0


Enter fullscreen mode Exit fullscreen mode

现在我们安全了!但为什么我们要返回0?为什么不1?为什么不None?虽然None在大多数情况下,它和异常一样糟糕(甚至更糟),但事实证明,我们应该高度依赖此函数的业务逻辑和用例。

我们究竟该划分什么?任意数字?某些特定单位?还是金钱?并非所有情况都能被覆盖并轻松恢复。有时,当我们将此功能用于不同的用例时,我们会发现它需要不同的恢复逻辑。

所以,令人难过的结论是:所有问题都必须根据具体的使用场景单独解决。没有ZeroDivisionError一劳永逸地解决所有问题的灵丹妙药。而且,我甚至没有讨论带有重试策略和指数超时的复杂 IO 流程。

也许我们根本不应该就地处理异常?也许我们应该把它抛到执行流程的更深处,以后有人会以某种方式处理它。

执行流程不清晰

好的,现在我们希望其他人能够捕获这个异常并尽可能地处理它。例如,系统可能会通知用户更改输入,因为我们无法除以0。这显然不是该函数的职责divide

现在我们只需要检查这个异常到底在哪里被捕获。顺便问一下,我们怎么才能知道它到底会在哪里被处理呢?我们能定位到代码中的这个位置吗?结果发现,我们做不到。

无法确定异常抛出后会执行哪一行代码。不同类型的异常可能由不同的except用例处理,某些异常可能被suppressed。此外,你还可能因为在不同的模块中引入新的用例而意外地在随机位置中断程序except。记住,几乎任何一行代码都可能引发异常。

我们的应用中有两个独立的流程:常规流程从上到下,特殊流程则随意运行。我们该如何有意识地阅读这样的代码呢?

仅当调试器打开时,并启用“捕获所有异常”策略。

IDE调试器

例外就像臭名昭著的言论一样goto,破坏了我们的计划结构。

例外并不例外

我们来看另一个例子,这是访问远程 HTTP API 的典型代码:



import requests

def fetch_user_profile(user_id: int) -> 'UserProfile':
    """Fetches UserProfile dict from foreign API."""
    response = requests.get('/api/users/{0}'.format(user_id))
    response.raise_for_status()
    return response.json()


Enter fullscreen mode Exit fullscreen mode

毫不夸张地说,这个例子中的所有内容都可能出错。以下是可能发生的所有错误的不完整列表:

  1. 您的网络可能出现故障,因此请求根本不会发生
  2. 服务器可能已关闭
  3. 服务器可能太忙,您将面临超时
  4. 服务器可能需要身份验证
  5. API 端点可能不存在
  6. 用户可能不存在
  7. 您可能没有足够的权限来查看它
  8. 服务器在处理您的请求时可能会出现内部错误
  9. 服务器可能返回无效或损坏的响应
  10. 服务器可能会返回无效json,因此解析将失败

诸如此类的问题还有很多!这三行代码可能存在很多潜在问题,所以更容易说它只是偶然运行的。通常情况下,它会抛出异常并失败。

如何才能安全?

现在我们知道了异常对代码的危害。接下来,我们来学习如何避免异常。编写无异常代码有多种模式:

  1. 到处写字except Exception: pass。这比你能想象的还要糟糕。别这么做。
  2. 返回None。这也太邪恶了!你要么最终if something is not None:几乎每行都犯同样的错误,并且你的逻辑会被类型检查条件语句污染,要么TypeError每天都要忍受这种痛苦。这可不是什么愉快的选择。
  3. 编写特殊情况类。例如,你会有User一个包含多个错误子类的基类,例如UserNotFound(User)MissingUser(User)。它可能用于某些特定情况,例如AnonymousUserdjango但不可能将所有可能的错误都包装在特殊情况类中。这会给开发人员带来太多工作量,并使你的领域模型过于复杂。
  4. 您可以使用容器值,它将实际的成功或错误值包装到一个精简的包装器中,并使用实用方法来处理此值。这正是我们创建@dry-python/returns项目的原因。这样您就可以让您的函数返回有意义、类型明确且安全的值。

让我们从相同的数字除法示例开始,该示例0在发生错误时返回结果。也许我们可以在不提供任何明确数值的情况下,指示结果不成功?



from returns.result import Result, Success, Failure

def divide(first: float, second: float) -> Result[float, ZeroDivisionError]:
    try:
        return Success(first / second)
    except ZeroDivisionError as exc:
        return Failure(exc)


Enter fullscreen mode Exit fullscreen mode

现在,我们将值包装到以下两个包装器之一中:SuccessFailure。这两个类继Result承自基类。我们可以在函数返回注解中指定包装值的类型,例如Result[float, ZeroDivisionError]返回Success[float]Failure[ZeroDivisionError]

这对我们意味着什么?这意味着,异常并不例外,它们代表着可预见的问题。但是,我们也将它们包装起来,Failure以解决第二个问题:发现潜在异常很困难



1 + divide(1, 0)
# => mypy error: Unsupported operand types for + ("int" and "Result[float, ZeroDivisionError]")


Enter fullscreen mode Exit fullscreen mode

现在你可以轻松识别它们了!规则是:如果你看到 a Result,就意味着这个函数可能抛出异常。而且你甚至可以提前知道它的类型。

此外,returns该库是完全类型化的,并且兼容 PEP561。这意味着mypy如果你尝试返回违反声明类型约定的内容,它会发出警告。



from returns.result import Result, Success, Failure

def divide(first: float, second: float) -> Result[float, ZeroDivisionError]:
    try:
        return Success('Done')
        # => error: incompatible type "str"; expected "float"
    except ZeroDivisionError as exc:
        return Failure(0)
        # => error: incompatible type "int"; expected "ZeroDivisionError"


Enter fullscreen mode Exit fullscreen mode

如何处理包装值?

有两种方法可以处理这些包装的值

  • map与返回常规值的函数一起使用
  • bind与返回其他容器的函数一起使用


Success(4).bind(lambda number: Success(number / 2))
# => Success(2)

Success(4).map(lambda number: number + 1)
# => Success(5)


Enter fullscreen mode Exit fullscreen mode

关键在于:你将免受失败场景的影响。由于.bind.map不会在Failure容器中执行:



Failure(4).bind(lambda number: Success(number / 2))
# => Failure(4)

Failure(4).map(lambda number: number / 2)
# => Failure(4)


Enter fullscreen mode Exit fullscreen mode

现在您只需集中精力于正确的执行流程,并确保失败状态不会在随机位置破坏您的程序。

如果你愿意的话,你总是可以处理失败的状态,甚至修复它并回到正轨。



Failure(4).rescue(lambda number: Success(number + 1))
# => Success(5)

Failure(4).fix(lambda number: number / 2)
# => Success(2)


Enter fullscreen mode Exit fullscreen mode

这意味着“所有问题都必须单独解决”的实践是唯一的出路,并且“执行流程现在清晰了”。祝你享受铁路编程的乐趣!

但是如何从容器中解开值呢?

是的,在处理实际接受原始值的函数时,确实需要原始值。您可以使用.unwrap().value_or()方法:



Success(1).unwrap()
# => 1

Success(0).value_or(None)
# => 0

Failure(0).value_or(None)
# => None

Failure(1).unwrap()
# => Raises UnwrapFailedError()


Enter fullscreen mode Exit fullscreen mode

等等,什么?你之前保证过会让我避免异常,现在却告诉我,我所有的.unwrap()调用都会导致一个又一个的异常!

如何不关心这些 UnwrapFailedErrors?

好的,让我们看看如何处理这些新的异常。考虑这个例子:我们需要验证用户的输入,然后在数据库中创建两个模型。每个步骤都可能因异常而失败,所以我们将所有方法都包装到了Result包装器中:



from returns.result import Result, Success, Failure

class CreateAccountAndUser(object):
    """Creates new Account-User pair."""

    # TODO: we need to create a pipeline of these methods somehow...

    def _validate_user(
        self, username: str, email: str,
    ) -> Result['UserSchema', str]:
        """Returns an UserSchema for valid input, otherwise a Failure."""

    def _create_account(
        self, user_schema: 'UserSchema',
    ) -> Result['Account', str]:
        """Creates an Account for valid UserSchema's. Or returns a Failure."""

    def _create_user(
        self, account: 'Account',
    ) -> Result['User', str]:
        """Create an User instance. If user already exists returns Failure."""


Enter fullscreen mode Exit fullscreen mode

首先,在编写自己的业务逻辑时,您不能解开任何值:



class CreateAccountAndUser(object):
    """Creates new Account-User pair."""

    def __call__(self, username: str, email: str) -> Result['User', str]:
        """Can return a Success(user) or Failure(str_reason)."""
        return self._validate_user(
            username, email,
        ).bind(
            self._create_account,
        ).bind(
            self._create_user,
        )

   # ...


Enter fullscreen mode Exit fullscreen mode

这会正常运行,不会引发任何异常,因为.unwrap()没有使用。但是,这样的代码容易阅读吗?,不容易。我们能提供什么替代方案呢?@pipeline



from result.functions import pipeline

class CreateAccountAndUser(object):
    """Creates new Account-User pair."""

    @pipeline
    def __call__(self, username: str, email: str) -> Result['User', str]:
        """Can return a Success(user) or Failure(str_reason)."""
        user_schema = self._validate_user(username, email).unwrap()
        account = self._create_account(user_schema).unwrap()
        return self._create_user(account)

   # ...


Enter fullscreen mode Exit fullscreen mode

现在它完全可读了。这就是协同工作的原理.unwrap()@pipeline每当实例.unwrap()上的任何方法失败时,装饰器都会捕获它并返回结果值。这就是我们消除代码中所有异常并使其真正类型安全的方法。Failure[str]@pipelineFailure[str]

包装在一起

现在,让我们requests用所有新工具来解决这个例子。还记得吗,每一行都可能引发异常?而且没有办法让它们返回Result容器。但是你可以使用@safe装饰器包装不安全的函数,使它们变得安全。这两个例子是相同的:



from returns.functions import safe

@safe
def divide(first: float, second: float) -> float:
     return first / second


# is the same as:

def divide(first: float, second: float) -> Result[float, ZeroDivisionError]:
    try:
        return Success(first / second)
    except ZeroDivisionError as exc:
        return Failure(exc)


Enter fullscreen mode Exit fullscreen mode

我们可以看到第一个@safe更加易读和简单。

这是我们解决requests问题所需的最后一件事。最终的结果代码如下所示:



import requests
from returns.functions import pipeline, safe
from returns.result import Result

class FetchUserProfile(object):
    """Single responsibility callable object that fetches user profile."""

    #: You can later use dependency injection to replace `requests`
    #: with any other http library (or even a custom service).
    _http = requests

    @pipeline
    def __call__(self, user_id: int) -> Result['UserProfile', Exception]:
        """Fetches UserProfile dict from foreign API."""
        response = self._make_request(user_id).unwrap()
        return self._parse_json(response)

    @safe
    def _make_request(self, user_id: int) -> requests.Response:
        response = self._http.get('/api/users/{0}'.format(user_id))
        response.raise_for_status()
        return response

    @safe
    def _parse_json(self, response: requests.Response) -> 'UserProfile':
        return response.json()


Enter fullscreen mode Exit fullscreen mode

回顾一下:

  1. 我们对可能引发异常的所有方法使用@safe,它将函数的返回类型更改为Result[OldReturnType, Exception]
  2. 我们用Result一个简单的抽象来包装值和错误
  3. 我们用来.unwrap()从容器中解开原始值
  4. 我们用来@pipeline使.unwrap调用序列可读

这是一种非常易读且安全的方法,可以完成与我们之前使用 unsafe 函数完全相同的操作。它消除了我们在处理异常时遇到的所有问题:

  1. “异常很难被注意到”。现在,它们被一个类型化的Result容器包裹着,变得一目了然。
  2. “无法就地恢复正常行为”。现在我们可以安全地将恢复过程委托给调用者。我们针对此特定用例提供了.fix()和方法。.rescue()
  3. “执行流程不清晰”。现在它和常规业务流程一样,从上到下。
  4. “例外并不例外”。我们知道这一点!我们预料到事情会出错,并为此做好了准备。

用例和限制

显然,你不能用这种方式编写所有代码。在大多数情况下,这种方式过于安全,而且与其他库/框架不兼容。但是,你绝对应该像我上面提到的那样,只编写业务逻辑中最重要的部分。这将提高系统的可维护性和正确性。

GitHub 徽标 dry-python /返回

让你的函数返回一些有意义的、有类型的、安全的东西!

返回徽标


构建状态 代码验证 文档状态 Python 版本 康达 wemake-python-样式指南 电报聊天


让你的函数返回一些有意义的、有类型的、安全的东西!

特征

  • 将函数式编程引入 Python
  • 提供一系列原语来编写声明式业务逻辑
  • 实施更好的架构
  • 完全类型化并带有注释mypy兼容 PEP561
  • 添加模拟高级类型的支持
  • 提供类型安全接口,以创建具有强制法律的自己的数据类型
  • 有一堆助手可以更好地进行构图
  • 写起来和读起来都充满 Python 风格,令人愉悦🐍
  • 支持函数和协程,与框架无关
  • 易于上手:有大量文档、测试和教程

立即快速启动!

安装

pip install returns
Enter fullscreen mode Exit fullscreen mode

您还可以安装returns最新支持的mypy版本:

pip install returns[compatible-mypy]
Enter fullscreen mode Exit fullscreen mode

您还需要配置我们的mypy插件

# In setup.cfg or mypy.ini:
[mypy]
plugins =
  returns.contrib.mypy.returns_plugin
Enter fullscreen mode Exit fullscreen mode

或者:

[tool.mypy]
plugins = ["returns.contrib.mypy.returns_plugin"]
Enter fullscreen mode Exit fullscreen mode

我们还建议使用相同的……

鏂囩珷鏉ユ簮锛�https://dev.to/wemake-services/python-exceptions-considered-an-anti-pattern-17o9
PREV
Python 中的简单依赖类型
NEXT
在 Python 中实施单一职责原则