在JavaScript里写类层次结构?别那么做!

 从理论上讲,JavaScript并没有类。在实践中,下面的代码片段被广泛认为是JavaScript“类”的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Account () {
  this._currentBalance = 0;
}
 
Account.prototype.balance = function () {
  return this._currentBalance;
}
 
Account.prototype.deposit = function (howMuch) {
  this._currentBalance = this._currentBalance + howMuch;
  return this;
}
 
// ...
 
var account = new Account();

  这个模式可以被拓展以提供子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function ChequingAccount () {
  Account.call(this);
}
 
ChequingAccount.prototype = Object.create(Account.prototype);
 
ChequingAccount.prototype.sufficientFunds = function (cheque) {
  return this._currentBalance >= cheque.amount();
}
 
ChequingAccount.prototype.process = function (cheque) {
  this._currentBalance = this._currentBalance - cheque.amount();
  return this;
}

  这些类和子类拥有类的大部分特性,就像Smalltalk语言中类的特性一样:

  • 类负责创建对象且用参数来初始化它们(比如当前余额)。
  • 类管理和拥有函数(方法),对象委托函数(方法)来处理它们的类(还有超级类)。
  • 函数(方法)直接操作对象的属性。

  这种模式在JavaScript文化中变得根深蒂固,ECMAScript-6——即将对JavaScript进行大修改的标准,提供了一些“糖衣语法”,因此在我们写类和子类的时候不用手工写完全部的模式内容。这对语义并没有太大的更改, 一切还是如我们看到的一样在后台运行得很好。

  当然,Smalltalk是四十多年前发明的,在这四十多年里,我们学会了关于哪些能或不能在面向对象程序设计中使用的很多问题。不幸的是,这种模式为做不了的事高兴,而却掩盖或忽略能做的事情。

  更不幸的是,即将到来的糖衣语法并没有解决关于类的任何问题,只解决了这些问题:“我希望能少敲点代码”这样的问题,或者对于新程序员来说“我不理解这些运动的部件实际上是如何工作的,所以我可以写错代码了,有没有更简单的方法来写这些代码呢?”。

  层次结构的语义问题

  在语义层面,类是本体的构建单元。下图的内容通常有有效的:

  基于类的面向对象编程的背后思想是将我们的对象知识分类(注意这个词)成树。在顶层的是有关所有对象的最一般知识,顺着树下来,我们得到越来越多的关于对象特定的类的特定知识,比如代表VisaDebit账号的对象。

  仅仅在编程上而已,真实的世界并不是那样。确实不是那样的。在形态学中,比如我们有,企鹅像鸟类那样游泳,蝙蝠像哺乳动物那样飞,像鸭嘴兽那样的单孔目动物是卵生的哺乳动物。

  事实证明我们有意义的领域(比如形态学或银行业务)的行为并没有能分类成很好的树,它形成一个有向非循环图。如果我们站在其中,那么它就是一片丛林。

  此外,在树形本体顶端构建软件的想法将要破灭,即使我们的知识能很整齐的构成一棵树。本体论不用来构建真实的世界,他们通过观察来描绘这个世界。随着认知不断的增长,我们也在不断的更新自己的本体论,有时能移动周围的一切。

  在软件中,这具有难以置信的破坏力:移动周围所有的东西会破坏所有的东西。在真实世界中,如果我们重新排列本体,卑微的鸭嘴兽并不介意,因为我们并没有用本体论来构建澳大利亚,只是用来描述我们的发现而已。

  通过观察像银行账号这样的事物来构建本体论是合理的。这种本体对于需求、用例、测试等来说是有用的。但这并不意味着它对实现银行账户代码书写有帮助。

  类层次结构是错误的语义模型,四十年的经验智慧让他们有更好的办法构建程序。

  封装

  这些都是语义问题。让我们来谈谈工程方面的问题,让我们来处理类就好像我们并不关心它们是否代表真实世界中的一些知识,让我们相信类仅仅只是让我们程序能够正常运行的一个工具而已。那么这些还是问题吗?

  类层次结构是一个问题,即使我们都想要做的是用它们来实现一些行为。程序有三个重要的规则:

  1.程序必须易于编写

  2.程序必须易于理解

  3.程序必须易于修改

  类要权衡所有这3个重要的规则,但类层次结构对遵从理解和改变程序是有害的,因为这种方式导致了封装问题。

  封装是面向对象编程一个核心的原则。(其它的编程风格,如函数式编程,也注重封装,即使以不同的方式实现)。在面向对象编程中,封装是由对象的私有状态和方法的公共接口来实现的。

  JavaScript并不强制要求私有状态,但能很容易写出封装很好的程序:只需避免一个对象直接操作另一个对象的属性。Smalltalk发明了四十多年后,这是一个很好理解的原则。

  显然,代码间将会有依赖性。A将依赖B,B将依赖C,且这些依赖是具有传递性的,所有A依赖B,那么A同时也依赖于C。封装并没有消除依赖关系,但确实还是限制了依赖的范围:如果我们改变B和/或C,假如我们没有改变或移动A调用的外部可视的方法,那么A就不会被破坏。

  到目前为止,一切都很好。或至少如果A、B、C是对象和/或方法。例如:

1
2
3
4
5
6
7
function depositAndReturnBalance(account, amount) {
  return account.deposit(amount).balance();
}
 
var account = new Account();
depositAndReturnBalance(account, 100)
  //=> 100

  很明显depositAndReturnBalance通过一个对象的传递实现了.deposit 和.balance方法。但这不依赖于这些方法是如何实现的:我们可以这样来写Account,也能得到相同功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Account () {
  this._transactionHistory = [];
}
 
Account.prototype.balance = function () {
  return this._transactionHistory.reduce(function (acc, transaction) {
    return acc + transaction;
  }, 0);
}
 
Account.prototype.deposit = function (howMuch) {
  this._transactionHistory.unshift(howMuch)
  return this;
}
 
function depositAndReturnBalance(account, amount) {
  return account.deposit(amount).balance();
}
 
var account = new Account();
depositAndReturnBalance(account, 100)
  //=> 100

  .deposit 和.balance完全不同的实现方法,但depositAndReturnBalance并没有依赖于这些实现方法。

  所以,类给我们提供了封装“账户余额”实现的一种方法。太棒了!这有什么问题吗?

  父类没有被封装

  我们说过当实体只包含对象和/或方法时,封装能在JavaScript中实现。但是类呢?

  事实证明,类之间的层级关系是没有封装的。这是因为类之间没有通过明确定义的方法接口来进行关联,而是各自“隐藏”其内部状态。

  这是ChequingAccount子类实现.process函数的一种方法:

1
2
3
4
ChequingAccount.prototype.process = function (cheque) {
  this._currentBalance = this._currentBalance - cheque.amount();
  return this;
}

  如果我们用交易记录代替当前余额来重写Account类,这会破坏ChequingAccount的代码。在JavaScript(和同一家庭的其它语言)中类和子类共享对象私有属性的访问权。如果没有细致检查每一个子类和每一处调用子类的代码,那么改变Account的实现细节是不太可能的,因为改变私有属性将破坏它们。

  当然,我们知道代码间是存在关联的,所有子类依赖父类这并不让我们感到惊讶。但不同的是这种关联是不受方法和接口范围影响的。我们没有封装。

  这个问题并不是一个新的问题。这很好理解,它甚至有一个名字:叫做脆弱的基类问题。改变靠近继承树顶端的类会产生深远的影响,且这种影响是呈数量级排列的,还是因为没有封装。

  类继承会让程序变得难以修改且脆弱。

  展望未来

  JavaScript是在1995年首次露面的,约Smalltalk首次发布后的15年。从那以后的20年里,我们学习了很多关于JavaScript好的坏的东西,同时我们也学到了面向对象编程的很多好方法和坏主意。

  很明显,我们应该回顾和借鉴之前发生的事情。好的理念,比如封装,函数属于第一类对象,委托,特性和构成应该被包含进来且需要提升。新的理念,比如promises模式,应该得以发展。

  人家经常说“JavaScript不是Ruby”,因为它是基于原型的,不是基于类的。这确实是真的,但如果我们重造,那么优势将会丢失,如果将40年前创造的并一直延用的理念弃用,这很不好。

  所以当有人让你陈述如何写一个类层次结构的话,请告诉它们:别那么做!

  (在 hacker news/r/javascript, 和 /r/programming上参与讨论)

posted @ 2014-04-18 10:10  super1234  阅读(604)  评论(0编辑  收藏  举报