程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> Effective C++(20) 繼承與面向對象設計

Effective C++(20) 繼承與面向對象設計

編輯:C++入門知識

本文主要參考《Effective C++ 3rd》中的第六章部分章節的內容。

關注的問題集中在繼承、派生、virtual函數等。如:

  • virtual? non-virtual? pure virtual?
  • 缺省參數值與virtual函數有什麼交互影響?
  • 繼承如何影響C++的名稱查找規則?
  • 什麼情況下有比virtual更好的選擇?

這些都是我們將要從這一章裡學到的內容。


1 確定你的public繼承可以塑模出is-a關系

謹記public繼承的含義:

    如果class D以public形式繼承class B,則每一個類型D的對象同時也是一個類型B的對象,反之不成立。

    即,B比D表現出更一般化的概念,而D比B表現出更特殊的概念。

如:

class Person { ... };

class Student : public Person { ... };

這個體系告訴我們:每個學生都是人,但並非每個人都是學生。

從C++的角度來看,任何函數,如果期望獲得一個類型為Person(或指向Person對象的指針或引用),也都願意接受一個Student對象(或指針或引用)。

需要留意的一點是:

    以我們在生活中的直覺為基礎來塑模is-a關系有時是錯誤的,可以說犯了“經驗主義錯誤”。

如:

    class Square應該以public形式繼承class Rectangle嗎?

    即正方形是一個(is-a)矩形嗎?

至少我們在學校裡是這麼學到的:正方形是一個矩形,但是矩形不一定是正方形。

那麼我們來寫一些這個繼承

class Rectangle {

public:

    virtual void setHeight(int newHeight);

    virtual void setWidth(int newWidth);

    virtual int height() const;

    virtual int width() const;

    ......

};

void makeBigger (Rectangle& r) {

    int oldHeght = r.height();

    r.setWidth(r.width() + 10);

    assert( r.heght() == oldHeght );    // 判斷r的高度是否改變,永為真。

}

在這個矩形的基礎上派生出一個正方形

class Square : public Rectangle { ... };

Square s;
...
assert( s.width() == s.height() );
makeBigger(s);
assert( s.widht() == s.height() );

顯然makeBigger只改變矩形的寬度,而不改變矩形的長度。這和s是個正方形矛盾。

public所包含的含義為:能夠使用在base class對象身上的每件事,應該同樣可以使用在derived class對象身上。

由此可見,其他領域或者生活中,我們習得的直覺,在軟件領域並不總是正確的。

因此,除了is-a關系,我們還要更多地思考和在適當的場合使用has-a和is-implemented-in-terms-of(根據某物實現出)

小結:

“public繼承”意味著is-a。適用於base classes身上的每一件事情一定也適用於derived classes身上,每一個derived class對象也都是一個base class對象。


 

2 避免遮掩繼承而來的名稱

關鍵字:作用域。

先看一個簡單的例子:

int x;

void someFunc() {
    double x;
    std::cin >> x;
}

這個讀取數據的語句使用的是局部變量x,而不是全局變量x。因為內層作用域的名稱會遮掩外圍作用域的名稱。

加入繼承機制,有如下的代碼:

class Base {

private:

    int x;

public:

    virtual void mf1() = 0;

    virtual void mf2() ;

    void mf3();

    ....

};

class Derived : public Base {
public:

    virtual void mf1();

    void mf4();
    ....
};

mf4函數中有如下實現:

void Derived::mf4() {
    ...
    mf2();
    ...
}

編譯器的查找作用域順序:

local作用域--->class Derived覆蓋的作用域

---> class覆蓋的作用域(本例到這停止)

---> Base的那個namespace作用域

---> global作用域。

現在來為上面的兩個類加幾個成員函數:

class Base {

private:

    int x;

pubic:

    virtual void mf1() = 0;

    virtual void mf1( int );

    virtual void mf2() ;

    void mf3();

    viod mf3( double );

    ....

};

class Derived : public Base {

public:

    virtual void mf1();

    void mf3();

    void mf4();

    ....

};

這樣做會有什麼效果呢?

Derived d;

int x;

......

d.mf1();
d.mf1(x);    //error
d.mf2();
d.mf3();
d.mf3(x);    //error

由此可見,基於作用的名稱遮掩規則,並沒有因為重載函數而特殊處理,那些名字相同的重載函數同樣被遮掩掉了。

如果我們想在子類中繼承那些重載函數,並重寫其中的一部分(像本例中的mf1和mf3),那麼可以使用using語句

讓Base class內名為mf1和mf3的所有東西(所有重載函數)在Derived作用域內都是可見的。

class Base {

private:

    int x;

public:

    Base() {};

    virtual void mf1() = 0;

    virtual void mf1( int m ) { std::cout << "Base mf1 int: "<< m << std::endl; } ;

    virtual void mf2() { std::cout << "Base mf2 " << std::endl; };

    void mf3() { std::cout << "Base mf3" << std::endl;};

    void mf3( double m ) { std::cout << "Base mf3 double:" << m << std::endl; };

};

class Derived : public Base {

public:

    using Base::mf1;        // 讓Base class內名為mf1和mf3的所有東西(所有重載函數)

    using Base::mf3;        // 在Derived作用域內都是可見的。

    virtual void mf1() { std::cout << "Derived mf1" << std::endl; };

    void mf3() { std::cout << "Derived mf3" << std::endl; };

    void mf4() { std::cout << "Derived mf4" << std::endl; };

};

調用:

Derived* d = new Derived();

d->mf1();
d->mf1(1);
d->mf2();
d->mf3();
d->mf3(1);
d->mf4();

運行截圖:

class Base { public: virtual void mf1() = 0; virtual void mf1(int ); .... }; class Derived : private Base { public: virtual void mf1() { Base::mf1(); } // 轉交函數 ...... };

小結:

derived classes內的名稱會遮掩base classes內的所有相同名稱的重載函數,在public繼承下這個機制並不希望發揮作用。

可使用using聲明式或轉交函數來調用被遮掩的重載函數。


 

3 區分接口繼承和實現繼承

選擇繼承的集中情況:

  • a:希望derived classes只繼承成員函數的接口
  • b:希望derived classes同時繼承函數的接口和實現,又希望能夠重寫它們所繼承的實現
  • c:希望derived classes同時繼承函數的藉口和實現,並且不允許重寫任何東西

看一個幾何圖形例子:

class Shape {

public:

    virtual void draw() const = 0;

    virtual void error(const std::string& msg);

    int objectID() const;

};

class Rectangle: public Shape { ...... };

class Ellipse: public Shape { ...... };

首先考慮純虛函數draw

pure virtual函數有兩個最突出的特征:

  • 它們必須被任何“繼承了它們”的子類重新聲明
  • 它們在抽象class中通常沒有定義

綜合上面兩個特征:聲明一個純虛函數的目的是為了讓derived class只繼承函數接口

滿足了本節開頭的情景a。

考慮虛函數error。

虛函數的目的是讓derived classes繼承該函數的接口和缺省實現。滿足了情景b。

最後,考慮non-virtual函數objectID。

聲明non-virtual函數的目的是為了令derived classes繼承函數的接口及一份強制性實現。

對應了情景c。

純虛函數、虛函數和非虛函數使得你可以精確地指定你想要derived classes繼承的東西。

小結:

接口繼承和實現繼承不同。在public繼承之下,derived classes總是繼承base class的接口

pure virtual函數只具體指定接口繼承。

virtual 函數具體指定接口繼承及缺省實現繼承。

non-virtual函數具體指定接口繼承以及強制性實現繼承。


 

4 考慮virtual函數之外的選擇

考慮為游戲內的人物設計一個繼承體系。

class GameCharacter {
public:
    virtual int healthValue() const;    // 返回人物的健康指數。
    ......
};

有時候,常規的面向對象設計方法往往看起來是那麼的自然,以至於我們從未考慮其他的一些解法。

這一節就讓我們跳出常規設計的思維,考慮一些不那麼常規的設計方法。

方法1:借由non-virtual interface手法實現Template Method模式

class GameCharacter {

public:

    int healthValue() const  {
        ...
        int retVal = doHealthValue();
        ...
        return retVal;
    }

    ....

private:
    virtual int doHealthValue() const {
        ...
    }

};

讓客戶通過public non-virtual成員函數間接調用private virtual函數,稱為non-virtual interface(NVI)手法。

這個non-virtual函數(healthValue)稱為virtual函數的包裝器(wrapper)。

從程序執行的角度來看,derived classes重新定義了virtual函數,從而賦予它們“如何實現功能”的控制能力,base classes保留控制“函數何時被調用”的權利。

方法2:借由Function Pointer實現Strategy模式

代碼如下:

class GameCharacter:;

int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter {

public:

    typedef int ( *HealthCalcFunc ) ( const GameCharacter& );

    explicit GameCharacter( HealthCalcFunc hcf = defaultHealthCalc ) : healthFunc(hcf) { }

    int healthValue() const {
        return healthFunc(*this);
    }

    ...

private:
    HealthCalcFunc healthFunc;
};

還有其他的一些方法,在此並不一一討論,詳見《Effective C++》

 

5 絕不重新定義繼承而來的non-virtual函數

在子類中重定義繼承而來的non-virtual函數,會導致子類你的設計出現矛盾。

比如在class Base有一個non-virtual函數setBigger,而所有繼承Base的子類都可以執行變大的動作,那麼這個動作就是一個不變性(共性)。

而在class Derived : public Base子類中,重寫了setBigger函數,那麼class Derived便無法反映出“不變性凌駕於特性”的性質。

從另一方面說,如果setBigger操作真的需要在子類中重定義,那麼就不應該把它設定為一個共性(non-virtual)。

因此,重新定義繼承來的non-virtual函數可能並不會對你的程序的運行造成太大的困擾,但是正如上面提到的,這是設計上的矛盾,或者說缺陷。

 

6 絕不重新定義繼承而來的缺省參數值

本小節的討論局限於“繼承一個帶有缺省參數值的virtual函數”。

理由:virtual函數動態綁定,缺省參數值靜態綁定。

class Shape {

public:
    Shape() {};

    enum ShapeColor { Red = "red", Green = "green" , Blue = "blue"};

    virtual void draw(ShapeColor color = Red) const = 0
    {
        std::cout << "This shape is " << color << std::endl;
    }
};

class Rectangle : public Shape {
public:
    Rectangle() {};
    virtual void draw ( ShapeColor color = Green ) const;
};

class Circle : public Shape {
public:
    virtual void draw(ShapeColor color) const;
};

先考慮如下指針

Shape* ps;
Shape* pc = new Circle;
Shape* pr = new Rectangle;

ps、pc、pr的靜態類型都是Shape*

所謂動態類型就是“目前所指對象的類型”。也就是說動態類型可以表現出一個對象將會有什麼行為。

在本例中,ps沒有動態類型,pc的動態類型為Circle*,pr的動態類型為Rectangle*。

動態類型可以在程序執行過程中改變(通常是經由賦值動作)。如

ps = pc;    // ps的動態類型現在是Circle
ps = pr;    // ps的動態類型現在是Rectangle

上面是對動態綁定和靜態綁定的簡單復習。

現在,考慮帶有缺省參數值的virtual函數。

在上面的例子中,Shape中的draw函數的color默認參數是Red,而子類中的draw函數的color默認參數是Green。

Shape* shape = new Rectangle();
shape->draw();

根據動態綁定規則,上述代碼的輸出應該為:This shape is 1

但是運行代碼之後會發現,結果並不是我們想的那樣。

參考資料:

《Effective C++ 3rd》

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved