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

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

扯皮

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

正文

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");

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

相关推荐

苹果电脑怎么连接打印机_苹果笔记本连接打印机
台式机装机大概要多久(组装电脑最快要多少时间?)
阴阳师现世鬼王cd多久 阴阳师现世鬼王多久打一次
王者荣耀玩游戏突然卡屏〖王者荣耀卡屏是什么情况〗
bat365在线平台官网登录

王者荣耀玩游戏突然卡屏〖王者荣耀卡屏是什么情况〗

07-31 👁️ 5016
肿么把手机麦克风打开?
bat365在线平台官网登录

肿么把手机麦克风打开?

07-13 👁️ 1542
【ZTE(中兴)Axon系列】ZTE(中兴)Axon系列手机报价及图片大全
手机进水后能否自愈?揭秘背后的科学原理与急救措施
二尺五的腰围是多少厘米(二尺五的腰围是多少厘米皮带)
更换漏电保护器多少钱(漏电保护器换一个多少钱