避免 "向下轉換" 繼承層次
在當今喧囂的經濟時代,關注一下我們的金融機構是個不錯的主意。所以,看看下面這個有關銀行帳戶的協議類(Protocol class
class Person { ... };
class BankAccount {
public:
BankAccount(const Person *primaryOwner,
const Person *jointOwner);
virtual ~BankAccount();
virtual void makeDeposit(double amount) = 0;
virtual void makeWithdrawal(double amount) = 0;
virtual double balance() const = 0;
...
};
很多銀行現在提供了多種令人眼花繚亂的帳戶類型,但為簡化起見,我們假設只有一種銀行帳戶,稱為存款帳戶:
class SavingsAccount: public BankAccount {
public:
SavingsAccount(const Person *primaryOwner,
const Person *jointOwner);
~SavingsAccount();
void creditInterest(); // 給帳戶增加利息
...
};
這遠遠稱不上是一個真正的存款帳戶,但還是那句話,現在什麼年代?至少,它滿足我們現在的需要。
銀行想為它所有的帳戶維持一個列表,這可能是通過標準庫(參見條款49)中的list類模板實現的。假設列表被叫做allAccounts:
list<BankAccount*> allAccounts; // 銀行中所有帳戶
和所有的標準容器一樣,list儲存的是對象的拷貝,所以,為避免每個BankAccount儲存多個拷貝,銀行決定讓allAccounts儲存BankAccount的指標,而不是BankAccount本身。
假設現在準備寫一段代碼來遍曆所有的帳戶,為每個帳戶計算利息。你會這麼寫:
// 不能通過編譯的迴圈(如果你以前從沒
// 見過使用 "迭代子" 的代碼,參見下文)
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != allAccounts.end();
++p) {
(*p)->creditInterest(); // 錯誤!
}
但是,編譯器很快就會讓你認識到:allAccounts包含的指標指向的是BankAccount對象,而非SavingsAccount對象,所以每次迴圈,p指向的是一個BankAccount。這使得對creditInterest的調用無效,因為creditInterest只是為SavingsAccount對象聲明的,而不是BankAccount。
如果"list<BankAccount*>::iterator p = allAccounts.begin()" 在你看來更象電話線中的噪音,而不是C++,那很顯然,你以前無緣見識過C++標準庫中的容器類模板。標準庫中的這一部分通常被稱為標準模板庫(STL),你可以在條款49和M35初窺其概貌。但現在你只用知道,變數p工作起來就象一個指標,它將allAccounts中的元素從頭到尾迴圈一遍。也就是說,p工作起來就好象它的類型是BankAccount**而列表中的元素都儲存在一個數組中。
上面的迴圈不能通過編譯很令人泄氣。的確,allAccounts是被定義為儲存BankAccount*,但要知道,上面的迴圈中它事實上儲存的是SavingsAccount*,因為SavingsAccount是僅有的可以被執行個體話的類。愚蠢的編譯器!對我們來說這麼顯然的事情它竟然笨得一無所知。所以你決定告訴它:allAccounts真的包含的是SavingsAccount*:
// 可以通過編譯的迴圈,但很糟糕
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != allAccounts.end();
++p) {
static_cast<SavingsAccount*>(*p)->creditInterest();
}
一切問題迎刃而解!解決得很清晰,很漂亮,很簡明,所做的僅僅是一個簡單的轉換而已。你知道allAccounts指標儲存的是什麼類型的指標,遲鈍的編譯器不知道,所以你通過一個轉換來告訴它,還有比這更合理的事嗎?
在此,我要拿聖經的故事做比喻。轉換之於C++程式員,就象蘋果之於夏娃。
這種類型的轉換 ---- 從一個基類指標到一個衍生類別指標 ---- 被稱為 "向下轉換",因為它向下轉換了繼承的階層。在剛看到的例子中,向下轉換碰巧可以工作;但正如下面即將看到的,它將給今後的維護人員帶來惡夢。
還是回到銀行的話題上來。受到存款帳戶業務大獲成功的激勵,銀行決定再推出支票帳戶業務。另外,假設支票帳戶和存款帳戶一樣,也要負擔利息:
class CheckingAccount: public BankAccount {
public:
void creditInterest(); // 給帳戶增加利息
...
};
不用說,allAccounts現在是一個包含存款和支票兩種帳戶指標的列表。於是,上面所寫的計算利息的迴圈轉瞬間有了大麻煩。
第一個問題是,雖然新增了一個CheckingAccount,但即使不去修改迴圈代碼,編譯還是可以繼續通過。因為編譯器只是簡單地聽信於你所告訴它們(通過static_cast)的一切:*p指向的是SavingsAccount*。誰叫你是它的主人呢?這會給今後維護帶來第一個惡夢。維護期第二個惡夢在於,你一定想去解決這個問題,所以你會寫出這樣的代碼:
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != allAccounts.end();
++p) {
if (*p 指向一個 SavingsAccount)
static_cast<SavingsAccount*>(*p)->creditInterest();
else
static_cast<CheckingAccount*>(*p)->creditInterest();
}
任何時候發現自己寫出 "如果對象屬於類型T1,做某事;但如果屬於類型T2,做另外某事" 之類的代碼,就要扇自己一個耳光。這不是C++的做法。是的,在C,Pascal,甚至Smalltalk中,它是很合理的做法,但在C++中不是。在C++中,要使用虛函數。
記得嗎?對於一個虛函數,編譯器可以根據所使用對象的類型來保證正確的函數調用。所以不要在代碼中隨處亂扔條件陳述式或開關語句;讓編譯器來為你效勞。如下所示:
class BankAccount { ... }; // 同上
// 一個新類,表示要支付利息的帳戶
class InterestBearingAccount: public BankAccount {
public:
virtual void creditInterest() = 0;
...
};
class SavingsAccount: public InterestBearingAccount {
... // 同上
};
class CheckingAccount: public InterestBearingAccount {
... // as above
};
用圖形表示如下:
BankAccount
^
|
InterestBearingAccount
//
/ /
/ /
CheckingAccount SavingsAccount
因為存款和支票賬戶都要支付利息,所以很自然地想到把這一共同行為轉移到一個公用的基類中。但是,如果假設不是所有的銀行帳戶都需要支付利息(以我的經驗,這當然是個合理的假設),就不能把它轉移到BankAccount類中。所以,要為BankAccount引入一個新的子類InterestBearingAccount,並使SavingsAccoun和CheckingAccount從它繼承。
存款和支票賬戶都要支付利息的事實是通過InterestBearingAccount的純虛函數creditInterest來體現的,它要在子類SavingsAccount和CheckingAccount中重新定義。
有了新的類階層,就可以這樣來重寫迴圈代碼:
// 好一些,但還不完美
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != allAccounts.end();
++p) {
static_cast<InterestBearingAccount*>(*p)->creditInterest();
}
儘管這個迴圈還是包含一個討厭的轉換,但代碼已經比過去健壯多了,因為即使又增加InterestBearingAccount新的子類到程式中,它還是可以繼續工作。
為了完全消除轉換,就必須對設計做一些改變。一種方法是限制帳戶列表的類型。如果能得到一列InterestBearingAccount對象而不是BankAccount對象,那就太好了:
// 銀行中所有要支付利息的帳戶
list<InterestBearingAccount*> allIBAccounts;
// 可以通過編譯且現在將來都可以工作的迴圈
for (list<InterestBearingAccount*>::iterator p =
allIBAccounts.begin();
p != allIBAccounts.end();
++p) {
(*p)->creditInterest();
}
如果不想用上面這種 "採用更特定的列表" 的方法,那就讓creditInterest操作使用於所有的銀行帳戶,但對於不用支付利息的帳戶來說,它只是一個空操作。這個方法可以這樣來表示:
class BankAccount {
public:
virtual void creditInterest() {}
...
};
class SavingsAccount: public BankAccount { ... };
class CheckingAccount: public BankAccount { ... };
list<BankAccount*> allAccounts;
// 看啊,沒有轉換!
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != allAccounts.end();
++p) {
(*p)->creditInterest();
}
要注意的是,虛函數BankAccount::creditInterest提供一個了空的預設實現。這可以很方便地表示,它的行為在預設情況下是一個空操作;但這也會給它本身帶來難以預見的問題。想知道內幕,以及如何消除這一危險,請參考條款36。還要注意的是,creditInterest是一個(隱式的)內嵌函式,這本身沒什麼問題;但因為它同時又是一個虛函數,內聯指令就有可能被忽略。條款33解釋了為什麼。
正如上面已經看到的,"向下轉換" 可以通過幾種方法來消除。最好的方法是將這種轉換用虛函數調用來代替,同時,它可能對有些類不適用,所以要使這些類的每個虛函數成為一個空操作。第二個方法是加強型別約束,使得指標的宣告類型和你所知道的真的指標類型之間沒有出入。為了消除向下轉換,無論費多大工夫都是值得的,因為向下轉換難看、容易導致錯誤,而且使得代碼難於理解、升級和維護(參見條款M32)。
至此,我所說的都是事實;但,不是全部事實。有些情況下,真的不得不執行向下轉換。
例如,假設還是面臨本條款開始的那種情況,即,allAccounts儲存BankAccount指標,creditInterest只是為SavingsAccount對象定義,要寫一個迴圈來為每個帳戶計算利息。進一步假設,你不能改動這些類;你不能改變BankAccount,SavingsAccount或allAccounts的定義。(如果它們在某個唯讀庫中定義,就會出現這種情況)如果是這樣的話,你就只有使用向下轉換了,無論你認為這個辦法有多醜陋。
儘管如此,還是有比上面那種原始轉換更好的辦法。這種方法稱為 "安全的向下轉換",它通過C++的dynamic_cast運算子(參見條款M2)來實現。當對一個指標使用dynamic_cast時,先嘗試轉換,如果成功(即,指標的動態類型(見條款38)和正被轉換的類型一致),就返回新類型的合法指標;如果dynamic_cast失敗,返回null 指標。
下面就是加上了 "安全向下轉換" 的例子:
class BankAccount { ... }; // 和本條款開始時一樣
class SavingsAccount: // 同上
public BankAccount { ... };
class CheckingAccount: // 同上
public BankAccount { ... };
list<BankAccount*> allAccounts; // 看起來應該熟悉些了吧...
void error(const string& msg); // 出錯處理函數;
// 見下文
// 嗯,至少轉換很安全
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != allAccounts.end();
++p) {
// 嘗試將*p安全轉換為SavingsAccount*;
// psa的定義資訊見下文
if (SavingsAccount *psa =
dynamic_cast<SavingsAccount*>(*p)) {
psa->creditInterest();
}
// 嘗試將它安全轉換為CheckingAccount
else if (CheckingAccount *pca =
dynamic_cast<CheckingAccount*>(*p)) {
pca->creditInterest();
}
// 未知的帳戶類型
else {
error("Unknown account type!");
}
}
這種方法遠不夠理想,但至少可以檢測到轉換失敗,而用dynamic_cast是無法做到的。但要注意,對所有轉換都失敗的情況也要檢查。這正是上面代碼中最後一個else語句的用意所在。採用虛函數,就不必進行這樣的檢查,因為每個虛函數調用必然都會被解析為某個函數。然而,一旦打算進行轉換,這一切好處都化為烏有。例如,如果某個人在類階層中增加了一種新類型的帳戶,但又忘了更新上面的代碼,所有對它的轉換就會失敗。所以,處理這種可能發生的情況十分重要。大部分情況下,並非所有的轉換都會失敗;但是,一旦允許轉換,再好的程式員也會碰上麻煩。
上面if語句的條件部分,有些看上去象變數定義的東西,看到它你是不是慌張地擦了擦眼鏡?如果真這樣,別擔心,你沒看錯。這種定義變數的方法是和dynamic_cast同時增加到C++語言中的。這一特性使得寫出的代碼更簡潔,因為對psa或pca來說,它們只有在被dynamic_cast成功初始化的情況下,才會真正被用到;使用新的文法,就不必在(包含轉換的)條件陳述式外定義這些變數。(條款32解釋了為什麼通常要避免多餘的變數定義)如果編譯器尚不支援這種定義變數的新方法,可以按老方法來做:
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != allAccounts.end();
++p) {
SavingsAccount *psa; // 傳統定義
CheckingAccount *pca; // 傳統定義
if (psa = dynamic_cast<SavingsAccount*>(*p)) {
psa->creditInterest();
}
else if (pca = dynamic_cast<CheckingAccount*>(*p)) {
pca->creditInterest();
}
else {
error("Unknown account type!");
}
}
當然,從處理事情的重要性來說,把psa和pca這樣的變數放在哪兒定義並不十分重要。重要之處在於:用if-then-else風格的編程來進行向下轉換比用虛函數要遜色得多,應該將這種方法保留到萬不得已的情況下使用。運氣好的話,你的程式世界裡將永遠看不到這樣悲慘荒涼的景象。