JavaScript 编程精解 中文第三版 3、函数

来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目

原文:Functionsjavascript

译者:飞龙html

协议:CC BY-NC-SA 4.0java

自豪地采用谷歌翻译git

部分参考了《JavaScript 编程精解(第 2 版)》程序员

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

高德纳apache

函数是 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
相关文章
相关标签/搜索