β

不要被函数式编程吓到

Superlin's Blog 102 阅读
不要被函数式编程吓到

虽然函数式编程并不是什么新鲜的词汇,但是你可能以为函数式编程就像 map reduce 这么简单,就像 jQuery 以及Javascript原生的数组函数的链式调用这么简单。此外,我们常说Javascript的一个重要特性就是闭包,那么闭包肯定是Javascript独有的吧?!其实不然,闭包只是函数式编程的特性之一,由此看来函数式编程并不是想象的那么简单。

近日准备读一读 《Functional Javascript》 一书,在此之前就先来浅浅的探一探“高深”的函数式编程。当然并不要被如此“高深”的东西吓到,其实很多特性和名词对于很多人来说都已经接触过了。除了之前提到的,还有像 柯里化 尾递归优化 高阶函数 等...应该都是似曾相识。下面就一起来探讨一下吧。

历史回眸

20世纪30年代普林斯顿大学有四位学者, 艾伦·图灵 约翰·冯·诺伊曼 库尔特·哥德尔 阿隆佐·邱奇 ,他们都对形式系统感兴趣,相对于现实世界,他们更关心如何解决抽象的数学问题。而他们的问题都有这么一个共同点:都在尝试解答关于计算的问题。诸如:如果有一台拥有无穷计算能力的超级机器,可以用来解决什么问题?它可以自动的解决这些问题吗?是不是还是有些问题解决不了,如果有的话,是为什么?如果这样的机器采用不同的设计,它们的计算能力相同吗?

在与这些人的合作下,阿隆佐设计了一个名为 lambda演算 的形式系统。这个系统实质上是为其中一个超级机器设计的编程语言。在这种语言里面,函数的参数是函数,返回值也是函数。这种函数用希腊字母lambda(λ),这种系统因此得名。有了这种形式系统,阿隆佐终于可以分析前面的那些问题并且能够给出答案了。

除了阿隆佐·邱奇,艾伦·图灵也在进行类似的研究。他设计了一种完全不同的系统(后来被称为图灵机),并用这种系统得出了和阿隆佐相似的答案。到了后来人们证明了图灵机和 lambda演算 的能力是一样的。

1949年第一台电子离散变量自动计算机诞生并取得了巨大的成功。它是冯·诺伊曼设计架构的第一个实例,也是一台现实世界中实现的图灵机。相比他的这些同事,那个时候阿隆佐的运气就没那么好了。

到了50年代末,一个叫John McCarthy的MIT教授(他也是普林斯顿的硕士)对阿隆佐的成果产生了兴趣。1958年他发明了一种列表处理语言(Lisp),这种语言是一种阿隆佐lambda演算在现实世界的实现,而且它能在冯·诺伊曼计算机上运行!很多计算机科学家都认识到了Lisp强大的能力。1973年在MIT人工智能实验室的一些程序员研发出一种机器,并把它叫做Lisp机。于是阿隆佐的 lambda演算 也有自己的硬件实现了!

定义

Lisp 诞生之后,新的函数式编程语言层出不穷,例如 Erlang clojure Scala F# 等等。目前最当红的 Python Ruby Javascript ,对函数式编程的支持都很强,就连老牌的面向对象的 Java 、面向过程的 PHP ,都忙不迭地加入对匿名函数的支持。

简单说,"函数式编程"是一种"编程范式"(programming paradigm),也就是如何编写程序的方法论。

它属于"结构化编程"的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用。举例来说,现在有这样一个数学表达式:

(1 + 2) * 3 - 4

传统的过程式编程,可能这样写:

var a = 1 + 2;  
var b = a * 3;  
var c = b - 4;  

函数式编程要求使用函数,我们可以把运算过程定义为不同的函数,然后写成下面这样:

var res = subtract(multiply(add(1,2), 3), 4);  

这就是函数式编程。

特点

函数是一等公民

所谓"第一等公民"(first class),指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。

举例来说,下面代码中的print变量就是一个函数,可以作为另一个函数的参数。

var print = function(i){ console.log(i);};  
[1,2,3].forEach(print);

不可改变量

在函数式编程中,我们通常理解的变量在函数式编程中也被函数代替了:在函数式编程中变量仅仅代表某个表达式。这里所说的‘变量’是不能被修改的。所有的变量只能被赋一次初值。在Java中就意味着每一个变量都将被声明为final(如果你用C++,就是const)。在函数式编程中,没有非final的变量。

final int i = 5;  
final int j = i + 3;  

无状态

如果变量不可以改变,那么状态如何存储,这个不用担心,函数式编程中状态通过函数来保存,如果你需要保存一个状态一段时间并且时不时的修改它,那么你可以编写一个递归函数。举个例子,试着写一个函数,用来反转一个字符串。

function reverse(String arg) {  
    if(arg.length == 0) {
        return arg;
    } else {
        return reverse(arg.substring(1, arg.length)) + arg.substring(0, 1);
    }
}

由于使用了递归,函数式语言的运行速度比较慢,这是它长期不能在业界推广的主要原因。

技术

map & reduce

map reduce 开篇时已经提到过,他们是对一个集合最常用的操作。

map 接受一个集合和一个函数f,集合中每个元素都映射到函数f上,并返回一个新的集合,简单实现如下(详见 mdn map polyfill ):

function map(arr, callback){  
  var l = arr && arr.length || 0,
      out = [];

  for (var i = 0; i < l; i++) {
      out[i] = callback(arr[i]);  
  }

  return out;
}

reduce 接受一个集合和一个函数f,然后将f映射到数组的相邻的两个元素上,简单实现如下(详见 mdn reduce polyfill ):

function reduce(arr, callback, b){  
  var l = arr && arr.length || 0,
      x = 0;

  b = b || 0;

  for (var i = 0; i < l; i++) {
      x = callback(arr[i], x);  
  }

  return x;
}

柯里化

柯里化就是把一个函数的多个参数分解成多个函数, 然后把函数多层封装起来,每层函数都返回一个函数去接收下一个参数这样,可以简化函数的多个参数。

例如要计算一个数的平方,可以先实现一个计算任意整数次幂的函数,然后调用接口实现计算一个数的平方:

function pow(base, p) {/*计算base的p次方*/}

function square(a) {  
    return pow(a, 2);
}

柯里化就是这么简单:一种可以快速且简单的实现函数封装的捷径。我们可以更专注于自己的设计,编译器则会为你编写正确的代码!什么时候使用currying呢?很简单,当你想要用适配器模式(或是封装函数)的时候,就是用currying的时候。对于函数编程来说,适配器模式就是多余的。

惰性求值

在指令式语言中以下代码会按顺序执行,由于每个函数都有可能改动或者依赖于其外部的状态,因此必须顺序执行。先是计算 somewhatLongOperation1 ,然后到 somewhatLongOperation2 ,最后执行 concatenate 。假如把 concatenate 换成另外一个函数,这个函数中有条件判断语句而且实际上只会需要两个参数中的其中一个,那么就完全没有必要执行计算另外一个参数的函数了!

var s1 = somewhatLongOperation1();  
var s2 = somewhatLongOperation2();  
var s3 = concatenate(s1, s2);  

函数式语言就不一样了。只有到了执行需要 s1 s2 作为参数的函数的时候,才真正需要执行这两个函数。于是在 concatenate 这个函数没有执行之前,都没有需要去执行这两个函数:这些函数的执行可以一直推迟到 concatenate() 中需要用到s1和s2的时候。

惰性求值是十分强大的技术,但是需要编译器的支持。

高阶函数

高阶函数就是函数当参数,把传入的函数做一个封装,然后返回这个封装函数。例如我们要实现一个计算1和任意数字的和的函数:

var partAdd = function(p1){  
    this.add = function (p2){
        return p1 + p2;
    };
    return add;
};

var add = partAdd(1);  
add(2); // 3  

执行 partAdd(1) 时返回的任然是一个函数,当再次传入第二个参数时,就可以计算出和了。

上面的例子只是为了理解高阶函数,实际运用如下例所示:

var add = function(a,b){  
    return a + b;
};

function math(func,array){  
    return func(array[0],array[1]);
}

math(add,[1,2]); // 3  

尾调用优化

尾调用的概念非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

function f(x){  
  return g(x);
}

上面代码中,函数f的最后一步是调用函数g,这就叫尾调用。

以下两种情况,都不属于尾调用。

// 情况一
function f(x){  
  let y = g(x);
  return y;
}

// 情况二
function f(x){  
  return g(x) + 1;
}

上面代码中,情况一是调用函数g之后,还有别的操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。

我们知道函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。多层次的调用记录形成了调用栈。尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。

function f() {  
  let m = 1;
  let n = 2;
  return g(m + n);
}
f();

// 等同于
function f() {  
  return g(3);
}
f();

// 等同于
g(3);  

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。基于尾调用优化的原理,我们可以对尾递归进行优化。递归需要保存大量的调用记录,很容易发生栈溢出错误,如果使用尾递归优化,将递归变为循环,那么只需要保存一个调用记录,这样就不会发生栈溢出错误了。

例如计算阶乘的函数:

// 不是尾递归,无法优化
function factorial(n) {  
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

// 尾递归,可以优化
function factorial(n, total) {  
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

目前的ES5中并没有规定尾调用优化,但是ES6中明确规定了必须实现尾调用优化,也就是ES6中只要使用尾递归,就不会发生栈溢出。所以 对于递归函数尽量改写为尾递归形式

闭包

目前为止关于函数式编程各种功能的讨论都只局限在“纯”函数式语言范围内。很多这样的语言都不要求所有的变量必须为final,可以修改他们的值。也不要求函数只能依赖于它们的参数,而是可以读写函数外部的状态。同时这些语言又包含了函数编程的特性,如高阶函数。与在lambda演算限制下将函数作为参数传递不同,在指令式语言中要做到同样的事情需要支持一个有趣的特性,人们常把它称为lexical closure。

看如下例子,虽然外层的 makePowerFn 函数执行完毕,栈上的调用帧被释放,但是堆上的作用域并不被释放,因此 power 依旧可以被 powerFn 函数访问,这样就形成了闭包:

function makePowerFn(power) {  
   function powerFn(base) {
       return pow(base, power);
   }

   return powerFn;
}

var square = makePowerFn(2);  
square(3); // 9  

优点

代码简洁,易于理解

函数式编程大量使用函数,减少了代码的重复,因此程序比较短,开发速度较快。Paul Graham在《黑客与画家》一书中写道:同样功能的程序,极端情况下,Lisp代码的长度可能是C代码的二十分之一。

函数式编程的自由度很高,可以写出很接近自然语言的代码。例如前文提到的 (1 + 2) * 3 - 4 的例子,写成函数时:

add(1,2).multiply(3).subtract(4);  

容易调试

因为函数式编程中的每个符号都是 final 的,于是没有什么函数会有副作用。谁也不能在运行时修改任何东西,也没有函数可以修改在它的作用域之外修改什么值给其他函数继续使用(在指令式编程中可以用类成员或是全局变量做到)。这意味着决定函数执行结果的唯一因素就是它的返回值,而影响其返回值的唯一因素就是它的参数。

如果一段FP程序没有按照预期设计那样运行,调试的工作几乎不费吹灰之力。这些错误是百分之一百可以重现的,因为FP程序中的错误不依赖于之前运行过的不相关的代码。

并发

函数式编程不需要考虑"死锁"(deadlock),因为它不修改变量,所以根本不存在"锁"线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程,部署"并发编程"(concurrency)。

还是之前的例子:

var s1 = somewhatLongOperation1();  
var s2 = somewhatLongOperation2();  
var s3 = concatenate(s1, s2);  

由于 s1 s2 互不干扰,不会修改变量,谁先执行是无所谓的,所以可以放心地增加线程,把它们分配在两个线程上完成。其他类型的语言就做不到这一点,因为s1可能会修改系统状态,而s2可能会用到这些状态,所以必须保证 s2 s1 之后运行,自然也就不能部署到其他线程上了。

热部署

函数式编程中所有状态就是传给函数的参数,而参数都是储存在栈上的。这一特性让软件的热部署变得十分简单。只要比较一下正在运行的代码以及新的代码获得一个diff,然后用这个diff更新现有的代码,新代码的热部署就完成了。其它的事情有FP的语言工具自动完成! Erlang 语言早就证明了这一点,它是瑞典爱立信公司为了管理电话系统而开发的,电话系统的升级当然是不能停机的。

参考

作者:Superlin's Blog
Front-End, Web, CSS, HTML, Javascript, HTML5, CSS3, Node JS...balaba
原文地址:不要被函数式编程吓到, 感谢原作者分享。

发表评论