重读红宝书后,关于函数的这几个细节你还记得吗?

扯皮

最近又把红宝书从零开始刷了一遍,发现很多知识点都有遗忘,之前阅读的过程中有不少用笔划线的内容现在都忘记了,因此这次准备把一些不太常见的知识点记录成文章方便以后回看。

正文

new Function 定义函数以及区分

针对于函数有三种定义方式,前两种是较为常用,最后一种很少在业务代码中使用:

  • function 函数声明
  • 函数表达式
  • new Function

因为函数的本质也是对象,所以定义函数的方法也可以把它当作实例化对象的过程,前两种反而较为特殊,第三种更符合实例化对象的操作。

这里简单介绍一下 Function 构造函数的使用,我们通过 new Function 创建一个函数对象,创建时可以给构造函数传递参数:

参数列表的最后一项会作为函数体,而前面的几项都将作为函数的参数进行传递

javascript 复制代码
const foo = new Function("a", "b", "console.log(a + b);");
foo(1, 2); // 3

可以看到创建的实例函数对象使用方式和普通函数没有任何区别,但一个函数的函数体往往不会那么简单,再看下面的例子:

javascript 复制代码
const sum = new Function("a", "b", "console.log(a + b) return a + b;"); // ❌

这里的错误较为明显,实际上它相当于我们这样定义函数,而这种语法在 JS 中是不允许的:

javascript 复制代码
function sum(a, b) {
    console.log(a + b) return a + b; // ❌
}

因此我们需要这样做,这时候 new Function 用起来就比较鸡肋了:

javascript 复制代码
// ; 分割语句
const sum = new Function("a", "b", "console.log(a + b); return a + b;");
// \n 语句换行
const sum = new Function("a", "b", "console.log(a + b)\n return a + b");

不管怎么样也是 JS 为我们提供定义函数的一种方式,但在红宝书上也有说明不推荐这种做法并给出了原因: new Function 定义函数的代码会被执行两次,第一次会把它当作常规的 ECMAScript 代码,第二次是解释传给构造函数的字符串,这显然会影响性能。

实际上与前两种定义方式不同,通过 new Function 的方式创建函数是可以区分出来的,我们知道函数的本质是对象,那么函数对象上面一定有一些相关的属性:

我们重点关注 name 属性,它是一个只读属性,值是函数名,我们关注这三种定义函数方式的 name 属性看看结果:

javascript 复制代码
function test1() {
  console.log("test1");
}

const test2 = function () {
  console.log("test2");
};

const test3 = new Function("console.log('test3');");

console.log(test1.name); // test1
console.log(test2.name); // test2
console.log(function () {}.name); //  (空字符串) 💡
console.log(test3.name); // anoymous 💡

注意这里的匿名函数以及通过 new Function 创建的函数,它们的 name 属性与正常的函数名都不同,且都是固定的,这也是区分函数不同类型的一种方式。

额外补充一个使用 bind 绑定 this 后的函数名,我们知道 bind 后会返回一个新的函数:

Javascript 复制代码
function test() {
  console.log(this);
}

const t = test.bind({ name: "abc" });
console.log(t.name); // bound test 💡

新的函数 name 属性是 bound 前缀加上原函数名

默认参数作用域问题

函数的默认参数我们并不陌生,当我们调用函数时不传入对应参数或者传入 undefined 会自动使用默认值进行初始化

javascript 复制代码
function foo(a = 1, b = 2) {
  console.log(a, b);
}

foo(); // 1 2
foo(undefined, undefined); // 1 2
foo(undefined, 100); // 1 100

但参数的初始化是按照顺序的,因此就有下面的例子:

javascript 复制代码
function foo(a = 1, b = a) {
  console.log(a, b);
}
foo(); // 1 1
javascript 复制代码
function foo(a = b, b = 2) {
  console.log(a, b);
}

foo(); // ❌ 运行时错误:Cannot access 'b' before initialization

这个错误现象被称之为参数的暂时性死区,类似用 let 声明变量一样,我们不能在其声明之前使用。

函数的参数有自己的作用域,它不能访问函数体中的作用域,但是函数体中却能够访问到参数:

javascript 复制代码
function foo(a = 1, b = c) {
  let c = 100;
  console.log(a, b, c);
}

foo(); // ❌ 运行时错误:c is not defined

arguments 与 函数参数的关系

我们知道 arguments 是一个类数组对象,它保存着函数的参数列表以及 callee 等属性,但当调用者不传入参数值时它是空的,并且即使设置了参数默认值也是空的,arguments 的对象长度是根据传入的参数个数,而非定义函数时给出的命名参数个数决定

javascript 复制代码
function foo1(a, b) {
  console.log(arguments[0], arguments[1], a, b); // undefined undefined undefined undefined
}

// 有默认值
function foo2(a = 1, b = 2) {
  console.log(arguments[0], arguments[1], a, b); // undefined undefined 1 2
}

// 实参不传入第二个参数
function foo3(a, b) {
  b = 1000;
  console.log(arguments[1], b); // undefined 1000
}

foo1(); 
foo2();
foo3(100);

而 arguments 与形参存在一种同步关系,这种关系只是单向的。

当我们修改形参时,arguments 保存的参数列表也会跟着改变

当我们修改 arguments 中参数列表中的元素值时,形参值也会跟着变

javascript 复制代码
// 修改形参
function foo1(a, b) {
  console.log(arguments[0], arguments[1], a, b); // 1 2 1 2
  a = 100;
  b = 1000;
  console.log(arguments[0], arguments[1], a, b); // 100 1000 100 1000
}

// 修改 arguments
function foo2(a, b) {
  console.log(arguments[0], arguments[1], a, b); // 1 2 1 2
  arguments[0] = 100;
  arguments[1] = 1000;
  console.log(arguments[0], arguments[1], a, b); // 100 1000 100 1000
}

foo1(1, 2);
foo2(1, 2);

虽然具有同步关系,但是两者访问的并不是同一个内存地址,它们在内存中还是分开的

不过需要注意一点,一旦给参数添加默认值,即使没有使用默认值,也会导致这种同步关系直接消失

javascript 复制代码
function foo1(a = 3, b) {
  console.log(arguments[0], arguments[1], a, b); // 1 2 1 2
  a = 100;
  b = 1000;
  console.log(arguments[0], arguments[1], a, b); // 1 2 100 1000
}

function foo2(a = 3, b) {
  console.log(arguments[0], arguments[1], a, b); // 1 2 1 2
  arguments[0] = 100;
  arguments[1] = 1000;
  console.log(arguments[0], arguments[1], a, b); // 100 1000 1 2
}

foo1(1, 2);
foo2(1, 2);

这里补充一个小的插曲,在我的红宝书中针对于这种同步关系是这样描述的,注意看红色笔画线部分,描述的是同步关系是单向的,这显然与上面的代码例子结论相违背:

我又去网上找到了红宝书的电子版找到对应的位置,才发现这句话居然是没有的,这也太难绷了😂:

递归与尾调用优化

递归指的是一个函数调用自己,通常会设置一个出口来表示不断将函数压入调用栈的终点,比如最简单的计算阶乘:

javascript 复制代码
function factorial(num) {
  if (num === 1) return num;
  else return num * factorial(num - 1);
}

console.log(factorial(5)); // 120
console.log(factorial(6)); // 720

但是可能会出现一种情况,我们使用函数表达式来定义递归函数,如果中途修改了函数引用,那这样写就会出现问题:

Javascript 复制代码
let factorial = function (num) {
  if (num === 1) return num;
  else return num * factorial(num - 1);
};

const foo = factorial;
factorial = null;
console.log(foo(5)); // ❌:factorial is not a function

那么解决办法很简单,借助 arguments 中的 callee 属性来获取函数自身,无需通过函数名进行调用:

javascript 复制代码
let factorial = function (num) {
  if (num === 1) return num;
  else return num * arguments.callee(num - 1); // 💡
};

const foo = factorial;
factorial = null;

不过一般情况下在业务编码阶段也很少对递归函数进行这样的操作,毕竟 arguments 也有限制。

ES6 中新增了内存管理优化机制,即重用栈帧,这种优化方式较适合尾调用,尾调用的定义很简单,就是一个函数的返回值是其一个内部函数的返回值:

javascript 复制代码
function outer() {
  return inner();
}

function inner() {
  return "hello";
}

简单来讲函数的调用符合栈结构,当函数进行嵌套时会按照栈的特性进行压栈出栈操作

ES6 之前针对于上述的代码是这样的流程:

outer 进栈 -> 执行 -> inner 进栈 -> 执行 -> inner 出栈 -> outer 出栈

而 ES6 针对于内存管理进行了优化,它的流程是这样的:

outer 进栈 -> 执行(发现只有 return 并且还要去求出 inner 的返回值) -> outer 出栈 -> inner 进栈 -> 执行 -> 出栈

相当于在整个函数执行流程中 outer 提前出栈了,这里只展示了嵌套一层,假如是一个尾调用递归函数,那么将是极大的优化(执行递归时内存中将不会一直执行进栈,而是中间会有弹出操作,最终整个过程只有一个栈帧)

但是这种优化是有一定条件的,就是在 outer 弹出之前的一个判断,如何判断它符合尾调用的条件去走出栈的逻辑? 红宝书中给出了答案,必须满足下面的所有条件

  • 代码在严格模式下执行
  • 外部函数的返回值是对尾调用函数的调用
  • 尾调用函数返回后不需要执行额外的逻辑
  • 尾调用函数不是引用外部函数作用域中自由变量的闭包

针对于这几个条件红宝书中也给出了不符合条件的例子:

javascript 复制代码
// 尾调用没有返回
function outer() {
    inner();
}

// 尾调用没有直接返回
function outer() {
    let innerRes = inner();
    return innerRes;
}

// inner 返回后还在 outer 中执行了 toString 逻辑
function outer() {
   return inner().toString();
}

// 存在闭包 
function outer() {
  let foo = "test";
  function inner() { return foo; }
  return inner();
}

符合条件的例子:

javascript 复制代码
"use strict";
// 👆开启严格模式

function outer(a, b) {
    return inner(a + b);
}

function outer(a, b) {
    if(a < b) return a;
    return inner(a + b);
}

function outer(flag) {
    return flag ? innerA() : innerB();
}

最后我们将递归与尾调用进行结合,以斐波那契为例先看递归的写法:

javascript 复制代码
function fib(n) {
  if (n < 2) return n;
  return fib(n - 1) + fib(n - 2);
}

由于 fib 最后 return 时是进行两个 fib 函数执行结果进行相加,不符合尾调用优化的第三条。

我们可以额外补充一个函数,将具体的运算逻辑进行抽离,这样两个函数结合,符合尾调用优化的所有条件:

javascript 复制代码
function fib(n) {
  return fibImpl(0, 1, n);
}

function fibImpl(a, b, n) {
  if (n === 0) return a;
  return fibImpl(b, a + b, n - 1);
}

最后我们来比较两者的耗时看看是不是真有优化效果:

javascript 复制代码
function fib1(n) {
  if (n < 2) return n;
  return fib1(n - 1) + fib1(n - 2);
}

function fib2(n) {
  return fibImpl(0, 1, n);
}

function fibImpl(a, b, n) {
  if (n === 0) return a;
  return fibImpl(b, a + b, n - 1);
}

console.time("test1");
console.log(fib1(40));
console.timeEnd("test1");

console.time("test2");
console.log(fib2(40));
console.timeEnd("test2");

显而易见,爆杀了家人们!😄

相关推荐
Fan_web9 分钟前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常11 分钟前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇1 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr1 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho2 小时前
【TypeScript】知识点梳理(三)
前端·typescript
安冬的码畜日常3 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
小白学习日记4 小时前
【复习】HTML常用标签<table>
前端·html
丁总学Java4 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele4 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范