22

我现在处理函数式编程的概念有一段时间了,发现它非常有趣、迷人和令人兴奋。尤其是纯函数的想法在各个方面都很棒。

但是有一件事我没有得到:在将自己限制为纯函数时如何处理副作用。

例如,如果我想计算两个数字的和,我可以编写一个纯函数(在 JavaScript 中):

var add = function (first, second) {
  return first + second;
};

完全没有问题。但是如果我想将结果打印到控制台怎么办?从定义上看,“向控制台打印一些东西”的任务并不是纯粹的——但我怎么能/应该用纯粹的函数式编程语言来处理这个问题?

4

3 回答 3

20

有几种方法可以解决这个问题。您必须接受的一件事是,在某些时候,存在一种神奇的不纯机器,它采用纯表达式并通过与环境交互使它们变得不纯。你不应该问关于这台神奇机器的问题。

我可以想到两种方法。至少还有第三个我已经忘记了。


I/O 流

最容易理解的方法可能是流式 I/O。你的main函数有一个参数:系统上发生的一系列事情——这包括按键、文件系统上的文件等等。您的main函数还返回一件事:您希望在系统上发生的一系列事情。

请注意,流就像列表一样,只有您一次可以构建一个元素,并且接收者将在您构建它后立即收到该元素。你的纯程序从这样的流中读取,并在它希望系统做某事时附加到它自己的流中。

使所有这些工作的粘合剂是一个神奇的机器,它位于您的程序之外,从“请求”流中读取并将内容放入“答案”流中。虽然你的程序是纯粹的,但这台神奇的机器却不是。

输出流可能如下所示:

[print('Hello, world! What is your name?'), input(), create_file('G:\testfile'), create_file('C:\testfile'), write_file(filehandle, 'John')]

并且相应的输入流将是

['John', IOException('There is no drive G:, could not create file!'), filehandle]

看看插播广告是如何出现input'John'插播广告中的?这就是原理。

单子 I/O

Monadic I/O 是 Haskell 所做的,而且做得非常好。你可以把它想象成用操作符构建一个巨大的 I/O 命令树,将它们粘合在一起,然后你的main函数将这个庞大的表达式返回到位于程序外部的神奇机器,并执行命令并执行指示的操作。这个神奇的机器是不纯的,而你的表达构建程序是纯的。

您可能想想象这个命令树看起来像

main
  |
  +---- Cmd_Print('Hello, world! What is your name?')
  +---- Cmd_WriteFile
           |
           +---- Cmd_Input
           |
           +---+ return validHandle(IOResult_attempt, IOResult_safe)
               + Cmd_StoreResult Cmd_CreateFile('G:\testfile') IOResult_attempt
               + Cmd_StoreResult Cmd_CreateFile('C:\testfile') IOResult_safe

它做的第一件事是打印问候语。它要做的下一件事就是要写一个文件。为了能够写入文件,它首先需要从输入中读取它应该写入文件的任何内容。然后它应该有一个要写入的文件句柄。它从一个被调用的函数中得到这个,该函数validHandle返回两个备选方案的有效句柄。这样,您可以将看起来不纯的代码与看起来像纯代码的代码混合在一起。


这个“解释”几乎是在问关于你不应该问问题的魔法机器的问题,所以我要用一些智慧来总结一下。

  • 真正的一元 I/O 看起来与我的示例相去甚远。我的示例是单子 I/O 如何在不破坏纯度的情况下看起来像“幕后”的可能解释之一。

  • 不要试图用的例子来理解如何使用纯 I/O。引擎盖下的工作方式与您使用它的方式完全不同。如果您以前从未见过汽车,那么您也不会通过阅读蓝图成为一名优秀的司机。

    我一直说你不应该问关于真正做事的神奇机器的原因是,当程序员学习东西时,他们往往想去戳机器试图弄清楚它。我不建议对纯 I/O 这样做。机器可能不会教您如何使用不同的 I/O 变体。

    这类似于你不通过查看反汇编的 JVM 字节码来学习 Java。

  • 学习使用 monadic I/O 和基于流的 I/O 这是一种很酷的体验,在你的工具带下拥有更多工具总是好的。

于 2013-08-11T16:02:50.927 回答
6

Haskell 是一种纯函数式语言,使用“monads”处理“不纯”函数。monad 基本上是一种模式,可以很容易地将函数调用与连续传递链接起来。从概念上讲,Haskell 中的 print 函数基本上接受三个参数:要打印的字符串、程序的状态以及程序的其余部分。它调用程序的其余部分,同时传入字符串在屏幕上的程序的新状态。这样就没有修改任何状态。

有很多关于 monad 如何工作的深入解释,因为出于某种原因,人们认为这是一个难以掌握的概念:它不是。你可以在网上搜索找到很多,我认为这是我最喜欢的一个:http: //blog.sigfpe.com/2006/08/you-could-have-invented-monads-and.html

于 2013-08-11T14:51:17.270 回答
2

我现在处理函数式编程的概念有一段时间了 [...] 但有一件事我不明白:如何在将自己限制为纯函数时处理副作用。

Claus Reinke 在撰写论文时提出了类似的问题- 从 210 页的第 10 页开始:

程序与外部环境(例如,由输入/输出设备、文件系统等组成)之间的交互必须如何用一种从外部世界的存在中抽象出来的编程语言来描述?

对于像 Haskell 这样追求函数数学概念的函数式语言,这带来了另一个问题:

  • 由于数学还从外部环境的存在中抽象出来(例如,由输入/输出设备、文件系统等组成):

    在初步意义上,数学是抽象的,因为它是使用高度通用和形式化的资源来研究的。

    数学的适用性网络哲学百科全书)。

    那么为什么有人会尝试使用数学来描述与外部环境的交互,即那些由编程语言支持的交互呢?

这似乎违反直觉...


在“传统编程语言”几乎总是势在必行的时代:

在 1960 年代,一些研究人员开始致力于证明有关程序的事情。努力证明:

  • 一个程序是正确的。

  • 当给定相同的输入时,具有不同代码的两个程序计算出相同的答案。

  • 一个程序比另一个程序快。

  • 给定的程序将始终终止。

虽然这些是抽象的目标,但它们实际上都与“调试程序”的实际目标相同。

这项工作出现了几个难题。一个是规范的问题:在证明一个程序是正确的之前,必须正式且明确地指明“正确”的含义。开发了用于指定程序含义的正式系统,它们看起来很像编程语言

编程语言剖析(第 353 页,共 600 页),Alice E. Fischer 和 Frances S. Grodzinsky。

(强调补充。)

克劳斯·雷因克(Claus Reinke)做出了类似的观察——来自210 页的第 65 页:

以单子风格编写的交互式程序的符号非常接近命令式语言中使用的符号。

但仍有成功的可能:

研究人员开始分析为什么证明用传统语言编写的程序往往比证明数学定理更难。传统语言的两个方面成为麻烦的根源,因为它们很难在数学系统中建模:可变性和顺序性。

编程语言剖析(同页。)

(“非常困难”,但不是“不可能”——而且显然不太实用。)

也许剩下的问题将在2260年代到 2060 年代的某个时间得到解决,使用一组扩展的基本数学概念。在那之前,我们只需要do使用笨拙的以 I/O 为中心的类型,例如:

IO不是外延

康纳尔埃利奥特


既然IO已经(有点)在别处解释过,让我们尝试一些不同的东西——受 Haskell 的FFI启发:

data FF a b  -- abstract "tag" type

foreign import ccall "primArrFF"  arrFF  :: (a -> b) -> FF a b
foreign import ccall "primPipeFF" pipeFF :: FF a b -> FF b c -> FF a c
foreign import ccall "primBothFF" bothFF :: FF a b -> FF c d -> FF (a, c) (b, d)
foreign import ccall "primAltFF"  altFF  :: FF a b -> FF c d -> FF (Either a c) (Either b d)
foreign import ccall "primAppFF"  appFF  :: FF (FF a b, a) b
foreign import ccall "primTieFF"  tieFF  :: FF (a, c) (b, c) -> FF a b
                      ⋮

foreign import ccall "primGetCharFF" getChar :: FF () Char
foreign import ccall "primPutCharFF" putChar :: FF Char ()
                      ⋮

至于Main.main

module Main(main) where

main :: FF () ()
       ⋮

...可以扩展为:

module Main() where

foreign export ccall "FF_main" main :: FF () ()
       ⋮

( , et alFF的例子留作练习 ;-)Arrow

因此,就目前而言(2022 年 1 月),当您限制自己使用普通的 Haskell 函数时,如何处理副作用:

  • 为具有可观察效果的实体引入适当的抽象类型(FF a b);
  • 引入两组原语 - 一组组合子arrFF, pipeFF, bothFF, altFF, appFF,tieFF等)和一组非标准态射getChar,putChar等);
  • 然后,您可以Main.main :: FF () ()使用普通的 Haskell 函数和那些FF原语进行定义。

通过这种方式,普通的 Haskell 函数可以保持没有副作用——它们实际上并不“运行”FF实体,而是从其他(通常更小的)实体构建它们。唯一FF要“运行”的实体是Main.main通过其外部导出,由运行时系统调用(通常以允许副作用的命令式语言实现)。

于 2020-06-08T11:57:26.503 回答