文章已寫完,因為文章太長,原訂第五部份:抽象類別,挪移到第四集的內容中,圖形的問題尚未解決,請見諒!

目錄:

一、形狀問題
二、經典的程式碼
三、還有其他寫法
四、不要只為了reuse而使用繼承
五、抽象類別:用共同的方法來操作不同的物件


引言:

  物件導向技術是以物件為中心在思考問題,先前的兩篇文章,探討了物件導向技術中的物件可以是什麼?以及如何決定要實作出哪些物件?接下來的文章,則要探討物件和物件之間的關係。

  物件和物件之間的關係有繼承、組合、聚合、使用等四種,哦!對了,還有另一種關係,叫做完全無關。關於這四種關係的差別,在每一本物件導向或UML的書中都會提到,但是物件導向就是這麼神秘,即使看盡了書中的詳細說明,當第一次上路的時候,還是會手足無措,不知如何把書中的知識應用上來,這就是理論與實務上的差距。

  物件導向世界中最經典案例為"形狀問題",本文將帶領讀者徹頭徹尾思索一遍形狀問題,提出六支不同的程式來解決同一個問題,並指出每支程式個別的優劣之處,以實際案例的程式碼,讓讀者體會實務面的考量會有哪些?


一、形狀問題

  事不宜遲,就讓我們直接來看看題目:

請寫一支程式在螢幕上畫出簡單幾何圖形,並計算各自的面積,這些幾何圖形包含:圓形、橢圓形、正方形、矩形。請問在不同形狀之間的繼承關係為何?

  我們提供了三種典型的答案,如下圖,請先看過,並從中選擇一個答案,然後在腦中思考為何會選擇這個答案,想清楚之後才繼續往下看文章。

形狀問題-誰繼承誰.GIF


答案一:圓形繼承橢圓形

選擇原因:依據真實世界的原則,衍生類別是一種(is a kind of)基礎類別

  橢圓有比較扁的橢圓,也有比較方的橢圓,而正圓是眾多橢圓中的一種,換句話說,正圓是一種(is a kind of)橢圓。

  橢圓有長徑與短徑,當長徑的長度與短徑相等的時候,這種橢圓就叫做正圓,而這個時候的長短徑統稱為半徑,正圓不過是橢圓的一種特殊情形,所以說,正圓亦是一個(is a)橢圓。

參考程式碼:

class Shape {
  public:
    virtual int area() {}
    virtual void display() {}
};

class Ellipse : public Shape {
  public:
    int radius1, radius2;
    Ellipse(int r1=0, int r2=0) : radius1(r1), radius2(r2) {}
    virtual int area() { return radius1 * radius2 * 3.1415926; }
    virtual void display() { ... }
};

class Circle : public Ellipse {
  public:
    int radius;
    Circle(int r=0) : radius(r) {}
    int area() { return radius * radius * 3.1415926; }
    void display() { ... }
};

  問題來了,首先是成員名字的問題:在橢圓裡的長徑與短徑,到了正圓中都變成了半徑,你所使用的程式語言是否有支援更換成員名字的語法?

  如果成員名字的問題無法解決,就會產生第二個問題:Ellipse與Circle各有一個方法叫做area(),那麼當Circle轉型為Ellipse之後,這個時候去叫用area()會叫用到Ellipse的area()還是Circle的area()?

class Circle : public Ellipse {
  public:
    /* 遮蓋Ellipse的radius1與radius2,並使其與radius連動 */
    Circle(int r=0) : Ellipse(r, r) {}
    int getRadius() { return radius1; }
    void setRadius(int r) { radius1 = radius2 = r; }
    /* 直接使用Ellipse的area()與display() */
};

  若想盡辦法要解決上述兩個問題,就可能會寫出類似getRadius()與setRadius()這般彆扭的程式碼來!況且即使如此,仍無法將Ellipse的radius1與radius2遮蓋掉,還是有可能不小心直接存取了radius1或radius2。




答案二:橢圓形繼承圓形

選擇原因:依據程式設計實務,衍生類別要比基礎類別還多了一些特徵

  圓形有一個半徑,橢圓形則有兩個半徑:長徑與短徑,可見得橢圓形比圓形多了一些特徵,因此依據程式設計實務,橢圓形應該要去繼承圓形,除了可以拿到圓形的特徵之外,還可以加上橢圓形特有的特徵。

參考程式碼:

class Shape {
  public:
    virtual int area() {}
    virtual void display() {}
};

class Circle : public Shape {
  public:
    int radius;
    Circle(int r=0) : radius(r) {}
    virtual int area() { return radius * radius * 3.1415926; }
    virtual void display() { ... }
};

class Ellipse : public Circle {
  public:
    int radius2;
    Ellipse(int r1=0, int r2=0) : Circle(r1), radius2(r2) {}
    int area() { return radius * radius2 * 3.1415926; }
    void display() { ... }
};

  畢竟是從實務上出發,使得所設計出的程式碼看似完美無瑕,但卻有一個邏輯上的誤謬之處:既然橢圓形繼承了圓形,橢圓形直接就拿到了圓形的所有特徵,那麼為何橢圓形要重寫area()方法,把基礎類別的特徵給覆蓋掉呢?

驚人的結論:

  為圖一時方便所寫出來的程式,有可能和真實世界的邏輯是相違背的,但是程式執行的結果仍然是正確的。

注意:

  答案二錯誤的應用了程式設計實務:衍生類別確實比基礎類別要多一些特徵,但在形狀問題中,衍生類別比基礎類別多出來的不是如半徑之類的實際特徵,而是多了一條但書,一項限制:長徑等於短徑。若以但書的概念來重新設計程式,竟會產出和答案一完全一致的程式碼,也同樣的會遇到名字問題。這個世界真奇妙,以不同的概念出發,竟會寫出一模一樣的程式碼!


答案三:互相不繼承


選擇原因:依據人類的直覺,井水不犯河水,避免一切麻煩事

  有兩種初學者會選擇互相不繼承,第一種是:沒有想太多,只是覺得圓形和橢圓形就是不一樣。第二種是:觀察入微,發現不管是誰繼承誰,都會惹來一身麻煩,並且想不到好方法去解決它,最後乾脆選擇互相不繼承,井水不犯河水,保持距離以策安全。

參考程式碼:

class Shape {
  public:
    virtual int area() {}
    virtual void display() {}
};

class Ellipse : public Shape {
  public:
    int radius1, radius2;
    Ellipse(int r1=0, int r2=0) : radius1(r1), radius2(r2) {}
    int area() { return radius1 * radius2 * 3.1415926; }
    void display() { ... }
};

class Circle : public Shape {
  public:
    int radius;
    Circle(int r=0) : radius(r) {}
    int area() { return radius * radius * 3.1415926; }
    void display() { ... }
};

  這支程式寫起來比先前的兩支要容易些,由於它將圓形與橢圓形視為完全無關的兩種形狀,藉此擺脫了幾何學的糾纏。程式碼本身沒有什麼學問,不需要再多做講解,但是各位需要在自己的心裡盤算一下:這麼做真的好嗎?以下的章節將會從不同的角度來解答這個問題。


二、經典的程式碼

  圓形和橢圓形有許多共同的特性,這使得答案三的兩個類別看起來十分神似,卻又有些微的差異存在,例如:兩個類別的area()方法幾乎長得一模一樣。

  假設未來的某一天,程式設計師想要更精確的計算出物件的面積,他有可能會以double的運算來取代int的運算,這時就要同時修改Ellipse和Circle的area()方法,而且兩次所修改的地方與修改方式將會一模一樣。而另一個情況是,如果計算面積的公式有錯誤,(這的確會發生,我先前就是把橢圓面積的公式背錯了,導致所有程式的執行結果都不正確,所幸即時發現並修正了這個錯誤),那麼Ellipse與Circle的area()方法都要修正,如果因為疏忽而只修改了其中之一,那麼錯誤仍然沒有完全修正掉。

  一個擁有良好設計的程式碼,須具備以下的特性:

一個擁有良好設計的程式碼,在進行維護修改時,同樣的修改會集中在一個地方,而不是將類似的修改重複應用到許多不同的地方。

  讓我們來看看,要如何修改答案三的程式碼,讓它具備上述的擁有良好設計程式碼的特性。
 


  所有初學者都知道:物件要對應到真實世界中。相信在練習形狀問題時,每個人都很努力的在思考真實世界中各種形狀之間的關係,偏偏大家的答案就是不一樣,答案一、二、三都有人選擇,甚至還可能會為自己所選的答案與別人爭論不休。但其實還有第四種選擇,如下圖,這是初學者所無法想像到的一種情況,您看了這張圖之後,是否在心理驚呼:「原來是這樣子啊!為什麼我沒有想到呢?」

圖二

答案四:圓形與橢圓形都繼承圓的形狀

選擇原因:依據精益求精的精神,集合各家的優點於一身

  系出同門的圓形與橢圓形擁有許多共同的特性,卻也擁有各自獨特的特性,故兩者互相不繼承,但它們全都繼承了圓的形狀。藉由繼承來消除重複的程式碼,同時又擁有各自的操作方式,(圓形有一個半徑,但橢圓形卻有兩個半徑)。

參考程式碼:

class Shape {
  public:
    virtual int area() {}
    virtual void display() {}
};

class RoundShape : public Shape {
  protected:
    int radius1, radius2;
  public:
    RoundShape(int r1=0, int r2=0) : radius1(r1), radius2(r2) {}
    int area() { return radius1 * radius2 * 3.1415926; }
    void display() { ... }
};

class Ellipse : public RoundShape {
  public:
    int getRadius1() { return radius1; }
    int getRadius2() { return radius2; }
    void setRadius1(int r) { radius1 = r; }
    void setRadius2(int r) { radius2 = r; }
    Ellipse(int r1=0, int r2=0) : RoundShape(r1, r2) {}
};

class Circle : public RoundShape {
  public:
    int getRadius() { return radius1; }
    void setRadius(int r) { radius1 = radius2 = r; }
    Circle(int r=0) : RoundShape(r, r) {}
};

  這支程式十分經典,它完完全全的對應到真實世界上,非常忠實的反映了形狀、圓的形狀、圓形、橢圓形這四者之間的關係。除此之外,還非常巧合的兼顧了程式設計上的實務考量,所以說,"對應到真實世界"可以在無形之中獲得不少意料之外的好處,但先決條件是,你必須要"夠深入"的了解真實世界,否則可能就會設計出如答案一、二、三這類自以為了解真實世界的半調子程式碼來。

  話雖如此,但實際的情形也可能是誤打誤撞,不小心寫出了這支經典程式。或許一開始寫出來的程式碼比較像答案三,但左看右看總覺得答案三的重複程式碼非常不妥,因而將重複的程式碼提出來,另外寫成一個類別,就變成了答案四:圓形與橢圓形都繼承圓的形狀。

  這支程式比較可惜的地方是,getRadius()與setRadius()用起來比較彆扭,這雖然是程式語言上的限制所造成的,但不巧的是,市面上流行的程式語言都有這限制。

  耶!歷經千辛萬苦,我們的程式碼終於能夠完全對應到真實世界了,但光是這樣並不夠,我們要做得比真實世界更棒!請耐心的繼續看下去,文章越到後面越是精采。

 



三、還有其他寫法


  首先讓我們來看看不同於前述經典程式碼的其他寫法。


答案五:用包含來reuse程式碼

選擇原因:依據"我掰不出來了"的精神,先看看程式碼再說吧!

  經典程式碼以繼承來消除重複的程式碼,達到reuse程式碼的目的,這支程式則以包含的技巧來達到相同的目的。

  圓形(與橢圓形)中宣告了一個圓的形狀的成員,因而直接獲得了圓的形狀的所有能力,而不是經由繼承圓的形狀來獲得這些能力。

參考程式碼:

class Shape {
  public:
    virtual int area() {}
    virtual void display() {}
};

class RoundShape : public Shape {
  public:
    int radius1, radius2;
    RoundShape(int r1=0, int r2=0) : radius1(r1), radius2(r2) {}
    int area() { return radius1 * radius2 * 3.1415926; }
    void display() { ... }
};

class Ellipse : public RoundShape {
  protected:
    RoundShape round;
  public:
    int getRadius1() { return round.radius1; }
    int getRadius2() { return round.radius2; }
    void setRadius1(int r) { round.radius1 = r; }
    void setRadius2(int r) { round.radius2 = r; }
    Ellipse(int r1=0, int r2=0) : round(r1, r2) {}
    int area() { return round.area(); }
    void display() { round.display(); }
};

class Circle : public Shape {
  protected:
    RoundShape round;
  public:
    int getRadius() { return round.radius1; }
    void setRadius(int r) { round.radius1 = round.radius2 = r; }
    Circle(int r=0) : round(r, r) {}
    int area() { return round.area(); }
    void display() { round.display(); }
};

  除了圓的形狀的兩個半徑由protected變成public,以及由原先的繼承圓的形狀,改為宣告一個型別為圓的形狀的成員之外,這支程式和前一支程式幾乎沒有差別,且它還是有彆扭的getRadius()與setRadius()。

  圓形(與橢圓形)中宣告了一個圓的形狀的成員,之後便使用圓的形狀的area()方法來計算圓形(與橢圓形)的面積,圓形(與橢圓形)本身並不需要知道如何去計算面積,反而是從它的成員去獲得計算面積的能力。

  這在物件導向程式設計中稱為"組合",圓的形狀是圓形(或橢圓形)的一部分(a part of)。雖然不管是繼承還是組合,都可以直接將別人的能力納為己用,但是正統的物件導向程式設計,非常注重如何去區別"繼承"與"組合",兩者是分的很清楚的。


答案六:直接引用公式來reuse程式碼

選擇原因:依據學無止境的道理,多多觀摩不同的方法,增廣見聞

  形狀問題的例子比較特殊,可以直接引用公式來計算面積,並透過引用共同的公式來reuse程式碼。

參考程式碼:

class ComputeArea {
  public:
    static int ellipse(int r1, int r2) { return r1 * r2 * 3.1415926; }
    static int retangle(int w, int h) { return w * h; }
};

class DrawShape {
  public:
    static int ellipse(int r1, int r2) { ... }
    static int retangle(int w, int h) { ... }
};

class Shape {
  public:
    virtual int area() {}
    virtual void display() {}
};

class Ellipse : public Shape {
  public:
    int radius1, radius2;
    Ellipse(int r1=0, int r2=0) : radius1(r1), radius2(r2) {}
    int area() { return ComputeArea::ellipse(radius1, radius2); }
    void display() { DrawShape::ellipse(radius1, radius2); }
};

class Circle : public Shape {
  public:
    int radius;
    Circle(int r=0) : radius(r) {}
    int area() { return ComputeArea::ellipse(radius, radius); }
    void display() { DrawShape::ellipse(radius, radius); }
}; 

  這支程式將計算各種形狀面積的公式,包裝成一個名為ComputeArea類別,包含:計算橢圓形面積的公式、計算矩形面積的公式,圓形與橢圓形再透過叫用此類別中適當的面積公式,來完成計算面積的工作。這種作法比較接近傳統程式設計的思維邏輯,而不是物件導向程式設計的思維邏輯。無論如何,這支程式還是完成了它份內該做的工作。


四、不要只為了reuse而使用繼承

  答案四的說明中提到,在實際的情形下,有可能一開始並不是那麼深入的了解真實世界的形狀問題,反而只是為了改良答案三的重複程式碼問題,才把重複的程式碼單獨寫成一個類別,誤打誤撞寫出了答案四的經典程式碼。

  這種「只為了reuse而使用繼承」的情況是不好的,程式的架構有可能因為這個「勉強的繼承」而變得不倫不類,或許目前並沒有問題,但在後續的維護工作上,可能會增加許多的困難度。

  以形狀問題為例,我們幾乎可以確定真實世界應該就如同答案四的經典程式碼那般,但我們仍然可以選擇答案五,以包含來取代繼承。繼承雖然好用,但是卻像族譜一般,為家族中的所有成員之間,建立了一套嚴密的、毫無彈性的脈絡體系。這關係一旦建立,就無法登報作廢,而且在家族中發生的任何事情,都會隨著脈絡體系延伸開來,間接影響了體系中的其他成員。

  反之,若我們以包含來取代繼承,關係的建立是單向片面性的,不會反饋回被包含的物件中,自然就不會隨著脈絡體系延伸開來。我們較能夠掌握每一件事情的影響範圍,不會因為複雜的繼承網絡,而難以估計任何事件的副作用影響範圍。

 



畫圓形的方法並沒有做最佳化:

  讓我們來看一個實例:假設有一天,我們發現在畫橢圓形時,可以利用橢圓形上下對稱且左右亦對稱的原理,先畫1/4的橢圓弧,再運用鏡射的公式畫出其他3/4的橢圓弧。接著更進一步發現,若這個橢圓形是正圓形,由於正圓形不管怎樣旋轉都滿足對稱的特性,可以再次應用對稱的原理,加速畫1/4圓弧的演算法,(先畫1/8圓弧,再以x,y座標對調的方法完成另外1/8圓弧),但這種方法僅限於圓形,無法適用於橢圓形上。所以這次圓形與橢圓形無法再共用程式碼了!

  啊!完蛋了,先前一直以為圓形就是橢圓形的一種,因而設計成圓形與橢圓形共用display()方法,這也沒錯,因為真實世界原本也正是如此。但若我們為了執行效率的實務考量,而想單獨針對圓形的display()做最佳化,選擇的條件改變,答案自然就不一樣了,舊愛答案四被新歡答案五取代,原本經典的程式碼再也不經典了!

  若要為答案四做最佳化,就要考慮是要修改圓形,還是要新增一個「正圓的形狀」,若是要新增類別,新增加的類別要放在繼承結構的哪個位置上?會不會為了display()方法此而破壞了area()方法的繼承關係?

  答案五則單純許多,這不關「圓的形狀」的事,它只是被包含進來「利用」而已,因此不會動到任何「圓的形狀」的成員或方法,只能針對圓形做修改。這時也有可能在考慮了這方因素之後,決定特別為圓形新增一個「正圓的形狀」類別,但由於新類別只是要給圓形「利用」而已,不會用在其他的用途上,不必在意它和其他形狀之間的繼承關係,是否符合真實世界的狀況。


結論:

  一個擁有良好設計的程式碼,須具備以下的特性:

盡量減少單純只為了reuse而使用到的繼承,多多基於抽象類別的原因而使用繼承

  抽象類別的相關討論,請繼續看第四集:繼承是父子關係?才怪! 物件導向初學者應該要知道的事情(四)


更多「物件導向」文章:

  為什麼我找出來的物件都是UI物件? 物件導向初學者應該要知道的事情(二)

  要如何找出物件呢? 物件導向初學者應該要知道的事情(一)

  [預告]: 物件導向初學者應該要知道的事情

更多「程式設計」文章:

  [分享] 程式設計寶庫,範例程式搜尋引擎

  最具殺傷力的小BUG

  最尷尬的『不恰當』,程式設計經驗談

  門面佈置的學問-UI開發

  寫程式會從哪邊下手? (問券調查)

  寫程式到底需不需要懂數學?

arrow
arrow

    牛奶 發表在 痞客邦 留言(1) 人氣()