14 Dec 2015

JavaScript 函数式编程

这篇文章将介绍 JavaScript 的函数式编程的理论. 其中有属于语言内置的内容, 其他均为额外实现, 但是所有内容都是类似于 Haskell 的很通用的”纯函数式语言”. 首先, 我想先说明一下”纯函数式语言”的含义. 这类语言都是”安全”的, 它们不会产生任何副作用, 例如: 执行表达式不会改变内部状态, 从而导致在下一次调用同一个表达式时产生不同的结果. 这看起来似乎很怪异并且没什么用, 但实际上是有一些列好处的:

  1. 并发. 不会有死锁或者竞争条件, 因为根本不需要锁 - 数据是不可变的. 看起来还是很有前途的吧…
  2. 单元测试. 可以尽情编写单元测试而无需担心状态, 因为根本不存在这个东西. 我们只需要关心测试函数的参数.
  3. 调试. 堆栈信息绝对够用了.
  4. 扎实的理论基础. 函数式语言基于数学形式系统中的λ演算. 这个理论基础使得证明程序的正确性是很直截了当的(例如: 使用归纳法).

希望上述参数足够促使你阅读接下来的部分. 下面, 我会描述 JavaScript 内的函数式要素, 并且提供一些不是原生但是代价很小的实现.

匿名函数

似乎函数式编程变得越来越流行了. 甚至 Java 8 中也拥有了匿名函数(注: 应该是 lambda 表达式), C# 中也已经引入了很长一段时间了. 匿名函数是一种函数定义不会绑定到特定标识符的函数. 如果你不仅仅是编写一些简单的练习而使用 JavaScript, 我相信你应该对于匿名函数是很熟悉的了. 当使用 jQuery 时, 下面代码很可能是你首先会写到的:

$(document).ready(function () {
    //do some stuff
});

传递给 $(document).ready 的函数就是一个实实在在的匿名函数. 这个理论提供了巨大的好处, 尤其在我们想保持事物的 DRY ( Don’t repeat yourself ) 原则时.

高阶函数

高阶函数是可以接收函数作为参数或者将函数作为返回值的函数. 在 C#, Java 8, Python, Perl, Ruby 等语言中都可以返回并将函数作为参数… 作为最为流行的语言 - JavaScript 已经在很久之前就内置了这些函数式的要素. 下面是一个基本的例子:

function animate(property, duration, endCallback) {
    //Animation here...
    if (typeof endCallback === 'function') {
        endCallback.apply(null);
    }
}

animate('background-color', 5000, function () {
    console.log('Animation finished');
});

上面的代码中存在一个名为 animate 的函数. 它的参数包含: 一个动画元素的属性, 持续时间和一个在动画完成时触发的回调函数. jQuery 中也有这种模式. jQuery 中含有大量的接收函数作为参数的方法, 例如 $.get:

$.get('http://example.com/test.json', function (data) {
    //processing of the data
});

JavaScript 内的回调函数处处可见, 它们是完美的异步编程, 例如: 事件处理, ajax 请求, 客户端处理 ( Node.js ) 等等. 正如我上面提到过的, 通过使用回调函数可以避免重复工作. 当你需要基于条件而表现出不同行为的代码时, 回调函数真的是大有裨益的.

还存在另一种高阶函数 - 返回值是函数的函数. JavaScript 同样存在大量非常有效的用例. 比如使用缓存:

/* From Asen Bozhilov's lz library */
lz.memo = function (fn) {
    var cache = {};
    return function () {
        var key = [].join.call(arguments, '§') + '§';
        if (key in cache) {
            return cache[key];
        }
        return cache[key] = fn.apply(this, arguments);
    };
};

代码中在父函数的局部作用域内定义了 cache 变量. 在每次调用时, 会首先检测是否函数已经被同样的参数调用过, 如果调用过则立即返回, 否则结果会被缓存并返回. 这里甚至有一种暗示: 如果使用相同的参数调用, 则函数会返回同样的结果. 这是一个典型的函数式编程思想.

设想一下这样的情况:

var foo = 1;
function bar(baz) {
    return baz + foo;
}
var cached = lz.memo(bar);
cached(1); //2
foo += 1;
cached(1); //2

有一个名为 bar 的函数, 只接受一个参数 - baz, 返回值为 baz 和全局变量 foo 的和. 当使用 memo 将 bar 函数包装为可缓存的函数并保存一份拷贝的引用给 cached. 首先用参数 1 调用 cached, 函数返回 2. 之后 foo 自增 1 并再次调用 cached. 我们会得到相同的结果, 但却是错误的结果. 那是因为有某种状态存在, 这种状态会在之后的 Monads 部分中被考虑到. 因此我们还是继续把注意力放在高阶函数上, 尤其是返回函数的那些.

闭包

让我们再次看一下 memo. 函数内定义了局部变量 cache 并且返回可缓存的函数. 变量在返回的函数内也是可被访问的, 因为此处创建了一个闭包. 这就是函数式编程的又一个要素, 并且传播的十分广泛. JavaScript 内可以通过闭包实现私有权限:

var timeline = (function () {
    var articles = [];

    function sortArticles() {
        articles.sort(function (a, b) {
            return a.name - b.name;
        });
    }

    return {
        getArticles: function () {
            return articles;
        },
        setArticles: function (articleList) {
           articles = articleList;
           sortArticles();
        }
    };
}());

这个例子里有一个名为 timeline 的对象. 它是一个立即执行函数表达式 ( IIFE ) 的执行结果, 返回了一个实为 timeline 的公共接口的对象, 对象中包含属性 getArticles 和 setArticles. 在 IIFE 的词法作用域的内部, 包含了 articles 数组和 sort 函数的定义, 它们通过 timeline 对象是无法直接调用的.

这个主题将会以 ECMAScript 5 的高阶函数作为结束. 近年来, JavaScript 包含了越来越的的函数式要素. 当我们听到函数式编程, 首先浮现在我们脑中的可能就是 map 函数了. 它接收一个匿名函数和一个列表作为参数. 并将列表中的所有元素应用到这个函数中. map 如今已经正式成为 ECMAScript 5 的一部分了.

下面是它的基本用法:

[1,2,3,4].map(function (a) {
    return a * 2;
});
//[2,4,6,8]

上面的代码中 map 将列表内的元素变为之前的 2 倍. map 并不是唯一被加入到 ECMAScript 5 的典型函数式编程语言的函数, 同样还有 filter 和 reduce.

递归

另一个广泛适用于几乎所有现代编程语言的元素则是递归. 它是一个在函数提内部调用函数本身的一种函数:

function factorial(n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

上面是一个使用递归来实现阶乘的例子. 这个概念应用的十分普遍, 因此就不再赘述了.

状态管理(Monads)

在 memoization 的例子中, JavaScript 不是一个纯粹的函数式语言 (也许这也是这门语言之所以如此流行的一个原因…) , 因为它包含了可变的数据与状态. 通常纯函数式编程语言 (比如: Haskell ) 使用 monads 管理状态. JavaScript 中有多种 Monads 的实现方式. 例如下面这个就是 Douglas Crockford 的实现:

/* Code by Douglas Crockford */
function MONAD(modifier) {
    'use strict';
    var prototype = Object.create(null);
    prototype.is_monad = true;
    function unit(value) {
        var monad = Object.create(prototype);
        monad.bind = function (func, args) {
            return func.apply(
                undefined,
                [value].concat(Array.prototype.slice.apply(args || []))
            );
        };
        if (typeof modifier === 'function') {
            modifier(monad, value);
        }
        return monad;
    }
    unit.method = function (name, func) {
        prototype[name] = func;
        return unit;
    };
    unit.lift_value = function (name, func) {
        prototype[name] = function () {
            return this.bind(func, arguments);
        };
        return unit;
    };
    unit.lift = function (name, func) {
        prototype[name] = function () {
            var result = this.bind(func, arguments);
            return result && result.is_monad === true ? result : unit(result);
        };
        return unit;
    };
    return unit;
}

这是一个简短的演示, 来说明如何创建一个一元 I/O:

var monad = MONAD();
monad(prompt("Enter your name:")).bind(function (name) {
    alert('Hello ' + name + '!');
});

在 JavaScript 内使用 monads 看起更像学术练习而并没有什么实际作用, 但是如果想在纯函数式方式下工作, 我们就能用到它了.

Schönfinkelization (柯里化)

Schönfinkelization 是一种函数式的变形, 它使我们可以逐步的填充函数的参数. 当函数接收到最后一个参数时才会返回结果. 它由 Moses Schönfinkel 发明并在之后由 Haskell Curry 重新发现. 下面是一个在 JavaScript 内的样例, 由 Stoyan Stefanov 实现:

/* By Stoyan Stafanov */
function schonfinkelize(fn) {
    var slice = Array.prototype.slice,
        stored_args = slice.call(arguments, 1);
    return function () {
        var new_args = slice.call(arguments),
            args = stored_args.concat(new_args);
        return fn.apply(null, args);
    };
}

接下来是一个使用函数来解二次方程的例子:

function quadraticEquation(a, b, c) {
    var d = b * b - 4 * a * c,
        x1, x2;
    if (d < 0) throw "No roots in R";
    x1 = (-b - Math.sqrt(d)) / (2 * a);
    x2 = (-b + Math.sqrt(d)) / (2 * a);
    return {
        x1: x1,
        x2: x2
    }
}

如果需要逐步填充函数的参数则可以:

var temp = schonfinkelize(quadraticEquation, 1);
temp = schonfinkelize(temp, -2);
temp(1); // { x1: 1, x2: 1 }

如果想使用语言内置功能来取代 schonfinkelize 函数, 你可以使用 Function.prototype.bind. 由于它定义在所有函数的构造函数的原型属性上, 因此可以作为函数的方法进行使用. bind 会创建一个包含特定上下文与参数的全新函数. 例如:

var f = function (a, b, c) {
  console.log(this, arguments);
};

接下来, 使用 bind 函数:

var newF = f.bind(this, 1, 2);
newF(); //window, [1, 2]
newF = newF.bind(this, 3)
newF(); //window, [1,2,3]
newF(4); //window, [1,2,3,4]

使用 bind 时候需要注意, 它并没有被广泛支持. 基于 kagax ES5 compatibility table, IE9 以上才支持 bind.

这个功能在某些场景下是很实用的. 设想有一个 session 对象. session 的键可以同构复杂耗时的算法生成, 一旦用户访问网站就需要生成. session 对象也应该保存关于用户的一些信息, 例如: 客户选择的当前页面的皮肤. 这样就可以使用柯里化来调用 session 的生成函数, 一旦页面加载, 就会生成 session 的键. 在那之后可以再次调用函数来获取用户皮肤. 这样就会在用户选择完皮肤后才生成 session 对象, 但却不会有额外开销, 因为生成 session 键值的复杂算法已经在之前执行过了(这个过程对用于不敏感).

模式匹配

函数式编程里最酷的一件事就是模式匹配了. 事实上, 我也不确定为什么我这么喜爱它, 或许是因为它的简洁并且可以把问题打散为更小的部分. 例如: 在 Haskell 中, 可以这样定义阶乘算法:

factorial 0 = 1
factorial n = n * factorial (n - 1)

看起来很酷吧. 你把一个巨大的任务分割成了一个个更小的部分, 并且将方案变得更加简单. 当我开始写这篇文章的时候, 我已经有了在JavaScript中实现类似功能的想法, 但是后来我发现它已经有了实现方案. 下面就是来自于 Bram Stein 的 funcy 的例子:

var $ = fun.parameter,
    fact = fun(
        [0, function ()  { return 1; }],
        [$, function (n) { return n * fact(n - 1); }]
    );

在 fun 内部有一个特殊的参数变量 - fun.parameter. 当通过空字符串 “” 调用 fact 时, function () { return 1; } 就会被执行, 否则执行 function (n) { return n * fact(n – 1); }, 是不是很酷?

我希望我已经为你们足够清晰的展示了 JavaScript 的所有的函数式的部分并且让大家理解了函数式编程的优美与简洁.

参见

[1] Functional programming with JavaScript


Tags:
0 comments