哦,雪白的纯朴具有何等大的威力!——济慈

纯函数的两个优良品质,为它赢得了很多优势。

引用透明

以直接用函数运行的结果替代函数表达式本身而不改变程序的最终结果,称为引用透明(Referential Transparent)。如:

val x = "hello".length
val y = "world".length
val z = "hello".length + y

如果把第二处”hello”.length换成x,或者反过来,把第二处y换成”world” .length,并不改变z的值。这似乎是很显然的事,却是以length是纯函数为前提的,换成随机数生成函数、获取当前时间的函数,就不能替换。如果改变函数范围外的变量,也会破坏引用透明,如:

var total = 0
def add(v: Int): Int = {
    total += v
    total
}

val t1 = add(1)
t1 == add(1)  //false
记忆

引用透明很好,可避免函数的反复执行,但毕竟还是需要人工替换,如果函数自身能智能地实现一次计算,多处使用就好了。可喜的是,对于纯函数,编译器可以实施名为”记忆”(Memoization)的优化技术,正是达到这个效果。特别是执行很费时的函数,把结果缓存起来,当下次用相同入参调用时,便可直接返回缓存的结果,不再重新计算,大大提高程序的效率。当然,通过把入参和结果作为键值对保存在Map中,我们可以自己实现缓存过程,方便控制缓存策略。用通常不太缺乏的内存空间换取宝贵的计算时间,这是计算机科学里常用的”伎俩”。

缓求值

缓求值(Lazy Evaluation),指尽可能地推迟求解表达式,是函数式编程语言常见的一种特性。Scala通过lazy关键字声明缓求值。缓求不会在遇到表达式时就触发运行,而是在用到的时候才真实计算。缓求在记忆之外,为昂贵的运算提供了另一种优化手段。如,以下判断质数的函数,虽然表达式isPrimeSlow(num)在if判断前面,但只有当输入为奇数时,才会真正被执行。

def isPrimeSlow(num: Int): Boolean =  2 until num forall(x => num % x != 0)

def isPrime(num: Int): Boolean = {
  lazy val res = isPrimeSlow(num)
  if (num % 2 == 0) false else res
}

缓求值在集合中的应用更能体现其价值。熟悉Python的同学知道生成器(generator)的概念,应用了缓求了集合与之类似,对于很大的集合,不是一次性生成(或者说根本就没有生成),只是需要的时候才吐出所需的元素。对比intsEager和intsLazy执行效果,便会发现对于寻找大于5的第一个数6而言,intsEager一次性生成了10万个元素,但比6大的元素对于求解结果毫无帮助,白白浪费了内存,简直是暴殄天物。如果把这个形式推到极限,对于有无限个元素的集合,似乎只有缓求方式才能胜任了。这下我们发现了,相比于”记忆”,缓求倒过来了,它是以临时计算来免除需要事先准备好的内存。

val intsEager = List.range(0, 100000)
intsEager.find(_ > 5)

val intsLazy = 0 until 100000
intsLazy.find(_ > 5)

以上,是纯函数为语言提供的额外特性,可以认为是一些与身俱来的优势。接下来分析纯函数为编程实践带来的好处,如何帮助提高代码质量,提高开发效率。

纯函数更易推断

纯函数的签名已经把它的目的和盘托出了,不需要细看内部实现,就可以相信它没有多干其它小动作,所以不会有超乎签名的副作用和意外效果。经验不足的同学甚至在java的getter类方法中做了写操作:

public Integer getNum() {
  this.visit ++;
  return this.num;
}

哪怕老手也难免有被糊弄的时候。比如,出乎意料,下面这段代码会访问网络,因为URL的hashCode方法会尝试解析域名。

public void notPure() {
  try {
    URL url = new URL("http://example.com");
    Sets.newHashSet(url);
  } catch (MalformedURLException e) {
    //ignored
  }
}

纯函数就没有这些问题,这是多么大的心智减负。

纯函数更易组合

函数的组合(compose),简单而言,就是把若干函数按一定顺序拼接组成新函数。

def compose[A, B, C](f: B => C, g: A => B): A => C = (a: A) => { f(g(a)) }

后续会介绍,组合在函数式编程中有着举足轻重的地位。且看组合的具体应用——优雅的链式调用:

val x = doThis(a).thenThis(b)
                 .andThenThis(c)
                 .doThisToo(a, d)
                 .andFinallyThis(e)

如果不是纯函数,很难想象可以放心地写出这段代码。doThis(a)如果改变了a,会怎么影响doThisToo的行为?如果各步骤共享了状态,拋了异常,代码的整体行为表现是怎样的?正确评估这些问题极其消耗心力。而纯函数的确定性,可以让后续步骤放心地依赖前面步骤的输出,代码的作用如字面语义一样跃然纸上。

纯函数更易测试和定位问题

唯一能影响函数行为的只有输入参数,没有不为人知的魔法,没有数据库、磁盘读写,所以不用关心函数外的环境因素,进而在不同人的电脑上debug效果一样。同理,指令式编程过程中费时费力的单步调试,此时不再重要了,关心函数的输出即可。

纯函数更易并发

纯函数保证值不变性,值一旦生成就不再改变,无论是同一线程还是多个线程,都不允许修改。所以连锁都免了,更没有死锁或数据竞态问题了。

因为没有副作用,从出入参就可以看出函数之间的依赖关系,如果前后没有数据依赖,其先后顺序便可以交换,或者在多个线程中独立运行而不相互干涉。如下面代码中,f、g、h在字面上的先后顺序是固定的,但编译器完全可以根据需要改动实际执行顺序,或者优化为并发执行。如果它们有副作用,比如暗地里修改了共享的状态,就不能这么轻巧地优化了。

val x = f(a)
val y = g(b)
val z = h(c)
val result = x + y + z

我们从回顾众所周知的函数开始,再区分纯函数和非纯函数,进而专注于纯函数式的特点、好处。接下来,如何不是特别说明,提到函数时指的就是纯函数。函数式编程,更准确的说法应该是”纯函数”式编程。纯函数所带来的好处构成了函数式编程的核心优势,这点后续将有更多体现。