【问题标题】:Advantages of stateless programming?无状态编程的优点?
【发布时间】:2010-10-25 01:52:21
【问题描述】:

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

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

我关心的不是性能而是生产力。所以我主要是问我是否会在函数式语言中比过程/面向对象/其他语言更有效率。

【问题讨论】:

    标签: functional-programming state immutability


    【解决方案1】:

    阅读Functional Programming in a Nutshell

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

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

    【讨论】:

    • 我支持这个!我相信函数式编程在未来会得到更广泛的应用,因为它适合并行编程。
    • @Ray:我还要添加分布式编程!
    • 大部分情况都是如此,除了调试。在 haskell 中通常更困难,因为您没有真正的调用堆栈,只有模式匹配堆栈。而且要预测代码的最终结果要困难得多。
    • 另外,函数式编程并不是真正的“无状态”。递归已经是隐式(本地)状态,是我们在 haskell 中做的主要事情。一旦您在惯用的 haskell 中实现了一些重要的算法(例如计算几何的东西)并享受调试这些算法的乐趣,这一点就会变得清晰。
    • 不喜欢将无状态等同于 FP。 许多 FP 程序都充满了状态,它只是存在于闭包中而不是对象中。
    【解决方案2】:

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

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

    您将gobs更有效率,特别是如果您选择一种同时具有代数数据类型和模式匹配(Caml、SML、Haskell)的函数式语言。

    【讨论】:

    • 难道 mixins 不会也以与 OOP 类似的方式提供可重用代码吗?不提倡 OOP 只是想自己理解事物。
    【解决方案3】:

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

    我实际上发现(根据个人经验)在 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 以及可能的其他函数式语言区分开来。它是管道运算符,它允许您将一个表达式的输出“管道”到另一个表达式的输入。它使代码更符合我的想法。就像上面的代码 sn-p 一样,这就是说,“取因子序列,过滤它,然后映射它。”这是一种非常高级的思维方式,因为你正忙于编写循环和 if 语句,所以你不会在命令式编程语言中获得这种思维方式。这是我每次学习另一种语言时最怀念的一件事。

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

    编辑:我在其中一个 cmet 中看到您要求提供函数式编程语言中“状态”的示例。 F# 可以命令式地编写,所以这里有一个直接的例子来说明如何在 F# 中拥有可变状态:

    let mutable x = 5
    for i in 1..10 do
        x <- x + i
    

    【讨论】:

    • 我一般同意你的帖子,但是 |> 与函数式编程本身无关。实际上,a |&gt; b (p1, p2) 只是b (a, p1, p2) 的语法糖。将此与右关联性结合起来,您就掌握了。
    • 没错,我应该承认,我对 F# 的许多积极体验可能更多地与 F# 有关,而不是与函数式编程有关。但是,两者之间仍然存在很强的相关性,即使类型推断和 |> 之类的东西本身不是函数式编程,我当然会声称它们“顺其自然”。至少在一般情况下。
    • |> 只是另一个高阶中缀函数,在本例中是函数应用运算符。定义您自己的高阶中缀运算符绝对是函数式编程的一部分(除非您是 Schemer)。 Haskell 的 $ 是相同的,只是管道中的信息从右到左流动。
    【解决方案4】:

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

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

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

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

    【讨论】:

    • 有人曾经说过,覆盖一个可变值意味着你正在明确地垃圾收集/释放以前的值。在某些情况下,程序的其他部分没有使用该值完成。当值不能被改变时,这类错误也会消失。
    【解决方案5】:

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

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

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

    【讨论】:

    • 第一部分非常有趣,我以前从未想过。谢谢!
    • 假设您的代码中有sin(PI/3),其中 PI 是一个常量,编译器可以在编译时评估此函数并将结果嵌入到生成的代码中。
    【解决方案6】:

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

    【讨论】:

    • 是的,我确实已经调查过了。尤其是 Erlang 的并发模型非常有趣。但是,在这一点上,我并不真正关心并发性,而是关心生产力。没有状态的编程是否有生产力奖励?
    • @musicfreak ,不,没有生产力奖金。但需要注意的是,如果你真的需要,现代 FP 语言仍然允许你使用状态。
    • 真的吗?您能否举一个函数式语言中的状态示例,以便我了解它是如何完成的?
    • 查看 Haskell 的 State Monad - book.realworldhaskell.org/read/monads.html#x_NZ
    • @Unknown:我不同意。没有状态的编程减少了由于不同组件的不可预见/意外交互而导致的错误的发生。它还鼓励更好的设计(更多的可重用性、机制和策略的分离等等)。它并不总是适合手头的任务,但在某些情况下确实很出色。
    【解决方案7】:

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

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

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

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

    【讨论】:

      【解决方案8】:

      不久前我就这个主题写了一篇文章:On The Importance of Purity

      【讨论】:

        【解决方案9】:

        我的理解是 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 的情况下有效。

        【讨论】:

          猜你喜欢
          • 2011-12-13
          • 1970-01-01
          • 1970-01-01
          • 2011-11-16
          • 1970-01-01
          • 1970-01-01
          • 2015-06-17
          • 1970-01-01
          • 2020-04-07
          相关资源
          最近更新 更多