第六章: 行为委托 - 迈向面向委托的设计

优质
小牛编辑
128浏览
2023-12-01

迈向面向委托的设计

为了将我们的思想恰当地集中在如何用最直截了当的方法使用 [[Prototype]],我们必须认识到它代表一种根本上与类不同的设计模式(见第四章)。

注意 某些 面向类的设计依然是很有效的,所以不要扔掉你知道的每一件事(扔掉大多数就行了!)。比如,封装 就十分强大,而且与委托是兼容的(虽然不那么常见)。

我们需要试着将我们的思维从类/继承的设计模式转变为行为代理设计模式。如果你已经使用你在教育/工作生涯中思考类的方式做了大多数或所有的编程工作,这可能感觉不舒服或不自然。你可能需要尝试这种思维过程好几次,才能适应这种非常不同的思考方式。

我将首先带你进行一些理论练习,之后我们会一对一地看一些更实际的例子来为你自己的代码提供实践环境。

类理论

比方说我们有几个相似的任务(“XYZ”,“ABC”,等)需要在我们的软件中建模。

使用类,你设计这个场景的方式是:定义一个泛化的父类(基类)比如 Task,为所有的“同类”任务定义共享的行为。然后,你定义子类 XYZABC,它们都继承自 Task,每个都分别添加了特化的行为来处理各自的任务。

重要的是, 类设计模式将鼓励你发挥继承的最大功效,当你在 XYZ 任务中覆盖 Task 的某些泛化方法的定义时,你将会想利用方法覆盖(和多态),也许会利用 super 来调用这个方法的泛化版本,为它添加更多的行为。你很可能会找到几个这样的地方:可以“抽象”到父类中,并在子类中特化(覆盖)的一般化行为。

这是一些关于这个场景的假想代码:

  1. class Task {
  2. id;
  3. // `Task()` 构造器
  4. Task(ID) { id = ID; }
  5. outputTask() { output( id ); }
  6. }
  7. class XYZ inherits Task {
  8. label;
  9. // `XYZ()` 构造器
  10. XYZ(ID,Label) { super( ID ); label = Label; }
  11. outputTask() { super(); output( label ); }
  12. }
  13. class ABC inherits Task {
  14. // ...
  15. }

现在,你可以初始化一个或多个 XYZ 子类的 拷贝,并且使用这些实例来执行“XYZ”任务。这些实例已经 同时拷贝 了泛化的 Task 定义的行为和具体的 XYZ 定义的行为。类似地,ABC 类的实例将拷贝 Task 的行为和具体的 ABC 的行为。在构建完成之后,你通常仅会与这些实例交互(而不是类),因为每个实例都拷贝了完成计划任务的所有行为。

委托理论

但是现在让我们试着用 行为委托 代替 来思考同样的问题。

你将首先定义一个称为 Task对象(不是一个类,也不是一个大多数 JS 开发者想让你相信的 function),而且它将拥有具体的行为,这些行为包含各种任务可以使用的(读作:委托至!)工具方法。然后,对于每个任务(“XYZ”,“ABC”),你定义一个 对象 来持有这个特定任务的数据/行为。你 链接 你的特定任务对象到 Task 工具对象,允许它们在必要的时候可以委托到它。

基本上,你认为执行任务“XYZ”就是从两个兄弟/对等的对象(XYZTask)中请求行为来完成它。与其通过类的拷贝将它们组合在一起,我们可以将它们保持在分离的对象中,而且可以在需要的情况下允许 XYZ 对象 委托到 Task

这里是一些简单的代码,示意你如何实现它:

  1. var Task = {
  2. setID: function(ID) { this.id = ID; },
  3. outputID: function() { console.log( this.id ); }
  4. };
  5. // 使 `XYZ` 委托到 `Task`
  6. var XYZ = Object.create( Task );
  7. XYZ.prepareTask = function(ID,Label) {
  8. this.setID( ID );
  9. this.label = Label;
  10. };
  11. XYZ.outputTaskDetails = function() {
  12. this.outputID();
  13. console.log( this.label );
  14. };
  15. // ABC = Object.create( Task );
  16. // ABC ... = ...

在这段代码中,TaskXYZ不是类(也不是函数),它们 仅仅是对象XYZ 通过 Object.create() 创建,来 [[Prototype]] 委托到 Task 对象(见第五章)。

作为与面向类(也就是,OO —— 面向对象)的对比,我称这种风格的代码为 “OLOO”(objects-linked-to-other-objects(链接到其他对象的对象))。所有我们 真正 关心的是,对象 XYZ 委托到对象 Task(对象 ABC 也一样)。

在 JavaScript 中,[[Prototype]] 机制将 对象 链接到其他 对象。无论你多么想说服自己这不是真的,JavaScript 没有像“类”那样的抽象机制。这就像逆水行舟:你 可以 做到,但你 选择 了逆流而上,所以很明显地,你会更困难地达到目的地。

OLOO 风格的代码 中有一些需要注意的不同:

  1. 前一个类的例子中的 idlabel 数据成员都是 XYZ 上的直接数据属性(它们都不在 Task 上)。一般来说,当 [[Prototype]] 委托引入时,你想使状态保持在委托者上XYZABC),不是在委托上(Task)。
  2. 在类的设计模式中,我们故意在父类(Task)和子类(XYZ)上采用相同的命名 outputTask,以至于我们可以利用覆盖(多态)。在委托的行为中,我们反其道而行之:我们尽一切可能避免在 [[Prototype]] 链的不同层级上给出相同的命名(称为“遮蔽” —— 见第五章),因为这些命名冲突会导致尴尬/脆弱的语法来消除引用的歧义(见第四章),而我们想避免它。
    这种设计模式不那么要求那些倾向于被覆盖的泛化的方法名,而是要求针对于每个对象的 具体 行为类型给出更具描述性的方法名。这实际上会产生更易于理解/维护的代码,因为方法名(不仅在定义的位置,而是扩散到其他代码中)变得更加明白(代码即文档)。
  3. this.setID(ID); 位于对象 XYZ 的一个方法内部,它首先在 XYZ 上查找 setID(..),但因为它不能在 XYZ 上找到叫这个名称的方法,[[Prototype]] 委托意味着它可以沿着链接到 Task 来寻找 setID(),这样当然就找到了。另外,由于调用点的隐含 this 绑定规则(见第二章),当 setID() 运行时,即便方法是在 Task 上找到的,这个函数调用的 this 绑定依然是我们期望和想要的 XYZ。我们在代码稍后的 this.outputID() 中也看到了同样的事情。
    换句话说,我们可以使用存在于 Task 上的泛化工具与 XYZ 互动,因为 XYZ 可以委托至 Task

行为委托 意味着:在某个对象(XYZ)的属性或方法没能在这个对象(XYZ)上找到时,让这个对象(XYZ)为属性或方法引用提供一个委托(Task)。

这是一个 极其强大 的设计模式,与父类和子类,继承,多态等有很大的不同。与其在你的思维中纵向地,从上面父类到下面子类地组织对象,你应当并列地,对等地考虑对象,而且对象间拥有方向性的委托链接。

注意: 委托更适于作为内部实现的细节,而不是直接暴露在 API 接口的设计中。在上面的例子中,我们的 API 设计没必要有意地让开发者调用 XYZ.setID()(当然我们可以!)。我们以某种隐藏的方式将委托作为我们 API 的内部细节,即 XYZ.prepareTask(..) 委托到 Task.setID(..)。详细的内容,参照第五章的“链接作为候补?”中的讨论。

相互委托(不允许)

你不能在两个或多个对象间相互地委托(双向地)对方来创建一个 循环 。如果你使 B 链接到 A,然后试着让 A 链接到 B,那么你将得到一个错误。

这样的事情不被允许有些可惜(不是非常令人惊讶,但稍稍有些恼人)。如果你制造一个在任意一方都不存在的属性/方法引用,你就会在 [[Prototype]] 上得到一个无限递归的循环。但如果所有的引用都严格存在,那么 B 就可以委托至 A,或相反,而且它可以工作。这意味着你可以为了多种任务用这两个对象互相委托至对方。有一些情况这可能会有用。

但它不被允许是因为引擎的实现者发现,在设置时检查(并拒绝!)无限循环引用一次,要比每次你在一个对象上查询属性时都做相同检查的性能要高。

调试

我们将简单地讨论一个可能困扰开发者的微妙的细节。一般来说,JS 语言规范不会控制浏览器开发者工具如何向开发者表示指定的值/结构,所以每种浏览器/引擎都自由地按需要解释这个事情。因此,浏览器/工具 不总是意见统一。特别地,我们现在要考察的行为就是当前仅在 Chrome 的开发者工具中观察到的。

考虑这段传统的“类构造器”风格的 JS 代码,正如它将在 Chrome 开发者工具 控制台 中出现的:

  1. function Foo() {}
  2. var a1 = new Foo();
  3. a1; // Foo {}

让我们看一下这个代码段的最后一行:对表达式 a1 进行求值的输出,打印 Foo {}。如果你在 FireFox 中试用同样的代码,你很可能会看到 Object {}。为什么会有不同?这些输出意味着什么?

Chrome 实质上在说“{} 是一个由名为‘Foo’的函数创建的空对象”。Firefox 在说“{} 是一个由 Object 普通构建的空对象”。这种微妙的区别是因为 Chrome 在像一个 内部属性 一样,动态跟踪执行创建的实际方法的名称,而其他浏览器不会跟踪这样的附加信息。

试图用 JavaScript 机制来解释它很吸引人:

  1. function Foo() {}
  2. var a1 = new Foo();
  3. a1.constructor; // Foo(){}
  4. a1.constructor.name; // "Foo"

那么,Chrome 就是通过简单地查看对象的 .Constructor.name 来输出“Foo”的?令人费解的是,答案既是“是”也是“不”。

考虑下面的代码:

  1. function Foo() {}
  2. var a1 = new Foo();
  3. Foo.prototype.constructor = function Gotcha(){};
  4. a1.constructor; // Gotcha(){}
  5. a1.constructor.name; // "Gotcha"
  6. a1; // Foo {}

即便我们将 a1.constructor.name 合法地改变为其他的东西(“Gotcha”),Chrome 控制台依旧使用名称“Foo”。

那么,说明前面问题(它使用 .constructor.name 吗?)的答案是 ,它一定在内部追踪其他的什么东西。

但是,且慢!让我们看看这种行为如何与 OLOO 风格的代码一起工作:

  1. var Foo = {};
  2. var a1 = Object.create( Foo );
  3. a1; // Object {}
  4. Object.defineProperty( Foo, "constructor", {
  5. enumerable: false,
  6. value: function Gotcha(){}
  7. });
  8. a1; // Gotcha {}

啊哈!Gotcha,Chrome 的控制台 确实 寻找并且使用了 .constructor.name。实际上,就在写这本书的时候,这个行为被认定为是 Chrome 的一个 Bug,而且就在你读到这里的时候,它可能已经被修复了。所以你可能已经看到了被修改过的 a1; // Object{}

这个 bug 暂且不论,Chrome 执行的(刚刚在代码段中展示的)“构造器名称”内部追踪(目前仅用于调试输出的目的),是一个仅在 Chrome 内部存在的扩张行为,它已经超出了 JS 语言规范要求的范围。

如果你不使用“构造器”来制造你的对象,就像我们在本章的 OLOO 风格代码中不鼓励的那样,那么你将会得到一个 Chrome 不会为其追踪内部“构造器名称”的对象,所以这样的对象将正确地仅仅被输出“Object {}”,意味着“从 Object() 构建生成的对象”。

不要认为 这代表一个 OLOO 风格代码的缺点。当你用 OLOO 编码而且用行为委托作为你的设计模式时, “创建了”(也就是,哪个函数 被和 new 一起调用了?)一些对象是一个无关的细节。Chrome 特殊的内部“构造器名称”追踪仅仅在你完全接受“类风格”编码时才有用,而在你接受 OLOO 委托时是没有意义的。

思维模型比较

现在你至少在理论上可以看到“类”和“委托”设计模式的不同了,让我们看看这些设计模式在我们用来推导我们代码的思维模型上的含义。

我们将查看一些更加理论上的(“Foo”,“Bar”)代码,然后比较两种方法(OO vs. OLOO)的代码实现。第一段代码使用经典的(“原型的”)OO 风格:

  1. function Foo(who) {
  2. this.me = who;
  3. }
  4. Foo.prototype.identify = function() {
  5. return "I am " + this.me;
  6. };
  7. function Bar(who) {
  8. Foo.call( this, who );
  9. }
  10. Bar.prototype = Object.create( Foo.prototype );
  11. Bar.prototype.speak = function() {
  12. alert( "Hello, " + this.identify() + "." );
  13. };
  14. var b1 = new Bar( "b1" );
  15. var b2 = new Bar( "b2" );
  16. b1.speak();
  17. b2.speak();

父类 Foo,被子类 Bar 继承,之后 Bar 被初始化两次:b1b2。我们得到的是 b1 委托至 Bar.prototypeBar.prototype 委托至 Foo.prototype。这对你来说应当看起来十分熟悉。没有太具开拓性的东西发生。

现在,让我们使用 OLOO 风格的代码 实现完全相同的功能

  1. var Foo = {
  2. init: function(who) {
  3. this.me = who;
  4. },
  5. identify: function() {
  6. return "I am " + this.me;
  7. }
  8. };
  9. var Bar = Object.create( Foo );
  10. Bar.speak = function() {
  11. alert( "Hello, " + this.identify() + "." );
  12. };
  13. var b1 = Object.create( Bar );
  14. b1.init( "b1" );
  15. var b2 = Object.create( Bar );
  16. b2.init( "b2" );
  17. b1.speak();
  18. b2.speak();

我们利用了完全相同的从 BarFoo[[Prototype]] 委托,正如我们在前一个代码段中 b1Bar.prototype,和 Foo.prototype 之间那样。我们仍然有三个对象链接在一起

但重要的是,我们极大地简化了发生的 所有其他事项,因为我们现在仅仅建立了相互链接的 对象,而不需要所有其他讨厌且困惑的看起来像类(但动起来不像)的东西,还有构造器,原型和 new 调用。

问问你自己:如果我能用 OLOO 风格代码得到我用“类”风格代码得到的一样的东西,但 OLOO 更简单而且需要考虑的事情更少,OLOO 不是更好吗

让我们讲解一下这两个代码段间涉及的思维模型。

首先,类风格的代码段意味着这样的实体与它们的关系的思维模型:

迈向面向委托的设计 - 图1

实际上,这有点儿不公平/误导,因为它展示了许多额外的,你在 技术上 一直不需要知道(虽然你 需要 理解它)的细节。一个关键是,它是一系列十分复杂的关系。但另一个关键是:如果你花时间来沿着这些关系的箭头走,在 JS 的机制中 有数量惊人的内部统一性

例如,JS 函数可以访问 call(..)apply(..)bind(..)(见第二章)的能力是因为函数本身是对象,而函数对象还拥有一个 [[Prototype]] 链接,链到 Function.prototype 对象,它定义了那些任何函数对象都可以委托到的默认方法。JS 可以做这些事情,你也能!

好了,现在让我们看一个这张图的 稍稍 简化的版本,用它来进行比较稍微“公平”一点 —— 它仅展示了 相关 的实体与关系。

迈向面向委托的设计 - 图2

仍然非常复杂,对吧?虚线描绘了当你在 Foo.prototypeBar.prototype 间建立“继承”时的隐含关系,而且还没有 修复 丢失的 .constructor 属性引用(见第五章“复活构造器”)。即便将虚线去掉,每次你与对象链接打交道时,这个思维模型依然要变很多可怕的戏法。

现在,让我们看看 OLOO 风格代码的思维模型:

迈向面向委托的设计 - 图3

正如你比较它们所得到的,十分明显,OLOO 风格的代码 需要关心的东西少太多了,因为 OLOO 风格代码接受了 事实:我们唯一需要真正关心的事情是 链接到其他对象的对象

所有其他“类”的烂设计用一种令人费解而且复杂的方式得到相同的结果。去掉那些东西,事情就变得简单得多(还不会失去任何功能)。