C++物件導向特性實現機制的初步分析 Part3 (轉)

worldblog發表於2007-12-13
C++物件導向特性實現機制的初步分析 Part3 (轉)[@more@]

Chapter 2 封裝

:namespace prefix = o ns = "urn:schemas--com::office" />

2.1 封裝的目的和意義

廣義的說,封裝是為了使這個世界以更加簡單的方式呈現在我們的面前。買一臺電冰箱,我不必要知道里面機的運作過程,不必瞭解詳細的製冷過程,我所要所的是,只是給它通電,透過調整一些旋鈕來設定溫度,剩下的事情,就不歸我管了。

就面向設計來說,封裝是為了隱藏實現的細節,使類的透過公開的介面來使用類的功能。在C++中,宣告為public的成員,就是這樣的介面,類的使用者可以透過藉口來的修改類的內部資料(設想,如果冰箱廠商讓使用者直接調節壓縮機引數,那將是非常危險的)。對類內部方法的封裝,可以增強程式碼的可複用性,使用者是透過介面在訪問類的功能的,類成員函式的實現細節發生變化,但只要結構不變,所有的使用類的客戶程式碼都不需要修改。

很多人會認為C++中的class的封裝效果,用C中的struct也可以實現。其實不然,看程式碼:

/////////////////////////////////////////////

// Code 2.1

#include

struct s_Test

{

  int (*p_add)(s_Test*); 

  int i;

};

int add(s_Test* p){return ++(p->i);}

class c_Test

{

public:

  c_Test(){i=0;}

  int  add(){return ++i;}

  void show(){cout<

private:

  int i;

};

void main(void)

{

s_Test s;

s.i=0;

s.p_add=add;

s.p_add(&s);

cout<

c_Test c;

c.add();

c.show();

}

//定義結構體 s_Test

//其中包含兩個成員

//p_add為指向函式的指標

//i 為整形資料

//定義引數為指向結構體s_Test的指標的函式

//定義類c_Test

//建構函式對內部資料成員初始化

//成員函式add()完成對內部資料成員i的+1操作

//成員函式show()顯示內部資料成員i的值

//內部資料成員

//建立結構體 s

//給s的資料成員i賦值

//初始化p_add指標,使其指向int add(s_Test* p)

//透過指向函式的指標來add(s_Test* p)

//輸出i的值

//建立類c_Test的物件 c

//呼叫add()成員函式

//使用show()輸出i的值

透過觀察上述的程式碼,我們可以發現,struct可以將資料和操作“包裝”在一起,但這種包裝是鬆散和不安全的。首先,struct沒有訪問控制機制,任何程式碼都可以操作其內部資料i,其次,結構體內指標指向的函式與結構體本身沒有必然的聯絡,任何程式碼都可以呼叫add(s_Test* p)函式。Struct只是一個,結構體中所有的成員對外來說都是可見和可訪問的。再看Class,透過使用public和private關鍵字,類很好的對藉口和內部資料進行的分離。對內部資料的訪問,都要透過成員函式來實現,例如int i的初始化,改變i的值,先是輸出等,都實現了封裝。

我們可以得出這樣的結論

a.封裝的作用是提供介面

b.封裝可以保護內部資料

c.封裝可以程式碼的可複用性

2.2. 封裝的實現機制

2.2.1 類成員函式的呼叫方法

在文章的第一部分中,我們探討了C++ Class的格局,那麼,類的封裝和其記憶體格局之間,有著什麼樣的聯絡呢?成員函式對內部變數的訪問,又是怎麼實現的呢?

透過觀察圖1-2,我們知道,類的例項中只包括資料成員,類的成員函式是被排除在物件封裝之外的。透過“類名::函式名”的方式,區分每一個成員函式。這樣的記憶體佈局,使人認為class對成員函式的處理方法,跟上節程式中struct的函式指標像類似。我們先看看類對其成員函式的呼叫方法與struct中使用的函式指標在形式上的區別,看程式碼片斷:

void main(void)

{

s_Test s;

s.i=0;

s.p_add=add;

s.p_add(&s);

cout<

c_Test c;

c.add();

c.show();

}

//完整程式碼見上節Source Code 2.1

//透過函式指標呼叫add()時,我們把結構體指標傳給了函式,//透過這個指標使add()函式訪問並操作結構體中的資料

//呼叫類的成員函式,不需要傳遞指標

我們知道c_Test可以被建立很多份例項,但其成員函式在記憶體中只有一份,那麼,這一份成員函式在沒有引數傳入的情況下,如何分辨這若干個c_Test的例項呢?呼叫類成員函式的時候,真的沒有引數傳入嗎?讓我們看看編譯器針對c.add();語句生成的二進位制程式碼,這樣,任何隱藏的機制,都將一目瞭然。

Main函式中呼叫c.add()時,生成如下的程式碼,其中

00401050  lea  ecx,[ebp-4]

00401053  call  @ILT+5(c_Test::add) (0040100a)

call  @ILT+5(c_Test::add) (0040100a) 將控制轉向地址0040100a,這裡是一條跳轉指令

0040100A  jmp  c_Test::add (004010c0)

地址004010c0為函式c_Test::add的真實入口地址,我們再看一下004010c0處的程式碼

004010C0

004010C1

004010C3

004010C6

004010C7

004010C8

004010C9

004010CA

004010CD

004010D2

004010D7

004010D9

004010DA

004010DD

004010E0

004010E2

004010E5

004010E8 

004010EA 

004010ED   

004010EF 

004010F0 

004010F1 

004010F2 

004010F4 

004010F5 

push

mov

sub

push

push

push

push

lea

mov

mov

rep stos

pop

mov

mov

mov

add

mov

mov 

mov

mov 

pop 

pop

pop

mov

pop

ret

ebp

ebp,esp

esp,44h

ebx

esi

edi

ecx

edi,[ebp-44h]

ecx,11h

eax,0CCCCCCCCh

d ptr [edi]

ecx

dword ptr [ebp-4],ecx

eax,dword ptr [ebp-4]

ecx,dword ptr [eax]

ecx,1

edx,dword ptr [ebp-4]

dword ptr [edx],ecx

eax,dword ptr [ebp-4]

eax,dword ptr [eax]

edi

esi

ebx

esp,ebp

ebp

上述程式碼為c_Test::adde的具體實現程式碼。

透過上述程式碼的分析,我們可以發現add()函式有一個隱藏的引數,這是一個指向呼叫add()函式的那個類例項(即c這個物件的地址)的引數,因此,實際上編譯器會將c.add();的呼叫轉化為如下程式碼:

Test::add((Test*)&c);

int  add(){return ++i;}函式實際上是

int  add((Test*)&this)

{return ++((Test*)&this).i;}

這裡,隱藏的引數(Test*)&this就是我們常說的this指標。我們會發現,這樣的做法與使用結構體中指向函式的指標的方法相似(s.p_add(&s);),但C++中這一步驟是由編譯器來實現的,通常使用者不需要對this指標進行操作。這便是封裝的好處,把複雜而且容易出錯的部分交給編譯器來實現。

2.2.2 封裝的問題

封裝就是將事物的內容和行為都隱藏在實現裡,使用者不需要知道其內部實現。但有得必有失,有時候我們無法保證封裝的高效性。C++對封裝的實現到底有沒有效能上的損失呢?要是有的話,有多少呢?

請看下面的測試程式和相應的程式碼註釋

//////////////////////////////////

//Source Code 2.2

//C++ class封裝的效能

const double COUNT=1000000;

#include

#include

#include

void showtime()

class Test{

public:

  Test(){i=0;}

  int foo(){return 0;}

  double i;

};

int foo(){return 0;}

double i=0;

void main(void){

double j=0;

Test t; 

showtime();

for(j=0;j

showtime();

for(j=0;j

showtime();

for(j=0;j

showtime();

for(j=0;j

showtime();

i++;

t.i++;

foo();

t.foo();

}

//測試在英特爾賽揚800MHz

//VC++ 6.0 , 2000環境下完成

//定義迴圈次數

//輸出毫秒級精度的時間函式,實現程式碼略

//迴圈控制變數

//Test類的例項

//以下程式碼為效能測試,輸出迴圈執行時間

//時間精確到毫秒級,i++和t.i++執行時間相同

//時間精確到毫秒級,foo()和t.foo()呼叫執行時間相同

//程式碼檢驗,輸出對變數和函式訪問的實際彙編程式碼

//0040128E  fld  qword ptr [i (0042f220)]

//00401294 fadd qword ptr [__real@8@3fff8000000000000000 (0042b050)]

//0040129A  fstp  qword ptr [i (0042f220)]

//004012A0  fld  qword ptr [ebp-10h]

//004012A3 fadd qword ptr [__real@8@3fff8000000000000000 (0042b050)]

//004012A9  fstp  qword ptr [ebp-10h]

//004012AC  call  @ILT+35(foo) (00401028)

//地址 00401028 處為一跳轉指令,指向foo()函式的入口

//004012B1  lea  ecx,[ebp-10h]

//004012B4  call  @ILT+40(Test::foo) (0040102d)

//地址 0040102d 處為一跳轉指令,指向Test::foo()函式的入口

從上面的結果我們可以知道,對類中封裝了的資料和函式進行訪問的時間與訪問未封裝的資料與函式相同。從上述反彙編出來的程式碼中我們可以發現,程式對封裝資料和未封裝資料的訪問方式,在彙編程式碼的級別上是完全一樣的。下面來看對函式的訪問。

呼叫foo()生成的彙編程式碼為

004012AC  call  @ILT+35(foo) (00401028)

而呼叫類成員函式生成的程式碼為

004012B1  lea   ecx,[ebp-10h]

004012B4  call  @ILT+40(Test::foo) (0040102d)

這條指令引起了效能問題嗎?前面進行的時間測試告訴我們這兩個函式執行的時間是相同的,而且,針對一個函式呼叫過程,編譯器會產生至少上百條彙編程式碼,因此,這裡多出來的一條指令,在考慮效能問題的時候,完全可以忽略。那麼,這條指令的作用是什麼呢?在2.2.1 類成員函式的呼叫方法這一節中我分析了this指標的作用和實現,這條指令,便是起這個作用,具體的細節,請參考2.2.1節。

透過上面的分析,我們知道封裝在普通成員變數和普通成員函式方面沒有引起效能上的額外開銷,參考C++ Class的記憶體格局一節,我們可以很容易的發現封裝對靜態成員也沒有任何的效能開銷,那麼,虛擬函式呢?這的確是一個問題。

系統訪問虛擬函式,實現要透過指標虛擬函式表,並且考慮到派生類對虛擬函式的改寫,虛擬函式表實際上是在執行期才進行繫結的。關於虛擬函式的詳細分析,將在多型一部分中展開。

根據權威測試,C++由封裝而引起的效能損失相比同類的C語言,大概在5%以內。5%的效能損失,換來物件導向的,我想,任何程式設計師都是會做出明智的選擇的。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-992406/,如需轉載,請註明出處,否則將追究法律責任。

相關文章