Lab 14-2: Proper Inheritance

 
實習目標 課堂中舉了一個 Shape-Point-Circle-Cylinder 的不適當繼承的範例, 請綜合運用 繼承 (inheritance) 與 組合 (composition) 來設計較好的程式架構
 
步驟一 不適當繼承的範例如下:
    class Shape
    {
    public:
        virtual float area() const { return 0.0; }
        virtual float volume() const { return 0.0; }
    };
    
    class Point: public Shape
    {
    public:
        Point(float x=0, float y=0, float z=0) 
        { this->x = x; this->y = y; this->z = z; }
        float getX() const { return x; }
        float getY() const { return y; }
        float getZ() const { return z; }
    private:
        float x, y, z;
    };
    
    class Circle: public Point
    {
    public:
        Circle(float r=0., float x=0., float y=0., float z=0.)
            :Point(x,y,z),radius(r){}
        float area() const { return 3.141159*radius*radius; }
        float getRadius() const { return radius; }
    private:
        float radius;
    };
    
    class Cylinder: public Circle
    {
    public:
        Cylinder(float h=0., float r=0., 
            float x=0., float y=0., float z=0.):
            Circle(r,x,y,z), height(h) {}
        float area() const 
        { return 2*Circle::area()+height*2*3.14159*getRadius(); }
        float volume() const { return Circle::area()*height; }
    private:
        float height;
    };
    
    void main()
    {
        Point point1(1,2,3), point2(4,5,6);
        Circle circle(5, 2,0,-2);
        Cylinder cylinder1(5, 3, 0,0,0), cylinder2(4, 2, 1,1,1);
        Shape *shapes[] = {&point1, &point2, &circle, 
                           &cylinder1, &cylinder2};
        
        int i;
        for (i=0; i<5; i++)
        {
            shapes[i]->display(cout);
            cout << endl;
            cout << "   area:" << shapes[i]->area();
            cout << "   volume:" << shapes[i]->volume() << endl;
        }
    }

上面程式碼中類別的階層如下圖:

這樣子的類別階層設計, 最主要的錯誤在於子類別的物件和父類別的物件並沒有 IS-A 的關係, 實務上是用衍生類別的物件能不能取代 (substitute) 基礎類別的物件來判斷 (LSP, Liskov Substitution Principle):

例如考量一個 Cylinder 物件能不能當成一個 Circle 物件? 比方說 Circle 物件在客戶端程式裡也許用來描述一個圓形紙片, 或是根本就描述一個圓柱型物件的上底, 當傳進一個 Cylinder 物件時, 如何把 Cylinder 物件當成是另一個圓柱型物件的上底???

另外你也可以運用可取代性檢查一個 Cylinder 物件能不能當成一個 Point 物件? 如果 Point 物件在客戶端程式中用來描述一個多邊形的角, 當傳進一個 Cylinder 物件時, 如何把 Cylinder 物件當成多邊形的一個角?

在簡單的測試程式中, 你沒有看到這些類別的客戶程式, 也許不會造成什麼錯誤, 但是你用物件導向來設計的時候就是為了程式的規模可以不斷地擴大, 將來如果擴充程式的功能時, 就會因為這種不適當繼承想要重用幾個類別的實作, 而導致各別類別的客戶端發生錯誤, 不適當的 (improper) 繼承一定會因小失大。

所以上面的應用不應該運用繼承的語法來重用相關的程式碼。

步驟二 我們仔細審查所要製作的這幾個類別: Point, Circle, Cylinder, 希望重新組合這幾個物件的設計, 運用比較多的重用機制來避免程式碼的重複, 但是又不至於錯用繼承的機制。

我們發現 Circle 物件中應該可以用到一個 Point 物件來表達圓心所在, Cylinder 物件中應該可以用兩個 Circle 物件來表達上底及下底兩個圓。 另外如果希望在程式中以一致的方法來處理和儲存這些圖形物件的話, 我們可以將共通的資料與界面抽出來成為 Shape 抽象類別, 如下圖所示:

步驟三 請修改類別的定義來完成上圖的架構

首先 Shape 類別的界面除了保留 area() 及 volume() 之外, 再加上 display(), 為了後續可以運用多型指標或是多型參考來操作 Shape 衍生類別的物件, 這些界面函式都宣告為虛擬函式 (virtual function), 同時因為要讓這個類別成為抽象類別 (其中包含共同界面但是不讓它生成物件), 所以這些虛擬函式的後面加上 =0 定義為純粹的虛擬函式 (pure virtual function):

    class Shape
    {
    public:
        virtual float area() const=0; 
        virtual float volume() const=0;
        virtual void display(ostream &os) const=0;
    };
雖然 area() 及 volume() 是純粹虛擬函式, 在 Shape.cpp 中我們還是可以實作 Shape::area() 以及 Shape::volume() 來容納共通的程式碼, 例如:
    float Shape::area() const 
    { 
        return 0.0;
    }
如果衍生類別需要的話也可以經由 Shape::area() 呼叫這個實作。
步驟四 其次我們實作 Point, Circle, 和 Cylinder 三個類別, 由於在 Shape 類別中定義為純粹虛擬函式, 所以衍生類別中需要完整實作 area(), volume() 和 display() 三個函式, 客戶端才能夠產生衍生類別的實體物件。

各個類別運用其它類別中的一部份程式碼, 並且使用委託的機制來取代步驟一中完全運用繼承的重用方式。 例如: Circle 中定義 Point 物件 m_center, Circle 的建構元函式和 display() 函式如下:

    Circle::Circle(float r, float x, float y, float z)
        :m_center(x,y,z), m_radius(r)
    {
    }
    
    void Circle::display(ostream &os) const
    {
        os << "Circle: radius=" << m_radius << " center:";
        m_center.display(os);
    }
步驟五 請用下面的異質容器程式碼來測試相關的功能:
    #include <iostream>
    using namespace std;
    
    #include "Point.h"
    #include "Circle.h"
    #include "Cylinder.h"
    
    void main()
    {
        Point point1(1,2,3), point2(4,5,6);
        Circle circle(5, 2,0,-2);
        Cylinder cylinder1(5, 3, 0,0,0), cylinder2(4, 2, 1,1,1);
        Shape *shapes[] = {&point1, &point2, &circle, &cylinder1, &cylinder2};
        
        int i;
        for (i=0; i<5; i++)
        {
            shapes[i]->display(cout);
            cout << endl;
            cout << "   area:" << shapes[i]->area();
            cout << "   volume:" << shapes[i]->volume() << endl;
        }
    }
範例執行程式

執行結果範例:

Point: [1,2,3]
   area:0   volume:0
Point: [4,5,6]
   area:0   volume:0
Circle: radius=5 center:Point: [2,0,-2]
   area:78.529   volume:0
Cylinder: height=5 topCircle:Circle: radius=3 center:Point: [0,0,0] 
                   bottomCircle:Circle: radius=3 center:Point: [0,0,5]
   area:150.789   volume:141.352
Cylinder: height=4 topCircle:Circle: radius=2 center:Point: [1,1,1] 
                   bottomCircle:Circle: radius=2 center:Point: [1,1,5]
   area:75.3947   volume:50.2585
步驟六 請助教檢查後, 將所完成的 專案 (只需保留 .cpp, .h, .sln 以及 .vcxproj 檔案即可; 刪除掉 .suo, .sdf, .filters, .users, debug\ 資料匣, 以及 ipch\ 資料匣下的所有內容) 壓縮起來, 選擇 Lab14-2 上傳, 後面的實習課程可能需要使用這裡所完成的程式
後續

假如你設計的系統中已經有一個如下的 Rectangle 類別描述長方形:

class Rectangle
{
public:
    void setWidth(double w);
    void setHeight(double h);
private:
    double m_width, m_height;
};

現在需要增加一個 Square 類別來描述正方形, 請問如果讓 Square 類別繼承 Rectangle 類別, 如何維持 Square 類別中長與寬相等的特性, 另外請問這樣的設計有沒有違反 Liskov Substitution Principle, 請舉例說明

C++ 物件導向程式設計課程 首頁

製作日期: 05/31/2004 by 丁培毅 (Pei-yih Ting)
E-mail: pyting@mail.ntou.edu.tw TEL: 02 24622192x6615
海洋大學 電機資訊學院 資訊工程學系 Lagoon