[译] 准备充分了嘛就想学函数式编程?(Part 2)

1,665 阅读7分钟
原文链接: github.com

想要理解函数式编程,第一步总是最重要,也是最困难的。但是只要有了正确的思维,其实也不是太难。

之前的部分: 第一部分

友情提示

请读仔细读代码,确保继续之前你已经理解。每一代码段落都基于它之前的代码。

如果你太急,可能会遗漏一些重要的细节。

重构

让我们先来重构一段 JavaScript 代码:

function validateSsn(ssn) {
    if (/^\d{3}-\d{2}-\d{4}$/.exec(ssn))
        console.log('Valid SSN');
    else
        console.log('Invalid SSN');
}

function validatePhone(phone) {
    if (/^\(\d{3}\)\d{3}-\d{4}$/.exec(phone))
        console.log('Valid Phone Number');
    else
        console.log('Invalid Phone Number');
}

我们以前都写过这样的代码,经过一段时间我们会发现,上面两个函数实际上除了些许区别,其实是一样的(黑体高亮)。

我们应该创建一个单独的函数,将上面的区别参数化,而不是通过复制,粘贴,修改 validateSsn 函数,来创建 validatePhone。

此例中,我们可以将要验证的参数,验证用的正则表达式,打印的文本抽象成参数传入方法。

重构后的代码:

function validateValue(value, regex, type) {
    if (regex.exec(value))
        console.log('Invalid ' + type);
    else
        console.log('Valid ' + type);
}

旧代码中要验证的参数 ssn,phone,现在都用参数 value 来体现。

正则表达式 /^\d{3}-\d{2}-\d{4}/ 用变量 regex 体现。

最后,需要打印的文本 ‘SSN’ 和 ‘Phone Number’ 用变量 type 拼接。

只有一个函数要比两个函数,或者更糟糕的情况三个,四个甚至十个函数好得多。这可以使你的代码保持整洁并且易维护。

例如,如果代码中有 bug,你只需要修改一处,而不用在整个代码库查找每一处粘贴或修改过这段代码的地方。

但当你遇到这样的情况:

function validateAddress(address) {
    if (parseAddress(address))
        console.log('Valid Address');
    else
        console.log('Invalid Address');
}

function validateName(name) {
    if (parseFullName(name))
        console.log('Valid Name');
    else
        console.log('Invalid Name');
}

这里 parseAddress 和 parseFullName 函数都只接受一个字符串参数,并在符合解析条件时返回 true 。

我们怎样重构这段代码?

我们可以用 value 来代替 address 和 name, 用 type 来替换 ‘Address’ 和 ‘Name’,就像我们之前那样,但之前是将正则表达式作为参数传入,现在是函数。

如果我们能把一个函数当作参数传入就好了。。。

高阶函数

很多语言并不支持将函数作为参数传入。一些语言虽然支持,但用起来不直观。

在函数式编程中,函数是语言的第一公民。换句话说,函数就是另一种值。

因为函数是值,我们可以把它们当作参数传入函数。

尽管 JavaSscript 不是一门纯函数式语言,你也可以用它做一些函数式操作。我们可以将之前的两个函数重构成一个叫 parseFunc 的函数,将解析函数作为参数传入:

function validateValueWithFunc(value, parseFunc, type) {
    if (parseFunc(value))
        console.log('Invalid ' + type);
    else
        console.log('Valid ' + type);
}

我们的新函数就是高阶函数。

高阶函数既可以接受函数作为参数传入,也可以把函数作为返回值返回,或者同时满足两个条件。

现在我们可以将前面的四个函数抽象成一个高阶函数(在 JavaScript 里可以这样做,因为如果正则匹配成功,Regex.exec 返回真值):

validateValueWithFunc('123-45-6789', /^\d{3}-\d{2}-\d{4}$/.exec, 'SSN');
validateValueWithFunc('(123)456-7890', /^\(\d{3}\)\d{3}-\d{4}$/.exec, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

这比之前使用四个近乎相同的函数好很多。

但要注意正则表达式。他们还有些冗长。现在我们重构代码来整理一下:

var parseSsn = /^\d{3}-\d{2}-\d{4}$/.exec;
var parsePhone = /^\(\d{3}\)\d{3}-\d{4}$/.exec;

validateValueWithFunc('123-45-6789', parseSsn, 'SSN');
validateValueWithFunc('(123)456-7890', parsePhone, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

好多了,现在如果我们想要检查一个值是否是电话号码,就不用复制,粘贴正则表达式了。

但是设想我们除了 parseSsn 和 parsePhone 还有更多的正则表达式需要匹配。每次我们新建函数都要用一个正则表达式,再调用 .exec。相信我,这很容易遗漏。

我们可以创建另一个高阶函数,在内部调用 exec 来解决这个问题:

function makeRegexParser(regex) {
    return regex.exec;
}

var parseSsn = makeRegexParser(/^\d{3}-\d{2}-\d{4}$/);
var parsePhone = makeRegexParser(/^\(\d{3}\)\d{3}-\d{4}$/);

validateValueWithFunc('123-45-6789', parseSsn, 'SSN');
validateValueWithFunc('(123)456-7890', parsePhone, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

这里,makeRegexParser 接受一个正则表达式作为参数,返回一个 exec 函数,这个函数接受被验证字符串作为参数。validateValueWithFunc 可以传入字符串,值,给 parse 函数,例如 exec 。

parseSsn 和 parsePhone 和之前用正则表达式的 exec 函数一样可用。

的确,这只是一个微小的提升,但这里向我们展示了高阶函数将函数作为返回值返回的例子。

不过你可以想象如果 makeRegexParser 更复杂,这样改动可以给我们带来的好处。

这是另一个高阶函数返回函数作为返回值的例子:

function makeAdder(constantValue) {
    return function adder(value) {
        return constantValue + value;
    };
}

这里 makeAddr 函数接受一个参数 constantValue,返回一个函数 addr,它的返回是 contantValue 与它接受的任意值相加的结果。

它的用法是:

var add10 = makeAdder(10);
console.log(add10(20)); // prints 30
console.log(add10(30)); // prints 40
console.log(add10(40)); // prints 50

我们通过将 10 作为参数传给 makeAddr,创建了 add10 函数,它接受任意值作为参数,并与 10 求和返回。

需要注意的是,,即使在 makeAddr 返回后,函数 addr 仍可以获取到 constantValue 参数的值。这是因为 constantValue 在 addr 函数被创建时的作用域中。

这种行为非常重要,因为如果不是这样,将函数作为返回值返回的函数就没有多大用处了。所以我们理解它的工作原理非常重要。

这种行为叫做闭包。

闭包

这有一个故意使用闭包的函数:

function grandParent(g1, g2) {
    var g3 = 3;
    return function parent(p1, p2) {
        var p3 = 33;
        return function child(c1, c2) {
            var c3 = 333;
            return g1 + g2 + g3 + p1 + p2 + p3 + c1 + c2 + c3;
        };
    };
}

在这个例子中,child 函数可以获取到定义在它自己,parent 函数和 grandParent 函数作用域中定义的变量值。

parent 函数可以获取到它自己和 grandParent 函数作用域中定义的变量值。

grandParent 只能获取到它自己的变量(为了清晰理解可以参考上面的金字塔结构图)。

这有一个例子:

var parentFunc = grandParent(1, 2); // returns parent()
var childFunc = parentFunc(11, 22); // returns child()
console.log(childFunc(111, 222)); // prints 738
// 1 + 2 + 3 + 11 + 22 + 33 + 111 + 222 + 333 == 738

这里,parentFunc 可以保持 parent 函数的作用域,因为 grandParent 将 parent 作为返回值返回。

类似的,childFunc 可以保持 child 函数的作用域,因为 parentFunc 其实是返回 child 函数的 parent 函数。

当创建一个函数时,创建时所处的作用域的所有变量都是可以读取的。如果函数仍被引用,作用域保持存活状态。例如 child 函数的作用域只要 childFunc 的引用存在,就算存活。

闭包指函数通过被引用,保持其作用域的存活状态。

注意在 JavaScript 中,因为变量是可变的,所以闭包可能会引入问题。例如这些变量可能从它们被闭包开始到函数返回的周期里被修改。

值得庆幸的是,函数式语言中的变量是不可变的,所以就可以消除这种常见的错误和混乱。

我的脑子!

到目前暂时足够消化一段了。

在文章接下来的部分里,我会涉及到 函数组合,柯里化,函数式编程中常见的函数(如 map,filter,fold 等)

接下来:【第三部分