重生之我在 Vibe Coding 时代当程序员:第十六课,从模拟队列到原型链

重生之我在 Vibe Coding 时代当程序员:第十六课,从模拟队列到原型链

上一节课我还在补 JavaScript 执行机制:执行上下文、调用栈、声明提升、词法环境。 这一节课看起来是算法课:如何用栈模拟队列。但真正走进去以后,我发现它又把我带回了 JS 的底层:函数为什么也是对象,new 到底做了什么,prototype__proto__ 又是怎么把方法串起来的。 这一节课,我要解决的问题是:当 AI 可以很快给我写出 MyQueue 的答案时,我自己到底要看懂哪几层东西,才不只是复制一段代码。

先把上一段 JS 执行机制接上

课堂笔记里先放了一组 runway 代码,用来复盘 JS 代码是怎么跑起来的。

我一开始记下来的关键词是:

  • 引擎
  • 同步、异步
  • 生命周期
  • 作用域
  • 变量提升,其实更准确的名字应该是声明提升
  • 执行栈,也叫调用栈

变量提升这一块,老师把它拆成了执行上下文对象里的几个部分:

  • 变量环境:functionvar 声明的内容在这里
  • 词法环境:letconst 声明的内容在这里
  • 剩余可执行代码:从上到下顺序执行

为什么要有执行上下文对象?笔记里写得很直接:便于运行代码。

执行栈是 V8 引擎用来管理函数之间调用关系的一种数据结构。同一时刻只有一个函数会被执行。JS 作为编译型语言,编译总发生在执行前一刻,而不是提前很久编译。全局代码和函数体编译时会生成执行上下文对象,并存入调用栈。当一个函数执行完毕后,它的执行上下文会弹出调用栈并销毁。函数是代码执行的最小单元。

笔记里还有一句没补完的话:

? 中间讲了什么,等会查看一下录播

这个我先原样保留。这里说明我当时中间有一段没有完全跟上,后面如果补录播,可以继续把这块补完整。

声明提升:代码执行前,JS 先整理"名单"

第一段示例代码是这样的:

javascript 复制代码
// JavaScript 执行机制对于开发者来说至关重要
// 代码是怎么执行的?(与 c++/java 等语言的不同)

showName('即可时间');
console.log(myName);

var myName = '李四';
function showName(name) {
    console.log(name);
    var b = 1;
    console.log('函数 showName 执行了');
}

这段代码的重点不是 showName 打印了什么,而是:函数声明和 var 声明在执行前会先被处理。

从 V8 引擎眼里看,它更像下面这样:

javascript 复制代码
// V8 引擎眼里
// 声明提升
var myName; // 变量提升

function showName() {
    console.log('函数 showName 执行了');
}

showName();
console.log(myName);
myName = '张三';

所以我现在更愿意把"变量提升"理解成"声明提升":不是值被提前搬走了,而是声明先进入了执行上下文。var myName 先存在,值暂时是 undefined,后面执行到赋值语句时才变成具体值。

编译过程笔记里写了四步:

  1. 创建执行上下文 context 对象
  2. 找形参和变量声明,将形参和声明的变量名作为 keyvalueundefined
  3. 统一形参和实参的值,全局上下文没有这个步骤
  4. 找函数声明,将函数名作为 keyvalue 为函数体

这里还有两个用来观察优先级的例子。

javascript 复制代码
console.log(func);

function func(){
    console.log('函数 func 执行了');
}

var func = '123';

以及:

javascript 复制代码
var a = 1;

function fn(a) {
  var a = 2
  function a() {}
  var b = a;
  console.log(a);
}
fn(3);

这类题我以前会直接背答案,但现在我更想把它归到"执行前先编译,编译阶段先创建执行上下文"这一套规则里。形参、var、函数声明不是在同一个时刻被随便塞进去的,它们有自己的处理顺序。

var、let、const:为什么词法环境也是一个栈

接下来是 varlet 的对比。

var 不支持块级作用域:

javascript 复制代码
function varText() {
    var x = 1;
    if(true){
        // var不支持块级作用域
        var x = 2;
        console.log(x);
    }
    console.log(x);
}
varText();

换成 let 以后,块里面的 x 和外面的 x 就不是同一个东西了:

javascript 复制代码
function varText() {
    var x = 1;
    if(true){
        let x = 2;
        console.log(x);
    }
    console.log(x);
}
varText();

课堂里进一步把词法环境和栈联系起来:

javascript 复制代码
function foo(){
    var a = 1;
    let b = 2;
    {
    // 块级作用域,存储在词法环境中,词法环境好像也是一个作用域
    // 如果词法环境中的变量使用完成,也会被销毁
    // 词法环境是一个栈?
    // 块级作用域是通过词法环境栈来实现的
    let b = 3;
    var c = 4;
    let d = 5;
    console.log(a);
    console.log(b);
    }
    console.log('-----------------');
    console.log(b);
    console.log(c);
    console.log(d);
}
foo();

这里我当时的临时理解写在注释里了:词法环境好像也是一个作用域,块级作用域是通过词法环境栈来实现的。

现在把它说得更稳一点:letconst 会进入词法环境,词法环境支持块级作用域。调用栈是执行上下文的容器,栈顶指针指向当前正在执行的函数或全局上下文;而词法环境也可以用类似栈的方式管理不同作用域里的变量。

最后一个例子是 let 不允许重复声明:

javascript 复制代码
// console.log(a);
// var a = 1;
// // var 支持多次声明
// var a = 2;
// function a(){}

// let 不可以多次声明
let a = 2;
let a = 1;

function a(){}

console.log(a);

笔记总结得很清楚:

let/const 不能重复声明,也不会变量提升(更准确地说,是提前进入词法环境),会进入暂时性死区(Dead Zone,词法环境中),这些都是为之前 JS 中的 Bug 买单。

也就是说,ES6 不是凭空多了几个新关键字,而是在给早期 JS 的语言设计补课。

从执行栈走到数据结构:栈和队列

有了调用栈这个背景,再看"栈和队列",就不只是算法题了。

笔记先把数据结构分了类:

  • 线性结构
    • 队列
    • 链表
    • 双端队列
  • 非线性结构
    • 二叉树
    • 多叉树
    • DST
    • 大小顶堆

后面又补了一版:

  • 基于对数组的理解和掌握,来实现线型数据结构:栈、队列、链表
  • 非线性数据结构会分叉:树有唯一的根节点,图不一定有唯一的根节点
  • 线型数据结构里,数组和链表是基础
    • 数组开箱即用,连续存储,下标访问
    • 链表不连续,添加删除元素方便
    • 栈和队列可以看成操作受限的数组

这句话对我很关键:栈和队列不是神秘的新东西,而是"受限的数组"。

Stack 是先进后出,笔记里前面写了 FILO,后面写了更常见的 LIFO Last In Fist Out。这里应该是 First 少了一个 r,但我保留原始笔记里的拼写,同时在理解上记成:后进先出。

队列 Queue 是先进先出,FIFO

数组增删:push、unshift、splice 都会改原数组

在进入栈和队列之前,课堂先从数组操作补了一下:

  • 尾部添加元素:push
    • 有数组扩容的问题,链表不需要扩容
  • 首部添加元素:unshift
    • 可以从内存视角查看增添元素操作
  • 组内任意位置添加元素:splice(start_index, del_count, ...added)
    • 笔记里写了 MDN 官方文档
    • splice 可以理解成 slice + replace
    • 可以删,也可以增

示例代码:

javascript 复制代码
const arr = [1,2];

console.log(arr.splice(1,0,3));

arr.splice(1,1);
console.log(arr);

这里最容易忽略的一点是:数组的增删都不是纯函数,原数组会被修改。splice 返回的是被删除的元素数组,而不是修改后的新数组。

栈:只能在尾端操作的数组

栈可以看成只允许在栈顶操作的数组,也就是只在数组尾部操作。后进先出 LIFO,体现的是"只在尾端操作"。

课堂里的比喻是:字节面试题,冰柜里的雪糕。

我理解这个比喻是:如果雪糕一根一根叠进去,最后放进去的在最上面,最先被拿出来。这就是栈。

代码如下:

javascript 复制代码
// push,pop 如何获取栈顶元素 peek:stack[stack.length-1]

const stack =[];// 空栈
stack.push('东北大板');
stack.push('东北小板');
stack.push('可爱多');
stack.push('冰工厂');

console.log(stack);

// 如何编写出栈代码
while(stack.length){
    const top = stack[stack.length-1];
  console.log('取出来的元素是',top);
  stack.pop();
}

这里有三个动作:

  • push:入栈
  • pop:出栈
  • peek:访问栈顶元素,也就是 stack[stack.length - 1]

while(stack.length) 这个写法也很直观:只要栈里还有元素,就继续取出栈顶,再 pop 掉。

队列:队尾入队,队头出队

队列也是受限的数组,只是限制和栈不同:

  • 队尾入队
  • 队头出队
  • 先进先出 FIFO

课堂里的基础写法是:

javascript 复制代码
const queue = [];// 空队列
queue.push('东北大板');
queue.push('东北小板');
queue.push('可爱多');
queue.push('冰工厂');

while(queue.length){
    const top = queue[0];
    console.log('取出来的元素是',top);
    queue.shift();
}

console.log(queue);

这里 push 是入队,shift 是出队。访问队头元素就是 queue[0]

但是用数组直接 shift 有一个问题:每次从头部删除元素,后面的元素都要移动。于是题目变成:如何用两个栈来模拟一个队列?

两个栈模拟队列:把顺序倒两次

笔记里的核心描述是:

A 栈来作为队列的入口,将元素先全部加入到 A 栈中,然后在内部将 A 栈的元素全部存入 B 栈,等到 A 栈的全部元素都存入 B 栈,也就是 A 栈为空之后,此时 B 栈的栈顶就可以作为队列的出口了。

这句话其实就是"倒两次"的思想。

假设元素按 1, 2, 3 进入 A 栈:

text 复制代码
A 栈入栈后:1, 2, 3
栈顶是 3

如果把 A 栈依次 pop 出来,再 push 到 B 栈:

text 复制代码
B 栈入栈顺序:3, 2, 1
B 栈栈顶:1

这时 B 栈的栈顶就变成了队列最早进入的元素。栈是后进先出,两个栈一倒腾,就把顺序翻回了先进先出。

具体需要实现的方法有四个:

  • push(x):将一个元素放入队列的尾部
  • pop():从队列首部移除元素
  • peek():返回队列首部的元素
  • empty():返回队列是否为空

这道题表面上是算法题,实际上也逼着我设计一个"队列对象":它要有自己的两个栈,还要有 pushpoppeekempty 方法。

于是课堂就自然转到了 JS 的面向对象。

JS 的面向对象:不走寻常路

笔记里这块的第一句话是:

不需要 class,也可以完成面向对象。

为什么?

因为函数也是对象,函数是一等对象。函数可以作为普通函数使用,也可以通过 new + 构造函数 使用。

先看函数作为对象:

javascript 复制代码
function greeting() {
  console.log('hello world');
}

greeting.a = '1';
console.log(greeting.a);
greeting(); // 作为普通函数调用

这里 greeting 不只是能被调用的函数,它还可以挂属性 a。这就是"函数也是对象"的直观证据。

笔记里还写到:

  • 作为普通函数使用时,this 指向全局对象 global/window
  • 如果是 new + 构造函数
    • 内部的 this 指向新创建的对象
    • 此时就可以通过 this 获取属性

早期 JS 没有 class 的概念,所以约定首字母大写就是构造函数:

javascript 复制代码
// 早期的 JS 没有 class ,所以约定首字母大写就是构造函数
function Greeting(name) {
    console.log(this);
    this.name = name;
    console.log('hello', this.name);
}

Greeting.prototype.say = function() {
    console.log(`hello ${this.name}`);
}

Greeting.prototype.work = function() {
    console.log(`${this.name} is working`);
}

const liu = new Greeting('liu');
// hello liu

console.log(liu.name);
liu.say();
liu.work();

构造函数 Greeting 负责给实例添加自己的属性,比如 name。而 saywork 放在 Greeting.prototype 上,实例 liu 也可以调用。

这就是 JS 早期不用 class 也能模拟类的方式:函数 + prototype

MyQueue:算法题把构造函数和原型链带出来了

回到队列题,课堂里先写了一个 MyQueue 的雏形:

javascript 复制代码
// 函数表达式 匿名函数 语言精粹第四章
// 定义了 MyQueue 类,可以复用
// 早期的 JS 没有类的概念,只能用函数来模拟类
// 不需要类(class)也可以完成面向对象
// JS 是基于原型的面向对象语言
// JS 中的使用函数+prototype 就可以实现类的模拟 更优秀 基于原型的面向对象
// 开发 JS 语言较快,没时间去实现类的语法
// 什么是类? 抽象的 抽象了一套属性 + 方法的模版
const MyQueue = function() {
    // 构造函数,实例对象就拥有属性了
    console.log('实例化', this);
    // this.x = 1;
    this.stack = [];
    this.stack2 = [];
}
// 实例方法
MyQueue.prototype.push = function() {
    console.log('push方法');
}

// 如果以 new 运算符来运行,会返回一个实例对象,this 会指向实例对象,不需要 return
const queue = new MyQueue();
console.log(queue, queue.push());

这里还没有把 push/pop/peek/empty 完整实现完,但它已经把结构搭出来了:

  • MyQueue 是构造函数
  • this.stack = []this.stack2 = [] 是每个队列实例自己的两个栈
  • MyQueue.prototype.push 是共享方法
  • new MyQueue() 会返回一个实例对象
  • new 的时候不需要手动 return

也就是说,模拟队列不只是写算法逻辑,还要决定数据放在哪里、方法放在哪里。

如果按这节课的思路继续补完,我会这样理解四个方法:

javascript 复制代码
const MyQueue = function() {
    this.stack = [];
    this.stack2 = [];
}

MyQueue.prototype.push = function(x) {
    this.stack.push(x);
}

MyQueue.prototype.move = function() {
    if (this.stack2.length === 0) {
        while (this.stack.length) {
            this.stack2.push(this.stack.pop());
        }
    }
}

MyQueue.prototype.pop = function() {
    this.move();
    return this.stack2.pop();
}

MyQueue.prototype.peek = function() {
    this.move();
    return this.stack2[this.stack2.length - 1];
}

MyQueue.prototype.empty = function() {
    return this.stack.length === 0 && this.stack2.length === 0;
}

这段是我基于课堂雏形补全的理解版,不是原始笔记里已经写完的代码。它的关键规则是:只有当 stack2 为空时,才把 stack 里的元素倒过去。否则直接从 stack2 出队,避免每次都重复搬运。

new 的过程:实例属性和共享方法怎么分工

笔记里把 new 的过程拆成了四步:

  • 创建一个空对象,自动创建一个 this 指向这个空对象
  • 构造函数执行,通过 this 在这个实例上添加这些属性
  • 构造函数上有一个 prototype 属性,指向它的原型对象
  • 原型对象上拥有的方法,它的实例,也就是子对象,也会拥有这些方法

这也是为什么 MyQueue 里两个栈要写在构造函数里:

javascript 复制代码
this.stack = [];
this.stack2 = [];

因为每个队列实例都应该有自己独立的两个栈。

pushpoppeekempty 这些方法,更适合放在 MyQueue.prototype 上,因为所有实例都可以共享同一套方法。

如果把方法都写在构造函数里面,每次 new 都会创建一份新函数;放到原型对象上,就可以共享。

原型对象:实例找不到,就沿着原型链继续找

课堂里还有一个 Person 的 HTML 示例,用来观察 Object、构造函数、原型对象和实例对象之间的关系。

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script>
        // 创建一个空对象
        // Object 是顶层对象
        // Object.prototype 是所有对象的原型对象
        // const zhangsan = new Object();
        // // Object.prototype 上的方法,他的子对象都可以调用
        // console.log(zhangsan);
        // 构造函数,首字母大写
        function Person(name, age) {
            // 构造实例,属性是实例私有的
            console.log(this);
            this.name = name;
            this.age = age;
        }
        // 原型对象上的方法和属性都是公有的
        Person.prototype.poem = '仁义礼智信'
        Person.prototype.sayName = function () {
            console.log(`我是${this.name}`);
        }
        Person.prototype.timeMF = function () {
            console.log('时间管理魔法');
        }
        const ls = new Person('李四', 18);
        console.log(ls);
        console.log(ls.toString());
    </script>
</body>
</html>

这段代码里:

  • nameage 是实例私有属性
  • poemsayNametimeMF 是原型对象上的公有属性和方法
  • ls 是通过 new Person('李四', 18) 创建的实例
  • ls.toString() 不是写在 ls 自己身上的,而是沿着原型链往上找到了 Object.prototype 上的方法

所以"对象调用方法"不一定表示这个方法就在对象自己身上。JS 会先在实例自身查找属性或方法,找不到再沿着原型链查找。

JS 设计哲学:一切皆对象,没有类

笔记里把 JS 的原型系统总结成了一组很重要的规则:

  • 一切皆是对象,没有类
  • Object 是顶层对象
    • 按照原型式的面向对象来设计
    • Object() 是函数对象
    • Object.prototype 是一个原型对象
    • let obj = {} 对象字面量,本质是 new Object(),是实例对象
    • FunctionArrayDateRegExp 都是函数对象
      • 它们的下一站都是 Object,通过原型链
  • 实例对象都有 __proto__ 私有属性,指向原型对象
  • 沿着 __proto__ 一直查找,也就是沿着原型链查找
    • 终点是 null
  • 任何函数都有 prototype 属性,指向原型对象
    • 负责给实例们提供共享方法
  • 构造函数负责提供让实例构建自己属性方法的方式
  • 原型对象提供给实例们共有的共享方法与属性
  • 原型对象上有 constructor 属性,指向构造函数
  • 实例先在自身查找属性或方法,然后沿着原型链查找属性或方法
  • 任何对象,要不原型直接是 Object.prototype,要不终点前一定是 Object.prototype,终点是 null

这套规则一开始看有点绕,但如果和 MyQueue 放在一起,就清楚很多。

queue 自己有:

javascript 复制代码
queue.stack
queue.stack2

queue 没有自己的 push 方法时,会去:

javascript 复制代码
MyQueue.prototype.push

如果继续找通用对象方法,就会一路到:

javascript 复制代码
Object.prototype

最后原型链终点是:

javascript 复制代码
null

这就是从一个算法题走到 JS 对象系统的原因。

这节课我真正要带走的东西

这一节课最开始看起来是"如何用栈模拟队列",但我现在觉得它至少有三层。

第一层是数据结构:

  • 数组是基础
  • 栈和队列是操作受限的数组
  • 栈是后进先出
  • 队列是先进先出
  • 两个栈可以模拟队列,因为顺序被翻转两次

第二层是语言机制:

  • 调用栈管理函数调用
  • 执行上下文在编译阶段创建
  • var/function 进入变量环境
  • let/const 进入词法环境
  • 块级作用域可以用词法环境栈来理解

第三层是 JS 的对象系统:

  • 函数也是对象
  • 没有 class 也可以做面向对象
  • 构造函数负责实例私有属性
  • prototype 负责共享方法
  • 实例通过 __proto__ 沿着原型链查找方法
  • 原型链终点是 null

放到 Vibe Coding 时代,这节课给我的提醒是:AI 可以直接写出 MyQueue 的代码,但它不能替我建立这些连接。

如果我只问"怎么用两个栈实现队列",我得到的是一段答案。

如果我继续追问"为什么这段代码要用构造函数,为什么方法放到 prototype,为什么实例能调用原型上的方法,为什么 letvar 表现不一样",我得到的才是可迁移的理解。

这也是我这节课最想记住的:算法题不是孤立的题,语言机制也不是孤立的概念。很多时候,一个小题就是一条入口,顺着它往下走,可以摸到整门语言的设计骨架。

相关推荐
vim怎么退出1 小时前
Dive into React——高级特性
前端·react.js·源码阅读
冰暮流星1 小时前
javascript之this关键字
开发语言·前端·javascript
百度Geek说1 小时前
CodingAgent 的原始森林困境:一张地图能解决什么?
开发语言·javascript·ecmascript·coding agent
余大大.1 小时前
SystemVerilog-参数宏与拼接符的使用
前端
羸弱的穷酸书生1 小时前
跟AI学一手之前端导出
前端·文件导出
怕浪猫1 小时前
Electron 开发实战(十三):性能优化策略|极速启动、低内存、流畅渲染、极致瘦身
前端·javascript·electron
想要成为糕糕手1 小时前
JavaScript 异步编程完全指南
javascript·面试·promise
Csvn1 小时前
Linux 文件与目录操作命令(通关版)
后端