面向对象与行为委托风格对比

最近在阅读 You-Dont-Know-JS 一书,作者 Kyle Simpson 提出了一种新的OLOO(objects-linked-to-other-objects)编程风格,他认为 OLOO 比基于原型的面向对象风格和 Class 语法更为优秀,那么我们到底应该如何选择呢?

类与混入

类模型

传统面向对象编程有三大特性:封装,继承和多态。

封装 隐藏了类的内部实现机制,可以在不影响使用的情况下改变类的内部结构,同时也保护了数据。对外界而已它的内部细节是隐藏的,暴露给外界的只是它的访问方法。

继承 是为了重用父类代码。两个类若存在IS-A的关系就可以使用继承,同时继承也为实现多态做了铺垫。(继承是一种高耦合设计,子类依赖于父类的实现,总得来说组合优于继承)

多态 是同一个行为具有多个不同表现形式或形态的能力,父类的通用行为可以被子类用更特殊的行为重写。多态存在的条件:存在继承关系, 子类要重写父类的方法 ,父类类型的引用指向子类对象。

JavaScript 是一门基于原型的面向对象语言,Kyle Simpson 认为 JavaScript 虽然有近似类的语法(比如 new,instanceof,class),但是 JavaScript 的机制似乎一直在阻止你使用类设计模式,他认为在 JavaScript 代码中这样做会降低代码的可读性和健壮性。

混入

在继承或者实例化时,JavaScript 的对象机制并不会自动执行复制行为。混入模式可以用来模拟类的复制行为,但是通常会产生丑陋并且脆弱的语法,比如显式伪多态 (call)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// `mixin(..)` example:
function mixin( sourceObj, targetObj ) {
for (var key in sourceObj) {
// only copy if not already present
if (!(key in targetObj)) {
targetObj[key] = sourceObj[key];
}
}
return targetObj;
}
var Vehicle = {
engines: 1,
ignition: function() {
console.log( "Turning on my engine." );
},
drive: function() {
this.ignition();
console.log( "Steering and moving forward!" );
}
};
var Car = mixin( Vehicle, {
wheels: 4,
drive: function() {
// 显式多态,确保 drive() 在 Car 对象的上下文执行
Vehicle.drive.call( this );
console.log( "Rolling on all " + this.wheels + " wheels!" );
}
} );

在支持相对多态的面向类的语言中,Car 和 Vehicle 之间的联系只在类定义的开头被创建(比如 extends),从而只需要在这一个地方维护两个类的联系。

但是在 JavaScript 中使用显式(伪)多态会在所有需要使用(伪)多态引用的地方创建一个函数关联,这会极大地增加维护成本。

原型

属性设置和屏蔽

1
myObject.foo = "bar"

分析一下如果 foo 不直接存在于 myObject 中 而是存在于原型链上层时 myObject.foo = “bar” 会出现的三种情况。

  1. 如果在 [[Prototype]] 链上层存在名为 foo 的普通数据访问属性并且没有被标记为只读 (writable:false) ,那就会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性。
  2. 如果在 [[Prototype]] 链上层存在 foo,但是它被标记为只读 (writable:false) ,那么无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
  3. 如果在 [[Prototype]] 链上层存在foo并且它是一个 setter,那就一定会调用这个 setter。foo 不会被添加到(或者说屏蔽于) myObject,也不会重新定义 foo 这个 setter。

如果你希望在第二种和第三种情况下也屏蔽 foo,那就不能使用 = 操作符来赋值,而是使用 Object.defineProperty(..)来向 myObject 添加 foo。

模仿类

在基于类的语言中,类可以被复制(或者说实例化)多次,但是在 JavaScript 中,不能创建一个类的多个实例,只能创建多个对象,它们 [[Prototype]] 关联的是同一个对象,这些对象是互相关联的。

实际上,new Foo() 这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo() 只是间接完成了我们的目标:一个关联到其他对象的新对象。

当代码 new Foo(…) 执行时:

1.一个新对象被创建。它继承自 Foo.prototype。
foo.__proto__ === Foo.prototype

2.构造函数 Foo 被执行。执行的时候,相应的传参会被传入,同时 this 会被指定为这个新实例。

3.如果构造函数返回了一个“对象”,那么这个对象会取代整个new出来的结果。如果构造函数没有返回对象,那么 new 出来的结果为步骤1创建的对象。

构造函数

函数不是构造函数,但是当且仅当使用 new 时,函数调用会变成“构造函数调用”。

原型继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
};
function Bar(name,label) {
Foo.call( this, name );
this.label = label;
}
// 创建一个新的 Bar.prototype 对象并关联到 Foo.prototype
Bar.prototype = Object.create( Foo.prototype );
// 没有 Bar.prototype.constructor
// 如果你需要这个属性的话需要手动修复
Bar.prototype.myLabel = function() {
return this.label;
};
var a = new Bar( "a", "obj a" );
a.myName(); // "a"
a.myLabel(); // "obj a"

对象关联

关联两个对象最常用的方法是使用 new 关键词进行函数调用,在调用的几个步骤中会创建一个关联其他对象的新对象。

1
2
3
4
5
6
7
if (!Object.create) {
Object.create = function(o) {
function F(){}
F.prototype = o;
return new F();
};
}

这段 polyfill 代码使用了一个一次性函数 F,我们通过改写它的 .prototype 属性使其指向想
要关联的对象,然后再使用 new F() 来构造一个新对象进行关联。

虽然这些 JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的[[Prototype]] 链关联的。

面向对象与行为委托

(基于原型的) 面向对象风格

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

子类 Bar 继承了父类 Foo,然后生成了 b1 和 b2 两个实例。b1 委托了 Bar.prototype,后者委托了 Foo.prototype。这种风格很常见,你应该很熟悉了。

对象关联风格 OLOO ([[Prototype]] 委托机制)

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

行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的 [[Prototype]] 机制本质上就是行为委托机制。也就是说,我们可以选择在 JavaScript 中努力实现类机制,也可以拥抱更自然的 [[Prototype]] 委托机制。

ES6中的Class

class 语法并没有解决所有的问题,在 JavaScript 中使用“类”设计模式仍然存在许多深层问题。你可能会认为 ES6 的 class 语法是向 JavaScript 中引入了一种新的“类”机制,其实 class 基本上只是现有 [[Prototype]] 机制的一种语法糖。

也就是说,class 并不会像传统面向类的语言一样在声明时静态复制所有行为。如果你修改或者替换了父“类”中的一个方法,那子“类”和所有实例都会受到影响,因为它们在定义时并没有进行复制,只是使用基于 [[Prototype]] 的实时委托:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class C {
constructor () {
this.num = Math.random();
}
rand () {
console.log('Random: ' + this.num);
}
}
var c1 = new C();
c1.rand(); // "Random: 0.4324299..."
C.prototype.rand = function () {
console.log('Random: ' + Math.round(this.num * 1000));
};
var c2 = new C();
c2.rand(); // "Random: 867"
c1.rand(); // "Random: 432" ——噢!

此外,class 语法仍然面临意外屏蔽的问题:

1
2
3
4
5
6
7
8
9
10
11
12
class C {
constructor (id) {
// 噢,郁闷,我们的 id 属性屏蔽了 id() 方法
this.id = id;
}
id () {
console.log('Id: ' + id);
}
}
var c1 = new C('c1');
c1.id(); // TypeError -- c1.id 现在是字符串 "c1"

通过上面的这些特性可以看出,ES6 的 class 最大的问题在于,(像传统的类一样)它的语法有时会让你认为,定义了一个 class 后,它就变成了一个(未来会被实例化的)东西的静态定义。你会彻底忽略 C 是一个对象,是一个具体的可以直接交互的东西。

在传统面向类的语言中,类定义之后就不会进行修改,所以类的设计模式就不支持修改。但是 JavaScript 最强大的特性之一就是它的动态性,任何对象的定义都可以修改(除非你把它设置成不可变)。

class 似乎不赞成这样做,所以强制让你使用丑陋的 .prototype 语法以及 super 问题,等等。而且对于这种动态产生的问题,class 基本上都没有提供解决方案。

小结

书中作者 Kyle Simpson 坚持认为:class 伪装了 JavaScript 中类和继承设计模式的解决方案,并隐藏了许多问题。class 加深了我们对于 JavaScript 中“类”的误解,在某些方面,它产生的问题比解决的多,而且让本来优雅简洁的[[Prototype]] 机制变得非常别扭。

然而,书中 Kyle Simpson 的观念也并非完全正确,有反对 class 的声音,也一定有更多支持 class 的声音,不然 class 也不会出现在 ES6 规范中。JavaScript 作为一门支持多范式的动态语言,可以使用任何合适的方法编程,但也不代表任何地方都需要动态性。class 在实际编程中可以让构建对象实例时比 OLOO 更为统一, 同时还提供了 super 调用, static 函数之类的功能。

在实际编码中,我们往往也不是采用最优秀的解法,而是采用最容易和易于理解的方法,这对于习惯了其他基于类语言的程序员往往也更容易接受。