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

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

1. 函数式编程能解决的问题

  • 可扩展性–我是否需要不断地重构代码来支持额外的功能?
  • 易模块化–如果我更改了一个文件,另一个文件是否会受到影响?
  • 可重用性–是否有很多重复的代码?
  • 可测性–给这些函数添加单元测试是否让我纠结?
  • 易推理性–我写的代码是否非结构化严重并难以推理?

2. 学习之前你需要了解的一些概念

函数输入

在数学中,函数总是获取一些输入值,然后给出一个输出值。

但在程序中,它或许有许多个输入值,或许没有。它或许有一个输出值( return 值),或许没有。

从上述的定义出发,所有的函数都需要输入。

大多数情况下,人们把函数的输入值称为 “arguments” 或者 “parameters” 。所以它到底是什么?

arguments 是你输入的值(实参), parameters 是函数中的命名变量(形参),用于接收函数的输入值。例子如下:

1
2
3
4
5
6
function fn (x, y) {
console.log(x, y)
}
fn(3, 4)

// 3, 4

34是函数 fn(..) 调用的 argumentsxyparameters,用于接收参数值(分别为 34 )。

javascript中定义的形参和实参可以是不等的:

1
2
3
4
5
6
function fn (x, y) {
console.log(x, y)
}
fn(3)

// 3, undefined

你传入少于声明形参个数的实参,所有缺少的参数将会被赋予 undefined 变量,意味着你仍然可以在函数作用域中使用它,但值是 undefined

输入计数

一个函数所“期望”的实参个数是取决于已声明的形参个数,即你希望传入多少参数。

如以下函数:

1
2
3
function fn (x, y, z) {
console.log(x, y, z)
}

fn期望三个参数,因为它声明了三个形参。这里有一个特殊的术语:Arity。Arity 指的是一个函数声明的形参数量。 fn(..) 的 Arity 是 3

函数的length

你可能需要在程序运行时获取函数的 Arity,使用函数的 length 属性即可。

1
2
3
4
function fn (x, y, z) {
console.log(x, y, z)
}
fn.length // 3

提示: 函数的 length 属性是一个只读属性,并且它是在最初声明函数的时候就被确定了。它应该当做用来描述如何使用该函数的一个基本元数据。

引入ES6特性的一些函数的length:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo(x,y = 2) {
// ..
}

function bar(x,...args) {
// ..
}

function baz( {a,b} ) {
// ..
}

foo.length; // 1
bar.length; // 1
baz.length; // 1

arguments

上面我们已经知道了,可以用函数的length属性来获取到这个函数的形参个数,但是在实际使用时,我们更需要知道的是函数传入的实参个数,此时可以使用每个函数都有的arguments对象(类数组),来获取到传入的实参个数。

如下面这个例子:

1
2
3
4
5
function foo(x,y,z) {
console.log( arguments.length ); // 2
}

foo( 3, 4 );

由于 ES5(特别是严格模式下)的 arguments 不被一些人认同,很多人尽可能地避免使用。尽管如此,它永远不会被移除,这是因为在 JS 中我们“永远不会”因为便利性而去牺牲向后的兼容性,但是大多数人还是不建议使用它。

所以你只需要知道以下几点:

  1. 当你需要知道参数个数的时候,arguments.length 还是可以用的。
  2. 不要通过 arguments[1] 访问参数的位置。只要记住 arguments.length

…ES6获取剩余参数

当你需要像数组那样访问参数,很有可能的原因是你想要获取的参数没有在一个规范的位置。我们如何处理?

ES6 救星来了!让我们用 ... 操作符声明我们的函数,也被当做 “spread”、“rest” 或者 “gather” (我比较偏爱)提及。

1
2
3
function foo(x,y,z,...args) {
// ..
}

看到参数列表中的 ...args 了吗?那就是 ES6 用来告诉解析引擎获取所有剩余的未命名参数,并把它们放在一个真实的命名为 args 的数组。args 无论是不是空的,它永远是一个数组。但它不包含已经命名的 xyz 参数,只会包含超出前三个值的传入参数.

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

foo(); // undefined undefined undefined []
foo( 1, 2, 3 ); // 1 2 3 []
foo( 1, 2, 3, 4 ); // 1 2 3 [ 4 ]
foo( 1, 2, 3, 4, 5 ); // 1 2 3 [ 4, 5 ]

甚至可以直接在参数列中使用 ... 操作符,没有其他正式声明的参数也没关系:

1
2
3
4
5
function foo(...args) {
console.log(args)
}

foo(1, 2); // [1, 2]

现在 args 是一个由参数组成的完整数组,你可以尽情使用 args.length 来获取传入的参数。你也可以安全地使用 args[1] .

关于实参的小技巧

如果你希望调用函数的时候只传一个数组代替之前的多个参数,该怎么办?

1
2
3
4
5
6
7
function foo(...args) {
console.log( args[3] );
}

var arr = [ 1, 2, 3, 4, 5 ];

foo( ...arr ); // 4

操作符在这里也被用到了。

在形参中使用的时候function foo(…args){},是将形参整合,形成一个数组:

1
2
3
function foo(x, y){}
// 经过...之后变为
function foo([x, y]){}

在实参中使用的时候foo(…arr),是将实参展开:

1
2
3
foo([1, 2, 3])
// 经过...之后变为
foo(1, 2)

你甚至可以多个值和一起来使用:

1
2
3
4
5
6
7
8
function foo(...args) {
console.log( args[3] );
}

var arr = [ 2 ];

foo( 1, ...arr, 3, ...[4,5] ); // 4
// 相当于是foo(1, 2, 3, 4, 5); 所以获取到的args[3]为4

关于形参的小技巧

默认参数

在 ES6 中,形参可以声明默认值。当形参没有传入到实参中,或者传入值是 undefined,会进行默认赋值的操作:

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

foo(); // 3
foo( undefined ); // 3
foo( null ); // null
foo( 0 ); // 0

解构

现在有这么一个函数:

1
2
3
4
5
function foo(params) {
console.log(params)
}

foo([1, 2, 3])

我在拿到args数组之后,想要命名传入数组的第 1、2 个值,也许你可以这么做:

1
2
3
4
5
6
7
8
9
// example1
function foo(params) {
console.log(params) // [1, 2, 3]
var x = params[0] // 1
var y = params[1] // 2
var args = params.slice( 2 ) // [3] (args就是剩余的参数集合)
}

foo([1, 2, 3])

现在你可以用更酷的ES解构的方式来写这个函数:

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

foo( [1,2,3] );

上面的这种[x, y, …args] = []就是一种数组解构,解构是通过你期望的模式来描述数据(对象,数组等),并分配(赋值)值的一种方式。

在这里例子中,解构告诉解析器,一个数组应该出现的赋值位置(即参数)。这种模式是:拿出数组中的第一个值,并且赋值给局部参数变量 x,第二个赋值给 y,剩下的则组成 args

同样这种解构也可以用在对象中,称为对象解构

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

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

我们传入一个对象作为一个参数,它解构成两个独立的参数变量 xy,从传入的对象中分配相应属性名的值。我们不在意属性值 x 到底存不存在对象上,如果不存在,它最终会如你所想被赋值为 undefined

通过上面的学习,我们需要认识到很重要的一个原则:声明性代码通常比命令式代码更干净

声明性代码也就是上面的example2,在定义形参的时候就给参数命好名,而example1就是命令式代码,它在拿到形参之后再进行命名。

随着输入而变化的函数

现在有这么一个函数:

1
2
3
4
5
6
7
8
function foo(x,y) {
if (typeof x == "number" && typeof y == "number") {
return x * y;
}
else {
return x + y;
}
}

明显地,这个函数会根据你传入的值而有所不同,比如:

1
2
3
foo( 3, 4 );			// 12

foo( "3", 4 ); // "34"

程序员这样定义函数的原因之一是,更容易通过同一个函数来重载不同的功能。乍一看这样设计一个函数好像很方便,使得我们的函数可以有很多不同的行为,其实通过不同的输入值让一个函数重载拥有不同的行为的技巧叫做特定多态(ad hoc polymorphism)。但在函数式编程中,要对方便的诱惑有警惕之心。因为你可以通过这种方式设计一个函数,即使可以立即使用,但这个设计的长期成本可能会让你后悔。

函数输出

在 JavaScript 中,函数只会返回一个值。下面的三个函数都有相同的 return 操作。

1
2
3
4
5
6
7
8
9
function foo() {}

function bar() {
return;
}

function baz() {
return undefined;
}

如果你没有 return 值,或者你使用 return;,那么则会隐式地返回 undefined 值。

如果想要尽可能靠近函数式编程的定义:使用函数而非程序,那么我们的函数必须永远有返回值。这也意味着他们必须明确地 return 一个值,通常这个值也不是 undefined

上面已经说了一个函数只能有一个返回值,那么如果我们现在想要一次返回多个值怎么办?切实可行的办法就是把你需要返回的值放到一个复合值当中去,例如数组、对象:

1
2
3
4
5
6
function foo() {
var retValue1 = 11;
var retValue2 = 31;
return [ retValue1, retValue2 ];
// 或者 return { retValue1, retValue2 }
}

解构方法可以使用于解构对象或者数组类型的参数,也可以使用在平时的赋值当中:

1
2
3
4
5
6
7
8
function foo() {
var retValue1 = 11;
var retValue2 = 31;
return [ retValue1, retValue2 ];
}

var [ x, y ] = foo();
console.log( x + y ); // 42

将多个值集合成一个数组(或对象)做为返回值,然后再解构回不同的值,这无形中让一个函数能有多个输出结果。

提前return

return 语句不仅仅是从函数中返回一个值,它也是一个流量控制结构,它可以结束函数的执行。因此,具有多个 return语句的函数具有多个可能的退出点,这意味着如果输出的路径很多,可能难以读取并理解函数的输出行为。

现在有这么一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(x) {
if (x > 10) return x + 1;

var y = x / 2;

if (y > 3) {
if (x % 2 == 0) return x;
}

if (y > 1) return y;

return x;
}

请思考 foo(2) 返回什么? foo(4) 返回什么? foo(8)foo(12) 呢?

你对自己的回答有多少信心?你付出多少精力来获得答案?

我认为在许多可读性的问题上,是因为我们不仅使用 return 返回不同的值,更把它作为一个流控制结构——在某些情况下可以提前退出一个函数的执行。我们显然有更好的方法来编写流控制( if 逻辑等),也有办法使输出路径更加明显。

上面的答案分别是:2,2,8,13

现在我们把上面的代码换个版本:

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 foo(x) {
var retValue;

if (retValue == undefined && x > 10) {
retValue = x + 1;
}

var y = x / 2;

if (y > 3) {
if (retValue == undefined && x % 2 == 0) {
retValue = x;
}
}

if (retValue == undefined && y > 1) {
retValue = y;
}

if (retValue == undefined) {
retValue = x;
}

return retValue;
}

这个版本毫无疑问是更冗长的。但是在逻辑上,我认为这比上面的代码更容易理解。因为在每个 retValue 可以被设置的分支, 这里都有个守护者以确保 retValue 没有被设置过才执行。

相比在函数中提早使用 return,我们更应该用常用的流控制( if 逻辑 )来控制 retValue 的赋值。到最后,我们 return retValue

未return的输出和纯函数

有个技巧你可能在你的大多数代码里面使用过,并且有可能你自己并没有特别意识到,那就是让一个函数通过改变函数体外的变量产出一些值。

比如我们现在想要设计一个这样功能的函数:f(x) = 2x + 3:

1
2
3
4
5
6
var y;
function foo(x) {
y = 2 * x + 3;
}
foo( 2 );
y; // 7

或许我们完全可以用 return 来返回,而不是赋值给 y

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

var y = foo( 2 );

y; // 7

这两个函数完成相同的任务。解释这两者不同的一种方法是,后一个版本中的 return 表示一个显式输出,而前者的 y 赋值是一个隐式输出。在这种情况下,我们开发人员肯定更喜欢显示模式而非隐式。

但是,改变一个外部作用域的变量,就像我们在 foo(..) 中所做的赋值 y 一样,只是实现隐式输出的一种方式。一个更微妙的例子是通过引用对非局部值进行更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function sum(list) {
var total = 0;
for (let i = 0; i < list.length; i++) {
if (!list[i]) list[i] = 0;

total = total + list[i];
}

return total;
}

var nums = [ 1, 3, 9, 27, , 84 ];

sum( nums ); // 124

上面的例子中,我们知道这个函数输出为124,并且也非常明确的return了,但你是否发现其他的输出?查看代码,并检查 nums 数组。你发现区别了吗?

为了填补 4 位置的空值 undefined,这里使用了 0 代替。尽管我们在局部操作 list 参数变量,但我们仍然影响了外部的数组。

因为 list 使用了 nums 的引用,不是对 [1,3,9,..] 的值复制,而是引用复制。因为 JS 对数组、对象和函数都使用引用和引用复制,我们可以很容易地从函数中创建输出,即使是无心的。

这个隐式函数输出在函数式编程中有一个特殊的名称:副作用。当然,没有副作用的函数也有一个特殊的名称:纯函数。我们将在以后的章节讨论这些,但关键是我们应该喜欢纯函数,并且要尽可能地避免副作用。

函数功能

高阶函数

函数是可以接受并且返回任何类型的值。一个函数如果可以接受或返回一个甚至多个函数,它被叫做高阶函数。

例如:

1
2
3
4
5
6
7
8
9
10
function forEach(list,fn) {
for (let i = 0; i < list.length; i++) {
fn( list[i] );
}
}

forEach( [1,2,3,4,5], function each(val){
console.log( val );
} );
// 1 2 3 4 5

forEach(..) 就是一个高阶函数,因为它可以接受一个函数作为参数。

一个高阶函数同样可以把一个函数作为输出,像这样:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var fn = function inner(msg){
console.log( msg );
};

return fn;
}

var f = foo();

f( "Hello!" ); // Hello!

return 不是“输出”函数的唯一办法。可以看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var fn = function inner(msg){
console.log( msg );
};

bar( fn );
}

function bar(func) {
func( "Hello!" );
}

foo(); // Hello!

如果你感觉看起来很吃力,或者不理解什么意思,我建议你最好自己手敲一遍,并要习惯这样的写法,因为将其他函数视为值的函数是高阶函数的定义。函数式编程者们应该学会这样写!

保持作用域

在所有编程,尤其是函数式编程中,最强大的就是:当一个函数内部存在另一个函数的作用域时,对当前函数进行操作。当内部函数从外部函数引用变量,这被称作闭包。

比如我们来看一些闭包的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function person(id) {
var randNumber = Math.random();

return function identify(){
console.log( "I am " + id + ": " + randNumber );
};
}

var fred = person( "Fred" );
var susan = person( "Susan" );

fred(); // I am Fred: 0.8331252801601532
susan(); // I am Susan: 0.3940753308893741

identify() 函数内部有两个闭包变量,参数 idrandNumber

闭包不仅限于获取变量的原始值:它不仅仅是快照,而是直接链接。你可以更新该值,并在下次访问时获取更新后的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function runningCounter(start) {
var val = start;

return function current(increment = 1){
val = val + increment;
return val;
};
}

var score = runningCounter( 0 );

score(); // 1
score(); // 2
score( 13 ); // 15

上面的写法,就是闭包中一个典型的例子。

我们将函数runningCounter()函数赋值给变量score,此时将0传递进函数中,也就是start变量,并将其赋值给val变量。在后面调用score()函数的时候,传递的实参也就是传递给runningCounter()函数的返回值current()函数。

执行第一个score()函数的时候就相当于执行:

1
2
3
4
current(1) {
val = 0 + 1;
return val
}

此时runningCounter函数中的变量val变为了1并且被记录下来(这也意味着下次在调用的时候val的值还是1)。

执行第二个时:

1
2
3
4
current(1) {
val = 1 + 1;
return val
}

第三次:

1
2
3
4
current(1) {
val = 2 + 13;
return val
}

利用js闭包的这种特性,我们可以做下面的事:

如果你需要设置两个输入,一个你已经知道,另一个还需要后面才能知道,你可以使用闭包来记录第一个输入值,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function makeAdder(x) {
return function sum(y){
return x + y;
};
}

//我们已经分别知道作为第一个输入的 10 和 37
var addTo10 = makeAdder( 10 );
var addTo37 = makeAdder( 37 );

// 紧接着,我们指定第二个参数
addTo10( 3 ); // 13
addTo10( 90 ); // 100

addTo37( 13 ); // 50

通常, sum(..) 函数会一起接收 xy 并相加。但是在这个例子中,我们接收并且首先记录(通过闭包) x 的值,然后等待 y 被指定。

在连续函数调用中指定输入,这种技巧在函数式编程中非常普遍,并且有两种形式:偏函数应用和柯里化。在后面的章节中我们会进行详细的讲解。

当然,因为函数如果只是 JS 中的值,我们可以通过闭包来记住函数值,就像这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

function formatter(formatFn) {
return function inner(str){
return formatFn( str );
};
}

var lower = formatter( function formatting(v){
return v.toLowerCase();
} );

var upperFirst = formatter( function formatting(v){
return v[0].toUpperCase() + v.substr( 1 ).toLowerCase();
} );

lower( "WOW" ); // wow
upperFirst( "hello" ); // Hello

我知道看到这里你可能就有疑问了,感觉上面的代码是否有太多重复的逻辑,而且为什么要有一个formatter()函数来做一个中间件样的载体。

函数式编程并不是在我们的代码中分配或重复 toUpperCase()toLowerCase() 逻辑,而是鼓励我们用优雅的封装方式来创建简单的函数。

具体来说,我们创建两个简单的一元函数 lower(..)upperFirst(..),因为这些函数在我们程序中,更容易与其他函数配合使用。

后语

这一章节主要是介绍了函数式编程的一些入门知识点,为后面更加复杂的知识点打好基础。
参考资料:
Functional-Light-JS

评论