从两摞盘子到 JS 原型链——一场蓄谋已久的“降维打击“
从两摞盘子到 JS 原型链——一场蓄谋已久的"降维打击"
缘起:队列不过是个"调度员"
诸位请先看一道经典题目:用栈模拟队列。
没学过数据结构的同学此刻可能在想——栈是什么?队列又是什么?能吃吗?
容我用生活场景打个比方。栈,就是一摞盘子。你只能从最上面拿,也只能往最上面放(这种悲催的"后进先出",简称
FILO——First In, Last Out)。队列,就是食堂打饭的队伍。先来的先打、后来的后打(这就是文明的"先进先出",简称
FIFO——First In, First Out)。
题目要求用两摞盘子模拟一个打饭队伍。乍一听,这就像让你用两把叉子喝汤——工具不对啊!
但你可以这样:来一个人,我们把他放进第一摞盘子里(stack1.push)。要出队的时候呢?把第一摞盘子里的所有人依次倒到第
二摞盘子里——这时候,原本在最底下的那个"最早来的人"就跑到了第二摞的最上面,pop
一下就出去了。然后再把剩下的人倒回去。
妙啊!这招叫"乾坤大挪移"。两个栈,一个负责"进",一个负责"出"——本质上是用空间换时间,用两次翻转来化解 FILO 和 FIFO
之间的根本矛盾。
你以为你在学算法题,其实你在学哲学:有些看似不可调和的矛盾,加一层中间层就解决了。队列是栈的抽象,抽象是计算机科学
的灵魂。
暴论:JavaScript 根本没有类
当你满怀信心地打开 1.js,准备写一个 MyQueue 类的时候,写着写着发现不对劲——怎么没有 class 关键字?
那个时代(ES6 之前),JS 的 class 还在娘胎里。但人家照样做面向对象开发,靠的是什么?
函数 + prototype。
const MyQueue = function () {
this.stack1 = [];
this.stack2 = [];
}
MyQueue.prototype.push = function() { … }
我第一次看到这段代码的时候,内心是崩溃的。函数就函数,怎么还能 .prototype?这到底是函数还是对象?
答案是——都是。
JavaScript 的设计者 Brendan Eich 在 1995 年花了十天写出了这门语言。十天的产品,你不能要求它像 Java
那样西装革履。但它有一个极为大胆、极为天才的内核设计:一切皆是对象,原型链替代类继承。
这就引出第二个暴论:函数也是对象,而且是一等公民。
看一下 2.js:
function greeting() { console.log(‘hello world’); }
greeting.a = ‘q’;
console.log(greeting.a); // ‘q’
你给一个函数动态添加了属性,就跟给普通对象加属性一样自然。这在 Java
开发者眼里属于"大逆不道"——函数怎么可以有属性?但 JS 说:凭什么不可以?
函数不仅是可执行的代码块,它还是一个可以携带数据的容器。这种"身兼两职"的设计,后来成为了前端生态的基石——回调函数、
高阶函数、闭包、装饰器……全是这个思想开出的花。
一条链子串起万千对象
4.html 和 readme.md 里详细记录了 new 关键字和原型链的完整知识,我用大白话重新串一遍。
当你写 const zwy = new Person(‘zwy’, 18) 的时候,JS 悄悄干了四件事:
- 创建一个空对象 {}
- 让这个空对象的proto指向 Person.prototype
- 把构造函数里的 this 指向这个空对象
- 执行构造函数,给 this 塞属性
- 返回这个对象(除非你手动 return 了别的东西)
其中第二步,就是 JS 面向对象体系最精妙的一环——原型链。
来看看 readme.md 里亲手在 Console 做的实验:
zwy.proto=== Person.prototype // true
zwy.proto.proto=== Object.prototype // true
zwy.proto.proto.proto// null
一条链,串了三层:
- zwy 自身:存了你造的独有属性(name、age)
- Person.prototype:存了共享方法(say、timeMF)和共享属性(poem)
- Object.prototype:存了万物之祖的方法(toString、hasOwnProperty 等)
- null:链的终点,一切归于虚无
当你调用 zwy.say() 时,JS 先在自己身上找 say。找不到?那就沿着proto去 Person.prototype
上找。还找不到?继续沿着proto.proto去 Object.prototype 找。一直找到 null,找不到就报 undefined。
这不就是一个天然的责任链模式吗?设计模式书里要写十几页的东西,JS 从娘胎里就带着。Brendan Eich
这十天的工作效率,我哭死。
议:原型继承是 Bug 还是 Feature?
很多从 Java/C++ 转过来的程序员痛斥 JS 的原型链是"反人类的设计"——没有私有属性,没有真正的类,继承要靠手动连
prototype,this 指向说变就变……
这些批评都有道理。但我想换个角度:
原型继承是一种"极简版"的面向对象。
类的本质是什么?是"模板"和"实例"的关系。原型直接跳过了"模板"这个概念——你不需要先定义一个抽象的类,你直接拿一个已经
存在的对象当"原型",然后在这个基础上造出相似但不同的新对象。
这像什么?像生物进化。你不是从"人类图纸"上生产出来的,你是从一个具体的人(你爸妈的基因组合)变异出来的。原型链就是
物种的进化树——从 Object.prototype 这个"单细胞生物"一路分化出 Array、Function、Date……
从这个角度看,原型继承比类继承更贴近这个世界的运行方式。现实中本来就没有"抽象的完美模板"——只有不断复制、变异、适应
的具体个体。
当然,ES6 后来还是加了 class 关键字。但你心里要清楚——那只是语法糖,底下跑的还是原型链。JS
的设计哲学从未改变,它只是换了一张更友好的皮。
教学时刻:三个灵魂拷问
学到这里,你需要能回答三个问题:
第一问:prototype 和proto到底什么关系?
prototype 是函数的属性——“我造出来的实例,共享方法上这儿找”。proto是实例的属性——“我自己的原型对象是谁?”
两者指向同一个对象,但站在不同的立场上。
第二问:为什么把方法放在 prototype 上而不是构造函数里?
构造函数里定义的方法,每个实例都会拷贝一份——一百个实例就是一百份一模一样的函数,浪费内存。放在 prototype
上,一百个实例共享同一份——省内存,而且运行时还能热更新(改一次原型,所有实例立刻生效)。
第三问:栈模拟队列到底怎么写完整代码?
const MyQueue = function() {
this.stack1 = []; // 负责入队
this.stack2 = []; // 负责出队
}
MyQueue.prototype.push = function(x) {
this.stack1.push(x);
}
MyQueue.prototype.pop = function() {
if (this.stack2.length === 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop());
}
}
return this.stack2.pop();
}
MyQueue.prototype.peek = function() {
if (this.stack2.length === 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop());
}
}
return this.stack2[this.stack2.length - 1];
}
MyQueue.prototype.empty = function() {
return this.stack1.length === 0 && this.stack2.length === 0;
}
用原型式面向对象写出来的队列,行云流水,毫不违和。
结语
这个小小的 stack_queue 目录,表面上在讲一道算法题和一个 JS 知识点,实际上它是两种思维的碰撞:
- 数据结构思维:栈的 FILO 和队列的 FIFO 是矛盾,加一层栈做中转,矛盾化解——这是工程的智慧。
- 语言设计思维:类继承和原型继承是两种世界观,JS
选了更野的那条路,然后用二十年的时间证明——野路子也能长出参天大树。
下次有人嘲笑你"JS 连类都没有",你可以微笑着回一句:
▎ “是的,但它有原型链。而你的类,本质上也不过是原型链上的一颗语法糖罢了。”
