函数式编程之纯函数与柯里化

137 阅读8分钟

1. 纯函数

1.1 什么是纯函数?

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

1.2 副作用内容

“副作用“的关键部分在于”副“。就像一潭死水中的”水“本身并不是幼虫的培养器,“死”才是生成虫群的原因。同理,副作用中的“副”是滋生bug的温床。 副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。

副作用可能包含,但不限于:

  1. 更改文件系统;
  2. 往数据库插入记录
  3. 发送一个http请求;
  4. 可变数据
  5. 打印/log
  6. 获取用户输入
  7. DOM查询
  8. 访问系统状态

概括来讲,只要是跟函数外部环境发生的交互就都是副作用——这一点可能会让你怀疑无副作用编程的可行性。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。

这并不是说,要禁止使用一切副作用,而是说,要让他们在可控的范围内发生。

副作用让一个函数变得不纯是有道理的:从定义上来说,纯函数必须要能够根据相同的收入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了。

1.3 追求纯函数的原因

1.3.1 可缓存性(Cacheable)

首先,纯函数总能够根据输入来做缓存。实现缓存的一种典型方式是 memorize 技术:

var squareNumber = memorize(function(x){return x * x})

squareNumber(4)
// => 16
squareNumber(4)
// => 16  从缓存中读取输入值 4 的结果


var memorize = function(f){
  var cache = {}
  
  return function(){
    var arg_str = JSON.stringify(arguments)
    cache[arg_str] = cahce[arg_str] || f.apply(f,arguments)
    return cache[arg_str]
  }
} 

值得注意的一点是,可以通过延迟执行的方式把不纯的函数转换为纯函数:

var pureHttpCall = memorize(function(url,params){
  return function() { return $.getJSOn(url,params)}
})

这里并没有真正发送http请求——只是返回了一个函数,当调用它的时候才会发请求。这个函数之所以有资格称为纯函数,是因为它总是会根据相同的输入返回相同的输出:给定了 url 和 params 之后,它就只会返回同一个发送http请求的函数。

memorize 函数工作起来没有任何问题,虽然他缓存的并不是 http 请求所返回的结果,而是生成的函数。

1.3.2 可移植性/自文档化(Portable / Self-Documenting)

纯函数的依赖很明确,因此更易于观察和理解。

// 不纯的
var signUp = function(attrs) {
  var user = saveUser(attrs);
  welcomeUser(user);
};

var saveUser = function(attrs) {
    var user = Db.save(attrs);
    ...
};

var welcomeUser = function(user) {
    Email(user, ...);
    ...
};

// 纯的
var signUp = function(Db, Email, attrs) {
  return function() {
    var user = saveUser(Db, attrs);
    welcomeUser(Email, user);
  };
};

var saveUser = function(Db, attrs) {
    ...
};

var welcomeUser = function(Email, user) {
    ...
};

上述例子表明,纯函数对于其他以来必须要明确,这样我们就能知道它的目的。

仅从纯函数版本的 signUp 的签名就可以看出,它将要用到Db,Email和 attrs,这在最小程度上给了我们足够多的信息。其次,通过强迫“注入”依赖,或者把他们当作参数传递,我们的应用也更加灵活。

命令式编程中“典型”的方法和过程都深深地根植于他们所在的环境中,通过状态、依赖和有效作用(available effects)达成。纯函数与此相反,它与环境无关,只要我们愿意,可以在任何地方运行它。

1.3.3 可测试性

纯函数让测试更加容易。我们不需要伪造一个“真实“支付网关,或者每一次测试之前都要配置、之后都要断言状态(assert the state)。只需简单地给函数一个输入,然后断言出就好了。

1.3.4 合理性

很多人相信使用纯函数最大的好处是引用透明性(referential transparency)。如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们说这段代码是引用透明的。

由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。我下面是一个例子:

var Immutable = require('immutable');

var decrementHP = function(player) {
  return player.set("hp", player.hp-1);
};

var isSameTeam = function(player1, player2) {
  return player1.team === player2.team;
};

var punch = function(player, target) {
  if(isSameTeam(player, target)) {
    return target;
  } else {
    return decrementHP(target);
  }
};

var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"});
var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});

punch(jobe, michael);
//=> Immutable.Map({name:"Michael", hp:19, team: "green"})

decrementHP,isSameTeam 和 punch 都是纯函数,所以引用透明的。可以使用一种叫做“等式推导“(equational reasoning)的方法来分析代码。所谓“等式推导”就是一对一替换,有点像在不考虑程序性执行的怪异行为(quirks of programmatic evaluation)的情况下,手动执行相关代码。我们借助引用透明性来剖析一下这段代码。

首先内联 isSameTeam函数:

var punch = function(player, target) {
  if(player.team === target.team) {
    return target;
  } else {
    return decrementHP(target);
  }
};

因为是不可变数据,我们可以直接把 team 替换为实际值:

var punch = function(player, target) {
  if("red" === "green") {
    return target;
  } else {
    return decrementHP(target);
  }
};

if语句执行结果为 false,所以可以把整个if语句都删掉:

var punch = function(player, target) {
  return decrementHP(target);
};

如果再内联 decrementHP,我们会发现这种情况下,punch 变成了一个让hp的值减1的调用:

var punch = function(player, target) {
  return target.set("hp", target.hp-1);
};

等式推导带来的分析代码的能力对重构和理解代码非常重要。事实上,我们重构海鸥程序使用的正是这项技术:利用加和乘的特性。

1.3.5 并行代码

最后一点,也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存。而且根据定义,纯函数也不会因副作用而进入竞争态(race codition)。

并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里非常容易实现的,因为它们使用了线程(thread)。不过处于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。

2. 柯里化

2.1 什么是柯里化

概念:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

可以一次性地调用curry函数,也可以每次只传一个参数分多次调用。

var add = function(x) {
  return function(y) {
    return x + y;
  };
};

var increment = add(1);
var addTen = add(10);

increment(2);
// 3

addTen(2);
// 12

上述代码中定义了一个 add 函数,它接受一个参数并返回一个新的函数。调用 add 之后,返回的函数就通过必报的方式记住了 add的 第一个参数。一次性地调用它实在是有点繁琐,好在可以使用一个特殊的 curry 帮助函数(helper function)使这类函数的定义和调用更加容易。

var curry = require('lodash').curry;

var match = curry(function(what, str) {
  return str.match(what);
});

var replace = curry(function(what, replacement, str) {
  return str.replace(what, replacement);
});

var filter = curry(function(f, ary) {
  return ary.filter(f);
});

var map = curry(function(f, ary) {
  return ary.map(f);
});

上述代码中遵循的是一种简单,同时也非常重要的模式。即策略性地把要操作的数据(String,Array)放到最后一个参数里。在使用它们的时候就明白这样做的原因是什么了。

match(/\s+/g, "hello world");
// [ ' ' ]

match(/\s+/g)("hello world");
// [ ' ' ]

var hasSpaces = match(/\s+/g);
// function(x) { return x.match(/\s+/g) }

hasSpaces("hello world");
// [ ' ' ]

hasSpaces("spaceless");
// null

filter(hasSpaces, ["tori_spelling", "tori amos"]);
// ["tori amos"]

var findSpaces = filter(hasSpaces);
// function(xs) { return xs.filter(function(x) { return x.match(/\s+/g) }) }

findSpaces(["tori_spelling", "tori amos"]);
// ["tori amos"]

var noVowels = replace(/[aeiou]/ig);
// function(replacement, x) { return x.replace(/[aeiou]/ig, replacement) }

var censored = noVowels("*");
// function(x) { return x.replace(/[aeiou]/ig, "*") }

censored("Chocolate Rain");
// 'Ch*c*l*t* R**n'

这里表明的是一种“”预加载“函数的能力,通过传递一到两个参数调用函数,就能得到一个记住了这些参数的新函数。

2.2 使用用途

curry 的用处非常广泛,就像在 hasSpaces,findSpaces 和 censored看到的那样,只需传给函数一些参数,就能得到一个新函数。

用 map 简单地把参数是单个元素的函数包裹一下,就能把它转换成参数为数组的函数。

var getChildren = function(x) {
  return x.childNodes;
};

var allTheChildren = map(getChildren);

只传给函数一部分参数通常也叫做局部调用(partial application),能够大量减少样板文件代码(boilerplate code)。考虑上面的 allTheChildren 函数,如果用 lodash 的普通 map 来写会是什么样的(注意参数的顺序变了):

var allTheChildren = function(elements) {
  return _.map(elements, getChildren);
};

通常我们不定义直接操作数组的函数,因为只需要内联调用 map(getChildren) 就能达到目的。这一点同样适用于sort、filter以及其他的高阶函数(higher order function)(高阶函数:参数或返回值为函数的函数)。

当谈论到纯函数时,都说他们接受一个输入返回一个输出。curry 函数所做的正是这样:每传递一个参数调用的函数,就返回一个新函数处理剩余的参数。这就是一个输入对应一个输出。