js:物件導向程式設計,帶你認識封裝、繼承和多型

sunshine小小倩發表於2017-06-08

本文首發於我的個人網站:cherryblog.site

週末的時候深入的瞭解了下javascript的物件導向程式設計思想,收穫頗豐,感覺對物件導向程式設計有了那麼一丟丟的瞭解了~很開森

什麼是物件導向程式設計

生動描述物件導向概念
生動描述物件導向概念

先上一張圖,可以對物件導向有一個大致的瞭解,然而什麼是物件導向呢,用java中的一句經典語句來說就是:萬事萬物皆物件。物件導向的思想主要是以物件為主,將一個問題抽象出具體的物件,並且將抽象出來的物件和物件的屬性和方法封裝成一個類。

物件導向是把構成問題事務分解成各個物件,建立物件的目的不是為了完成一個步驟,而是為了描敘某個事物在整個解決問題的步驟中的行為。

物件導向和麵向過程的區別

物件導向和麵向過程是兩種不同的程式設計思想,我們經常會聽到兩者的比較,剛開始程式設計的時候,大部分應該都是使用的程式導向的程式設計,但是隨著我們的成長,還是物件導向的程式設計思想比較好一點~
其實物件導向和麵向過程並不是完全相對的,也並不是完全獨立的。
我認為物件導向和麵向過程的主要區別是程式導向主要是以動詞為主,解決問題的方式是按照順序一步一步呼叫不同的函式。
而物件導向主要是以名詞為主,將問題抽象出具體的物件,而這個物件有自己的屬性和方法,在解決問題的時候是將不同的物件組合在一起使用。
所以說物件導向的好處就是可擴充套件性更強一些,解決了程式碼重用性的問題。

  • 程式導向就是分析出解決問題所需要的步驟,然後用函式把這些步驟一步一步實現,使用的時候一個一個依次呼叫就可以了。
  • 物件導向是把構成問題事務分解成各個物件,建立物件的目的不是為了完成一個步驟,而是為了描敘某個事物在整個解決問題的步驟中的行為。

有一個知乎的高票回答很有意思,給大家分享一下~

物件導向: 狗.吃(屎)
程式導向: 吃.(狗,屎)

具體的實現我們看一下最經典的“把大象放冰箱”這個問題

程式導向的解決方法

在程式導向的程式設計方式中實現“把大象放冰箱”這個問題答案是耳熟能詳的,一共分三步:

  1. 開門(冰箱);
  2. 裝進(冰箱,大象);
  3. 關門(冰箱)。

    物件導向的解決方法

  4. 冰箱.開門()
  5. 冰箱.裝進(大象)
  6. 冰箱.關門()

可以看出來物件導向和麵向過程的側重點是不同的,程式導向是以動詞為主,完成一個事件就是將不同的動作函式按順序呼叫。
物件導向是以主謂為主。將主謂看成一個一個的物件,然後物件有自己的屬性和方法。比如說,冰箱有自己的id屬性,有開門的方法。然後就可以直接呼叫冰箱的開門方法給其傳入一個引數大象就可以了。
簡單的例子物件導向和麵向過程的好處還不是很明顯。

五子棋例子

下面是一個我認為比較能夠說明兩者區別的一個栗子~:
例如五子棋,程式導向的設計思路就是首先分析問題的步驟:

  1. 開始遊戲
  2. 黑子先走
  3. 繪製畫面
  4. 判斷輸贏
  5. 輪到白子
  6. 繪製畫面
  7. 判斷輸贏
  8. 返回步驟2

把上面每個步驟用分別的函式來實現,問題就解決了。

而物件導向的設計則是從另外的思路來解決問題。整個五子棋可以分為

  1. 黑白雙方,這兩方的行為是一模一樣的
  2. 棋盤系統,負責繪製畫面

第一類物件(玩家物件)負責接受使用者輸入,並告知第二類物件(棋盤物件)棋子佈局的變化,棋盤物件接收到了棋子的i變化就要負責在螢幕上面顯示出這種變化,同時利用第三類物件(規則系統)來對棋局進行判定。

可以明顯地看出,物件導向是以功能來劃分問題,而不是步驟。同樣是繪製棋局,這樣的行為在程式導向的設計中分散在了總多步驟中,很可能出現不同的繪製版本,因為通常設計人員會考慮到實際情況進行各種各樣的簡化。而物件導向的設計中,繪圖只可能在棋盤物件中出現,從而保證了繪圖的統一。

功能上的統一保證了物件導向設計的可擴充套件性。比如我要加入悔棋的功能,如果要改動程式導向的設計,那麼從輸入到判斷到顯示這一連串的步驟都要改動,甚至步驟之間的循序都要進行大規模調整。如果是物件導向的話,只用改動棋盤物件就行了,棋盤系統儲存了黑白雙方的棋譜,簡單回溯就可以了,而顯示和規則判斷則不用顧及,同時整個對物件功能的呼叫順序都沒有變化,改動只是區域性的。

再比如我要把這個五子棋遊戲改為圍棋遊戲,如果你是程式導向設計,那麼五子棋的規則就分佈在了你的程式的每一個角落,要改動還不如重寫。但是如果你當初就是物件導向的設計,那麼你只用改動規則物件就可以了,五子棋和圍棋的區別不就是規則嗎?(當然棋盤大小好像也不一樣,但是你會覺得這是一個難題嗎?直接在棋盤物件中進行一番小改動就可以了。)而下棋的大致步驟從物件導向的角度來看沒有任何變化。

當然,要達到改動只是區域性的需要設計的人有足夠的經驗,使用物件不能保證你的程式就是物件導向,初學者或者很蹩腳的程式設計師很可能以物件導向之虛而行程式導向之實,這樣設計出來的所謂物件導向的程式很難有良好的可移植性和可擴充套件性。

封裝

物件導向有三大特性,封裝、繼承和多型。對於ES5來說,沒有class的概念,並且由於js的函式級作用域(在函式內部的變數在函式外訪問不到),所以我們就可以模擬 class的概念,在es5中,類其實就是儲存了一個函式的變數,這個函式有自己的屬性和方法。將屬性和方法組成一個類的過程就是封裝。

封裝:把客觀事物封裝成抽象的類,隱藏屬性和方法的實現細節,僅對外公開介面。

通過建構函式新增

javascript提供了一個建構函式(Constructor)模式,用來在建立物件時初始化物件。
建構函式其實就是普通的函式,只不過有以下的特點

  • 首字母大寫(建議建構函式首字母大寫,即使用大駝峰命名,非建構函式首字母小寫)
  • 內部使用this
  • 使用 new生成例項

通過建構函式新增屬性和方法實際上也就是通過this新增的屬性和方法。因為this總是指向當前物件的,所以通過this新增的屬性和方法只在當前物件上新增,是該物件自身擁有的。所以我們例項化一個新物件的時候,this指向的屬性和方法都會得到相應的建立,也就是會在記憶體中複製一份,這樣就造成了記憶體的浪費。

function Cat(name,color){
        this.name = name;
        this.color = color;
        this.eat = function () {
            alert('吃老鼠')
        }
    }複製程式碼

生成例項:

var cat1 = new Cat('tom','red')複製程式碼

通過this定義的屬性和方法,我們例項化物件的時候都會重新複製一份

通過原型prototype

在類上通過 this的方式新增屬性和物件會導致記憶體浪費的問題,我們就考慮,有什麼方法可以讓例項化的類所使用的方法直接使用指標指向同一個方法。於是,就想到了原型的方式

Javascript規定,每一個建構函式都有一個prototype屬性,指向另一個物件。這個物件的所有屬性和方法,都會被建構函式的例項繼承。
也就是說,對於那些不變的屬性和方法,我們可以直接將其新增在類的prototype 物件上。

 function Cat(name,color){
    this.name = name;
    this.color = color;
  }
  Cat.prototype.type = "貓科動物";
  Cat.prototype.eat = function(){alert("吃老鼠")};複製程式碼

然後生成例項

var cat1 = new Cat("大毛","黃色");
  var cat2 = new Cat("二毛","黑色");
  alert(cat1.type); // 貓科動物
  cat1.eat(); // 吃老鼠複製程式碼

這時所有例項的type屬性和eat()方法,其實都是同一個記憶體地址,指向prototype物件,因此就提高了執行效率。

在類的外部通過.語法新增

我們還可以在類的外部通過. 語法進行新增,因為在例項化物件的時候,並不會執行到在類外部通過. 語法新增的屬性,所以例項化之後的物件是不能訪問到. 語法所新增的物件和屬性的,只能通過該類訪問。

三者的區別

通過建構函式、原型和. 語法三者都可以在類上新增屬性和方法。但是三者是有一定的區別的。
建構函式:通過this新增的屬性和方法總是指向當前物件的,所以在例項化的時候,通過this新增的屬性和方法都會在記憶體中複製一份,這樣就會造成記憶體的浪費。但是這樣建立的好處是即使改變了某一個物件的屬性或方法,不會影響其他的物件(因為每一個物件都是複製的一份)。
原型:通過原型繼承的方法並不是自身的,我們要在原型鏈上一層一層的查詢,這樣建立的好處是隻在記憶體中建立一次,例項化的物件都會指向這個prototype 物件,但是這樣做也有弊端,因為例項化的物件的原型都是指向同一記憶體地址,改動其中的一個物件的屬性可能會影響到其他的物件
. 語法:在類的外部通過. 語法建立的屬性和方法只會建立一次,但是這樣建立的例項化的物件是訪問不到的,只能通過類的自身訪問

javascript也有private public protected

對於java程式設計師來說private public protected這三個關鍵字應該是很熟悉的哈,但是在js中,並沒有類似於private public protected這樣的關鍵字,但是我們又希望我們定義的屬性和方法有一定的訪問限制,於是我們就可以模擬private public protected這些訪問許可權。
不熟悉java的小夥伴可能不太清楚private public protected概念(其他語言我也不清楚有沒有哈,但是應該都是類似的~),先來科普一下小知識點~

  • public:public表明該資料成員、成員函式是對所有使用者開放的,所有使用者都可以直接進行呼叫
  • private:private表示私有,私有的意思就是除了class自己之外,任何人都不可以直接使用,私有財產神聖不可侵犯嘛,即便是子女,朋友,都不可以使用。
  • protected:protected對於子女、朋友來說,就是public的,可以自由使用,沒有任何限制,而對於其他的外部class,protected就變成private。

js中的private

因為javascript函式級作用域的特性(在函式中定義的屬性和方法外界訪問不到),所以我們在函式內部直接定義的屬性和方法都是私有的。

js中的public

通過new關鍵詞例項化時,this定義的屬性和變數都會被複制一遍,所以通過this定義的屬性和方法就是公有的。
通過prototype建立的屬性在類的例項化之後類的例項化物件也是可以訪問到的,所以也是公有的。

js中的protected

在函式的內部,我們可以通過this定義的方法訪問到一些類的私有屬性和方法,在例項化的時候就可以初始化物件的一些屬性了。

new的實質

雖然很多人都已經瞭解了new的實質,那麼我還是要再說一下new 的實質
var o = new Object()

  1. 新建一個物件o
  2. o. __proto__ = Object.prototype 將新建立的物件的__proto__屬性指向建構函式的prototype
  3. 將this指向新建立的物件
  4. 返回新物件,但是這裡需要看建構函式有沒有返回值,如果建構函式的返回值為基本資料型別string,boolean,number,null,undefined,那麼就返回新物件,如果建構函式的返回值為物件型別,那麼就返回這個物件型別

栗子~

var Book = function (id, name, price) {
        //private(在函式內部定義,函式外部訪問不到,例項化之後例項化的物件訪問不到)
        var num = 1;
        var id = id;
        function checkId() {
            console.log('private')
        }
        //protected(可以訪問到函式內部的私有屬性和私有方法,在例項化之後就可以對例項化的類進行初始化拿到函式的私有屬性)
        this.getName = function () {
            console.log(id)
        }
        this.getPrice = function () {
            console.log(price)
        }

        //public(例項化的之後,例項化的物件就可以訪問到了~)
        this.name = name;
        this.copy = function () {
            console.log('this is public')
        }

    }

    //在Book的原型上新增的方法例項化之後可以被例項化物件繼承
    Book.prototype.proFunction = function () {
        console.log('this is proFunction')
    }

    //在函式外部通過.語法建立的屬性和方法,只能通過該類訪問,例項化物件訪問不到
    Book.setTime = function () {
        console.log('this is new time')
    }
    var book1 = new Book('111','悲慘世界','$99')
    book1.getName();        // 111 getName是protected,可以訪問到類的私有屬性,所以例項化之後也可以訪問到函式的私有屬性
    book1.checkId();        //報錯book1.checkId is not a function
    console.log(book1.id)   // undefined id是在函式內部通過定義的,是私有屬性,所以例項化物件訪問不到
    console.log(book1.name) //name 是通過this建立的,所以在例項化的時候會在book1中複製一遍name屬性,所以可以訪問到
    book1.copy()            //this is public
    book1.proFunction();    //this is proFunction
    Book.setTime();         //this is new time
    book1.setTime();        //報錯book1.setTime is not a function複製程式碼

繼承

繼承:子類可以使用父類的所有功能,並且對這些功能進行擴充套件。繼承的過程,就是從一般到特殊的過程。

其實繼承都是基於以上封裝方法的三個特性來實現的。

類式繼承

所謂的類式繼承就是使用的原型的方式,將方法新增在父類的原型上,然後子類的原型是父類的一個例項化物件。

//宣告父類
    var SuperClass = function () {
        var id = 1;
        this.name = ['javascript'];
        this.superValue = function () {
            console.log('superValue is true');
            console.log(id)
        }
    };

    //為父類新增共有方法
    SuperClass.prototype.getSuperValue = function () {
        return this.superValue();
    };

    //宣告子類
    var SubClass = function () {
        this.subValue = function () {
            console.log('this is subValue ')
        }
    };

    //繼承父類
    SubClass.prototype = new SuperClass() ;

    //為子類新增共有方法
    SubClass.prototype.getSubValue= function () {
        return this.subValue()
    };

    var sub = new SubClass();
    var sub2 =  new  SubClass();

    sub.getSuperValue();   //superValue is true
    sub.getSubValue();     //this is subValue

    console.log(sub.id);    //undefined
    console.log(sub.name);  //javascript

    sub.name.push('java');  //["javascript"]
    console.log(sub2.name)  //["javascript", "java"]複製程式碼

其中最核心的一句程式碼是SubClass.prototype = new SuperClass() ;
類的原型物件prototype物件的作用就是為類的原型新增共有方法的,但是類不能直接訪問這些方法,只有將類例項化之後,新建立的物件複製了父類建構函式中的屬性和方法,並將原型__proto__ 指向了父類的原型物件。這樣子類就可以訪問父類的publicprotected 的屬性和方法,同時,父類中的private 的屬性和方法不會被子類繼承。

敲黑板,如上述程式碼的最後一段,使用類繼承的方法,如果父類的建構函式中有引用型別,就會在子類中被所有例項共用,因此一個子類的例項如果更改了這個引用型別,就會影響到其他子類的例項。
提一個小問題~為什麼一個子類的例項如果更改了這個引用型別,就會影響到其他子類的例項呢,在javascript中,什麼是引用型別呢,引用型別和其他的型別又有什麼區別呢?

建構函式繼承

正式因為有了上述的缺點,才有了建構函式繼承,建構函式繼承的核心思想就是SuperClass.call(this,id),直接改變this的指向,使通過this建立的屬性和方法在子類中複製一份,因為是單獨複製的,所以各個例項化的子類互不影響。但是會造成記憶體浪費的問題

//建構函式繼承
    //宣告父類
    function SuperClass(id) {
        var name = 'javascript'
        this.books=['javascript','html','css'];
        this.id = id
    }

    //宣告父類原型方法
    SuperClass.prototype.showBooks = function () {
        console.log(this.books)
    }

    //宣告子類
    function SubClass(id) {
        SuperClass.call(this,id)
    }

    //建立第一個子類例項
    var subclass1 = new SubClass(10);
    var subclass2 = new SubClass(11);

    console.log(subclass1.books);
    console.log(subclass2.id);
    console.log(subclass1.name);   //undefined
    subclass2.showBooks();複製程式碼

組合式繼承

我們先來總結一下類繼承和建構函式繼承的優缺點

類繼承 建構函式繼承
核心思想 子類的原型是父類例項化的物件 SuperClass.call(this,id)
優點 子類例項化物件的屬性和方法都指向父類的原型 每個例項化的子類互不影響
缺點 子類之間可能會互相影響 記憶體浪費

所以組合式繼承就是汲取兩者的優點,即避免了記憶體浪費,又使得每個例項化的子類互不影響。

//組合式繼承
    //宣告父類
    var SuperClass = function (name) {
        this.name = name;
        this.books=['javascript','html','css']
    };
    //宣告父類原型上的方法
    SuperClass.prototype.showBooks = function () {
        console.log(this.books)
    };

    //宣告子類
    var SubClass = function (name) {
        SuperClass.call(this, name)

    };

    //子類繼承父類(鏈式繼承)
    SubClass.prototype = new SuperClass();

    //例項化子類
    var subclass1 = new SubClass('java');
    var subclass2 = new SubClass('php');
    subclass2.showBooks();
    subclass1.books.push('ios');    //["javascript", "html", "css"]
    console.log(subclass1.books);  //["javascript", "html", "css", "ios"]
    console.log(subclass2.books);   //["javascript", "html", "css"]複製程式碼

寄生組合繼承

那麼問題又來了~組合式繼承的方法固然好,但是會導致一個問題,父類的建構函式會被建立兩次(call()的時候一遍,new的時候又一遍),所以為了解決這個問題,又出現了寄生組合繼承。
剛剛問題的關鍵是父類的建構函式在類繼承和建構函式繼承的組合形式中被建立了兩遍,但是在類繼承中我們並不需要建立父類的建構函式,我們只是要子類繼承父類的原型即可。所以說我們先給父類的原型建立一個副本,然後修改子類constructor屬性,最後在設定子類的原型就可以了~

//原型式繼承
    //原型式繼承其實就是類式繼承的封裝,實現的功能是返回一個例項,改例項的原型繼承了傳入的o物件
    function inheritObject(o) {
        //宣告一個過渡函式物件
        function F() {}
        //過渡物件的原型繼承父物件
        F.prototype = o;
        //返回一個過渡物件的例項,該例項的原型繼承了父物件
        return new F();
    }
    //寄生式繼承
    //寄生式繼承就是對原型繼承的第二次封裝,使得子類的原型等於父類的原型。並且在第二次封裝的過程中對繼承的物件進行了擴充套件
    function inheritPrototype(subClass, superClass){
        //複製一份父類的原型儲存在變數中,使得p的原型等於父類的原型
        var p = inheritObject(superClass.prototype);
        //修正因為重寫子類原型導致子類constructor屬性被修改
        p.constructor = subClass;
        //設定子類的原型
        subClass.prototype = p;
    }
    //定義父類
    var SuperClass = function (name) {
        this.name = name;
        this.books = ['javascript','html','css']
    };
    //定義父類原型方法
    SuperClass.prototype.getBooks = function () {
        console.log(this.books)
    };

    //定義子類
    var SubClass = function (name) {
        SuperClass.call(this,name)
    }

    inheritPrototype(SubClass,SuperClass);

    var subclass1 = new SubClass('php')複製程式碼

相關文章