我們都知道,C++中最重要的概念——類,了解了類之後,已經可以開始做些編程方面比較高級的應用——設計程序,而不再只是將算法變成代碼。要說明如何設計程序,有必要先了解何謂面向對象編程思想。建議大家閱讀這一系列的文章,供大家參考。接上一篇>>
面向對象編程思想
前面已說明設計程序就是編寫程序欲解決的問題的描述,也就是編寫論調。而論調可以只用“名詞性概念”和“動詞性概念”表現出來,對象又正好是“名詞性概念”的實現,而利用前面說的沒有成員變量的類來映射“動詞性概念”就可以將其轉換為對象。因此,一個世界,可以完全由對象組成,而將算法所基於的世界只用對象表現出來,再進行後續代碼的編寫,這種編程方法就被稱作面向對象的編程思想。
注意,先設計算法應基於的世界,再全部用對象將其表述出來,然後再設計算法,最後映射為代碼。但前面在編寫商人過河問題時是直接給出算法的,並沒有設計世界啊?其實由於那個問題的過於簡單,我直接下意識地設計了世界,並且用前面所說的河岸論來描述它。應注意世界的設計完全依賴於問題,而准確地說,前面我並沒有設計世界,而是設計了河岸論來描述問題。
接著,由於對象就是實例,因此以對象來描述世界在C++中就是設計類,通過類的實例來組合表現世界。但應注意,面向對象是以對象來描述世界,但也描述算法,因為算法也會提出一些需要被映射的概念,如前面商人過河問題的算法中的過河方案。
但切記,當描述算法時操作了描述世界時定義的類,則一定要保持那個類的設計,不要因為算法中對那個類的實例的操作過於復雜而將那部分算法映射為這個類的一個成員函數,因為這嚴重遮蔽了算法的實現,破壞了程序的架構。如一個算法是讓汽車原地不停打轉,需要復雜的操作,那麼難道給汽車加一個功能,讓它能原地不停地打轉?!這是在設計類的時候經常犯的錯誤,也由於這個原因,一個面向對象編寫的代碼並不是想象的只由類組成,其也可能由於將算法中的某些操作映射成函數而有大量的全局函數。
請記住:設計類時,如果是映射世界裡的概念,不要考慮算法,只以這個世界為邊界來設計它,不要因為算法裡的某個需要而給它加上錯誤的成員。
因此,將“名詞性概念”映射成類,“名詞性概念”的屬性和狀態映射為成員變量,“名詞性概念”的功能映射為成員函數。那麼“動詞性概念”怎麼辦?映射成沒有成員變量的類?前面也看見,由於過於別扭,實際中這種做法並不常見STL中也只是將其作為一種技巧),故經常是將它映射為函數,雖然這有背於面向對象的思想,但要易於理解得多,進而程序的架構要簡明得多。
隨著面向對象編程思想的問世,一種全新的設計方式誕生了。由於它是如此的好以至於廣為流傳,但理解的錯誤導致錯誤的思想遍地而生,更糟糕的就是本末倒置,將這個設計方式稱作面向對象的編程思想,它的名字就是封裝。
封裝
先來看現在在各類VC教程中關於對象的講解中經常能看見的如下的一個類的設計。
- class Person
- { private: char m_Name[20]; unsigned long m_Age; bool m_Sex;
- public: const char* GetName() const; void SetName( const char* );
- unsigned long GetAge() const; void SetAge( unsigned long );
- bool GetSex() const; void SetSex( bool );
- };
上面將成員變量全部定義為private,然後又提供三對Get/Set函數來存取上面的三個成員變量因為它們是private,外界不能直接存取),這三對函數都是public的,為什麼要這樣?那些教材將此稱作封裝,是對類Person的內部內存布局的封裝,這樣外界就不知道其在內存上是如何布局的並進而可以保證內存的有效性只由類自身操作其實例)。
首先要確認上面設計的荒謬性,它是正宗的“有門沒鎖”毫無意義。接著再看所謂的對內存布局的封裝。假設上面是在Person.h中的聲明,然後在b.cpp中要使用類Person,本來要#include "Person.h",現在替換成下面:
- class Person
- { public: char m_Name[20]; unsigned long m_Age; bool m_Sex;
- public: const char* GetName() const; void SetName( const char* );
- unsigned long GetAge() const; void SetAge( unsigned long );
- bool GetSex() const; void SetSex( bool );
- };
然後在b.cpp中照常使用類Person,如下:
- Person a, b; a.m_Age = 20; b.GetSex();
這裡就直接使用了Person::m_Age了,就算不做這樣蹩腳的動作,依舊#include "Person.h",如下:
- struct PERSON { char m_Name[20]; unsigned long m_Age; bool m_Sex; };
- Person a, b; PERSON *pP = ( PERSON* )&a; pP->m_Age = 40;
上面依舊直接修改了Person的實例a的成員Person::m_Age,如何能隱藏內存布局?!請回想聲明的作用,類的內存布局是編譯器生成對象時必須的,根本不能對任何使用對象的代碼隱藏有關對象實現的任何東西,否則編譯器無法編譯相應的代碼。
那麼從語義上來看。Person映射的不是真實世界中的人的概念,應該是存放某個數據庫中的某個記錄人員信息的表中的記錄的緩沖區,那麼緩沖區應該具備那三對Get/Set所代表的功能嗎?緩沖區是緩沖數據用的,緩沖後被其它操作使用,就好像箱子,只是放東西用。
故上面的三對Get/Set沒有存在的必要,而三個成員變量則不能是 private。當然,如果Person映射的並不是緩沖區,而在其它的世界中具備像上面那樣表現的語義,則像上面那樣定義就沒有問題,但如果是因為對內存布局的封裝而那樣定義類則是大錯特錯的。
上面錯誤的根本在於沒有理解何謂封裝。為了說明封裝,先看下MFCMicrosoft Foundation Class Library——微軟功能類庫,一個定義了許多類的庫文件,其中的絕大部分類是封裝設計。關於庫文件在說明SDK時闡述)中的類CFile的定義。
從名字就可看出它映射的是操作系統中文件的概念,但它卻有這樣的成員函數——CFile::Open、CFile::Close、CFile::Read、 CFile::Write,有什麼問題?這四個成員函數映射的都是對文件的操作而不是文件所具備的功能,分別為打開文件、關閉文件、從文件讀數據、向文件寫數據。這不是和前面說的成員函數的語義相背嗎?
上面四個操作有個共性,都是施加於文件這個資源上的操作,可以將它們叫做“被功能”,如文件具有“被打開”的功能,具有“被讀取”的功能,但應注意它們實際並不是文件的功能。
按照原來的說法,應該將文件映射為一個結構,如FILE,然後上面的四個操作應映射成四個函數,再利用名字空間的功能,如下:
- namespace OFILE
- {
- bool Open( FILE&, … ); bool Close( FILE&, … );
- bool Read( FILE&, … ); bool Write( FILE&, … );
- }
上面的名字空間OFILE表示裡面的四個函數都是對文件的操作,但四個函數都帶有一個FILE&的參數。回想非靜態成員函數都有個隱藏的參數this,因此,一個了不起的想法誕生了。
將所有對某種資源的操作的集合看成是一種資源,把它映射成一個類,則這個類的對象就是對某個對象的操作,此法被稱作封裝,而那個類被稱作包裝類或封裝類。
很明顯,包裝類映射的是“對某種資源的操作”,是一抽象概念,即包裝類的對象都是無狀態對象指邏輯上應該是無狀態對象,但如果多個操作間有聯系,則還是可能有狀態的,但此時它的語義也相應地有些變化。如多一個CFile::Flush成員函數,用於刷新緩沖區內容,則此時就至少有一個狀態——緩沖區,還可有一個狀態記錄是否已經調用過CFile::Write,沒有則不用刷新)。
現在應能了解封裝的含義了。將對某種資源的操作封裝成一個類,此包裝類映射的不是世界中定義的某一“名詞性概念”,而是世界的“動詞性概念”或算法中“對某一概念的操作”這個人為定出來的抽象概念。由於包裝類是對某種資源的操作的封裝,則包裝類對象一定有個屬性指明被操作的對象,對於MFC中的 CFile,就是CFile::m_hFile成員變量類型為HANDLE),其在包裝類對象的主要運作過程前面的CFile::Read和 CFile::Write)中被讀。
有什麼好處?封裝提供了一種手段以將世界中的部分“動詞性概念”轉換成對象,使得程序的架構更加簡單多條“動詞性概念”變成一個“名詞性概念”,減少了“動詞性概念”的數量),更趨於面向對象的編程思想。
但應區別開包裝類對象和被包裝的對象。包裝類對象只是個外殼,而被包裝的對象一定是個具有狀態的對象,因為操作就是改變資源的狀態。對於 CFile,CFile的實例是包裝類對象,其保持著一個對被包裝對象——文件內核對象Windows操作系統中定義的一種資源,用HANDLE的實例表征)——的引用,放在CFile::m_hFile中。
因此,包裝類對象是獨立於被包裝對象的。即CFile a;,此時a.m_hFile的值為0或-1,表示其引用的對象是無效的,因此如果a.Read( … );將失敗,因為操作施加的資源是無效的。
對此,就應先調用a.Open( … );以將a和一特定的文件內核對象綁定起來,而調用a.Close( … );將解除綁定。注意CFile::Close調用後只是解除了綁定,並不代表a已經被銷毀了,因為a映射的並不是文件內核對象,而是對文件內核對象操作的包裝類對象。
如果仔細想想,就會發現,老虎能夠吃兔子,兔子能夠被吃,那這裡應該是老虎有個功能是“吃兔子”還是多個兔子的包裝類來封裝“吃兔子”的操作?
這其實不存在任何問題,“老虎吃兔子”和“兔子被吃”完全是兩個不同的操作,前者涉及兩種資源,後者只涉及一種資源,因此可以同時實現兩者,具體應視各自在相應世界中的語義。如果對於真實世界,則可以簡略地說老虎有個“吃”的功能,可以吃“肉”,而動物從“肉”和“自主能動性”多重繼承,兔子再從動物繼承。
這裡有個類叫“自主能動性”,指動物具有意識,能夠自己動作,這在C++中的表現就是有成員函數的類,表示有功能可以被操作,但收音機也具有調台等功能,難道說收音機也能自己動?!這就是世界的意義——運轉。
希望通過本文的介紹,能夠對你有幫助。