Scala 最佳实践:纯函数

作者: liuchengxu | 来源:发表于2017-07-26 17:11 被阅读187次

    我们所处的是一个命令式编程(imperative programming)的时代,这也是我们为何更喜欢用命令式风格写代码的原因。在我们周围的一切都是可变的。虽然可变性并没有那么差劲,但是共享可变性就有点麻烦了。当我们引入共享可变性时,各种问题就会随之而来。函数式风格是应对这类问题的一个很好的方法。

    函数式编程指的是仅通过使用纯函数(pure function)和不可变值来完成软件应用的编写。

    在本文,我们将会探讨 纯函数 的一些内容。

    什么是一个纯函数?

    纯函数没有任何副作用中文维基:函数副作用),除了它的输入以外,函数结果不依赖于其他任何事情。

    对于给定的输入,一个纯函数唯一的作用是就是产生一个输出 -- 此外无任何作用。

    可以将纯函数想象为了一个管道,有输入流入,然后输出流出,在流入流出的过程中没有任何损耗。

    下面是 Scala 的一个函数,它接收两个值并返回它们的和:

    scala> def add(a:Int, b:Int) = a + b
    add: (a: Int, b: Int)Int
    

    这个函数没有任何的副作用。它不会改变所提供的输入值,而是利用了另一个纯函数,+ 操作符。作为该函数调用的结果,它返回了两个值的和。这个 add 函数就是一个纯函数。

    当我们使用纯函数时,对于函数调用的先后顺序并无显式要求。

    举个例子,我们有两个纯函数:加法和乘法,它们接受两个输入值,一个返回两个值的和,一个返回两个值的积。因为这两个函数是纯函数,下面两个不同顺序的函数调用所产生的结果是相同的:

    scala> def add(a:Int,b:Int) = a + b
    add: (a: Int, b: Int)Int
    
    scala> def multiply(a:Int,b:Int) = a * b
    multiply: (a: Int, b: Int)Int
    
    scala> add(5,8) + multiply(5,8)
    res0: Int = 53
    
    scala> multiply(5,8) + add(5,8)
    res1: Int = 53
    

    不过,如果我们的计算涉及对一个非纯函数的调用,就不能像上面这样随意调换顺序进行调用了。出于优化角度,可以对使用纯函数的表达式的调用顺序进行重新安排,这样所产生的结果与之前是完全相同的

    为什么要使用纯函数

    函数式编程的一个主要原则就是写出核心为纯函数的应用,这样一来,那么副作用就会只存在于占比不多的外层结构。

    纯函数的好处有:

    易推断

    这是因为一个纯函数,它没有任何副作用,也没有隐藏的 I/O 信息,仅通过查看它的签名就能知道这个函数是干什么的。

    易组合

    一个纯函数接受一个输入,然后对输入进行一些计算,最后返回一个结果。因为“输出只依赖于输入”,所以它不会改变周围的任何事情,这便使得纯函数易于组合起来形成简单的解决方案。

    易测试

    比起非纯函数,纯函数要容易测试的多。举个例子:

    scala> def pureFunction(name : String) = s"My name is $name"
    
    pureFunction: (name: String)String
    
    scala> def impureFunction(name : String) = println(s"My name is $name")
    
    impureFunction: (name: String)Unit
    

    如果想要测试函数 pureFunction, 一行代码就足够了:

    assert(pureFunction("Shivangi") == "My name is Shivangi)"
    

    而测试 impureFunction 就要复杂得多了,因为我们需要重定向标准输出,然后在上面进行断言。

    易调试

    因为一个纯函数的输出仅依赖于函数的输入和算法本身,在调试时,根本不用关心函数外部的信息,所以纯函数比非纯函数更易于调试。

    易并行

    通过函数式编程很容易写出并行/并发的应用。原因如下:

    如果在两个纯表达式中没有数据依赖,那么它们的调用顺序就可以进行调换,或者可以被并行执行而彼此不会相互影响(换句话说,任何纯表达式的求值都是线程安全的))。

    除此以外,纯函数还有以下一些特点:

    引用透明

    引用透明(Referentially transparent)指的是一个表达式或函数可以被相应的数值进行安全替换。对于所有的引用透明值 x,如果表达式 f(x) 是引用透明的,那么这个函数就是纯函数

    现在让我们来看一下到底引用透明是什么。

    引用透明是一个函数属性,它指的是函数不受临时的上下文影响,没有任何副作用。对一个特定的输入而言,一个引用透明的调用可以在不改变程序语义的情况下被它的结果所代替。

    比如,输入 + 3*2 可以被替换为输入 + 6,因为子表达式 3*2 是引用透明的。

    我们为什么要关心引用透明呢???

    引用透明在程序优化中扮演了一个非常重要的角色。如果能够在编译期用一个函数或表达式的值来替换该函数或表达式,将会节省运行期的很多时间。

    “引用透明” 指的是表达式的值仅依赖于其自身值,而不依赖于其他任何内容。

    幂等

    幂等(Idempotent)(中文维基:幂等)这个词有多重含义,不过在这里我们仅关注它在编程上的意义。给定一个值,如果一个函数或操作不论执行多次或仅执行一次,所得结果都是相同的,那么我们就说这个函数或操作时幂等的。加法函数就是幂等的,它可以被执行任意多次。对于给定的 a 和 b,如果我们调用多少次,所得结果都是一样的。

    纯函数就是幂等的。给定一个输入,基于该输入值,我们调用一个纯函数一次,会产生一个输出值。给定同样的输入,基于该输入值,我们调用一个相同的纯函数多次,所产生的输出值是与调用一次完全相同的。

    幂等的好处就是纯函数可以被安全地执行任意多次,甚至如果我们不需要该函数结果的话,完全可以跳过不执行。

    引用透明说的是一个纯函数可以被安全地替换为函数的输出值。幂等说的是重复计算任意多次是完全没问题的。这两个特性组合起来就是说,处理纯函数很容易,而且很完全 -- 这给程序优化提供了极大的便利。

    可记忆

    可记忆(Memoizable)是一个优化技术。它的目的在于以空间换时间,也就是说,通过存储或缓存计算结果来减少计算时间。

    只有当给定参数或输入,函数结果是完全相同的,记忆才变得有意义。显然纯函数具备这个属性,因此它们很容易进行记忆。

    延迟处理

    延迟求值(Lazy evaluation)指的是只有当需要一个表达式的值时,才会该表达式进行求值。如果在程序执行过程中,这个值从来没有被用到,那么可能就根本不会对该表达式求值。在 Scala 中,我们可以通过标记一些变量进行延迟处理。

    延迟处理的好处就是,我们变得更有效率了,而这种效率的提升并非通过更快地执行程序,而是通过消除我们不需要执行的操作。通过这种消除计算的方式,我们可以变得十分有效率。

    总结

    纯函数 是函数式编程中一个根本的概念。对于一个纯函数,你可以立即求值,也可以放心大胆地放在后面求值。此外,因为无论我们求值多少次,何时求值,一个纯函数的结果总是唯一的,所以我们可以保存求值的结果(通过延迟处理标记)并进行重用。还有,如果一个函数没有任何副作用,对于想要知道该函数是否已经被求值的任何人,方法就是查看函数结果。函数计算也可以根据需要进行延迟计算。由于引用透明和记忆特性,对于程序优化也非常有帮助。

    参考:

    文本译自:Scala Best Practices: Pure Functions

    相关文章

      网友评论

        本文标题:Scala 最佳实践:纯函数

        本文链接:https://www.haomeiwen.com/subject/fuenkxtx.html