條款43: 明智地使用多繼承
要看是誰來說,多繼承(MI)要么被認為是神來之筆,要么被當成是魔鬼的造物。支持者宣揚說,它是對真實世界問題進行自然模型化所必需的;而批評者爭論說,它太慢,難以實現,功能卻不比單繼承強大。更讓人為難的是,面向對象編程語言領域在這個問題上至今仍存在分歧:C++,Eiffel和the Common LISP Object System (CLOS)提供了MI;Smalltalk,Objective C和Object Pascal沒有提供;而Java只是提供有限的支持。可憐的程序員該相信誰呢?
在相信任何事情之前,首先得弄清事實。C++中,關于MI一條不容爭辯的事實是,MI的出現就象打開了潘朵拉的盒子,帶來了單繼承中絕對不會存在的復雜性。其中,最基本的一條是二義性(參見條款26)。如果一個派生類從多個基類繼承了一個成員名,所有對這個名字的訪問都是二義的;你必須明確地說出你所指的是哪個成員。下面的例子取自ARM(參見條款50)中的一個專題討論:
class Lottery {
public:
virtual int draw();
...
};
class GraphicalObject {
public:
virtual int draw();
...
};
class LotterySimulation: public Lottery,
/t/t/t public GraphicalObject {
.../t/t/t // 沒有聲明draw
};
LotterySimulation *pls = new LotterySimulation;
pls->draw();/t/t // 錯誤! ---- 二義
pls->Lottery::draw();/t // 正確
pls->GraphicalObject::draw(); // 正確
這段代碼看起來很笨拙,但起碼可以工作。遺憾的是,想避免這種笨拙很難。即使其中一個被繼承的draw函數是私有成員從而不能被訪問,二義還是存在。(對此有一個很好的理由來解釋,但完整的說明在條款26中提供,所以此處不再重復。)
顯式地限制修飾成員不僅很笨拙,而且還帶來限制。當顯式地用一個類名來限制修飾一個虛函數時,函數的行為將不再具有虛擬的特征。相反,被調用的函數只能是你所指定的那個,即使調用是作用在派生類的對象上:
class SpecialLotterySimulation: public LotterySimulation {
public:
virtual int draw();
...
};
pls = new SpecialLotterySimulation;
pls->draw();/t/t // 錯誤! ---- 還是有二義
pls->Lottery::draw();/t // 調用Lottery::draw
pls->GraphicalObject::draw(); // 調用GraphicalObject::draw
注意,在這種情況下,即使pls指向的是SpecialLotterySimulation對象,也無法(沒有 "向下轉換" ---- 參見條款39)調用這個類中定義的draw函數。
沒完,還有呢。Lottery和GraphicalObject中的draw函數都被聲明為虛函數,所以子類可以重新定義它們(見條款36),但如果LotterySimulation想對二者都重新定義那該怎么辦?令人沮喪的是,這不可能,因為一個類只允許有唯一一個沒有參數、名稱為draw的函數。(這個規則有個例外,即一個函數為const而另一個不是的時候 ---- 見條款21)
從某一方面來說,這個問題很嚴重,嚴重到足以成為修改C++語言的理由。ARM中就討論了一種可能,即,允許被繼承的虛函數可以 "改名" ;但后來又發現,可以通過增加一對新類來巧妙地避開這個問題:
class AuxLottery: public Lottery {
public:
virtual int lotteryDraw() = 0;
virtual int draw() { return lotteryDraw(); }
};
class AuxGraphicalObject: public GraphicalObject {
public:
virtual int graphicalObjectDraw() = 0;
virtual int draw() { return graphicalObjectDraw(); }
};
class LotterySimulation: public AuxLottery,
/t/t/t public AuxGraphicalObject {
public:
virtual int lotteryDraw();
virtual int graphicalObjectDraw();
...
};
這兩個新類, AuxLottery和AuxGraphicalObject,本質上為各自繼承的draw函數聲明了新的名字。新名字以純虛函數的形式提供,本例中即lotteryDraw和graphicalObjectDraw;函數是純虛擬的,所以具體的子類必須重新定義它們。另外,每個類都重新定義了繼承而來的draw函數,讓它們調用新的純虛函數。最終效果是,在這個類體系結構中,有二義的單個名字draw被有效地分成了無二義但功能等價的兩個名字:lotteryDraw和graphicalObjectDraw:
LotterySimulation *pls = new LotterySimulation;
Lottery *pl = pls;
GraphicalObject *pgo = pls;
// 調用LotterySimulation::lotteryDraw
pl->draw();
// 調用LotterySimulation::graphicalObjectDraw
pgo->draw();
這是一個集純虛函數,簡單虛函數和內聯函數(參見條款33)綜合應用之大成的方法,值得牢記在心。首先,它解決了問題,這個問題說不定哪天你就會碰到。其次,它可以提醒你,使用多繼承會導致復雜性。是的,這個方法解決了問題,但僅僅為了重新定義一個虛函數而不得不去引入新的類,你真的愿意這樣做嗎?AuxLottery和AuxGraphicalObject類對于保證類層次結構的正確運轉是必需的,但它們既不對應于問題范疇(problem domain )的某個抽象,也不對應于實現范疇(implementation domain)的某個抽象。它們單純是作為一種實現設備而存在,再沒有別的用處。你一定知道,好的軟件是 "設備無關" 的,這條法則在此也適用。
將來使用MI還會面臨更多的問題,二義性問題(盡管有趣)只不過是剛開始。另一個問題基于這樣一個實踐經驗:一個起初象下面這樣的繼承層次結構:
class B { ... };
class C { ... };
class D: public B, public C { ... };
/t/t B C
/t/t /
/t/t /
/t/t /
/t/t D
往往最后悲慘地發展成象下面這樣:
class A { ... };
class B : virtual public A { ... };
class C : virtual public A { ... };
class D: public B, public C { ... };
/t/t A
/t/t /
/t/t /
/t/t /
/t/t B C
/t/t /
/t/t /
/t/t /
/t/t D
鉆石可能是女孩最好的朋友,也許不是;但肯定的是,象這樣一種鉆石形狀的繼承結構絕對不可能成為我們的朋友。如果創建了象這樣的層次結構,就會立即面臨這樣一個問題:是不是該讓A成為虛基類呢?即,從A的繼承是否應該是虛擬的呢?現實中,答案幾乎總是 ---- 應該;只有極少數情況下會想讓類型D的對象包含A的數據成員的多個拷貝。正是認識到這一事實,上面的B和C將A聲明為虛基類。
遺憾的是,在定義B和C的時候,你可能不知道將來是否會有類去同時繼承它們,而且知不知道這一點實際上對正確地定義這兩個類沒有必要。對類的設計者來說,這實在是進退兩難。如果不將A聲明為B和C的虛基類,今后D的設計者就有可能需要修改B和C的定義,以便更有效地使用它們。通常,這很難做到,因為A,B和C的定義往往是只讀的。例如這樣的情況:A,B和C在一個庫中,而D由庫的用戶來寫。
另一方面,如果真的將A聲明為B和C的虛基類,往往會在空間和時間上強加給用戶額外的開銷。因為虛基類常常是通過對象指針來實現的,并非對象本身。自不必說,內存中對象的分布是和編譯器相關的,但一條不變的事實是:如果A作為 "非虛" 基類,類型D的對象在內存中的分布通常占用連續的內存單元;如果A作為 "虛" 基類,有時,類型D的對象在內存中的分布占用連續的內存單元,但其中兩個單元包含的是指針,指向包含虛基類數據成員的內存單元:
A是非虛基類時D對象通常的內存分布:
/t A部分+ B部分+ A部分 + C部分 + D部分
A是虛基類時D對象在某些編譯器下的內存分布:
/t/t/t ------------------------------------------------
/t/t/t |/t/t/t/t/t/t |
/t/t/t |/t/t/t/t/t/t +
/t B部分 + 指針 + C部分 + 指針 + D部分 + A部分
/t/t/t/t/t/t |/t/t +
/t/t/t/t/t/t |/t/t |
/t/t/t/t/t/t ------------------------
即使編譯器不采用這種特殊的實現策略,使用虛繼承通常也會帶來某種空間上的懲罰。
考慮到這些因素,看來,在進行高效的類設計時如果涉及到MI,作為庫的設計者就要具有超凡的遠見。然而現在的年代,常識都日益成為了稀有品,因而你會不明智地過多依賴于語言特性,這就不僅要求設計者能夠預計得到未來的需要,而且簡直就是要你做到徹底的先知先覺(參見條款M32)。
當然,這也可以說成是在虛函數和非虛函數間選擇,但還是有重大的不同。條款36說明,虛函數具有定義明確的高級含義,非虛函數也同樣具有定義明確的高級含義,而且它們的含義有顯著的不同,所以在清楚自己想對子類的設計者傳達什么含義的基礎上,在二者之間作出選擇是可能的。但是,決定基類是否應該是虛擬的,則缺乏定義明確的高級含義;相反,決定通常取決于整個繼承的層次結構,所以除非知道了整個層次結構,否則無法做出決定。如果正確地定義出個類之前需要清楚地知道將來怎么使用它,這種情況下將很難設計出高效的類。
就算避開了二義性問題,并且解決了是否應該從基類虛擬繼承的疑問,還是會有許多復雜性問題等著你。為了長話短說,在此我僅提出應該記住的其它兩點:
? 向虛基類傳遞構造函數參數。非虛繼承時,基類構造函數的參數是由緊臨的派生類的成員初始化列表指定的。因為單繼承的層次結構只需要非虛基類,繼承層次結構中參數的向上傳遞采用的是一種很自然的方式:第n層的類將參數傳給第n-1層的類。但是,虛基類的構造函數則不同,它的參數是由繼承結構中最底層派生類的成員初始化列表指定的。這就造成,負責初始化虛基類的那個類可能在繼承圖中和它相距很遠;如果有新類增加到繼承結構中,執行初始化的類還可能改變。(避免這個問題的一個好辦法是:消除對虛基類傳遞構造函數參數的需要。最簡單的做法是避免在這樣的類中放入數據成員。這本質上是Java的解決之道:Java中的虛基類(即,"接口")禁止包含數據)
? 虛函數的優先度。就在你自認為弄清了所有的二義之時,它們卻又在你面前搖身一變。再次看看關于類A,B,C和D的鉆石形狀的繼承圖。假設A定義了一個虛成員函數mf,C重定義了它;B和D則沒有重定義mf:
/t A virtual void mf();
/t /
/t /
/t /
/t B C virtual void mf();
/t /
/t /
/t /
/t D
根據以前的討論,你會認為下面有二義:
D *pd = new D;
pd->mf();/t/t // A::mf或者C::mf?
該為D的對象調用哪個mf呢,是直接從C繼承的還是間接(通過B)從A繼承的那個呢?答案取決于B和C如何從A繼承。具體來說,如果A是B或C的非虛基類,調用具有二義性;但如果A是B和C的虛基類,就可以說C中mf的重定義優先度高于最初A中的定義,因而通過pd對mf的調用將(無二義地)解析為C::mf。如果你坐下來仔細想想,這正是你想要的行為;但需要坐下仔細想想才能弄懂,也確實是一種痛苦。
也許至此你會承認MI確實會導致復雜化。也許你認識到每個人其實都不想使用它。也許你準備建議國際C++標準委員會將多繼承從語言中去掉;或者至少你想向你的老板建議,全公司的程序員都禁止使用它。
也許你太性急了。
請記住,C++的設計者并沒有想讓多繼承難以使用;恰恰是,想讓一切都能以更合理的方式協調工作,這本身會帶來某些復雜性。上面的討論中你會注意到,這些復雜性很多是由于使用虛基類引起的。如果能避免使用虛基類 ---- 即,如果能避免產生那種致命的鉆石形狀繼承圖 ---- 事情就好處理多了。
例如,條款34中講到,協議類(Protocol class)的存在僅僅是為派生類制定接口;它沒有數據成員,沒有構造函數,有一個虛析構函數(參見條款14),有一組用來指定接口的純虛函數。一個Person協議類看起來象下面這樣:
class Person {
public:
virtual ~Person();
virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
virtual string nationality() const = 0;
};
這個類的用戶在編程時必須使用Person的指針或引用,因為抽象類不能被實例化。
為了創建 "可以作為Person對象而使用" 的對象,Person的用戶使用工廠函數(factory function,參見條款34)來實例化具體的子類:
// 工廠函數,從一個唯一的數據庫ID
// 創建一個Person對象
Person * makePerson(DatabaseID personIdentifier);
DatabaseID askUserForDatabaseID();
DatabaseID pid = askUserForDatabaseID();
Person *pp = makePerson(pid); // 創建支持Person
/t/t/t/t // 接口的對象
.../t/t/t // 通過Person的成員函數
/t/t/t/t // 操作*pp
delete pp;/t/t // 刪除不再需要的對象
這就帶來一個問題:makePerson返回的指針所指向的對象如何創建呢?顯然,必須從Person派生出某種具體類,使得makePerson可以對其進行實例化。
假設這個類被稱為MyPerson。作為一個具體類,MyPerson必須實現從Person繼承而來的純虛函數。這可以從零做起,但如果已經存在一些組件可以完成大多數或全部所需的工作,那么從軟件工程的角度來說,能利用這些組件將再好不過。例如,假設已經有一個和數據庫有關的舊類PersonInfo,它提供的功能正是MyPerson所需要的:
class PersonInfo {
public:
PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char * theName() const;
virtual const char * theBirthDate() const;
virtual const char * theAddress() const;
virtual const char * theNationality() const;
virtual const char * valueDelimOpen() const; // 看下文
virtual const char * valueDelimClose() const;
...
};
可以斷定這是一個很舊的類,因為它的成員函數返回的是const char*而不是string對象。但是,如果鞋合腳,為什么不穿呢?這個類的成員函數名暗示,這雙鞋穿上去會很舒服。
隨之你會發現,當初設計PersonInfo是用來方便地以各種不同格式打印數據庫字段,每個字段值的開頭和結尾用特殊字符串分開。默認情況下,字段值的起始分隔符和結束分隔符為括號,所以字段值 "Ring-tailed Lemur" 將會這樣被格式化:
[Ring-tailed Lemur]
因為括號不是所有PersonInfo的用戶都想要的,虛函數valueDelimOpen和valueDelimClose允許派生類指定它們自己的起始分隔符和結束分隔符。PersonInfo類的theName,theBirthDate,theAddress以及theNationality的實現將調用這兩個虛函數,在它們的返回值中添加適當的分隔符。拿PersonInfo::name作為例子,代碼看起來象這樣:
const char * PersonInfo::valueDelimOpen() const
{
return "[";/t/t // 默認起始分隔符
}
const char * PersonInfo::valueDelimClose() const
{
return "]";/t/t // 默認結束分隔符
}
const char * PersonInfo::theName() const
{
// 為返回值保留緩沖區。因為是靜態
// 類型,它被自動初始化為全零。
static char value[MAX_FORMATTED_FIELD_VALUE_LENGTH];
// 寫起始分隔符
strcpy(value, valueDelimOpen());
將對象的名字字段值添加到字符串中
// 寫結束分隔符
strcat(value, valueDelimClose());
return value;
}
有些人會挑剔PersonInfo::theName的設計(特別是使用了固定大小的靜態緩沖區 ---- 參見條款23),但請將你的挑剔放在一邊,關注這一點:首先,theName調用valueDelimOpen,生成它將要返回的字符串的起始分隔符;然后,生成名字值本身;最后,調用valueDelimClose。因為valueDelimOpen和valueDelimClose是虛函數,theName返回的結果既依賴于PersonInfo,也依賴于從PersonInfo派生的類。
作為MyPerson的實現者,這是條好消息,因為在研讀Person文檔的細則時你發現,name及其相關函數需要返回的是不帶修飾的值,即,不允許帶分隔符。也就是說,如果一個人來自Madagascar,調用這個人的nationality函數將返回"Madagascar",而不是 "[Madagascar]"。
MyPerson和PersonInfo之間的關系是,PersonInfo剛好有些函數使得MyPerson易于實現。僅次而已。沒看到有 "是一個" 或 "有一個" 的關系。它們的關系是 "用...來實現",而且我們知道,這可以用兩種方式來表示:通過分層(見條款40)和通過私有繼承(見條款42)。條款42指出,分層一般來說是更好的方法,但在有虛函數要被重新定義的情況下,需要使用私有繼承?,F在的情況是,MyPerson需要重新定義valueDelimOpen和valueDelimClose,所以不能用分層,而必須用私有繼承:MyPerson必須從PersonInfo私有繼承。
但MyPerson還必須實現Person接口,因而需要公有繼承。這導致了多繼承一個很合理的應用:將接口的公有繼承和實現的私有繼承結合起來:
class Person {/t/t // 這個類指定了
public:/t/t/t // 需要被實現
virtual ~Person();/t/t // 的接口
virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
virtual string nationality() const = 0;
};
class DatabaseID { ... };/t // 被后面的代碼使用;
/t/t/t/t // 細節不重要
class PersonInfo {/t/t // 這個類有些有用
public:/t/t/t // 的函數,可以用來
PersonInfo(DatabaseID pid);/t // 實現Person接口
virtual ~PersonInfo();
virtual const char * theName() const;
virtual const char * theBirthDate() const;
virtual const char * theAddress() const;
virtual const char * theNationality() const;
virtual const char * valueDelimOpen() const;
virtual const char * valueDelimClose() const;
...
};
class MyPerson: public Person, // 注意,使用了
/t private PersonInfo { // 多繼承
public:
MyPerson(DatabaseID pid): PersonInfo(pid) {}
// 繼承來的虛分隔符函數的重新定義
const char * valueDelimOpen() const { return ""; }
const char * valueDelimClose() const { return ""; }
// 所需的Person成員函數的實現
string name() const
{ return PersonInfo::theName(); }
string birthDate() const
{ return PersonInfo::theBirthDate(); }
string address() const
{ return PersonInfo::theAddress(); }
string nationality() const
{ return PersonInfo::theNationality(); }
};
用圖形表示,看起來象下面這樣:
/t Person PersonInfo
/t/t /
/t/t /
/t/t /
/t/t MyPerson
這種例子證明,MI會既有用又易于理解,盡管可怕的鉆石形狀繼承圖不會明顯消失。
然而,必須當心誘惑。有時你會掉進這樣的陷阱中:對某個需要改動的繼承層次結構來說,本來用一個更基本的重新設計可以更好,但你卻為了追求速度而去使用MI。例如,假設為可以活動的卡通角色設計一個類層次結構。至少從概念上來說,讓各種角色能跳舞唱歌將很有意義,但每一種角色執行這些動作時方式都不一樣。另外,跳舞唱歌的缺省行為是什么也不做。
所有這些用C++來表示就象這樣:
class CartoonCharacter {
public:
virtual void dance() {}
virtual void sing() {}
};
虛函數自然地體現了這樣的約束:唱歌跳舞對所有CartoonCharacter對象都有意義。什么也不做的缺省行為通過類中那些函數的空定義來表示(參見條款36)。假設有一個特殊類型的卡通角色是蚱蜢,它以自己特殊的方式跳舞唱歌:
class Grasshopper: public CartoonCharacter {
public:
virtual void dance(); // 定義在別的什么地方
virtual void sing(); // 定義在別的什么地方
};
現在假設,在實現了Grasshopper類后,你又想為蟋蟀增加一個類:
class Cricket: public CartoonCharacter {
public:
virtual void dance();
virtual void sing();
};
當坐下來實現Cricket類時,你意識到,為Grasshopper類所寫的很多代碼可以重復使用。但這需要費點神,因為要到各處去找出蚱蜢和蟋蟀唱歌跳舞的不同之處。你猛然間想出了一個代碼復用的好辦法:你準備用Grasshopper類來實現Cricket類,你還準備使用虛函數以使Cricket類可以定制Grasshopper的行為。
你立即認識到這兩個要求 ---- "用...來實現" 的關系,以及重新定義虛函數的能力 ---- 意味著Cricket必須從Grasshopper私有繼承,但蟋蟀當然還是一個卡通角色,所以你通過同時從Grasshopper和CartoonCharacter繼承來重新定義Cricket:
class Cricket: public CartoonCharacter,
/t private Grasshopper {
public:
virtual void dance();
virtual void sing();
};
然后準備對Grasshopper類做必要的修改。特別是,需要聲明一些新的虛函數讓Cricket重新定義:
class Grasshopper: public CartoonCharacter {
public:
virtual void dance();
virtual void sing();
protected:
virtual void danceCustomization1();
virtual void danceCustomization2();
virtual void singCustomization();
};
蚱蜢跳舞現在被定義成象這樣:
void Grasshopper::dance()
{
執行共同的跳舞動作;
danceCustomization1();
執行更多共同的跳舞動作;
danceCustomization2();
執行最后共同的跳舞動作;
}
蚱蜢唱歌的設計與此類似。
很明顯,Cricket類必須修改一下,因為它必須重新定義新的虛函數:
class Cricket:public CartoonCharacter,
private Grasshopper {
public:
virtual void dance() { Grasshopper::dance(); }
virtual void sing() { Grasshopper::sing(); }
protected:
virtual void danceCustomization1();
virtual void danceCustomization2();
virtual void singCustomization();
};
這看來很不錯。當需要Cricket對象去跳舞時,它執行Grasshopper類中共同的dance代碼,然后執行Cricket類中定制的dance代碼,接著繼續執行Grasshopper::dance中的代碼,等等。
然而,這個設計中有個嚴重的缺陷,這就是,你不小心撞上了 "奧卡姆剃刀" ---- 任何一種奧卡姆剃刀都是有害的思想,William of Occam的尤其如此。奧卡姆者鼓吹:如果沒有必要,就不要增加實體?,F在的情況下,實體就是指的繼承關系。如果你相信多繼承比單繼承更復雜的話(我希望你相信),Cricket類的設計就沒必要復雜。(譯注:1) William of Occam(1285-1349),英國神學家,哲學家。2) 奧卡姆剃刀(Occam's razor)是一種思想,主要由William of Occam提出。之所以將它稱為 "奧卡姆剃刀",是因為William of Occam經常性地、很銳利地運用這一思想。)
問題的根本之處在于,Cricket類和Grasshopper類之間并非 "用...來實現" 的關系。而是,Cricket類和Grasshopper類之間享有共同的代碼。特別是,它們享有決定唱歌跳舞行為的代碼 ---- 蚱蜢和蟋蟀都有這種共同的行為。
說兩個類具有共同點的方式不是讓一個類從另一個類繼承,而是讓它們都從一個共同的基類繼承,蚱蜢和蟋蟀之間的公共代碼不屬于Grasshopper類,也不屬于Cricket,而是屬于它們共同的新的基類,如,Insect:
class CartoonCharacter { ... };
class Insect: public CartoonCharacter {
public:
virtual void dance(); // 蚱蜢和蟋蟀
virtual void sing(); // 的公共代碼
protected:
virtual void danceCustomization1() = 0;
virtual void danceCustomization2() = 0;
virtual void singCustomization() = 0;
};
class Grasshopper: public Insect {
protected:
virtual void danceCustomization1();
virtual void danceCustomization2();
virtual void singCustomization();
};
class Cricket: public Insect {
protected:
virtual void danceCustomization1();
virtual void danceCustomization2();
virtual void singCustomization();
};
/t CartoonCharacter
/t/t |
/t/t |
/t/t Insect
/t/t /
/t/t /
/t/t /
Grasshopper Cricket
可以看到,這個設計更清晰。只是涉及到單繼承,此外,只是用到了公有繼承。Grasshopper和Cricket定義的只是定制功能;它們從Insect一點沒變地繼承了dance和sing函數。William of Occam一定會很驕傲。
盡管這個設計比采用了MI的那個方案更清晰,但初看可能會覺得比使用MI的還要遜色。畢竟,和MI的方案相比,這個單繼承結構中引入了一個全新的類,而使用MI就不需要。如果沒必要,為什么要引入一個額外的類呢?
這就將你帶到了多繼承誘人的本性面前。表面看來,MI好象使用起來更容易。它不需要增加新的類,雖然它要求在Grasshopper類中增加一些新的虛函數,但這些函數在任何情況下都是要增加的。
設想有個程序員正在維護一個大型C++類庫,現在需要在庫中增加一個新的類,就象Cricket類要被增加到現有的的CartoonCharacter/Grasshopper層次結構中一樣。程序員知道,有大量的用戶使用現有的層次結構,所以,庫的變化越大,對用戶的影響越大。程序員決心將這種影響降低到最小。對各種選擇再三考慮之后,程序員認識到,如果增加一個從Grasshopper到Cricket的私有繼承連接,層次結構中將不需要任何其它變化。程序員不禁因為這個想法露出了微笑,暗自慶幸今后可以大量地增加功能,而代價僅僅只是增加很小一點復雜性。
現在設想這個負責維護的程序員是你。那么,請抵御這一誘惑!
新聞熱點
疑難解答
圖片精選