极客时间已完结课程限时免费阅读

16 | [a, b] = {a, b}:让你从一行代码看到对象的本质

16 | [a, b] = {a, b}:让你从一行代码看到对象的本质-极客时间

16 | [a, b] = {a, b}:让你从一行代码看到对象的本质

讲述:周爱民

时长20:35大小18.85M

你好,我是周爱民。欢迎回到我的专栏。
接下来的两讲,我要讲的仍然是 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)”来编程,那么差不多就需要写机器指令,或者手工去搬动逻辑电路的开关了。
所谓“最高的抽象层级”,在一个“有限的存储空间”里面,其实只能表达为一个“块”。简单地说,你只能称呼“一堆数据”为“一堆数据”,因为当你不了解它们的具体性质时,你只能这样称呼它。而“块”其实是对“有限空间”的边界分解,设定了“有限空间”,那么对应的,也就出来了“块”这个概念。
而由此带来的问题是:在一个有限空间中,如何找到一个“块”?
如果从这些“块”的相关位置出发,以位置关系来看,就只有两个解:
为所有连续的块添加一个连续的“索引”;
为所有不连续的块添加一个唯一的“名字”。
当然,关键点在于所谓的“连续”和“不连续”。“连续”“不连续”,在语义上就是二分的,所以也就只需要两个解。其中“索引”比较简单,它就对应于连续性本身,表达为可计算的特性是“a[i]”,也就是 a 的下标 _i_。
而“名字”对应于“找到块”这一目的本身,表达为一个可计算的函数“f()”。你可以认为这里的ffind的简写。于是一旦系统认为一个函数“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 就实现了两种数据结构的大统一:
数组(Array class)是一种对象(Object class);
对象本质上是关联数组(Associative array)。

解构

所以,对象不过是“稍微复杂一点的数据结构”,相比起来,它并不比稍早一点出现的“记录 / 结构体”更复杂。从抽象的演进过程来说,对象只是“没有顺序存储限制,以及添加了成员名字的”结构体而已。
图引自:《程序原本》“10.1 抽象本质上的一致性”
在前面的文章里我就讲过,计算的本质是求“值”,因此几乎所有的引用类型呢,最终都会将“与它的相关的运算结果”指向“”。至于这一切背后的原因,其实也很简单,就是物理的计算系统最终也只能接收“字节、位”等等这样的值类型数据。但是在高级语言中,或者应用编程中呢,程序员又需要高层级的抽象来简化编程,所以才会有结构体,以及我们在这里讲到的对象
还原这个过程,也就意味着“结构”是应用编程的必须,而“解构”是底层计算的必须。从一个“结构(这里是指数据结构,或者对象等复杂的结构)”中把那些值数据取出来,就称为解构。这一讲的代码标题,就是这样的一个“解构赋值”,它的目的呢,也正是“从一个结构中提取值”。你仔细看这行代码:
[a, b] = {a, b}
等号右侧是一个对象的字面量,它的语义是将ab两个数据变成“对象”这个数据结构中的两个成员。其中,由于 a、b 都是既已约定的名字,所以在作为对象成员的时候,“名字 + 值”就都已经具备了,完全符合“关联数组(或名 / 值数据对)”的语义要求。
而再看它的左侧,是一个数组?不是的,这称为一个“(数组)赋值模板”。
所谓赋值模板,不过是“变量名字”和“它的值”之间的位置关系的一个“说明”,这个说明是描述型的、声明风格的。因此它事实上在 JavaScript 语法解析阶段就完成了处理,根本不会“产生”任何运行期的执行过程。
所以左侧的“赋值模板”只是说明了一堆被声明的变量,也就是说,它们跟代码var x, y, z = 100中的x,y,z这样的名字声明没有任何差异,在处理上也是一样的。但是,这些赋值模板中声明的变量,每一个都“绑定”了一段赋值过程。这样的“赋值过程”在之前讲函数的非简单参数时也讲过(参见第 8 讲),就是“初始器赋值”。在 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}
当然,其结果都是一样的,也就是左侧的ab都将被赋以左侧对象{a, b}所解构出来的“值”。但是,如果你运行标题中的代码,你会发现它“可能”与你的预期并不一样。例如左侧的ab与原来有的变量“a、b”并不一样(假设这些变量是有的话)。
在上面的三个例子中,示例三的 let/const 赋值将不成立,因为右侧的对象将不能被创建出来。例如:
> let [a, b] = {a, b}
ReferenceError: a is not defined
但前两个示例在代码逻辑上是可以成立的,只是“一般来说”运行会抛出异常。例如:
# “赋值未声明变量”
> a = 100, b = 200;
# 示例代码(与使用var声明相同)
> [a, b] = {a, b};
TypeError: {(intermediate value)(intermediate value)} is not iterable
现在你可以思考一个小小的问题:
有什么办法可以让这个代码可以执行呢?
这就回到今天这一讲的标题的核心话题了。

两种数据结构的统一

既然我已经说过,对象和数组在本质上都是存放“一堆数据”的结构,而差异只是查找的过程不同。那么,模拟它们不同的查找过程,也就可以在这些结构之间完成统一的“赋值行为”。
“数组赋值模板”其实是引用了数组的下标索引过程,ECMAScript 将索引次序用专门的增序来管理,并将右操作数视作为“迭代器”来取值。注意,你确实需要留意这两者之间的区别,重点在于:“迭代器”的取值是序列的,但并没有确定使用数组的下标(例如序号)。
所以,只要让右侧的对象成为一个“可迭代对象”,那么赋值表达式就可以知道如何将它赋给左侧的模板了。这并不难:
## 模拟成数组的迭代器
> Object.prototype[Symbol.iterator] = function() {
return Array.prototype[Symbol.iterator].call(Object.values(this));
};
## 测试
> a = 100, b = 200;
> [a, b] = {a, b}
...
当然,你也可以不借用数组的迭代器。这是一个更简单的版本:
Object.prototype[Symbol.iterator] = function*() {
yield* Object.values(this);
};
...
也就是说,只需要将“对象成员”的列举,变成“对象成员的值”的列举,那么关联数组就可以用作索引数组了。当然,在代码中你也通常不需要这样写。只要写成下面这样就足够了:
> [a, b] = Object.values({a, b})
...
既然将对象赋给数组(赋值模板)是可行的,那么将数组赋给“对象(赋值模板)”又是否可行呢?答案当然是“可以”。不过仍然和上面的问题一样,你得有办法在模板中“描述”索引与名字之间的关系才行。例如:
# 在对象赋值模板中声明变量名与索引的关系
> ({0: x, 1: y} = [a, b])
> console.log(x, y);
100 200
如果你直接使用像标题一样的代码(并且将它们反过来的话),例如:
{a, b} = [a, b]
那么由于没有这种关系描述,所以右侧的数组被“强制地”作为一个对象来使用,因此变成了取ab这两个成员的值。当然,它的结果就是不可预知的了。这种不可预知,来自于“将右侧数组作为对象”的并尝试取得具体的成员这样的行为,并且还受到它的原型对象的影响。
当然,也有使类似行为不受到原型影响的办法,这就是“人人都爱”的所谓“展开语法(Spread syntax)”。
关于展开语法的特点,我之前在第 9 讲中也已经讲过了,你可以复习一下那一讲的内容。展开语法与这一讲略有关联的事情是:“对象展开(Object spread)”,以及与它相关的“剩余参数(Rest paraments)”这两种东西,都将只处理那些“可列举的、自有的”属性。因此,展开过程并不受对象原型的影响。例如:
# 测试变量
> var a = 100, b = 200;
# 将数组展开到一个对象(的成员)
> obj = {...[a,b]}
{0: 100, 1: 200}
# 或,将对象展开到一个数组
> iterator = function*() { yield* Object.values(this) };
> obj[Symbol.iterator] = iterator;
> arr = [...obj]
[ 100, 200

知识回顾

这一讲的话题,重点在于从抽象层面认识对象与数组这两种东西,以及它们更为学术的名词概念:关联数组和索引数组。
由于索引数组本质上是关联数组的特例,所以在 JavaScript 中,用关联数组(也就是对象)来实现索引数组(也就是一般概念上的数组对象)是合理的,并且也是有着很深层面的理论根基的一个设计。
由于两种数据结构既相关、又相同,因此在它们之间相互转换的行为,其实就是一个名字和索引变换的游戏,这也是本讲中会再次讨论“展开语法”的原因:展开语法是在两种数据类型之间的一个桥梁。
当然,这一讲的标题尽管并不能直接运行,但“如何让它能运行”这个问题所涉及的知识,与我们计算机领域中较深层面的运行原理,以及较高层次的抽象结构之间,都存在着密不可分的关系。无论是出于理解 JavaScript 代码的目的,还是出于理解语言中最本质的那些假设或前设,我都非常建议你尝试一下这篇文章中的示例代码。

思考题

最后,作为一个小小的思考与练习,我希望你能够在学习完这一讲之后回答一个问题:
“有迭代器的对象”在哪些场合中可以替代“索引数组”?
谢谢你的收听,希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 6

提建议

上一篇
15 | return Object.create(new.target.prototype):做框架设计的基本功:写一个根类
下一篇
17 | Object.setPrototypeOf(x, null):连Brendan Eich都认错,但null值还活着
 写留言

精选留言(10)

  • Astrogladiator-埃蒂...
    2019-12-20
    “有迭代器的对象”在哪些场合中可以替代“索引数组”? 正如文中老师所讲的那样, 究其根本来说,索引数组其实是关联数组的一个特例——被存取的数据所关联的名字就是它的索引。关联数组可以通过实现对象迭代器的接口去实现。所以说有迭代器的对象可以适应于那些键名是非数字的场合。但是索引数组是一组连续的数据在一块内存区间,一般会有初始大小,而用迭代器实现的关联数组是非连续的数据。Map,Set, WeakMap, WeakSet,就是这种关联数组。
    展开

    作者回复: 对于JavaScript来说,索引数组的连续性意义不大。不过也正是为了解决这个问题才引用了TypedArray。 对于你开始的问题,其实没有太好的答案。在本文里面,这种可替代性更多的是一种尝试。真正有用的是用户自定义的Symbol.iterator,而不是直接引用Array.prototype中的。然而一旦是用户自定义,就与确定的需求和场景有关了。 另外,事实上字符串也有这样的性质,亦即是字符串可用"...str"这样的语法。所以一个非常有趣的例子就是把一个字符串塞给Set,利用Set没有重复元素的特性来得到“字符串中的有效字符统计”。例如: ``` str='abccccd'; console.log((new Set(str)).size); // 4 console.log(...new Set(str)); // a b c d ``` 所以,我想用这个例子来说明的是:很难确定地讲这个东西在“哪些场合”下有用。理解它的性质和本质,在需要的时候能找到高效的解,就可以了。

    11
  • 墨灵
    2020-03-20
    这绝对是我今年看过最难的专栏,但也是我顿悟最多的专栏。
    9
  • 穿秋裤的男孩
    2020-01-08
    [a, b] = Object.values({a, b}) 这个并不能确定a一定等于obj.a吧

    作者回复: 是的,不确定

    共 2 条评论
    1
  • Elmer
    2020-01-07
    [a, b] = {a, b}如果从可执行角度可以这样理解吗 本身是个赋值表达式,右侧是个对象字面量,左侧是一个可执行结构?

    作者回复: 不能的呢。左侧在语法解析阶段就会被理解为“数据模板的‘语法’”,它不是一个动态执行的东西。 在语法形式上,下面的代码必须有一个单一的、明确的语义: ``` var a = 100, b = 200; [a, b] = new Object; ``` 如果左侧被解释成“表达式”,那么它一定是一个数组,但这并不成立。左侧必须是一个“赋值模板(语法)”,才能让上述代码成立。不可能让左侧存在二义性的解释,它“既能执行”,又“不是表达式”,这个在语义上是不成立的。

    1
  • 孜孜
    2022-08-09 来自辽宁
    我最近在读一本书,Structure and Interpretation of Computer Programs(sicp),突然感觉和这本课不知道哪里有很多相似。
  • 小炭
    2020-11-10
    https://juejin.im/post/6844903715078406151 私有字段 我们来看看这个提案中最具争议的部分。 它是如此有争议: 1. 尽管事实上,它已经在Chrome Canary中实现,并且默认情况下公共字段可用,但是私有字段功能仍需额外开启; 2. 尽管事实上,原始的私有字段提案与当前的提案合并,关于分离私有和公有字段的issue一再出现(如:140,142,144,148); 3. 甚至一些委员会成员(如:Allen Wirfs-Brock和Kevin Smith)也反对它并提供替代方案,但是该提案仍然顺利进入stage 3; 4. 该提案的issue数量最多——当前提案的GitHub仓库为131个,原始提案(合并前)的GitHub仓库为96个(相比BigInt提案的issue数量为126个),并且大多数issue持反对观点; 5. 甚至创建了单独的issue,以便统计总结对它的反对意见; 6. 为了证明这一部分的合理性而创建了单独的FAQ,然而不够强力论据又导致了新的争论(133,136);
    展开
  • kkxue
    2020-08-02
    化繁为简,程序原本已成宝典
  • 青史成灰
    2020-01-18
    老师,有2个问题想问下: ①:对于这段话的描述>而”名字“对应于“找到块”这一目的本身,表达为一个可计算的函数“f()”... 对于这段话,感觉对象(关联数组)和数据结构中的散列表很像,不知道js中的对象和散列表有什么关系? 但是数据接口中的散列表通过hash函数将一个key散列到数值中的索引,底层还是使用数组来实现,也就是说它还是一种连续的数据结构。但是通过文章和老师在评论中的回答,貌似这是一种不连续的数据结构,请问老师这要作何理解? ②:从> Astrogladiator的评论中描述看,老师说索引数组的连续性意义不大。但是在连续的数据结构对于查找效率和缓存都是很友好的,请问老师这又要作何理解?
    展开

    作者回复: 1、同一个东西。 2、抽象概念上叫散列表或关联数组,并不意味着它一定要在连续存储中实现。索引数组与关联数组存在“连续/不连续”的概念,也只是在逻辑层面上说明它们是否是顺序的,或者有序的,而并不表明当它们实现在物理层面上时,“必须是”连续存储。 3、就好象JavaScript中的索引数组,在概念上它的确是连续的,但它的实现,却是用非连续索引的、非连续存储的关联数组来做到的,并不矛盾。 4、对于JavaScript的数组(Array)来说,意义不大。不过为了解决你说的问题,JavaScript中有类型化数组(TypedArray),这就是连续存储的了,并且,索引+连续在它的应用中就有很重要的价值。 5、不要死学。理解原理,比理解它的应用重要。但是,在应用中还是要“因地制宜”,不同的引擎/环境/语言等等,对不同的“概念”的实现并不一样,要活用。

  • 水木年华
    2020-01-05
    周老师关于对象和数组的描述,让我恍然大悟。
  • 许童童
    2019-12-22
    当需要遍历一个数组的时候,比如for of时,但对其遍历的顺序不是特别敏感时,是可以用可迭代的对象来替代索引数组的。