第三章 - 函数


  • administrators

    人们认为计算机科学是天才的艺术,但实际的现实恰恰相反,它只有把很多人做的事情紧密结合,而各个部分又是相互依赖,就像一堵迷你石头垒成的墙。
    唐纳德克努特

    函数是JavaScript编程的基础。将一段程序包装在一个值中的概念有很多用途。它为我们提供了一种方法来构建更大的程序,减少重复,将名称与子程序相关联,以及将这些子程序相互隔离。

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

    典型的成年英语使用者的词汇量大约有20,000个单词。很少有编程语言都带有内置20,000命令。而这些词汇是提供更趋于精确定义的,因而不够灵活,比人类的语言。因此,我们通常必须引入新概念以避免重复过多。

    定义一个函数

    函数定义是常规绑定,其中绑定的值是函数。例如,此代码定义square为引用生成给定数字的平方的函数:

    const square = function(x) {
      return x * x;
    };
    
    console.log(square(12));
    // → 144
    

    使用以关键字开头的表达式创建函数function。函数有一组参数(在这种情况下,只有x)和一个body,它包含调用函数时要执行的语句。以这种方式创建的函数的函数体必须始终用大括号包装,即使它只包含一个语句。

    一个函数可以有多个参数或根本没有参数。在以下示例中,makeNoise不列出任何参数名称,而power列出两个:

      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
    

    某些函数会产生一个值,例如power和square,有些则不会产生,例如makeNoise,其唯一的结果是副作用。一个return语句确定函数返回值。当控件遇到这样的语句时,它会立即跳出当前函数并将返回的值提供给调用该函数的代码。return没有表达式的关键字将导致函数返回undefined。完全没有return语句的函数,例如makeNoise,类似地返回undefined。

    函数的参数表现得像常规绑定,但它们的初始值由函数的调用者给出,而不是函数本身的代码。

    绑定和范围

    每个绑定都有一个范围,它是绑定可见的程序的一部分。对于在任何函数或块之外定义的绑定,范围是整个程序 - 您可以在任何地方引用此类绑定。这些被称为全球性的。

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

    声明的绑定let和声明它们声明const的块实际上是本地的,所以如果你在循环中创建其中一个,循环之前和之后的代码都不能“看到”它。在2015年之前的JavaScript中,只有函数创建了新的作用域,因此使用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外部函数的绑定。但是它的本地绑定(例如unitor)ingredientAmount在外部函数中是不可见的。

    块内可见的绑定集由程序文本中该块的位置确定。每个本地作用域还可以查看包含它的所有本地作用域,并且所有作用域都可以看到全局作用域。这种绑定可见性的方法称为词法范围。

    作为值的功能

    函数绑定通常只是作为程序特定部分的名称。这样的绑定被定义一次并且从未改变。这样可以很容易地混淆函数及其名称。

    但两者是不同的。函数值可以执行其他值可以执行的所有操作 - 您可以在任意表达式中使用它,而不仅仅是调用它。可以将函数值存储在新绑定中,将其作为参数传递给函数,依此类推。类似地,保存函数的绑定仍然只是一个常规绑定,如果不是常量,可以赋予一个新值,如下所示:

    let launchMissiles = function() {
      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”);
    };
    

    没有深刻的理由function在语言中同时使用箭头函数和表达式。除了我们将在第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创建本地绑定的函数。然后它返回一个访问并返回此本地绑定的函数。

    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
    

    明确local从绑定wrapValue例子是不是真的需要,因为参数本身就是一个本地绑定。

    考虑这样的程序需要一些练习。一个好的心理模型是将函数值视为包含其体内的代码和创建它们的环境。调用时,函数体会查看创建它的环境,而不是调用它的环境。

    在该示例中,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,可以产生无限的数字集。你会如何写一个函数,给定一个数字,试图找到一系列这样的加法和乘法产生这个数字?

    例如,可以通过首先乘以3然后再加5来两次来达到数字13,而根本不能达到数字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。

    为了更好地理解这个函数如何产生我们正在寻找的效果,让我们看一下在find搜索数字13的解决方案时所做的所有调用。

    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)发生。这个搜索有更多的运气 - 它的第一个递归调用,通过另一个递归调用,命中目标数字。最里面的调用返回一个字符串,||中间调用中的每个运算符都传递该字符串,最终返回解决方案。

    功能增长

    将函数引入程序有两种或多或少的自然方式。

    首先,您发现自己多次编写类似的代码。你宁愿不这样做。拥有更多代码意味着更多的空间可以隐藏错误,并为尝试理解程序的人提供更多阅读材料。因此,您可以使用重复的功能,为它找到一个好名称,并将其放入一个函数中。

    第二种方式是你发现你需要一些你尚未编写的功能,听起来它应该有自己的功能。你将从命名函数开始,然后你将编写它的正文。在实际定义函数本身之前,您甚至可能开始编写使用该函数的代码。

    为函数找到一个好名字是多么困难,这很好地说明了你试图包装的概念是多么清晰。我们来看一个例子吧。

    我们想要编写一个打印两个数字的程序:农场中的奶牛和鸡的数量,Cows以及Chickens在它们之后填充的单词和零填充,以便它们总是三位数。

    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使得读取代码的人更容易弄清楚它的作用。并且这种功能在更多情况下比在这个特定程序中更有用。例如,您可以使用它来帮助打印精确对齐的数字表。

    我们的功能应该多么聪明和多样化?我们可以编写任何东西,从一个非常简单的函数,只能将一个数字填充为三个字符宽到一个复杂的广义数字格式系统,处理小数,负数,小数点对齐,不同字符的填充,等等。

    一个有用的原则是不添加聪明,除非你绝对确定你需要它。为您遇到的每一点功能编写通用“框架”可能很诱人。抵制这种冲动。你不会完成任何真正的工作 - 你只需要编写你从未使用过的代码。

    功能和副作用

    函数可以粗略地分为那些为副作用调用的函数和为其返回值调用的函数。(虽然两者都有可能产生副作用并返回值。)

    该场示例中的第一个辅助函数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行为不同 - 它们最终在最近的函数作用域或全局作用域中。

    将程序执行的任务分离到不同的功能是有帮助的。您不必重复自己,函数可以通过将代码分组为执行特定操作的部分来帮助组织程序。