三、函数

优质
小牛编辑
128浏览
2023-12-01

三、函数

人们认为计算机科学是天才的艺术,但是实际情况相反,只是许多人在其它人基础上做一些东西,就像一面由石子垒成的墙。

高德纳

函数是 JavaScript 编程的面包和黄油。 将一段程序包装成值的概念有很多用途。 它为我们提供了方法,用于构建更大程序,减少重复,将名称和子程序关联,以及将这些子程序相互隔离。

函数最明显的应用是定义新词汇。 用散文创造新词汇通常是不好的风格。 但在编程中,它是不可或缺的。

以英语为母语的典型成年人,大约有 2 万字的词汇量。 很少有编程语言内置了 2 万个命令。而且,可用的词汇的定义往往比人类语言更精确,因此灵活性更低。 因此,我们通常会引入新的概念,来避免过多重复。

定义函数

函数定义是一个常规绑定,其中绑定的值是一个函数。 例如,这段代码定义了square,来引用一个函数,它产生给定数字的平方:

const square = function(x) {
  return x * x;
};

console.log(square(12));
// → 144

函数使用以关键字function起始的表达式创建。 函数有一组参数(在本例中只有x)和一个主体,它包含调用该函数时要执行的语句。 以这种方式创建的函数的函数体,必须始终包在花括号中,即使它仅包含一个语句。

一个函数可以包含多个参数,也可以不含参数。在下面的例子中,makeNoise函数中没有包含任何参数,而power则使用了两个参数:

var makeNoise = function() {
  console.log("Pling!");
};

makeNoise();
// → Pling!

const power = function(base, exponent) {
  let result = 1;
  for (let count = 0; count < exponent; count++) {
     result *= base;
  }
  return result;
};

console.log(power(2, 10));
// → 1024

有些函数会产生一个值,比如powersquare,有些函数不会,比如makeNoise,它的唯一结果是副作用。 return语句决定函数返回的值。 当控制流遇到这样的语句时,它立即跳出当前函数并将返回的值赋给调用该函数的代码。 不带表达式的return关键字,会导致函数返回undefined。 没有return语句的函数,比如makeNoise,同样返回undefined

函数的参数行为与常规绑定相似,但它们的初始值由函数的调用者提供,而不是函数本身的代码。

绑定和作用域

每个绑定都有一个作用域,它是程序的一部分,其中绑定是可见的。 对于在任何函数或块之外定义的绑定,作用域是整个程序 - 您可以在任何地方引用这种绑定。它们被称为全局的。

但是为函数参数创建的,或在函数内部声明的绑定,只能在该函数中引用,所以它们被称为局部绑定。 每次调用该函数时,都会创建这些绑定的新实例。 这提供了函数之间的一些隔离 - 每个函数调用,都在它自己的小世界(它的局部环境)中运行,并且通常可以在不知道全局环境中发生的事情的情况下理解。

letconst声明的绑定,实际上是它们的声明所在的块的局部对象,所以如果你在循环中创建了一个,那么循环之前和之后的代码就不能“看见”它。JavaScript 2015 之前,只有函数创建新的作用域,因此,使用var关键字创建的旧式绑定,在它们出现的整个函数中内都可见,或者如果它们不在函数中,在全局作用域可见。

let x = 10;
if (true) {
  let y = 20;
  var z = 30;
  console.log(x + y + z);
  // → 60
}
// y is not visible here
console.log(x + z);
// → 40

每个作用域都可以“向外查看”它周围的作用域,所以示例中的块内可以看到x。 当多个绑定具有相同名称时例外 - 在这种情况下,代码只能看到最内层的那个。 例如,当halve函数中的代码引用n时,它看到它自己的n,而不是全局的n

const halve = function(n) {
  return n / 2;
}
let n = 10;
console.log(halve(100));
// → 50
console.log(n);
// → 10

嵌套作用域

JavaScript 不仅区分全局和局部绑定。 块和函数可以在其他块和函数内部创建,产生多层局部环境。

例如,这个函数(输出制作一批鹰嘴豆泥所需的配料)的内部有另一个函数:

const hummus = function(factor) {
  const ingredient = function(amount, unit, name) {
    let ingredientAmount = amount * factor;
    if (ingredientAmount > 1) {
      unit += "s";
    }
    console.log(`${ingredientAmount} ${unit} ${name}`);
  };
  ingredient(1, "can", "chickpeas");
  ingredient(0.25, "cup", "tahini");
  ingredient(0.25, "cup", "lemon juice");
  ingredient(1, "clove", "garlic");
  ingredient(2, "tablespoon", "olive oil");
  ingredient(0.5, "teaspoon", "cumin");
};

ingredient函数中的代码,可以从外部函数中看到factor绑定。 但是它的局部绑定,比如unitingredientAmount,在外层函数中是不可见的。

简而言之,每个局部作用域也可以看到所有包含它的局部作用域。 块内可见的绑定集,由这个块在程序文本中的位置决定。 每个局部作用域也可以看到包含它的所有局部作用域,并且所有作用域都可以看到全局作用域。 这种绑定可见性方法称为词法作用域。

作为值的函数

函数绑定通常只充当程序特定部分的名称。 这样的绑定被定义一次,永远不会改变。 这使得容易混淆函数和名称。

let launchMissiles = function(value) {
  missileSystem.launch("now");
};
if (safeMode) {
  launchMissiles = function() {/* do nothing */};
}

在第 5 章中,我们将会讨论一些高级功能:将函数类型的值传递给其他函数。

符号声明

创建函数绑定的方法稍短。 当在语句开头使用function关键字时,它的工作方式不同。

function square(x) {
  return x * x;
}

这是函数声明。 该语句定义了绑定square并将其指向给定的函数。 写起来稍微容易一些,并且在函数之后不需要分号。

这种形式的函数定义有一个微妙之处。

console.log("The future says:", future());

function future() {
  return "You'll never have flying cars";
}

前面的代码可以执行,即使在函数定义在使用它的代码下面。 函数声明不是常规的从上到下的控制流的一部分。 在概念上,它们移到了其作用域的顶部,并可被该作用域内的所有代码使用。 这有时是有用的,因为它以一种看似有意义的方式,提供了对代码进行排序的自由,而无需担心在使用之前必须定义所有函数。

箭头函数

函数的第三个符号与其他函数看起来有很大不同。 它不使用function关键字,而是使用由等号和大于号组成的箭头(=>)(不要与大于等于运算符混淆,该运算符写做>=)。

const power = (base, exponent) => {
  let result = 1;
  for (let count = 0; count < exponent; count++) {
    result *= base;
  }
  return result;
};

箭头出现在参数列表后面,然后是函数的主体。 它表达了一些东西,类似“这个输入(参数)产生这个结果(主体)”。

如果只有一个参数名称,则可以省略参数列表周围的括号。 如果主体是单个表达式,而不是大括号中的块,则表达式将从函数返回。 所以这两个square的定义是一样的:

const square1 = (x) => { return x * x; };
const square2 = x => x * x;

当一个箭头函数没有参数时,它的参数列表只是一组空括号。

const horn = () => {
  console.log("Toot");
};

在语言中没有很好的理由,同时拥有箭头函数和函数表达式。 除了我们将在第 6 章中讨论的一个小细节外,他们实现相同的东西。 在 2015 年增加了箭头函数,主要是为了能够以简短的方式编写小函数表达式。 我们将在第 5 章中使用它们。

调用栈

控制流经过函数的方式有点复杂。 让我们仔细看看它。 这是一个简单的程序,它执行了一些函数调用:

function greet(who) {
  console.log("Hello " + who);
}
greet("Harry");
console.log("Bye");

这个程序的执行大致是这样的:对greet的调用使控制流跳转到该函数的开始(第 2 行)。 该函数调用控制台的console.log来完成它的工作,然后将控制流返回到第 2 行。 它到达greet函数的末尾,所以它返回到调用它的地方,这是第 4 行。 之后的一行再次调用console.log。 之后,程序结束。

我们可以使用下图表示出控制流:

not in function
   in greet
        in console.log
   in greet
not in function
   in console.log
not in function

由于函数在返回时必须跳回调用它的地方,因此计算机必须记住调用发生处上下文。 在一种情况下,console.log完成后必须返回greet函数。 在另一种情况下,它返回到程序的结尾。

计算机存储此上下文的地方是调用栈。 每次调用函数时,当前上下文都存储在此栈的顶部。 当函数返回时,它会从栈中删除顶部上下文,并使用该上下文继续执行。

存储这个栈需要计算机内存中的空间。 当栈变得太大时,计算机将失败,并显示“栈空间不足”或“递归太多”等消息。 下面的代码通过向计算机提出一个非常困难的问题来说明这一点,这个问题会导致两个函数之间的无限的来回调用。 相反,如果计算机有无限的栈,它将会是无限的。 事实上,我们将耗尽空间,或者“把栈顶破”。

function chicken() {
  return egg();
}
function egg() {
  return chicken();
}
console.log(chicken() + " came first.");
// → ??

可选参数

下面的代码可以正常执行:

function square(x) { return x * x; }
console.log(square(4, true, "hedgehog"));
// → 16

我们定义了square,只带有一个参数。 然而,当我们使用三个参数调用它时,语言并不会报错。 它会忽略额外的参数并计算第一个参数的平方。

JavaScript 对传入函数的参数数量几乎不做任何限制。如果你传递了过多参数,多余的参数就会被忽略掉,而如果你传递的参数过少,遗漏的参数将会被赋值成undefined

该特性的缺点是你可能恰好向函数传递了错误数量的参数,但没有人会告诉你这个错误。

优点是这种行为可以用于使用不同数量的参数调用一个函数。 例如,这个minus函数试图通过作用于一个或两个参数,来模仿-运算符:

function minus(a, b) {
  if (b === undefined) return -a;
  else return a - b;
}

console.log(minus(10));
// → -10
console.log(minus(10, 5));
// → 5

如果你在一个参数后面写了一个=运算符,然后是一个表达式,那么当没有提供它时,该表达式的值将会替换该参数。

例如,这个版本的power使其第二个参数是可选的。 如果你没有提供或传递undefined,它将默认为 2,函数的行为就像square

function power(base, exponent = 2) {
  let result = 1;
  for (let count = 0; count < exponent; count++) {
    result *= base;
  }
  return result;
}

console.log(power(4));
// → 16
console.log(power(2, 6));
// → 64

在下一章当中,我们将会了解如何获取传递给函数的整个参数列表。我们可以借助于这种特性来实现函数接收任意数量的参数。比如console.log就利用了这种特性,它可以用来输出所有传递给它的值。

console.log("C", "O", 2);
// → C O 2

闭包

函数可以作为值使用,而且其局部绑定会在每次函数调用时重新创建,由此引出一个值得我们探讨的问题:如果函数已经执行结束,那么这些由函数创建的局部绑定会如何处理呢?

下面的示例代码展示了这种情况。代码中定义了函数wrapValue,该函数创建了一个局部绑定localVariable,并返回一个函数,用于访问并返回局部绑定localVariable

function wrapValue(n) {
  let local = n;
  return () => local;
}

let wrap1 = wrapValue(1);
let wrap2 = wrapValue(2);
console.log(wrap1());
// → 1
console.log(wrap2());
// → 2

这是允许的并且按照您的希望运行 - 绑定的两个实例仍然可以访问。 这种情况很好地证明了一个事实,每次调用都会重新创建局部绑定,而且不同的调用不能覆盖彼此的局部绑定。

这种特性(可以引用封闭作用域中的局部绑定的特定实例)称为闭包。 引用来自周围的局部作用域的绑定的函数称为(一个)闭包。 这种行为不仅可以让您免于担心绑定的生命周期,而且还可以以创造性的方式使用函数值。

我们对上面那个例子稍加修改,就可以创建一个可以乘以任意数字的函数。

function multiplier(factor) {
 return number => number * factor;
}

let twice = multiplier(2);
console.log(twice(5));
// → 10

由于参数本身就是一个局部绑定,所以wrapValue示例中显式的local绑定并不是真的需要。

考虑这样的程序需要一些实践。 一个好的心智模型是,将函数值看作值,包含他们主体中的代码和它们的创建环境。 被调用时,函数体会看到它的创建环境,而不是它的调用环境。

这个例子调用multiplier并创建一个环境,其中factor参数绑定了 2。 它返回的函数值,存储在twice中,会记住这个环境。 所以当它被调用时,它将它的参数乘以 2。

递归

一个函数调用自己是完全可以的,只要它没有经常这样做以致溢出栈。 调用自己的函数被称为递归函数。 递归允许一些函数以不同的风格编写。 举个例子,这是power的替代实现:

function power(base, exponent) {
  if (exponent == 0) {
    return 1;
  } else {
    return base * power(base, exponent - 1);
  }
}

console.log(power(2, 3));
// → 8

这与数学家定义幂运算的方式非常接近,并且可以比循环变体将该概念描述得更清楚。 该函数以更小的指数多次调用自己以实现重复的乘法。

但是这个实现有一个问题:在典型的 JavaScript 实现中,它大约比循环版本慢三倍。 通过简单循环来运行,通常比多次调用函数开销低。

速度与优雅的困境是一个有趣的问题。 您可以将其视为人性化和机器友好性之间的权衡。 几乎所有的程序都可以通过更大更复杂的方式加速。 程序员必须达到适当的平衡。

power函数的情况下,不雅的(循环)版本仍然非常简单易读。 用递归版本替换它没有什么意义。 然而,通常情况下,一个程序处理相当复杂的概念,为了让程序更直接,放弃一些效率是有帮助的。

担心效率可能会令人分心。 这又是另一个让程序设计变复杂的因素,当你做了一件已经很困难的事情时,担心的额外事情可能会瘫痪。

因此,总是先写一些正确且容易理解的东西。 如果您担心速度太慢 - 通常不是这样,因为大多数代码的执行不足以花费大量时间 - 您可以事后进行测量并在必要时进行改进。

递归并不总是循环的低效率替代方法。 递归比循环更容易解决解决一些问题。 这些问题通常是需要探索或处理几个“分支”的问题,每个“分支”可能再次派生为更多的分支。

考虑这个难题:从数字 1 开始,反复加 5 或乘 3,就可以产生无限数量的新数字。 你会如何编写一个函数,给定一个数字,它试图找出产生这个数字的,这种加法和乘法的序列?

例如,数字 13 可以通过先乘 3 然后再加 5 两次来到达,而数字 15 根本无法到达。

使用递归编码的解决方案如下所示:

function findSolution(target) {
  function find(current, history) {
    if (current == target) {
      return history;
    } else if (current > target) {
      return null;
    } else {
      return find(current + 5, `(${history} + 5)`) ||
             find(current * 3, `(${history} * 3)`);
    }
  }
  return find(1, "1");
}

console.log(findSolution(24));
// → (((1 * 3) + 5) * 3)

需要注意的是该程序并不需要找出最短运算序列,只需要找出任何一个满足要求的序列即可。

如果你没有看到它的工作原理,那也没关系。 让我们浏览它,因为它是递归思维的很好的练习。

内层函数find进行实际的递归。 它有两个参数:当前数字和记录我们如何到达这个数字的字符串。 如果找到解决方案,它会返回一个字符串,显示如何到达目标。 如果从这个数字开始找不到解决方案,则返回null

为此,该函数执行三个操作之一。 如果当前数字是目标数字,则当前历史记录是到达目标的一种方式,因此将其返回。 如果当前的数字大于目标,则进一步探索该分支是没有意义的,因为加法和乘法只会使数字变大,所以它返回null。 最后,如果我们仍然低于目标数字,函数会尝试从当前数字开始的两个可能路径,通过调用它自己两次,一次是加法,一次是乘法。 如果第一次调用返回非null的东西,则返回它。 否则,返回第二个调用,无论它产生字符串还是null

为了更好地理解函数执行过程,让我们来看一下搜索数字 13 时,find函数的调用情况:

find(1, "1")
  find(6, "(1 + 5)")
    find(11, "((1 + 5) + 5)")
      find(16, "(((1 + 5) + 5) + 5)")
        too big
      find(33, "(((1 + 5) + 5) * 3)")
        too big
    find(18, "((1 + 5) * 3)")
      too big
  find(3, "(1 * 3)")
    find(8, "((1 * 3) + 5)")
      find(13, "(((1 * 3) + 5) + 5)")
        found!

缩进表示调用栈的深度。 第一次调用find时,它首先调用自己来探索以(1 + 5)开始的解决方案。 这一调用将进一步递归,来探索每个后续的解,它产生小于或等于目标数字。 由于它没有找到一个命中目标的解,所以它向第一个调用返回null。 那里的||操作符会使探索(1 * 3)的调用发生。 这个搜索的运气更好 - 它的第一次递归调用,通过另一个递归调用,命中了目标数字。 最内层的调用返回一个字符串,并且中间调用中的每个“||”运算符都会传递该字符串,最终返回解决方案。

添加新函数

这里有两种常用的方法,将函数引入到程序中。

首先是你发现自己写了很多次非常相似的代码。 我们最好不要这样做。 拥有更多的代码,意味着更多的错误空间,并且想要了解程序的人阅读更多资料。 所以我们选取重复的功能,为它找到一个好名字,并把它放到一个函数中。

第二种方法是,你发现你需要一些你还没有写的功能,这听起来像是它应该有自己的函数。 您将首先命名该函数,然后您将编写它的主体。 在实际定义函数本身之前,您甚至可能会开始编写使用该函数的代码。

给函数起名的难易程度取决于我们封装的函数的用途是否明确。对此,我们一起来看一个例子。

我们想编写一个打印两个数字的程序,第一个数字是农场中牛的数量,第二个数字是农场中鸡的数量,并在数字后面跟上CowsChickens用以说明,并且在两个数字前填充 0,以使得每个数字总是由三位数字组成。

007 Cows
011 Chickens

这需要两个参数的函数 - 牛的数量和鸡的数量。 让我们来编程。

function printFarmInventory(cows, chickens) {
  let cowString = String(cows);
  while (cowString.length < 3) {
    cowString = "0" + cowString;
  }
  console.log(`${cowString} Cows`);
  let chickenString = String(chickens);
  while (chickenString.length < 3) {
    chickenString = "0" + chickenString;
  }
  console.log(`${chickenString} Chickens`);
}
printFarmInventory(7, 11);

在字符串表达式后面写.length会给我们这个字符串的长度。 因此,while循环在数字字符串前面加上零,直到它们至少有三个字符的长度。

任务完成! 但就在我们即将向农民发送代码(连同大量发票)时,她打电话告诉我们,她也开始饲养猪,我们是否可以扩展软件来打印猪的数量?

当然没有问题。但是当再次复制粘贴这四行代码的时候,我们停了下来并重新思考。一定还有更好的方案来解决我们的问题。以下是第一种尝试:

function printZeroPaddedWithLabel(number, label) {
  let numberString = String(number);
  while (numberString.length < 3) {
    numberString = "0" + numberString;
  }
  console.log(`${numberString} ${label}`);
}

function printFarmInventory(cows, chickens, pigs) {
  printZeroPaddedWithLabel(cows, "Cows");
  printZeroPaddedWithLabel(chickens, "Chickens");
  printZeroPaddedWithLabel(pigs, "Pigs");
}

printFarmInventory(7, 11, 3);

这种方法解决了我们的问题!但是printZeroPaddedWithLabel这个函数并不十分恰当。它把三个操作,即打印信息、数字补零和添加标签放到了一个函数中处理。

这一次,我们不再将程序当中重复的代码提取成一个函数,而只是提取其中一项操作。

function zeroPad(number, width) {
  let string = String(number);
  while (string.length < width) {
    string = "0" + string;
  }
  return string;
}

function printFarmInventory(cows, chickens, pigs) {
  console.log(`${zeroPad(cows, 3)} Cows`);
  console.log(`${zeroPad(chickens, 3)} Chickens`);
  console.log(`${zeroPad(pigs, 3)} Pigs`);
}

printFarmInventory(7, 16, 3);

名为zeroPad的函数具有很好的名称,使读取代码的人更容易弄清它的功能。 而且这样的函数在更多的情况下是有用的,不仅仅是这个特定程序。 例如,您可以使用它来帮助打印精确对齐的数字表格。

我们的函数应该包括多少功能呢?我们可以编写一个非常简单的函数,只支持将数字扩展成 3 字符宽。也可以编写一个复杂通用的数字格式化系统,可以处理分数、负数、小数点对齐和使用不同字符填充等。

一个实用原则是不要故作聪明,除非你确定你会需要它。 为你遇到的每一个功能编写通用“框架”是很诱人的。 控制住那种冲动。 你不会完成任何真正的工作 - 你只会编写你永远不会使用的代码。

函数及其副作用

我们可以将函数分成两类:一类调用后产生副作用,而另一类则产生返回值(当然我们也可以定义同时产生副作用和返回值的函数)。

在农场案例当中,我们调用第一个辅助函数printZeroPaddedWithLabel来产生副作用,打印一行文本信息。而在第二个版本中有一个zeroPad函数,我们调用它来产生返回值。第二个函数比第一个函数的应用场景更加广泛,这并非偶然。相比于直接产生副作用的函数,产生返回值的函数则更容易集成到新的环境当中使用。

纯函数是一种特定类型的,生成值的函数,它不仅没有副作用,而且也不依赖其他代码的副作用,例如,它不读取值可能会改变的全局绑定。 纯函数具有令人愉快的属性,当用相同的参数调用它时,它总是产生相同的值(并且不会做任何其他操作)。 这种函数的调用,可以由它的返回值代替而不改变代码的含义。 当你不确定纯函数是否正常工作时,你可以通过简单地调用它来测试它,并且知道如果它在当前上下文中工作,它将在任何上下文中工作。 非纯函数往往需要更多的脚手架来测试。

尽管如此,我们也没有必要觉得非纯函数就不好,然后将这类函数从代码中删除。副作用常常是非常有用的。比如说,我们不可能去编写一个纯函数版本的console.log,但console.log依然十分实用。而在副作用的帮助下,有些操作则更易、更快实现,因此考虑到运算速度,有时候纯函数并不可取。

本章小结

本章教你如何编写自己的函数。 当用作表达式时,function关键字可以创建一个函数值。 当作为一个语句使用时,它可以用来声明一个绑定,并给它一个函数作为它的值。 箭头函数是另一种创建函数的方式。

// Define f to hold a function value
const f = function(a) {
  console.log(a + 2);
};

// Declare g to be a function
function g(a, b) {
  return a * b * 3.5;
}

// A less verbose function value
let h = a => a % 3;

理解函数的一个关键方面是理解作用域。 每个块创建一个新的作用域。 在给定作用域内声明的参数和绑定是局部的,并且从外部看不到。 用var声明的绑定行为不同 - 它们最终在最近的函数作用域或全局作用域内。

将程序执行的任务分成不同的功能是有帮助的。 你不必重复自己,函数可以通过将代码分组成一些具体事物,来组织程序。

习题

最小值

前一章介绍了标准函数Math.min,它可以返回参数中的最小值。我们现在可以构建相似的东西。编写一个函数min,接受两个参数,并返回其最小值。

// Your code here.

console.log(min(0, 10));
// → 0
console.log(min(0, -10));
// → -10

递归

我们已经看到,%(取余运算符)可以用于判断一个数是否是偶数,通过使用% 2来检查它是否被 2 整除。这里有另一种方法来判断一个数字是偶数还是奇数:

  • 0是偶数

  • 1是奇数

  • 对于其他任何数字N,其奇偶性与N–2相同。

定义对应此描述的递归函数isEven。 该函数应该接受一个参数(一个正整数)并返回一个布尔值。

使用 50 与 75 测试该函数。想想如果参数为 –1 会发生什么以及产生相应结果的原因。请你想一个方法来修正该问题。

// Your code here.

console.log(isEven(50));
// → true
console.log(isEven(75));
// → false
console.log(isEven(-1));
// → ??

字符计数

你可以通过编写"string"[N],来从字符串中得到第N个字符或字母。 返回的值将是只包含一个字符的字符串(例如"b")。 第一个字符的位置为零,这会使最后一个字符在string.length - 1。 换句话说,含有两个字符的字符串的长度为2,其字符的位置为 0 和 1。

编写一个函数countBs,接受一个字符串参数,并返回一个数字,表示该字符串中有多少个大写字母"B"

接着编写一个函数countChar,和countBs作用一样,唯一区别是接受第二个参数,指定需要统计的字符(而不仅仅能统计大写字母"B")。并使用这个新函数重写函数countBs

// Your code here.

console.log(countBs("BBC"));
// → 2
console.log(countChar("kakkerlak", "k"));
// → 4