program = do
monster <- lookForMonsters
damage <- shoot monster
return ("Inflicted " ++ show damage ++ " points of damage)
单词do和左箭头被<-编译器翻译成连续的函数调用bind。
在所有这些转换之后,看看我们得到了什么:程序看起来像 C 或 Python 等某种语言中的“常规”命令式程序,看起来我们每一步都在“改变”游戏状态。但实际上,该程序完全由没有变异和副作用的“纯”函数组成。我们的程序将游戏的初始状态作为输入并产生最终状态作为输出,并且不依赖于其他任何东西。因此,我们有机会以一系列步骤的形式编写程序,但同时保留了没有突变的所有优点。
概括
再看一下我上面用“棘手”的方式编写的程序:
program = do
monster <- lookForMonsters
damage <- shoot monster
return ("Inflicted " ++ show damage ++ " points of damage)
它在现实世界中具有“副作用”——它从控制台读取文本字符串。但是这个字符串在 type 中被“包装”了IO。从逻辑上讲,这可以被认为是字符串所在的单元格,但是没有办法从那里“获取”这个字符串。你唯一能做的就是通过调用将它作为参数传递给另一个函数bind。但问题是:作为这个调用的结果,类型将再次被确定出来IO(可能不是在里面有一个字符串,而是有别的东西),你又不能从那里得到值。您只能在帮助下再次将其发送到第三个函数bind- 依此类推。
循环
正如您非常正确地指出的那样,为了实现循环算法,递归用于函数式编程。例如:
该函数
map遍历整个列表并使用f. 很简单。然而,在实践中,很少直接使用递归。通常,函数式程序根据预先存在的库原语表达循环操作,例如
map上面的函数。像这样的东西:这里,操作符
<$>是一个函数别名map(Haskell 中的标准),所以表达式show <$> numbers读作“将函数show应用于列表的每个元素numbers”。对于更复杂的情况,还有更通用的类比,例如
fold,for等mapM。最简单的使用递归实现,而更复杂的则建立在最简单的之上。突变
函数式编程(以及一般编程)中的“突变”是存储单元内容的变化。在 Haskell 中,突变原则上是可能的(见下文),但实际上他们试图避免它,只在最极端的情况下才使用它。在函数式编程中,通常不是直接更改数据,而是在旧结构的基础上创建新结构。例如:
该函数
replaceFirst将列表的第一个元素“替换”为一个新元素:(细心的读者会注意到该函数
replaceFirst不适用于空列表,但这与当前讨论无关)从逻辑上讲,该操作
replaceFirst xs 42可以理解为“将列表的第一个元素替换为xs”42,但实际上并非如此。实际上,旧清单xs还活着,而且清单ys是一个全新的清单。现在我们有一个选择:我们可以将列表作为不必要的列表丢弃xs,以便垃圾收集器稍后将其删除,或者我们可以将其“保留”以备将来使用。这种方法开辟了很多可能性。例如,保留更改历史不会花费我们任何费用——无论是为了会计,还是为了及时回溯,或者其他什么。一个更有趣的可能性是程序并行化。如果我们确定旧数据永远不会改变,那么隔离信号量和其他同步是没有意义的——你可以并行运行程序,就是这样!- 编译器保证它们永远不会通过修改共享数据相互干扰。数据同步是并行编程的基石,也是 bug 的主要来源。如果有疑问,请寻找“防御性副本”。
问:您是不是想说每次更改都必须分配一个全新的数据结构?!你不会得到足够的任何内存!
不,它不需要。为了防止这种情况发生,函数式编程使用特殊的数据结构,在英文中称为“持久数据结构”。这些是数据结构,其中每个下一次更新都是在先前版本的基础上构建的。
最简单的例子是单链表。想象一个列表:每个元素都包含一个指向前一个元素的链接(指针),而最近的元素包含一个指向“结束”的链接。像这样的东西:
在
replaceFirst(见上文)的示例中,此列表是为我调用的xs:当我调用该函数
replaceFirst时,它获取了列表的“尾部”(从两个开始)并为其附加了一个新的“头部”42。我称之为“新”列表ys:这表明为了“替换”列表的第一个元素,根本不需要分配新的内存块。唯一的新单元是新的第一个元素本身,仅此而已。所有其他元素都保持原样。此外,如果我现在决定
xs不再需要该列表,那么内存收集器将只需要删除 number1,因为列表的所有其他元素仍然涉及。另请注意,这种方法只有在确定列表的元素永远不会更改时才有可能。只有在这种情况下,我才能将列表
xs视为ys两个不同的列表,尽管它们部分占用相同的内存。当然,单链表是最简单的数据结构。在更复杂的情况下,使用更复杂的结构——主要是各种类型和大小的树。当然,在某些情况下,这种方法的效率仍然低于内存中的连续数组。但是,在 99% 的情况下,性能上的差异完全可以忽略不计,无法与增加的稳定性和易于开发性相提并论。
然而,对于剩下的 1% 的情况,即使是 Haskell 也允许您使用连续数组 - 只有它以更安全的方式来处理(见下文)
命令式程序
“命令式”被称为程序,表示为一系列步骤。这与“声明性”形成对比,“声明性”表示为部分之间的关系 - 功能和数据。我上面给出的所有例子 - “声明性”。它们都以“”的样式编写
xs- 这ys是第一个元素被42“替换的地方,依此类推。乍一看,似乎在原则上没有区别,但事实并非如此。主要区别在于命令式程序具有操作顺序,而声明性程序则没有。我已经说明了该值xs与 的关系ys,但我没有说明我希望以什么顺序评估这种关系。编译器会帮我解决的。但这并不总是可取的。尽管“原则上”任何程序都可以用两种方式表达,但从纯人类的角度来看,有时将程序视为功能关系,有时更方便,有时将其视为一系列步骤。
例如,考虑相同的游戏。想象一下,我有一个特定的数据类型
Game和一组操作:请注意,每个函数除了其“主要”结果(
Monster或Damage)外,还返回一个“新”的、修改后的游戏状态。这个“新”状态必须传递给下一个函数,因此这些函数有一定的执行顺序,如下所示:由于
game1需要调用该值,shoot但它是 的结果lookForMonsters,因此lookForMonsters您需要先调用,然后shoot- 然后。这是调用顺序的定义。然而,这种风格非常乏味,并且会产生拼写错误的危险。另外,要注意图案:所有线条的形状都非常相似。函数式程序员以模式为生。他找到它们并无情地概括以使他的代码更简单、更易于理解。在这种情况下,让我们将这个模式包装在几个函数中,我将调用它们
bind和return:如果不清楚,请不要详细说明。知道该函数
bind将游戏上的两个操作“链接在一起”就足够了,将游戏的状态从第一个操作的结果传递给第二个操作的参数;并且该函数return创建一个忽略游戏状态并返回一些特定值的操作。使用这些函数,我可以这样编写程序:你可以通过换行和缩进让它更漂亮一点:
或者如果你有一个
>>=将成为同义词的运算符,甚至更漂亮一点bind:这种确定执行顺序的方案称为“monad”,它在函数式语言中应用如此广泛,以至于大多数编译器都为其提供了特殊的语法糖。在 Haskell 中,这个程序可以这样写:
单词
do和左箭头被<-编译器翻译成连续的函数调用bind。在所有这些转换之后,看看我们得到了什么:程序看起来像 C 或 Python 等某种语言中的“常规”命令式程序,看起来我们每一步都在“改变”游戏状态。但实际上,该程序完全由没有变异和副作用的“纯”函数组成。我们的程序将游戏的初始状态作为输入并产生最终状态作为输出,并且不依赖于其他任何东西。因此,我们有机会以一系列步骤的形式编写程序,但同时保留了没有突变的所有优点。
概括
再看一下我上面用“棘手”的方式编写的程序:
请注意,这个程序的任何地方都没有提到类型本身
Game——也就是说,程序本身通常不知道它与游戏一起工作。所有关于游戏的知识都“隐藏”在函数bind和原始操作lookForMonsters中shoot。顺便说一句,这些操作本身也可能不是原始的——它们本身可以用与我的程序相同的风格编写,并且基于更原始的操作。但这只是一个词。而接下来的想法是:如果程序本身不依赖于“运算顺序”的思想究竟是如何实现的(即这个思想是由函数实现的
bind),那么我们可以改变这个在不触及程序本身的情况下实施。我们的实现可以简单且“玩具”,正如我上面给出的那样,或者如果我们需要一些额外的功能,例如错误中断或日志记录,我们可以将这些功能添加到函数bind中,程序不会注意到它。这种想法导致了“单子”可以不同的想法。此外,monad 可以从具有所需功能的块(如从多维数据集)中组装,同时编写程序,其中每个操作仅使用对其重要的多维数据集,而程序的其余部分不知道它们。
输入输出
而现在,当我们有了“monad”的概念,它是操作顺序的抽象表达,下面的想法就来了:如果我们发明一个不是用 Haskell 语言本身编写的,而是在编译器?如果你以这样一种方式组织这个 monad,使函数
bind可以做各种耻辱,打破规则,并且通常是用 C 语言编写的呢?用这样的 monad 编写的程序看起来完全是无辜的 - 就像一系列通过的函数
bind,就像上面的例子一样。但是原始操作将用于真正的输入输出。这是一个示例操作
getLine:它在现实世界中具有“副作用”——它从控制台读取文本字符串。但是这个字符串在 type 中被“包装”了
IO。从逻辑上讲,这可以被认为是字符串所在的单元格,但是没有办法从那里“获取”这个字符串。你唯一能做的就是通过调用将它作为参数传递给另一个函数bind。但问题是:作为这个调用的结果,类型将再次被确定出来IO(可能不是在里面有一个字符串,而是有别的东西),你又不能从那里得到值。您只能在帮助下再次将其发送到第三个函数bind- 依此类推。结果是,一旦我们完成了 I/O(即,调用其中一个
IO-functions),唯一可以对结果做的就是做更多的 I/O,更多,更多。但这没关系,因为 Haskell 程序的入口点只是一个 I/O 操作链,通过bind. 例如:或使用相同的语法
do:这就是 Haskell 中 I/O 的工作方式。最原始的操作,比如
getLineandputStrLn,不是用 Haskell 自己写的,而是用 C 写的。这和CreateProcess在 Windows 或forkUNIX 中的调用差不多,不是由程序本身实现的,而是由操作系统实现的。最原始的原语总是比它低一层,无论如何都没有它。看待这种结构的另一种方法是,您可以将 Haskell 程序视为一个自身不执行 I/O 的程序,而是用另一种语言生成一个执行 I/O 的程序。我不是这种方法的忠实拥护者,但它经常用于解释。
请注意,原则上,您完全可以直接在 中编写整个 Haskell 程序
IO,从而享受与 C 或 Python 程序相同的“好处”。然而,这完全扼杀了整个想法。Haskell 的重点是,如果您摆脱所有类型的废话IO并让编译器优化程序,您可以编写更安全、更具表现力和更快的程序。相信我,编译器(尤其是 Haskell 编译器)的性能比你我好几个数量级。因此,真正的程序通常分为两层:程序的最核心,所有逻辑和所有计算都写在“纯”函数中,只有最外层,非常薄的外壳处理
IO. 如今,这一原则在英语中被称为“功能核心,命令式外壳”,在函数式编程之外已经广为人知——事实上,许多其他的函数式思想(例如,LINQ、const、then、async、auto等)也广为人知。 P.)“真实”突变
既然我们已经遇到了 monad
IO并且了解到它的原语可以做各种乱七八糟的事情,接下来想到的是这个漏洞可以用于 Haskell 仍然不够快、需要太多内存或由于某些原因其他原因不适合。在这些情况下,您可以用 C 编写一个库并通过IO.这种方法的一个例子是具有“真实”突变支持的数组。该库提供
IO了用于创建、修改和对数组进行其他操作的 -primitives:正如我上面提到的,这些特性只在最极端的情况下使用——当 Haskell 本身“不支持”时。我会将它与用 C 编写程序进行比较,但汇编程序散布在最敏感的地方。程序变得更大、更难以理解、更脆弱,并且限制了优化的可能性,因为编译器不知道这些原语内部到底发生了什么,因此害怕接触它们。
现代程序员的悲剧在于,学过数学,开始编程,他们试图理解为什么记录
有这个意思。当他们明白这一点时,他们就很难学会“像以前一样”思考。Dmitry Soshnikov 说,此时函数式程序员死在其中。
不过,是可以记住的,也没有那么难。尝试在纸上解任何方程,你会以函数式的方式来做。尝试计算阶乘。你会发现你不需要累加器变量。
累加器变量的一个例子是经典的算盘,并且确实出现了state这样的东西。
函数式表示法是如此熟悉,以至于它出现在所有命令式语言中,除了一些深奥的语言。看任务
告诉我,右边的乘法将以什么顺序执行?第一个
a * b,最后一个e * f,反之亦然?a * b而c * d这些是操作符的操作数+,而在C、C++等语言中,操作数的求值顺序是没有定义的。因此,编译器可以优化代码。例如,如果上面某处c * d已经计算过了,并且变量的值没有改变,你可以用成品代替。未指定评估顺序的事实表明了功能样式。这就是他。如果你想以命令式的方式计算一个表达式,你必须把它分解成几个步骤,或者用 Fort 或 PostScript 编写它。
最后,另一种体验函数式风格的方法是在 Excel 中编写计算。例如,不使用函数,
SIN制作一张计算正弦的表格。这是一个有趣的任务。在这里,您也只定义了单元格之间的依赖关系,但没有定义计算它们的顺序。Excel 当然不是图灵完备的计算器,但是,它可以让您解决许多实际问题。
没有变量的编程是不可能的。首先,如果我们想在整个脚本中更改一个值,那么我们将不得不在整个程序中更改这个值(脚本中可能有数千、数万行)。其次,如果我们需要使用动作符号(+、-、*、:),那么我们就不能没有变量。