本專題,我們講述封裝性。封裝性是C++的入門特性,要想學習C++語言,封裝性是首先要掌握的。下面我們進入正題:
一、 類與物件
早在本系列第一節課(理解程式中的資料)的時候講述過資料型別與資料的區別和聯絡當時得出的結論如下:
Ø 資料型別規定了資料的大小和表現形式
Ø 資料就是電腦中存放的數。
Ø 每個資料都有自己的地址,而這些地址可以有變數來代替
Ø 因此,資料通常存放於變數(或常量)中
這個結論在C++中仍然同樣適用,類就是我們自己定義的複雜的資料型別,而物件則就是由類宣告的變數。下面我們進入純語法層面。
1、 類的定義方法
我相信,大家都還記得我在第一節課的時候講述的結構體的課程,也相信大家沒有忘記怎麼定義一個結構體。下面我給出類的定義方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
class CExample // 是不是很像定義一個結構體 { private: // 許可權控制,相關內容在下面的小節中詳細講述 int m_nFirstNum; //定義成員變數。也叫屬性 int m_nSecNum; public: int GetSum(){return m_nFirstNum} // 成員函式 bool SetNum(int nFirst,int nSec) { m_nFirstNum = nFirst; m_nSecNum = nSec ; return true; } CExample(){m_nFirstNum = 0; m_nSecNum = 0;} //建構函式 ~CExample(){} // 空析構 }; |
當然,上面這個類的定義是不是很像定義一個結構體?只不過多了個private和public還有一些函式。是的,C++裡面,將結構體升級了,結構體裡面可以有函式成員了,為了相容,換了個關鍵字,當然,上面的這個class完全可以改成struct,一點問題都沒有。
好奇的朋友會問:如果函式體的語句太多,邏輯複雜了,函式很大,那這個類豈不是很難看,太臃腫了吧。
是的,為了方便類的組織,也為了協調專案工程中檔案的組織。上面的類還可以寫成如下的形式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
// .h檔案中寫如下的宣告部分 class CExample // 是不是很像定義一個結構體 { private: // 許可權控制,相關內容在下面的小節中詳細講述 int m_nFirstNum; // 定義成員變數。也叫屬性 int m_nSecNum; public: int GetSum(); // 成員函式 bool SetNum(int nFirst,int nSec); CExample(); //建構函式 ~CExample(); // 空析構 }; // .cpp 檔案中寫如下的定義及實現部分 CExample::CExample() { } CExample::~CExample() { } int CExample::GetFirstNum() { return m_nFirstNum; } int CExample::GetSecNum() { return m_nSecNum; } bool CExample::SetNum(int nFirst, int nSec) { m_nFirstNum = nFirst; m_nSecNum = nSec ; return true; } int CExample::GetSum() { return m_nFirstNum+m_nSecNum; } |
上面兩種寫法也是有區別的,第一種方法寫的函式具有Inline函式的特性。後一種則沒有。
2、 屬性和方法的使用
C++中定義一個物件跟定義一個函式沒有什麼區別。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <stdio.h> #include "Example.h" int main(int argc, char* argv[]) { CExample obj_Exp; // 定義一個物件 obj_Exp.SetNum(10,20); // 呼叫一個方法 printf("%d+%d = %d\r\n", obj_Exp.GetFirstNum(), obj_Exp.GetSecNum(), obj_Exp.GetSum()); return 0; } |
由此,我們就可以通過一個函式間接的來操作我們的變數,使用者在給我們的變數賦值時,我們可以通過Set函式來對輸入的內容作檢測,在獲取一個變數的內容時,我們可以通過一個函式來取得,這樣都提高了程式安全性。
從程式設計的角度來講,如果我們以類為單位編碼的話,每個模組都是獨立的我們只要關注與本類相關操作,比如人這個類,它一般情況下有兩個眼睛、一個嘴巴等之類的屬性,人可以使用工具,可以行走,可以跳躍等方法。我們編寫的所有的函式都是針對這些展開的。而不用去關心誰要使用這個類。因此,類/物件概念的加入,不單單是給編碼方式做了改變,主要是設計思路的改變,程式模組化的改變等等。
二、 解析物件的記憶體結構
現在,我相信,如果習慣了我這種學習方式的朋友一定會很好奇,類定義物件的記憶體格式是怎樣的,它是不是像一個普通變數那樣,或者是不是像一個結構體變數那樣在記憶體裡連續的將各個成員組織到一起,它又是怎樣將各個成員變數還有函式繫結到一起的?變數和函式在一起它是怎麼組織的?本小節讓我們來解決這些問題。
為節省篇幅,我們仍舊使用上面的程式碼。我們用VC的偵錯程式,除錯這個程式碼:
注意看我們的變數監視區,我們定義的物件的內容跟結構體成員的內容格式差不多,(是按照定義的順序連續存放的,這點跟普通的區域性變數不一樣,普通的區域性變數在記憶體中的順序與定義順序相反)記憶體中只存放了成員變數,它並沒有標出SetNum的位置,那它是怎麼找到SetNum這個函式的呢?
根據我們先前除錯C函式的經驗,我們知道,函式的程式碼是被放在可執行檔案的程式碼區段中的。在這個程式碼中,也有呼叫SetNum的程式碼,我們詳細的跟一下它的彙編程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
10: CExample obj_Exp; 004011ED lea ecx,[ebp-14h] 004011F0 call @ILT+15(CExample::CExample) (00401014) 004011F5 mov dword ptr [ebp-4],0 11: obj_Exp.SetNum(10,20); 004011FC push 14h 004011FE push 0Ah 00401200 lea ecx,[ebp-14h] 00401203 call @ILT+0(CExample::SetNum) (00401005) |
這段程式碼又給我們帶來了新的問題,我們只用類定義了一個物件(變數),它自動的呼叫了一個函式,根據註釋我們知道它呼叫的是建構函式。我們跟進去看下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
11: CExample::CExample() 12: { 00401050 push ebp 00401051 mov ebp,esp 00401053 sub esp,44h 00401056 push ebx 00401057 push esi 00401058 push edi 00401059 push ecx ; 儲存暫存器環境 0040105A lea edi,[ebp-44h] 0040105D mov ecx,11h 00401062 mov eax,0CCCCCCCCh 00401067 rep stos dword ptr [edi] ; 將棧空間清為CC(Release編譯就沒有這部分程式碼了。) 00401069 pop ecx 0040106A mov dword ptr [ebp-4],ecx ; 將 ECX中的內容給區域性變數 13: } 0040106D mov eax,dword ptr [ebp-4] ; 將ECX的內容返回 00401070 pop edi 00401071 pop esi 00401072 pop ebx 00401073 mov esp,ebp 00401075 pop ebp 00401076 ret |
這段程式碼,首次看還真看不出個所以然來,原始碼的建構函式中,我們什麼都沒寫,是個空函式,而這裡做的是返回ECX的值,可是這個函式也沒有對ECX做什麼特別的操作,而是直接使用進函式時ECX的值。那隻能說明在呼叫這個函式前,ECX發生了變化。我們再回頭看下呼叫建構函式的程式碼:
1 2 3 4 5 6 7 |
10: CExample obj_Exp; 004011ED lea ecx,[ebp-14h] 004011F0 call @ILT+15(CExample::CExample) (00401014) 004011F5 mov dword ptr [ebp-4],0 |
哈哈,它是把我們obj_Exp物件的地址給了ECX,然後呼叫構造返回的,也就是說,構造的返回值是我們物件的首地址。哎,迷糊了,真搞不懂這是在幹什麼。先不管他,我們繼續看怎麼呼叫的SetNum這個函式吧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
11: obj_Exp.SetNum(10, 20); 004011FC push 14h ; 傳遞引數 004011FE push 0Ah 00401200 lea ecx,[ebp-14h] ; 也有這句,還是把我們的物件首地址給ECX 00401203 call @ILT+0(CExample::SetNum) (00401005) 29: bool CExample::SetNum(int nFirst, int nSec) 30: { 00401130 push ebp 00401131 mov ebp,esp 00401133 sub esp,44h 00401136 push ebx 00401137 push esi 00401138 push edi 00401139 push ecx 0040113A lea edi,[ebp-44h] 0040113D mov ecx,11h 00401142 mov eax,0CCCCCCCCh 00401147 rep stos dword ptr [edi] 00401149 pop ecx 0040114A mov dword ptr [ebp-4],ecx ; 備份一下我們的物件首地址 31: m_nFirstNum = nFirst; 0040114D mov eax,dword ptr [ebp-4] ; 取出物件首地址 00401150 mov ecx,dword ptr [ebp+8] ; 取出nFirst引數 00401153 mov dword ptr [eax],ecx ; 給物件首地址指向的內容賦值為nFirst的內容 32: m_nSecNum = nSec ; 00401155 mov eax,dword ptr [ebp-4] ; 取出物件首地址 00401158 mov ecx,dword ptr [ebp+0Ch]; 取出nSec引數 0040115B mov dword ptr [eax+4],ecx ; 給物件首地址+4指向的你內容賦值 return true; 0040115E mov al,1 ; 返回1 34: } 00401160 pop edi 00401161 pop esi 00401162 pop ebx 00401163 mov esp,ebp 00401165 pop ebp 00401166 ret 8 |
我簡要的註釋下來一下上面的程式碼。通過分析上面的程式碼,我們可以得出這樣的結論:
A、 函式通過ecx傳遞了我們物件的首地址。
B、 函式通過ecx傳遞的物件首地址定位物件的每個成員變數。
這樣,很明顯,ECX起到了傳遞引數的作用,這時ecx中的地址有個專業術語,叫做this指標。
OK,這就是一個新的知識點,我們成員函式的呼叫方式。
1、 成員函式的呼叫方式: __thiscall
記得在前面章節介紹函式時,講過一些呼叫方式,但是沒有提到過這種呼叫方式。下面我做一個簡要的總結:
A、 引數也通過棧傳遞。
B、 它用一個暫存器來傳遞this指標。
C、 本條特性摘自《加密與解密》(第三版)非原文:
a) 對於VC++中傳參規則:
i. 最左邊兩個不大於4位元組的引數分別用ECX和EDX傳引數.
ii. 對於浮點數、遠指標、__int64型別總是通過棧來傳遞的。
b) 對於BC++|DELPHI中的傳遞規則:
i. 最左邊三個不大於DWORD的引數,依次使用EAX,ECX,EDX傳遞,其它多的引數依次通過PASCAL方式傳遞。
這樣,函式的地址還是在程式碼區域,物件的記憶體中只存放資料成員,當我們要呼叫成員函式時,就通過一個暫存器將函式操作的物件的首地址(也就是this指標)傳遞過去就可以了,傳遞不同的物件指標,就操作不同的資料。哈哈,太巧妙了。
2、 淺談構造與解構函式
OK,繼續除錯程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
13: printf("%d+%d = %d\r\n", obj_Exp.GetFirstNum(), 14: obj_Exp.GetSecNum(), 15: obj_Exp.GetSum()); 00401208 lea ecx,[ebp-14h] 0040120B call @ILT+30(CExample::GetSum) (00401023) ; 呼叫GetSum函式 00401210 push eax 00401211 lea ecx,[ebp-14h] 00401214 call @ILT+5(CExample::GetSecNum) (0040100a) 00401219 push eax 0040121A lea ecx,[ebp-14h] 0040121D call @ILT+10(CExample::GetFirstNum) (0040100f) 00401222 push eax 00401223 push offset string "%d+%d = %d\r\n" (0042501c) 00401228 call printf (00401290) 0040122D add esp,10h 16: 17: return 0; 00401230 mov dword ptr [ebp-18h],0 00401237 mov dword ptr [ebp-4],0FFFFFFFFh 0040123E lea ecx,[ebp-14h] 00401241 call @ILT+20(CExample::~CExample) (00401019); 呼叫解構函式 00401246 mov eax,dword ptr [ebp-18h] |
我們至始至終都沒有呼叫過構造和解構函式。但是,通過這次調我們知道,在建立一個物件(變數)的時候,我們的程式會自動的呼叫我們的建構函式,在要出物件作用域的時候,會自動的呼叫解構函式。
這樣,我們很容易就能想象出,構造和析構的用途:構造就做初始化物件的各個成員,申請空間等初始化工作。析構就做一些釋放申請的空間啊之類的清理工作。
就這樣,C++將資料跟函式封裝到了一起,這樣我們每個類產生的物件都是一個獨立的個體,它有一個自己的運作方式,幾乎完全獨立。在我們使用它的時候,根本不需要它是怎麼實現了,只要知道怎麼使用即可。
三、 淺談類的靜態成員
通過前面幾節的學習,我們大概的能理解類的封裝性及其運作過程,但是,如果我們繼續深入的學習C++,我們很快就能發現一個問題:我們上面說的所有的成員都是屬於物件的,也就是說,我們必須先通過類來定義一個物件才可以操作。但是有的時候,我們需要一些屬於類的成員,比如:人都有一個腦袋,這一個腦袋屬於人類共有的特性。不需要具體到哪一個人,我們都可以確定人只有一個腦袋。
放到類中也一樣,比如我們需要知道當前這個類建立了幾個物件的時候,我們不必在建立一個新的物件只需要使用類的相關函式或者直接訪問類的某些屬性就可以了,而這些函式或者變數,它肯定不可能屬於某個物件,它應該屬於這個類本身。
OK,下面就來體驗一下靜態帶給我們的一些好處。同樣,我們將前面的程式碼新增點兒東西(見Exp02):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
public: static int m_nCount; //統計產生物件的 static int print(constchar *szFormat, ...); // 讓我們的類有自己的輸出函式 // .cpp檔案中 int CExample::m_nCount = 0; // 初始化靜態成員變數 CExample::CExample() { m_nCount++; // 當建立一個物件的時候,這個變數加1 } CExample::~CExample() { if (m_nCount > 0) { m_nCount--; // 當物件銷燬時,這個變數減1 } } /************************************************************************/ /*讓我們的CExample可以列印自己的資訊 /*支援多參,同printf用法相同 /************************************************************************/ int CExample::print(constchar *szFormat, ...) { if (!szFormat) { return 0; } va_list pArgs; char szBuffer[256 * 15] = {0}; va_start(pArgs,szFormat); vsprintf(szBuffer,szFormat, pArgs); va_end(pArgs); printf(szBuffer); return strlen(szFormat); } |
好,有了這些,我們可以編寫如下的測試程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
#include "stdafx.h" #include <stdio.h> #include "Example.h" int main(int argc, char* argv[]) { CExample obj_Exp1; CExample::print("當前物件的數量為:%d\r\n", CExample::m_nCount); if (1) { CExample obj_Exp2;// 該物件屬於if作用域,出了if,物件自動銷燬 CExample::print("當前物件的數量為:%d\r\n", CExample::m_nCount); } CExample::print("當前物件的數量為:%d\r\n", CExample::m_nCount); return 0; } |
我想大家應該能想象出來執行的結果:
好,我們除錯一下這段程式:
1 2 3 4 5 6 7 8 9 10 11 |
11: CExample::print("當前物件的數量為:%d\r\n", CExample::m_nCount); 004012EC mov eax,[CExample::m_nCount (0042ae6c)] ;這明顯告訴我們,靜態就是全域性 004012F1 push eax 004012F2 push offset string "當前物件的數量為:%d\r\n" 004012F7 call @ILT+30(CExample::print) (00401023) ;呼叫該靜態函式沒有傳遞this指標 004012FC add esp,8 |
多了不用看了,通過這段程式碼,我們很明顯就可以清楚,靜態變數,不屬於類物件,它存放於全域性資料區,同全域性變數在一個地方(更多關於靜態變數的相關說明見我發的《static學習筆記》一文)。
靜態函式,跟全域性函式一樣,它雖然在原始碼中書寫與類內,但是它其實就是一個全域性函式,不傳遞this指標,因此,在使用靜態函式時需要知道,靜態函式中不能呼叫其它普通的成員函式也不能引用普通的成員變數。但是反過來,在其它的成員函式中可以呼叫靜態函式也可以使用靜態變數。
四、 打破類封裝性的棒槌 —— 友元
當我們的工程總類越來越多的時候,我們很難避免類與類之間相互操作。倘若每個類之間相互操作都通過函式進行,那可想我們編寫程式碼是多麼的繁瑣,想投機取巧走小路麼?是的,C++提供了這種捷徑,讓我們直接對一個類的私有成員進行操作——當然,我極力反對這種做法,因為它打破了類的封裝性。
我不推薦使用友元在這裡仍然講述友元並不是我無聊也不是我寫它裝門面。因為我們再後面學習用全域性函式運算子的時候,不可避免的要使用到它。
宣告友元的關鍵字是friend,OK,看程式碼,我們還是在上面的程式碼中加點東西(見Exp03):
1 2 3 |
// Example.h friend extern void ShowPrivMem(CExample tmpExpObj); |
宣告ShowPrivMem函式是CExample類的友元函式,這時ShowPrivMem中就可以隨便訪問CExample類中任何一個成員了。
編寫測試程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
void ShowPrivMem(CExample tmpExpObj) { printf("%d+%d = %d\r\n", tmpExpObj.m_nFirstNum, tmpExpObj.m_nSecNum, tmpExpObj.GetSum()); } int main(int argc,char* argv[]) { CExample obj_Exp; obj_Exp.SetNum(10,20); ShowPrivMem(obj_Exp); return 0; } |
當然,這是將一個函式宣告為友元,將另外一個類宣告為自己的友元也是一樣的。由於沒有什麼特別之處,我就不為此特別的寫一個程式碼了。
另外,需要注意的是,將一個類宣告為自己的友元,並不是自己可以訪問那個類的私有成員而是被宣告為友元的類可以訪問自己的私有成員。注意順序別弄反了……。
五、 學習小結
本節講的東西很少,但是很關鍵,除錯C++程式,摸清楚this指標對摸清程式結構,摸清程式資料格式都起著非常重要的作用。
學習本小節的方法,不是理解,而是多寫,多除錯。
祝大家成功。