什么是函数式编程
先了解一下编程范式:
编程范式 指的是一种编程风格,它描述了程序员对程序执行的看法。在编程的世界中,同一个问题,可以站在多个角度去分析解决,这些不同的解决方案就对应了不同的编程风格。
大致常见的有: 命令式编程,面向过程编程,面向对象编程,声明式编程,函数式编程。
那什么是函数式编程,来看一下定义:
函数式编程 属于声明式编程中的一种,它的主要思想是 将计算机运算看作为函数的计算,也就是把程序问题抽象成数学问题去解决。
函数式编程中,我们可以充分利用数学公式来解决问题。也就是说,任何问题都可以通过函数(加减乘除)和数学定律(交换律、结合律等),一步一步计算,最终得到答案。
函数式编程特性:
- 函数是"第一等公民":函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。
- 只用"表达式",不用"语句":"表达式"是一个单纯的运算过程,总是有返回值;"语句"是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。
- 引用透明性:函数的运行不依赖于外部变量,只依赖于输入的参数。
- 没有"副作用":所谓"副作用",指的是函数内部与外部互动,产生运算以外的其他结果。
函数式编程强调没有"副作用",意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。 - 不修改状态:不修改变量,也是它的一个重要特点。
在其他类型的语言中,变量往往用来保存"状态"(state)。不修改变量,意味着状态不能保存在变量中。函数式编程使用参数保存状态,最好的例子就是递归。
衍生应用场景
- 纯函数:同样的输入得到同样的输出,无副作用。
- 高阶函数:可以加工函数的函数,接收一个或多个函数作为输入、输出一个函数。
- 闭包:函数作用域嵌套,实现的不同作用域变量共享。
- 递归:控制函数循环调用的一种方式。
- 尾递归:避免多层级函数嵌套导致的内存溢出的优化。
- 函数组合:将多个依次调用的函数,组合成一个大函数,简化操作步骤。
- 柯里化:将一个多参数函数转化为多个嵌套的单参数函数。
- 偏函数:缓存一部分参数,然后让另一些参数在使用时传入。
- 惰性求值:预先定义多个操作,但不立即求值,在需要使用值时才去求值,可以避免不必要的求值,提升性能。
纯函数(purity)
首先,我们要理清纯函数的概念。
纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态。
比如 slice 和 splice,这两个函数的作用并无二致——但是注意,它们各自的方式却大不同,但不管怎么说作用还是一样的。我们说 slice 符合纯函数的定义是因为对相同的输入它保证能返回相同的输出。而 splice 却会嚼烂调用它的那个数组,然后再吐出来;这就会产生可观察到的副作用,即这个数组永久地改变了。
var xs = [1,2,3,4,5];
// 纯的
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
// 不纯的
xs.splice(0,3);
//=> [1,2,3]
xs.splice(0,3);
//=> [4,5]
xs.splice(0,3);
//=> []
在函数式编程中,我们讨厌这种会改变数据的笨函数。我们追求的是那种可靠的,每次都能返回同样结果的函数,而不是像 splice 这样每次调用后都把数据弄得一团糟的函数。
让我们来仔细研究一下“副作用”以便加深理解。那么,我们在纯函数定义中提到的万分邪恶的副作用到底是什么?
副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。
副作用可能包含,但不限于:
- 更改文件系统
- 往数据库插入记录
- 发送一个 http 请求
- 可变数据
- 打印/log
- 获取用户输入
- DOM 查询
- 访问系统状态
概括来讲,只要是跟函数外部环境发生的交互就都是副作用——这一点可能会让你怀疑无副作用编程的可行性。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。
这并不是说,要禁止使用一切副作用,而是说,要让它们在可控的范围内发生。
追求“纯”的理由
-
可缓存性(Cacheable):
首先,纯函数总能够根据输入来做缓存。 -
可移植性/自文档化
在 JavaScript 的设定中,可移植性可以意味着把函数序列化并通过 socket 发送。也可以意味着代码能够在 web workers 中运行。
它与环境无关,只要我们愿意,可以在任何地方运行它。 -
可测试性(Testable)
我们不需要伪造一些环境和配置。只需简单地给函数一个输入,然后断言输出就好了。 -
合理性(Reasonable)
很多人相信使用纯函数最大的好处是引用透明性。 -
并行代码
我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态。
柯里化(curry)
curry 的概念很简单:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。
比如对于加法函数 var add = (x, y) => x + y ,我们可以这样进行柯里化:
//比较容易读懂的ES5写法var add = function(x){
return function(y){
return x + y
}}
//ES6写法,也是比较正统的函数式写法
var add = x => (y => x + y);
//试试看
var add2 = add(2);
var add200 = add(200);
add2(2); // =>4
add200(50); // =>250
实际应用的示例
import { curry } from 'lodash';
//首先柯里化两个纯函数
var match = curry((reg, str) => str.match(reg));
var filter = curry((f, arr) => arr.filter(f));
//判断字符串里有没有空格
var haveSpace = match(/\s+/g);
haveSpace("ffffffff"); //=>null
haveSpace("a b"); //=>[" "]
filter(haveSpace, ["abcdefg", "Hello World"]); //=>["Hello world"]
学会了使用纯函数以及如何把它柯里化之后,我们会很容易写出这样的“包菜式”代码:
h(g(f(x)));
虽然这也是函数式的代码,但它依然存在某种意义上的“不优雅”。为了解决函数嵌套的问题,让我们接下来学习下“函数组合”。
组合(compose)
这就是 组合(compose,以下将称之为组合):
var compose = function(f,g) {
return function(x) {
return f(g(x));
};
};
f 和 g 都是函数,
x 是在它们之间通过“管道”传输的值。
组合看起来像是在饲养函数。你就是饲养员,选择两个有特点又遭你喜欢的函数,让它们结合,产下一个崭新的函数。
var toUpperCase = function(x) { return x.toUpperCase(); };
var exclaim = function(x) { return x + '!'; };
var shout = compose(exclaim, toUpperCase);
shout("send in the clowns");
//=> "SEND IN THE CLOWNS!"
在 compose 的定义中,g 将先于 f 执行,因此就创建了一个从右到左的数据流。这样做的可读性远远高于嵌套一大堆的函数调用,如果不用组合,shout 函数将会是这样的:
var shout = function(x){
return exclaim(toUpperCase(x));
};
示例:
var toUpperCase = function(x) { return x.toUpperCase(); };
var head = function(x) { return x[0]; };
var reverse = reduce(function(acc, x){ return [x].concat(acc); }, []);
// 比较两个方法
compose(toUpperCase, compose(head, reverse));
compose(compose(toUpperCase, head), reverse);
//组合是符合结合律的
// 结合律(associativity)
var associative = compose(f, compose(g, h)) == compose(compose(f, g), h);
// true
var lastUpper = compose(toUpperCase, head, reverse);
pointfree:
pointfree这种模式现在还暂且没有中文的翻译,
用中文解释的话大概就是,不要命名转瞬即逝的中间变量。
// 非 pointfree,因为提到了数据:name
var initials = function (name) {
return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};
// pointfree
var initials = compose(join('. '), map(compose(toUpperCase, head)), split(' '));
initials("hunter stockton thompson");
// 'H. S. T'
pointfree 模式能够帮助我们减少不必要的命名,让代码保持简洁和通用。
尾递归
尾递归就是从最后开始计算, 每递归一次就算出相应的结果, 也就是说, 函数调用出现在调用者函数的尾部, 因为是尾部, 所以根本没有必要去保存任何局部变量. 直接让被调用的函数返回时越过调用者, 返回到调用者的调用者去。
当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。编译器可以做到这点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。
线性递归:
long Rescuvie(long n) {
return (n == 1) ? 1 : n * Rescuvie(n - 1);
}
当n = 5时
对于线性递归, 他的递归过程如下:
Rescuvie(5)
{5 * Rescuvie(4)}
{5 * {4 * Rescuvie(3)}}
{5 * {4 * {3 * Rescuvie(2)}}}
{5 * {4 * {3 * {2 * Rescuvie(1)}}}}
{5 * {4 * {3 * {2 * 1}}}}
{5 * {4 * {3 * 2}}}
{5 * {4 * 6}}
{5 * 24}
120
尾递归:
long TailRescuvie(long n, long a) {
return (n == 1) ? a : TailRescuvie(n - 1, a * n);
}
long TailRescuvie(long n) { //封装用的
return (n == 0) ? 1 : TailRescuvie(n, 1);
}
对于尾递归, 他的递归过程如下:
TailRescuvie(5)
TailRescuvie(5, 1)
TailRescuvie(4, 5)
TailRescuvie(3, 20)
TailRescuvie(2, 60)
TailRescuvie(1, 120)
120
很容易看出, 普通的线性递归比尾递归更加消耗资源, 在实现上说, 每次重复的过程调用都使得调用链条不断加长. 系统不得不使用栈进行数据保存和恢复。
而尾递归就不存在这样的问题, 因为他的状态完全由n和a保存。