文章已寫完,因為文章太長,原訂第五部份:抽象類別,挪移到第四集的內容中,圖形的問題尚未解決,請見諒!
目錄:
一、形狀問題
二、經典的程式碼
三、還有其他寫法
四、不要只為了reuse而使用繼承
五、抽象類別:用共同的方法來操作不同的物件
引言:
物件導向技術是以物件為中心在思考問題,先前的兩篇文章,探討了物件導向技術中的物件可以是什麼?以及如何決定要實作出哪些物件?接下來的文章,則要探討物件和物件之間的關係。
物件和物件之間的關係有繼承、組合、聚合、使用等四種,哦!對了,還有另一種關係,叫做完全無關。關於這四種關係的差別,在每一本物件導向或UML的書中都會提到,但是物件導向就是這麼神秘,即使看盡了書中的詳細說明,當第一次上路的時候,還是會手足無措,不知如何把書中的知識應用上來,這就是理論與實務上的差距。
物件導向世界中最經典案例為"形狀問題",本文將帶領讀者徹頭徹尾思索一遍形狀問題,提出六支不同的程式來解決同一個問題,並指出每支程式個別的優劣之處,以實際案例的程式碼,讓讀者體會實務面的考量會有哪些?
一、形狀問題
事不宜遲,就讓我們直接來看看題目:
請寫一支程式在螢幕上畫出簡單幾何圖形,並計算各自的面積,這些幾何圖形包含:圓形、橢圓形、正方形、矩形。請問在不同形狀之間的繼承關係為何?
我們提供了三種典型的答案,如下圖,請先看過,並從中選擇一個答案,然後在腦中思考為何會選擇這個答案,想清楚之後才繼續往下看文章。
答案一:圓形繼承橢圓形
選擇原因:依據真實世界的原則,衍生類別是一種(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物件? 物件導向初學者應該要知道的事情(二)
更多「程式設計」文章: