C语言面向对象编程
最近在用 C 做項目,之前用慣了 C++ ,轉回頭來用C 還真有點不適應。 C++ 語言中自帶面向對象支持,如封裝、繼承、多態等面向對象的基本特征。 C 原本是面向過程的語言,自身沒有內建這些特性,但我們還是可以利用 C 語言本身已有的特性來實現面向對象的一些基本特征。接下來我們就一一來細說封裝、繼承、多態、純虛類等面向對象特性在 C 語言中如何實現,并且給出實例。
? ? 這篇文章中我們先說封裝和繼承。
? ? 先來看封裝。
? ? 所謂封裝,通俗地說,就是一個姑娘化了妝,只給你看她想讓你看的那一面,至于里面是否刮了骨、墊了東西,不給你看。說到封裝就得說隱藏,這是對兄弟概念;其實我理解隱藏是更深的封裝,完全不給你看見,而封裝可能是猶抱琵琶半遮面。封裝在 C++ 語言中有 protected 、 private 關鍵字在語言層面上支持,而 C 語言中沒有這些。 C 有結構體( struct ),其實可以實現封裝和隱藏。
? ? 在 QT 中,為了更好的隱藏一個類的具體實現,一般是一個公開頭文件、一個私有頭文件,私有頭文件中定義實現的內部細節,公開頭文件中定義開放給客戶程序員的接口和公共數據。看看 QObject (qobject.h ),對應有一個 QObjectPrivate (qobject_p.h ) ,其他的也類似。而代碼框架如下:
[cpp]?view plaincopy
[cpp]?view plaincopy
? ? 這樣做的好處是顯而易見的,除了預定義好的接口,客戶程序員完全不需要知道實現細節,即便實現經過重構完全重來,客戶程序員也不需要關注,甚至相應的模塊連重新編譯都不要——因為 abc.h 自始至終都沒變過。
? ? 上面代碼有個問題,客戶程序員如何得到 struct st_abc 的一個實例,他不知道 struct st_abc_private 如何實現的呀。 C 中沒有構造函數,只好我們自己提供了:我們可以在 abc.h 中聲明一個類似構造函數的函數來生成 struct st_abc 的實例,名字就叫作 new_abc() ,函數原型如下:
[cpp]?view plaincopy
[cpp]?view plaincopy
? ? 到現在為止,封裝和隱藏就實現了,而且很徹底。接下來看繼承。
? ? 什么是繼承?在面向對象層面上不講了,只說語法層面。語法層面上講,繼承就是派生類擁有父類的數據、方法,又添了點自己的東西,所謂子承父業,發揚光大。在 C 語言中可以用結構體的包含來實現繼承關系。代碼框架如下:
? ? 繼承在語法層面上看,有數據成員、函數,數據成員通過上面的方法自動就“繼承”了,至于函數,在結構體表示為函數指針,其實也是一個數據成員,是個指針而已,也會自動“繼承”。之所以還要在這里列出來說明,是因為 C++ 中有一個很重要的概念:重載。要在 C 中完整實現有點兒麻煩。
? ? 重載,我們常說的重載大概有三種含義:
- 其一,函數重載,指函數名字一樣,參數個數、類型不一樣的函數聲明和實現。由于 C 編譯器的緣故,不支持。不過這個影響不大。
- 其二,重定義或者說覆蓋,指派生類中定義與基類簽名一樣(名字、返回值、參數完全一樣)的非虛函數,這樣派生類的中的函數會覆蓋基類的同簽名函數,通過成員操作符訪問時無法訪問基類的同簽名函數。
- 其三,虛函數重寫,指在派生類中實現基類定義的虛函數或純虛函數。虛函數是實現多態的關鍵,可以在結構體中使用函數指針來表達,但要完全實現,也很麻煩。
? ? 我們平常在交流時通常不明確區分上面三種類型的重載,這里出于習慣,也不作區分。
? ? 好了,第一篇就到這里,有時間會往下續。
? 在?C 語言面向對象編程(一)里說到繼承,這里再詳細說一下。
? ? C++ 中的繼承,從派生類與基類的關系來看(出于對比 C 與 C++,只說公有繼承):
- 派生類內部可以直接使用基類的 public 、protected 成員(包括變量和函數)
- 使用派生類的對象,可以像訪問派生類自己的成員一樣訪問基類的成員
- ?對于被派生類覆蓋的基類的非虛函數,在派生類中可以通過基類名和域作用符(::)來訪問
- 當使用基類指針調用虛函數時,會調用指針指向的實際對象實現的函數,如果該對象未重載該虛函數,則沿繼承層次,逐級回溯,直到找到一個實現
? ? 先看 C 語言中通過“包含”模擬實現繼承的簡單代碼框架:
[cpp]?view plaincopy
? ? 上面的示例只有數據成員,函數成員其實是個指針,可以看作數據成員。 C 中的 struct 沒有訪問控制,默認都是公有訪問(與 java 不同)。
? ? 下面是帶成員函數的結構體:
[cpp]?view plaincopy
? ? 為了像 C++ 中一樣通過類實例來訪問成員函數,必須將結構體內的函數指針的第一個參數定義為自身的指針,在調用時傳入函數指針所屬的結構體實例。這是因為 C 語言中不存在像 C++ 中那樣的 this 指針,如果我們不顯式地通過參數提供,那么在函數內部就無法訪問結構體實例的其它成員。
? ? 下面是在 c 文件中實現的函數:
[cpp]?view plaincopy
? ? C++ 的 new 操作符會調用構造函數,對類實例進行初始化。 C 語言中只有 malloc 函數族來分配內存塊,我們沒有機會來自動初始化結構體的成員,只能自己增加一個函數。如下面這樣(略去頭文件中的聲明語句):
[cpp]?view plaincopy
? ? 好的,構造函數有了。通過 new_base() 調用返回的結構體指針,已經可以像類實例一樣使用了:
[cpp]?view plaincopy
? ? 到這里我們已經知道如何在 C 語言中實現一個基本的“類”了。接下來一一來看前面提到的幾點。
? ?第一點,派生類內部可以直接使用基類的 public 、protected 成員(包括變量和函數)。具體到上面的例子,我們可以在 derived_func2 中訪問基類 base 的成員 a 和 func1 ,沒有任何問題,只不過是顯式通過 derived 的第一個成員 parent 來訪問:
?
[cpp]?view plaincopy
[cpp]?view plaincopy
? ? 第三點,對于被派生類覆蓋的基類的非虛函數,在派生類中可以通過基類名和域作用符(::)來訪問。其實通過前兩點,我們已經熟悉了在 C 中訪問“基類”成員的方法,總是要通過“派生類”包含的放在結構體第一個位置的基類類型的成員變量來訪問。所以在 C 中,嚴格來講,實際上不存在覆蓋這種情況。即便定義了完全一樣的函數指針,也沒有關系,因為“包含”這種方式,已經從根本上分割了“基類”和“派生類”的成員,它們不在一個街區,不會沖突。
? ? 下面是一個所謂覆蓋的例子:
[cpp]?view plaincopy
? ? 第四點,虛函數。虛函數是 C++ 里面最有意義的一個特性,是多態的基礎,要想講明白比較困難,我們接下來專門寫一篇文章講述如何在 C 中實現類似虛函數的效果,實現多態。
? ? 回顧一下:
?? 在《 C++ 編程思想》一書中對虛函數的實現機制有詳細的描述,一般的編譯器通過虛函數表,在編譯時插入一段隱藏的代碼,保存類型信息和虛函數地址,而在調用時,這段隱藏的代碼可以找到和實際對象一致的虛函數實現。
? ? 我們在這里提供一個 C 中的實現,模仿 VTABLE 這種機制,但一切都需要我們自己在代碼中裝配。
? ? 之前在網上看到一篇描述 C 語言實現虛函數和多態的文章,談到在基類中保存派生類的指針、在派生類中保存基類的指針來實現相互調用,保障基類、派生類在使用虛函數時的行為和 C++ 類似。我覺得這種方法有很大的局限性,不說繼承層次的問題,單單是在基類中保存派生類指針這一做法,就已經違反了虛函數和多態的本意——多態就是要通過基類接口來使用派生類,如果基類還需要知道派生類的信息……。
? ? 我的基本思路是:
- 在“基類”中顯式聲明一個 void** 成員,作為數組保存基類定義的所有函數指針,同時聲明一個 int 類型的成員,指明 void* 數組的長度。
- “基類”定義的每個函數指針在數組中的位置、順序是固定的,這是約定,必須的
- 每個“派生類”都必須填充基類的函數指針數組(可能要動態增長),沒有重寫虛函數時,對應位置置 0
- “基類”的函數實現中,遍歷函數指針數組,找到繼承層次中的最后一個非 0 的函數指針,就是實際應該調用的和對象相對應的函數實現
? ? 好了,先來看一點代碼:
[cpp]?view plaincopy
? ? 注意,derived 和 derived_2 并沒有定義 func_1 和 func_2 。在 C 的虛函數實現中,如果派生類要重寫虛函數,不需要在派生類中顯式聲明。要做的是,在實現文件中實現你要重寫的函數,在構造函數中把重寫的函數填入虛函數表。
? ? 我們面臨一個問題,派生類不知道基類的函數實現在什么地方(從高內聚、低耦合的原則來看),在構造派生類實例時,如何初始化虛函數表?在 C++ 中編譯器會自動調用繼承層次上所有父(祖先)類的構造函數,也可以顯式在派生類的構造函數的初始化列表中調用基類的構造函數。怎么辦?
? ? 我們提供一個不那么優雅的解決辦法:
? ? 每個類在實現時,都提供兩個函數,一個構造函數,一個初始化函數,前者用戶生成一個類,后者用于繼承層次緊接自己的類來調用以便正確初始化虛函數表。依據這樣的原則,一個派生類,只需要調用直接基類的初始化函數即可,每個派生類都保證這一點,一切都可以進行下去。
? ? 下面是要實現的兩個函數:
[cpp]?view plaincopy
[cpp]?view plaincopy
? ? 說完了構造函數,對應的要說析構函數,而且析構函數要是虛函數。在刪除一個對象時,需要從派生類的析構函數依次調用到繼承層次最頂層的基類的析構函數。這點在 C 中也是可以保障的。做法是:給基類顯式聲明一個析構函數,基類的實現中查找虛函數表,從后往前調用即可。函數聲明如下:
[cpp]?view plaincopy
? ? 說完構造、析構,該說這里的虛函數表到底是怎么回事了。我們先畫個圖,還是以剛才的 base 、 derived 、derived_2 為例來說明,一看圖就明白了:
? ? 我們假定 derived 類實現了三個虛函數, derived_2 類實現了兩個,func_2 沒有實現,上圖就是 derived_2 的實例所擁有的最終的虛函數表,表的長度( vt_size )是 9?。如果是 derived 的實例,就沒有表中的最后三項,表的長度( vt_size )是 6 。
? ? 必須限制的是:基類必須實現所有的虛函數,只有這樣,這套實現機制才可以運轉下去。因為一切的發生是從基類的實現函數進入,通過遍歷虛函數表來找到派生類的實現函數的。
? ? 當我們通過 base 類型的指針(實際指向 derived_2 的實例)來訪問 func_1 時,基類實現的 func_1 會找到 VTABLE 中的 derived_2_func_1 進行調用。
??
? ? 好啦,到現在為止,基本說明白了實現原理,至于 初始化函數如何裝配虛函數表、基類的虛函數實現,可以根據上面的思路寫出代碼來。按照我的這種方法實現的虛函數,通過基類指針訪問,行為基本和 C++ 一致。
? ?回顧一下:
?Java 中有 interface 關鍵字,C++ 中有抽象類或純虛類可以與 interface 比擬,C 語言中也可以實現類似的特性。
? ? 在面試 Java 程序員時我經常問的一個問題是:接口和抽象類有什么區別。
? ? 很多編程書籍也經常說要面向接口編程,我的理解是,接口強制派生類必須實現基類(接口)定義的契約,而抽象類則允許實現繼承從而導致派生類可以不實現(重寫)基類(接口)定義的契約。通常這不是問題,但在有一些特定的情況,看起來不那么合適。
? ? 比如定義一個 Shape 基類,其中定義一個 draw() 方法,給一個什么都不做的默認實現(通常是空函數體),這實際沒有任何意義。
? ? 再比如基類改變某個方法的實現,而派生類采用實現繼承并沒有重寫這個方法,此時可能會導致一些奇怪的問題。以鳥為例,基類為 Bird ,我們可能會定義一個 fly() 方法,一個 walk() 方法,因為有的人認為鳥既可以走又可以飛。開始時我們在 walk() 的實現里作了假定,認為雙腳交叉前進才是 walk ,可是后來發現有些鳥是雙腳一齊蹦的,不會交叉前進。這個時候怎么辦?基類 Bird 的 walk() 方法是否要修改、如何修改?
? ? 在 C++ 中,沒有接口關鍵字 interface ,同時為了代碼復用,經常采用實現繼承。在 C 語言中,我們前面幾篇文章討論了封裝、隱藏、繼承、虛函數、多態等概念,雖然都可以實現,但使用起來總不如自帶這些特性的語言(如 C++ 、Java )等得心應手。一旦你采用我們前面描述的方法來進行面向對象編程,就會發現,在 C 語言中正確的維護類層次是一件非常繁瑣、容易出錯的事情,而且要比面向對象的語言多寫很多代碼(這很容易理解,面向對象語言自帶輪子,而 C 要自己造輪子,每實現一個類都要造一遍)。但有一點,當我們使用 C 語言作面向對象編程時,比 C++ 有明顯的優勢,那就是接口。
? ? 接口強制派生類實現,這點在 C 中很容易做到。而且我們在編程中,實際上多數時候也不需要那么多的繼承層次,一個接口類作為基類,一個實現類繼承接口類,這基本就夠了。在 C 語言中采用這種方式,可以不考慮析構函數、超過 3 層繼承的上下類型轉換、虛函數調用回溯、虛函數表裝配等等問題,我們所要做的,就是實現基類接口,通過基類指針,就只能操作繼承層次中最底層的那個類的對象;而基類接口,天生就是不能實例化的(其實是實例化了沒辦法使用,因為結構體的函數指針沒人給它賦值)。
? ? 一個示例如下:
[cpp]?view plaincopy
? ? 上面是頭文件,derived 結構體通過包含 base_interface 類型的成員 bi 來達到繼承的效果;而 base_interface 無法實例化,我們沒有提供相應的構造函數,也沒有提供與 func_1 , func_2 等函數指針對應的實現,即便有人 malloc 了一個 base_interface ,也無法使用。
? ? derived 類可以提供一個構造函數 new_derived ,同時在實現文件中提供 func_1 , func_2 ,func_3 的實現并將函數地址賦值給 bi 的成員,從而完成 derived 類的裝配,實現 base_interface 定義的契約。
? ? 示例實現如下:
[cpp]?view plaincopy
? ? 我們可以這么使用 base_interface 接口:
[cpp]?view plaincopy
? ? 上面的代碼中 do_something 函數完全按照接口編程,而 bi 可以實際指向任意一個實現了 base_interface 接口的類的實例,在一定程序上達到多態的效果,花費的代價相當小,卻可以讓我們的程序提高可擴展性,降低耦合。
? ? 這種簡單的方法也是我在自己的項目中使用的方法,效果不錯。
? ? 好啦,C 語言面向對象編程系列的基礎性介紹就告一段落,下面是前幾篇的鏈接,有興趣的可以回頭看看:
總結
- 上一篇: 面向对象的C语言开发框架:Nesty
- 下一篇: 用 C 语言实现面向对象编程