函数式函数的基础


《JS轻量级函数式编程》系列的第二章。这一本主要是对JavaScript中的函数进行了梳理。从最基本的高中代数知识开始,介绍了函数的用法和若干技巧,在其中还简单的比较了过程式和命令式代码的特点。总之这是非常基础的一章,但也正如作者是所说,这里讲述的函数远比你自己知道的要多。

函数式编程并不仅仅意味着使用function关键字来进行编程。如果它就是这么简单,那么这本书到这里就可以结束了!但不幸的是,函数也的确是函数式编程的核心,而且我们也是使用函数来让代码变得更加函数式的。

但是,你真的知道函数的意义到底是什么吗? 在本章之中,我们将会为本书的其余部分奠定基础,在这里我们将会涵盖函数的所有基础知识。在某种意义上来说,这里的内容是对所有非函数式程序员们所知道的函数知识的回顾。但是如果我们想要更多的学习函数式编程的概念,我们必须对函数了若指掌。 不要放弃,因为这里讲述的函数远比你自己知道的要多。

函数是什么?

我想我们开始学习函数式编程最自然的方式那就是使用函数来编程了。这看起来实在是太简单和明显了,但是我认为我们的旅程需要这坚实的第一步。 所以……函数是什么?

数学基础复习

我知道我承诺过我们会尽量远离数学,但是请继续忍受我一会儿,在我们继续之前,先来快速复习一下在代数上有关函数和图像的相关基础知识。 你还记得你在学校里学习的有关 f(x) 的相关知识吗?等式 y = f(x) 在描述什么? 我们像这样定义了一个等式:f(x) = 2x^2^ + 3,它的意义是什么?绘制该方程又是什么意思?这里就是它的图像: 函数图像

你注意到了吗?对于x的任意值,例如2,如果你把它代入方程之中,你会得到11。但是11又是什么?它是函数f(x)返回值,这就是我们前面所说的描述的一个y值。

换句话来说,在图中的曲线上有一点(2, 11)。对于每个我们带入的x的值,我们都能得到与之对应的y,它们就可以组成一个点的坐标。比如(0, 3)(-1, 5)。将所有的这些点放在一起,你就能得到如上图所示的抛物线图形。

所以,这和函数式编程有什么关系呢?

在数学中,一个函数输入了一个或多个值,那么总能够得到一个与之对应的输出。在函数式编程中你常常能听到一个词,叫“态射(morphism)”,这是描述从一组值映射到另一组值的奇特方式,就像是函数的输入和输出的关系。 在代数运算之中,这些输入和输出通常被解释为曲线坐标的一部分。然而,在我们的程序中,我们可以定义各种输入输出的函数,并且它们并不需要与可视的图像曲线有任何关系。

函数与过程

为什么我们一直在谈论数学和图像?因为在某种意义上来说,函数式编程中的函数就是数学意义上的函数。 你可能更习惯于将函数当作是过程。它们有什么不同?任意函数功能的集合,它可能有输入,也可能没有;它可能有一个输出(返回值),也可能没有。

但,函数接受输入,并且一定有一个返回值。

如果你打算做函数式编程,你应该尽可能多的使用函数,而不是过程,所有的函数都应该接受输入和返回输出。至于为什么,这个问题的答案将会有很多种层次上的意义,我们将会在书中慢慢解释它。

函数输入

从上面的定义来说,所有的函数都需要输入。 你可能时常听到人们常说“实际参数(arguments)”,又或者是“形式参数(parameters)”。他们到底在说什么? 实际参数就是指你传进函数的值,形式参数是在函数内部的具名变量,它们将会接受这些传进来的值。我们举个例子:

  1. function foo(x,y) {
  2. // ..
  3. }
  4. var a = 3;
  5. foo( a, a * 2 );

aa * 2(实际上应该是这个表达式的结果——6)都是foo(..)调用的实际参数xy都是接受了这些值(分别是36)的形式参数

在JavaScript中,并不要求形参和实参的数量一定要匹配。如果你传递的实参多余了你声明的用来接收它们的形参数量,这些值也会被传递,只是你无法直接访问它们。这些值可以通过几种不同的方式来访问,当然也包括你可能已经听说过的arguments对象。假设你传递的实参少于了你声明的形参,则每个没有接受实参的形参都将会是个“undefined”变量,就是说在当前的函数作用域中可以找到这个变量,但是它初始化的值是undefined

统计输入

函数“期望”的实参数量——你可能想要传递给它的实参数量——是由声明的形参数量决定的。

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

foo(..)期望三个实参,因为这里有三个声明的形参。这个计数有一个特殊的术语来描述它:计数值(arity)。 计数值是函数声明中的形参数量。foo(..)的计数值是3。 你可能希望在程序运行期间检查函数的形参数量,这可以通过该函数引用的length属性来完成:

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

为什么会想要在运行期间来确定计数值,是因为可能存在这样的情况,如果一段代码从多个源接收到了某个函数的引用,并且需要根据每个函数引用的计数值来发送不同的值。 例如,假设一个函数引用fn可以接受1个、2个或者是3个实参,但是你总是希望在最后的位置里传输变量x

  1. // `fn` is set to some function reference
  2. // `x` exists with some value
  3. if (fn.length == 1) {
  4. fn( x );
  5. }
  6. else if (fn.length == 2) {
  7. fn( undefined, x );
  8. }
  9. else if (fn.length == 3) {
  10. fn( undefined, undefined, x );
  11. }

length属性是只读的,它是在函数声明的时候就已经确定。它应该被认为是一个元数据,因为它描述了函数的预期用途。

有一点需要注意的是,某些种类的形参列表变体可以使函数属性length返回的值与你预期的想象不同。别担心,我们将会在本章的后面来解释这些(ES6引入的)功能:

  1. function foo(x,y = 2) {
  2. // ..
  3. }
  4. function bar(x,...args) {
  5. // ..
  6. }
  7. function baz( {a,b} ) {
  8. // ..
  9. }
  10. foo.length; // 1
  11. bar.length; // 1
  12. baz.length; // 1

如果你使用这些形式的形参,一定要小心,函数的length值可能会吓到你。

如何计算当前函数调用接受到的实参数量?这在以前是小事儿一桩,但现在情况变得稍微复杂了点。每个函数都有个arguments对象(类数组)用来保存对传入的每个参数的引用。然后你可以查看argumentslength参数来确定实际到底传入了多少个参数:

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

从ES5(具体来说是从严格模式)开始,arguments就已经开始被不推荐使用了,很多人也在实际中尽量避免使用它。但是它是永远不会被删除的——在JS中,无论这会多么方便,但我们“绝不”会打破JS向后的兼容性——但是由于各种原因我们强烈建议你在实际中尽量避免使用它。 但是,我个人是建议使用arguments.length的。当你需要关注传输进来的实参的数量的时候,但也仅在这个情况下,继续使用这个参数是没有问题的。未来的JS版本可能会添加一个功能,你可以在不依靠arguments.length的情况下来确定传递的实参数量。如果真的是这样,那么我们就可以完全抛弃使用arguments了。

绝不要像arguments[1]这样直接用下标来访问实参。如果有必要,坚持只使用arguments.length吧。

除非……假如你传递的实参超过了你声明的形参,你要如何访问它们呢?对于这个问题,首先,请先退一步,然后问问你自己:“为什么我要这么做呢?”请你严肃而仔细的想一想。 这种情况很少发生,它不应该是你在编程的时候经常期望和依赖的东西。如果你发现自己确实遇到了这样的情况,我建议你花20分钟来尝试设计下与该函数不同的交互方式。即便它是例外,最好也为它命名额外的形参。 接受不确定的自变量函数签名^注^被称为可变函数,有很多人喜欢这种风格的函数设计,但是我想你会发现,通常而言,函数式编程者们一般都想避免这些可能。

函数签名,即 Function signature,又被称作 Type signature。它包含了参数的数量、类型和顺序,通常在重载解析期间使用它来选择在许多重载形式中调用正确的函数。 JavaScript中并没有函数签名以及函数重载功能,但是根据输入参数的不同进行不同的逻辑处理,在这一点上和函数签名的功能是比较类似的。

好了,在这一点上的阐述已经足够了。

有时候你会想要像类数组那样直接用下标来访问实参,发生这种情况很可能是因为并没有含有下标的标准化形参来接受你传输进去的实参,这时应该怎么办? ES6来帮忙了!让我们用...运算符来声明我们的函数——它有着“扩展运算符”,“其余运算符”,或者“聚合运算符”(我比较喜欢这个)等等称呼。

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

看到形参列表里面的...args了吗?这是ES6中添加的新的声明形式,它将会告诉引擎收集(或者叫“聚合”)所有剩余的未分配给具名形参的实参,然后把它们放在一个名为args的数组之中。args将始终是个数组,即便它是个空的。但是它不会包括分配给xyz的形参值,它只会包括在前三个值以外的所有传递进来的值。

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

所以,假如你确实想要设计一个能够接受可变实参计数值的函数的话,请使用...args(或者任何你喜欢的名字)吧。现在,你将有一个真正的,不会丢失参数的数组来访问这些实参了。

只是要注意这样的事情,4args数组的下标0的位置,而不是下标3的位置。并且,它的length属性将不会包含12以及3...args将会收集所有值,不过当然会除开xy以及z

你甚至能够直接在形参列表中使用...运算符,即便没有声明其他形式的形参:

  1. function foo(...args) {
  2. // ..
  3. }

现在args将会是所有实参的聚合数组,无论它们是什么。而且你可以使用args.length来确定到底传入了多少个参数,你也可以安全的直接使用args[1]甚至是args[317](如果你可以访问的话)。当然,你不要真的传入318个参数就是了。

说到ES6的好东西啊,这里还有些其他的你可能会想知道的关于函数实参和形参的技巧。有关该指令的更多信息,请参阅我的另一个本书《你不知道的JS:ES6以及更高版本》。

实参的技巧

如果你想把某数组中的所有值作为实参传递进函数调用中怎么办?

  1. function foo(...args) {
  2. console.log( args[3] );
  3. }
  4. var arr = [ 1, 2, 3, 4, 5 ];
  5. foo( ...arr ); // 4

我们同样可以使用我们的新朋友...,它不仅能用在形参列表中,还能用在函数调用的实参列表中。它在这种情况下的行为刚好相反,在形参列表中它将会将实参们都聚合起来;而在实参列表中,它将会把它们展开。所以,arr内的值们将会作为单独的参数传递给foo(..)调用。你看到这和传递整个arr数组的引用之间的区别了吗?

值得一提的是,...运算符和单独的值是可以混用的,像这样:

  1. var arr = [ 2 ];
  2. foo( 1, ...arr, 3, ...[4,5] ); // 4

...运算符的效果是对称的,在值列表^注^中,它总是展开运算。而在进行被赋值的地方^注^——像是形参列表,因为实参总是会赋值给形参——它总是聚合运算。 无论你调用的是什么行为,...运算让参数数组使用起来更加容易了。想想使用slice(..)concat(..)apply(..)来摆弄参数数组的日子吧!我们终于可以和它们说再见了!

值列表:在这里可以理解为赋值运算的右值,即赋值运算的数据源。 被赋值的地方:在这里可以理解为赋值运算的左值,即赋值运算的目标变量。

形参的技巧

从ES6起,形参可以使用默认值声明。在未向该形参传递实参值或者传递了undefined的情况下,默认赋值表达式将会代替原来的实参赋值给形参的表达式。

比如:

  1. function foo(x = 3) {
  2. console.log( x );
  3. }
  4. foo(); // 3
  5. foo( undefined ); // 3
  6. foo( null ); // null
  7. foo( 0 ); // 0

我们不会再在这里介绍更多细节了,不过我想要强调一点,默认值表达式是惰性的,这意味着直到必须运行它之前,它都是不会运行的,此外,它允许是任何合法的JS表达式,甚至是函数调用。这样的特性使得它有很多很酷的技巧。比如,你可以在形参列表中声明x = required(),然后在required()函数中简单的抛出错误throw "This argument is required.",这样来确保使用者在调用函数的时候始终指定实参/形参。

在形参中我们还能使用另一种ES6的技巧,它叫做“解构赋值(destructuring)”。我们只会在这里简单的介绍一下,因为它的全部内容比起我们现在涉及到的知识而言要复杂的多。在这里我想再次提一下,关于它更详细的内容,请参考《ES6以及更高版本》这本书。

还记得我们在上面提到的接受了318个实参的函数foo(..)吗?

  1. function foo(...args) {
  2. // ..
  3. }
  4. foo( ...[1,2,3] );

如果我们想要改变这种交互方式,我们想要在函数调用中传入一个值数字组,而不是一个个单独的值,应该怎么做呢?只需要去掉两个...运算符就行了:

  1. function foo(args) {
  2. // ..
  3. }
  4. foo( [1,2,3] );

看起来很简单吧。但是如果我们现在想要给传入的实参数组的前两个值各声明一个形参呢?因为我们没有单独传入实参,所以看起来我们似乎是做不到这种要求的。但是,解构赋值是可以的:

  1. function foo( [x,y,...args] = [] ) {
  2. // ..
  3. }
  4. foo( [1,2,3] );

你看到在形参列表中的[..]括号了吗?这就是数组的解构赋值。解构赋值是一种以声明语句的方式,来描述你希望看到的某些结构体(对象、数组等)的模式,以及如何对其各个部分进行分解(赋值)的方法。 在这个例子中,解构赋值告诉引擎在这个赋值位置(形参)中需要一个数组,并且数组的第一个值会被赋值给当前名为x的形参变量,第二值则会赋值给名为y的形参变量,而剩下的所有值将会被聚合args之中。

你当然也可以像下面这样手动的做同样的事情:

  1. function foo(params) {
  2. var x = params[0];
  3. var y = params[1];
  4. var args = params.slice( 2 );
  5. // ..
  6. }

在这里我想开始揭示函数式编程的第一个原则,我们之后也将会反复强调下面这句话:声明式的代码通常会比命令式的代码更容易理解。 声明式的代码,就像前面代码片段中的解构赋值,它只关注一段代码的结果应该是什么。命令式的代码,就像刚才显式的手动分配形参变量的做法,则更注重于它是如何获得结果的。如果你以后再来阅读这段代码的时候,你必须在头脑中模拟执行一遍这段代码,来了解结果到底是什么样子的。从这些代码当然也能看出结果,但它终归不是那么清晰明了。 无论语言和我们的库/框架让我们如何如何,只要有可能,我们都应该努力写出声明式以及自解释性的代码

正如我们可以解构数组,我们同样也能够结构对象形参:

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

我们把一个对象作为单个实参传递了进去,然后将之解构成了两个独立的形参xy,它们都接受了传入对象与之对应的属性名的值。对象没有x这个属性并不是问题,就像你所想的那样,最终这个变量的值将会是undefined。 虽然形参是对象解构出来的一部分,但是我希望你能注意这个被传入foo(..)的对象本身。 使用像是foo(undefined, 3)这样正常的函数调用,参数的位置决定了从实参到形参的映射。我们将3放在了第二个位置上,所以它将会赋值给形参y。但是在这种使用了形参解构赋值的新式函数调用的情况下,映射是由对象属性来决定的,正如这里形参(y)将会被实参值3赋值。

我们没有在正常的函数调用中考虑x,因为事实上我们并不关心x。我们想要忽略它,所以我们必须要额外做一些事情,比如传递undefined值作为占位符。 一些语言对此行为有更为直接的功能:命名实参。换句话来说,在函数调用的时候可以直接标记实参,这就能直接指示它应该映射到哪个形参之中。JavaScript中没有命名实参,但是形参对象解构赋值是可以代替它的最好的功能。 使用对象解构赋值来传递参数,比起传递多个参数而言,对于函数式而言是有益处的。只需要一个形参(对象)的函数更容易与另一个单输出的函数相组合。我们将在后面详细解释这一点。

回想一下,计数值这个术语指的是一个函数想要接受多少个形参,计数值的值为1的函数也被称作一元函数。在函数式编程中,我们希望我们的函数尽可能的都是一元的,有时候我们甚至会使用各种函数技巧,把具有更多计数值的函数转换为一元的形式。

在第三章,我们将会重新讨论这个命名实参解构赋值的技巧,来解决形参排序这个令人恼人的问题。

根据输入而变化的函数

思考下面的函数:

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

显然,上面这个例子中的函数将会根据你传递的输入值而发生对应的变化。 比如:

  1. foo( 3, 4 ); // 12
  2. foo( "3", 4 ); // "34"

程序员这样定义函数的原因,是因为将不同的行为重载到单个函数中可能会更加方便。最著名的例子就是$(..)函数,它是由JS非常流行的的库jQuery提供的。这个“美元符号”的函数大约有十几种不同的行为——从DOM元素的查找到创建DOM之后为它添加加载完成的回调事件——这些都取决于你传递给它的参数。 我们在学习它的时候能感觉到它最为明显的优势,毕竟它的API实在是简单(只有一个$(..)函数);然而它也有明显的缺点,那就是在阅读代码的时候,我们必须仔细检查传入的参数,然后再去解读这个调用将会做什么。 这种基于输入而表现出不同行为的重载函数的技术,被称做随意多态(ad-hoc polymorphism)

这种设计模式还有另一种表现形式,那就是在不同的场景下使用它,将会具有不同的输出(更多的细节将会在下一部分中介绍)。

一定要小心这里的这种“便利性”的诱惑。因为这样的设计在短期看来可能是有很大优势的,但是它的长期成本可能并不怎么好看。

函数的输出

在JavaScript中,函数总会返回一个值。这三个函数都具有相同的返回行为:

  1. function foo() {}
  2. function bar() {
  3. return;
  4. }
  5. function baz() {
  6. return undefined;
  7. }

假如你并没有为函数写return语句,或者是放了一个空的return语句,那么此时将会隐式的返回undefined值。 但是为了尽量贯彻函数式函数定义的精神——使用函数而不是过程——我们的函数应该总是有输出的,这意味着它应该显式的返回一个值,而且这个值不应该是undefined

return语句只能返回单个值。因此,如果你的函数需要返回多个值,你唯一可行的选择就是将它们聚合成一个复合值,比如数组或对象:

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

就像我们在形参中对数组/对象进行解构赋值那样,我们也可以在正常的赋值语句中这么做:

  1. function foo() {
  2. var retValue1 = 11;
  3. var retValue2 = 31;
  4. return [ retValue1, retValue2 ];
  5. }
  6. var [ x, y ] = foo();
  7. console.log( x + y ); // 42

将多个值收集到数组(或者对象)中,把它们当作返回值返回,然后将这些值进行解构赋值,这是一种透明地表达函数多个输出的方式。

如果你想要通过重构来避免一个函数拥有多个输出的情况,那么将函数拆分成2个甚至是多个单输出的函数会是一个好主意吗?忘了提醒你这件事情,这是我的疏忽。在此我的回答是,有时候是的,有时候却不是的。但是至少,你应该认真考虑一下。

提前返回

return语句并不只是从函数返回一个值的语句,它也可以当作是一个流控制结构;函数将会在这里停止运行。因此当一个拥有多个return语句时,这也意味着这个函数拥有多个可能的出口,也意味着假如一个函数拥有多个输出路径,那么要理解函数的输出行为将会更加困难。 比如:

  1. function foo(x) {
  2. if (x > 10) return x + 1;
  3. var y = x / 2;
  4. if (y > 3) {
  5. if (x % 2 == 0) return x;
  6. }
  7. if (y > 1) return y;
  8. return x;
  9. }

小测验:不要在浏览器中运行代码来作弊哟,请直接回答以下问题,foo(2)的返回值是多少?foo(4)呢?还有foo(8)以及foo(12),以上函数调用的输出分别是多少? 你对你的答案有多少信心?你思考这个问题耗费了多少脑细胞呢?不怕告诉你,我的头两次尝试都以失败告终。

我认为这里在可读性方面的主要问题是在于,return不仅仅是返回了不同的值,还被当作了流控制的语句,用以在某种情况下提前退出函数。很明显,这里有更好的方式来进行流控制(if逻辑等),但是我认为也有办法让输出路径更加明显。

这里的答案是228以及13

思考下面这个版本的代码:

  1. function foo(x) {
  2. var retValue;
  3. if (retValue == undefined && x > 10) {
  4. retValue = x + 1;
  5. }
  6. var y = x / 2;
  7. if (y > 3) {
  8. if (retValue == undefined && x % 2 == 0) {
  9. retValue = x;
  10. }
  11. }
  12. if (retValue == undefined && y > 1) {
  13. retValue = y;
  14. }
  15. if (retValue == undefined) {
  16. retValue = x;
  17. }
  18. return retValue;
  19. }

这个版本毫无疑问要更加啰嗦一点,但是我认为它遵循的逻辑却要显得更加清晰明了,每个可以给retValue赋值的分支中都将这个变量保护了起来,因为每个分支条件都会对这个变量是否已经被赋值做检查。 比起在函数中使用return来提前返回值,我通常会使用正常的流控制语句(if逻辑)来决定retValue的赋值。然后在最后直接return retValue。 我不是说你必须要无条件的使用单一的return,或者是不要提前使用return,我只是希望你能在使用return的时候小心点,因为它在函数中也是一种隐式的流控制结构。尝试找出最明确的方式来表达逻辑,通常而言,这才是最好的方式。

没有返回的输出

在你编写的大部分代码中你可能试用过一种技术,虽然你可能从来没有注意到它。这个技术就是通过简单的改变外部变量来输出一些或者所有值。

还记得我们在上一章提到的 f(x) = 2x^2^ + 3 函数吗?我们可以在JS中这么定义它:

  1. var y;
  2. function foo(x) {
  3. y = (2 * Math.pow( x, 2 )) + 3;
  4. }
  5. foo( 2 );
  6. y; // 11

我知道这是个非常简单的例子,我们能够很容易的使用return返回值的技术,来替代现在这样在函数内直接操作y变量的方式:

  1. function foo(x) {
  2. return (2 * Math.pow( x, 2 )) + 3;
  3. }
  4. var y = foo( 2 );
  5. y; // 11

这两个函数能完成相同的任务,我们有什么理由一定要选择后者呢?是的,的确是有的。 这两种方法有一个很明显的区别,那就是后一个版本中使用了return语句来显式的输出了值;相对的,前者则是直接给变量y赋值,隐式的输出了这个值。你可能已经有了点感觉,通常而言,开发人员更喜欢显式的模式,而不是隐式的模式。 正如我们在foo(..)函数中直接对y赋值那样,改变外部作用域中的变量只是实现隐式输出的一种方法。现在还有个更微妙的例子,它是通过引用的方式来修改了非本地的值。

思考:

  1. function sum(list) {
  2. var total = 0;
  3. for (let i = 0; i < list.length; i++) {
  4. if (!list[i]) list[i] = 0;
  5. total = total + list[i];
  6. }
  7. return total;
  8. }
  9. var nums = [ 1, 3, 9, 27, , 84 ];
  10. sum( nums ); // 124

这个函数最明显的输出就是数组的和124,我们显式的返回了它。但是你找到另一个输出了吗?实时运行这个代码,然后检查nums数组吧。现在,你发现不同了吗?

本来在下标为4的位置是个undefined的空位,但现在那里却是个0。看起来无害的list[i] = 0操作却影响了外部的数组值,即便我们操作的是一个本地的list形参变量。 为什么?因为list的值实际上是对于nums引用值的引用复制,而并不是其数组内容[1,3,9,...]的值复制^注^。因为JS对于数组、对象、函数使用的都是其引用以及引用的复制,我们从我们的函数中创建输出实在是非常容易,即便是因为某些意外。 这些隐式的函数输出在函数式编程的世界中有一个特殊的名称:副作用(side effects)。如果一个函数没有任何副作用,那么这个函数也有一个特殊的名称:纯函数(pure function)。我们会在后面的章节中更详细的讨论这个问题,但是我有个忠告,那就是我们更加偏爱于纯函数,而且应该尽可能的避免副作用。

list数组的引用复制的这种行为一般称为浅复制。

函数的函数

函数可以接收和返回任何类型的值。接收或返回一个或多个其他函数的函数具有一个特殊名称:高阶函数(higher-order function)。 看下面的例子:

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

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

高阶函数也能输出另一个函数,比如:

  1. function foo() {
  2. var fn = function inner(msg){
  3. console.log( msg );
  4. };
  5. return fn;
  6. }
  7. var f = foo();
  8. f( "Hello!" ); // Hello!

return也并不是唯一“输出”另一个函数的方式:

  1. function foo() {
  2. var fn = function inner(msg){
  3. console.log( msg );
  4. };
  5. bar( fn );
  6. }
  7. function bar(func) {
  8. func( "Hello!" );
  9. }
  10. foo(); // Hello!

把其他函数当做值,这就是高阶函数的定义。函数式程序员们一天到晚都在写这个东西!

保持作用域

在所有的编程范式,特别是函数式之中,有一个非常强大的东西,那就是当函数在另一个函数的作用域内时,此时函数的行为是怎样的。内部的函数引用来自外部函数的变量,这一行为就被称为闭包。 从编程的角度来说,闭包就是函数能够记住且访问到在它自己作用域外部的变量的行为,甚至该函数是在别的作用域中运行的时候。

思考:

  1. function foo(msg) {
  2. var fn = function inner(){
  3. console.log( msg );
  4. };
  5. return fn;
  6. }
  7. var helloFn = foo( "Hello!" );
  8. helloFn(); // Hello!

在作用域foo(..)中的形参变量msg,它被内部的函数引用了。当foo(..)函数运行的时候,内部的函数就会被创建,此时这个内部生成的函数捕获了对变量msg的访问,并且在return之后仍然保持着这个访问。 一旦我们得到了helloFn,对函数foo(..)的引用已经完成,它的作用域应该已经消失了才对,当然这也就意味着变量msg也将会随之消失。但是并不会发生这种事情,因为内部函数有一个闭包覆盖了变量msg,从而保持了这个变量的存在。只要这个内部函数存在(现在它被在不同作用域的helloFn所引用),那变量msg也会随之存在。

我们来看看更多的关于闭包操作的例子:

  1. function person(id) {
  2. var randNumber = Math.random();
  3. return function identify(){
  4. console.log( "I am " + id + ": " + randNumber );
  5. };
  6. }
  7. var fred = person( "Fred" );
  8. var susan = person( "Susan" );
  9. fred(); // I am Fred: 0.8331252801601532
  10. susan(); // I am Susan: 0.3940753308893741

内部函数identify()的闭包包含了两个变量,分别是形参id和内部变量randNumber。 闭包允许的访问不仅限于读取变量的原始值——它不仅仅是变量的快照,而更像是活动链接。你能够更新这个值,当前状态将会保持这个值,直到你下次访问它。

  1. function runningCounter(start) {
  2. var val = start;
  3. return function current(increment = 1){
  4. val = val + increment;
  5. return val;
  6. };
  7. }
  8. var score = runningCounter( 0 );
  9. score(); // 1
  10. score(); // 2
  11. score( 13 ); // 15

这个问题我们将会在后面进行更详细的讨论,但是这个使用闭包来记住状态改变的例子,可能是你想要极力避免的。

如果你有一个操作需要两个输入,其中一个你现在就已经知道,但是另一个将会在之后指定,这时你就可以使用闭包来记住第一个输入:

  1. function makeAdder(x) {
  2. return function sum(y){
  3. return x + y;
  4. };
  5. }
  6. // we already know `10` and `37` as first inputs, respectively
  7. var addTo10 = makeAdder( 10 );
  8. var addTo37 = makeAdder( 37 );
  9. // later, we specify the second inputs
  10. addTo10( 3 ); // 13
  11. addTo10( 90 ); // 100
  12. addTo37( 13 ); // 50

通常而言,sum(..)函数将会求出两个输入xy的和。但是在这个例子中,我们先接收并保存(通过闭包)了x的值,而y的值将会在后面单独指定。

这种在连续的函数调用中指定输入的技术在函数式编程中非常常见,它们有两种形式:局部应用(Partial Application,也译作“偏应用”或“部分应用”)局部套用(Currying,也译作“柯里化”)。我们将会在之后的内容中详细介绍。

当然,由于函数在JS中也是一种值,我们当然也可以通过闭包来记住函数值。

  1. function formatter(formatFn) {
  2. return function inner(str){
  3. return formatFn( str );
  4. };
  5. }
  6. var lower = formatter( function formatting(v){
  7. return v.toLowerCase();
  8. } );
  9. var upperFirst = formatter( function formatting(v){
  10. return v[0].toUpperCase() + v.substr( 1 ).toLowerCase();
  11. } );
  12. lower( "WOW" ); // wow
  13. upperFirst( "hello" ); // Hello

比起在我们代码中分布在各处的toUpperCase()toLowerCase(),函数式编程更鼓励我们用简单的函数来对这种行为进行封装。 具体的来说,我们创建了两个简单的一元函数lower(..)upperFirst(..),因为这些函数更容易连接到我们程序的其他功能。

你发现了吗,其实这里可以直接用upperFirst(..)来调用lower(..)的。

我们会在本书的其他部分中大量的使用闭包,它可能是整个函数式编程中最为重要的基础实践。熟练的掌握它吧!

语法

在我们从这些函数入门开始之前,我们先花点时间来讨论一下函数相关的语法。 无论你是否同意,在这一节中的观点比起本书的其他部分而言,都充满了我个人的主观倾向和偏好。虽然很多人觉得它们是比较绝对的准则,但它们也的确是我个人非常主观结论。最终,是否接受他们由你自己来决定。

名字之中都有什么?

从语法上来讲,函数声明需要包含一个名称:

  1. function helloMyNameIs() {
  2. // ..
  3. }

但是,函数表达式却有具名和匿名两种形式:

  1. foo( function namedFunctionExpr(){
  2. // ..
  3. } );
  4. bar( function(){ // <-- look, no name!
  5. // ..
  6. } );

话说回来,我们所谓的匿名到底指的是什么?具体来说是这样的,函数有一个name的属性,它将会保存函数在语法上给出的名称的字符串,例如helloMyNameIsnamedFunctionExprname属性最重要的功能就是,当控制台/开发人员在追踪堆栈(通常来自于异常)时,JS环境将会列出函数列表,函数有了各自的名字,查询起来非常方便。 而匿名函数通常只会显示(anonymous function)

如果你曾经调试过跟踪异常堆栈的JS程序的话,你可能已经感受过看到一行接一行(anonymous function)时的痛苦。这个列表对于开发者而言毫无意义,因为它不能提供关于异常来源的任何线索。

从ES6开始,匿名表达式有了名称推断(name inferencing)的帮助,例如:

  1. var x = function(){};
  2. x.name; // x

如果引擎能够猜到你可能想要该函数采用什么名称,它就会为匿名函数添加名称。 但是请注意,并非所有的语法形式都能够从名称推断中获益,在下面这段代码中展示了一个非常常见的函数表达式,它将会作为一个参数被另一个函数调用:

  1. function foo(fn) {
  2. console.log( fn.name );
  3. }
  4. var x = function(){};
  5. foo( x ); // x
  6. foo( function(){} ); //

当无法根据周围的语法直接推断名称时,名称属性将会保持为空字符串。在跟踪堆栈时,这样的函数将会被报告为(anonymous function)。 当函数具名时,除开调试还有别的优势。首先句法名称(也称为词法名称)对于内部的自引用很有用。自引用对于递归(不管是异步还是同步)都是必要的,并且对于事件处理程序也有帮助。

思考下面几个很容易出现的场景:

  1. // sync recursion:
  2. function findPropIn(propName,obj) {
  3. if (obj == undefined || typeof obj != "object") return;
  4. if (propName in obj) {
  5. return obj[propName];
  6. }
  7. else {
  8. let props = Object.keys( obj );
  9. for (let i = 0; i < props.length; i++) {
  10. let ret = findPropIn( propName, obj[props[i]] );
  11. if (ret !== undefined) {
  12. return ret;
  13. }
  14. }
  15. }
  16. }
  1. // async recursion:
  2. setTimeout( function waitForIt(){
  3. // does `it` exist yet?
  4. if (!o.it) {
  5. // try again later
  6. setTimeout( waitForIt, 100 );
  7. }
  8. }, 100 );
  1. // event handler unbinding
  2. document.getElementById( "onceBtn" )
  3. .addEventListener( "click", function handleClick(evt){
  4. // unbind event
  5. evt.target.removeEventListener( "click", handleClick, false );
  6. // ..
  7. }, false );

在所有的这些情况下,具名函数的名字都是一个有用且可靠的内部自引用。 此外,即使是在具有单线程函数的简单情况下,命名它们也能让代码更好的解释自身,就是说这个函数对于之前没有阅读过它的人而言会更容易阅读:

  1. people.map( function getPreferredName(person){
  2. return person.nicknames[0] || person.firstName;
  3. } )
  4. // ..

函数名字getPreferredName(..)告诉了读者,这是个有关映射操作的函数,而从函数本身的代码来看,这个操作的特征并不怎么明显。所以名称标签有助于提高代码的可读性。

匿名函数表达式在IIFEs(立即调用函数表达式)中也很常见:

  1. (function(){
  2. // look, I'm an IIFE!
  3. })();

你可能几乎从没看到IIFE使用具名函数的函数表达式,但它们应该这么做。为什么?所有的原因我们上面已经介绍过了,堆栈跟踪调试,可靠的自引用和更好的可读性。如果你不能给你的IIFE命名任何名字,那么至少请直接使用IIFE这个单词:

  1. (function IIFE(){
  2. // You already knew I was an IIFE!
  3. })();

这就是我说具名函数比起匿名函数而言总是更具优势的原因。但事实上我会说,到目前为止,基本上没有匿名函数比具名函数更好的情况,匿名函数对于它们具名的同行而言,没有任何优势。 但是写匿名函数实在是比较容易,因为要是写具名函数的话,我们就必须集中注意力给函数指定一个名字。 说实话,我和其他人一样内疚,我也不喜欢和命名作斗争。命名的时候,最开始的3、4个名字通常都不怎么样,我必须一遍又一遍的重命名它们,相比之下我更愿意使用一个好的匿名函数表达式。

但这其实是轻松编写痛苦阅读之间的交易,这并不是个划算的交易。懒惰或者缺乏创造力并不足以构成你不想给函数命名的借口。 给你的每个单函数命名吧。如果你坐在那里,但却不能为你的函数拿出一个好名字,我觉得你可能并没有完全理解这个函数的目的——或者它太过宽泛或抽象。我强烈建议你重新设计这个函数,直到它变得更加清楚。到那时,名字自然而然的就出现了。 我可以从我个人的经验中证明这一点,能够对某函数命名的很好,这通常需要我很好的理解它,为了达到这一点我甚至经常重构它们,以提高可读性和可控性。这样的投资是值得的。

没有function的函数

到目前为止,我们一直使用完整规范语法的函数,但是你肯定也听说了有关ES6的=>箭头函数的相关小道消息。

比较下面的代码:

  1. people.map( function getPreferredName(person){
  2. return person.nicknames[0] || person.firstName;
  3. } )
  4. // ..
  5. people.map( person => person.nicknames[0] || person.firstName );

哇~ 关键字function不见了,还有return()圆括号、{}花括号,还有;分号都不见了!对于这些所有东西,我们仅仅用了一个胖胖的箭头符号=>来替换它们。 但是我们还省略了另一个东西。你找到了吗?函数名getPreferredName,也被省略了。 那就对了,=>箭头函数在词法规定上就是匿名的,没有办法给它一个名字。它们的名字可以像普通函数那样被推断,但是最常见的函数表达式作为值的情况将同样不会得到任何帮助。 如果出于某种原因,person.nicknames并没有被定义,此时就会抛出异常,这意味着在你跟踪堆栈的时候,位于顶端的一定是(anonymous function)。额…… 老实说,=>箭头函数的匿名性就是它的罩门,至少对我而言是这样。我不能接受无法命名的特性,这样的代码将会很难阅读,更难调试,也不能自我引用。 但这还不算最糟糕的,最麻烦的是你必须要面对的另一个问题。在不同的场景下,你的函数定义必须要涉及一系列微妙的句法变化。我并不打算在这里详细的介绍它们,但这里有个简单的例子:

  1. people.map( person => person.nicknames[0] || person.firstName );
  2. // multiple parameters? need ( )
  3. people.map( (person,idx) => person.nicknames[0] || person.firstName );
  4. // parameter destructuring? need ( )
  5. people.map( ({ person }) => person.nicknames[0] || person.firstName );
  6. // parameter default? need ( )
  7. people.map( (person = {}) => person.nicknames[0] || person.firstName );
  8. // returning an object? need ( )
  9. people.map( person =>
  10. ({ preferredName: person.nicknames[0] || person.firstName })
  11. );

=>运算符在函数式的世界中备受推崇是有原因的,主要是因为它是个完全遵循于数学形式的函数符号,尤其是在像Haskell这样的函数式编程语言中,我们就是使用形如=>的箭头函数语法来和数学符号进行交流的。 从更深入的角度来说,我建议赞成=>的主要理由是这样的,通过使用更轻量级的语法,来减少函数间的视觉边界。这会让我们使用简单的函数表达式,就像我们使用惰性表达式那样——这是另一个函数式编程人员最喜欢的东西。 我认为大多数函数式编程人员并不会太关注这些问题,他们喜欢匿名函数,他们喜欢简洁的语法。但就像我之前所说的:这由你自己来决定。

尽管我并不喜欢在我的程序中使用=>,但是在本书的其它部分中,我们将会在许多地方使用它——特别是当我们提供典型的函数式应用程序时——我们将会首选更简洁的,尤其是对有限的物理空间有所优化的代码片段。是否使用这种方法将会影响代码的可读性。当然,这也得你自己来选择。

这(This)是什么?

如果你不熟悉JavaScript中this的绑定规则,我建议你去看看我的《你不知道的JS:This和对象原型》一书。在本节中,我假设你知道在函数的调用时如何确定this(四规则之一)。如果你对this仍然有些模糊,那这里有个好消息要告诉你——如果你试图使用函数式编程,那么你就不应该使用this。 JavaScript的function有一个this关键字,它会在每个函数被调用的时候自动绑定。this这个关键字可以用很多种不同的方式来描述,但我更喜欢说,它为函数提供了一个运行的上下文对象。 this也是函数的一个隐式的形参输入。

思考:

  1. function sum() {
  2. return this.x + this.y;
  3. }
  4. var context = {
  5. x: 1,
  6. y: 2
  7. };
  8. sum.call( context ); // 3
  9. context.sum = sum;
  10. context.sum(); // 3
  11. var s = sum.bind( context );
  12. s(); // 3

当然,如果this可以被隐式的输入到函数中,那么相同的上下文对象当然也可以作为显示的实参输入:

  1. function sum(ctx) {
  2. return ctx.x + ctx.y;
  3. }
  4. var context = {
  5. x: 1,
  6. y: 2
  7. };
  8. sum( context );

看起来要简单多了吧。这样的代码在函数式编程中更容易处理,在将多个函数连接在一起的情况下也显得更简单。或者在输入全是显式的情况下,使用我们将在下一章中介绍的输入竞争技术。上面这几种技术,在输入都是像this这样的隐式输入的情况下将会十分的尴尬,当然这也取决于应用场景。 我们可以在这个基于this的系统中使用其他技巧,比如说原型委托(更多的细节请参考《this与对象原型》):

  1. var Auth = {
  2. authorize() {
  3. var credentials = this.username + ":" + this.password;
  4. this.send( credentials, resp => {
  5. if (resp.error) this.displayError( resp.error );
  6. else this.displaySuccess();
  7. } );
  8. },
  9. send(/* .. */) {
  10. // ..
  11. }
  12. };
  13. var Login = Object.assign( Object.create( Auth ), {
  14. doLogin(user,pw) {
  15. this.username = user;
  16. this.password = pw;
  17. this.authorize();
  18. },
  19. displayError(err) {
  20. // ..
  21. },
  22. displaySuccess() {
  23. // ..
  24. }
  25. } );
  26. Login.doLogin( "fred", "123456" );

Object.assign(..)是一个ES6+的小工具,它的功能是对一个或者多个源对象执行属性的浅复制,并把复制的键值对放置到单个的目标对象中:Object.assign( target, source1, ... )

如果你无法理解这段代码的意思,我来稍微解释下它做了什么:我们有两个独立的对象LoginAuth,并且LoginAuth执行了事件委托。通过委托和隐式的this上下文共享,这两个对象在this.authorize()函数调用期间被虚拟的合并了起来,所以在Auth.authorize(..)函数中,属性/方法通过this实现了动态共享。 this由于各种原因并不符合函数式编程的各种原则,但它明显是一个隐式的共享。我们可以更为显式的描述它,并让它在函数式的方向中更容易使用:

  1. // ..
  2. authorize(ctx) {
  3. var credentials = ctx.username + ":" + ctx.password;
  4. Auth.send( credentials, function onResp(resp){
  5. if (resp.error) ctx.displayError( resp.error );
  6. else ctx.displaySuccess();
  7. } );
  8. }
  9. // ..
  10. doLogin(user,pw) {
  11. Auth.authorize( {
  12. username: user,
  13. password: pw
  14. } );
  15. }
  16. // ..

从我的角度来看,问题并不在于使用对象来组织行为,而是我们试图使用隐式的输入而不是显式的。当我戴着函数式编程的帽子的时候,我想我还是把this这东西放在架子上比较好。

总结

函数们是很强大的。 但是我们应该对函数到底是什么了若指掌。它不仅仅只是语句/操作的集合。具体来说函数需要一个或者多个输入(理想情况下只有一个)和输出。 函数内部的函数可以将外部变量闭包,并在之后依旧保持着它们的值。这是所有编程中最重要的概念之一,这也是函数式编程的基础。 小心匿名函数,尤其是=>箭头函数。它们方便写作,但同时也将这部分成本从作者转移到了读者。我们在这里研究函数式编程的原因是为了编写更高可读性的代码,所以不要那么快的跳入那个潮流之中。 不要使用this,不要。