函数式编程

什么是函数式编程

"函数式编程"是一种编程范式(programming paradigm),就如我们熟知的"面向对象编程"与“面向过程编程”,也就是如何编写程序的方法论。至于为什么要学习函数式编程还不是自己找不到对象嘛!(大雾)。

函数(Function)与方法(Method)

在这里需要提一下函数与方法的区别,区别概念性的东西,只是为了防止有些童鞋混淆函数与方法的指代。其实没啥区分,方法其实也是函数,只不过是比较特殊,方法是附属在对象上的函数。

纯函数(Pure Function)

什么是纯函数

在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:

  • 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关。
  • 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。

via: Wikipiedia

下面的 double 函数就是一个纯函数:

let double = (value) => value * 2;

为了加深对纯函数的理解我们下面举几个不是纯函数的反例:

  1. let add = (x) => x + a;:因为依赖的外部变量,所以其不是纯函数。
  2. random():因为其输出有变化非相同的值,所以其不是一个纯函数。
  3. printf():产生了 I/0 输出操作,产生了副作用

纯函数的好处

  1. 可测试:因为纯函数不依赖外部变量,所以对其编写测试用例特别方便而不用考虑外部变量情况,只需专注于输入输出的情况。
  2. 可缓存:因为相同的输入值总是对应相同的输出值,所以可以将之前的确定值缓存起来,再次遇见相同的输入可快速从缓存中读取。
  3. 无副作用:同纯函数不会改变外部变量,所以无需担心其执行后对外部变量产生影响。特别是多线程的情况下,可以安全的并行执行。

高阶函数(Higher-Order Function/HOF)

说到高阶函数,首先理解一下为什么在 JavaScript 中函数是一等公民这个概念,这有助于我们理解高阶函数。

函数是一等公民(First-Class Citizens)

在 JavaScript 中函数是一等公民的存在,也是相较于一些传统语言(C / C++ / Java / C# 等)而言:在这些语言中函数只是二等公民,你只能用语言的关键字声明一个函数然后调用它,如果需要把函数作为参数传给另一个函数,或是赋值给一个本地变量,又或是作为返回值,就需要通过函数指针(function pointer)、代理(delegate)等特殊的方式周折一番。

在 JavaScript 中不仅拥有一切传统函数的使用方式(声明和调用),而且可以做到像简单值一样赋值、传参、返回,拥有这样能力也被称为第一公民。

OK,我们再将注意力转向高阶函数,正因为 JavaScript 中函数是一等公民,所以函数能够实现以下用法,而符合以下条件函数的也被称为高阶函数:

  1. 函数可以作为参数被传递
  2. 函数可以作为返回值输出

多说无益,看看具体的例子:

// 将函数作为返回值输出
let returnFunc = (() => () => '将函数作为返回值输出')();
returnFunc(); // 将函数作为返回值输出

// 将函数最为参数传递
let argFunction = () => '将函数作为参数传递';
let exec = (func) => typeof func === 'function' && func();
exec(argFunction); // 将函数作为参数传递

利用高阶函数进行抽象

我们已经了解了如何创建并执行高阶函数了,一般而言,高阶函数通常用于抽象通用问题,换句话说,高阶函数就是用来定义抽象的。光看这一结论,其实并不助于我们理解高阶函数的好用之处。

Array 常用方法中的函数式编程

大家熟知的 Array 好多常用方法,都利用了高阶函数的特性,我们通过自己来实现其中的一些方法,来加深对高阶函数的理解。

ForEach

比如我们常用的遍历数组,用面向过程的方式需要写一个 for 循环,大家基本在日常开发中经常使用也很熟悉,故不再赘述。

let array = ['this', 'is', 'a', 'array'];
for (let i = 0, len = array.length; i < len; i++) {
    // do something
}

现在我们进一步去思考遍历这个操作的目的:通常情况下遍历一个数组,我们关心只不过是该数组的每个元素及其下标索引值、至多还有这个数组本身,获取这些数据进而去执行一些操作,至于遍历的过程本身是不 care 的。并且遍历数组方式基本是一致的,本着 DRY(Don't repeat yourself) 原则,数组遍历这一操作可抽象成一个 forEach 函数 。

我们的最终目的是拿到所需数据之后进行一系列操作,这些操作各不相同,需要 forEach 函数的使用者去定义这些操作。这些操作也可以封装成函数,而函数可以通过参数的形式传递给 forEach 函数。将该函数通过参数传递给 forEach 后,交由其在执行遍历的时候去执行该函数,而该函数执行所需要的相应数据可通过参数传递的方式提供。这样一个灵活巧妙可复用的 forEach 函数就诞生了。

let forEach = (arr, fn) => {
    for (var i = 0, len = arr.length; i < len; i++) {
        typeof fn === 'function' && fn(arr[i], i, arr);
    }
}

let array = [1, 2, 3, 4];
forEach(array, (item) => console.log(item));// 1 2 3 4
Map

Array中的 map 方法与 forEach 函数非常类似,只不过前者返回了一个包含有捕获相应操作结果的新数组。

let map = (arr, fn) => {
    var res = []; 
    forEach(arr, (item, index, arr) => {
        typeof fn === 'function' && res.push(fn(item, index, arr));
    });
    return res;
}

let array = [1, 2, 3, 4];
console.log(map(array, item => item * 2));// [2, 4, 6, 8]
Filter

Arrayfilter 方法可用于筛选数组中符合筛选条件的选项,并将这些返回。我们已经拥有一个 forEach 工具函数,借助于该函数,我们可以构建出我们自己的 filter 函数。

let filter = (arr, fn)=> {
    let res = [];
    forEach(arr, (item, index) => {
        if (typeof fn === 'function' && fn(item, idnex, arr)) {
            res.push(item);
        }
    });
    return res;
}

let array = [1, 2, 3, 4];
console.log(filter(array, item => item >= 4)); // [4]
Reduce

reduce 函数是一个很美妙的函数,其不光使用了高阶函数,其还借助了闭包的能力,至于该能力是啥,下文说到会提到。

const reduce = (arr, fn, initVal) => {
    if(arr.length === 0) {
        return ;
    }
    let accumlator;
    accumlator = initVal !== undefined ? initVal : arr[0];
    
    forEach(arr, (val, index, arr) => {
        if (initVal === undefined && index >= 1) {
            accumlator = fn(accumlator, val, index, arr);
        } else {
            accumlator = fn(accumlator, val, index, arr);
        }
    });
    return accumlator;
}

let array = [1, 2, 3, 4];
console.log(reduce(array, (a, b) => a + b, 10));// 20

闭包

说到 JavaScript 哪能避得开闭包,作为面试的高频考察点,这里就不赘述什么是闭包了。闭包有个很重要的特性之一就是,将变量保存在内存中。

once 函数是一个小巧的函数,用于确保我们的目标函数只能被执行一次,其中标记函数是否被执行状态的 done 变量被一个匿名函数所引用,且该匿名函数也被外部的变量所引用,done 状态会被保持在内存中,所以其能记住函数执行的状态,进而控制目标函数能够被多次执行。

let once = (fn) => {
    let done = false;
    return (...args) => done ? undefined : ((done = true), fn.apply(this, args));
}

let times = 0;;
let fire = () => `I am fired ${++times} times`;
let justDoOnce = once(fire);

fire(); // I am fired 1 times
fire(); // undefined
fire(); // undefined
带记忆的斐波那契函数

请问如何实现一个斐波那契函数,是一个老掉牙的面试考点,相信大家已经熟练背住掌握了其用法。

const fibonacci = ( n ) => {
    return n < 2 ? 1 : fibonacci( n - 1 ) + fibonacci( n - 2 );
}

不过这种实现方式不够高效,我们已知上面的 fibonacci 函数式一个纯函数,其特定输入产生的值也是固定的,所以何不缓存已求过的值呢?

const fibonacci = (() => {
    let cache = {};
    return (n) => {
        if (cache[n]) {
            return cache[n];
        }
        
        if (n < 2) {
            cache[n] = 1;
            return n;
        }
        
        cache[n] = fibonacci(n - 1) + fibonacci(n - 2);
        return cache[n];
    };
})();

函数式编程应用

柯里化

在很多函数式编程的教程中都提到了柯里化,那么柯里化是啥,先给出定义:

柯里化是吧一个多参数函数转换为一个嵌套的一元函数的过程。

如果看了定义还是觉得难以理解,那么请继续往下看,我们来一步步来理解上述定义。

一元函数(Unary Function)

只接受一个参数的函数叫一元函数

const identity = (x) => x;

二元函数

依照一元函数的定义类推,接受两个个参数的函数叫一元函数

const add = (x, y) => x + y;

变参函数

再次类推由普通到特殊:变参函数则是接受可变参数数量参数的函数,在 ES5 之前我们可以通过 arguments 来获得可变数量的参数。依照这个特性我们实现函数的重载。

function variadic(args) {
    console.log(arguments);
}

function add() {
    var sum =0,
    len = arguments.length;
    for(var i=0; i<len; i++){
        sum += arguments[i];
    }
    return sum;
}

add();// 0
add(1);// 1
add(1,2,3,4);// 10

在 ES6 中我们有了一个新的运算符:拓展运算符,可以获得同样的使用效果。

let variadic = (...args) => {
    console.log(args);
}

现在我们将目光继续转回到柯里化函数,我们先来实现一个简单点的。我们先把一个三元函数转换为嵌套的一元函数,比如这样的求和函数,只需疯狂 return 3 次,转换后的函数可读性更强了。

var add = (a, b, c) => a + b +c;
add(1, 2, 3); // 6
var add = (a) => (b) => (c) => a + b + c;
add(1)(2)(3); // 6

来,我们进一步把这个过程抽象一下:

var add = (a, b, c) => a + b +c;
var simpleCurry = (fn) => (a) => (b) => (c) => fn(a, b, c);
var addCurry = simpleCurry(add);
addCurry(1)(2)(3);// 6

假如是不定参数的函数呢怎么转换,鉴于本文也属于教你三步学会一个 XXX系列,想必聪明的你已经知道了答案。

ley curry = function (fn) {
    var _args = [];
    return function () {
        if (arguments.length === 0) {
            return fn.apply(this, _args);
        }
        Array.prototype.push.apply(_args, [].slice.call(arguments));
        return arguments.callee;
    }
};

管道与组合

管道对于熟悉 unix、类 unix 的用户来说,不会陌生。 符号 | 被称为管道符号,| 将最左侧函数输出作为输入发送给右侧的的函数,这样的处理过程从技术上将称为管道。

如下命令就是用于统计给定文本在文本文件 text.txt 中的数量。管道符号将一个个基础函数组合起来称为一个新函数,这就要求每一个基础函数都需要接受一个参数并返回数据。

cat test.txt | grep 'world' | wc

接下来我们先实现一个简单的 compose 函数,在该函数中,接受一个参数 c,然后会从由向左执行,先执行函数 b 再将其执行结果过作为参数交由函数 a 去执行。

let compose = (a, b) => (c) => a(b(c));

let number = compose(Math.round, parseFloat);
number('3.145');//3

组合多个函数

let compose = (...fns) => {
    (value) => reduce(fns.reverse(), (acc, fn) => fn(acc), value);
}

let number = compose(Math.round, parseFloat);
number('3.145');//3

管道

大家也注意到了,管道是从右向左执行的,上文中管道的执行方向则是与之相反,所以我们只要小小改变一下传入参数顺序即可。

let pipe = (...fns) => {
    (value) => reduce(fns, (acc, fn) => fn(acc), value);
}

偏应用

函子(functor)

发表评论

电子邮件地址不会被公开。 必填项已用*标注