深入剖析C/C++函數的參數傳遞機制


深入剖析C/C++函數的參數傳遞機制 (轉-有修正)作者:leeyeafu(明經CAD社區 編程申請版塊 版主)


首先,這篇文章針對近期網友在ARX版塊的提問,很多都是在調用ARX函數或者設計自定義函數時出現的困惑,爲方便大家分析和理解問題,進而正確解決問題,我將個人的一些理解寫成文字,希望對大家在做ARX程序設計時有所幫助。同時,這篇文章也爲“ObjectARX程序設計入門(2)”作些準備工作。

 

 

 

這篇文章與普通的C/C++教材相比,可能要深入得多,閱讀時應該細心。而對於未接觸過C語言的讀者來說,大概需要先閱讀一般的C++教材。我的看法,《C++編程思想》和《深入淺出MFC》一類的書對於初學者太過深入,而類似《Visual C++ 6.0從入門到精通》的書籍主要篇幅在介紹VC軟件的使用方法而不是講解C++程序設計方法,它們都不適宜作C++ARX程序設計入門學習用書。我個人學習C++使用的是南京大學出版社的書名就是《C++教程》,它是爲C程序員編寫的C++教材,全書僅130多頁,內容淺顯但基本夠用。不過那是上世紀90年代初出版的,現在大概不好找了,不過類似的(比如說大學教材)我想書店裏還是有的。

 

 

 

文章中的大部分內容是我個人的看法,一般的C++書籍上找不到類似的說法與其比較,對於其正確性,我沒有十足的把握。各位網友可以對此進行討論或者批評。(只是不要真的用磚頭砸,那樣對於我英勇而忙碌的醫護人員太不尊重,別再給他們添亂了。)

 

 

 

C語言的函數入口參數,可以使用值傳遞和指針傳遞方式,C++又多了引用(reference)傳遞方式。引用傳遞方式在使用上類似於值傳遞,而其傳遞的性質又象是指針傳遞,這是C++初學者經常感到困惑的。爲深入介紹這三種參數傳遞方式,我們先把話題扯遠些:

 

 

 

1 C/C++函數調用機制及值傳遞:

 

 

 

在結構化程序設計方法中,先輩們告訴我們,採用“自頂向下,逐步細化”的方法將一個現實的複雜問題分成多個簡單的問題來解決。而細化到了最底層,就是“實現單一功能”的模塊,在C/C++中,這個最小的單元模塊就是函數。然而,這些單個的模塊(或者說函數)組合起來要能完成一項複雜的功能,這就註定各個函數之間必然要有這樣或那樣的聯繫(即耦合)。而參數耦合是各個函數之間最爲常見的耦合方式,也就是說,各個函數之間通常通過參數傳遞的方式來實現通訊。

 

 

 

當我們設計或者調用一個函數時,首先要注意的是函數的接口,也就是函數的參數和返回值。調用一個函數就是將符合函數接口要求的參數傳遞給函數體,函數執行後返回一個值給調用者。(當然,C/C++允許void類型的參數和返回值。當返回值爲void時,函數類似BasicSub子過程或者PascalProcedure過程。)

 

 

 

函數的參數傳遞,就是將在函數體外部已賦值(或者至少已經定義並初始化)的變量通過函數接口傳遞到函數體內部。根據變量種類的不同,有不同的參數傳遞方式:

 

 

 

若傳遞的參數是一個類對象(包括象Intfloat這樣的C/C++內部數據類型),這種傳遞方式爲值傳遞。C/C++這種以函數爲主體的語言中,幾乎所有的功能都是通過函數調用來實現的。<不是嗎?你說C/C++運算符操作?還有變量聲明?你先等等,接下來我們就看看C++中這些操作是怎麼實現的。>以下的C/C++代碼是如此的簡單,可能你從未想過還有什麼要分析的,但它確實是函數值傳遞方式的典型例子。

 

 

 

float x = 0.254;
float y = 3.1415;
float z = x + y;

 

 

 

以上代碼編譯執行時,第一步float x,即聲明一個實數變量。即將標誌符x認爲是一個實數變量,並調用float類的初始化函數。當然你可能感覺不到它的存在,因爲現在的CPU都直接支持浮點運算,它只是一條彙編指令而已。

 

 

 

初始化完成後,調用賦值函數:

 

 

 

x.operator = (0.254); 

 

 

 

不要奇怪以上函數的寫法,它實際上與 x = 0.254; 效果完全相同,會產生同樣的彙編代碼。

 

 

 

該函數首先根據變量x的數據類型分配合適的內存空間,並將該內存地址與標誌符x關聯。然後將立即數0.254寫入分配的內存。(這裏借用彙編語言的術語,立即數可以理解爲程序已指定的具體數值。)然而,賦值函數的設計者並不能獲知立即數0.254的數值,調用該函數時就必須通過參數傳遞的方法將數值通知給函數體。賦值函數接口大致是這樣:

 

 

 

float float::operator = (register float a);

 

 

 

變量a是在CPU寄存器中使用的臨時變量。調用賦值函數時,將0.254送到寄存器變量a中,再將a值送到變量x所在的內存位置中。以上函數的返回值用於類似這樣的鏈式表達式的實現:

 

 

 

x = y = z;

 

 

 

說了許多,好象十分複雜,其實賦值操作僅僅只是兩條彙編代碼:

 

 

 

mov AX, 0.254 

 

 

 

mov [x], AX

 

 

 

事實上,它之所以簡單,僅僅是因爲floatCPU能直接處理的數據類型。若以上代碼中不是float類型數據賦值,而是更復雜的(比如說自定義)類型數據,同樣的賦值操作儘管是相同的步驟,但實際情況要複雜得多。因爲寄存器容量限制,可能變量a無法作爲寄存器變量存放,這樣即使是簡單的賦值操作也要爲函數的臨時變量分配內存並初始化,在函數的返回時,臨時變量又要析構(或者說從內存中釋放),這也就是參數值傳遞方式的弱點之一:效率低。以後我們還可以看到,值傳遞方式還有其力所不能及的時候。

 

 

 

上面的代碼段中加法調用這樣的函數,其參數傳遞方式同樣是值傳遞:

 

 

 

float::operator + (float a, float b);

 

 

 

下面看一個稍微複雜的類,Complex複數類。ObjectARX程序設計中使用的大部份對象類型都將比這個類複雜。

 

 

 

class Complex
{
  public:
  Complex operator = (Complex others); //
賦值函數,事實上不聲明系統也會默認

  Complex operator + (Complex c1, Complex c2); //
加法
  void Complex (float Re, float Im); //
帶參數的構造函數
  //
當然,真正的複數類接口遠比這複雜,爲了說明問題,僅寫出這三個接口函數。
  private:
  float Re; //
複數的實部
  float Im; //
複數的虛部
} //
類接口函數的實現應該並不複雜,在此略過。

 

 

 

類的接口函數的參數仍然用值傳遞方式。當執行下列代碼中的加法和賦值操作時,程序將要多次執行Complex類的構造函數和析構函數。

 

 

 

Complex A(2.5, 3);

 

 

 

Complex B(0.4, 2.5);

 

 

 

Complex C = A + B;

 

 

 

最後一句代碼,首先聲明一個Complex類對象C,然後根據運算符優先級,執行加法運算,將對象AB傳遞給加法函數,這時C++調用Complex類的默認構造函數聲明兩個臨時變量,再調用默認的“拷貝構造函數”採用位拷貝的方法將對象AB複製到臨時變量,加法操作返回時,再將臨時變量析構,返回值再用值傳遞方式傳遞給賦值函數。

 

 

 

從以上執行過程可以看出,值傳遞方式效率低的關鍵在於臨時變量的建立和析構。於是考慮,因爲在調用函數時該變量已經在內存中存在,將這個已經存在的變量直接傳遞給函數體而不去聲明和拷貝臨時變量。這樣,臨時變量的構造、拷貝、析構等工作都被省略,從而大大提高了函數效率。這便是使用C/C++指針和引用傳遞機制的主要原因。另外,使用這樣的函數參數傳遞機制,在函數體內部可以很輕易地修改變量的內容。(而使用值傳遞方式,函數體內部只能修改臨時變量,沒有辦法修改這些外部變量本身的值。)這樣一方面增加了程序設計的靈活性,同時也給程序帶來了安全隱患。當然,我們可以使用const聲明防止變量的內容在函數體內部被修改,但這需要編程者有良好的編程風格和編程習慣。在介紹函數參數的指針和引用傳遞方式之前,先說一說指針和引用這兩個概念。

 

 

 

2、指針和引用

 

 

 

在解釋指針和引用之前,先看看普通變量是怎樣在內存中存放的。聲明變量後,編譯程序要維護一張包括各種標識符的表。在這張表內,每一個標識符,比如說變量名都應該有它的類型和在內存中的位置。

 

 

 

在這要進一步說明幾個問題,這些問題可能涉及多個計算機專業領域,我也不想在這作深入介紹,看不明白沒有關係,不會影響您繼續閱讀這篇文章。

 

 

 

首先,C/C++的內存分配有靜態分配和動態分配兩種機制。靜態分配內存是由編譯程序爲標識符分配固定的內存地址,而動態分配機制是應用程序在進入內存後再根據程序使用內存的實際情況決定變量存放地址。這個話題非常複雜,不過進行ObjectARX程序設計好象不必太在意內存分配機制,讓編譯程序和Windows去管這件事吧。而且內存分配機制對於我們理解指針和引用不會造成影響。

 

 

 

其次,標識符可以標識變量,也可以標識函數入口。從而它的類型可以是CPU能直接處理的內部數據類型<例如int類型>,也可以是用戶自定義類型,還可以是函數類型。

 

 

 

另外,由於標識符的類型不同,它佔用內存的大小也各有差異。“在內存中的位置”實際上指的是它佔用的內存塊的首地址。對於80286以上的計算機<這句話是不是多餘?>,內存地址由基址(或段地址)加上偏移地址組成。基址是應用程序被調入內存時由操作系統分配,當然,編譯程序把應用程序編譯成多個段,從而要求操作系統對於不同的段分配不同的基址。而編譯程序(哪怕是使用靜態地址分配)只能決定標識符存放的偏移地址,也就是說,“在內存中的位置”只是標識符佔用內存的第一個字節的偏移地址。說了這麼多,有一點需要記住,無論是程序設計者還是編譯程序都無法確知變量的內存中的實際位置。

 

 

 

最後,這個標識符表要比上面說的複雜,我只選擇了與目前討論的問題有關的內容。

 

 

 

好了,準備工作做了許多,讓我們正式進入C/C++指針和引用的神祕世界。

 

 

 

指針變量其實質類似一個int整型變量。我們在源程序中這樣聲明一個指針變量:

 

 

 

float *px;

 

 

 

此時,標識符px指示的內存位置上存放的就是一個int類型整數,或者說,通過變量px可以訪問到一個int類型整數,並且這個整數與指針指向的數據類型<在此例中爲float浮點數>無關。在ARX程序中,甚至可以用這樣的方式打印一個指針變量:

 

 

 

acutPrintf(“指針變量px的值爲%d, px);

 

 

 

當然,這個整數值到底意味着什麼,可以只有計算機(或者說操作系統)自己知道,因爲這個值表示的是指針指向的數據在內存中的位置。也就是說,不應該將指針變量與普通int整型混淆,例如,對指針進行四則運算將使用結果變得計算機和程序員都無法理解,儘管編譯器允許你這樣做。<實際上,計算數組下標就要使用指針的加法。>

 

 

 

與普通變量不同,若在程序中聲明指針變量的同時不進行初始化,系統會自動將指針變量初始化爲NULL<NULL的值與0相同,但好的編程風格是使用NULL而非0,以與普通int類型區別。>而聲明普通變量,系統僅爲其分配內存,而不做自動初始化,從而未初始化的變量值是不可預測的。當然,直接使用未初始化的指針決不是一個好程序(此時編譯器會發出警告信息),其危害或隱患以後在說明內存管理技術時再討論。<ObjectARX程序設計連載能堅持寫下去,我想會要涉及到內存管理的。>
在聲明時初始化指針變量可以這樣:

 

 

 

//float *px = 0.254;原文如此

 

 

 

float f = 0.254 ;

 

 

 

float *px = &f ;

 

 

 

這是初始化同時賦值,也可以使用new運算符進行初始化:

 

 

 

float *px = new float;

 

 

 

這種初始化方式經常用於不方便或不能直接賦值的複雜數據類型。

 

 

 

上述語句執行時,首先分配一塊可存放數據的內存區域<大小與數據類型有關>,若要同時賦值,就調用賦值函數將數值寫入剛分配的內存中。然後爲標識符px分配一個int整型要佔用的(通常爲4字節)內存空間,最後將分配的用於存放數據的內存首地址寫入內存。

 

 

 

注意:使用new運算符初始化指針,指針變量使用結束後應該用delete運算符釋放其佔用的內存。也就是說,new運算符和delete運算符最好能成對使用。

 

 

 

指針的初始化可以在程序的任何位置進行,<當然,最好在使用它之前初始化。>比如:

 

 

 

float x = 0.254;
float *px;
//
其它語句,請注意不要在這裏使用px指針

px = &x; //
在這進行px指針的初始化工作

 

 

 

上面最後一行代碼是將變量x的地址賦值給指針px。以上初始化指針的方法效率相差無幾,讀者可自行分析。&運算符稍後討論。

 

 

 

下面看一個ObjectARX程序中最爲普通的使用指針的代碼段:(注意,以下不是完整的代碼,不能進行編譯或執行。)

 

 

 

void Sample(void)
{
//
聲明指針變量同時調用AcDbLine類的構造函數進行初始化。

//
注意此時使用了new運算符爲AcDbLine類對象分配內存。
AcDbLine *pLine = new AcDbLine(AcGePoint3d(10, 20, 0) ,
AcGePoint3d(50, 75, 0));

//
下面看看如何訪問指針變量及複雜類的接口函數。
pLine->setColorIndex(1); //
將線對象的顏色設置爲紅色

//layer()
函數返回一個指向char類型的指針,先聲明一個指針變量用於接收返回值
char *pLayerName;
pLayerName = pLine->layer(); 
acutPrintf(
/n紅色的線對象在%s圖層上。”, pLayerName);
}

 

 

 

這段代碼不作深入分析,請讀者注意pLine指針的聲明和初始化過程以及pLayerName指針的賦值過程。

 

 

 

注意到我們在上面初始化px指針時使用了&運算符,即取地址運算符,這也是使用引用的一般方法之一。引用是C++概念,C++初學者容易將引用和指針混淆在一起。

 

 

 

以下代碼中,m就是n的一個引用:

 

 

 

int n;
int &m = n;

 

 

 

編譯程序編譯以上代碼時,在標識符表中添加一個int引用類型的標識符m,它使用與標識符n相同的內存位置。這樣對m的任何操作實際上就是對n的操作,反之亦然。注意,m既不是n的拷貝,(這樣的話,內存中應該的兩塊不同的區域,存放着完全相同的內容。),也不是指向n的指針,其實m就是n本身,只不過使用了另外一個名稱而已。

 

 

 

儘管指針和引用都是利用內存地址來使用變量,它們之間還是有本質的區別:

 

 

 

首先,指針變量(包括函數調用時的臨時指針變量)在編譯和運行時要分配相當於一個int變量的內存空間以存放指針變量的值,儘管這個值表示的是指針指向的變量的地址。而引用與普通變量一樣,標識符所指示的內存位置就是變量存放位置。這樣不僅不需要在內存中分配一個int變量的內存空間(儘管它可能微不足道),而且在使用中可以少一次內存訪問。<僅就內存使用效率而言,指針和引用所帶來的區別確實不大,完全可以不去在意它。>

 

 

 

其次,由於標識符表在填寫後就不能再被修改,因此引用在創建就必須初始化,並且初始化後,不能改變引用的關係。另外,引用初始化時,系統不提供默認設置,引用必須與合法的內存位置相關聯。而這些特徵對於指針而言都是不存在的。指針可以在程序任何時刻初始化,初始化的指針在程序中也可以根據需要隨時改變所指向的對象,(這隻需要改寫指針變量的值就可以了。)當然,未初始化的指針變量系統會初始化爲NULL,而NULL引用是非法的。

 

 

 

下面看一段類似文字遊戲的程序:

 

 

 

int I = 6;
int J = 8;
int &K = I; //K
I的引用

K = J; //K
I的值都變成了8

 

 

 

注意,由於引用關係不能被修改,語句K = J;並不能將K修改爲對J的引用,只是修改了K的值。實際上,聲明並初始化引用後,可以把引用當作普通變量來使用,只不過在操作時會影響另外一個變量。

 

 

 

以上代碼僅僅只是解釋引用的定義,並不能體現引用的價值。引用的主要功能在於函數的參數(或者返回值)的傳遞。

注:下圖應該是float *px = &x ;

3 函數參數的指針和引用傳遞機制

 

 

 

先看一下簡單的例子。

 

 

 

void Func1(int x) //這個函數的參數使用值傳遞方式
{
  x = x + 10;
}
//
當參數類型更復雜時,指針和引用傳遞方式在效率等方面的優勢更爲明顯
//
不過那樣例子就不夠“簡單”了
void Func2(int *x) //
這個函數的參數使用指針傳遞方式
{
  *x = *x + 10;
}

void Func3(int &x) //
這個函數的參數使用引用傳遞方式
{
  x = x + 10;
}

 

 

 

以下代碼調用這些函數:

 

 

 

int n = 0;
Func1(n);
acutPrintf(
n = %d
, n); // n = 0
Func2(&n);
acutPrintf(
n = %d
, n); //n = 10
Func3(n);
acutPrintf(
n = %d, n); //n = 20

 

 

 

以上代碼段中,當程序調用Func1()函數時,首先在棧(Stack)內分配一塊內存用於複製變量n。若變量n的類型複雜,甚至重載了該類的默認拷貝構造函數:

 

 

 

CMyClass(const CMyClass &obj);

 

 

 

這個過程可能會比較複雜。<類的默認拷貝構造函數使用“位拷貝”而非“值拷貝”,若類中包括指針成員,不重載該函數幾乎註定程序會出錯。關於這個問題以後再深入探討。>

 

 

 

程序進入函數Func1()體內後,操作的是棧中的臨時變量,當函數結束(或者說返回)時,棧內變量被釋放。而對於函數Func1()來說的外部變量n並未起任何變化,因此隨後的acutPrintf函數將輸出n = 0

 

 

 

程序調用函數Func2()時,在棧內分配內存用於存放臨時的指針變量x。然後用&運算取得變量n的地址,並拷貝給臨時指針變量x作爲x的值。此時,指針x就成了指向變量n的指針。在函數體內,*x運算得到的是指針x指向的內容,即變量n。對*x操作實際上就是對n操作。因此,在函數Func2()中變量n的值起了變化。在分析Func2()函數時應該注意到,臨時指針變量x要指向的內存地址,也就是說變量x的“值”仍然是採用了值傳遞方式從函數外部(或者說函數調用者)獲得,那麼“值”也就應該具有值傳遞方式的特點,它要在棧中複製臨時變量,它在函數體內被修改不會影響到函數外部。比如說,在上面的代碼段中,函數Func2()內可以讓指針x指向另外的變量,但函數結束或返回後,在函數外部是無法得到這樣的指向另外變量的指針。

 

 

 

程序調用函數Func3()時,臨時變量x是一個變量n的引用,此時變量x就是變量n本身,對x操作的同時,外部變量n也起了變化。實際上,引用能做的事,指針也能做到。

 

 

 

以上的代碼段確實簡單,以至還不能充分顯示指針和引用在傳遞函數參數時的許多其他功能。下面我們設計這樣一個函數,函數需要兩個參數,在函數內將兩個參數的值互換。由於值傳遞方式儘管能通過返回值賦值的方法修改一個參數值,但不能同時修改兩個參數值,因此這個函數不能使用值傳遞方式。使用指針傳遞方式,函數可以寫成這樣:

 

 

 

bool swap(int *x, int *y)
{
  int temp;
  temp = *x;
  *x = *y;
  *y = temp;
  return true;
}

 

 

 

以下代碼調用該函數:

 

 

 

/*原文如此

 

 

 

int *a = 10;
int *b = 15;

 

 

 

*/

 

 

 

int a1 = 10 ;

 

 

 

int b1 =15 ;

 

 

 

int *a = &a1 ;

 

 

 

int *b = &b1 ;
if (swap(a, b))
{
  acutPrintf(
“整數a1,b1已交換數據,*a = %d, *b = %d
, *a, *b);
}

 

 

 

在以上代碼中,swap()函數設計成與常見的ARX函數一致的風格,用一個bool類型返回函數執行狀態。<ARX中,這個返回值通常使用Acad::ErrorStatus類。>在調用函數時,由於變量ab已經聲明爲指針,使用標識符ab訪問的是int類型變量的內存地址。

 

 

 

使用引用傳遞參數,可以這樣設計swap()函數:

 

 

 

bool swap(int &x, int &y)
{
int temp;
temp = x;
x = y;
y = temp;
return true;
}

 

 

 

使用代碼swap(int a, int b)調用以上函數時,進入函數體內,xy分別是變量ab的引用,對xy操作就是操作變量ab。函數返回後,變量ab的值互相交換了。

 

 

 

注意:以上代碼只是交換兩個變量(或者指針指向的變量)的值。即將變量ab(或指針ab指向的變量)的修改爲ba(或指針ba指向的變量)的值,而不是將指針a指向原來指針b指向的變量。也就是說,swap()函數調用前後,指針ab的值(地址)並沒有發生任何變化。(當然,引用關係在任何時候都不能修改。)要修改指針的地址值,應該使用指向指針的指針或者使用對指針的引用。這樣設計和調用函數:

 

 

 

bool swap(int **x, int **y); //使用指向指針的指針傳遞參數
int *a = 10;//
原文如此,不可
int *b = 15;// //
原文如此,不可
swap(&a, &b);

 

 

 

或者:

 

 

 

bool swap(int *&x, int *&y); //使用對指針的引用傳遞參數
int *a = 10; //
原文如此,不可
int *b = 15; //
原文如此,不可
swap(a,b);

 

 

 

在以上的兩個swap()函數以交換兩個指針的值,使指針a指向原來指針b指向的變量,指針b指向原來指針a指向的變量。
另外,由於引用關係不可修改,指向引用的指針和引用一個引用沒有實際意義。若編譯器允許它們存在,實際上也會退化爲普通指針(或對指針的引用)和引用。這一點請讀者自行分析。

 

 

 

最後,我們看一個ARX程序中使用指針和引用傳遞參數的函數例子:

 

 

 

AcDbDatabase *pDb = new AcDbDatabase();
AcDbBlockTable *pBlkTbl;
pDb->getBlockTable(pBlkTbl, AcDb::kForRead);

 

 

 

ARX幫助中可以查看到,getBlockTable()函數的原型是:

 

 

 

Acad::ErrorStatus getBlockTable( AcDbBlockTable*& pTable, AcDb::OpenMode mode);

 

 

 

其中可以看到,函數的第一個參數是對一個AcDbBlockTable類型指針的引用,從而可以在函數體內部對指針pBlkTbl進行修改,使之指向pDb指針指向的圖形數據庫的塊表

 

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