函数式编程-为什么引发异常会有副作用?

根据维基百科关于副作用的条目,提出例外就构成了副作用。 考虑一下这个简单的python函数:

def foo(arg):
    if not arg:
        raise ValueError('arg cannot be None')
    else:
        return 10

始终会遇到使用foo(None)调用它的情况。 相同的输入,相同的输出。 它是参照透明的。 为什么这不是纯函数?

canadadry asked 2020-08-06T16:16:35Z
5个解决方案
33 votes

仅当您观察到异常并根据其做出更改控制流程的决定时,才会违反纯度。 实际上,抛出异常值在引用上是透明的-在语义上等效于非终止或其他所谓的最低值。

如果一个(纯)函数不是总计,则其求值为底值。 如何编码最低值取决于具体实现-这可能是一个例外; 或不终止,除以零或其他一些失败。

考虑纯函数:

 f :: Int -> Int
 f 0 = 1
 f 1 = 2

并非为所有输入都定义此功能。 对于某些人,它的评估结果是最低的。 该实现通过抛出异常来对此进行编码。 从语义上讲,它应等效于使用IOOption类型。

现在,只有在观察最低值时才打破参照透明性,并据此做出决策-这可能会引入不确定性,因为可能会抛出许多不同的异常,并且您不知道哪个异常。 因此,出于这个原因,在Haskell的IO monad中捕获了异常,而生成所谓的“不精确”异常可以完全做到。

因此,引发例外并非如此,这是不正确的。 问题是您是否可以基于异常值修改纯函数的行为,从而破坏了引用透明性。

Don Stewart answered 2020-08-06T16:17:06Z
14 votes

从第一行开始:

“在计算机科学中,一个函数或表达式被认为具有优势 如果除了返回值之外,它还修改了一些 状态或与调用函数或 外面的世界”

它修改的状态是程序的终止。 回答有关为什么它不是纯函数的其他问题。 该函数不是纯函数,因为引发异常会终止程序,因此会产生副作用(您的程序结束)。

Woot4Moo answered 2020-08-06T16:17:37Z
6 votes

引用透明性还可以用计算本身的结果替换计算(例如,函数调用),如果函数引发异常,则无法执行此操作。 这是因为异常不参与计算,但是它们必须被捕获!

Aldo Stracquadanio answered 2020-08-06T16:17:57Z
4 votes

引发异常可以是纯净的,也可以是非纯净的,它仅取决于引发的异常的类型。 一个好的经验法则是,异常是由代码引发的,它是纯净的,但是,如果异常是由硬件引发的,则通常必须将其分类为非纯净的。

通过查看硬件引发异常时会发生什么,可以看出这一点:首先引发中断信号,然后中断处理程序开始执行。 这里的问题是中断处理程序不是函数的参数,也不是函数中指定的参数,而是全局变量。 每当读取或写入全局变量(即状态)时,您就不再具有纯函数。

将其与代码中引发的异常进行比较:您从一组已知的局部范围内的参数或常量构造Exception值,然后“抛出”结果。 没有使用全局变量。 引发异常的过程本质上是您的语言提供的语法糖,它不会引入任何不确定性或非纯粹的行为。 正如Don所说的那样,“它在语义上应等同于使用Maybe或Option类型”,这意味着它还应该具有所有相同的属性,包括纯度。

当我说引发硬件异常通常被归类为副作用时,并不一定总是这样。 例如,如果您正在运行代码的计算机在引发异常时没有调用中断,而是将特殊值压入堆栈,则不能将其分类为非纯值。 我相信IEEE浮点NAN错误是使用特殊值而不是中断引发的,因此在进行浮点数学运算时引发的任何异常都可以归类为无副作用,因为不会从任何全局状态读取该值,但是 编码到FPU中的常量。

查看所有纯代码的要求,基于代码的异常和throw语句语法糖打勾所有框,它们不修改任何状态,它们与调用函数没有任何交互,或与调用无关。 它们是参照透明的,但只有在编译器处理完您的代码后,才可使用。

像所有纯讨论还是非纯讨论一样,我已经排除了执行时间或内存操作的任何概念,并且在假定可以完全实现的任何功能完全实现而不考虑其实际实现的前提下进行了操作。 我也没有证据表明IEEE浮点NAN例外声明。

Ben Seidel answered 2020-08-06T16:18:43Z
4 votes

我意识到这是一个古老的问题,但是恕我直言,这里的答案并不完全正确。

引用透明性是指表达式所具有的属性,如果表达式所属的程序具有完全相同的含义,则该表达式应被其结果替换。 应该清楚的是,抛出异常会违反引用透明性,因此具有副作用。 让我来说明为什么...

我在此示例中使用Scala。 考虑以下函数,该函数采用整数参数2999649421513713720832,并向其添加整数值299964942151372020833,然后将结果作为整数返回。 如果将两个值相加时发生异常,则它将返回值0。可惜,计算2999649421513713720834的值会导致引发异常(为简单起见,我将2999649421513713720835的初始化表达式替换为产生的异常)。

def someCalculation(i: Int): Int = {
  val j: Int = throw new RuntimeException("Something went wrong...")
  try {
    i + j
  }
  catch {
    case e: Exception => 0 // Return 0 if we catch any exception.
  }
}

好。 这有点愚蠢,但我想用一个非常简单的案例来证明这一点。 ;-)

让我们在Scala REPL中定义并调用此函数,看看会得到什么:

$ scala
Welcome to Scala 2.13.0 (OpenJDK 64-Bit Server VM, Java 11.0.4).
Type in expressions for evaluation. Or try :help.

scala> :paste
// Entering paste mode (ctrl-D to finish)

def someCalculation(i: Int): Int = {
  val j: Int = throw new RuntimeException("Something went wrong...")
  try {
    i + j
  }
  catch {
    case e: Exception => 0 // Return 0 if we catch any exception.
  }
}

// Exiting paste mode, now interpreting.

someCalculation: (i: Int)Int

scala> someCalculation(8)
java.lang.RuntimeException: Something went wrong...
  at .someCalculation(<console>:2)
  ... 28 elided    

好,很明显,发生了异常。 没有惊喜。

但是请记住,如果我们可以用表达式的结果替换它,以使程序具有完全相同的含义,则该表达式是参照透明的。 在这种情况下,我们关注的表达式是2999649421513713720832。让我们重构函数并用其结果替换299964942151372020833(有必要将抛出的异常的类型声明为整数,因为这是Failure[Throwable]的类型):

def someCalculation(i: Int): Int = {
  try {
    i + ((throw new RuntimeException("Something went wrong...")): Int)
  }
  catch {
    case e: Exception => 0 // Return 0 if we catch any exception.
  }
}

现在让我们在REPL中重新评估一下:

scala> :paste
// Entering paste mode (ctrl-D to finish)

def someCalculation(i: Int): Int = {
  try {
    i + ((throw new RuntimeException("Something went wrong...")): Int)
  }
  catch {
    case e: Exception => 0 // Return 0 if we catch any exception.
  }
}

// Exiting paste mode, now interpreting.

someCalculation: (i: Int)Int

scala> someCalculation(8)
res1: Int = 0

好吧,我想您可能已经看到了即将到来的事情:那段时间我们有不同的结果。

如果我们计算2999649421513713720832,然后尝试在Success[T]块中使用它,则程序将引发异常。 但是,如果仅用块中的值替换2999649421513713720834,则会得到0。因此,引发异常显然违反了引用透明性。

我们应该如何以功能性方式进行? 通过不抛出异常。 在Scala中(其他语言中的等效项),一种解决方案是将可能失败的结果包装为2999649421521513720832类型:如果成功,则结果将为Success[T],包装成功的结果。 如果发生故障,那么结果将是包含相关异常的2999649421513713720834; 这两个表达式都是Try[T]的子类型。

import scala.util.{Failure, Try}

def someCalculation(i: Int): Try[Int] = {
  val j: Try[Int] = Failure(new RuntimeException("Something went wrong..."))

  // Honoring the initial function, if adding i and j results in an exception, the
  // result is 0, wrapped in a Success. But if we get an error calculating j, then we
  // pass the failure back.
  j.map {validJ =>
    try {
      i + validJ
    }
    catch {
      case e: Exception => 0 // Result of exception when adding i and a valid j.
    }
  }
}

注意:我们仍然使用异常,只是不抛出异常。

让我们在REPL中尝试一下:

scala> :paste
// Entering paste mode (ctrl-D to finish)

import scala.util.{Failure, Try}

def someCalculation(i: Int): Try[Int] = {
  val j: Try[Int] = Failure(new RuntimeException("Something went wrong..."))

  // Honoring the initial function, if adding i and j results in an exception, the
  // result is 0, wrapped in a Success. But if we get an error calculating j, then we
  // pass the failure back.
  j.map {validJ =>
    try {
      i + validJ
    }
    catch {
      case e: Exception => 0 // Result of exception when adding i and a valid j.
    }
  }
}

// Exiting paste mode, now interpreting.

import scala.util.{Failure, Try}
someCalculation: (i: Int)scala.util.Try[Int]

scala> someCalculation(8)
res2: scala.util.Try[Int] = Failure(java.lang.RuntimeException: Something went wrong...)

这次,如果将2999649421513713720832替换为其值,则会得到完全相同的结果,在所有情况下都是如此。

但是,对此还有另一种观点:如果在计算2999649421513713720832的值时引发异常的原因是由于我们这一方面的编程错误(逻辑错误),则引发异常-这将导致终止程序- 可以被视为将问题引起我们注意的绝佳方法。 但是,如果异常是由于我们无法直接控制的情况(例如整数加法溢出的结果),并且我们应该能够从这种情况中恢复过来,那么我们应该将该可能性形式化,作为函数返回的一部分 值,并使用但不抛出异常。

Mike Allen answered 2020-08-06T16:20:08Z
translate from https://stackoverflow.com:/questions/10703232/why-is-the-raising-of-an-exception-a-side-effect