霖呆呆的函数式编程之路(四)

组合函数

到了第四章了,函数式编程的魅力似乎越来越大。

对于函数式编程者,他们会将每个函数都当成是一个“部件”,在需要时通过组装不同的“部件”,来拼凑出一个自己想要的“模型”。

专业点的角度来说,就是我们能够定义某种组合方式,来让它们成为一种新的组合函数,程序中不同的部分都可以使用这个函数。这种将函数一起使用的过程叫做组合。

再介绍组合函数的概念之前,我们就已经使用过组合了。

例如在之前我们的一个案例:

1
unary(adder(3))

上面的表达式,我们将两个函数整合起来,然后将第一个函数调用产生的值(输出)当成第二个函数调用的实参(输入)。画个简图,也就是这样:

1
functionValue <-- unary <-- adder <-- 3

3adder(..) 的输入。而 adder(..) 的输出是 unary(..) 的输入。unary(..) 的输出是 functionValue。 这就是 unary(..)adder(..) 的组合。

Compose2函数

为了满足上面组合函数的要求,我们可以来构造这么一个简单的函数:

1
2
3
4
5
6
7
8
9
10
11
function compose2(fn2,fn1) {
return function composed(origValue){
return fn2( fn1( origValue ) );
};
}

// ES6 箭头函数形式写法
var compose2 =
(fn2,fn1) =>
origValue =>
fn2( fn1( origValue ) );

它能够自动创建两个函数的组合,这和我们手动做的是一模一样的。

Words案例

现在有这么一个需求,需要将给定的一个英文字符串,提取其中全部的英文单词,先全部转化小写,然后去除其中重复的单词。

我们可以先来创建这么2个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function words(str) {
return String( str )
.toLowerCase()
.split( /\s|\b/ )
.filter( function alpha(v){
return /^[\w]+$/.test( v );
} );
}

function unique(list) {
var uniqList = [];

for (let i = 0; i < list.length; i++) {
// value not yet in the new list?
if (uniqList.indexOf( list[i] ) === -1 ) {
uniqList.push( list[i] );
}
}

return uniqList;
}

接下来我们解析文本字符串:

1
2
3
4
5
6
7
8
9
10
11
var text = "To compose two functions together, pass the \
output of the first function call as the input of the \
second function call.";

var wordsFound = words( text );
var wordsUsed = unique( wordsFound );

wordsUsed;
// ["to","compose","two","functions","together","pass",
// "the","output","of","first","function","call","as",
// "input","second"]

在上面的例子中,我们将该过程分为了2步来做。

并且先创建了wordsFound函数,然后将该函数的输出再传递给unique,实际上,上面的效果等同于:

1
var wordsUsed = unique( words(text) )

所以我们可以将其封装一层:

1
2
3
4
5
function uniqueWords(str) {
return unique( words( str ) );
}

var wordsUsed = uniqueWords(text)

你会发现,其实我们还可以这样写:

1
2
3
var uniqueWords = compose2( unique, words )

var wordsUsed = uniqueWords(text)

这样我们就成功将uniqueWords转化为了无形参的函数。

uniqueWords(..) 接收一个字符串并返回一个数组。它是 unique(..)words(..) 的组合,并且满足我们的数据流向要求:

1
wordsUsed <-- unique <-- words <-- text

compose函数

在上面我们构造了compose2函数,它能接收2个函数,并将2个函数从右向左的执行。

如果我们能够定义两个函数的组合,我们也同样能够支持组合任意数量的函数。任意数目函数的组合的通用可视化数据流如下:

1
finalValue <-- func1 <-- func2 <-- ... <-- funcN <-- origValue

我们能够像这样实现一个通用 compose(..) 实用函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function compose(...fns) {
return function composed(result){
// 拷贝一份保存函数的数组
var list = fns.slice();

while (list.length > 0) {
// 将最后一个函数从列表尾部拿出
// 并执行它
result = list.pop()( result );
}

return result;
};
}

// ES6 箭头函数形式写法
var compose =
(...fns) =>
result => {
var list = fns.slice();

while (list.length > 0) {
// 将最后一个函数从列表尾部拿出
// 并执行它
result = list.pop()( result );
}

return result;
};

现在看一下组合超过两个函数的例子。回想下我们的 uniqueWords(..) 组合例子,让我们增加一个 skipShortWords(..),它将所有单词字母数大于4的提取出来:

1
2
3
function skipShortWords(list) {
return list.filter(str => str.length > 4)
}

让我们再定义一个 biggerWords(..) 来包含 skipShortWords(..)。我们期望等价的手工组合方式是 skipShortWords(unique(words(text))),所以让我们采用 compose(..) 来实现它:

1
2
3
4
5
6
7
8
9
10
11
var text = "To compose two functions together, pass the \
output of the first function call as the input of the \
second function call.";

var biggerWords = compose( skipShortWords, unique, words );

var wordsUsed = biggerWords( text );

wordsUsed;
// ["compose","functions","together","output","first",
// "function","input","second"]

现在,让我们回忆一下第 3 章中出现的 partialRight(..) 来让组合变的更有趣。我们能够构造一个由 compose(..) 自身组成的右偏函数应用,通过提前定义好第二和第三参数(unique(..)words(..));我们把它称作 filterWords(..)(如下)。

然后,我们能够通过多次调用 filterWords(..) 来完成组合,但是每次的第一参数却各不相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function skipShortWords(list) {
return list.filter(str => str.length > 4)
}
function skipLongWords(list) {
return list.filter(str => str.length <= 4)
}

var filterWords = partialRight( compose, unique, words );

var biggerWords = filterWords( skipShortWords );
var shorterWords = filterWords( skipLongWords );

biggerWords( text );
// ["compose","functions","together","output","first",
// "function","input","second"]

shorterWords( text );
// ["to","two","pass","the","of","call","as"]

花些时间考虑一下基于 compose(..) 的右偏函数应用给了我们什么。

甚至我们可以结合前面一章的notwhenidentity函数来重构一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 取反辅助函数
function not(predicate) {
return function negated(...args) {
return !predicate(...args)
}
}
// 判断某个条件成立之后执行fn
function when(predicate, fn) {
return function conditional(...args) {
if (predicate(...args)) {
return fn(...args)
}
}
}
// 传一个返回一个
function identity(v) {
return v;
}

var isLong = (str) => str.length > 4;
var isShort = not(isLong)
var returnLong = when(isLong, identity)
var returnShort = when(isShort, identity)

function skipShortWords(list) {
return list.filter(str => returnLong(str))
}
function skipLongWords(list) {
return list.filter(str => returnShort(str))
}

var filterWords = partialRight(compose, unique, words)

var biggerWords = filterWords(skipShortWords)
var shorterWords = filterWords(skipLongWords)

biggerWords( text );
// ["compose","functions","together","output","first",
// "function","input","second"]

shorterWords( text );
// ["to","two","pass","the","of","call","as"]

compose的不同实现方式

reduce实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function compose(...fns) {
return fns.reverse().reduce( function reducer(fn1,fn2){
return function composed(...args){
return fn2( fn1( ...args ) );
};
} );
}

// ES6 箭头函数形式写法
var compose =
(...fns) =>
fns.reverse().reduce( (fn1,fn2) =>
(...args) =>
fn2( fn1( ...args ) )
);

递归实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

function compose(...fns) {
// 拿出最后两个参数
var [ fn1, fn2, ...rest ] = fns.reverse();

var composedFn = function composed(...args){
return fn2( fn1( ...args ) );
};

if (rest.length == 0) return composedFn;

return compose( ...rest.reverse(), composedFn );
}

// ES6 箭头函数形式写法
var compose =
(...fns) => {
// 拿出最后两个参数
var [ fn1, fn2, ...rest ] = fns.reverse();

var composedFn =
(...args) =>
fn2( fn1( ...args ) );

if (rest.length == 0) return composedFn;

return compose( ...rest.reverse(), composedFn );
};

pipe函数

我们早期谈及的是从右往左顺序的标准 compose(..) 实现。这么做的好处是能够和手工组合列出参数(函数)的顺序保持一致。

不足之处就是它们排列的顺序和它们执行的顺序是相反的,这将会造成困扰。同时,不得不使用 partialRight(compose, ..) 提早定义要在组合过程中 第一个 执行的函数。

相反的顺序,从右往左的组合,有个常见的名字:pipe(..)

pipe(..)compose(..) 一模一样,除了它将列表中的函数从左往右处理。

1
2
3
4
5
6
7
8
9
10
11
12
function pipe(...fns) {
return function piped(result){
var list = fns.slice();

while (list.length > 0) {
// 从列表中取第一个函数并执行
result = list.shift()( result );
}

return result;
};
}

实际上,我们只需将 compose(..) 的参数反转就能定义出来一个 pipe(..)

1
var pipe = reverseArgs( compose );

回忆下之前的通用组合的例子:

1
var biggerWords = compose( skipShortWords, unique, words );

pipe(..) 的方式来实现,我们只需要反转参数的顺序:

1
var biggerWords = pipe( words, unique, skipShortWords );

pipe(..) 的优势在于它以函数执行的顺序排列参数,某些情况下能够减轻阅读者的疑惑

抽象

先来介绍2个简单且实用的函数

prop函数

将任意对象的任意属性通过属性名提取出来。让我们把这个实用函数称为 prop(..)

1
2
3
4
5
6
7
8
function prop(name,obj) {
return obj[name];
}

// ES6 箭头函数形式
var prop =
(name,obj) =>
obj[name];

使用:

1
2
3
4
var obj = { x: 1, y: 2 }

prop('x', obj)
// 1

setProp函数

我们处理对象属性的时候,也需要定义下反操作的工具函数:setProp(..),为了将属性值设到某个对象上。

1
2
3
4
5
function setProp(name,obj,val) {
var o = Object.assign( {}, obj );
o[name] = val;
return o;
}

使用:

1
2
3
4
var obj = { x: 1, y: 2 }

var obj2 = setProp('z', obj, 3)
// { x: 1, y: 2, z: 3 }

makeObjProp函数

1
2
3
4
5
6
7
8
function makeObjProp(name,value) {
return setProp( name, {}, value );
}

// ES6 箭头函数形式
var makeObjProp =
(name,value) =>
setProp( name, {}, value );

提示: 这个实用函数在 Ramda 库中被称为 objOf(..)

回顾ajax案例

让我们回顾一下第二章介绍的ajax案例

1
2
3
4
5
6
7
8
9
10
11
12
13
function ajax (url, data, callback) {
// ...
}
var getUser = partial( ajax, "/api/user" );
var getLastOrder = partial( ajax, "/api/order", { orderId: -1 } );

var output = (str) => console.log(sgr);

getLastOrder(function orderFound(order) {
getUser({ userId: order.userId }, function userFound (user) {
output(user.name)
})
})

如上,我们在给getLastOrder函数传递最后一个参数(一个回调函数orderFound)

该函数用查询到的订单信息order中的userId查询当前订单的用户,并输出用户的姓名name.

可以看到上面的函数需要orderuser两个形参。

我们可以用现有的函数式编程的知识将其转化为一个无形参的函数getLastOrder.

移除user形参

从里向外,我们先想想如何移除user这个形参。

首先output函数是需要接收user.name这个参数的。我们可以用什么样的方式来移除这个参数呢。

在这里我们的目的是想要获取user中的name属性:

定义一个extractName函数:

1
var extractName = partial(prop, 'name')

之后我们就可以直接用:

1
extractName(user)

这样的方式获取到user.name

接着你是不是也想到可以用compose了呢?

1
var outputUserName = compose( output, extractName )

想一下我们需要的数据流是什么样:

1
output <-- extractName <-- user

下一步,让我们缩小关注点,看下例子中嵌套的这块查找操作的调用:

1
2
3
getLastOrder( function orderFound(order){
getUser( { userId: order.userId}, outputUserName );
} );

我们刚刚创建的 outputUserName(..) 函数是提供给 getUser(..) 的回调。所以我们还能定义一个函数叫做 processUser(..) 来处理回调参数,使用 partialRight(..)

1
var processUser = partialRight( getUser, outputUserName )

让我们用新函数来重构下之前的代码:

1
2
3
getLastOrder(function orderFound(order) {
processUser({ userId: order.userId })
})

Ok,至此,user这个形参已经被我们干掉了。

移除order形参

接下来你可以用类似的方式移除掉order形参。

首先是获取userId

1
var extractUserId = partial(prop, 'userId')

接着你需要定义一个函数来解决{ userId: order.userId }这个问题。我们可以使用上面的makeObjProp函数:

1
var userData = partial(makeObjProp, 'userId')

为了使用 processUser(..) 来完成通过 order 值查找一个人的功能,我们需要的数据流如下:

1
processUser <-- userData <-- extractUserId <-- user

所以我们只需要再使用一次 compose(..) 来定义一个 lookupUser(..)

1
var lookupUser = compose( processUser, userData, extractUserId )

完整流程

然后,就是这样了!把这整个例子重新组合起来,不带任何的“形参”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function ajax (url, data, callback) {
// ...
};
var getUser = partial( ajax, "/api/user" );
var getLastOrder = partial( ajax, "/api/order", { orderId: -1 } );

var output = (str) => console.log(sgr);

var extractName = partial(prop, 'name');
var outputUserName = compose( output, extractName );
var processUser = partialRight( getUser, outputUserName )
var extractUserId = partial(prop, 'userId')
var userData = partial(makeObjProp, 'userId')
var lookupUser = compose( processUser, userData, extractUserId )

getLastOrder( lookupUser )

Look!不带任何的形参。

总结

函数组合是一种定义函数的模式,它能将一个函数调用的输出路由到另一个函数的调用上,然后一直进行下去。

因为 JS 函数只能返回单个值,这个模式本质上要求所有组合中的函数(可能第一个调用的函数除外)是一元的,当前函数从上一个函数输出中只接收一个输入。

相较于在我们的代码里详细列出每个调用,函数组合使用 compose(..) 实用函数来提取出实现细节,让代码变得更可读,让我们更关注组合完成的是什么,而不是它具体做什么

组合 ———— 声明式数据流 ———— 是支撑函数式编程其他特性的最重要的工具之一。

评论