第 2 章:函数基础
函数式编程不是仅仅用 function
这个关键词来编程。如果真这么简单,那我这本书可以到此为止了!重点在于:函数是函数式编程的核心。这也是如何使用函数(function)才能使我们的代码具有函数式(functional)的方法。
然而,你真的明白函数的含义吗?
在这一章,我们将会介绍函数的基础知识,为阅读本书的后续章节打下基础。从某些方面来讲,这章回顾的函数知识并不是针对函数式编程者,非函数式编程者同样需要了解。但如果我们想要充分、全面地学习函数式编程的概念,我们需要从里到外地理解函数。
请做好准备,因为还有好多你未知的函数知识。
什么是函数?
针对函数式编程,很自然而然的我会想到从函数开始。这太明显不过了,但是我认为我们需要扎实地走好旅程的第一步。
所以......什么是函数?
简要的数学回顾
我知道我曾说过,离数学越远越好,但是让我们暂且忍一小段时间,在这段时间里,我们会尽快地回顾在代数中一些函数和图像的基本知识。
你还记得你在学校里学习任何有关 f(x)
的知识吗?还有方程 y = f(x)
?
现有方程式定义如下:<code>f(x) = 2x2 + 3</code>。这个方程有什么意义?它对应的图像是什么样的呢?如下图:
你可以注意到:对于 x
取任意值,例如 2
,带入方程后会得到 11
。这里的 11
代表函数的返回值,更简单来说就是 y
值。
根据上述,现在有一个点 (2,11)
在图像的曲线上,并且当我们有一个 x
值,我们都能获得一个对应的 y
值。把两个值组合就能得到一个点的坐标,例如 (0,3)
, (-1,5)
。当把所有的这些点放在一起,就会获得这个抛物线方程的图像,如上图所示。
所以,这些和函数式编程有什么关系?
在数学中,函数总是获取一些输入值,然后给出一个输出值。你能听到一个函数式编程的术语叫做“态射”:这是一个优雅的方式来描述一组值和另一组值的映射关系,就像一个函数的输入值与输出值之间的关联关系。
在代数数学中,那些输入值和输出值经常代表着绘制坐标的一部分。不过,在我们的程序中,我们可以定义函数有各种的输入和输出值,并且它们不需要和绘制在图表上的曲线有任何关系。
函数 vs 程序
为什么所有的讨论都围绕数学和图像?因为在某种程度上,函数式编程就是使用在数学意义上的方程作为函数。
你可能会习以为常地认为函数就是程序。它们之间的区别是什么?程序就是一个任意的功能集合。它或许有许多个输入值,或许没有。它或许有一个输出值( return
值),或许没有。
而函数则是接收输入值,并明确地 return
值。
如果你计划使用函数式编程,你应该尽可能多地使用函数,而不是程序。你所有编写的 function
应该接收输入值,并且返回输出值。这么做的原因是多方面的,我们将会在后面的书中来介绍的。
函数输入
从上述的定义出发,所有的函数都需要输入。
你有时听人们把函数的输入值称为 “arguments” 或者 “parameters” 。所以它到底是什么?
arguments 是你输入的值(实参), parameters 是函数中的命名变量(形参),用于接收函数的输入值。例子如下:
function foo(x,y) {
// ..
}
var a = 3;
foo( a, a * 2 );
a
和 a * 2
(即为 6
)是函数 foo(..)
调用的 arguments。x
和 y
是 parameters,用于接收参数值(分别为 3
和 6
)。
注意: 在 JavaScript 中,实参的个数没必要完全符合形参的个数。如果你传入许多个实参,而且多过你所声明的形参,这些值仍然会原封不动地被传入。你可以通过不同的方式去访问,包含了你以前可能听过的老办法 —— arguments
对象。反之,你传入少于声明形参个数的实参,所有缺少的参数将会被赋予 undefined
变量,意味着你仍然可以在函数作用域中使用它,但值是 undefined
。
输入计数
一个函数所“期望”的实参个数是取决于已声明的形参个数,即你希望传入多少参数。
function foo(x,y,z) {
// ..
}
foo(..)
期望三个实参,因为它声明了三个形参。这里有一个特殊的术语:Arity。Arity 指的是一个函数声明的形参数量。 foo(..)
的 Arity 是 3
。
你可能需要在程序运行时获取函数的 Arity,使用函数的 length
属性即可。
function foo(x,y,z) {
// ..
}
foo.length; // 3
在执行时要确定 Arity 的一个原因是:一段代码接受一个函数的指针引用,有可能这个引用指向不同来源,我们要根据这些来源的 Arity 传入不同的参数值。
举个例子,如果 fn
可能指向的函数分别期望 1、2 或 3 个参数,但你只希望把变量 x
放在最后的位置传入:
// fn 是一些函数的引用
// x 是存在的值
if (fn.length == 1) {
fn( x );
}
else if (fn.length == 2) {
fn( undefined, x );
}
else if (fn.length == 3) {
fn( undefined, undefined, x );
}
提示: 函数的 length
属性是一个只读属性,并且它是在最初声明函数的时候就被确定了。它应该当做用来描述如何使用该函数的一个基本元数据。
需要注意的是,某些参数列表的变量会让 length
属性变得不同于你的预期。别紧张,我们将会在后续的章节逐一解释这些特性(引入 ES6):
function foo(x,y = 2) {
// ..
}
function bar(x,...args) {
// ..
}
function baz( {a,b} ) {
// ..
}
foo.length; // 1
bar.length; // 1
baz.length; // 1
如果你使用这些形式的参数,你或许会被函数的 length
值吓一跳。
那我们怎么得到当前函数调用时所接收到的实参个数呢?这在以前非常简单,但现在情况稍微复杂了一些。每一个函数都有一个 arguments
对象(类数组)存放需要传入的参数。你可以通过 arguments
的 length
值来找出有多少传入的参数:
function foo(x,y,z) {
console.log( arguments.length ); // 2
}
foo( 3, 4 );
由于 ES5(特别是严格模式下)的 arguments
不被一些人认同,很多人尽可能地避免使用。尽管如此,它永远不会被移除,这是因为在 JS 中我们“永远不会”因为便利性而去牺牲向后的兼容性,但我还是强烈建议不要去使用它。
然而,当你需要知道参数个数的时候,arguments.length
还是可以用的。在未来版本的 JS 或许会新增特性来替代 arguments.length
,如果成真,那么我们可以完全把 arguments
抛诸脑后。
请注意:不要通过 arguments[1]
访问参数的位置。只要记住 arguments.length
。
除此之外,你或许想知道如何访问那些超出声明的参数?这个问题我一会儿会告诉你,不过你先要问自己的问题是,“为什么我想要知道这个?”。认真地思考一段时间。
发生这种情况应该是非常罕见的。因为这不会是你日常需要的,也不会是你编写函数时所必要的东西。如果这种情况真的发生,你应该花 20 分钟来试着重新设计函数,或者命名那些多出来的参数。
带有可变数量参数的函数被称为 variadic。有些人更喜欢这样的函数设计,不过你会发现,这正是函数式编程者想要避免的。
好了,上面的重点已经讲得够多了。
例如,当你需要像数组那样访问参数,很有可能的原因是你想要获取的参数没有在一个规范的位置。我们如何处理?
ES6 救星来了!让我们用 ...
操作符声明我们的函数,也被当做 “spread”、“rest” 或者 “gather” (我比较偏爱)提及。
function foo(x,y,z,...args) {
// ..
}
看到参数列表中的 ...args
了吗?那就是 ES6 用来告诉解析引擎获取所有剩余的未命名参数,并把它们放在一个真实的命名为 args
的数组。args
无论是不是空的,它永远是一个数组。但它不包含已经命名的 x
,y
和 z
参数,只会包含超出前三个值的传入参数。
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 ]
所以,如果你诚心想要设计一个函数,并且计算出任意传入参数的个数,那就在最后用 ...args
(或任何你喜欢的名称)。现在你有一个真正的、好用的数组来获取这些参数值了。
你需要注意的是: 4
所在的位置是 args
的第 0
个,不是在第 3
个位置。它的 length
值也不包含 1
、2
和 3
,...args
剩下所有的值, 但不包括 x
、y
和 z
。
你甚至可以直接在参数列中使用 ...
操作符,没有其他正式声明的参数也没关系:
function foo(...args) {
// ..
}
现在 args
是一个由参数组成的完整数组,你可以尽情使用 args.length
来获取传入的参数。你也可以安全地使用 args[1]
或者 args[317]
。当然,别真的传 318 个参数!
说到 ES6 的好,你肯定想知道一些小秘诀。在这里将会介绍一些,更多的内容推荐你阅读《You Don't Know JS: ES6 & Beyond》这本书的第 2 章。
关于实参的小技巧
如果你希望调用函数的时候只传一个数组代替之前的多个参数,该怎么办?
function foo(...args) {
console.log( args[3] );
}
var arr = [ 1, 2, 3, 4, 5 ];
foo( ...arr ); // 4
我们的新朋友 ...
在这里被使用到了,但不仅仅在形参列表,在函数调用的时候,同样使用在实参列表。在这里的情况有所不同:在形参列表,它把实参整合。在实参列表,它把实参展开。所以 arr
的内容是以函数 foo(..)
引用的单独参数进行展开。你能理解传入一个引用值和传入整个 arr
数组两者之间的不同了吗?
顺带一提,多个值和 ...
是可以相互交错放置的,如下:
var arr = [ 2 ];
foo( 1, ...arr, 3, ...[4,5] ); // 4
在对称的意义上来考虑 ...
:在值列表的情况,它会展开。在赋值的情况,它就像形参列表一样,因为实参会赋值到形参上。
无论采取什么行为, ...
都会让实参数组更容易操作。那些我们使用实参数组 slice(..)
,concat(..)
和 apply(..)
的日子已经过去了。
关于形参的小技巧
在 ES6 中,形参可以声明默认值。当形参没有传入到实参中,或者传入值是 undefined
,会进行默认赋值的操作。
思考下面代码:
function foo(x = 3) {
console.log( x );
}
foo(); // 3
foo( undefined ); // 3
foo( null ); // null
foo( 0 ); // 0
注意: 我们不会更加详细地解释了,但是默认值表达式是惰性的,这意味着仅当需要的时候,它才会被计算。它同样也可以是一些有效的 JS 表达式,甚至一个函数引用。许多非常酷的小技巧用到了这个方法。例如,你可以这样在你的参数列声明 x = required()
,并且在函数 required()
中 抛出 "This argument is required."
来确信总有人用你指定的实参或形参来引用你的函数。
另一个我们可以在参数中使用的 ES6 技巧,被称为“解构”。在这里我们只会简单一提,因为要说清这个话题实在太过繁杂。在这里推荐《ES6 & Beyond》这本书了解更多信息。
还记得我们之前提到的可以接受 318 个参数的 foo(..)
吗?
function foo(...args) {
// ..
}
foo( ...[1,2,3] );
如果我们想要把函数内的参数从一个个单独的参数值替换为一个数组,应该怎么做?这里有两个 ...
的写法:
function foo(args) {
// ..
}
foo( [1,2,3] );
这个非常简单。但如果我们想要命名传入数组的第 1、2 个值,该怎么做?我们不能用单独传入参数的办法了,所以这似乎看起来无能为力。不过解构可以回答这个问题:
function foo( [x,y,...args] = [] ) {
// ..
}
foo( [1,2,3] );
你看到了在参数列出现的 [ .. ]
了吗?这就是数组解构。解构是通过你期望的模式来描述数据(对象,数组等),并分配(赋值)值的一种方式。
在这里例子中,解构告诉解析器,一个数组应该出现的赋值位置(即参数)。这种模式是:拿出数组中的第一个值,并且赋值给局部参数变量 x
,第二个赋值给 y
,剩下的则组成 args
。
你可以通过自己手动处理达到同样的效果:
function foo(params) {
var x = params[0];
var y = params[1];
var args = params.slice( 2 );
// ..
}
现在我们可以发现,在我们这本书中要多次提到的第一条原则:声明性代码通常比命令式代码更干净。
声明式代码,如同之前代码片段里的解构,强调一段代码的输出结果。命令式代码,像刚才我们自己手动赋值的例子,注重的是如何得到结果。如果你稍晚再读这一段代码,你必须在脑子里面再执行一遍才能得到你想要的结果。这个结果是编写在这儿,但是不是直接可见的。
只要可能,无论我们的语言和我们的库或框架允许我们达到什么程度,我们都应该尽可能使用声明性的和自解释的代码。
正如我们可以解构的数组,我们可以解构的对象参数:
function foo( {x,y} = {} ) {
console.log( x, y );
}
foo( {
y: 3
} ); // undefined 3
我们传入一个对象作为一个参数,它解构成两个独立的参数变量 x
和 y
,从传入的对象中分配相应属性名的值。我们不在意属性值 x
到底存不存在对象上,如果不存在,它最终会如你所想被赋值为 undefined
。
但是我希望你注意:对象解构的部分参数是将要传入 foo(..)
的对象。
现在有一个正常可用的调用现场 foo(undefined,3)
,它用于映射实参到形参。我们试着把 3
放到第二个位置,分配给 y
。但是在新的调用现场上用到了参数解构,一个简单的对象属性代表了实参 3
应该分配给形参(y
)。
我们不需要操心 x
应该放在哪个调用现场。因为事实上,我们不用去关心 x
,我们只需要省略它,而不是分配 undefined
值。
有一些语言对这样的操作有一个直接的特性:命名参数。换句话说,在调用现场,通过标记输入值来告诉它映射关系。JavaScript 没有命名参数,不过退而求其次,参数对象解构是一个选择。
使用对象解构来传入多个匿名参数是函数式编程的优势,这个优势在于使用一个参数(对象)的函数能更容易接受另一个函数的单个输出。这点会在后面讨论到。
回想一下,术语 Arity 是指期望函数接收多少个参数。Arity 为 1 的函数也被称为一元函数。在函数式编程中,我们希望我们的函数在任何的情况下是一元的,有时我们甚至会使用各种技巧来将高 Arity 的函数都转换为一元的形式。
注意: 在第 3 章,我们将重新讨论命名参数的解构技巧,并使用它来处理关于参数排序的问题。
随着输入而变化的函数
思考以下函数
function foo(x,y) {
if (typeof x == "number" && typeof y == "number") {
return x * y;
}
else {
return x + y;
}
}
明显地,这个函数会根据你传入的值而有所不同。
举例:
foo( 3, 4 ); // 12
foo( "3", 4 ); // "34"
程序员这样定义函数的原因之一是,更容易通过同一个函数来重载不同的功能。最广为人知的例子就是 jQuery 提供的 $(..)
。"$" 函数大约有十几种不同的功能 —— 从 DOM 元素查找,到 DOM 元素创建,到等待 “DOMContentLoaded” 事件后,执行一个函数,这些都取决于你传递给它的参数。
上述函数,显而易见的优势是 API 变少了(仅仅是一个 $(..)
函数),但缺点体现在阅读代码上,你必须仔细检查传递的内容,理解一个函数调用将做什么。
通过不同的输入值让一个函数重载拥有不同的行为的技巧叫做特定多态(ad hoc polymorphism)。
这种设计模式的另一个表现形式就是在不同的情况下,使函数具有不同的输出(在下一章节会提到)。
警告: 要对方便的诱惑有警惕之心。因为你可以通过这种方式设计一个函数,即使可以立即使用,但这个设计的长期成本可能会让你后悔。
函数输出
在 JavaScript 中,函数只会返回一个值。下面的三个函数都有相同的 return
操作。
function foo() {}
function bar() {
return;
}
function baz() {
return undefined;
}
如果你没有 return
值,或者你使用 return;
,那么则会隐式地返回 undefined
值。
如果想要尽可能靠近函数式编程的定义:使用函数而非程序,那么我们的函数必须永远有返回值。这也意味着他们必须明确地 return
一个值,通常这个值也不是 undefined
。
一个 return
的表达式仅能够返回一个值。所以,如果你需要返回多个值,切实可行的办法就是把你需要返回的值放到一个复合值当中去,例如数组、对象:
function foo() {
var retValue1 = 11;
var retValue2 = 31;
return [ retValue1, retValue2 ];
}
解构方法可以使用于解构对象或者数组类型的参数,也可以使用在平时的赋值当中:
function foo() {
var retValue1 = 11;
var retValue2 = 31;
return [ retValue1, retValue2 ];
}
var [ x, y ] = foo();
console.log( x + y ); // 42
将多个值集合成一个数组(或对象)做为返回值,然后再解构回不同的值,这无形中让一个函数能有多个输出结果。
提示: 在这里我十分建议你花一点时间来思考:是否需要避免函数有可重构的多个输出?或许将这个函数分为两个或更多个更小的单用途函数。有时会需要这么做,有时可能不需要,但你应该至少考虑一下。
提前 return
return
语句不仅仅是从函数中返回一个值,它也是一个流量控制结构,它可以结束函数的执行。因此,具有多个 return
语句的函数具有多个可能的退出点,这意味着如果输出的路径很多,可能难以读取并理解函数的输出行为。
思考以下:
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
。
思考以下版本的代码:
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
,或你不应该提早 return
,我只是认为在定义函数时,最好不要用 return
来实现流控制,这样会创造更多的隐含意义。尝试找出最明确的表达逻辑的方式,这往往是最好的办法。
未 return
的输出
有个技巧你可能在你的大多数代码里面使用过,并且有可能你自己并没有特别意识到,那就是让一个函数通过改变函数体外的变量产出一些值。
还记得我们之前提到的函数<code>f(x) = 2x2 + 3</code>吗?我们可以在 JS 中这样定义:
var y;
function foo(x) {
y = (2 * Math.pow( x, 2 )) + 3;
}
foo( 2 );
y; // 11
我知道这是一个无聊的例子。我们完全可以用 return
来返回,而不是赋值给 y
:
function foo(x) {
return (2 * Math.pow( x, 2 )) + 3;
}
var y = foo( 2 );
y; // 11
这两个函数完成相同的任务。我们有什么理由要从中挑一个吗?是的,绝对有。
解释这两者不同的一种方法是,后一个版本中的 return
表示一个显式输出,而前者的 y
赋值是一个隐式输出。在这种情况下,你可能已经猜到了:通常,开发人员喜欢显式模式而不是隐式模式。
但是,改变一个外部作用域的变量,就像我们在 foo(..)
中所做的赋值 y
一样,只是实现隐式输出的一种方式。一个更微妙的例子是通过引用对非局部值进行更改。
思考:
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 对数组、对象和函数都使用引用和引用复制,我们可以很容易地从函数中创建输出,即使是无心的。
这个隐式函数输出在函数式编程中有一个特殊的名称:副作用。当然,没有副作用的函数也有一个特殊的名称:纯函数。我们将在以后的章节讨论这些,但关键是我们应该喜欢纯函数,并且要尽可能地避免副作用。
函数功能
函数是可以接受并且返回任何类型的值。一个函数如果可以接受或返回一个甚至多个函数,它被叫做高阶函数。
思考:
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(..)
就是一个高阶函数,因为它可以接受一个函数作为参数。
一个高阶函数同样可以把一个函数作为输出,像这样:
function foo() {
var fn = function inner(msg){
console.log( msg );
};
return fn;
}
var f = foo();
f( "Hello!" ); // Hello!
return
不是“输出”函数的唯一办法。
function foo() {
var fn = function inner(msg){
console.log( msg );
};
bar( fn );
}
function bar(func) {
func( "Hello!" );
}
foo(); // Hello!
将其他函数视为值的函数是高阶函数的定义。函数式编程者们应该学会这样写!
保持作用域
在所有编程,尤其是函数式编程中,最强大的就是:当一个函数内部存在另一个函数的作用域时,对当前函数进行操作。当内部函数从外部函数引用变量,这被称作闭包。
实际上,闭包是它可以记录并且访问它作用域外的变量,甚至当这个函数在不同的作用域被执行。
思考:
function foo(msg) {
var fn = function inner(){
console.log( msg );
};
return fn;
}
var helloFn = foo( "Hello!" );
helloFn(); // Hello!
处于 foo(..)
函数作用域中的 msg
参数变量是可以在内部函数中被引用的。当 foo(..)
执行时,并且内部函数被创建,函数可以获取 msg
变量,即使 return
后仍可被访问。
虽然我们有函数内部引用 helloFn
,现在 foo(..)
执行后,作用域应该回收,这也意味着 msg
也不存在了。不过这个情况并不会发生,函数内部会因为闭包的关系,将 msg
保留下来。只要内部函数(现在被处在不同作用域的 helloFn
引用)存在, msg
就会一直被保留。
让我们看看闭包作用的一些例子:
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()
函数内部有两个闭包变量,参数 id
和 randNumber
。
闭包不仅限于获取变量的原始值:它不仅仅是快照,而是直接链接。你可以更新该值,并在下次访问时获取更新后的值。
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
警告: 我们将在之后的段落中介绍更多。不过在这个例子中,你需要尽可能避免使用闭包来记录状态更改(val
)。
如果你需要设置两个输入,一个你已经知道,另一个还需要后面才能知道,你可以使用闭包来记录第一个输入值:
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(..)
函数会一起接收 x
和 y
并相加。但是在这个例子中,我们接收并且首先记录(通过闭包) x
的值,然后等待 y
被指定。
注意: 在连续函数调用中指定输入,这种技巧在函数式编程中非常普遍,并且有两种形式:偏函数应用和柯里化。我们稍后会在文中深入讨论。
当然,因为函数如果只是 JS 中的值,我们可以通过闭包来记住函数值。
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
函数式编程并不是在我们的代码中分配或重复 toUpperCase()
和 toLowerCase()
逻辑,而是鼓励我们用优雅的封装方式来创建简单的函数。
具体来说,我们创建两个简单的一元函数 lower(..)
和 upperFirst(..)
,因为这些函数在我们程序中,更容易与其他函数配合使用。
提示: 你知道如何让 upperFirst(..)
使用 lower(..)
吗?
我们将在本书的后续中大量使用闭包。如果抛开整个编程来说,它可能是所有函数式编程中最重要的基础。希望你能用得舒服!
句法
在我们函数入门开始之前,让我们花点时间来讨论它的语法。
不同于本书中的许多其他部分,本节中的讨论主要是意见和偏好,无论你是否同意这里提出的观点或采取相反的观点。这些想法是非常主观的,尽管许多人似乎对此非常执着。不过最终,都由你决定。
什么是名称?
在语法上,函数声明需要包含一个名称:
function helloMyNameIs() {
// ..
}
但是函数表达式可以命名或者匿名:
foo( function namedFunctionExpr(){
// ..
} );
bar( function(){ // <-- 这就是匿名的!
// ..
} );
顺便说一句,匿名的意思是什么?具体来说,函数具有一个 name
的属性,用于保存函数在语法上设定名称的字符串值,例如 "helloMyNameIs"
或 "FunctionExpr"
。 这个name
属性特别用于 JS 环境的控制台或开发工具。当我们在堆栈轨迹中追踪(通常来自异常)时,这个属性可以列出该函数。
而匿名函数通常显示为:(anonymous function)
。
如果你曾经试着在一个异常的堆栈轨迹中调试一个 JS 程序,你可能已经发现痛苦了:看到 (anonymous function)
出现。这个列表条目不给开发人员任何关于异常来源路径的线索。它没有给我们开发者提供任何帮助。
如果你命名了你的函数表达式,名称将会一直被使用。所以如果你使用了一个良好的名称 handleProfileClicks
来取代 foo
,你将会在堆栈轨迹中获得更多的信息。
在 ES6 中,匿名表达式可以通过名称引用来获得名称。思考:
var x = function(){};
x.name; // x
如果解析器能够猜到你可能希望函数采用什么名称,那么它将会继续下去。
但请注意,并不是所有的句法形式都可以用名称引用。最常见的地方是函数表达式是函数调用的参数:
function foo(fn) {
console.log( fn.name );
}
var x = function(){};
foo( x ); // x
foo( function(){} ); //
当名称不能直接从周围的语法中被推断时,它仍会是一个空字符串。这样的函数将在堆栈轨迹中的被报告为一个 (anonymous function)
。
除了调试问题之外,函数被命名还有一个其他好处。首先,句法名称(又称词汇名)是可以被函数内部的自引用。自引用是递归(同步和异步)所必需的,也有助于事件处理。
思考这些不同的情况:
// 同步情况:
function findPropIn(propName,obj) {
if (obj == undefined || typeof obj != "object") return;
if (propName in obj) {
return obj[propName];
}
else {
let props = Object.keys( obj );
for (let i = 0; i < props.length; i++) {
let ret = findPropIn( propName, obj[props[i]] );
if (ret !== undefined) {
return ret;
}
}
}
}
// 异步情况:
setTimeout( function waitForIt(){
// it 存在了吗?
if (!o.it) {
// 再试一次
setTimeout( waitForIt, 100 );
}
}, 100 );
// 事件处理未绑定
document.getElementById( "onceBtn" )
.addEventListener( "click", function handleClick(evt){
// 未绑定的 event
evt.target.removeEventListener( "click", handleClick, false );
// ..
}, false );
在这些情况下,使用命名函数的函数名引用,是一种有用和可靠的在自身内部自引用的方式。
此外,即使在单行函数的简单情况下,命名它们往往会使代码更加明了,从而让以前没有阅读过的人更容易阅读:
people.map( function getPreferredName(person){
return person.nicknames[0] || person.firstName;
} )
// ..
光看函数 getPreferredName(..)
的代码,并不能很明确告诉我们这里的操作是什么意图。但有名称就可以增加代码可读性。
经常使用匿名函数表达式的另一个地方是 IIFE (立即执行函数表达式):
(function(){
// 我是 IIFE!
})();
你几乎从没看到为 IIFE 函数来命名,但他们应该命名。为什么?我们刚刚提到过的原因:堆栈轨迹调试,可靠的自我引用和可读性。如果你想不出你的 IIFE 应该叫什么,请至少使用 IIFE:
(function IIFE(){
// 现在你真的知道我叫 IIFE!
})();
我有许多个理由可以解释命名函数比匿名函数更可取。事实上,我甚至认为匿名函数都是不可取的。相比命名函数,他们没有任何优势。
写匿名功能非常容易,因为我们完全不用在想名称这件事上费神费力。
诚实来讲,我也像大家一样在这个地方犯错。我不喜欢在起名称这件事上浪费时间。我能想到命名一个函数的前 3 或 4 个名字通常是不好的。我必须反复思考这个命名。这个时候,我宁愿只是用一个匿名函数表达。
但是,我们把易写性拿来与易读性做交换,这不是一个好选择。因为懒而不想为你的函数命名,这是常见的使用匿名功能的借口。
命名所有单个函数。如果你对着你写的函数,想不出一个好名称,我明确告诉你,那是你并没有完全理解这个函数的目的——或者来说它的目的太广泛或太抽象。你需要重新设计功能,直到它更清楚。从这个角度说,一个名称会更明白清晰。
从我自己的经验中证明,在思考名称的过程中,我会更好地了解它,甚至重构其设计,以提高可读性和可维护性。这些时间的投入是值得的。
没有 function
的函数
到目前为止,我们一直在使用完整的规范语法功能。但是相信你也对新的 ES6 =>
箭头函数语法有所耳闻。
比较:
people.map( function getPreferredName(person){
return person.nicknames[0] || person.firstName;
} )
// ..
people.map( person => person.nicknames[0] || person.firstName );
哇!
关键字 function
没了,return
,()
括号,{}
花括号和 ;
分号也是这样。所有这一切,都是我们与一个胖箭头做了交易: =>
。
但还有另一件事我们忽略了。 你发现了吗?getPreferredName
函数名也没了。
那就对了。 =>
箭头函数是词法匿名的。没有办法合理地为它提供一个名字。他们的名字可以像常规函数一样被推断,但是,最常见的函数表达式值作为参数的情况将不会起任何作用了。
假设 person.nicknames
因为一些原因没有被定义,一个异常将会被抛出,意味着这个 (anonymous function)
将会在追踪堆栈的最上层。啊!
=>
箭头函数的匿名性是 =>
的阿喀琉斯之踵。这让我不能遵守刚刚所说的命名原则了:阅读困难,调试困难,无法自我引用。
但是,这还不够糟糕,要面对的另一个问题是,如果你的函数定义有不同的场景,那么你必须要一大堆细微差别的语句来实现。我不会在这里详细介绍所有,但会简要地说:
people.map( person => person.nicknames[0] || person.firstName );
// 多个参数? 需要 ( )
people.map( (person,idx) => person.nicknames[0] || person.firstName );
// 解构参数? 需要 ( )
people.map( ({ person }) => person.nicknames[0] || person.firstName );
// 默认参数? 需要 ( )
people.map( (person = {}) => person.nicknames[0] || person.firstName );
// 返回对象? 需要 ( )
people.map( person =>
({ preferredName: person.nicknames[0] || person.firstName })
);
在函数式编程中, =>
令人兴奋的地方在于它几乎完全遵循函数的数学符号,特别是像 Haskell 这样的函数式编程语言。=>
箭头函数语法甚至可以用于数学交流。
我们进一步地来深挖,我建议使用 =>
的论点是,通过使用更轻量级的语法,可以减少函数之间的视觉边界,也让我们使用偷懒的方式来使用它,这也是函数式编程者的另一个爱好。
我认为大多数的函数式编程者都会对此睁只眼闭只眼。他们喜欢匿名函数,喜欢简洁语法。但是像我之前说过的那样:这都由你决定。
注意: 虽然我不喜欢在我的应用程序中使用 =>
,但我们将在本书的其余部分多次使用它,特别是当我们介绍典型的函数式编程实战时,它能简化、优化代码片段中的空间。不过,增强或减弱代码的可读性也取决你自己做的决定。
来说说 This ?
如果您不熟悉 JavaScript 中的 this
绑定规则,我建议去看我写的《You Don't Know JS: this & Object Prototypes》。 出于这章的需要,我会假定你知道在一个函数调用(四种方式之一)中 this
是什么。但是如果你依然对 this 感到迷惑,告诉你个好消息,接下来我们会总结在函数式编程中你不应当使用 this
。
JavaScript 的 function
有一个 this
关键字,每个函数调用都会自动绑定。this
关键字有许多不同的方式描述,但我更喜欢说它提供了一个对象上下文来使该函数运行。
this
是函数的一个隐式的输入参数。
思考:
function sum() {
return this.x + this.y;
}
var context = {
x: 1,
y: 2
};
sum.call( context ); // 3
context.sum = sum;
context.sum(); // 3
var s = sum.bind( context );
s(); // 3
当然,如果 this
能够隐式地输入到一个函数当中去,同样的,对象也可以作为显式参数传入:
function sum(ctx) {
return ctx.x + ctx.y;
}
var context = {
x: 1,
y: 2
};
sum( context );
这样的代码更简单,在函数式编程中也更容易处理:当显性输入值时,我们很容易将多个函数组合在一起, 或者使用下一章输入适配技巧。然而当我们做同样的事使用隐性输入时,根据不同的场景,有时候会难处理,有时候甚至不可能做到。
还有一些技巧,是基于 this
完成的,例如原型授权(在《this & Object Prototypes》一书中也详细介绍):
var Auth = {
authorize() {
var credentials = this.username + ":" + this.password;
this.send( credentials, resp => {
if (resp.error) this.displayError( resp.error );
else this.displaySuccess();
} );
},
send(/* .. */) {
// ..
}
};
var Login = Object.assign( Object.create( Auth ), {
doLogin(user,pw) {
this.username = user;
this.password = pw;
this.authorize();
},
displayError(err) {
// ..
},
displaySuccess() {
// ..
}
} );
Login.doLogin( "fred", "123456" );
注意: Object.assign(..)
是一个 ES6+ 的实用工具,它用来将属性从一个或者多个源对象浅拷贝到目标对象: Object.assign( target, source1, ... )
。
这段代码的作用是:现在我们有两个独立的对象 Login
和 Auth
,其中 Login
执行原型授权给 Auth
。通过委托和隐式的 this
共享上下文对象,这两个对象在 this.authorize()
函数调用期间实际上是组合的,所以这个 this
上的属性或方法可以与 Auth.authorize(..)
动态共享 this
。
this 因为各种原因,不符合函数式编程的原则。其中一个明显的问题是隐式 this
共享。但我们可以更加显式地,更靠向函数式编程的方向:
// ..
authorize(ctx) {
var credentials = ctx.username + ":" + ctx.password;
Auth.send( credentials, function onResp(resp){
if (resp.error) ctx.displayError( resp.error );
else ctx.displaySuccess();
} );
}
// ..
doLogin(user,pw) {
Auth.authorize( {
username: user,
password: pw
} );
}
// ..
从我的角度来看,问题不在于使用对象来进行操作,而是我们试图使用隐式输入取代显式输入。当我戴上名为函数式编程的帽子时,我应该把 this
放回衣架上。
总结
函数是强大的。
现在,让我们清楚地理解什么是函数:它不仅仅是一个语句或者操作的集合,而且需要一个或多个输入(理想情况下只需一个!)和一个输出。
函数内部的函数可以取到闭包外部变量,并记住它们以备日后使用。这是所有程序设计中最重要的概念之一,也是函数式编程的基础。
要警惕匿名函数,特别是 =>
箭头函数。虽然在编程时用起来很方便,但是会对增加代码阅读的负担。我们学习函数式编程的全部理由是为了书写更具可读性的代码,所以不要赶时髦去用匿名函数。
别用 this
敏感的函数。这不需要理由。
** 【上一章】翻译连载 |《JavaScript 轻量级函数式编程》- 第 1 章:为什么使用函数式编程? **
活动报名地址:http://www.huodongxing.com/event/5399668594900
网友评论