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

只要一个实参

unary函数

先来看一个奇怪的例子:

1
2
3
4
5
6
// example1
["1","2","3"].map( parseFloat );
// [1,2,3]

["1","2","3"].map( parseInt );
// [1,NaN,NaN]

在上面的例子中,我想要将一组字符串全部设置成数字类型,但是在调用parseInt的时候,却出现了这样怪异的事情。这是为什么呢?

首先我们来说一下parseInt(str,radix)这个函数,它接受两个参数,第一个是要被解析的字符串,第二个为可选参数,表示要解析的数字的基数。该值介于 2 ~ 36 之间,比如:

1
2
3
parseInt("10");			//返回 10
parseInt("19",10); //返回 19 (10+9)
parseInt("11",2); //返回 3 (2+1)
  • 当参数 radix 的值为 0,或没有设置该参数时,parseInt() 会根据 string 来判断数字的基数。
  • 如果该参数小于 2 或者大于 36,则 parseInt() 将返回 NaN。

而在案例1中,由于我们在调用map的时候,函数时会传入三个实参:valueindexlistparseInt()又会接收2个参数,所以每次都相当于是将valueindex传入进去了。这样就造成后面2个NaN的情况。

根据上面,我们可以看到,在实际开发中,我们会面临这样一个问题:在设计一个实用函数传入一个函数,而这个实用函数会把多个实参传入函数,但可能你只希望你的函数接收单一实参。如上面的parseInt()函数,我在调用它的时候希望它每次只接收一个参数。

根据上面的需要,我们是不是可以来实现这么一个简单的实用函数,它包装一个函数调用,让这个函数在每次调用的时候只接收一个参数:

1
2
3
4
5
6
7
8
9
10
function unary(fn) {
return function onlyOneArg(arg) {
return fn(arg)
}
}

// ES6
var unary = fn =>
arg =>
fn(arg);

很简单的一层封装,也很好理解。

现在我们用它来配合上面的案例1:

1
2
3
4
5
6
7
8
["1","2","3"].map( parseFloat );
// [1,2,3]

["1","2","3"].map( parseInt );
// [1,NaN,NaN]

["1","2","3"].map( unary( parseInt ) );
// [1,2,3]

sum案例

了解了上面的unary函数之后,我们来看一个复杂一些的案例。还是用map函数来进行举例,不过要用到我们在第二章讲到的松散型柯里化looseCurry

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
function looseCurry(fn, arity = fn.length) { // 松散型柯里化
return (function nextCurried(prevArgs) {
return function curried(...nextArgs) {
var args = prevArgs.concat(nextArgs)
if (args.length >= arity) {
return fn(...args)
} else {
return nextCurried(args)
}
}
})([])
}
function sum(...args) {
var sum = 0;
for (let i = 0; i < args.length; i++) {
sum += args[i];
}
return sum;
}
let adder = looseCurry(sum, 2) // 第一步 传2表示至少接收2个参数才可以
let arr = [1, 2, 3, 4, 5]
let arr2 = arr.map(adder(3)) // 第二步
console.log(arr2)
// 生成的结果很有意思
// ["41,2,3,4,5", "61,2,3,4,5", "81,2,3,4,5", "101, ...

大家可以先将这段代码在本地跑一下,并思考为什么会出现这样的情况?🤔

其实原理很简单:

在第一步的时候,创建adder函数,只有在接收2个及以上参数才会运行sum()

第二步的时候,由于map()会传入3个实参:分别是value,indexlist,而松散型的柯里化是可以接收比预期(这里也就是2)多的参数的。

所以在每次执行sum函数的时候,实际传入的都是3个参数,比如arr执行第一次的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
第一次传入:
firstArg: 3, // 调用adder()时传入的3
value: 1, // 数组第一项的值
index: 0, // 数组第一项的索引
list: [1, 2, 3, 4, 5] // 整个数组
// 前面3项相加为数字4,之后数字4与数组做字符串相加
=> 3 + 1 + 0 + [1, 2, 3, 4, 5]
=> "41,2,3,4,5"

第二次传入
firstArg: 3,
value: 2,
index: 1,
list: [1, 2, 3, 4, 5]

此时,使用我们的unary函数就可以解决上面的问题了:

1
2
let arr2 = arr.map( unary( adder(3) ) )
// [4,5,6,7,8]

传一个返回一个

identity函数

说到只传一个实参的函数,在函数式编程工具库中有另一种通用的基础函数:该函数接收一个实参,然后什么都不做,原封不动地返回实参值。

1
2
3
4
5
6
7
8
function identity(v) {
return v;
}

// ES6 箭头函数形式
var identity =
v =>
v;

举个例子,想象一下你要用正则表达式拆分(split up)一个字符串,但输出的数组中可能包含一些空值。我们可以使用 filter(..) 数组方法(下文会详细说到这个方法)来筛除空值,而我们将 identity(..) 函数作为 filter(..) 的断言:

1
2
3
4
5
6
var words = "   Now is the time for all...  ".split( /\s|\b/ );
words;
// ["","Now","is","the","time","for","all","...",""]

words.filter( identity );
// ["Now","is","the","time","for","all","..."]

转换函数的妙用

另一个使用 identity(..) 的示例就是将其作为替代一个转换函数(译者注:transformation,这里指的是对传入值进行修改或调整,返回新值的函数)的默认函数:

1
2
3
4
5
6
7
8
9
10
11
function output(msg,formatFn = identity) {
msg = formatFn( msg );
console.log( msg );
}

function upper(txt) {
return txt.toUpperCase();
}

output( "Hello World", upper ); // HELLO WORLD
output( "Hello World" ); // Hello World

上面的例子,相当于是给output函数一个默认的函数,若是没有传的话,则原封不动的返回传进来的msg

恒定参数

Certain API

Certain API 禁止直接给方法传值,而要求我们传入一个函数,就算这个函数只是返回一个值。JS Promise 中的 then(..)方法就是一个 Certain API。

then(..)中必须要传入一个函数,而有时候我们可能不需要传入一个有实际用处的函数,而是直接返回then在上一步中获取到的值。

或许你可以用ES6的箭头函数解决这个问题:

1
p1.then( foo ).then( () => p2 ).then( bar )

在第二个then中直接将p2返回。

constant函数

我们可以来构造一个实用函数来实现上面的功能:

1
2
3
4
5
6
7
8
9
10
11
function constant(v) {
return function value(){
return v;
};
}

// or the ES6 => form
var constant =
v =>
() =>
v;

constant函数功能也很简单,返回传入的参数。

对比:

1
2
3
4
5
p1.then( foo ).then( () => p2 ).then( bar );

// 对比:

p1.then( foo ).then( constant( p2 ) ).then( bar );

两种方式都可以解决Certain API的问题。但是我更建议用第二种方式,该箭头函数返回了一个来自外作用域的值,这和 函数式编程的理念有些矛盾。在后面“减少副作用”中会提到该行为的不足。

扩展在参数中的妙用

在第一章中,我们提到了形参数组解构:

1
2
3
4
5
6
function foo( [x,y,...args] ) {
console.log(x, y, args)
// 1, 2, [3]
}

foo( [1,2,3] );

当函数必须接收一个数组,而你却想把数组内容当成单独形参来处理的时候,这个技巧十分有用。

然而,有的时候,你无法改变原函数的定义,但想使用形参数组解构。

比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
function foo(x, y) {
console.log( x + y );
}

function bar(fn) {
fn( [ 3, 9 ] );
}

bar( foo );
// 3,9undefined

由于fn会将实参[3, 9]作为x传入,那么y就是undefined,所以达不到我们期望的效果。

在这种情况下,我们可能想要改变bar()函数的行为,将foo([3, 9])改为foo(…[3, 9]),这样就能将 39 分别传入 foo(..) 函数了。

为了调整一个函数,让它能把接收的单一数组扩展成各自独立的实参,我们可以定义一个辅助函数:

1
2
3
4
5
6
7
8
9
10
11
function spreadArgs(fn) {
return function spreadFn(argsArr) {
return fn( ...argsArr );
};
}

// ES6 箭头函数的形式:
var spreadArgs =
fn =>
argsArr =>
fn( ...argsArr );

注意⚠️

在我参考的教材中,将这个辅助函数叫为spreadArgs,但一些库,比如 Ramda,经常把它叫做 apply(..)

现在我们可以使用 spreadArgs(..) 来调整 foo(..) 函数,使其作为一个合适的输入参数并正常地工作:

1
bar( spreadArgs( foo ) );			// 12

本质上,spreadArgs(..) 函数使我们能够定义一个借助数组 return 多个值的函数,不过,它让这些值仍然能分别作为其他函数的输入参数来处理。

一个函数的输出作为另外一个函数的输入被称作组合(composition),这个在后面的章节中会详细说明。

有了spreadArgs函数,同样的我们也可以定义一个与它功能相反的函数:

1
2
3
4
5
6
7
8
9
10
11
function gatherArgs(fn) {
return function gatheredFn(...argsArr) {
return fn( argsArr );
};
}

// ES6 箭头函数形式
var gatherArgs =
fn =>
(...argsArr) =>
fn( argsArr );

在 Ramda 中,该实用函数被称作 unapply(..),是与 apply(..) 功能相反的函数。我认为术语 “扩展(spread)” 和 “聚集(gather)” 可以把这两个函数发生的事情解释得更好一些。

不需要顺序的柯里化和偏应用

在上面介绍的多形参柯里化和偏应用中,参数传递都是有一定顺序的。哪一个参数在哪一步才能传,放在第一个位置都是固定好的,要是想进行修正调整可能需要费一番功夫。

这时候我们不得不思考有没有一种方法能让我们从修正参数顺序这件事里解脱出来呢?

或许解构模式可以?

在第一章节中,我们提到了命名参数解构模式:

1
2
3
4
5
6
7
function foo( {x,y} = {} ) {
console.log( x, y );
}

foo( {
y: 3
} ); // undefined 3

可以看到这种解构模式就相当于是一种映射,将调用时传入的实参于函数的形参进行一个映射。

命名实参主要的好处就是不用再纠结实参传入的顺序,因此提高了可读性。

partialProps和curryProps

有了这样的想法,我们就可以来重新调整一下原先的柯里化curry和偏应用partial了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function partialProps(fn, presetArgsObj) {
return function partialApplied(laterArgsObj) {
return fn(Object.assign({}, presetArgsObj, laterArgsObj))
}
}
function curryProps(fn, arity = 1) {
return (function nextCurried(prevArgsObj) {
return function curried(nextArgObj = {}) {
var [key] = Object.keys(nextArgObj)
var allArgsObj = Object.assign({}, prevArgsObj, { [key]: nextArgObj[key] })
if (Object.keys(allArgsObj).length >= arity) {
return fn(allArgsObj);
}
else {
return nextCurried(allArgsObj)
}
}
})({})
}

我们甚至不需要设计一个 partialPropsRight(..) 函数了,因为我们根本不需要考虑属性的映射顺序,通过命名来映射形参完全解决了我们有关于顺序的烦恼!

现在可以来试试这两个新函数:

1
2
3
4
5
6
7
8
9
10
11
12
function foo({ x, y, z } = {}) {
console.log( `x:${x} y:${y} z:${z}` );
}

var f1 = curryProps( foo, 3 );
var f2 = partialProps( foo, { y: 2 } );

f1( {y: 2} )( {x: 1} )( {z: 3} );
// x:1 y:2 z:3

f2( { z: 3, x: 1 } );
// x:1 y:2 z:3

我们不用再为参数顺序而烦恼了!现在,我们可以指定我们想传入的实参,而不用管它们的顺序如何。也不需要类似 reverseArgs(..) 的函数或其它妥协了。

属性扩展

上面的partialProps 看似解决了我们这种多形参无顺序的问题,但是,只有在我们可以掌控 foo(..) 的函数签名,并且可以定义该函数的行为,使其解构第一个参数的时候,以上技术才能起作用。

如果一个函数,其形参是各自独立的(没有经过形参解构),而且不能改变它的函数签名,那我们应该如何运用这个技术呢?

1
2
3
function bar(x,y,z) {
console.log( `x:${x} y:${y} z:${z}` );
}

比如上面的bar函数,接收的就是三个参数,但是我想要能映射到对应的位置,比如这样传:

1
bar({ y: 2, x: 1, z: 3 })

spreadArgProps函数

就像之前的 spreadArgs(..) 实用函数一样,我们也可以定义一个 spreadArgProps(..) 辅助函数,它接收对象实参的 key: value 键值对,并将其 “扩展” 成独立实参。

为了满足上面的需求,我们现在需要取得在调用fn时传递的实际参数。

JS的函数对象上有一个toString()方法,它返回函数代码的字符串形式,其中包括函数声明的签名。

1
2
3
4
5
6
7
8
9
10
11
12
function spreadArgProps(
fn,
propOrder =
fn.toString()
.replace( /^(?:(?:function.*\(([^]*?)\))|(?:([^\(\)]+?)\s*=>)|(?:\(([^]*?)\)\s*=>))[^]+$/, "$1$2$3" )
.split( /\s*,\s*/ )
.map( v => v.replace( /[=\s].*$/, "" ) )
) {
return function spreadFn(argsObj) {
return fn( ...propOrder.map( k => argsObj[k] ) );
};
}

让我们看看 spreadArgProps(..) 实用函数是怎么用的:

1
2
3
4
5
6
7
8
9
10
11
12
function bar(x,y,z) {
console.log( `x:${x} y:${y} z:${z}` );
}

var f3 = curryProps( spreadArgProps( bar ), 3 );
var f4 = partialProps( spreadArgProps( bar ), { y: 2 } );

f3( {y: 2} )( {x: 1} )( {z: 3} );
// x:1 y:2 z:3

f4( { z: 3, x: 1 } );
// x:1 y:2 z:3

虽然上面的方法看上去有些不靠谱,但是它确实能解决我们实际的问题,至少解决了80%的情况。

无形参风格

在函数式编程中,还有一种流行的代码风格,其目的是通过移除不必要的形参-实参映射来减少视觉上的干扰。这种风格的正式名称为 “隐性编程(tacit programming)”,一般则称作 “无形参(point-free)” 风格。术语 “point” 在这里指的是函数形参。

我们从一个简单的例子开始:

1
2
3
4
5
6
7
8
function double(x) {
return x * 2;
}

[1,2,3,4,5].map( function mapper(v){
return double( v );
} );
// [2,4,6,8,10]

可以看到 mapper(..) 函数和 double(..) 函数有相同(或相互兼容)的函数签名。形参(也就是所谓的 “point“)v 可以直接映射到 double(..) 函数调用里相应的实参上。这样,mapper(..) 函数包装层是非必需的。我们可以将其简化为无形参风格:

1
2
3
4
5
6
function double(x) {
return x * 2;
}

[1,2,3,4,5].map( double );
// [2,4,6,8,10]

还有之前parseInt()的例子:

1
2
3
4
5
6
7
8
["1","2","3"].map( function mapper(v){
return parseInt( v );
} );
// [1,2,3]

=> 无形参风格:
["1","2","3"].map( unary( parseInt ) );
// [1,2,3]

借助unary()函数使得每次只传一个参数。

not函数

首先来看一个案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function output(txt) {
console.log( txt );
}

function printIf( predicate, msg ) {
if (predicate( msg )) {
output( msg );
}
}

function isShortEnough(str) {
return str.length <= 5;
}

var msg1 = "Hello";
var msg2 = msg1 + " World";

printIf( isShortEnough, msg1 ); // Hello
printIf( isShortEnough, msg2 );

案例很简单,我们要求当信息足够长时,将它打印出来,换而言之,我们需要一个 !isShortEnough(..) 断言。你可能会首先想到:

1
2
3
4
5
6
function isLongEnough(str) {
return !isShortEnough( str );
}

printIf( isLongEnough, msg1 );
printIf( isLongEnough, msg2 ); // Hello World

上面的方式看似是很简单的,但是还是需要我们传递str,现在我们能否不通过重新实现 str.length 的检查逻辑,而重构代码并使其变成无形参风格呢?

我们定义一个 not(..) 取反辅助函数(在函数式编程库中又被称作 complement(..)):

1
2
3
4
5
6
7
8
9
10
11
function not(predicate) {
return function negated(...args){
return !predicate( ...args );
};
}

// ES6 箭头函数形式
var not =
predicate =>
(...args) =>
!predicate( ...args );

传入的predicate为断言,也就是条件。

现在我们可以用not函数来修改上面的例子:

1
2
3
var isLongEnough = not( isShortEnough )

printIf( isLongEnough, msg2 ) // Hello World

when函数

到目前位置,上面的案例已经被我们优化的不错了。但是也许还能再进一步,我们实际上可以将 printIf(..) 函数本身重构成无形参风格。

我们可以用 when(..) 实用函数来表示 if 条件句:

1
2
3
4
5
6
7
8
9
10
11
function when (predicate, fn) {
return function conditional (...args) {
if (predicate(...args)) {
return fn(...args)
}
}
}
// ES6
var when = (predicate, fn) =>
(...args) =>
predicate(...args) ? fn(...args) : undefined

我们把本章前面讲到的另一些辅助函数和 when(..) 函数结合起来搞定无形参风格的 printIf(..) 函数:

1
var printIf = uncurry( partialRight( when, output ) );

我们是这么做的:将 output 方法右偏应用为 when(..) 函数的第二个(fn 形参)实参,这样我们得到了一个仍然期望接收第一个实参(predicate 形参)的函数。当该函数被调用时,会产生另一个期望接收(译者注:需要被打印的)信息字符串的函数,看起来就是这样:fn(predicate)(str)

多个(两个)链式函数的调用看起来很挫,就像被柯里化的函数。于是我们用 uncurry(..) 函数处理它,得到一个期望接收 strpredicate 两个实参的函数,这样该函数的签名就和 printIf(predicate,str) 原函数一样了。

printIf案例

现在我们可以将上面的printIf整理一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function output(msg) {
console.log( msg );
}

function isShortEnough(str) {
return str.length <= 5;
}

var isLongEnough = not( isShortEnough );

var printIf = uncurry( partialRight( when, output ) );

var msg1 = "Hello";
var msg2 = msg1 + " World";

printIf( isShortEnough, msg1 ); // Hello
printIf( isShortEnough, msg2 );

printIf( isLongEnough, msg1 );
printIf( isLongEnough, msg2 ); // Hello World

list案例

实现功能:

若是列表中的数大于3则添加进greater,否则添加进less

这种简单的案例虽然可以用十分简单的方式来实现,但是为了习惯函数式编程的写法,所以算是我做一个小练习吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var list = [1, 2, 3, 4, 5]
var greater = []
var less = []
var isGreater = (val) => val > 3;
var pushGreater = (val) => greater.push(val);
var pushLess = (val) => less.push(val);
var handleFn = fn => uncurry( partialRight( when, fn ) )
var setGreater = handleFn( pushGreater )
var setLess = handleFn( pushLess )
function handleList (list) {
list.forEach(val => {
setGreater(isGreater, val)
setLess(not(isGreater), val)
})
console.log('greater', greater)
console.log('less', less)
}
handleList(list)
// greater [4, 5]
// less [1, 2, 3]

总结

偏应用是用来减少函数的参数数量 —— 一个函数期望接收的实参数量 —— 的技术,它减少参数数量的方式是创建一个预设了部分实参的新函数。

柯里化是偏应用的一种特殊形式,其参数数量降低为 1,这种形式包含一串连续的链式函数调用,每个调用接收一个实参。当这些链式调用指定了所有实参时,原函数就会拿到收集好的实参并执行。你同样可以将柯里化还原。

其它类似 unary(..)identity(..) 以及 constant(..) 的重要函数操作,是函数式编程基础工具库的一部分。

无形参是一种书写代码的风格,这种风格移除了非必需的形参映射实参逻辑,其目的在于提高代码的可读性和可理解性。

评论