剪不斷,理還亂,是離愁。
前面已經提到過新語言開發的兩個步驟,分別是:一、定義基本的數據類型,完善結構化編程語言的設計;二、為函數類型綁定this的概念,好在對象的方法中可以引用到對象自身。下面是繼續下去的思路,其主體思想是盡可能地引用傳統面向對象語言的相關概念(如類、繼承等)到新語言中來。
三、讓對象屬於某個類
這次要引入類的概念來。但是注意的是,還是前面提到過的思路,是讓對象看起來屬於某個類,而不是真正地構造基於類的種種語義概念。
一般來說,類包括類符號和類模板。最簡單的類符號可以是一個字符串屬性。比如隨便一個對象,它的class屬性來指明類名,即調用obj.class返回類名字符串(如"Cat", "Dog"等)。不過麻煩的是類模板。我們要在統一的地方定義類模板,以便於在用統一的模式創建類實例。這樣創建的類實例(即對象)才是真正有意義的。因為我們不僅要在類符號上來區別類;更重要的是,要在類行為上來統一類。
這時想到的是函數。因為在JavaScript中,一切對象的創建通過一段代碼塊來實現的,而函數又能夠將這段代碼塊組合起來。所以,可以讓一般的函數作為類模板的定義;進一步地,將它視為構造方法。
一般的函數定義是:
function createStudent(name, age) {
var student = {};
student.name = name;
student.age = age;
student.toString = function() {
return this.name + " " + this.age;
};
student.class = 'Student';
return student;
}
這樣才能夠完整地表現我們之前的一些概念。而且,這種方式沒有引入任何新的語言概念。只不過,這種構造方式要完全依賴開發人員去實現。語言本身並不能自動支持其中的任何一個概念。
因此,當時的JavaScript設計者進一步地推動了在語言自身中去自動實現一些概念。分為以下幾步:
1. 引入構造函數的概念
上面的createStudent多多少少不是構造函數的樣子。像Java和C++那樣的面向對象語言,當調用構造函數時,對象已經創建好了。構造函數完成的是一些初始化的工作。根本就不需要像第2行代碼那樣去顯示地創建對象。所以,前面的代碼要改寫成下面的樣子:
function Student(name, age) {
this.name = name;
this.age = age;
this.toString = function() {
return this.name + " " + this.age;
};
}
這里主要做了以下幾點改動:
1. 函數名不再是createStudent,而是Student,這看起來更像是構造函數的名字。
2. 不再顯示地創建對象;而是當函數作為構造函數調用時,會默認構造一個空對象,並能夠通過this訪問。
3. 函數不再return返回任何對象;而是默認地,應該返回this指向地對象。
4. 不再有任何顯示地構造this.class = "Student"之類的語句;而是默認地,這一構建應該在構造過程中自動完成。
只不過,這里說是這樣說,要達到這一系列的改動必須要做一些工作。如果還像以前那樣調用Student函數時達不到上面提到的四點效果的(說錯了,其實第一點效果達到了)。有興趣的同學可以自己揣摩下。
為達到上面提到的四點效果,JavaScript設計者引入一個新的new語句。它像Java構造對象時那樣調用。像下面:
new Student("Sam", 18);
而上面語句實際做的工作,用JavaScript描述大致就是:
var obj = {};
Student.call(obj, "Sam", 18);
obj.i_was_build_by = Student;
return obj;
這里特別注意的是代碼的第三行。我們不再是通過增加一個class屬性來區分對象的類,而是通過加入一個i_was_build_by屬性,它引用了構造函數Student。這個相當於前面的class屬性,不過它引用的不是一個單純的字符串,而是一個函數了。這樣也行。我們也可以類似的判斷一個對象是否屬於某個類:
s instanceof Student //等效於 s.i_was_build_by == Student
我寫出這么一個奇葩的名字,是不想誤導讀者。如果要深追究,JavaScript當中不是通過這種方式來區別對象的,其機制要稍微復雜些,不過大體思想是一致的。
讓方法只定義一次
我們看到改動后的代碼,其依然有個不足之處。在基於類模板的語言中,方法是屬於類的,只需要定義一次。而在我們的版本中,方法是屬於對象的,其在每次Student函數調用過程當中都會被定義一次。且不說帶來的內存消耗吧。這樣離看上去像Java也是差了些。所以這里又要做些改動,使得方法只需要定義一次。
思路就是新創建的對象要保持一個對象引用,這個對象囊括了對象所屬類的方法集合。首先,這個引用的名字是prototype;其次,它的來源是構造函數同為名prototype的引用;最后,所有在本對象中找不到的方法,都推到prototype中去查找。例如,我們要把之前的案例像下面這樣寫:
function Student(name, age) {
this.name = name;
this.age = age;
}
Student.prototype.toString = function() {
return this.name + " " + this.age;
};
var s = new Student();
//s.prototype == Student.prototype;
//s.toString() == s.prototype.toString.call(s);
解釋:Student函數本身有個屬性prototype。通過new Student()語句構造的s對象,它的prototype屬性指向了函數Student的prototype屬性。最后當調用s.toString()時,由於s中不存在toString屬性,繼而跳到prototype對象中去查找。就好像prototype當中的屬性是自己的屬性一樣。
那么真正地new Student("Sam", 18)語句執行邏輯可以總結如下:
var obj = {};
Student.call(obj, "Sam", 18);
obj.i_was_build_by = Student;
obj.prototype = Student.prototype;
return obj;
通過這種方式,我們可以只需要在prototype處定義方法一次即可;另外,prototype也可以定義類的共有屬性。這就是prototype處的作用。下面我們還會看到,通過prototype鏈的方式,它也開拓了通往繼承之門的道路。
四、繼承並不神秘,它就是prototype鏈
真的快要寫完了。也許在JavaScript中,最值得着墨的地方就是繼承了。不過我寫的有些累了,這里不再多提了。
其思路就是擴展prototype下去。我們之前提過,如果一個對象的屬性找不到,就會在它的prototype引用中去找;如果在prototype引用中還找不到呢?那么就會在prototype引用的prototype引用中再去找,一直到找到為止或者prototype引用為空。
但這與繼承有什么聯系呢?事實上,通過巧妙地構造prototype鏈,就可以實現繼承的效果了。不便說了,上例子吧:
function Animal() {}
function Dog() {}
Dog.prototype = new Animal();
這便實現了繼承的魔法。乍一看也許沒明白,需要拆解開:
let animal = new Animal();
animal.prototype == Animal.prototype;
let Dog.prototype = animal;
let dog = new Dog();
dog.prototype == Dog.prototype == animal;
上面的例子顯示了,如果新建一個dog對象,它的prototype對象(暫且取個中間變量)為animal,而animal對象的prototype對象就會回到Animal的prototype中去。對於dog的某個方法調用,它首先在animal中尋找(這個是Dog的prototype);如果找不到,就會在animal的prototype中尋找(這個是Animal的prototype)。這樣,我們不僅可以調用Dog.prototype中定義的方法(這是子類的方法),也可以調用Animal.prototype的方法(這個是繼承於父類的方法)。這樣操作便實現了繼承。
五、總結
JavaScript按照這種思路就創造得差不多了。這種思路差不多就是JavaScript面向對象的一種概述了。而實際上,JavaScript真正地內部機制比起這個要復雜一些;不過我相信它也有自己的考量。總之,JavaScript有着自己的面向對象思想,又要引入傳統的基於類模板的面向對象概念進來,就變成了現在這樣了。