JavaScript 函数式编程(一)

前言

初学 JavaScript 时,班上学 Java 后台的同学都在问我,JavaScript 是不是也是一门面向对象语言,我回答“是”。

事实上,当时的我连面向对象是什么都不知道,只是因为网上铺天盖地的写着“JavaScript 是一门面向对象的语言”。学了好一段时间以后才知道,JavaScript 是基于原型(prototype-based)的多范式编程语言。什么意思呢,也就是说面向对象只是 JavaScript 支持的其中一种范式而已,而且这种面向对象是基于原型委托的机制,和传统的(如 Java)面向对象概念其实是有点差别的。由于 JavaScript 的函数是一等公民,所以自然它也就支持函数式编程范式。

函数式编程本身是一个很庞大的命题,本文只能抓住一些小点来展开介绍,不可能做到全覆盖,看完此文可能你只能对函数式有一个大概的印象,如果能因此受到一些编码风格上的启发,那就是本文价值所在。

编程范式

在了解函数式编程风格之前,我们可以先了解下都有哪些其他编程风格(编程范式)。

常见的编程范式有 3 种:命令式、面向对象及函数式。事实上还有第 4 种,逻辑式编程。

命令式

我们学的 C 语言,就是典型的标准的命令式语言。命令式语言顾名思义就是以一条条命令的方式编程,告诉计算机我需要先做这个任务,然后再做另一个任务。还有一些控制命令执行过程的流控制,比如我们熟悉的循环语句:

for (let i = 0; i < 10; i++) {
    console.log('命令', i);
}

当然还有分支语句、switch 等,都用来控制命令的执行过程。

面向对象

这恐怕是目前最常见的编程范式了,我们学的 Java 就是面向对象的。面向对象的思想则更接近于现实世界,封装好的对象之间通过消息互相传递信息,以这种熟悉的方式来建模显然要更容易一点。面向对象由一些我们熟悉的概念组成,比如封装、继承、多态等。而面向对象的思维主要是通过抽象成包含状态和一些方法的对象来解决问题,可以通过继承关系复用一些方法和行为。

函数式编程

函数式则更接近于数学,简单来说就是对表达式求值。跟面向对象有所不同的是函数式对问题的抽象方式是抽象成带有动作的函数。其思维更像我们小时候解应用题时需要套用各种公式来求解的感觉。函数式包含了很多概念,比如高阶函数、不可变性、惰性求值等。

函数式编程关注的是数据到数据的映射,而不是数据的处理过程。函数式推崇以函数作为抽象单元,用函数组合的方式解决问题,并将显性的状态改变缩减到最小。

逻辑式编程

可能这个名词我们听得比较少,我们经常在用却没有意识到的 SQL 的 query 语句就是逻辑式编程。所谓逻辑式,就是通过提问找到答案的编程方式。比如:

select lastname from someTable where sex='女' and firstname in ('艳艳', '仙女')

这里问了两个问题:

  1. 性别是女?
  2. 名字必须是“艳艳”或者“仙女”?

那么得到的答案就是符合问题描述的结果集了。

为什么要关注函数式编程

这个问题得从函数式思想与当下流行技术思想的契合度谈起。

我之所以关注到函数式编程,主要是来源于现在基于数据流思想的各种 MV* 框架,这些 MV* 框架都把前端的关注点从曾经的事件流转移到了数据流上,我们在使用这些框架的时候会发现,我们总是通过各种函数来处理这些数据,完成对数据的转化(相当于 view-model),最后在页面上呈现出来。

然后函数式的编程思维恰好就是以数据流为中心,通过组合函数来对数据加工,直到得出最终结果。而函数式编程应该是更优雅的,所以我就想到,能不能用函数式风格的编码来处理这些 MV* 框架的数据流呢

MV* 将数据作为关注核心的思想和函数式思想是一致的,这是学习原因之一。

其次,函数式编程只需要关注数据的输入输出,而不关注内部的具体实现,把具体实现都抽象成函数单元了。这种方式是模块化的。我们通过把行为抽象成函数的这个过程,把数据和逻辑处理进一步解耦了, 从维护长远性角度来看,这个好处是显而易见的。与模块化思想的契合,这是学习原因之二

最后,ES6 的一些新语法,例如箭头函数,事实上就是简化了过程专注了数据映射,我认为箭头函数的出现是带有函数式思想的。ES6 的编码风格再配上函数式编程思维是相得益彰的,代码会更加优雅,这是学习原因之三

事实上,我觉得现在函数式已经算不上是什么新知识,学习它并不是未雨绸缪,它就是当下应该学习并能学以致用的东西。MV* 的数据流、模块化、ES6,哪个不是现在正在用的?我们学习函数式编程,就是为了编写更加漂亮的代码,并不是为了未来某刻作什么准备。

以上的见解是我初学函数式编程的个人见解,不保证正确,网上并没有此问题的回答,但我是这么看待并使用它的。

函数式推崇的编码方式以及特点特性的举例

接下来就通过介绍函数式编程的一些特性、推崇的编码方式以及对应的例子(将借助underscorelodash工具库实现)来普及函数式编程风格到底是怎样的,你只能通过这些片段,感性认识到函数式编程。我会尽可能涵盖更多的特性,但函数式编程绝对还有更多的特性没有被我列出,这需要靠我们自己通过书本系统地学习。

以函数为抽象单元

函数式的其中一个思想就是,编码时多使用函数隐藏一些实现细节,而这种函数我们习惯称为抽象方法。什么意思?

这里假设一个场景,我们要实现一个解析年龄的函数,里面需要包含一些错误和警告的报告,我们通常会写成下面这样:

function parseAge(age) {
    if ( !_.isString(age) ) {
        throw new Error('Expecting a string');
    }
    var a ;
    console.log('Attempting to parse an age');

    a = parseInt(age, 10);
    if ( _.isNaN(a) ) {
        console.log( ["Could not parse age:", age].join('  ') );
        a = 0;
    }

    return a;
}

我们可以用如下方法来调用parseAge

parseAge('42');
// (console) Attempting to parse an age
// => 42

parse(42);
// Error: Expecting a string

parse('wyy');
// (console) Attempting to parse an age
// (console) Could not parse age: wyy
// => 0 

parseAge函数工作正常,但是我们可以仔细想想我们编写parseAge的初衷,我们仅仅是为了按照要求解析一个年龄字符串,然后返回数字化的年龄,这是我们需要的。而中间的一些报错处理其实并不是这个函数应该实现的细节,对吗?

并且,显而易见的是,这些throw Errorconsole.log只是我们抛错和警告的一种呈现方式,这种模块的呈现方式一般来说整个站点都是通用的,就是说,其他地方也会需要抛错和警告。那如果现在,我们要修改这些输出错误、输出信息和警告呈现的方式,那么我们是不是要修改每个对应的代码行?

实际上,无论是从编码风格还是从编码逻辑上来看parseAge函数,我们都应该将数据处理和数据处理以外的行为(这里是“输出信息”)解耦开来。

所以,一个比较好的方法是,将这些错误、信息和警告的概念抽象成不同的函数:

function fail(msg) {
    throw new Error(msg);
}

function warn(msg) {
    console.log( ['WARNING:', msg].join(' ') );
}

function note(msg) {
    console.log( ['NOTE:', msg].join(' ') );
}

有了这些抽象函数,我们就可以将parseAge函数改写成:

function parseAge(age) {
    if ( !_.isString(age) ) {
        fail('Expecting a string');
    }
    var a ;
    note('Attempting to parse an age');

    a = parseInt(age, 10);
    if ( _.isNaN(a) ) {
        warn( ["Could not parse age:", age].join('  ') );
        a = 0;
    }

    return a;
}

两个parseAge的行为差别不大,不同的是数据逻辑以外的这些报告都已经被我们抽象化了。我们得到的好处是,我们可以随时改写这些行为:

function warn(msg) {
    alert("That doesn't look like a valid age");
}

这一点相信大家日常开发都会这样做,但函数式在这个方面的关注点是:把一些无关的处理逻辑抽象化为函数。当然,只能说上面的代码相比之前的实现变得更函数式了点,而还不足以被称为函数式编程。

以函数为行为单位

这个点还是隐藏实现细节的思想,只是为了能用多种函数式技术来提供并促进创建抽象的方式。

先来一个简单的例子,我们要实现一个函数抽象数组索引,然后索引不合法或不存在的时候就做错误警告处理:

function nth(arr, index) {
    // 一些错误判断处理...
    // 例如下标非法、下标不存在等
    // 这里省略

    return arr[index];
}

整个框架是这样的,我们可以这样调用nth函数:

var letters = ['a', 'b', 'c'];
nth(letters, 1); // => 'b'

那如果我们现在想实现一个功能,就是读取所有数组的第二项,我们平常的做法可能是:

nth(letters1, 1);
nth(letters2, 1);
nth(letters3, 1);
...
nth(letters10, 1);

但函数式推崇,将显性的状态改变缩减到最小来变得更加模块化。所以可以进一步封装隐藏,构建一个second抽象函数:

function second(arr) {
    return nth(arr, 1);
}

second(['a', 'b']); // => 'b'
second('fogus'); // => 'o'

当然,这个例子仅仅是举例,并没有什么明显优势。下面举一个更有启发性的场景。

实现排序函数的比较器。比较器是一个函数,它接受两个参数:

JavaScript 的数组提供一个默认的sort方法,似乎可以利用数字本身的性质来实现排序:

[2, 3, -6, 0, -108, 42].sort();
// => [-108, -6, 0, 2, 3, 42]

但事实上:

[0, -1, -2].sort();
// => [-1, -2, 0]

[2, 3, -6, 0, -108, 42, -1].sort();
// => [-1, -108, -6, 0, 2, 3, 42]

问题在于,在没有给定参数的情况下,Array#sort方法执行字符串的比较。所以,我们需要传入一个比较器,写成:

[-1, 2, 3, -6, 0, -108, 42].sort(function(x,y) {
    if ( x < y ) return -1;
    if ( y < x ) return 1;
    return 0;
});

// => [-1, -108, -6, 0, 2, 3, 42]

现在看起来是正常的,但是有更为通用的方法。毕竟我们可能还要在其他的代码中用到这样的排序,所以把这个匿名函数抽出来,给它起一个名字,或许会更好一些:

function compareLessThanOrEqual(x, y) {
    if ( x < y ) return -1;
    if ( y < x ) return 1;
    return 0;
}

[-1, 2, 3, -6, 0, -108, 42].sort(compareLessThanOrEqual);
// => [-1, -108, -6, 0, 2, 3, 42]

但函数compareLessThanOrEqual的问题在于,它被耦合到了“比较器”这个概念中,你发现了吗?它并不容易被单独当作一个通用的比较器来用,例如:

if ( compareLessThanOrEqual(1,1) ) {
    console.log('less or equal');
}

// 不会输出任何东西

为了达到预期效果我们可能需要了解compareLessThanOrEqual的内部实现,就是针对它的返回值再来作判断:

if ( _.contains([0, -1], compareLessThanOrEqual(1,1)) ) {
    console.log('less or equal');
}

// => 'less or equal'

但这不让人满意,因为将来函数compareLessThanOrEqual的返回值可能被更改为其他值例如-20来代表比较结果。一个比较好的实现compareLessThanOrEqual函数的方式是:

function lessOrEqual(x, y) {
    return x <= y;
}

这种总是返回一个布尔值(只会返回truefalse)的函数被称为谓词函数

[-1, 2, 3, -6, 0, -108, 42].sort(lessOrEqual);
// => [42, 3, 2, 0, -6, -108, -1]

组合函数

函数式编程关注的是数据的映射,而不关注解决问题的步骤。函数式编程推崇通过函数组合的方式,隐藏更多的实现细节,达到数据映射的方式。

函数lessOrEqual事实上并不是一个精心设计的比较器,它只是对<=操作符的一个简单包装,通过它我们并不能选择数组的升序或降序。如果sort函数需要一个比较器,并且lessOrEqual函数只会返回truefalse,那么我们需要通过某种方式在不重复一堆if/then/else模板的情况下,从后者布尔的世界转换到前者数值的世界。

解决方案是创建一个comparator函数,它接受一个谓词,并将其结果转化成comparator函数所期待的-1/0/1

function comparator(pred) {
    return function(x, y) {
        if ( pred(x, y) ) return -1;
        else if ( pred(y, x) ) return 1;
        else return 0;
    }
}

现在,我们可以用comparator函数来返回一个能够将谓词lessOrEqual的结果(truefalse)映射成比较器所期待的结果(-1,01)的新函数了。

在函数式编程中,我们经常会看到这类用于将一种类型数据转换成另一种类型数据的函数。我们来看看comparator的用法:

[-1, 2, 3, -6, 0, -108, 42].sort(comparator(lessOrEqual));
// => [-1, -108, -6, 0, 2, 3, 42]

comparator函数可以将任何返回“真”或“假”的函数映射到“比较器”的概念上来。好处是lessOrEqual在这个函数组合中只负责提供谓词,我们可以将其替换成其他条件的谓词,就可以很方便通过组合的方式实现出想要的效果。这种编码风格比一开始时使用的方法更加模块化,可重用性也更高。

举一个更复杂的例子,实现一个函数executeIfHasField,传入一个对象和对象属性/方法名,如果后者存在于前者中,则返回相应的结果。

实现过程:我们需要先判断target[name]是否存在,存在时返回对应属性值或返回执行对应方法后的结果。下面是函数式的实现方式:

我们需要先定义两个函数existytruthy

function existy(x) { return x != null };

existy(null); //=> false
existy(undefined); //=> false
existy({}.notHere); //=> false
existy(0); //=> true

使用existy函数简化了 JavaScript 中对象是否存在的判断。至少,它将存在性检查并设置成了一个简单易用的函数。上面说到的第二个函数truthy定义如下:

function truthy(x) { return (x !== false) && existy(x) };

函数truthy用来判断一个对象是否应该被认为是true的同义词,它的使用方法如下:

truthy(false); //=> false
truthy(undefined); //=> false
truthy(0); //=> true
truthy(''); //=> true

在 JavaScript 中,有时只有存在某个条件为真的情况下执行某些操作,否则返回类似undefinednull的值。一般命令式编程的模式如下所示:

if (condition) {
    return _.isFunction(doSomething) ? doSomething() : doSomething;
} else {
    return undefined;
}

使用truthy函数,我们可以将逻辑通过以下方式封装起来:

function doWhen(cond, action) {
    return truthy(cond) ? action() : undefined;
}

最后,executeIfHasField实现如下:

function executeIfHasField(target, name) {
    reutrn doWhen(target[name], () => _.result(target, name));
}

函数executeIfHasField的成功执行和出错的情况:

executeIfHasField([1,2,3], 'reverse');
//=> [3, 2, 1]

executeIfHasField({foo: 42}, 'foo');
//=> 42

executeIfHasField([1,2,3], 'notHere');
//=> undefined

当然,这也没什么大不了的,但是我们可以在其他地方也使用doWhen来代替if-else块进行编码,这更加优雅,函数式理念正是来自于它们的使用。以上的代码就是函数式编程:

函数式的执行效率

看到这里可能你就有疑问了,这函数式的代码的执行效率看起来太低了,不是吗?

是,当然会低,好比实现一个功能用循环肯定比用递归快点,因为前者的函数调用开销更小。但问题关键是,这个优化是不是性能痛点?上面的函数式代码造成的效率差绝不会超过 0.1ms,讨论这类性能问题就相当于讨论应该使用a++还是++a是一样的。很多时候我们太急于考虑运算速度,甚至实在写出一段代码之前就开始考虑运算速度。

我举个例子,一个网页加载时间,花费了 1450ms 用来加载文档与资源,用了 50ms 来执行脚本。这个时候,即使你付出了巨大的成本把脚本执行效率提高 80%,网页加载也只能快 40ms。但如果这时候稍微去做一些简单的资源合并压缩的行为,将 loading 时间缩短 20%,这时网页加载时间就能减少 290ms!

所以说,很多时候,我们在意的这些性能细节并不是提升网页体验的关键点,这根本不是性能瓶颈所在,甚至这些细节影响的速度根本就不会被用户所察觉。换句话说,很多时候我们的系统并不需要考虑这点速度。而函数式换来的好处是巨大的,编码优雅、模块化、可维护性,那为什么不用呢?

对我个人而言,编程风格的第一条规则应该是:写漂亮的代码。

高阶函数

函数式编程应该是促进创造和使用函数的。前面说过,JavaScript 中函数是一等公民,所以其天生就是一门函数式语言。

那么,“一等公民”是什么意思呢?

“一等”这个属于通常用来描述值。当函数被看作“一等公民”时,那它就可以去任何值可以去的地方,很少有限制。比如数字在 JavaScript 里就是一等公民,同样作为一等公民的函数就会拥有类似数字的性质。

var fortytwo = function () { return 42 };
var fortytwos = [42, function() { return 42 }];
42 + (function { return 42 })(); //=> 84
function weirdAdd(n, f) { return n + f() };

weirdAdd(42, function() { return 42 });
return 42;
return function() { return 42 };

最后两点,其实就高阶函数函数的定义。一个高阶函数应该可以执行下列至少一项操作。

我们上面实现过的comparatordoWhenexecuteIfHasField其实都属于高阶函数。在函数式编程中,我们应该多使用函数,而不是值来作参数。

关于传递函数的思考

为什么使用函数而不是值,举个例子,很多编程语言甚至是一些核心库,都包括一个称为max的函数,用来找到一个列表或数组中的最大值(通常是一个数字)。underscore的函数如下:

_.max([1, 2, 3, 4, 5]); //=> 5

执行结果并没有什么奇怪,但是这个特定的用例存在一个限制。就是说,如果我们想从一个不是数字数组的对象中找到最大值,该怎么办?值得庆幸的是,_.max是一个高阶函数,它接受一个可选的第二个参数,用来从被比较的对象中获得一个数值的函数。例如:

var people = [{name: 'Fred', age: 65}, {name: 'Lucy', age: 36}];
_.max(people, (p) => p.age );
//=> { name: 'Fred', age: 65 }

关于传递函数的更多思考

_.max的例子其实算不上函数式。现在,再举个例子,让我们从一个很简单的repeat函数开始。它以一个数字和一个值为参数,将该值进行多次复制,并放入一个数组当中:

function repeat(times, VALUE) {
    return _.map(_.range(times), function() { return VALUE; });
}

repeat(4, 'Major');
//=> ["Major", "Major", "Major", "Major"]

repeat的实现使用_.map函数来遍历从0times-1的数组,并将VALUE丢到数组中。你会发现,里面的匿名函数使用了VALUE变量。但是,还是前面那个观点“使用函数,而不是值”

repeat的实现仍然有提高的空间。我的想法是,一个函数将一个值重复多次是可以的,但将运算重复多次是不是会更好?。略微修改一下repeat,按如下方式来执行:

function repeatedly(times, fun) {
    return _. map(_.range(times), fun);
}

repeatedly(3, () => Math.floor((Math.random() * 10)+1));
//=> [1, 3, 8]

函数repeatedly是展示函数式思维方式力量的一个很好的例证。通过将参数从值替换为函数,给重复函数打开了一个充满可能性的世界。与repeat类似,在调用端,我们可以用一个可以填充任何东西的数组来替换一个固定的值。如果我们真的想在repeatedly中用常量,那么我们只需要执行以下操作:

repeatedly(3, () => "Odelay!");
//=> ["Odelay!", "Odelay!", "Odelay!"]

事实上,由于repeatedly的实现是对_.range结果进行_.map应用到函数上,因此函数可以接受到当前重复的次数。这种技术在用来生成一些已知数量的DOM节点时非常有用,其中每个节点都用带计数值的id,如下所示:

repeatedly(3, function(n) {
    var id = 'id' + n;
    $('body').append($('<p>Odelay!</p>').attr('id', id));
    return id;
});

//=> ["id0", "id1", "id2"]

我使用了 jQuery 来添加一些节点。这是完全合法的使用方式,但它对函数之外的“世界”进行了修改,这个不是函数式推崇的。这个会在下一篇函数式相关的文章中再作介绍。

总结

上文提及的函数式编程的一些特性都是函数式中比较重要与普适的思想,下面总结一下提及的特性:

概括地说,“函数式编程”包括以下技术:

但是,仅仅只是构建函数是不够的,数据抽象也是函数式编程很重要的一块,这里不作介绍,读者自行了解。

以上就是对 JavaScript 函数式编程部分特性的介绍,未介绍到的特性和应用其实还有很多,像作用域、闭包、纯度、不变性、组合子、柯里化等等概念在函数式编程上的应用,后续会再写博客补上。本节只以对比的方式介绍函数式编程思维,并不全面但足以此扩宽编码时看待问题的角度。

函数式编程的思想其实很容易掌握,而真正编写出函数式的代码就不那么容易。我们学习函数式编程并不是为了抛弃命令式或者面向对象去编写代码,而是,我们旨在就其本身来讨论函数式编程,了解函数式编程是什么以及确定它是否适合你的需求。

参考书籍: