在JavaScript裡寫類層次結構?別那麼做!

ourjs發表於2014-04-04

  從理論上講,JavaScript並沒有類。在實踐中,下面的程式碼片段被廣泛認為是JavaScript“類”的一個例子:

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();

  這個模式可以被擴充以提供子類:

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是物件和/或方法。例如:

function depositAndReturnBalance(account, amount) {
  return account.deposit(amount).balance();
}

var account = new Account();
depositAndReturnBalance(account, 100)
  //=> 100

  很明顯depositAndReturnBalance通過一個物件的傳遞實現了.deposit 和.balance方法。但這不依賴於這些方法是如何實現的:我們可以這樣來寫Account,也能得到相同功能:

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函式的一種方法:

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上參與討論)

相關文章