20

一段时间以来,我一直在尝试将注意力集中在函数式编程上。我查看了 lambda 演算、LISP、OCaml、F# 甚至组合逻辑,但我遇到的主要问题是 - 你如何做需要副作用的事情,例如:

  • 与用户交互,
  • 与远程服务通信,或
  • 使用随机抽样处理模拟

在不违反纯函数式编程的基本前提的情况下,即对于给定的输入,输出是确定性的?

我希望我说得通;如果不是,我欢迎任何帮助我理解的尝试。提前致谢。

4

10 回答 10

24

大多数现实世界的函数式编程在大多数意义上都不是“纯粹的”,所以你问题的一半答案是“你通过放弃纯粹性来做到这一点”。也就是说,还有其他选择。

在纯粹的“最纯粹”意义上,整个程序代表一个或多个参数的单个函数,返回一个值。如果您眯起眼睛并稍微挥动一下手,您可以声明所有用户输入都是函数“参数”的一部分,所有输出都是“返回值”的一部分,然后稍微捏造一些东西,让它只做“按需”的实际 I/O。

类似的观点是声明函数的输入是“外部世界的整个状态”,并且评估函数返回一个新的、修改后的“世界状态”。在这种情况下,程序中使用世界状态的任何函数显然都摆脱了“确定性”的束缚,因为程序的两次评估都不会有完全相同的外部世界。

如果您想用纯 lambda 演算(或类似的东西,例如深奥的语言 Lazy K)编写一个交互式程序,那么从概念上讲,您就是这样做的。

在更实际的情况下,问题归结为在将输入用作函数的参数时确保 I/O 以正确的顺序发生。这个问题的“纯”解决方案的一般结构是函数组合。例如,假设您有三个执行 I/O 的函数,并且您想按特定顺序调用它们。如果你做的事情就像RunThreeFunctions(f1, f2, f3)没有什么可以确定它们将被评估的顺序。另一方面,如果你让每个函数接受另一个函数作为参数,你可以像这样链接它们:f1( f2( f3())),在这种情况下你知道f3会首先被评估,因为评估f2取决于它的值。[编辑:另请参阅下面关于惰性与急切评估的评论。这很重要,因为惰性求值实际上在非常纯粹的上下文中很常见;例如,纯 lambda 演算中递归的标准实现在热切评估下是非终止的。]

同样,要在 lambda 演算中编写交互式程序,您可能会这样做。如果您想要一些实际可用于编程的东西,您可能希望将函数组合部分与函数的概念结构结合起来,该函数获取和返回表示世界状态的值,并创建一些高阶抽象来处理流水线“ I/O 函数之间的世界状态”值,理想情况下还保持“世界状态”包含以强制执行严格的线性——此时您几乎重新发明了 Haskell 的IOMonad。

希望这不仅使您更加困惑。

于 2009-12-16T18:58:14.513 回答
9

Haskell 是一种纯函数式编程语言。在 Haskell 中,所有函数都是纯函数(即它们总是为相同的输入提供相同的输出)。但是你如何处理 Haskell 中的副作用?嗯,这个问题通过使用monads很好地解决了。

以 I/O 为例。在 Haskell 中,每个执行 I/O 的函数都会返回一个 IO 计算,即 IO monad 中的一个计算。因此,例如,一个从键盘读取 int 的函数,而不是返回一个 int,而是返回一个在运行时产生一个 int 的 IO 计算:

askForInt :: String -> IO Int

例如,因为它返回 I/O 计算而不是Int,所以不能直接在求和中使用此结果。为了访问Int您需要“解包”计算的值。唯一的方法是使用绑定函数 ( >>=):

(>>=) :: IO a -> (a -> IO b) -> IO b

因为这也返回一个 IO 计算,所以你总是以一个 I/O 计算结束。这就是 Haskell 隔离副作用的方式。IO monad 充当对现实世界状态的抽象(实际上,在幕后,它通常使用RealWorld为状态部分命名的类型来实现)。

于 2009-12-16T18:43:51.890 回答
7

与用户交互并与远程服务通信确实需要您的软件的某种非功能部分。

许多“函数式语言”(像大多数 Lisps 一样)并不是纯粹的函数式。他们仍然允许你做有副作用的事情,尽管在大多数情况下副作用的事情是“不鼓励的”。

Haskell 是“纯功能性的”,但仍然允许您通过 IO monad 执行非功能性的事情。基本思想是,您的纯函数式程序会发出一个惰性数据结构,该数据结构由非函数式程序(您不编写,它是环境的一部分)评估。有人可能会争辩说,这种数据结构本身就是一个命令式程序。所以你有点用函数式语言进行命令式元编程。

忽略哪种方法“更好”,这两种情况的目标都是在程序的功能部分和非功能部分之间建立分离,并尽可能限制非功能部分的大小。功能部分往往更可重用、可测试且更易于推理。

于 2009-12-16T18:45:49.307 回答
6

函数式编程是关于限制和隔离副作用,而不是试图完全摆脱它们……因为你做不到。

... 是的,我发现 FP 很有用(当然对于 Erlang 来说):我发现从“想法”到“程序”(或从问题到解决方案;)更容易......但当然这可能只是我。

于 2009-12-16T18:37:37.410 回答
3

我所知道的唯一完全纯函数式语言是 C++ 中的模板系统。Haskell 通过使程序的命令部分显式化而位居第二。

在 Haskell 中,程序具有可变状态,但函数(几乎总是)没有。你保持了 99% 的程序纯,只有与外界交互的部分是不纯的。因此,当您测试一个函数时,您知道没有副作用。纯净的核心,带有不纯净的外壳。

于 2009-12-16T19:48:58.290 回答
2

你至少需要知道另一个基本概念:Monads。您将需要它来执行 I/O 和其他“有用”的东西!

于 2009-12-16T18:41:45.023 回答
2

Haskell 这样做的方式是使用 monad,请参阅wikipedia和 Haskell 在其页面上的解释。

基本上这个想法是你不要摆脱 IO monad。我的理解是,您可以链接解开 IO monad 的函数并执行该函数。但是你不能完全删除 IO monad。

另一个使用不直接绑定到 IO 的 monad 的例子是 Maybe Monad。与 IO monad 相反,这个 monad 是“不可包装的”。但是使用 Maybe monad 更容易解释 monad 的使用。假设您具有以下功能。

wrap :: Maybe x -> (x -> y) -> Maybe y
wrap Nothing  f = Nothing
wrap (Just x) f = Just (f x)

现在你可以调用 wrap (Just 4) (5+)which 将返回Just 9

IO-monad 的想法是您可以在内部类型上使用 (+5) 之类的函数。monad 将确保函数将被串行调用,因为每个函数都与包装 IO-monad 链接在一起。

于 2009-12-16T18:44:08.017 回答
1

鉴于大多数程序对外部世界都有一些影响(写入文件、修改数据库中的数据......),整个程序很少没有副作用。除了学术练习之外,即使尝试也没有任何意义。

但是程序是由构建块(子例程、函数、方法,随心所欲地称呼它)组装而成的,而纯函数构成了行为良好的构建块。

大多数函数式编程语言不要求函数是纯的,尽管优秀的函数式程序员会尝试尽可能多地使他们的函数成为纯函数,以便获得引用透明性的好处。

哈斯克尔走得更远。Haskell 程序的每一部分都是纯粹的(至少在没有诸如“unsafePerformIO”之类的罪恶的情况下)。您在 Haskell 中编写的所有函数都是纯函数。

副作用是通过单子引入的。它们可以用来引入一种“购物清单——购物者”的分离。本质上,您的程序编写了一个购物清单(它只是数据,可以以纯粹的方式进行操作),而语言运行时解释购物清单并进行有效的购物。你所有的代码都是纯的,对等式推理等很友好,而不纯的代码是由编译器编写者提供的。

于 2009-12-20T11:58:04.527 回答
0

即使您没有在工作中使用它,学习一种或多种函数式编程语言也是一种学习不同思考方式的好方法,并为您提供了解决问题的替代方法的工具包(当您无法做到时,它也会让您感到沮丧)像其他语言中的函数式方法一样整洁干净)。

它使我更擅长编写 XSL 样式表。

于 2009-12-16T18:42:23.977 回答
0

在纯函数式编程中是否可能产生副作用?

这取决于...

[...] 我遇到的主要问题是如何在不违反纯函数式编程的基本前提的情况下做需要副作用的事情 [...] ,即对于给定的输入,输出是确定性的

...这是一个有趣的描述:这是一个类似的描述:

[...] 函数式编程的数学基础,它要求函数的值由其参数的值唯一确定。

F.沃伦伯顿。,

...然后他对此进行了扩展:

引用透明性,即表达式在相同环境中始终具有相同值的属性,是函数式程序的数学基础的核心。

我们将选择“纯”、“参照透明”和“副作用”等术语留给另一个问题,而不是选择修改此处提出的问题以避免使用它们:

用函数式语言编写的程序(函数式程序)如何执行以下实际任务:

  • 与用户交互,
  • 与远程服务通信,
  • 使用随机抽样处理模拟,
  • 打印出SVG 文件(例如作为海报),
  • 进行计划备份,

...等等,同时确保对于给定的输入,输出是确定性的?

Burton 的解决方案使用了他所谓的伪数据:提供抽象的一次性价值。然后以合适的结构化值的形式提供初始源 - Burton 使用树:

  • 原始树 - 作为参数传递给正在运行的程序 - 被划分为子树,在整个程序中分布(也作为程序函数的参数);

  • 然后从这些子树中检索新的抽象值以供原始函数使用,其中会发生预期的效果。

  • 每个抽象值只能使用一次,因此每个原始调用都需要另一个新的抽象值作为输入 - 如果原始调用以某种方式重复,则输出将是相同的。

除了提供不确定性之外,Burton 还简要描述了如何扩展他的方法以访问其他系统资源(特别是时间戳空格)。有关更多信息,请阅读他的论文——它只有 5 页长……

于 2021-09-30T00:21:26.860 回答