多重繼承和純抽象類

多重繼承和純抽象類

Bill Venners我在199119965年間,幾乎一直 僅僅使用C++編程。在那時,我認爲多重繼承唯一目的就是讓我能夠從多個基類中繼承它們各自的數據和函數 — 不管是虛 擬函數還是非虛擬函數。那時候,我和我使用C++的同事幾乎從未想過可以使用一種不含任何數據而僅包含 純虛函數的類,也就是現在Java中被稱爲接口的東西。最近您好像又越來越多地提起了抽象類這個概念,我想問問是不是最近在實驗的過程中發現了一些我們以前未曾注意到的對純 接口類進行多重繼承的好處,抑或是您認爲我們以前對抽象類重視得不夠?

Bjarne Stroustrup我在對人們解釋這個問題的過程中遇到了很多問題,而且我也一直不能理解爲什麼讓人們理解這個問題是如此困難。自C++出現那天起,就存在着包含數據成員的類和不包含數據成員的類。在過去,人們強調利用一個最基礎的設施以及該設施內部的 東西來構造軟件系統,而那個“最基本的設施”通常就是抽象基類。從80年代中葉到80年代末,那些僅由 虛擬函數組合而成的類通常都被稱爲ABCsAbstract Base Classes 抽象基類)。1987年,我在C++中加入了 純虛函數的概念,一個純虛函數必須被其派生類重寫。藉助此概念,你可以在一個C++類中通過將其成員函數 聲明爲純虛函數的方法表明該類是一個純接口類。從那以後,我就一直強調在C++中,有一種主要的使用類的方法就是讓該類不包含任何狀態, 而僅僅作爲一個接口。

C++的 角度來看,一個抽象類和一個接口之間沒有任何區別。有時,我們習慣使用“純抽象類”這個詞來表示某個類僅僅只含有純虛函數(不包含任何數據成員),它是抽象類的最常見的形式。當我試圖向人們解釋這個概念時,我發現如果我不先向他們介紹 純虛函數這個語言中被直接支持的概念,人們就很難接受它。有些人僅僅因爲可以在基類中放入一些數據成員,就覺得他們必須這樣做。他們這樣做,就等於構造了經典的不穩定基類,當然同時也就 招致該結構所帶來的一切問題。當我向人們介紹C++中直接支持抽象基類的概念時,情況稍微好一些,不過仍然有許多人不能理解它。我認爲這是由於我自身的原因所造成的教育上的失敗 — 我低估了做這件事的難度。這與早些時候Simula社團在理解新概念上的失敗異常相似。有些新概念難以理解,部分原因 在於許多人並不是真的想去學習一些全新的東西,他們自以爲自己已經知道了答案。而一旦以爲自己已經知道了答案,再去學一些新東西就會變得非常困難了。在1991年的《The C++ Programming Language》第二版中,有幾個例子描述了抽象類的概念,可不幸的是,我並沒有在全書從頭至尾都貫穿這個思想。

Bill Venners使用純抽象類有什麼好處?什麼時候我們應該使用純抽象類而不是使用更爲普遍的多重繼承?

Bjarne Stroustrup最明顯的例子就是“多接口、單實現”,這是一種很常見的情況。例如 ,你的系統也許既需要序列化功能,也需要迭代功能,那麼這兩個功能都可以接口的形式利用抽象類提供。然後,如果需要提供一個支持序列化的容器,你只需要讓容器類繼承序列化抽象類和迭代抽象類就可以了 ,而這種多重繼承的形式已被JavaC#採納

另一種通常需要使用多重繼承的情況是僅僅通過多重繼承將手頭的一些類組合起來。它們每一個都沒有特別複雜的語義,將其組合起來完全是出於使用上的方便。當然,你也可以使用委託的模式來完成這個工作,也就是說,你可以在對象中 容納一個指向真正實現某些功能的對象指針。這種方法雖然也不錯,但每當你在間接對象中添加一個新方法時,你都需要在自己的類中對應地增加一個新方法。這種做法真讓人頭痛,而且也沒有直截了當地表示出原本的想法,維護 起來則更是費時費力。最後一種情況是你需要從兩個類中分別繼承它們各自的狀態。在這種情況下,當這兩個類都非常複雜或它們的語義相互影響時,你很容易陷入混亂之中。 然而你可以通過減少過度繼承的方法儘量減少這種情況發生的次數,而當你不可避免地需要使用繼承時,你可以通過儘量減少過度使用多重繼承達到目的,而如果到了連多重繼承都是非要不可的時候,那麼你應該儘量迴避那些複雜的 變數。總的來說,在對一個具體問題建立一個模型時,你應該讓該模型儘量簡單,但不致於過分簡單。

有些人經常會說他並不需要多重繼承,因爲所有多重繼承能做的事情都能通過單繼承完成,只是要使用我上面提到的那個名爲“委託”的小技巧而已。更進一步,你也並不需要任何繼承,因爲所有單繼承能夠完成的事都可以通過類之間的 轉發完成。實際上,你根本不需要任何類,因爲你完全可以利用指針和數據結構來達到目的。可爲什麼你會想要建立類呢?什麼時候使用語言內建設施比較方便?什麼時候你寧願用一種繞彎的方法呢?我見過 有很多場合多重繼承甚至是非常複雜的多重繼承發揮了重要作用。總體上來說,我更喜歡使用語言提供的功能來處理事情。

我們應對複雜情形的另外一種方法是利用模板進行組合。具體而言就是提供多個模板參數,而每個參數都是一個完全獨立的類,它們都是你能夠進行組合的抽象的具體實現。這些類每一個都是完全獨立的,只有最後的派生類才與它們 中的每一個存在依賴關係。有時候在一個模板內部根據繼承關係進行組合是很便捷的,而有時則需另想辦法(例如你可以將每一個單獨的類作爲一個數據成員存儲或僅 存儲它們各自的指針)。這裏有一個你有時需要從多個類中繼承狀態的例子:你有一個配置器對象,它知道如何處理關於內存的分配和銷燬的問題,你也有一個存取器對象,只要你把內存地址給它,它就能處理關於內存存取的問題。現在,你準備將他們都用於你的一個項目實現中 ,就讓我們假設是一個操作矩陣的複雜函數吧,此時你至少已經擁有了兩個狀態量,可是並沒有帶來那些對多重繼承心存疑慮的人所擔心的那些問題。基本上, 你用一些非常簡單的詞彙就可以將運作的情況解釋清楚。

多範型程序設計

Bill Venners另一個我以前用C++寫程序的時候未曾聽說過的概念是多範型編程 ,即在程序中使用多種範型。最近您似乎經常談到這個概念。C++支持什麼樣的程序設計風格?在同一程序中組合運用各種風格有何優點?

Bjarne Stroustrup多範型程序設計並不是一個新概念 ,它不過是描述事物的一種新方法而已。就像我並不能成功地教育人們應該使用作爲接口的抽象類來代替狀態易變的類一樣,我在解釋如何使用各種不同的程序範型時也遇到了很多困難。 不過,在我的關於C++的第一本書中有如下描述:C++支持傳統的C形式的程序設計,並且比C做得更好;C++支持數據抽象以及面向對象思想。數據抽象的思想基本上就是你在利用Ada或者類似的其他語言寫程序時 採用的思想,這種編程思想非常適合處理有着確定的標準概念(如複數、向量和上對角矩陣)的高性能數值計算問題。這些問題要求對於那些標準概念有着非常高效的實現,並將這些概念用一些相互聯繫的類來表達。

Bill Venners數據抽象就是指不帶任何繼承關係的 各自獨立的類嗎?

Bjarne Stroustrup基本上是。面向對象編程的意義就在於你可以使用類繼承的概念 ,這個概念首先由Simula引入。當你閱讀我以前寫的一些材料時,你會發現在有關數據抽象的主題後面,我通常會跟着寫上“我們還需要一種將容器內元素的型別參數化,並且能針對該種容器實施某種操作的機制”,這種思想就是後來發展起來的所謂 的泛型程序設計。至於面向對象的思想則於稍晚些時候出現,並且迅速攫取了許多人的注意力,使他們在進行程序設計時特別關注於類繼承的概念。而在C++世界,泛型程序設計的思想從80年代末起,就開始緩慢地從數據抽象思想中顯現出它的一些獨立的特性,發展到今天,它已經取得了令人矚目的成績 ,我們對於這種思想的瞭解程度也今非昔比了,所以我需要將它提出來,單獨描述。

Bill Venners泛型的思想就是我針對 類型T編寫代碼,而T的具體類型將在以後確定?

Bjarne Stroustrup沒錯。不過當你說“模板類型T”的時候,你已經落後了 ,現在的說法應該是“所有T類型”。我在1981年寫的關於“帶類的C”(後來發展成爲C++)的第一篇論文中就已經提出了參數化類型的概念。不過那時候,我雖然提出了正確的問題,可是卻給出了錯誤的答案 。我那時解釋說我們可以使用宏機制來解決問題,可那隻會產生出一堆噁心的代碼。幸運的是,一個正確的問題比一個正確的答案重要得多,因爲至少當你有了一個正確的問題時,你總可以通過努力來找到它的答案。相比於我定義它們時而言,現在我們對於參數化機制有了更深刻地認識。我的意思是,我當時僅僅看到了這個問題重要性的一部分,也只看到了這個問題答案的一部分。不過令人高興的是,我那時看到的已經足夠多了。現在,由於C++使用模板對參數化機制 提供了直接的支持,我們已經可以在C++中完成很多在上個世紀八九十年代時不可能完成的任務了。

其實從一開始就存在對多範型編程的需要。這就是我爲什麼在說“C++支持面向對象編程”時通常還要加上“而且比一些其他語言做得更好”,我從不說“C++是一門面向對象的語言”。因爲我從不認爲這個世界上只存在一種正確的 編程方式。從一開始,對於各種編程範型的需要就一直存在,我常列出的範型有C形式的程序設計、數據抽象、面向對象以及泛型式程序設計,它們都得到了C++的直接支持 ,而且從一開始,將這些範型組合使用的例子也一直存在,我現在只是更着重強調了這一點而已。我認爲我在這個問題上的教育工作做得比較成功,也許我更擅長讓人理解這個概念 ,也許整個C++社羣已經足夠成熟,能夠很輕鬆地領悟對多範型的需求。儘管如此,在C++社羣中,還存在着許多 待以理解的問題,特別是如何將各種風格組合在一起以產生最好、最有效率和最易維護的代碼。

說到這個問題,我恰好在一次閱讀我的《The C++ Programming Language》一書第3版的一篇書評時有過一次愉快的體驗。如果我沒記錯的話,書評者應該是Al Stevens,他說他認爲第三版比原來的版本更容易閱讀。爲了證實這個想法,他回過頭去重新檢閱了一下原來的版本,看看它們是否真如他所想得那樣糟糕。結果他得出的結論是:第一版不是很糟,不過是講述問題不如現在的版本清晰而已。可是當那本書的第一版出版時,他也曾寫過一篇書評,其間提到那本書幾乎是不可理解的。先鋒們的工作在現在來看,的確是難以理解,不過通過他們的努力工作,思想和社羣都會慢慢地成熟起來。如果你回到C++最初出現的時代,你將會發現現在很多顯而易見的概念在那時都顯得如此難以理解,你一定會奇怪,爲什麼對於使用這些如此基本的元素,人們都存在這麼多的困難呢?我不是很理解爲什麼我不能很好的將這些想法教給人們,可我自己從這些想法中學到了很多東西。

資源分配就是初始化

Bill Venners另一項當年我在使用C++編程時從未聽過的概念就是“資源分配就是初始化”。您可以給我解釋一下這項與內存管理、資源管理 和異常安全都有一定關係的技術嗎?

Bjarne Stroustrup如果我創建了10000個對象,並且得到了它們的指針,那麼我就需要顯式地刪除10000個對象,既不是9999個,也不是10001個。天哪!我可不知道要怎樣才能做到!如果要讓我直接控制10000個對象的話,我相信我馬上會抓狂的。這就是爲什麼我在開始時說過 ,如果以使用malloc的方法來使用new的話,你就已經被麻煩糾纏上了。所以,在很久以前我就想過“但我可以正確地處理較低數量的對象 啊”。如果我手頭只有一百個對象需要管理,那麼我可以有相當的自信來操控這一百個對象。當然,如果這個數量能夠下降到100的話,那麼我就開始沾沾自喜了,因爲我完全可以肯定自己能夠正確地處理這 種情況。

例如容器就是一個系統管理對象的方式。雖然你可以在容器中放入一些指向其他對象的指針,然而你也可以利用容器的構造函數和析構函數自動地爲你管理被包含的對象。這個 技術的關鍵之處在於將分配操作隱藏起來了。既然你不需要直接顯式地分配任何東西,那麼顯然你也無需操心與之相關的對象的銷燬任務,對那些資源具有“擁有權”的東西會負責 銷燬它們。在這裏,擁有權的概念是一箇中心點。我們可以說一個容器“擁有”它內部的對象,因爲那些對象是直接存放在容器內部的,而對於一個存放對象指針的容器來說,事情則稍微複雜一點 ,我們既可以讓容器擁有指針所指向的對象,也可以不這樣。不管怎麼說,在這裏我們僅僅需要作出一個決定:擁有,還是放棄。這樣,與前者相比較而言,我們將複雜度降低了1000倍。如果你將這項技術 反覆用在越來越多的地方,那麼你的代碼從表面上來看,將會看不到任何分配和銷燬的操作了。

你需要關注的另一件事情就是更一般範圍的資源管理。你應該如何管理一個文件?難道是採用老式的文件指針的方法嗎?如果是這樣,你就需要使用open操作去初始化文件指針,並且要牢牢記住當文件使用完畢將其關閉。如前所言,你不應該在分配操作後無所事事,然後去使用那種完全“裸露”的指針,你 應該認識到,打開操作的實質就是分配一個文件操作句柄。所以作爲替代,我們可以爲一個文件或者說是文件的句柄建立起相應的資源對象。當對文件執行打開操作時,就初始化一個文件句柄。如果在構造函數中成功打開了一個文件,那麼在析構函數中就會將這個文件關閉。所以,“資源分配就是初始化”的說法的實質是利用構造函數和析構函數來隱藏顯式的分配 和釋放操作,這項技術有時被簡稱爲“RAII”,聽起來有些笨拙。湊巧的是,在異常處理領域,這項技術也被發現是非常必要的 ,因爲異常處理的主要宗旨就是確保程序能夠以一種合理的狀態運行。這就表示它不會泄漏資源、不會改變不變式等,而這些要求就帶來了同樣的資源管理問題。再強調一次:資源管理的最主要工具就是構造函數和析構函數。

Bill Venners所以異常安全的意思就是如果在我的類中拋出了一個異常,那麼我的類的析構函數會自動負責清理工作 — 關閉那些當類析構時需要關閉的資源,並且保證我所使用的不變式不會因此受到影響。

Bjarne Stroustrup你說得對。基本上是這樣,不過詳細討論起來,這個問題也沒有如此簡單 ,關於它有一套完整的理論,人們可以從《The C++ Programming Language》第三版附錄E中獲得 有關信息。如果你手頭的第版並未包含附錄E的話,那麼你應該換一本更新的了。不過如果你已經身無分文,再也買不起一本新的話,也可以 到我的主頁下載附錄E的全文。無論如何,如果你手頭的C++書籍沒有一章關於異常安全的內容的話,那它已經過時了。

異常表示發生了一些壞的(至少是不期望的)的事情,並且你希望讓別人幫助你從中恢復過來。要想達到這個目的,你要確保在拋出一個異常之前,你已經將你領域內的混亂情況清理乾淨了。更具體地說 ,你不應該在堆內分配了一個對象,然後再緊跟着又丟出一個異常,這樣做會“泄漏”這個對象。如果你分配了這個資源,那你要麼將它銷燬,要麼在拋出異常前將 其擁有權轉移給別的什麼東西。異常被拋出後,程序執行的流程將會沿着原本的調用鏈被層層解開,在每一層調用被解開時,都要保證在該層內分配的所有需要釋放的資源都得到了正確 地釋放。如果你不使用“資源分配就是初始化”技術,你就需要寫一個try代碼塊,並且還需要提供一塊能夠捕捉所有異常的代碼,在這段代碼中,進行必要的清理工作,然後將異常重新拋出,就像在Java中寫finally代碼塊一樣。當然,如果你忘了寫這個finally代碼塊,你也就製造了一個BUG。你必須確保每次代碼運行到這 兒時都保持正常,而我認爲這是不可能的事情。最簡單也是最容易管理的方法就是使用“資源分配就是初始化”。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章