簡介: JavaScript 函式式指令碼語言特性以及其看似隨意的編寫風格,導致長期以來人們對這一門語言的誤解,即認為 JavaScript 不是一門物件導向的語言,或者只是部分具備一些物件導向的特徵。本文將回歸物件導向本意,從對語言感悟的角度闡述為什麼 JavaScript 是一門徹底的物件導向的語言,以及如何正確地使用這一特性。
前言
當今 JavaScript 大行其道,各種應用對其依賴日深。web 程式設計師已逐漸習慣使用各種優秀的 JavaScript 框架快速開發 Web 應用,從而忽略了對原生 JavaScript 的學習和深入理解。所以,經常出現的情況是,很多做了多年 JS 開發的程式設計師對閉包、函數語言程式設計、原型總是說不清道不明,即使使用了框架,其程式碼組織也非常糟糕。這都是對原生 JavaScript 語言特性理解不夠的表現。要掌握好 JavaScript,首先一點是必須摒棄一些其他高階語言如 Java、C# 等類式物件導向思維的干擾,全面地從函式式語言的角度理解 JavaScript 原型式物件導向的特點。把握好這一點之後,才有可能進一步使用好這門語言。本文適合群體:使用過 JS 框架但對 JS 語言本質缺乏理解的程式設計師,具有 Java、C++ 等語言開發經驗,準備學習並使用 JavaScript 的程式設計師,以及一直對 JavaScript 是否物件導向模稜兩可,但希望知道真相的 JS 愛好者。
為了說明 JavaScript 是一門徹底的物件導向的語言,首先有必要從物件導向的概念著手 , 探討一下物件導向中的幾個概念:
- 一切事物皆物件
- 物件具有封裝和繼承特性
- 物件與物件之間使用訊息通訊,各自存在資訊隱藏
以這三點做為依據,C++ 是半物件導向半程式導向語言,因為,雖然他實現了類的封裝、繼承和多型,但存在非物件性質的全域性函式和變數。Java、C# 是完全的面嚮物件語言,它們通過類的形式組織函式和變數,使之不能脫離物件存在。但這裡函式本身是一個過程,只是依附在某個類上。
然而,物件導向僅僅是一個概念或者程式設計思想而已,它不應該依賴於某個語言存在。比如 Java 採用物件導向思想構造其語言,它實現了類、繼承、派生、多型、介面等機制。但是這些機制,只是實現物件導向程式設計的一種手段,而非必須。換言之,一門語言可以根據其自身特性選擇合適的方式來實現物件導向。所以,由於大多數程式設計師首先學習或者使用的是類似 Java、C++ 等高階編譯型語言(Java 雖然是半編譯半解釋,但一般做為編譯型來講解),因而先入為主地接受了“類”這個物件導向實現方式,從而在學習指令碼語言的時候,習慣性地用類式面嚮物件語言中的概念來判斷該語言是否是面嚮物件語言,或者是否具備物件導向特性。這也是阻礙程式設計師深入學習並掌握 JavaScript 的重要原因之一。
實際上,JavaScript 語言是通過一種叫做 原型(prototype)的方式來實現物件導向程式設計的。下面就來討論 基於類的(class-based)物件導向和 基於原型的 (prototype-based) 物件導向這兩種方式在構造客觀世界的方式上的差別。
基於類的物件導向和基於原型的物件導向方式比較
在基於類的物件導向方式中,物件(object)依靠 類(class)來產生。而在基於原型的物件導向方式中,物件(object)則是依靠 構造器(constructor)利用 原型(prototype)構造出來的。舉個客觀世界的例子來說明二種方式認知的差異。例如工廠造一輛車,一方面,工人必須參照一張工程圖紙,設計規定這輛車應該如何製造。這裡的工程圖紙就好比是語言中的 類 (class),而車就是按照這個 類(class)製造出來的;另一方面,工人和機器 ( 相當於 constructor) 利用各種零部件如發動機,輪胎,方向盤 ( 相當於 prototype 的各個屬性 ) 將汽車構造出來。
事實上關於這兩種方式誰更為徹底地表達了物件導向的思想,目前尚有爭論。但筆者認為原型式物件導向是一種更為徹底的物件導向方式,理由如下:
首先,客觀世界中的物件的產生都是其它實物物件構造的結果,而抽象的“圖紙”是不能產生“汽車”的,也就是說,類是一個抽象概念而並非實體,而物件的產生是一個實體的產生;
其次,按照一切事物皆物件這個最基本的物件導向的法則來看,類 (class) 本身並不是一個物件,然而原型方式中的構造器 (constructor) 和原型 (prototype) 本身也是其他物件通過原型方式構造出來的物件。
再次,在類式面嚮物件語言中,物件的狀態 (state) 由物件例項 (instance) 所持有,物件的行為方法 (method) 則由宣告該物件的類所持有,並且只有物件的結構和方法能夠被繼承;而在原型式面嚮物件語言中,物件的行為、狀態都屬於物件本身,並且能夠一起被繼承,這也更貼近客觀實際。
最後,類式面嚮物件語言比如 Java,為了彌補無法使用程式導向語言中全域性函式和變數的不便,允許在類中宣告靜態 (static) 屬性和靜態方法。而實際上,客觀世界不存在所謂靜態概念,因為一切事物皆物件!而在原型式面嚮物件語言中,除內建物件 (build-in object) 外,不允許全域性物件、方法或者屬性的存在,也沒有靜態概念。所有語言元素 (primitive) 必須依賴物件存在。但由於函式式語言的特點,語言元素所依賴的物件是隨著執行時 (runtime) 上下文 (context) 變化而變化的,具體體現在 this 指標的變化。正是這種特點更貼近 “萬物皆有所屬,宇宙乃萬物生存之根本”的自然觀點。在 程式清單 1中 window 便類似與宇宙的概念。
清單 1. 物件的上下文依賴
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<script> var str = "我是一個 String 物件 , 我宣告在這裡 , 但我不是獨立存在的!" var obj = { des: "我是一個 Object 物件 , 我宣告在這裡,我也不是獨立存在的。" }; var fun = function() { console.log( "我是一個 Function 物件!誰呼叫我,我屬於誰:", this ); }; obj.fun = fun; console.log( this === window ); // 列印 true console.log( window.str === str ); // 列印 true console.log( window.obj === obj ); // 列印 true console.log( window.fun === fun ); // 列印 true fun(); // 列印 我是一個 Function 物件!誰呼叫我,我屬於誰:window obj.fun(); // 列印 我是一個 Function 物件!誰呼叫我,我屬於誰:obj fun.apply(str); // 列印 我是一個 Function 物件!誰呼叫我,我屬於誰:str </script> |
在接受了物件導向存在一種叫做基於原型實現的方式的事實之後,下面我們就可以來深入探討 ECMAScript 是如何依據這一方式構造自己的語言的。
最基本的物件導向
ECMAScript 是一門徹底的物件導向的程式語言(參考資源),JavaScript 是其中的一個變種 (variant)。它提供了 6 種基本資料型別,即 Boolean、Number、String、Null、Undefined、Object。為了實現物件導向,ECMAScript設計出了一種非常成功的資料結構 – JSON(JavaScript Object Notation), 這一經典結構已經可以脫離語言而成為一種廣泛應用的資料互動格式 (參考資源)。
應該說,具有基本資料型別和 JSON 構造語法的 ECMAScript 已經基本可以實現物件導向的程式設計了。開發者可以隨意地用 字面式宣告(literal notation)方式來構造一個物件,並對其不存在的屬性直接賦值,或者用 delete 將屬性刪除 ( 注:JS 中的 delete 關鍵字用於刪除物件屬性,經常被誤作為 C++ 中的 delete,而後者是用於釋放不再使用的物件 ),如 程式清單 2。
清單 2. 字面式 (literal notation) 物件宣告
1 2 3 4 5 6 7 8 9 10 |
var person = { name: “張三”, age: 26, gender: “男”, eat: function( stuff ) { alert( “我在吃” + stuff ); } }; person.height = 176; delete person[ “age” ]; |
在實際開發過程中,大部分初學者或者對 JS 應用沒有太高要求的開發者也基本上只用到 ECMAScript 定義的這一部分內容,就能滿足基本的開發需求。然而,這樣的程式碼複用性非常弱,與其他實現了繼承、派生、多型等等的類式物件導向的強型別語言比較起來顯得有些乾癟,不能滿足複雜的 JS 應用開發。所以 ECMAScript 引入原型來解決物件繼承問題。
使用函式構造器構造物件
除了 字面式宣告(literal notation)方式之外,ECMAScript 允許通過 構造器(constructor)建立物件。每個構造器實際上是一個 函式(function) 物件, 該函式物件含有一個“prototype”屬性用於實現 基於原型的繼承(prototype-based inheritance)和 共享屬性(shared properties)。物件可以由“new 關鍵字 + 構造器呼叫”的方式來建立,如 程式清單 3:
清單 3. 使用構造器 (constructor) 建立物件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 構造器 Person 本身是一個函式物件 function Person() { // 此處可做一些初始化工作 } // 它有一個名叫 prototype 的屬性 Person.prototype = { name: “張三”, age: 26, gender: “男”, eat: function( stuff ) { alert( “我在吃” + stuff ); } } // 使用 new 關鍵字構造物件 var p = new Person(); |
由於早期 JavaScript 的發明者為了使這門語言與大名鼎鼎的 Java 拉上關係 ( 雖然現在大家知道二者是雷鋒和雷鋒塔的關係 ),使用了new 關鍵字來限定構造器呼叫並建立物件,以使其在語法上跟 Java 建立物件的方式看上去類似。但需要指出的是,這兩門語言的new含義毫無關係,因為其物件構造的機理完全不同。也正是因為這裡語法上的類似,眾多習慣了類式面嚮物件語言中物件建立方式的程式設計師,難以透徹理解 JS 物件原型構造的方式,因為他們總是不明白在 JS 語言中,為什麼“函式名可以作為類名”的現象。而實質上,JS 這裡僅僅是借用了關鍵字 new,僅此而已;換句話說,ECMAScript 完全可以用其它 非 new 表示式來用呼叫構造器建立物件。
徹底理解原型鏈 (prototype chain)
在 ECMAScript 中,每個由構造器建立的物件擁有一個指向構造器 prototype 屬性值的 隱式引用(implicit reference),這個引用稱之為 原型(prototype)。進一步,每個原型可以擁有指向自己原型的 隱式引用(即該原型的原型),如此下去,這就是所謂的原型鏈(prototype chain) (參考資源)。在具體的語言實現中,每個物件都有一個 __proto__ 屬性來實現對原型的 隱式引用。程式清單 4說明了這一點。
清單 4. 物件的 __proto__ 屬性和隱式引用
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function Person( name ) { this.name = name; } var p = new Person(); // 物件的隱式引用指向了構造器的 prototype 屬性,所以此處列印 true console.log( p.__proto__ === Person.prototype ); // 原型本身是一個 Object 物件,所以他的隱式引用指向了 // Object 構造器的 prototype 屬性 , 故而列印 true console.log( Person.prototype.__proto__ === Object.prototype ); // 構造器 Person 本身是一個函式物件,所以此處列印 true console.log( Person.__proto__ === Function.prototype ); |
有了 原型鏈,便可以定義一種所謂的 屬性隱藏機制,並通過這種機制實現繼承。ECMAScript 規定,當要給某個物件的屬性賦值時,直譯器會查詢該物件原型鏈中第一個含有該屬性的物件(注:原型本身就是一個物件,那麼原型鏈即為一組物件的鏈。物件的原型鏈中的第一個物件是該物件本身)進行賦值。反之,如果要獲取某個物件屬性的值,直譯器自然是返回該物件原型鏈中首先具有該屬性的物件屬性值。圖 1說名了這中隱藏機制:
圖 1. 原型鏈中的屬性隱藏機制
在圖 1 中,object1->prototype1->prototype2 構成了 物件 object1 的原型鏈,根據上述屬性隱藏機制,可以清楚地看到 prototype1 物件中的 property4 屬性和 prototype2 物件中的 property3 屬性皆被隱藏。理解了原型鏈,那麼將非常容易理解 JS 中基於原型的繼承實現原理,程式清單 5 是利用原型鏈實現繼承的簡單例子。
清單 5. 利用原型鏈 Horse->Mammal->Animal 實現繼承
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 35 36 37 38 |
// 宣告 Animal 物件構造器 function Animal() { } // 將 Animal 的 prototype 屬性指向一個物件, // 亦可直接理解為指定 Animal 物件的原型 Animal.prototype = { name: "animal", weight: 0, eat: function() { alert( "Animal is eating!" ); } } // 宣告 Mammal 物件構造器 function Mammal() { this.name = "mammal"; } // 指定 Mammal 物件的原型為一個 Animal 物件。 // 實際上此處便是在建立 Mammal 物件和 Animal 物件之間的原型鏈 Mammal.prototype = new Animal(); // 宣告 Horse 物件構造器 function Horse( height, weight ) { this.name = "horse"; this.height = height; this.weight = weight; } // 將 Horse 物件的原型指定為一個 Mamal 物件,繼續構建 Horse 與 Mammal 之間的原型鏈 Horse.prototype = new Mammal(); // 重新指定 eat 方法 , 此方法將覆蓋從 Animal 原型繼承過來的 eat 方法 Horse.prototype.eat = function() { alert( "Horse is eating grass!" ); } // 驗證並理解原型鏈 var horse = new Horse( 100, 300 ); console.log( horse.__proto__ === Horse.prototype ); console.log( Horse.prototype.__proto__ === Mammal.prototype ); console.log( Mammal.prototype.__proto__ === Animal.prototype ); |
理解清單 5 中物件原型繼承邏輯實現的關鍵在於 Horse.prototype = new Mammal() 和 Mammal.prototype = new Animal() 這兩句程式碼。首先,等式右邊的結果是構造出一個臨時物件,然後將這個物件賦值給等式左邊物件的 prototype 屬性。也就是說將右邊新建的物件作為左邊物件的原型。讀者可以將這兩個等式替換到相應的程式清單 5 程式碼最後兩行的等式中自行領悟。
JavaScript 類式繼承的實現方法
從程式碼清單 5 可以看出,基於原型的繼承方式,雖然實現了程式碼複用,但其行文鬆散且不夠流暢,可閱讀性差,不利於實現擴充套件和對原始碼進行有效地組織管理。不得不承認,類式繼承方式在語言實現上更具健壯性,且在構建可複用程式碼和組織架構程式方面具有明顯的優勢。這使得程式設計師們希望尋找到一種能夠在 JavaScript 中以類式繼承風格進行編碼的方法途徑。從抽象的角度來講,既然類式繼承和原型繼承都是為實現物件導向而設計的,並且他們各自實現的載體語言在計算能力上是等價的 ( 因為圖靈機的計算能力與 Lambda 演算的計算能力是等價的 ),那麼能不能找到一種變換,使得原型式繼承語言通過該變換實現具有類式繼承編碼的風格呢?
目前一些主流的 JS 框架都提供了這種轉換機制,也即類式宣告方法,比如 Dojo.declare()、Ext.entend() 等等。使用者使用這些框架,可以輕易而友好地組織自己的 JS 程式碼。其實,在眾多框架出現之前,JavaScript 大師 Douglas Crockford 最早利用三個函式對 Function 物件進行擴充套件,實現了這種變換,關於它的實現細節可以(參考資源)。此外還有由 Dean Edwards實現的著名的 Base.js(參考資源)。值得一提的是,jQuery 之父 John Resig 在搏眾家之長之後,用不到 30 行程式碼便實現了自己的 Simple Inheritance。使用其提供的 extend 方法宣告類非常簡單。程式清單 6是使用了 Simple Inheritance庫實現類的宣告的例子。其中最後一句列印輸出語句是對 Simple Inheritance實現類式繼承的最好說明。
清單 6. 使用 Simple Inheritance 實現類式繼承
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 |
// 宣告 Person 類 var Person = Class.extend( { _issleeping: true, init: function( name ) { this._name = name; }, isSleeping: function() { return this._issleeping; } } ); // 宣告 Programmer 類,並繼承 Person var Programmer = Person.extend( { init: function( name, issleeping ) { // 呼叫父類建構函式 this._super( name ); // 設定自己的狀態 this._issleeping = issleeping; } } ); var person = new Person( "張三" ); var diors = new Programmer( "張江男", false ); // 列印 true console.log( person.isSleeping() ); // 列印 false console.log( diors.isSleeping() ); // 此處全為 true,故列印 true console.log( person instanceof Person && person instanceof Class && diors instanceof Programmer && diors instanceof Person && diors instanceof Class ); |
如果您已對原型、函式構造器、閉包和基於上下文的 this 有了充分的理解,那麼理解 Simple Inheritance 的實現原理也並非相當困難。從本質上講,var Person = Class.extend(…)該語句中,左邊的 Person 實際上是獲得了由 Class 呼叫 extend 方法返回的一個構造器,也即一個 function 物件的引用。順著這個思路,我們繼續介紹 Simple Inheritance 是如何做到這一點,進而實現了由原型繼承方式到類式繼承方式的轉換的。圖 2 是 Simple Inheritance 的原始碼及其附帶註釋。為了方便理解,用中文對程式碼逐行補充說明。
圖 2.Simple Inheritance 原始碼解析
拋開程式碼第二部分,整體連貫地考察第一和第三部分會發現,extend 函式的根本目的就是要構造一個具有新原型屬性的新構造器。我們不禁感嘆 John Resig的大師手筆及其對 JS 語言本質把握的細膩程度。至於 John Resig是如何想到這樣精妙的實現方法,感興趣的讀者可以閱讀本文 (參考資源),其中有詳細介紹關於最初設計 Simple Inheritance 的思維過程。
JavaScript 私有成員實現
到此為止,如果您任然對 JavaScript 物件導向持懷疑態度,那麼這個懷疑一定是,JavaScript 沒有實現物件導向中的資訊隱藏,即私有和公有。與其他類式物件導向那樣顯式地宣告私有公有成員的方式不同,JavaScript 的資訊隱藏就是靠閉包實現的。見 程式清單 7:
清單 7. 使用閉包實現資訊隱藏
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 |
// 宣告 User 構造器 function User( pwd ) { // 定義私有屬性 var password = pwd; // 定義私有方法 function getPassword() { // 返回了閉包中的 password return password; } // 特權函式宣告,用於該物件其他公有方法能通過該特權方法訪問到私有成員 this.passwordService = function() { return getPassword(); } } // 公有成員宣告 User.prototype.checkPassword = function( pwd ) { return this.passwordService() === pwd; }; // 驗證隱藏性 var u = new User( "123456" ); // 列印 true console.log( u.checkPassword( "123456" ) ); // 列印 undefined console.log( u.password ); // 列印 true console.log( typeof u.gePassword === "undefined" ); |
JavaScript 必須依賴閉包實現資訊隱藏,是由其函式式語言特性所決定的。本文不會對函式式語言和閉包這兩個話題展開討論,正如上文預設您理解 JavaScript 中基於上下文的 this 一樣。關於 JavaScript 中實現資訊隱藏,Douglas Crockford在《 Private members in JavaScript 》(參考資源)一文中有更權威和詳細的介紹。
JavaScript 被認為是世界上最受誤解的程式語言,因為它身披 c 語言家族的外衣,表現的卻是 LISP 風格的函式式語言特性;沒有類,卻實也徹底實現了物件導向。要對這門語言有透徹的理解,就必須扒開其 c 語言的外衣,從新回到函數語言程式設計的角度,同時摒棄原有類的物件導向概念去學習領悟它。隨著近些年來 Web 應用的普及和 JS 語言自身的長足發展,特別是後臺 JS 引擎的出現 ( 如基於 V8 的 NodeJS 等 ),可以預見,原來只是作為玩具編寫頁面效果的 JS 將獲得更廣闊發展天地。這樣的發展趨勢,也對 JS 程式設計師提出了更高要求。只有徹底領悟了這門語言,才有可能在大型的 JS 專案中發揮她的威力。
學習
- Standard ECMA-262: ECMAScript 官方標準,學習它,可以讓你全面理解 JavaScript 的本質。
- Pro JavaScript: 《精通 JavaScript 》:jQuery 之父 John Resig 的鼎力之作。他不是一本介紹語法知識及相關入門的書。您可以在書中學習到現代 JavaScript 程式設計方法。
- Introducing JSON: JSON 官方介紹:學習它可以理解 JSON 的構造本質。
- Simple JavaScript Inheritance:John Resig 關於實現的簡單 JS 繼承的詳細介紹,包含原始碼和思維過程及使用例項。
- The World’s Most Misunderstood Programming Language:這是 JS 大師 Douglas Crockford 寫的一篇關於介紹 JavaScript 語言本質的文章。
- A Base Class for JavaScript Inheritance:JS 大師 Dean Edwards 實現的 Base.js。