和少婦白潔一塊兒學JavaScript

我不肯定JavaScript語言是否應該被稱爲Object-Oriented,由於Object Oriented是一組語言特性、編程模式、和設計與工程方法的籠統稱謂,沒有一個詳盡和你們都承認的checklist去比較,就很難在主觀意見上互相認同。node

但JavaScript百分之一百是一門Object語言。程序員

這句話有兩個直接含義:shell

  1. 除了原始類型(primitive type)值以外,一切皆對象,包括函數;編程

  2. 一切對象都是構造出來的,有一個函數做爲它的構造函數(constructor);瀏覽器

JavaScript的另外一個標誌性特性是原型重用(prototype-based reuse),我在這裏故意避免使用繼承(inheritance)這個詞語,是不想讓讀者馬上聯想C++/Java語言的繼承,請忘記它們;網絡

JavaScript裏的對象並不是是Class的實例化,它沒有靜態結構的概念;固然這不意味這對象沒有結構,但對象的結構只能由構造函數在運行時構造出來,所以構造函數在JavaScript裏的地位是很高的,它是惟一負責結構的地方。數據結構

  1. 每一個對象都有一個原型,對象可使用和重載原型對象上的數據成員或方法,這是對象的惟一重用機制;閉包

介紹原型概念的文章和書不少,假定你理解原型的基本概念;這裏須要指出的問題是,對象之間的屬性重用,和麪向對象裏面說的重用是兩回事;編程語言

你能夠從重用的如此簡單的定義看出,它惟一的設計目的是想減小對象的數量,它提供的機制就是讓多個對象共享原型對象上的屬性,同時又能夠有重載能力;函數

但不要對此浮想連篇,它和Java語言裏經過繼承重用靜態結構和行爲是徹底兩回事,即便說「JavaScript的原型化重用僅僅是行爲重用,而Java的重用是結構和行爲的雙重重用」,這樣的表述也沒有意義,由於前者在運行時對象之間發生後者在靜態編譯時發生,一個在說咱們發明了活字印刷術讓印刷變得更容易,另外一個在說咱們發明了電腦上的字體,你須要顯示哪一個字就來到我這裏拿;雖然結果有時看起來很像,可是機制上徹底風馬牛不相及,不要上了阮一峯老師的當。

前面寫的這三條,能夠做爲構造JavaScript對象系統的三個基礎假設;

在JavaScript裏最最底層的概念,並不是你在如何使用JavaScript語言的那些教材中看到的種種編程概念,而是兩個詞語:構造原型(或者說結構與重用)。

每一個對象必有構造函數和原型,整個JavaScript系統裏你看到的全部東西,均可以在概念或模型上這樣去理解,雖然實現上是另外一回事。

JavaScript對運行環境(runtime)的假設只有一個,就是單線程事件模型,其餘關於虛擬機該怎樣實現並沒有定義,也沒有bytecode的定義;ECMA262採用了一種相似僞碼的方式定義了對對象、屬性、函數的基本操做邏輯,全部實現,解釋器也好,JIT也好,不管如何執行JavaScript腳本,只要保證語義一致便可;其實這種僞碼定義方式自己,就暗示了某種特性,但咱們暫且不表。

單線程的事件模型不是萬能的,但絕大多數狀況下讓編程變得簡單;缺少runtime定義使得這門語言並不實用,開發者老是須要完整的東西,但好在JavaScript自誕生起就有了第一個runtime:網絡瀏覽器,這讓它有了立錐之地,以後又出現Node.js,它又找到一個能夠生存的地方。

扯遠了,咱們說回構造和原型的問題。

創世紀

假現在天咱們冒充上帝,開始構造JavaScript的對象世界,在這個世界裏沒有什麼不是對象,也遵循前述原則;

咱們開始犯愁的第一個問題,彷佛咱們掉進了雞生蛋蛋生雞的邏輯怪圈。

對吧,第一個對象造不出來,由於對象須要構造函數構造,而函數也是對象,因此咱們前面說的那個對象必然不是第一個對象。

固然邏輯是邏輯,咱們能夠先捏幾個最原始的對象出來,而後把constructor__proto__引用裝載上去,讓它們成爲系統最初的亞當和夏娃。反正上帝原本也回答不了亞當的媽是誰的問題,咱們也這麼作。

最初在ECMA262裏並無約定JavaScript實現必須提供能訪問每一個對象的原型對象的方法,它只是一個概念;可是node/v8和js shell都提供了__proto__這個名字的屬性,能夠給出任何對象的原型;另外一個方法是使用Object.getPrototypeOf方法。

注意__proto__和function對象的prototype屬性是兩回事,prototype是function對象的特有屬性(就像Array對象有length這個特有屬性),__proto__纔是對象的原型;下面的描述和代碼裏都使用__proto__這個很彆扭的名字指對象的原型,它沒歧義,和代碼一致,再發明一個名字只會製造更多的混亂。

如今打開node shell。

> let m = {}
undefined
> m.__proto__
{}
> m.__proto__ === m
false

咱們建立了一個空對象,叫作m,它的原型也是一個空對象,雖然同爲空對象可是它們並不是一個對象,因此並不相等;

> m.__proto__.__proto__
null
> let op = m.__proto__
undefined

再沿着原型鏈往上爬,看看原型的原型是誰?沒了。這很好,咱們知道m的原型沒有原型了,咱們先把m的原型叫作op

誰構造的op呢?

> op.constructor
[Function: Object]
> op.constructor === Object
true

op的構造函數是全局那個叫Object的對象,它自己是一個函數;不要把Object理解成namespace,或者把Object對象上的方法理解爲「靜態方法」,Object就是一個對象,它被賦值給了全局對象的Object屬性,雖然它有特別的功能,可是要把它理解成咱們正在構造的對象世界中的一員,它只是在對象世界開天闢地時被構造好了而已,而咱們在討論的就是這個構造的過程。

咱們已經回答了op的構造函數和原型都是誰的問題,如今牽扯出來一個Object,咱們繼續檢查;

> Object.constructor
[Function: Function]
> Object.constructor === Function
true
> Object.__proto__
[Function]

Object的構造函數是全局對象上屬性叫Function的對象;Object的原型是個匿名函數,按照JavaScript關於構造函數的約定,它應該是構造函數的prototype屬性:

> Object.__proto__ === Function.prototype
true
> let fp = Function.prototype
undefined

咱們給這個對象起個名字,叫fp。

> fp
[Function]
> fp.constructor
[Function: Function]
> fp.constructor === Function
true
> fp.__proto__
{}
> fp.__proto__.__proto__
null
> fp.__proto__ === op
true

這個fp也不是很麻煩,咱們發現它是一個匿名函數,它的構造函數是Function,而它的原型是op

最後來看Function

> Function.constructor
[Function: Function]
> Function.__proto__
[Function]
> Function.__proto__ === fp
true

Function本身耍了一個賴皮,本身是本身的構造函數因此解決了雞和蛋的問題。Function的原型和prototype屬性指向了同一個對象fp

因此到此爲止呢,咱們扒開了JavaScript世界裏最原始的幾個對象,他們的原型關係是:

Function and Object -> fp -> op -> null

至於構造函數呢,由於Object是function,它的prototype是op,按照JavaScript的約定:function對象的prototype屬性指向的對象應該把constructor屬性設置成該function對象,即:

functionObject.prototype.constructor = functionObject

一樣的道理,Function的prototype是fpfp的constructor也要設置成Function

這是JavaScript裏最基礎的四個對象;其餘的一切對象,在模型和概念中均可以構造出來;

若是你在寫一個解釋器,你在最初就要把這些東西創造出來,而後創造一個global對象(或者叫context),在這個對象上裝上ObjectFunction,讓他們成爲全局對象,至於opfp,就讓他們藏在裏面好了;編程中沒有須要用到他們的地方,若是要找到他們,能夠用Object.prototype或者Function.prototype來找到。

因此到此爲止,咱們啓動了JavaScript的對象世界,有了Function咱們就能夠構造函數對象,有了函數咱們就能夠構造更多的對象,若是語言上容許(即不須要經過native code實現特殊功能),咱們能夠繼續建立Object.prototypeFunction.prototype上的那些函數對象並把他們裝載上去,在概念模型上,內置對象沒有什麼了不得,他們仍然能夠被理解成被構造出來的對象;

事實上全部的函數做用域和函數內的變量也能夠被理解成對象和它的屬性,在本文的結尾咱們會談這個問題,固然它只是模型上的;

咱們闡述了一切皆對象的含義;這個對象模型夠簡單嗎?我認爲是的;它只有對象,函數,原型三個概念。

一些人說JavaScript是Lisp穿了馬甲,從對象模型上是能夠成立的;由於Lisp裏的數據結構是List,它是一個鏈表,每一個節點有兩個slot,一個用於裝載值,另外一個裝載next;而JavaScript對象其實也是鏈表,只不過它給每一個節點增長了一個字符串標籤,即所謂的property name;但若是你用for ... in語法遍歷對象內部的時候,你仍然能看到內部結構的順序是穩定的,仍然是鏈表;

給每一個節點加上label是JavaScript設計上很是聰明的地方,由於它讓文科生也能夠參與如火如荼的編程活動。

可是這個對象模型說完了好像什麼也沒有說?怎麼JavaScript書上講的那麼多概念都沒有提到呢?

這是問題的本質,也是不少Java過來的程序員很費勁的地方;JavaScript利用上述的這個很是簡單的對象模型,去模擬,或者說實現,其餘全部的編程概念。

JavaScript最初的設計目的只是用於很是簡單的一些小功能,須要可編程;無論Brenden Eich是天才、拙劣、仍是巧合的模仿了Lisp,以及Smalltalk和Self,他把兩個很是簡單且獨一無二的事情結合在了一塊兒:

Lisp是λ Calculus在編程語言上的直接實現;原型重用的意思則是:

JavaScript:讓咱們消滅必須用靜態定義約定動態對象結構的作法吧,編程君!任何靜態能定義出來的結構,咱們在運行時也能夠經過不斷的複製得到啊,只是會慢一點點而已。
編程君:內存不夠怎麼辦?
JavaScript:咱們有原型啊!
編程君:好吧,但你要請我吃冰激凌。

不談工程實現,僅僅在概念和模型上紙上談兵的話,JavaScript語言模型之簡單,是不少老牌語言和新興腳本語言都難以企及的,它很是純粹。

函數對象與構造函數

在談構造函數以前咱們先看一段代碼:

// 構造對象的方式1
const factory = (a, b) => {

  return {
    a: a,
    b: b,
    sum: function() {
      return this.a + this.b
    }
  }
}

return語句後面返回的對象,被稱爲ex nihilo對象,拉丁語,out of nothing的意思,即這個對象沒有用一個專門的構造函數去構造,而是用那個全局的Object去構造了。

若是你僅僅是想建立具備一樣結構的對象實現功能,這樣的工廠方法足夠了。可是這樣寫,一方面,重用不方便;另外一方面,若是我只構造幾十個這樣對象,可能不是什麼大問題,可是若是要構造一百萬個呢?構造一百萬個會引起什麼問題?

讓咱們來從新強調對象的另外一個含義:對象是有生命週期的;由於函數也是對象,因此函數對象也不例外;這一點是JavaScript和Java的巨大差別,後者的函數,本質上是靜態存在的,或者說和程序的生命週期一致。但JavaScript裏的函數對象並不是如此。

前面的sum屬性對應的匿名函數對象,它是何時建立呢?在return語句觸發Object構造的時候。若是要建立一百萬個對象呢?這個函數對象也會被建立一百萬次,產生一百萬個函數對象實例!

換句話說,這個工廠方法建立的一百萬個對象不只狀態各有一份,方法也各有一份,前者是咱們的意圖,但後者是巨大的負擔,雖然運行環境不會真的蠢到去把代碼複製一百萬份,但函數對象確實存在那麼多,對象再小也有基礎的內存消耗,數量多時內存消耗無論怎樣都會可觀的,若是對象具備不僅一個函數,那浪費就更可觀了。

這是JavaScript的一切皆對象,包括函數也是對象的代價。

遇到這樣的問題通常有兩種辦法,一種是修改機制,即前面說的模型,引入新的概念;另外一種是加入策略,即在語言實現層面增長約定,可是利用現有機制,不增長概念;

JavaScript的設計者選擇了後者,這也是JavaScript的看似古怪的構造函數的由來。

設計者說能夠這樣來解決問題:若是一個函數對象的目的是構造其餘對象(即構造函數),它須要一個對象做爲它的合做者,裝載全部被構造的對象的公用函數,二者之間的聯繫這樣創建:

  1. 構造函數對象須要具備一個名稱爲prototype的屬性,指向公用函數容器對象;

  2. 公用函數容器對象須要具備一個名稱爲constructor的屬性,指向構造函數對象;

這個公用函數容器對象在建立function對象的時候,若是不是arrow function,它自動就有prototype屬性,指向一個空對象;若是是arrow函數,沒有這個屬性,arrow函數也不能夠和new一塊兒使用;

> function x() {}
undefined
> x.prototype
x {}
> const y = () => {}
undefined
> y.prototype
undefined
>

當調用構造函數時,經過使用new關鍵字明確表示要構造對象,這時函數的工做方式變了:

  1. 先建立一個空對象N,把它的原型__proto__設置成該構造函數對象的prototype屬性;

  2. 把N的constructor屬性設置爲構造函數對象;

  3. 把N bind成構造函數的this;

  4. 運行構造函數;

  5. 返回新對象N,無論構造函數返回了什麼;

new被定義成關鍵字是爲了兼容其餘語言使用者的習慣,寫成函數也同樣:

function NEW(constructor, ...args) {
  let obj = Object.create(constructor.prototype)
  obj.construtor = constructor
  constructor.bind(obj)(...args)
  return obj
}

另外一個關鍵字instanceof,則反過來工做,若是表達式是A instanceof B,若是不考慮繼承問題,就去判斷A.constructor === B便可;繼承的問題後面討論。

理解了這個過程就會明白,JavaScript裏的構造函數問題,其實並不是在發明構造函數的新語法,而是保持語言模型不變,讓他可以構造共享原型的對象的一種方式。

這就是爲何在ES5語法裏看到的構造函數和它的原型的代碼是相似這樣的:

function X(name) {  this.name = name }

X.prototype.hello = function() { console.log('hello ' + this.name) }

var x1 = new X('alice')
x1.hello()

var x2 = new X('bob')
x2.hello()

但即便須要這樣作,上面的寫法也不是惟一的寫法,也能夠這樣直接寫工廠方法:

let methods = {
  hello: function() {
    console.log('hello' + this.name)
  }
}

function createX(name) {
  let obj = Object.create(Object.assign({}, methods)) // 使用Object.assign能夠merge多個methods
  obj.name = name
  return obj
}

一樣實現構造共享原型的對象,只是返回的對象不具備constructor屬性,instanceof無法用,但若是你不須要instanceof,也不須要設計多層的繼承,這是可用的方法;

總結一下關於構造函數的這一節;

首先JavaScript在定義函數時,並不區分這個函數是否是構造函數,是不是構造函數取決於你是否使用new調用;

其次,若是一個函數是構造函數,它不是一我的在戰鬥,它須要和它的prototype屬性指向的對象合做,該對象將是構造的對象的原型,請把兩個對象而不是一個對象印在腦子裏,這對後面理解繼承很是關鍵;

第三,和Java裏那種數據成員和方法成員在心理上位於一個對象容器內不一樣,JavaScript的對象在設計上就要理解爲數據(或者狀態)在本身身上,方法(函數對象)在原型身上,這仍然是兩個對象在合做,表現得象一個對象

繼承

JavaScript裏的繼承仍然不是語言特性,在這個問題上咱們繼續沿用前面的思路:用JavaScript的原型重用能力,去模擬,或者說實現Java語言裏的繼承形式

咱們先說思路,假想咱們就是Brenden Eich幾分鐘。

假如咱們已經用構造共享原型的對象的思路,寫了一個構造函數BaseConstructor,它負責建立每一個對象的數據或狀態屬性,也有了一個合做者BaseConstructor.prototype,它提供了方法BaseMethod1, ...;如今咱們須要拓展它,要增長一部分狀態或者屬性,也要增長一部分方法,咱們該怎麼作?

首先咱們考慮拓展方法,這不難,若是咱們構建一個對象,把它的原型設置爲BaseConstructor.prototype,而後在新對象裏添加方法便可;

其次咱們將來須要使用的對象應該都以該對象爲原型,由於原有方法和擴展方法都能經過它訪問;這預示了咱們須要一個新的構造函數以該對象做爲prototype屬性;邏輯上能夠是這樣:

Base     <-> Base.prototype
  ^            ^
  |            *
  | call       * __proto__
  |            *
Extended <-> Extended.prototype

Extended函數能夠建立Extended.prototype裏擴展方法所須要的狀態或數據成員;可是Base.prototype裏須要的狀態或者數據成員須要Base來建立,咱們確定不但願把Base裏的代碼複製一份到Extended內;咱們須要調用它來建立原有方法所需的狀態或數據成員。

function Base(name) { this.name = name}
Base.prototype.printName = function() { console.log(this.name) }

function Extended(name, age) {
  Base.bind(this)(name)
  this.age = age
}
Extended.prototype = Object.create(Base.prototype)
Extended.prototype.constructor = Extended
Extended.prototype.printAge = function() { console.log(this.age) }

這裏tricky的地方有幾處:

第一,在Extended函數內,先把this bind到Base構造函數上,而後提供name參數調用它,這樣this就會具備printName所需的name屬性,實現結構繼承;

第二,咱們使用Object.create方法建立了一個以Base.prototype爲原型的新對象,把它設置爲Extended.prototype,實現行爲繼承;

第三,把Extended.prototype.constructor設置爲Extended構造函數,這樣咱們可使用instanceof語法糖;

最後咱們在Extended函數內建立新的狀態或數據屬性,咱們也在Extended.prototype上添加新的函數方法;

或者咱們說咱們找到了一種方式既拓展了構造函數構造的新對象的數據屬性,也拓展了它的函數屬性,沿着兩條鏈平行實施,達到了咱們的目的。

在JavaScript裏使用這種在原有構造函數及其prototype對象上拓展出一對新的構造函數和prototype對象的拓展方式,咱們稱之爲繼承。

由於對象能夠重載原型對象的屬性,因此在function.prototype的原型鏈上,重載函數的能力也具備了。

Class

JavaScript裏沒有type系統意義上的Class的概念。class關鍵字仍然是語法糖。

class A {

  constructor () { // 這是構造函數
  }

  method() { // 這是A.prototype上的方法
  }
}

這個語法比前面分開寫構造函數和prototype對象的寫法要簡潔乾淨不少,可是帶着Java的Class的概念試圖去理解它,更容易被誤導了。

A在這裏仍然是函數對象,只不過它只能當構造函數用,必須用new調用;其餘還有一些細節差別,不贅述了;

若是是繼承呢?

class Base {
  constructor() {}
  method1() {}
}

class Extended extends Base {
  constructor() {
    super()
    //...
  }
  method2() {}
}

也是大同小異;Extended構造函數內須要調用super()來實現調用Base構造函數構造屬性;這一句必須調用,不然沒有this,這是class語法和前面ES5語法的一個差別,在ES5語法內,新對象是在調用Extended構造函數時馬上建立的,在class語法中,這個對象是沿着super()向上爬到最頂層構造函數才建立的,因此若是不調用super就沒this了。

實際上在JavaScript裏的繼承,應該看成一種Pattern來理解,即:使用構造函數和它的prototype屬性對象合做來模擬傳統OO語言裏的繼承形式,把它叫作Inheritance Pattern恰當的多。

函數做用域

前面咱們曾冒充上帝,假想一個JavaScript程序啓動後,如何從零開始構造整個對象世界;如今咱們得寸進尺,冒充上帝他媽,考慮站在執行器的視角上,若是拿到一份JavaScript腳本如何執行;

假定咱們已經使用了底層語言,例如C/C++,實現了JavaScript的對象模型,即很容易建立對象,維護原型鏈。

咱們先建立一個空對象,把它稱爲global,先把標準的內置對象都做爲全局變量名稱裝載進去;而後開始運行。

JavaScript是個單線程模型,因此假定咱們用棧的方式來實現計算;基本操做符和表達式的棧計算就很少說了,咱們只說遇到函數怎麼辦。

通常來講遇到函數應該約定在棧上處理參數和返回值的方式,但這個可有可無,有關緊要的問題是咱們須要把傳統的Function Frame的概念,即對一個函數在棧上分配局部變量的概念,換個思惟,咱們不用Function Frame,而是建立一個空對象來表式一個Function Frame,咱們一行一行的讀入代碼,遇到局部變量聲明就在這個對象上裝上一個屬性,遇到修改局部變量的時候就給它賦值;

若是這樣作,咱們就能夠把Function Scope(通常說Function Scope指的是代碼層面的Lexical Scope,這裏咱們把Function Scope和Function Frame混用)做爲原型鏈串起來,詞法域中外圍的Function Scope是原型,內部的Function Scope是對象;這樣Function Scope的引用可能出如今棧上,但它自己並不是分配在棧上;Function Scope對象的建立是在調用函數時,它的銷燬咱們能夠暫時期望垃圾回收器,可回收的時間是該函數已經完成執行且沒有其餘Function Scope引用該Scope;

若是你仔細觀察在Function Scope構成的鏈上查找變量名(Identifier)的時候,其邏輯和在原型鏈上查找屬性的方式如出一轍;用這樣的方式也能夠準確找到閉包變量,惟一的區別是這裏須要小小的修改一下原型鏈的約定,原型上的屬性能夠直接修改,由於閉包變量是能夠賦值的;

這就是前面咱們說Function Scope也能夠看成是對象處理的緣由。

你能夠想象出來這個解釋器能夠寫得多小和多簡單,並且若是沒有hoisting,它能夠在源文件還沒下載完就開始投入運行,而不是一開始就把整個語法樹都解析出來;

若是你問爲何早期的JavaScript的var沒有block scope支持,由於block scope按照這種思路來講,須要爲block scope單首創建對象。

因此在這個討論裏,你能對JavaScript最初呱呱墜地時的一些小想法得到一些感覺;它從一開始只想用一個使人震驚的簡單的方法作幾件簡單的小事情,好比賺一個億,但這並不說明它無能,相反,在數學和編程的世界裏,越是簡單的事情越有無窮無盡的能量。

寫到這裏,我想我說完了本身對JavaScript的一切皆對象的認知,歡迎探討。

最後鳴謝少婦白潔願意出如今本文題目中。

相關文章
相關標籤/搜索