重生之我在 Vibe Coding 时代当程序员:第十六课,从模拟队列到原型链
上一节课我还在补 JavaScript 执行机制:执行上下文、调用栈、声明提升、词法环境。 这一节课看起来是算法课:如何用栈模拟队列。但真正走进去以后,我发现它又把我带回了 JS 的底层:函数为什么也是对象,
new到底做了什么,prototype和__proto__又是怎么把方法串起来的。 这一节课,我要解决的问题是:当 AI 可以很快给我写出MyQueue的答案时,我自己到底要看懂哪几层东西,才不只是复制一段代码。
先把上一段 JS 执行机制接上
课堂笔记里先放了一组 runway 代码,用来复盘 JS 代码是怎么跑起来的。
我一开始记下来的关键词是:
- 引擎
- 同步、异步
- 生命周期
- 作用域
- 变量提升,其实更准确的名字应该是声明提升
- 执行栈,也叫调用栈
变量提升这一块,老师把它拆成了执行上下文对象里的几个部分:
- 变量环境:
function和var声明的内容在这里 - 词法环境:
let和const声明的内容在这里 - 剩余可执行代码:从上到下顺序执行
为什么要有执行上下文对象?笔记里写得很直接:便于运行代码。
执行栈是 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,后面执行到赋值语句时才变成具体值。
编译过程笔记里写了四步:
- 创建执行上下文
context对象 - 找形参和变量声明,将形参和声明的变量名作为
key,value为undefined - 统一形参和实参的值,全局上下文没有这个步骤
- 找函数声明,将函数名作为
key,value为函数体
这里还有两个用来观察优先级的例子。
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:为什么词法环境也是一个栈
接下来是 var 和 let 的对比。
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();
这里我当时的临时理解写在注释里了:词法环境好像也是一个作用域,块级作用域是通过词法环境栈来实现的。
现在把它说得更稳一点:let 和 const 会进入词法环境,词法环境支持块级作用域。调用栈是执行上下文的容器,栈顶指针指向当前正在执行的函数或全局上下文;而词法环境也可以用类似栈的方式管理不同作用域里的变量。
最后一个例子是 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():返回队列是否为空
这道题表面上是算法题,实际上也逼着我设计一个"队列对象":它要有自己的两个栈,还要有 push、pop、peek、empty 方法。
于是课堂就自然转到了 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。而 say 和 work 放在 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 = [];
因为每个队列实例都应该有自己独立的两个栈。
而 push、pop、peek、empty 这些方法,更适合放在 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>
这段代码里:
name和age是实例私有属性poem、sayName、timeMF是原型对象上的公有属性和方法ls是通过new Person('李四', 18)创建的实例ls.toString()不是写在ls自己身上的,而是沿着原型链往上找到了Object.prototype上的方法
所以"对象调用方法"不一定表示这个方法就在对象自己身上。JS 会先在实例自身查找属性或方法,找不到再沿着原型链查找。
JS 设计哲学:一切皆对象,没有类
笔记里把 JS 的原型系统总结成了一组很重要的规则:
- 一切皆是对象,没有类
Object是顶层对象- 按照原型式的面向对象来设计
Object()是函数对象Object.prototype是一个原型对象let obj = {}对象字面量,本质是new Object(),是实例对象Function、Array、Date、RegExp都是函数对象- 它们的下一站都是
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,为什么实例能调用原型上的方法,为什么 let 和 var 表现不一样",我得到的才是可迁移的理解。
这也是我这节课最想记住的:算法题不是孤立的题,语言机制也不是孤立的概念。很多时候,一个小题就是一条入口,顺着它往下走,可以摸到整门语言的设计骨架。