功能样式异常处理


24

有人告诉我,在函数式编程中,不应抛出和/或观察异常。取而代之的是,应将错误的计算结果视为底值。在Python(或其他不完全鼓励函数式编程的语言)中,只要出现“保持纯净”的错误,就可以返回None(或另一种替代方法,尽管None它严格地不符合定义)。所以必须首先观察到错误,即

def fn(*args):
    try:
        ... do something
    except SomeException:
        return None

这违反纯洁吗?如果是这样,是否意味着不可能仅用Python处理错误?

更新资料

埃里克·利珀特(Eric Lippert)在他的评论中使我想起了FP中处理异常的另一种方法。尽管我从未在实践中看到过用Python完成的工作,但一年前我学习FP时我还是玩过它。在这里,任何optional装饰函数返回的Optional值对于正常输出以及指定的异常列表都可以为空(未指定的异常仍可以终止执行)。Carry创建一个延迟的评估,其中每个步骤(延迟的函数调用)要么Optional从上一步获取非空输出,然后继续传递,要么通过一个new评估自身Optional。最后,最终值是normal或Empty。此处,该try/except块隐藏在装饰器后面,因此可以将指定的异常视为返回类型签名的一部分。

class Empty:
    def __repr__(self):
        return "Empty"


class Optional:
    def __init__(self, value=Empty):
        self._value = value

    @property
    def value(self):
        return Empty if self.isempty else self._value

    @property
    def isempty(self):
        return isinstance(self._value, BaseException) or self._value is Empty

    def __bool__(self):
        raise TypeError("Optional has no boolean value")


def optional(*exception_types):
    def build_wrapper(func):
        def wrapper(*args, **kwargs):
            try:
                return Optional(func(*args, **kwargs))
            except exception_types as e:
                return Optional(e)
        wrapper.__isoptional__ = True
        return wrapper
    return build_wrapper


class Carry:
    """
    >>> from functools import partial
    >>> @optional(ArithmeticError)
    ... def rdiv(a, b):
    ...     return b // a
    >>> (Carry() >> (rdiv, 0) >> (rdiv, 0) >> partial(rdiv, 1))(1)
    1
    >>> (Carry() >> (rdiv, 0) >> (rdiv, 1))(1)
    1
    >>> (Carry() >> rdiv >> rdiv)(0, 1) is Empty
    True
    """
    def __init__(self, steps=None):
        self._steps = tuple(steps) if steps is not None else ()

    def _add_step(self, step):
        fn, *step_args = step if isinstance(step, Sequence) else (step, )
        return type(self)(steps=self._steps + ((fn, step_args), ))

    def __rshift__(self, step) -> "Carry":
        return self._add_step(step)

    def _evaluate(self, *args) -> Optional:
        def caller(carried: Optional, step):
            fn, step_args = step
            return fn(*(*step_args, *args)) if carried.isempty else carried
        return reduce(caller, self._steps, Optional())

    def __call__(self, *args):
        return self._evaluate(*args).value

1
您的问题已经得到回答,所以仅作评论:您是否理解为什么函数编程中不赞成让函数引发异常?这不是随心所欲:)
Andres F.

6
除了返回指示错误的值之外,还有另一种选择。请记住,异常处理是控制流,我们拥有用于简化控制流的功能机制。您可以通过编写方法来采用两种功能来模拟功能语言中的异常处理:成功延续错误延续。函数执行的最后一件事是调用成功继续并传递“结果”,或者调用错误继续并传递“异常”。不利的一面是,您必须以这种由内而外的方式编写程序。
埃里克·利珀特

3
不,这种情况下的状态如何?有多个问题,但有几个问题:1-存在一种可能的流程,该流程未在类型中进行编码,即,仅通过查看其类型就无法知道函数是否会引发异常(除非您仅检查了该异常)当然,但我不知道只有它们的语言)。您正在有效地在类型系统的“外部”工作,2函数式程序员努力在可能的情况下编写“总计”函数,即,为每个输入返回值(禁止非终止)的函数。例外与此相反。
安德列斯·F。

3
3-当使用全部功能时,可以将它们与其他功能组合在一起,并在更高阶的功能中使用它们,而不必担心错误结果“未按类型编码”,即异常。
Andres F.

1
@EliKorvigo设置变量会根据定义引入句号状态。变量的全部目的是保持状态。这与异常无关。观察函数的返回值并基于观察值设置变量的值会将状态引入评估中,不是吗?
user253751 '16

Answers:


20

首先,让我们清除一些误解。没有“底价”。底部类型定义为一种语言,它是该语言中所有其他类型的子类型。由此,可以证明(至少在任何有趣的类型系统中)底部类型没有值 -它为。因此,没有底值之类的东西。

为什么底部类型有用?好吧,知道它是空的,让我们对程序行为进行一些推断。例如,如果我们具有以下功能:

def do_thing(a: int) -> Bottom: ...

我们知道它do_thing永远不会返回,因为它必须返回type的值Bottom。因此,只有两种可能性:

  1. do_thing 不停止
  2. do_thing 引发异常(在具有异常机制的语言中)

请注意,我创建了Bottom在Python语言中实际上不存在的类型。None是一个不当用语;它实际上是unit值,这是unit type的唯一值,NoneType在Python 中被调用(请type(None)自行确认)。

现在,另一个误解是功能语言没有例外。这也不是真的。例如,SML具有非常好的异常机制。但是,在SML中使用异常的情况要比在Python中少得多。正如您已经说过的,在函数式语言中指示某种故障的常见方法是返回一个Option类型。例如,我们将创建一个安全的除法函数,如下所示:

def safe_div(num: int, den: int) -> Option[int]:
  return Some(num/den) if den != 0 else None

不幸的是,由于Python实际上没有求和类型,所以这不是可行的方法。你可以返回None的,穷人的选择类型,则意味着失败,但这是真的没有比返回Null。没有类型安全性。

因此,在这种情况下,我建议您遵循该语言的约定。Python习惯性地使用异常来处理控制流(这是很糟糕的设计,IMO,但是它是标准的),因此,除非您只使用自己编写的代码,否则建议遵循标准的做法。这是否“纯”无关紧要。


我所说的“底价”实际上是“底价”,这就是为什么我写的None不符合定义的原因。无论如何,谢谢你纠正我。您不认为仅使用异常来完全停止执行或返回可选值就可以了吗?我的意思是,为什么限制使用异常进行复杂控制很不好?
Eli Korvigo

@EliKorvigo差不多就是我说的对吧?特例是Python。
gardenhead

1
例如,我不鼓励本科生try/except/finally像的另一种选择那样使用if/else,即try: var = expession1; except ...: var = expression 2; except ...: var = expression 3...,尽管在任何命令式语言中都这样做是很平常的事情(顺便说一句,我也强烈反对if/else为此使用块)。您是说我不合理吗,因为“这是Python”,应该允许这样的模式吗?
Eli Korvigo

@EliKorvigo我总体上同意你(顺便说一句,你是教授吗?)。try... catch...应当被用于控制流。出于某种原因,这就是Python社区决定做事的方式。例如,safe_div我上面编写的函数通常会被编写try: result = num / div: except ArithmeticError: result = None。因此,如果要教他们通用软件工程原理,则绝对不应该这样做。if ... else...也是代码的味道,但这太长了。
gardenhead

2
有一个“底值”(例如,它用于谈论Haskell的语义),与底类型无关。因此,这实际上不是对OP的误解,只是在谈论另一件事。

11

由于最近几天人们对纯净性有极大的兴趣,为什么我们不检查纯净函数的外观。

一个纯函数:

  • 是参照透明的;也就是说,对于给定的输入,它将始终产生相同的输出。

  • 不产生副作用;它不会在其外部环境中更改输入,输出或其他任何内容。它只产生一个返回值。

所以问问你自己。除了接受输入并返回输出外,您的函数是否有任何作用?


3
所以,没关系,只要它在功能上起作用,它在里面被写得多么丑陋?
Eli Korvigo

8
是的,如果您仅对纯净感兴趣。当然,可能还需要考虑其他事项。
罗伯特·哈维

3
为了扮演魔鬼的拥护者,我认为抛出异常只是输出的一种形式,并且抛出的函数是纯净的。那是问题吗?
Bergi

1
@Bergi我不知道您是在扮演恶魔的拥护者,因为这正是这个答案所隐含的含义:)问题在于,除了纯洁之外,还有其他考虑因素。如果您允许未经检查的异常(根据定义,异常不是函数签名的一部分),则每个函数的返回类型都会有效地变为T + { Exception }T显式声明的返回类型在其中),这是有问题的。您不了解函数的源代码就无法知道它是否会引发异常,这也使编写高阶函数成为问题。
Andres F.

2
@Begri虽然可以说是纯粹的,但是使用异常的IMO不仅使每个函数的返回类型变得复杂。它会破坏功能的可组合性。考虑在map : (A->B)->List A ->List B哪里A->B可以执行错误。如果我们允许f引发异常,map f L 则将返回type Exception + List<B>。如果相反,我们允许它返回optional样式类型,map f L则将返回List <Optional <B >>`。第二个选择对我来说更实用。
迈克尔·安德森

7

Haskell语义使用“底值”来分析Haskell代码的含义。这不是在Haskell编程中直接真正使用的东西,返回None根本不是一回事。

最低值是Haskell语义将任何无法正常评估为值的计算赋予的值。Haskell计算可以做到这一点的一种方法实际上是抛出异常!因此,如果您尝试在Python中使用这种样式,则实际上应该像平常一样抛出异常。

Haskell语义使用最低值,因为Haskell是惰性的。您可以操纵尚未实际运行的计算返回的“值”。您可以将它们传递给函数,将它们保留在数据结构中,等等。这种未经评估的计算可能会永远引发异常或循环,但是如果我们从不真正需要检查该值,则该计算将永远不会运行并遇到错误,我们的整个程序可能会设法完成一些明确定义并完成。因此,我们不想通过在运行时指定程序的确切操作行为来解释Haskell代码的含义,而是声明这种错误的计算会产生最低值,并解释该值的行为。基本上是其需要在所有的谷值(比现有其他)的依赖于任何性质的任何表达式将导致底部值。

为了保持“纯”,必须将所有产生底值的可能方式视为等效。其中包括代表无限循环的“最低值”。由于无法知道某些无限循环实际上是无限的(如果您将它们运行更长的时间,它们可能会结束),因此您无法检查底值的任何属性。您无法测试某些内容是否在底部,无法将其与其他内容进行比较,无法将其转换为字符串,什么也没有。您可以做的所有事情都是将其放置(功能参数,数据结构的一部分等)保持不变且未经检查的。

Python已经有了这种底线。这是您从引发异常或不终止的表达式中获得的“值”。因为Python是严格的而不是懒惰的,所以这样的“底部”不能存储在任何地方,并且有可能未经检查。因此,没有必要真正使用底值的概念来解释如何仍然可以将无法返回值的计算视为具有值。但是,如果您愿意,也没有理由您不会以这种方式考虑异常。

抛出异常实际上被认为是“纯”的。它捕获了打破纯净度的异常-正是因为它允许您检查某些特定底值,而不是将它们全部互换。在Haskell中,您只能捕获IO允许不纯净接口的异常(因此它通常发生在相当外层)。Python不会强制执行纯净,但是您仍然可以自己决定哪些功能属于“外部不纯层”的一部分,而不是纯净函数,并且只允许您自己捕获那里的异常。

None相反,返回是完全不同的。None是一个非底值;您可以测试是否等于它,返回的函数的调用者None将继续运行,可能使用None不当。

因此,如果您想抛出一个异常并想“返回最低点”以模仿Haskell的方法,那么您什么也不做。让异常传播。这就是Haskell程序员在谈论返回底值的函数时的确切含义。

但这并不是函数程序员说避免异常的意思。功能程序员喜欢“全部功能”。对于每个可能的输入,它们总是返回其返回类型的有效非底值。因此,任何可能引发异常的功能都不是全部功能。

我们喜欢总功能的原因是,当我们组合和操作它们时,它们更容易被视为“黑匣子”。如果我有一个返回某类型A的合计函数和一个接受某个类型A的合计函数,那么我可以在第一个的输出上调用第二个,而无需了解任何一个的实现。我知道,无论将来如何更新这两个函数的代码(只要它们的总数得以保持,并且只要它们保持相同的类型签名),我都会得到有效的结果。关注点的分离可以为重构提供非常有力的帮助。

对于可靠的高阶函数(操纵其他函数的函数),这也有必要。如果我想编写接收完全任意函数(具有已知接口)作为参数的代码,必须将其视为黑匣子,因为我无法知道哪些输入可能触发错误。如果给我一个总体功能,那么没有输入将导致错误。同样,我的高阶函数的调用者也不会确切知道我用来调用传递给我的函数的参数(除非它们想依赖于我的实现细节),因此传递总函数意味着他们不必担心我用它做什么。

因此,建议您避免异常的函数式程序员更希望您返回一个对错误或有效值进行编码的值,并要求使用该值,您已准备好处理这两种可能性。Either类型或Maybe/ Option类型之类的东西是使用更强类型的语言(通常与特殊语法或高阶函数结合使用,以帮助将需要a A的事物与产生a的事物粘合在一起)的最简单方法Maybe<A>

既不返回None(如果发生错误)又返回某个值(如果没有错误)的函数都没有采用上述两种策略。

在带有鸭子类型的Python中,使用Either / Maybe样式的用处不大,而是抛出异常,并通过测试来验证代码是否有效,而不是相信函数是完全的并可以根据其类型自动组合。Python没有工具来强制代码正确使用Maybe类型之类的东西。即使您只是出于纪律原则使用它,也需要进行测试以实际使用您的代码进行验证。因此,异常/底部方法可能更适合于Python中的纯函数式编程。


1
+1很棒的答案!而且非常全面。
Andres F.

6

只要没有外部可见的副作用,并且返回值仅取决于输入,则该函数是纯函数,即使它在内部做了一些相当不纯净的事情。

因此,它实际上取决于什么会导致引发异常。如果您尝试打开具有给定路径的文件,则不会,它不是纯净的,因为该文件可能存在或不存在,这将导致相同输入的返回值有所不同。

另一方面,如果您尝试从给定的字符串中解析一个整数,并在失败时抛出异常,那么只要没有异常会溢出您的函数,这可能是纯净的。

顺便提一句,功能语言倾向于仅在只有一个可能的错误情况时才返回单元类型。如果存在多个可能的错误,则它们倾向于返回带有错误信息的错误类型。


您无法返回底部类型。
gardenhead

1
@gardenhead你是对的,我在想单位类型。固定。
8bittree '16

在您的第一个示例中,文件系统和其中包含的文件不只是函数的输入之一吗?

2
@Vaility实际上,您可能具有文件的句柄或路径。如果函数f是纯函数,则您希望f("/path/to/file")总是返回相同的值。如果实际的文件在两次调用之间被删除或更改了f怎么办?
Andres F.

2
@Vaility如果该函数不是传递文件的句柄,而是传递文件的实际内容(即字节的确切快照),那么该函数可以是纯函数,在这种情况下,您可以保证如果输入相同的内容,则输出相同出去。但是访问文件不是那样的:)
Andres F.
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.