CategoryResourceRepost/极客时间专栏/JavaScript核心原理解析/从原型到类:JavaScript是如何一步步走向应用编程语言的/16 | [a, b] = {a, b}:让你从一行代码看到对象的本质.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

275 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="16 | [a, b] = {a, b}:让你从一行代码看到对象的本质" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/39/a8a83584b9f1eb8ddfa5ef6275571239.mp3"></audio>
你好,我是周爱民。欢迎回到我的专栏。
接下来的两讲我要讲的仍然是JavaScript中的面向对象。有所不同的是今天这一讲说的是JavaScript中的对象本质而下一讲要说的则是它最原始的形态也通常称为原子对象
说回今天的话题,所谓的“对象本质”,就是从根本上来问,对象到底是什么?
## 对象的前生后世
要知道,面向对象技术并不是与生俱来、顺理成章就成为了占有率最高的编程技术的。
在早期,面向对象技术其实并不太受待见,因为它的抽象层级比较高,也就意味着它离具体的机器编程比较远,没有哪种硬件编程技术(在当时)是需要所谓的面向对象的。最核心的那部分编程逻辑通常就是写寄存器、响应中断,或者是发送指令。这些行为都是面向机器逻辑的,与什么面向对象之类的都无关。
最早大概是1967年的时候艾伦Alan Kay提出了这么一个称为“对象”的抽象概念和基于它的面向对象编程object-oriented programming这也成为他所发明的Smalltalk这个语言中的核心概念之一。
然而,回顾这段历史,这个所谓的“对象”的抽象概念中,只包含了**数据**和**行为**两个部分,分别称为**状态保存**和**消息发送**,再进一步地说,也就是我们今天讲的“**属性**”和“**方法**”。并且,在这个基础上,有了这些状态(或称为数据)的局部保存、保护和隐藏等概念,也就是我们现在说的**对象成员的可见性问题**。
你看,这里没有**继承**,也没有**多态**。历史中,最早出现的所谓**对象**,其实只是对数据的封装!
所以你会看到最近十余年来,无数的业界大师、众多的语言流派对所谓的“继承”,以及与此相关的“多态”特性发起非难。追根溯源,就在于这两个概念并非是“面向对象”思想的必然产物,因而它们的存在将有可能增加系统抽象的复杂性。
具体到你所了解的JavaScript一些新的面向对象特性也总会在ECMAScript规范的草案阶段碰壁。
例如近两年来最受非议的“Class Fields”提案在添加了“**私有字段**”这个概念之后,却将“**保护属性**”这个皮球扔给了远未成熟的注解提案。究其原因呢,则是“**字段**”与“**继承性**”之间存在概念和实现模型的冲突。
这也不枉我常常说tc39中存在着大量的“OOP敌视者”尽管是玩笑但也确实反映了“面向对象编程思想”在这门语言中恶劣的生存状态。
然而并不仅仅如此。最近这些年的新语言,除了使用类似“**字段**”“**记录**”这样的抽象概念来驱逐面向对象之外,还对**函数式编程**洞开怀抱。在我看来,这既是流行的趋势,也确实是计算机编程语言进化的必然方向。但是,这也带来了更深层面的问题,使得**面向对象**的生存环境进一步恶化。
为什么呢?
你看,面向对象的**封装**、**继承**和**多态**三个核心概念中多态有一部分是与继承性相关的去掉继承性多态就死了一半。而另一半又被“接口Interface”这个概念给干掉了。于是整个OOP的体系中就只剩下“封装”还算在概念上能独善其身。这也与上面说到的艾伦有关毕竟他提出的“面向对象”的最初意图也就在于提高封装性。
然而,一旦引入“函数式编程”,情况就发生了变化。
函数式语言根本不考虑数据封装问题,逻辑之间的数据是由函数界面(也就是函数参数)来传递的,而函数自身又强调“无副作用”,也就意味着它不影响函数之外的数据——那函数外也就没有任何数据封装(例如隐蔽)的要求了。
所以,简单地说,函数式一出,面向对象的最后一根稻草——“封装”特性也就扑街了!
你看看,面向对象到底怎么了?混了半个世纪了,最终落下个谁谁都嫌弃、人人都喊打的局面,连个打根儿上起就存在的核心抽象概念,都被人家掘断了气儿。
讲到这,你是不是觉得我给你扯的太远了?其实不是的。
这一讲的标题是“x = y”这样一个赋值表达式而赋值表达式右边的“y”正是这样的一个“对象”。我与你说了半天的这些所谓“三个核心概念”在这一行代码中被瓦解掉了2/3剩下的正是最最原始的东西
- 所谓对象,是对数据的封装;
- 所谓解构,就是从封装的对象中,抽取数据。
你看,聊了半天,我又圆回来了吧:对象,其实是一个数据结构;解构赋值,就是将这个结构解构了,拿去赋值。
要紧的地方在于:对象,是怎样的一个数据结构呢?
## 两种数据结构
其实所谓的“某某编程思想”,本质上就是在说两个东西:一个,是在编程中怎么管理数据,另一个则是怎么组织逻辑。
而结构化又或者说具体到“数据结构”无非是在说将系统中的数据用统一的、确切的、有限的数据样式给管理起来。这些样式小到一个位bit、一个字节byte大到一个库Database、一个节点Node都是对数据加以规划的结果。编程的思想在机器指令的编码与数据集群的管理里面都是如出一辙的。在所有的这些思想的背后都有一个核心的问题
- 如何抽象“一堆”的数据,使得它们能被方便和有效地管理。
在我们的单机系统或者说像JavaScript这类应用环境的编程语言中这些数据是假设被放在“有限的存储空间里面”的。这个假设模拟了内存和指令带宽的基本性能。
那么,在这样有限的存储空间里面如何存储数据呢?又或者说,如何得到一个“最高的抽象层级的数据结构”,以便于通过编程语言来处理操作呢?
一个数据结构的抽象层次越是低级那么对它的编程就越是复杂。例如说如果你需要面向“位bit”来编程那么差不多就需要写机器指令或者手工去搬动逻辑电路的开关了。
所谓“最高的抽象层级”,在一个“有限的存储空间”里面,其实只能表达为一个“块”。简单地说,你只能称呼“一堆数据”为“一堆数据”,因为当你不了解它们的具体性质时,你只能这样称呼它。而“块”其实是对“有限空间”的边界分解,设定了“有限空间”,那么对应的,也就出来了“块”这个概念。
而由此带来的问题是:在一个有限空间中,如何找到一个“块”?
如果从这些“块”的相关位置出发,以位置关系来看,就只有两个解:
1. 为所有**连续的**块添加一个**连续的**“索引”;
1. 为所有**不连续**的块添加一个唯一的“名字”。
当然关键点在于所谓的“连续”和“不连续”。“连续”“不连续”在语义上就是二分的所以也就只需要两个解。其中“索引”比较简单它就对应于连续性本身表达为可计算的特性是“a[**i**]”也就是a的下标_i_。
而“名字”对应于“找到块”这一目的本身,表达为一个可计算的函数“**f()**”。你可以认为这里的`f`是`find`的简写。于是一旦系统认为一个函数“**f()**”可以用于找到它需要计算的数据那么数据就可以理解为“b[**f()**]”,而其中的函数***f()***如何实现,则可以交给“另外的一个系统”去完成了。
那么重要的是为什么不能将“i”也理解为“找到i”呢
如果是这样那么这个所谓的“索引”其实也可以作为名字啊对的如果这样来理解那么也可以为上面的“a[**i**]”引入一个用于计算索引的函数f只是该函数_f()_的唯一作用就是返回了“**i**”。也就是:
```
function f() {
return i
}
a[i] === a[f()];
```
现在,我们看到了这两个数据结构——一种是“连续的块”,另一种是“不连续的块”,它们都存在一种统一的“找到块的模式”,也就是:通过一个函数来找到块。
进一步阐释的话,对于索引数组来说,这个函数是取数组成员的“索引”;对于关联数组来说,这个函数是取数组成员的“名字”。其中“关联数组”是用一对“名/值”来创建的数组在实现中为了将无穷尽的“名字”收敛在一个有限范围内通常是用值的HASH作为名字。
所以,在“怎么管理数据”这个问题上,你可以将所有数据看成只具有两种数据结构的构成,一种称为**索引数组**(对应于可索引的块),另一种称为**关联数组**(对应于不可索引的块)。而究其根本来说,索引数组其实是关联数组的一个特例——被存取的数据所关联的名字就是它的索引。
JavaScript中的“对象”在本质上就是这样的一个关联数组。同时所谓的“数组Array”——也就是索引数组Index array正是作为关联数组的一个特例来实现的。这样一来JavaScript就实现了两种数据结构的大统一
1. 数组Array class是一种对象Object class
1. 对象本质上是关联数组Associative array
## 解构
所以,对象不过是“稍微复杂一点的数据结构”,相比起来,它并不比稍早一点出现的“记录/结构体”更复杂。从抽象的演进过程来说,对象只是“没有顺序存储限制,以及添加了成员名字的”结构体而已。<br>
<img src="https://static001.geekbang.org/resource/image/f7/af/f71eda18ca7a490bb794fe244a3c25af.jpg" alt="">
>
图引自《程序原本》“10.1 抽象本质上的一致性”
在前面的文章里我就讲过,计算的本质是求“值”,因此几乎所有的引用类型呢,最终都会将“**与它的相关的运算结果**”指向“**值**”。至于这一切背后的原因,其实也很简单,就是物理的计算系统最终也只能接收“字节、位”等等这样的值类型数据。但是在高级语言中,或者应用编程中呢,程序员又需要高层级的抽象来简化编程,所以才会有**结构体**,以及我们在这里讲到的**对象**。
还原这个过程,也就意味着“结构”是应用编程的必须,而“解构”是底层计算的必须。从一个“结构(这里是指数据结构,或者对象等复杂的结构)”中把那些值数据取出来,就称为解构。这一讲的代码标题,就是这样的一个“解构赋值”,它的目的呢,也正是“从一个结构中提取值”。你仔细看这行代码:
```
[a, b] = {a, b}
```
等号右侧是一个对象的字面量,它的语义是将`a`、`b`两个数据变成“对象”这个数据结构中的两个成员。其中由于a、b都是既已约定的名字所以在作为对象成员的时候“名字+值”就都已经具备了,完全符合“关联数组(或名/值数据对)”的语义要求。
而再看它的左侧,是一个数组?不是的,这称为一个“(数组)赋值模板”。
所谓赋值模板不过是“变量名字”和“它的值”之间的位置关系的一个“说明”这个说明是描述型的、声明风格的。因此它事实上在JavaScript语法解析阶段就完成了处理根本不会“产生”任何运行期的执行过程。
所以左侧的“赋值模板”只是说明了一堆被声明的变量,也就是说,它们跟代码`var x, y, z = 100`中的`x,y,z`这样的名字声明没有任何差异,在处理上也是一样的。但是,这些赋值模板中声明的变量,每一个都“绑定”了一段赋值过程。这样的“赋值过程”在之前讲**函数的非简单参数**时也讲过(参见[第8讲](https://time.geekbang.org/column/article/171617)就是“初始器赋值”。在ECMAScript中尽管它们调用的是相同的“赋值过程”但这两者之间是有语义上的区别的。具体来说就是
- 当赋值模板用作声明var/let/const上面的“赋值过程”将作为值绑定的初始器
- 当该模板用作赋值运算的右操作数时,右操作数将作为“赋值过程”的传入参数。
因此,对于标题中的代码来说,存在三种在语义上并不相同的逻辑:
```
// 1. lhsKind is assignment, call DestructuringAssignmentEvaluation
[a, b] = {a, b}
// 2. lhsKind is varBinding, call BindingInitialization,
// and env will be current function scope.
var [a, b] = {a, b}
// 3. lhsKind is lexicalBinding, call BindingInitialization and current env
let [a, b] = {a, b}
```
当然,其结果都是一样的,也就是左侧的`a`和`b`都将被赋以左侧对象`{a, b}`所解构出来的“值”。但是,如果你运行标题中的代码,你会发现它“可能”与你的预期并不一样。例如左侧的`a`和`b`与原来有的变量“a、b”并不一样假设这些变量是有的话
在上面的三个例子中示例三的let/const赋值将不成立因为右侧的对象将不能被创建出来。例如
```
&gt; let [a, b] = {a, b}
ReferenceError: a is not defined
```
但前两个示例在代码逻辑上是可以成立的,只是“一般来说”运行会抛出异常。例如:
```
# “赋值未声明变量”
&gt; a = 100, b = 200;
# 示例代码与使用var声明相同
&gt; [a, b] = {a, b};
TypeError: {(intermediate value)(intermediate value)} is not iterable
```
现在你可以思考一个小小的问题:
- **有什么办法可以让这个代码可以执行呢?**
这就回到今天这一讲的标题的核心话题了。
## 两种数据结构的统一
既然我已经说过,对象和数组在本质上都是存放“一堆数据”的结构,而差异只是查找的过程不同。那么,模拟它们不同的查找过程,也就可以在这些结构之间完成统一的“赋值行为”。
“数组赋值模板”其实是引用了数组的下标索引过程ECMAScript将索引次序用专门的增序来管理并将右操作数视作为“迭代器”来取值。注意你确实需要留意这两者之间的区别重点在于“迭代器”的取值是序列的但并没有确定使用数组的下标例如序号
所以,只要让右侧的对象成为一个“可迭代对象”,那么赋值表达式就可以知道如何将它赋给左侧的模板了。这并不难:
```
## 模拟成数组的迭代器
&gt; Object.prototype[Symbol.iterator] = function() {
return Array.prototype[Symbol.iterator].call(Object.values(this));
};
## 测试
&gt; a = 100, b = 200;
&gt; [a, b] = {a, b}
...
```
当然,你也可以不借用数组的迭代器。这是一个更简单的版本:
```
Object.prototype[Symbol.iterator] = function*() {
yield* Object.values(this);
};
...
```
也就是说,只需要将“对象成员”的列举,变成“对象成员的值”的列举,那么关联数组就可以用作索引数组了。当然,在代码中你也通常不需要这样写。只要写成下面这样就足够了:
```
&gt; [a, b] = Object.values({a, b})
...
```
既然将对象赋给数组(赋值模板)是可行的,那么将数组赋给“对象(赋值模板)”又是否可行呢?答案当然是“可以”。不过仍然和上面的问题一样,你得有办法在模板中“描述”索引与名字之间的关系才行。例如:
```
# 在对象赋值模板中声明变量名与索引的关系
&gt; ({0: x, 1: y} = [a, b])
&gt; console.log(x, y);
100 200
```
如果你直接使用像标题一样的代码(并且将它们反过来的话),例如:
```
{a, b} = [a, b]
```
那么由于没有这种关系描述,所以右侧的数组被“强制地”作为一个对象来使用,因此变成了取`a`、`b`这两个成员的值。当然,它的结果就是不可预知的了。这种不可预知,来自于“将右侧数组作为对象”的并尝试取得具体的成员这样的行为,并且还受到它的原型对象的影响。
当然也有使类似行为不受到原型影响的办法这就是“人人都爱”的所谓“展开语法Spread syntax”。
关于展开语法的特点,我之前在[第9讲](https://time.geekbang.org/column/article/172636)中也已经讲过了你可以复习一下那一讲的内容。展开语法与这一讲略有关联的事情是“对象展开Object spread以及与它相关的“剩余参数Rest paraments”这两种东西都将只处理那些“可列举的、自有的”属性。因此展开过程并不受对象原型的影响。例如
```
# 测试变量
&gt; var a = 100, b = 200;
# 将数组展开到一个对象(的成员)
&gt; obj = {...[a,b]}
{0: 100, 1: 200}
# 或,将对象展开到一个数组
&gt; iterator = function*() { yield* Object.values(this) };
&gt; obj[Symbol.iterator] = iterator;
&gt; arr = [...obj]
[ 100, 200
```
## 知识回顾
这一讲的话题,重点在于从抽象层面认识对象与数组这两种东西,以及它们更为学术的名词概念:关联数组和索引数组。
由于索引数组本质上是关联数组的特例所以在JavaScript中用关联数组也就是对象来实现索引数组也就是一般概念上的数组对象是合理的并且也是有着很深层面的理论根基的一个设计。
由于两种数据结构既相关、又相同,因此在它们之间相互转换的行为,其实就是一个名字和索引变换的游戏,这也是本讲中会再次讨论“展开语法”的原因:展开语法是在两种数据类型之间的一个桥梁。
当然这一讲的标题尽管并不能直接运行但“如何让它能运行”这个问题所涉及的知识与我们计算机领域中较深层面的运行原理以及较高层次的抽象结构之间都存在着密不可分的关系。无论是出于理解JavaScript代码的目的还是出于理解语言中最本质的那些假设或前设我都非常建议你尝试一下这篇文章中的示例代码。
## 思考题
最后,作为一个小小的思考与练习,我希望你能够在学习完这一讲之后回答一个问题:
- “有迭代器的对象”在哪些场合中可以替代“索引数组”?
谢谢你的收听,希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。