《JavaScript函数式编程思想》——从面向对象到函数式编程

版权声明:原创内容,转载须注明出处。 https://blog.csdn.net/starrow/article/details/87347165

第9章  从面向对象到函数式编程

假如本书的写作时间倒退回十年前,书名可能会变成JavaScript面向对象编程思想。自上世纪90年代兴起的面向对象编程思想随Java的繁荣达于顶点,在JavaScript从一门只被用来编写零星的简单的表单验证代码的玩具语言变成日益流行的Web应用不可取代的开发语言的过程中,脚本的作者们也逐渐学习和习惯了被视为软件开发正统的面向对象编程。等到盛极而衰,面向对象编程的缺点开始浮现和被广泛讨论,Java的热度和市场份额不及以往,而对函数式编程的兴趣和关注从学术圈子扩散至商业软件开发领域,Clojure、Erlang、Scala等新的函数式编程语言被发明和获得市场接纳,Lambda表达式成为编程语言中的热词和老牌语言竞相增添的功能,JavaScript程序员也开始认识到函数式编程的优点。幸运的是,函数式编程所需的基本能力JavaScript在诞生之初就具备了,在迄今二十多年的历史中,JavaScript发扬光大的是事件编程和在面向对象编程世界里长久被忽略的基于原型的对象模型,现在它作为最流行和有活力的编程语言之一,正在逐渐发掘和转向自身混合基因中的另一元素。虽然以函数式编程语言的标准来看,JavaScript相比其他专为此设计的语言有不少不足和缺陷,但是它的普及度还是让它成为函数式编程的有力推动者甚至有希望是这种范式应用最广泛的语言。

从现实来看,巨大的惯性、既有的代码、程序员的知识和思维习惯使得面向对象编程依然是JavaScript开发的主流。此外,自ECMAScript 6原来的诸次语言扩展和改进主要是增强面向对象编程能力的。所以提倡JavaScript的函数式编程,不可避免的问题或者说最有力的方法就是将之与面向对象编程做比较,解释前者的优点,说明后者的概念和做法如何能用前者取代。

9.1  面向对象编程的特点
9.1.1  封装性
9.1.2  继承性
9.1.3  多态性
9.2  JavaScript面向对象编程
9.2.1  创建和修改单个对象
9.2.2  克隆和复制属性
9.2.3  原型
9.2.4  建构函数
9.2.5  建构函数和类型继承
9.2.6  原型和类型继承
9.2.7  Proxy和对象继承
9.2.8  Mixin
9.2.9  工厂函数

9.3  函数式编程的视角

在上一节里,我们介绍了JavaScript面向对象编程的种种模式和风格,充分体现了JavaScript的灵活性。程序员可以选择自己喜欢和习惯的编程模式,造就了JavaScript代码五彩纷呈的风格。然而从函数式编程的视角来看,这些编程模式和技巧就有了好坏之分,有些在函数式编程中还有用武之地,大部分则被理念不同的函数式编程的模式替代。

9.3.1  不可变的对象

面向对象编程的出发点是将数据和处理它们的函数封装成对象,以对象为视角和工具来对实际问题进行建模,代码的复用和开发都是以对象为单位。函数式编程以函数为中心,数据和函数保持分离,大量使用部分应用和复合的技术。至于依赖递归与强调纯函数和不变性等,则不是函数式编程与命令式编程的分野,在面向对象编程中也可以采纳。上一节介绍的各种模式中的对象都可以被改造成不可变的对象,以避免6.3.5小节分析的可变类型可能导致的副作用。以工厂函数为例,下面的版本创建的就是不可变的对象。

const immutableCounter = (value = 0) => {
    const current = () => value;

    const increment = () => immutableCounter(value + 1);

    return {current, increment};
};

const c1 = immutableCounter();
const c2 = c1.increment();
f.log(c1.increment().current());
//=> 1

f.log(c1 === c2);
//=> false

因为reset方法不再修改当前对象,而是返回一个全新的计数器实例,与直接调用工厂函数毫无差别,所以被删除了。对于极简代码的爱好者,上述版本还可以进一步简化。

const immutableCounter2 = (value = 0) => ({
    current() {
        return value;
    }, increment() {
        return immutableCounter2(value + 1);
    }
});

 注意在箭头函数表达式的箭头后面必须加上一对圆括号,以使JavaScript将返回的对象当作一个值来计算,否则对象字面值起始和末尾处的大括号会被解释成包围函数主体的代码。进一步地,共享方法的工厂函数也可以如法炮制。

const immutableCounter3 = (function () {
    const methods = {
        current() {
            return this._val;
        },
        increment() {
            return counter(this._val + 1);
        }
    };

    function counter(value = 0) {
        return Object.assign({_val: value}, methods);
    }

    return counter;
})();

const c3 = immutableCounter3();
const c4 = c3.increment();
f.log(c3.increment().current());
//=> 1

f.log(c3 === c4);
//=> false

将建构函数和Mixin和其他面向对象编程的模式改造成使用不可变的对象,就留给有兴趣的读者做练习,对于开发智力、开拓思路、辨析JavaScript的特性和掌握其编程技能都大有裨益。

9.3.2  评判面向对象编程

以函数式编程的标准,面向对象编程的第一个问题是其中的函数不是一等值。不少专为面向对象编程设计的语言无法享受到函数是一等值的好处,也就缺乏进行函数式编程的基础。JavaScript没有这个问题,对象的方法可以作为参数传递,可以被返回,可以被赋予变量。但是方法的调用对象通过this关键字传递,而当方法像函数一样作为一等值运用时,this绑定的值往往不正确。面向对象编程的第二个问题是,方法所属的对象作为特殊的参数,不像其他函数参数那样传递,因此方法难以像函数那样采用部分应用和复合的技术。所以函数式编程的做法是保持数据和函数的分离,即使出于方便组织代码和传递数据的需要将数据和函数置于一个容器内,函数也和容器没有任何绑定关系,也就是不依赖this,函数所需的变量数据全部以参数方式传入。函数式编程中所说的对象便是指这样的复合数据或容器。从这一点出发,再加上对纯函数的要求,我们来评判和改造9.2节介绍的各种模式和技术。

用字面值创建对象没有副作用,修改对象的属性则有。所以前者符合函数式编程的精神,后者最好限制于函数内的变量,不要对参数使用。

克隆操作没有副作用,因为JavaScript的内置的对象是可变的,所以克隆在避免函数的副作用时十分有用。将一个对象的属性复制到另一个对象,在最一般的情况下,涉及到源对象、目标对象和待复制的属性名称,最直接的想法是用一个函数完成。函数签名为copy(source, dest, names),目标对象可以是现有对象或缺省(新创建的对象),属性名称可以是字符串数组或缺省(复制所有的属性),因此该函数一共可以处理四种情况。另一种方案是创建两个较简单的函数,pick(source, names)和assign(object, source)。前者摘取一个对象的若干属性,组成一个新对象;后者将一个对象的所有自身属性复制给另一个对象。组合使用这两个函数,可以实现第一个函数的所有功能,而且调用时更方便灵活。新的API还有一个问题,就是会改动参数中的目标对象。更符合函数式编程风格的API应该是用合并两个对象的merge函数来取代assign函数。最后,我们再按照函数式编程的习惯调整pick函数的参数顺序,就得到了从函数式编程的角度解决复制对象属性问题的两个函数。

export function pick(names, source) {
    let ret = {};
    for (let n of names) {
        set(n, get(n, source), ret);
    }
    return ret;
}

export function merge(o1, o2) {
    // 在支持对象散布属性的JavaScript引擎中,可以使用下列简洁的语句。
    // return {...o1, ...o2};
    let obj = {...o1};
    Object.assign(obj, o2);
    return obj;
}

原型是JavaScript的内置的继承机制,它的问题是只能进行单一继承,单一继承的缺点在9.2.8小节已经分析过了,所以即使要采用面向对象编程,9.2.5小节、9.2.7小节和9.2.9小节介绍的多重继承与9.2.8小节介绍的Mixin都是更好的替代方案。

建构函数是JavaScript的为了方便创建对象设立的语法,它必须配合new操作符使用,与普通函数调用方式的差别不仅不方便函数式编程,还有可能引发错误(程序员因为不了解或不小心将建构函数当作普通函数调用,导致全局对象的属性被修改。)。建构函数的自动行为也限制了代码功能的可能性和灵活性,所以函数式编程采用没有以上缺点的工厂函数来创建对象。

多重继承和Mixin同样强大,不过后者的实现更简单,应用时也更为灵活,既可以针对单个对象,也可以结合工厂函数和原型对一个类型的对象生效,所以更为可取。于是我们得出在JavaScript面向对象编程的阵营中,Mixin技术是最为灵活和强大的。再看Mixin的理念,数据和处理它们的函数分开定义,只是在使用时才结合成对象,这距离函数式编程的做法只有一步之遥。如果我们不做最后一步的结合,并且不再通过this来传递函数操作的对象,Mixin就转化成了函数式编程的代码。

//函数式编程中的模块相当于Mixin。
const module1 = {
    current: (obj) => obj.value,
    increment: (obj) =>
        f.set('value', f.inc(obj.value), obj)
};

const module2 = {
    increment: (obj) =>
        f.set('value', f.add(obj.step, obj.value), obj),

    set: f.curry((val, obj) =>
        f.set('value', val, obj))

};

let o = {name: 'Peter', value: 1, step: 2};
f.log(module1.current(module2.set(4, o)));
//=> 4

//结合工厂函数。
function counter(val, step) {
    return {
        value: val,
        step: step
    };
}

let c = counter(1, 2);
const fn = f.pipe(module2.set(3), module2.increment, module1.current);
f.log(fn(c));
//=> 5

回顾本章至此的三节,JavaScript中的面向对象编程不可谓不灵活,但是采用种种或繁琐或精巧的技术所实现的功能,函数式编程都能用更简洁明了的方式做到。反过来函数式编程中对抽象算法和复用代码有巨大帮助的高阶函数和复合等技术,面向对象编程却没有对应的技术。虽然纯函数和不可变的数据对任何一种编程范式都有好处,但函数式编程对此原则性的要求最能鼓励程序员遵循这些思想。从面向对象转换到函数式编程,既是编程技术的转换,更是理念和思考方法的转换,它给程序员的回报是更少的、更易理解和维护的、更优美的代码。

9.4  方法链和复合函数
9.4.1  方法链
9.4.2  延迟的方法链
9.4.3  复合函数
9.4.3  函数式的SQL
9.5  小结

更多内容,请参看拙著:

《JavaScript函数式编程思想》(京东)

《JavaScript函数式编程思想》(当当)

《JavaScript函数式编程思想》(亚马逊)

《JavaScript函数式编程思想》(天猫)

没有更多推荐了,返回首页