对纯函数式语言的误解?


39

我经常遇到以下语句/参数:

  1. 纯函数式编程语言不允许有副作用(因此在实践中很少使用,因为任何有用的程序的确有副作用,例如,当它与外界交互时)。
  2. 纯函数式编程语言不允许编写维护状态的程序(这使编程变得很尴尬,因为在许多应用程序中您确实需要状态)。

我不是功能语言方面的专家,但是到目前为止,这是我对这些主题的了解。

关于第1点,您可以使用纯函数式语言与环境进行交互,但是必须显式标记引入副作用的代码(函数)(例如,在Haskell中通过单子类型)。而且,据我所知,即使不是首选工作方式,也应该可以进行副作用计算(破坏性地更新数据)(使用monadic类型?)。

关于第2点,据我所知,您可以通过几个计算步骤(在Haskell中,再次使用monadic类型)对值进行线程化来表示状态,但是我没有这样做的实践经验,我的理解还很模糊。

那么,以上两个陈述在任何意义上是正确的还是仅仅是对纯函数式语言的误解?如果是误解,它们是怎么产生的?您能写一个(可能很小的)代码片段来说明Haskell惯用的方式来(1)实现副作用和(2)实现有状态的计算吗?


7
我认为这大部分取决于您所定义的“纯”功能语言。
jk。

@jk:为避免定义“纯”功能语言的问题,请假设具有Haskell含义(定义明确)的纯正性。在这种情况下,可以将功能语言视为“纯”语言,这可能是未来问题的主题。
乔治

这两个答案都包含很多澄清的想法,我很难选择要接受的想法。由于其他伪代码示例,我决定接受sepp2k的答案。
乔治

Answers:


26

出于此答案的目的,我将“纯函数式语言”定义为表示函数引用透明的函数式语言,即用相同的参数多次调用相同的函数将始终产生相同的结果。我认为,这是纯功能语言的通常定义。

纯函数式编程语言不允许有副作用(因此在实践中很少使用,因为任何有用的程序的确有副作用,例如,当它与外界交互时)。

实现引用透明性的最简单方法实际上是禁止副作用,并且确实存在这种情况(多数情况下是针对特定领域的语言)。但是,它当然不是唯一的方法,大多数通用的纯函数式语言(Haskell,Clean等)确实会产生副作用。

我还说没有副作用的编程语言在实践中很少使用是不公平的-我肯定不是针对特定领域的语言,但是即使对于通用语言,我也想一种语言在不提供副作用的情况下会非常有用。也许不是针对控制台应用程序,但我认为GUI应用程序可以很好地实现,而不会在功能性反应模式中产生副作用。

关于第1点,您可以使用纯函数式语言与环境进行交互,但是您必须显式标记引入它们的代码(函数)(例如在Haskell中通过单子类型)。

这有点简化它。仅具有需要这样标记副作用函数的系统(类似于C ++中的const正确性,但具有一般的副作用)不足以确保引用透明性。您需要确保程序永远不能使用相同的参数多次调用函数并获得不同的结果。您可以通过做类似的事情来做到这一点readLine可能不是函数(这是Haskell对IO monad所做的事情),或者您可能无法使用相同的参数多次调用副作用函数(这就是Clean所做的事情)。在后一种情况下,编译器将确保每次调用副作用函数时都使用一个新的参数,否则它将拒绝将同一参数两次传递给副作用函数的任何程序。

纯函数式编程语言不允许编写维护状态的程序(这使编程变得很尴尬,因为在许多应用程序中您确实需要状态)。

同样,纯函数式语言很可能不允许可变状态,但是如果以与我上面提到的副作用相同的方式实现它,那么纯净的语言当然仍然可能具有可变状态。真正可变的状态只是副作用的另一种形式。

也就是说,函数式编程语言肯定会阻止可变状态-尤其是纯状态。而且我不认为这会使编程尴尬-相反。有时(但并非总是如此)可变状态不能避免而不会失去性能或清晰度(这就是为什么Haskell之类的语言确实具有可变状态的功能)的原因,但大多数情况下是可以避免的。

如果是误解,它们是怎么产生的?

我认为许多人只是读了“一个函数在使用相同的参数调用时必须产生相同的结果”,并得出结论,不可能实现类似readLine或代码的方法来保持可变状态。因此,他们根本不了解纯函数式语言可以用来引入这些东西而又不破坏引用透明性的“秘籍”。

另外,可变状态在函数式语言中非常不鼓励使用,因此假设纯函数式语言根本不允许这种状态并不过分。

您能写一个(可能很小的)代码片段来说明Haskell惯用的方式来(1)实现副作用和(2)实现有状态的计算吗?

这是Pseudo-Haskell中的一个应用程序,要求用户输入名称并向他打招呼。Pseudo-Haskell是我刚刚发明的一种语言,它具有Haskell的IO系统,但使用的是更常规的语法,更具描述性的函数名,并且没有- do标记(因为这只会分散IO monad的工作方式):

greet(name) = print("Hello, " ++ name ++ "!")
main = composeMonad(readLine, greet)

这里的线索是,它readLine是type的值,IO<String>并且composeMonad是一个接受type IO<T>(对于某些type T)参数的函数,而另一个参数是该函数,它接受了type的参数T并返回type IO<U>(对于某些type U)的值。print是一个接受字符串并返回type值的函数IO<void>

type IO<A>的值是“编码”产生type值的给定动作的值AcomposeMonad(m, f)产生一个新IO值,该新值编码m跟随的动作,然后是的动作f(x),其中x值是通过执行的动作而产生的m

可变状态如下所示:

counter = mutableVariable(0)
increaseCounter(cnt) =
    setIncreasedValue(oldValue) = setValue(cnt, oldValue + 1)
    composeMonad(getValue(cnt), setIncreasedValue)

printCounter(cnt) = composeMonad( getValue(cnt), print )

main = composeVoidMonad( increaseCounter(counter), printCounter(counter) )

mutableVariable是一个接受任何类型的值T并产生的函数MutableVariable<T>。该函数getValue接受MutableVariable并返回IO<T>产生其当前值的。setValue取a MutableVariable<T>和a T并返回IO<void>设置值的a。与第一个参数composeVoidMonad相同,composeMonad只是第一个参数是一个IO不会产生有意义值的参数,第二个参数是另一个monad,而不是返回monad的函数。

在Haskell中有一些语法糖,可以减轻整个折磨的痛苦,但是仍然很明显,可变状态是语言真正不希望您执行的操作。


很好的答案,阐明了很多想法。代码片段的最后一行是否应该使用名称counter,即increaseCounter(counter)
乔治

@Giorgio是的,应该。固定。
sepp2k 2012年

1
@Giorgio我忘了在我的帖子中明确提到的一件事是,返回的IO操作main将是实际执行的操作。除了从IO返回IO之外,其他main方法均无法执行IO操作(不使用unsafe名称中具有可怕含义的邪恶函数)。
sepp2k 2012年

好。Scarfridge还提到破坏IO价值。我不明白他是否指模式匹配,即您可以解构代数数据类型的值这一事实,但是不能使用模式匹配来对IO值进行此操作。
乔治

16

恕我直言,您很困惑,因为纯语言和纯函数之间存在差异。让我们从功能开始。如果函数(给定相同的输入)始终返回相同的值并且不会引起任何可观察到的副作用,则该函数是函数。典型的例子是数学函数,例如f(x)= x * x。现在考虑该功能的实现。即使在通常不被视为纯功能语言(例如ML)的那些语言中,它也将是纯语言。甚至具有这种行为的Java或C ++方法也可以视为纯方法。

那么什么是纯语言?严格来说,人们可能期望纯语言不会让您表达非纯函数。让我们称其为纯语言的理想定义。这种行为是非常期望的。为什么?好吧,一个仅包含纯函数的程序的好处是,您可以用函数的值替换函数应用程序,而无需更改程序的含义。这使对程序的推理变得非常容易,因为一旦知道结果,就可以忘记计算方法。纯度还可能使编译器执行某些积极的优化。

那么,如果您需要一些内部状态怎么办?您只需将计算之前的状态添加为输入参数,并将计算之后的状态添加为结果的一部分,即可使用纯语言模拟状态。而不是Int -> Bool得到类似的东西Int -> State -> (Bool, State)。您只需简单地使依赖关系明确(在任何编程范例中都认为是好的做法)。顺便说一句,有一种monad是将此类状态模仿功能组合为更大的状态模仿功能的一种特别优雅的方法。这样,您绝对可以用一种纯语言“保持状态”。但是您必须使其明确。

那么这是否意味着我可以与外界互动?毕竟,有用的程序必须与现实世界互动才能有用。但是输入和输出显然不是纯粹的。第一次将特定字节写入特定文件可能没问题。但是第二次执行完全相同的操作可能会返回错误,因为磁盘已满。显然,没有可以写到文件的纯语言(理想主义意义上的)。

因此,我们面临一个难题。我们主要需要纯函数,但是绝对需要一些副作用,而这些副作用不是纯函数。现在,对纯语言现实定义是必须有某种方法将纯文本部分与其他部分分开。该机构必须确保没有不正确的操作潜入纯零件中。

在Haskell中,这是通过IO类型完成的。您不能破坏IO结果(没有不安全的机制)。因此,您只能使用IO模块本身定义的功能来处理IO结果。幸运的是,有一个非常灵活的组合器,您可以获取一个IO结果并在一个函数中对其进行处理,只要该函数返回另一个IO结果即可。此组合器称为bind(或>>=),类型为IO a -> (a -> IO b) -> IO b。如果将这个概念概括化,您将到达monad类,IO恰好是它的一个实例。


4
我真的看不到Haskell(忽略unsafe其名称中的任何函数)如何不符合您的理想定义。Haskell中没有不纯函数(再次忽略unsafePerformIO和co。)。
sepp2k 2012年

4
readFilewriteFileIO给定相同参数的情况下始终返回相同的值。因此,例如两个代码片段let x = writeFile "foo.txt" "bar" in x >> x,并writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"会做同样的事情。
sepp2k 2012年

3
@AidanCully“ IO功能”是什么意思?返回类型为IO Something?的值的函数 如果是这样,完全有可能使用相同的参数两次调用IO函数:putStrLn "hello" >> putStrLn "hello"-此处两个调用putStrLn都具有相同的参数。当然,这不是问题,因为正如我之前所说,这两个调用将导致相同的IO值。
sepp2k 2012年

3
@scarfridge评估writeFile "foo.txt" "bar"不能引起错误,因为评估函数调用并没有执行动作。如果您在前面的示例中说let带有IO的版本只有一次机会导致IO故障,而没有IO的版本let有两次机会导致IO故障,那么您错了。两种版本都有两次发生IO故障的机会。由于let版本writeFile仅评估一次调用,而没有版本let评估两次调用,因此您可以看到函数的调用频率无关紧要。只关系到产生的频率是多少...
sepp2k 2012年

6
@AidanCully“单子机制”不会传递隐式参数。该putStrLn函数仅接受一个参数,类型为String。如果您不相信我,请查看其类型:String -> IO ()。它当然不带任何类型的参数IO-它产生该类型的值。
sepp2k 2012年
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.