148

我最近一直在学习函数式编程(特别是 Haskell,但我也阅读了有关 Lisp 和 Erlang 的教程)。虽然我发现这些概念很有启发性,但我仍然看不到“无副作用”概念的实际方面。它的实际优势是什么?我试图以函数式思维方式思考,但有些情况似乎过于复杂,无法以简单的方式保存状态(我不认为 Haskell 的 monads '容易')。

是否值得继续深入学习 Haskell(或另一种纯函数式语言)?函数式编程或无状态编程实际上比程序式编程更有效率吗?以后有可能我会继续使用 Haskell 或其他函数式语言,还是应该只是为了理解而学习它?

我关心的不是性能而是生产力。所以我主要是问我是否会比使用过程/面向对象/其他语言更高效。

4

9 回答 9

182

阅读简而言之的函数式编程

无状态编程有很多优点,尤其是显着的多线程和并发代码。说白了,可变状态是多线程代码的敌人。如果默认值是不可变的,程序员就不需要担心一个线程改变两个线程之间共享状态的值,因此它消除了与竞争条件相关的一整类多线程错误。由于没有竞争条件,因此也没有理由使用锁,因此不变性也消除了与死锁相关的另一类错误。

这就是函数式编程很重要的一个重要原因,并且可能是跳上函数式编程火车的最佳选择。还有许多其他好处,包括简化调试(即函数是纯粹的,不会在应用程序的其他部分改变状态),更简洁和富有表现力的代码,与严重依赖设计模式的语言相比,样板代码更少,以及编译器可以更积极地优化您的代码。

于 2009-05-10T02:20:28.067 回答
48

程序的无状态片段越多,将片段组合在一起而不会出现任何中断的方法就越多。无状态范式的力量不在于无状态(或纯度)本身,而在于它赋予您编写强大、可重用的函数并将它们组合起来的能力。

您可以在 John Hughes 的论文Why Functional Programming Matters (PDF)中找到包含大量示例的优秀教程。

你的工作效率会更高,尤其是如果你选择一种同时具有代数数据类型和模式匹配的函数式语言(Caml、SML、Haskell)。

于 2009-05-10T02:39:37.177 回答
20

许多其他答案都集中在函数式编程的性能(并行性)方面,我认为这非常重要。但是,您确实特别询问了生产力,例如,您是否可以在功能范式中比在命令式范式中更快地编程相同的东西。

实际上,我发现(根据个人经验)在 F# 中编程更符合我的想法,因此更容易。我认为这是最大的不同。我在 F# 和 C# 中都进行过编程,而且我喜欢的 F# 中“与语言对抗”的情况要少得多。您不必考虑 F# 中的细节。以下是一些我发现我真正喜欢的例子。

例如,即使 F# 是静态类型的(所有类型都在编译时解析),类型推断会确定您拥有哪些类型,因此您不必说出来。如果它无法弄清楚,它会自动使您的函数/类/任何通用。所以你永远不必写任何通用的东西,这都是自动的。我发现这意味着我花更多的时间思考这个问题,而不是如何实施它。事实上,每当我回到 C# 时,我发现我真的很怀念这种类型推断,你永远不会意识到它是多么让人分心,直到你不再需要这样做。

同样在 F# 中,您调用函数而不是编写循环。这是一个微妙的变化,但意义重大,因为您不必再​​考虑循环构造了。例如,这是一段代码,它会通过并匹配一些东西(我不记得是什么,它来自一个项目 Euler 谜题):

let matchingFactors =
    factors
    |> Seq.filter (fun x -> largestPalindrome % x = 0)
    |> Seq.map (fun x -> (x, largestPalindrome / x))

我意识到在 C# 中先做一个过滤器然后再做一个映射(即每个元素的转换)会很简单,但你必须在较低的层次上思考。特别是,您必须自己编写循环,并拥有自己的显式 if 语句以及诸如此类的东西。自从学习了 F#,我意识到我发现用函数式编码更容易,如果你想过滤,你写“过滤器”,如果你想映射,你写“映射”,而不是实现每一个细节。

我也喜欢 |> 运算符,我认为它将 F# 与 ocaml 以及可能的其他函数式语言区分开来。它是管道运算符,它允许您将一个表达式的输出“管道”到另一个表达式的输入。它使代码更符合我的想法。就像上面的代码片段一样,这就是说,“获取因子序列,过滤它,然后映射它。” 这是一种非常高级的思维方式,因为您正忙于编写循环和 if 语句,所以您无法在命令式编程语言中获得这种思维方式。每当我进入另一种语言时,这是我最想念的一件事。

所以总的来说,即使我可以同时使用 C# 和 F# 编程,我发现使用 F# 更容易,因为你可以在更高的层次上思考。我会争辩说,因为从函数式编程中删除了较小的细节(至少在 F# 中),所以我的工作效率更高。

编辑:我在其中一条评论中看到您要求提供函数式编程语言中的“状态”示例。F# 可以强制编写,因此这里有一个直接示例,说明如何在 F# 中拥有可变状态:

let mutable x = 5
for i in 1..10 do
    x <- x + i
于 2009-05-10T04:14:48.103 回答
15

考虑一下您花了很长时间调试的所有困难错误。

现在,这些错误中有多少是由于程序的两个独立组件之间的“意外交互”造成的?(几乎所有线程错误都有这种形式:涉及写入共享数据的竞争、死锁……此外,通常会发现对全局状态有一些意想不到的影响的库,或者读/写注册表/环境等。) 会假设至少有三分之一的“硬错误”属于这一类。

现在,如果您切换到无状态/不可变/纯编程,所有这些错误都会消失。相反,您会遇到一些新的挑战(例如,当您确实希望不同的模块与环境交互时),但是在像 Haskell 这样的语言中,这些交互会显式地具体化到类型系统中,这意味着您可以只查看关于它可以与程序的其余部分进行的交互类型的功能和原因。

这是“不变性”IMO 的巨大胜利。在一个理想的世界里,我们都会设计很棒的 AP​​I,即使事情是可变的,效果也会是本地的并且有据可查,并且“意外”的交互将保持在最低限度。在现实世界中,有很多 API 以多种方式与全局状态交互,而这些是最有害的错误的来源。渴望无状态就是渴望摆脱组件之间的意外/隐式/幕后交互。

于 2009-05-10T05:11:07.703 回答
8

无状态函数的一个优点是它们允许预先计算或缓存函数的返回值。甚至一些 C 编译器也允许您将函数显式标记为无状态以提高它们的可优化性。正如许多其他人所指出的,无状态函数更容易并行化。

但效率并不是唯一的问题。纯函数更容易测试和调试,因为任何影响它的东西都被明确说明。当使用函数式语言进行编程时,人们会养成尽可能少的“脏”函数(使用 I/O 等)的习惯。以这种方式分离有状态的东西是设计程序的好方法,即使在功能不那么强大的语言中也是如此。

函数式语言可能需要一段时间才能“获得”,而且很难向没有经历过这个过程的人解释。但是大多数坚持了足够长的人最终意识到大惊小怪是值得的,即使他们最终没有过多地使用函数式语言。

于 2009-05-10T02:31:04.187 回答
7

没有状态,很容易自动并行化您的代码(因为 CPU 具有越来越多的内核,这非常重要)。

于 2009-05-10T02:12:39.613 回答
6

当您开始拥有更高的流量时,无状态 Web 应用程序是必不可少的。

例如,出于安全原因,您可能不想将大量用户数据存储在客户端。在这种情况下,您需要将其存储在服务器端。您可以使用 Web 应用程序默认会话,但如果您有多个应用程序实例,则需要确保每个用户始终指向同一个实例。

负载均衡器通常具有“粘性会话”的能力,负载均衡器在某种程度上知道将用户请求发送到哪个服务器。虽然这并不理想,例如,这意味着每次重新启动 Web 应用程序时,所有连接的用户都将失去他们的会话。

更好的方法是将会话存储在 Web 服务器后面的某种数据存储中,现在有很多很棒的 nosql 产品可用于此(redis、mongo、elasticsearch、memcached)。这样,Web 服务器是无状态的,但您仍然拥有状态服务器端,并且可以通过选择正确的数据存储设置来管理此状态的可用性。这些数据存储通常具有很大的冗余,因此几乎总是可以对您的 Web 应用程序甚至数据存储进行更改而不会影响用户。

于 2013-08-22T12:47:09.610 回答
5

不久前我就这个主题写了一篇文章:关于纯度的重要性

于 2010-01-27T23:25:33.100 回答
2

我的理解是 FP 对测试也有巨大的影响。没有可变状态通常会迫使您为函数提供比为类提供更多的数据。有一些权衡,但想想测试一个“incrementNumberByN”而不是“Counter”类的函数是多么容易。

目的

describe("counter", () => {
    it("should increment the count by one when 'increment' invoked without 
    argument", () => {
       const counter = new Counter(0)
       counter.increment()
       expect(counter.count).toBe(1)
    })
   it("should increment the count by n when 'increment' invoked with 
    argument", () => {
       const counter = new Counter(0)
       counter.increment(2)
       expect(counter.count).toBe(2)
    })
})

功能性的

 describe("incrementNumberBy(startingNumber, increment)", () => {

   it("should increment by 1 if n not supplied"){
      expect(incrementNumberBy(0)).toBe(1)
   }

   it("should increment by 1 if n = 1 supplied"){
      expect(countBy(0, 1)).toBe(1)
   }

 })

由于该函数没有状态并且输入的数据更明确,因此当您试图找出测试可能失败的原因时,需要关注的事情更少。在我们必须做的柜台测试中

       const counter = new Counter(0)
       counter.increment()
       expect(counter.count).toBe(1)

前两行都对 的值有贡献counter.count。在像这样的简单示例中,1 对 2 行可能有问题的代码没什么大不了的,但是当您处理更复杂的对象时,您的测试也可能会增加大量复杂性。

相反,当您使用函数式语言编写项目时,它会促使您保持花哨的算法依赖于流入和流出特定函数的数据,而不是依赖于系统的状态。

另一种看待它的方式是说明在每个范例中测试系统的心态。

对于函数式编程:确保函数 A 适用于给定输入,确保函数 B 适用于给定输入,确保 C 使用给定输入。

对于 OOP:在对对象的状态执行 Y 和 Z 之后,确保对象 A 的方法在给定输入参数 X 的情况下有效。在对对象的状态执行 W 和 Y 之后,确保对象 B 的方法在给定输入参数 X 的情况下有效。

于 2021-06-29T18:25:24.910 回答