函数式编程

274 阅读18分钟

函数式编程

一.什么是函数式编程

1. 基本介绍

函数式编程是一种编程范式,通过应用和组合函数来构建程序,将计算机运算视为函数的计算,脱离业务场景选择最合适代码执行的方式。

主要的编程范式有三种:命令式编程声明式编程函数式编程

相比命令式编程函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而非设计一个复杂的执行过程。

下面我们来看一个应用函数式编程的案例。

2. 海鸥程序

以下是一个海鸥程序,鸟群合并则可以变成了一个更大的鸟群,繁殖则增加了鸟群的数量,增加的数量就是它们繁殖出来的海鸥的数量。

注意:这个程序并不是面向对象的良好实践,它只是强调当前这种变量赋值方式的一些弊端。

var Flock = function(n) {
  this.seagulls = n;
};

// 鸟群合并:相加
Flock.prototype.conjoin = function(other) {
  this.seagulls += other.seagulls;
  return this;
};

// 鸟群繁殖:相乘
Flock.prototype.breed = function(other) {
  this.seagulls = this.seagulls * other.seagulls;
  return this;
};

var flock_a = new Flock(4);
var flock_b = new Flock(2);
var flock_c = new Flock(0);

var result = flock_a.conjoin(flock_c).breed(flock_b).conjoin(flock_a.breed(flock_b)).seagulls;
console.log(result);
//=> 32

按照实现的预期,我们期望答案是16,但是输出的结果却是32,这样的代码形式会使得代码的内部状态变得非常难以追踪,错误的原因在于 flock_a 在运算过程中永久地改变了。

再换一种方式:

var conjoin = function(flock_x, flock_y) { return flock_x + flock_y };
var breed = function(flock_x, flock_y) { return flock_x * flock_y };

var flock_a = 4;
var flock_b = 2;
var flock_c = 0;

var result = conjoin(breed(flock_b, conjoin(flock_a, flock_c)), breed(flock_a, flock_b));
console.log(result);
//=> 16

虽然得到了正确的答案,而且少写了很多代码。但函数嵌套有点让人费解,不过代码肯定是越直白越好,所以如果我们再深入挖掘,我们会发现,它不过是在进行简单的加(conjoin) 和乘(breed)运算而已。

代码中的两个函数除了函数名有些特殊,其他没有任何难以理解的地方。我们把它们重命名一下,看看它们的真实功能。

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

var flock_a = 4;
var flock_b = 2;
var flock_c = 0;

var result = add(multiply(flock_b, add(flock_a, flock_c)), multiply(flock_a, flock_b));
console.log(result);
//=>16

这样,我们会发现,我们所做的无非是简单的加减乘除:

// 结合律(assosiative)
add(add(x, y), z) == add(x, add(y, z));

// 交换律(commutative)
add(x, y) == add(y, x);

// 同一律(identity)
add(x, 0) == x;

// 分配律(distributive)
multiply(x, add(y,z)) == add(multiply(x, y), multiply(x, z));

基于此,我们可以针对原有代码进行处理:

// 原有代码
add(multiply(flock_b, add(flock_a, flock_c)), multiply(flock_a, flock_b));

// 应用同一律,去掉多余的加法操作 (add(flock_a, flock_c) == flock_a)
add(multiply(flock_b, flock_a), multiply(flock_a, flock_b));

// 再应用分配律
multiply(flock_a, flock_b)*2;

这样,我们就可以通过简单的函数逻辑调整,优化了绝大多数的代码量。

二.一等公民的函数

1.概览

函数是一等公民实际上说的是它们和其他对象都一样,所以就是普通公民(坐经济舱的人)。换句话说,函数没什么特殊的,你可以像对待任何其他数据类型一样对待它们:把它们存在数组里,当作参数传递,赋值给变量等等。函数可以作为其他函数的输入,同时也可以作为返回值

这是 JavaScript 语言的基础概念,不过还是值得提一提的,大多数人对这个概念集体无视,或者也可能是无知。我们来看一个杜撰的例子:

const hi = name => `Hi ${name}`;
const greeting = name => hi(name);

这里 greeting 指向的那个把 hi 包了一层的包裹函数完全是多余的。为什么?因为 JavaScript 的函数是可调用的,当 hi 后面紧跟 () 的时候就会运行并返回一个值;如果没有 (),hi 就简单地返回,作为存到这个变量里的函数。我们来确认一下:

hi;   // name => `Hi ${name}`
hi("jonas"); // "Hi jonas"

greeting 只不过是转了个身,然后以相同的参数调用了 hi 函数而已,因此我们可以这么写:

const greeting = hi;
greeting("hll"); // "Hi hll"

换句话说,hi 已经是个接受一个参数的函数了,而它仅仅是用这个相同的参数调用 hi,这样完全多此一举。

用一个函数把另一个函数包起来,目的仅仅是延迟执行,真的是非常糟糕的编程习惯。

接下来看几个例子:

// bad
const getServerStuff = callback => ajaxCall(json => callback(json));

// good
const getServerStuff = ajaxCall;

等同于:

ajaxCall(json => callback(json));

// 等价于
ajaxCall(callback);

// 那么,重构下 getServerStuff
const getServerStuff = callback => ajaxCall(callback);

// 就等于
const getServerStuff = ajaxCall // <-- 看,没有括号哦

所以以上才是写函数的正确方式。

我们再来看一个 Controller 的例子:

const BlogController = {
  index(posts) { return Views.index(posts); },
  show(post) { return Views.show(post); },
  create(attrs) { return Db.create(attrs); },
  update(post, attrs) { return Db.update(post, attrs); },
  destroy(post) { return Db.destroy(post); },
};

这个Controller 99%的代码都是垃圾,可以重写为:

const BlogController = {
  index: Views.index,
  show: Views.show,
  create: Db.create,
  update: Db.update,
  destroy: Db.destroy,
};

或者直接全部删掉,因为它的作用仅仅就是把视图(Views)和数据库(Db)打包在一起而已。

2.为何钟爱一等公民?

回顾下前面的代码, getServerStuffBlogController,虽说添加一些没有实际用处的间接层实现起来很容易,但这样做除了徒增代码量,提高维护和检索代码的成本外,没有任何用处。

另外,如果一个函数被不必要地包裹起来了,而且发生了改动,那么包裹它的那个函数也要做相应的变更。

httpGet('/post/2', json => renderPost(json));

如果 httpGet 要改成可以抛出一个可能出现的 err 异常,那我们还要回过头去把“胶水”函数也改了。

// 把整个应用里的所有 httpGet 调用都改成这样,可以传递 err 参数:
httpGet('/post/2', (json, err) => renderPost(json, err));

写成一等公民函数的形式,要做的改动将会少得多:

// renderPost 将会在 httpGet 中调用,想要多少参数都行
httpGet('/post/2', renderPost);  

除了删除不必要的函数,正确地为参数命名也必不可少。当然命名不是什么大问题,但还是有可能存在一些不当的命名,尤其随着代码量的增长以及需求的变更,这种可能性也会增加。

项目中常见的一种造成混淆的原因是,针对同一个概念使用不同的命名。还有通用代码的问题。比如,下面这两个函数做的事情一模一样,但后一个就显得更加通用,可重用性也更高:

// 只针对当前的博客数据
const validArticles = articles =>
  articles.filter(article => article !== null && article !== undefined),

// 对未来的项目更友好
const compact = xs => xs.filter(x => x !== null && x !== undefined);

在命名的时候,我们特别容易把自己限定在特定的数据上(本例中是 articles)。这种现象很常见,也是重复造轮子的一大原因。

有一点我必须得指出,你一定要非常小心 this 值,这一点与面向对象代码类似。如果一个底层函数使用了 this,而且是以一等公民的方式被调用的,那就很容易掉进this的坑里。

const fs = require('fs');

// bad
fs.readFile('freaky_friday.txt', Db.save);

// good a little
fs.readFile('freaky_friday.txt', Db.save.bind(Db));

Db 绑定(bind)到它自己身上以后,你就可以随心所欲地调用它的原型链式了。

this 有利有弊,如果不熟悉,尽量避免使用它,因为在函数式编程中根本用不到它。然而,在使用其他的类库时,可能会发现各种各样关于this的使用。

三.纯函数的好处

1.什么是纯函数?

首先,我们先来了解纯函数的概念:

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

比如我们在上面海鸥程序中使用的addmultiple就是纯函数:

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

const multiply = function (x, y) {
  return x * y;
};

我们保证了一个函数的输入一致,就一定能确定这是一个纯函数吗?

async function getData(url) {
  const result = await fetch(url).then((res) => res.json());
  return result;
}

上面示例的 getData就不是一个纯函数,因为内部有无法预知的操作。

针对于数组的方法,我们看看哪些是纯函数,哪些不是。

比如 slice 和 splice,这两个函数的作用并无二致。但是注意,它们各自的方式却大不同,但不管怎么说作用还是一样的。

  • slice:符合纯函数的定义,不改变原数组,对相同的输入它保证能返回相同的输出;
  • splice:不符合纯函数的定义,会切割并改变原数组,产生可观察到的副作用,即这个数组永久地改变了;
var arr = [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 这样每次调用后都把数据弄得一团糟的函数。

那数组的map方法呢?

arr.map((item) => item + 1);

map方法显然是一个纯函数,我们再来看看另一个例子:

// 不纯的
var minimum = 21;

var checkAge = function(age) {
  return age >= minimum;
};


// 纯的
var checkAge = function(age) {
  var minimum = 21;
  return age >= minimum;
};

在不纯的版本中,checkAge 的结果将取决于 minimum 这个可变变量的值。换句话说,它取决于系统状态(system state);因为它引入了外部的环境,从而增加了认知负荷(cognitive load)。

这个例子可能还不是那么明显,但这种依赖状态是影响系统复杂度的罪魁祸首。输入值之外的因素能够左右 checkAge 的返回值,不仅让它变得不纯,而且导致每次我们思考整个软件的时候都痛苦不堪。

另一方面,使用纯函数的形式,函数就能做到自给自足。我们也可以让 minimum 成为一个不可变(immutable)对象,这样就能保留纯粹性,因为状态不会有变化。要实现这个效果,必须得创建一个对象,然后调用 Object.freeze 方法:

var immutableState = {
  minimum: 21
};
Object.freeze(immutableState);

var checkAge = function(age) {
  return age >= immutableState.minimum;
};

2. 副作用内容

副作用的关键部分在于。就像一潭死水中的“水”本身并不是幼虫的培养器,“死”才是生成虫群的原因。同理,副作用中的是滋生 bug 的温床。

副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。

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

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

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

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

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

3. 纯函数就是数学上的函数

函数是两种数值之间的关系:输入和输出。尽管每个输入都只会有一个输出,但不同的输入却可以有相同的输出。下图展示了一个合法的从 x 到 y 的函数关系:

image.png 下面这张图表展示的就不是一种函数关系,因为输入值 5 指向了多个输出:

image.png

函数可以描述为一个集合,这个集合里的内容是 (输入, 输出) 对:[(1,2), (3,6), (5,10)]。

或者:

var toLowerCase = {"A":"a", "B":"b", "C":"c", "D":"d", "E":"e", "D":"d"};

toLowerCase["C"];
//=> "c"

var isPrime = {1:false, 2: true, 3: true, 4: false, 5: true, 6:false};

isPrime[3];
//=> true

当然实际情况中,可能需要进行一些计算而不是手动指定各项值;不过上例倒是表明了另外一种思考函数的方式。

从数学的概念上讲,纯函数就是数学上的函数,而且是函数式编程的全部。使用这些纯函数编程能够带来大量的好处,下面让我们来看一下为何要不遗余力地保留函数的纯粹性的原因。

4.追求纯函数的原因

4.1 可缓存性(Cacheable)

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

// 我们的计算函数square:
function square(x) {
    return x * x;
}

// 将计算函数传给 memoize 生成可以缓存 square 结果的函数 memoizeFn:
var memoizeFn = memoize(square);

console.log(memoizeFn(3));
//=> 9
console.log(memoizeFn(3));   // 从缓存中读取输入值为 3 的结果
 //=> 9
console.log(memoizeFn(5));
//=> 25
console.log(memoizeFn(5));   // 从缓存中读取输入值为 5 的结果
//=> 25

下面的代码是memoize的一个简单实现,尽管它不太健壮。

function memoize(fn) {
    let cache = {};
    return function () {
        let argStr = JSON.stringify(arguments);
        // 不可以写成fn(...arguments),会导致丢失上下文
        cache[argStr] = cache[argStr] || fn.apply(fn, arguments);

        return cache[argStr]
    }
}

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

var pureHttpCall = memoize(function(url, params) {
  return function() { return $.getJSON(url, params); }
});

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

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

4.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 user = Db.save(attrs);
    ...
};

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

这个例子表明,纯函数对于其依赖必须要明确,这样我们就能知道它的目的。

仅从纯函数版本的 signUp 的签名就可以看出,它将要用到 DbEmailattrs,这在最小程度上给了我们足够多的信息。

其次,通过强迫“注入”依赖,或者把它们当作参数传递,我们的应用也更加灵活;因为数据库或者邮件客户端等等都参数化了。如果要使用另一个 Db,只需把它传给函数就行了。如果想在一个新应用中使用这个可靠的函数,尽管把新的 DbEmail 传递过去就好了,非常简单。

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

4.3 可测试性(Testable)

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

4.4 合理性(Reasonable)

很多人相信使用纯函数最大的好处是引用透明性(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"})

decrementHPisSameTeampunch 都是纯函数,所以是引用透明的。我们可以使用一种叫做等式推导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);
};

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

4.5 并行代码

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

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

四.柯里化

1.什么是柯里化?

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

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

我们来看下面的这个示例:我们通过curry来处理函数fn得到一个全新的函数curryFncurry将使用多个参数的函数fn转换成支持使用单个参数的函数curryFn

function fn(a, b, c, d, e) {
    console.log(a, b, c, d, e)
}

// 正常调用
fn(1, 2, 3, 4, 5)

// 柯里化
function curry() {
    ...
}

// 经过柯里化,支持使用单个参数
let curryFn = curry(fn)
curryFn(1, 2)(3)(4, 5)
curryFn(1)(2)(3)(4)(5)

2.柯里化的场景

我们在业务中常常遇到校验数字和邮箱的情况:

// 校验函数:
function checkByRegExp(regExp, string) {
    return regExp.test(string)
}

// 柯里化
function curry() {
    ...
}

// 不使用柯里化的写法:
checkByRegExp(/^d$/, '124252816')
checkByRefExp(/xxxxXX/, 'test@gmail.com')
checkByRefExp(/xxxxXX/, 'test1@gmail.com')
checkByRefExp(/xxxxXX/, 'test2@gmail.com')
checkByRefExp(/xxxxXX/, 'tes3t@gmail.com')

// 使用柯里化的写法:
let _check = curry(checkByRegExp)
let checkPhone = _check(/^d$/)
let checkEmail = _check(/xxxxXX/)

3.封装一个柯里化函数

  • ES5写法:
function curry(fn) {
    return function curriedFn() {
        let args = [].slice.call(arguments);
        if (args.length < fn.length) {
            return function () {
                let args2 = [].slice.call(arguments);
                return curriedFn.apply(null, args.concat(args2))
            }
        }

        return fn.apply(fn, args)
    }
}
  • ES6写法:
function curry(fn) {
    return function curriedFn(...args) {
        if (args.length < fn.length) {
            return function (...args2) {
                return curriedFn(...args, ...args2)
            }
        }

        return fn(...args)
    }
}

原理:递归操作。

思想:不断生成新函数来收集参数,对参数做一个数组的整理,然后一起调用。

实现过程:

fn(1, 2, 3, 4)
fn(1)(2)(3)(4)

// 传入1生成新函数fn1
const fn1 = fn(1)
fn1(2, 3, 4)  // 第一个参数是1
// 再传入2生成新函数fn2
const fn2 = fn1(2) 
fn2(3, 4)  // 第一个参数是1,第二个参数是2
// 再传入3生成新函数fn3
const fn3 = fn2(3) 
fn3(4)  // 第一个参数是1,第二个参数是2,第三个参数是3
// 以此类推,直到集齐所有参数
const fn4 = fn3(4)  // 第一个参数是1,第二个参数是2,第三个参数是3,第四个参数是4
// 相当于fn(1, 2, 3, 4)


如果上面的实现过程还看不太明白,我们可以用嵌套函数的方式去理解:

// 相当于:
function fn(1) {
  return function (2) {
    return function (3) {
      return function (4) {
        return fn(1, 2, 3, 4);
      };
    };
  };
}

五.代码组合

1.什么是代码组合?

const 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 + '!'; };
// 通过 compose 将 exclaim 和toUpperCase 聚合
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 head = function(x) { return x[0]; };
var reverse = function (arr) {
    return arr.reduce((acc, x) => {
        return [x].concat(acc);
    }, []);
}

// 注意参数传入顺序
var last = compose(head, reverse);

last(['jumpkick', 'roundhouse', 'uppercut']);
//=> 'uppercut'

reverse 反转列表,head 取列表中的第一个元素;所以结果就是得到了一个 last 函数,即取列表的最后一个元素,虽然它性能不高。这个组合中函数的执行顺序应该是显而易见的。尽管我们可以定义一个从左向右的版本,但是从右向左执行更加能够反映数学上的含义。

2.封装 compose 函数

function compose(...funcs) {
    // 第一步:判断参数长度
    // 如果没有参数,直接返回结果
    if (funcs.length === 0) {
        return arg => arg;
    }
    // 如果只有一个参数,把该函数返回
    if (funcs.length === 1) {
        return funcs[0];
    }

    // 第二步:处理复杂场景-多个参数
    return funcs.reduce((prevFuncs, curFunc) => {
        return function(...args) {
            // 创建一个新函数用来处理结果
            return prevFuncs(curFunc(...args)); // 包成了洋葱
        };
    });
}

3.pointfree

pointfree 模式指的是,永远不必说出你的数据。它的意思是说,函数无须提及将要操作的数据是什么样的。一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。

// 非 pointfree,因为提到了数据:word
var snakeCase = function (word) {
  return word.toLowerCase().replace(/\s+/ig, '_');
};

// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

这里所做的事情就是通过管道把数据在接受单个参数的函数间传递。利用 curry,我们能够做到让每个函数都先接收数据,然后操作数据,最后再把数据传递到下一个函数那里去。另外注意在 pointfree 版本中,不需要 word 参数就能构造函数;而在非 pointfree 的版本中,必须要有 word 才能进行一切操作。

再来看一个例子:

// 非 pointfree,因为提到了数据name
var initials = function (name) {
  return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};

// pointfree,不需要提到数据name
var initials = compose(join('. '), map(compose(toUpperCase, head)), split(' '));

initials("hunter stockton thompson");
//=> 'H. S. T'

pointfree 模式能够帮助我们减少不必要的命名,让代码保持简洁和通用。对函数式代码来说,pointfree 是非常好的石蕊试验,因为它能告诉我们一个函数是否是接受输入返回输出的小函数。比如,while 循环是不能组合的。不过你也要警惕,pointfree 就像是一把双刃剑,有时候也能混淆视听。并非所有的函数式代码都是 pointfree 的,不过这没关系。可以使用它的时候就使用,不能使用的时候就用普通函数。

4.debug

组合的一个常见错误是:在没有局部调用之前,就组合类似 map 这样接受两个参数的函数。

// 错误做法:我们传给了 `angry` 一个数组,根本不知道最后传给 `map` 的是什么东西。
var latin = compose(map, angry, reverse);

latin(["frog", "eyes"]);
// error


// 正确做法:每个函数都接受一个实际参数。
var latin = compose(map(angry), reverse);

latin(["frog", "eyes"]);
// ["EYES!", "FROG!"])

如果在 debug 组合的时候遇到了困难,那么可以使用下面这个实用的,但是不纯的 trace 函数来追踪代码的执行情况。

var trace = curry(function(tag, x) {
  console.log(tag, x);
  return x;
});

var dasherize = compose(join('-'), toLower, split(' '), replace(/\s{2,}/ig, ' '));

dasherize('The world is a vampire');
// TypeError: Cannot read property 'apply' of undefined

toLower 的参数是一个数组,所以需要先用 map 调用一下它。

var dasherize = compose(join('-'), map(toLower), split(' '), replace(/\s{2,}/ig, ' '));

dasherize('The world is a vampire');

// 'the-world-is-a-vampire'

5.范畴学

范畴学(category theory)是数学中的一个抽象分支,能够形式化诸如集合论(set theory)、类型论(type theory)、群论(group theory)以及逻辑学(logic)等数学分支中的一些概念。范畴学主要处理对象(object)、态射(morphism)和变化式(transformation),而这些概念跟编程的联系非常紧密。下图是一些相同的概念分别在不同理论下的形式:

在范畴学中,有一个概念叫做...范畴。有着以下这些组件的搜集,就构成了一个范畴:

  • 对象的搜集;
  • 态射的搜集;
  • 态射的组合;
  • identity 这个独特的态射;

范畴学抽象到足以模拟任何事物,不过目前我们最关心的还是类型函数,所以让我们把范畴学运用到它们身上看看。

5.1 对象的搜集

对象就是数据类型,例如 String、Boolean、Number 和 Object 等等。通常我们把数据类型视作所有可能的值的一个集合(set)。像 Boolean 就可以看作是 [true, false] 的集合,Number 可以是所有实数的一个集合。把类型当作集合对待是有好处的,因为我们可以利用集合论(set theory)处理类型。

5.2 态射的搜集

态射是标准的、普通的纯函数。

5.3 态射的组合

这里有一个具体的例子:

var g = function(x) { return x.length; };
var f = function(x) { return x === 4; };
var isFourLetterWord = compose(f, g);

这张图展示了什么是组合:

image.png

image.png

5.4 identity 这个独特的态射

让我们介绍一个名为 id 的实用函数,这个函数接受随便什么输入然后原封不动地返回它:

var id = function(x) { return x; };

id 函数跟组合一起使用简直完美。下面这个特性对所有的一元函数(只接受一个参数的函数) f 都成立:

// identity
compose(id, f) == compose(f, id) == f;
// true

参考链接:函数范式(functional paradigm)