1. 程式人生 > >【C/C++語言入門篇】-- 深入函式

【C/C++語言入門篇】-- 深入函式

前面一篇我們介紹了結構體,這篇終於能夠介紹函數了。為什麼這麼說呢?因為函式非常重要。就這麼簡單。嘿嘿!之所以在這時才講函式,是因為本篇將聯絡到前面的每一篇,這樣函式才能體現的透徹。那我們就迫不及待的切入正題。

從第一篇Helloworld開始到現在,就沒有脫離函式。那就是我們的main函式。main函式也是一個普通的函式,只不過通常把它作為我們寫的程式的入口。也就是說我們就當它最先執行。那這樣一來為什麼說它又是一個普通的函式呢?原因是我們可以通過寫程式碼改變這個入口。讓我們的程式一開始不執行main函式而先執行我們自定義的函式。具體怎麼實現不是本篇的內容,大家知道有這麼回事便可。記得main函式並不是一個特殊的函式,它只是被認為的定為程式的入口函式而已。

那麼,什麼是函式?通俗的理解,它就是一段程式碼塊,被我們將零散的語句集中在一起而用於支援某個功能。比如我們的strcpy也是一個函式,這個函式的作用是字串拷貝。它裡面有很多語句。這些語句被用一個函式的形式集中在一起而已。說到這裡又不得不強調一點,那就是我們在接觸一個新的東西的時候儘量往其本質想,這樣便不會感到抽象和陌生。就比如函式,我們就理解它就是一個程式碼塊集中管理的方案。一個函式名,引數列表加返回值用大括號將程式碼括起來就成了函式。雖然是括起來了,但是函式可以說是不存在的。當編譯器將我們的CC++程式碼編譯成組合語言的時候,每個函式都只是一段程式碼,什麼函式名,引數列表,返回值將不再清晰可見。那就是一段集中在一塊兒的程式碼。我們也就這麼理解。至於為什麼在CC++語法上函式要有名字、引數、返回值。這點是可以理解的。因為是高階語言嘛,這樣一來程式碼的模組性將很強。總不可能這樣寫喲:

有函式:

void fun( void )

{

     int a = 0;

}

void fun1( void )

{

     int b = 0;

}

int main( void )

{

      if ( ... )

         fun();

      else

         fun1();

       return 0;

}

沒函式:

int main( void )

{

      if ( ... )

         goto fun;

      else

         goto fun1;

      return 0;

      fun:

            int a = 0;

            return 0;

      fun1:

            int b = 0;

            return 0;

}

上面的程式碼顯而易見,函式的基本作用就得以體現了。模組化方便管理與維護。

知道了函式的概念及其本質後,我們再看具體的一些用法。首先從返回值上面說。

void         fun();

int*         fun();

struct A   fun();

int           fun();

char        fun();

看到上面不同返回值的函式。有指標,有型別,有空,有字元,還有結構體。C語言只能返回一個值,如果想返回多個,就只能用地址的形式返回給呼叫者了。其它花騷的辦法原理也都差不多。這裡不一一說明。函式的返回值在CC++層面上都是用return關鍵字進行返回的。返回值是為了能夠被需要一些結果的呼叫者獲得這個結果值。返回值在很多時候非常重要。void型別就不用返回,如果想在函式中間某處就返回的話可以這樣:

void fun()

{

    int a = 0;

    return;          // 執行完a = 0就直接返回了,這裡不返回任何值,只是充當一個結束此函式的作用。

    a = 10;

}

函式一旦return後,不管是否有返回值,都立即結束。因此,return通常用來返回值也用來終結函式。上面諸多返回值型別,我們只需要看兩個就夠了,一是返回指標,而是返回結構體。我們將一一追究起本質和一些注意事項。

首先看返回指標:

int* fun( void )
{
    int a = 100;
    return &a;
}

看上面這個程式,返回指標的形式很簡單。直接return a變數的地址。在外層呼叫的時候可以:

int*  p = fun();

這樣便把p指向了函式返回的地址上。

假如我們要返回一個數組,在前面我們講了指標和陣列。我們又知道不可能有 int[] fun( void )或者 int[ 3 ] fun( void )這樣的函式定義。那麼我們便聯想到了指標和陣列的共性,我們是否可以返回一個數組的首地址?然後再呼叫者取得這個首地址。在我們知道陣列大小的情況下就能挨個訪問這個陣列的每一個元素。有的人又會問,假如不知道大小了怎麼辦呢?不知道大小基本是不可能的。你的大小是否可以使用巨集定義或者全域性變數呢?我們為何要往死衚衕裡面鑽呢?對吧。

因此,就有如下程式碼:

int* fun( void )

{

    int a[ 3 ] = { 1, 2, 3 };

    return a;

}

在外層:

int* pArray = fun();

a = pArray[ 0 ];

是不是很方便。所以我們在使用指標和陣列的時候要靈活。C語言雖然不能返回多個值,但是我們有辦法實現這個功能。

寫到這裡,大家知道了返回指標,欣喜若狂。執行之。結果意料之外的事情發生了。為什麼返回的指標、陣列元素亂了?資料錯誤了?

這裡就牽涉到一個注意事項了。

我們上面寫的這兩個返回指標的函式,都是有問題的。說它是錯誤的也完全不過分。

為什麼這麼說呢?大家仔細觀察,我返回的陣列和返回的a的地址都是屬於臨時變數的地址。語法上這兩個函式確實沒有問題,錯誤的原因就在於我返回了臨時資料的記憶體地址。所謂臨時變數,也就是生命週期比較短,這裡的陣列和a在函式結束後生命便終結了。所以稱之為臨時變數。既然生命終結了,那麼這塊記憶體將會被重新利用。就會被任意程式碼或者操作重新賦值。這裡就是所謂的棧記憶體。這裡的棧不是資料結構裡面的那個棧。這裡通常指存放臨時變數的記憶體空間。一般很小,預設是1MB,也有2MB的。這個可以自己設定。這裡就不多說了。假如有這樣一段程式碼:

int* fun( void )
{
    int a = 100;
    return &a;
}

int* fun1( void )
{
    int b = 200;
    return &b;
}

int main( void )
{
    int* p;
    int* p1;
    int aa, bb;

    p = fun();
    p1 = fun1();

    aa = *p;
    bb = *p1;

    return 0;
}

在我的機器上,這兩個函式fun和fun1由於程式碼基本相似。我故意構造了一個能夠體現棧記憶體被修改的錯誤。在這個程式結束後,aa和bb的值都是200。為什麼?原因很簡單,我們在呼叫了fun函式後,p指向的棧記憶體比如是0x0012ffd4,當呼叫了fun1後,因為fun1跟fun區別很小,臨時變數b所在的棧記憶體地址剛好也被指定到了0x0012ffd4這個記憶體地址上。p1也便指向了這個記憶體地址。所以這裡aa和bb必然是相同的值了。為什麼是200原因也很簡單,臨時變數b把0x0012ffd4這個記憶體地址下的值賦值成了200,便覆蓋了之前的100。

那麼,如果我要改變這兩個函式,讓它們不會出錯該怎麼辦呢?如下:

int* fun( void )
{
    int* a = ( int* )malloc( sizeof( int ) );

    *a  = 100;


    return a;
}

這樣的話就不存在被覆蓋了,大家知道這裡使用的malloc函式申請的空間,此函式申請的空間將不在棧空間上,而是在堆記憶體中。我們不手工呼叫free函式,這個記憶體值將永遠存在。知道程式結束被回收。當然這樣做的的話,在外層獲得了這個a指標,在使用完後。記得把它free調。不然將造成記憶體洩露(一直申請,用完不釋放,記憶體被佔用逐漸耗盡。)。

問題一:寫出正確的返回陣列的函式fun1。

在瞭解了指標返回後,可能有的朋友會提問假如我要返回二級指標該怎麼寫呢?我這裡只說一句,二級指標也是指標,沒有什麼特別的。跟以及指標同樣一個道理返回,記得一點指標變數也可以是臨時變數。具體還不清楚的話建議看看前面兩篇關於指標的文章。

好了,返回指標說完了,再來說返回結構體。

大家由於看了上面的返回指標,心裡可能就會在猜想了。結構體以一組成員的集合,跟陣列類似。我們要返回的時候,是不是也必須得用指標的方式返回首地址呢?或者還有其它方法?先看程式:

struct A
{
    int a;
    int b;
    int c;
};

struct A fun( void )
{
    struct A a = { 1, 2, 3 };
    return a;
}

int main( void )
{
    struct A ret = fun();

    return 0;
}

有這樣一段程式碼,我們的目的是想返回臨時的結構體變數a的值給main函式裡面的臨時變數ret。這裡我故意強調了臨時變數這個詞。希望不要引起大家的誤解。這裡雖然a是一個臨時變數,但是我返回變數a到ret中,並不是指向。而是拷貝。意思就是說將臨時變數a的3個成員值拷貝到ret變數的對應的成員裡。跟指標是有區別的。我們前面說了C語言是不能返回多個值的,要返回就用指標。那麼這裡我沒有用指標很明顯的返回了3個值1、2、3.這是為什麼呢? 答案可能在這裡講不是怎麼適合,我先說在這裡,能理解就理解。不能理解就記住結構體變數返回能實現返回多個值,就把結構體變數當著是一個值,不要想到它的成員。那麼其本質上來說,結構體變數是怎麼返回3個值的呢?

原因在於,這裡C語言預設幫我們做了很多事情,在後臺其實還是返回的只是一個地址,也就是結構體變數a的首地址,這個首地址不是儲存在我們定義的變數上的,而是通過CPU暫存器傳遞的。然後將暫存器指向的那個記憶體地址的值賦值給ret變數的成員a,然後再暫存器所指向的地址+偏移(這裡是4,都是int型)就是b所在的記憶體地址,然後將b的值取出來賦值給ret中的b。c也是一個道理。這樣就把值傳遞過來了。我們可以理解為編譯器編譯後,程式會在記憶體中構建一個臨時的結構體。把函式要返回的結構體變數裡面的值都複製到這個臨時的結構體裡。我們是看不到這個結構體的。在函式執行完成後將這個臨時結構體的值賦值給我們的接收變數。這裡可能有點不好理解,什麼是臨時結構體,我之前不是一直強調本質嗎。結構體就是一塊連續的記憶體空間,我們這裡A結構體佔用12個位元組,因此我可以隨便在記憶體的某個地方構建一個12位元組的空間。放置這個結構體的3個成員的值。所以這裡叫臨時結構體。

說到這裡,又得提醒一點了。這裡我們要返回多個結構體變數的話,同樣也可以採用指標。原理跟上面基本型別指標返回一個道理。也存在臨時棧記憶體的問題。返回指標(返回地址)跟返回值(拷貝)大家要區分清楚。

好了。返回值我們就說完了。下面說引數。

引數可以有多個,還可以有不定引數,比如我們常用的printf函式就是不定引數。也就是動態的引數個數哈。

固定的引數個數多個和一個是一樣的道理,我在這裡只列舉一個引數的情況或者兩個引數的情況。

void fun( int* p );

void fun( int a );

void fun( void );

void fun( int* p, int size );

上面我沒有寫返回值,返回值不用說了。在瞭解引數之前我們先看一個例子:

void fun( int var )
{
    var = 100;
}

int main( void )
{
    int a = 1;
    fun( a );

    return 0;
}

在這個程式中,我們呼叫了fun函式,試圖去改變a的值。但是出乎意料的是,在呼叫了fun函式後a的值改變。這是為什麼?可能很多初學的讀者一直很納悶。或者就死記硬背這樣不會改變a的值。我們在研究一個東西只有知道了本質才能記得更牢,而且不用記都會一直明白。那麼我們先說說a沒有被改變的原因。

也許大家都聽說過值傳遞,地址傳遞,引用傳遞。引用傳遞我們在本篇不說,那牽涉到C++的相關概念了。以後我們在講引用的時候再說。

那麼先說說什麼叫值傳遞。

我們通過上面的內容瞭解到了棧記憶體,也就是臨時資料存放的地方。函式內部的臨時變數都是放在這裡面的。這裡傳引數,又不得不明白一點就是。不管我們傳的是指標還是值。程式在呼叫函式之前都會先將引數壓入函式內部的棧空間裡。意思就是說函式會把這些引數當著函式內部的臨時變數來處理。這裡將引數壓入我們函式內部所在的棧空間裡的過程叫傳遞,壓入的地方(記憶體地址)裡的值通常稱為引數的副本。這裡別想到遊戲裡面下FB哈,總結出來的意思就是說,我們在跟函式傳引數的時候會將引數一個一個壓入到函式內部所在的棧記憶體中。這裡的壓入也可以理解成向棧記憶體裡面寫值。

上面的fun( a ),首先是將a的值壓入到棧記憶體,比如0x0012ffec這個記憶體裡。這個記憶體地址下面的值就是1,也就是通常所說的a的副本(克隆體)。然後執行到函式內部的var = 100; 這裡的var所取值的記憶體地址就是0x0012ffec,也就是傳進來的引數的那個記憶體地址。這一切都是編譯器給安排好的。然後我們將這個0x0012ffec記憶體地址裡面的值賦值為100。好了,var變成了100。之後函式fun便執行完畢了。到這裡大家可能已經知道為什麼a的值不會改變了。原因就是函式內部只知道去改變0x0012ffec這個記憶體地址裡面的值,而改變了這個值並不會影響到a,因為a又屬於main函式的區域性變數,a所在的記憶體地址並不是0x0012ffec。0x0012ffec這個地址之所以能夠將a的值傳進函式是因為在壓引數的時候是將a的值1拷貝到0x0012ffec記憶體裡。注意這裡是拷貝。

那麼,到這裡我們想了想,要是我們想改變a的值怎麼辦呢?如下:

void fun( int* var )
{
    *var = 100;
}

int main( void )
{
    int a = 1;
    fun( &a );

    return 0;
}

用指標就可以將a 的值改變。大家又疑惑了。為什麼這裡指標就能改變呢?原因跟上面一樣,首先我們傳入的是a的記憶體地址,比如是0x0012ffff,將這個地址傳給了函式,通過我們上面知道,雖然是傳的地址,可它還是將這個地址當著值壓入函式內部棧空間,比如壓到了0x0012eeee這個記憶體裡。注意每個函式都有自己獨立的那塊棧空間提供給自己用,用完就丟棄。所以這裡壓入後的記憶體地址跟變數a本身的記憶體地址不可能相同。然後我們再看fun函式,它是一個指標取值操作然後再賦值為100。看看流程,首先var我們知道它的記憶體地址就是0x0012eeee(上面說的編譯器安排的),而這個記憶體地址裡面的值就是0x0012ffff這個記憶體地址。var是一個指標,在前面指標篇我們知道var有它自己的記憶體地址(這裡就是0x0012eeee),它自己又儲存了它所指向的記憶體地址(這裡就是0x0012ffff)。這裡這個記憶體地址也就是傳進來的a變數的地址,我們在間接訪問(*var)時,實際就是操作的a變數本身。因此這裡將會直接指向a的地址將其值改變為100。

這個例子在我沒有打招呼的情況下我們已經就講了地址傳遞的方法。地址傳遞就是將一個變數的地址傳遞給函式,函式內部在訪問壓入的這個引數時,讀寫的是外部變數的地址值。因此可以改變傳入引數的值。

問題二:假如上面的程式中a是一個指標,我們將a傳進函式fun,然後在fun函式裡改變指標的指向(指標的值)。外面的a指標是否會改變? 為什麼? (提示:原理跟上面一樣,必要時用二級指標進行地址傳遞)

說到陣列,我們又不得不想到如果我們想傳一個一維陣列到函式內部,供函式取值或者寫值。又該怎麼做?

void fun( int* a )
{
    a[ 0 ] = 100;
    a[ 1 ] = 100;
    a[ 2 ] = 100;
}

int main( void )
{
    int array[ 3 ] = { 1, 2, 3 };
    fun( array );

    return 0;
}

以上程式碼中,我們的意圖是想將array的值改成100。我們的目的達到了,結果一切正常。為什麼呢?可能有的讀者已經被上面的值傳遞和地址傳遞給弄混了。在這裡我們不用多想,就應該知道這裡傳入的是array陣列的首地址,在函式內部會將這個地址裡面的值進行修改,然後加上偏移逐個修改。這裡也是通過地址直接操作的。原理跟上面一樣我就不多說了。這裡的fun函式是我知道array陣列有3個元素的情況下,假如不知道,那麼我們就該再新增一個數組元素個數的引數。這樣既安全又得體。比如:void fun( int* pArray, int size );

fun( array, 3 ); 這樣函式內部就不會怕讀寫越界了。

問題三:怎麼傳二維陣列到函式內部?

下面我就來舉一個越界帶來的可怕後果之一:

void fun( void )
{
    printf( "I'm Come In!!!/n" );
}

int main( void )
{
    int array[ 1 ] = { 1 };
    array[ 3 ] = ( unsigned int )fun;
   
    return 0;
}

就上面一個簡單的程式,已經詮釋了一個經典的緩衝區溢位攻擊基本原理了。先解釋下程式,這裡定義了一個數組array,它是有一個元素,下面的一句    array[ 3 ] = ( unsigned int )fun; 我這裡是故意將fun函式的地址越界賦值給array陣列後面的第3個記憶體地址裡。佔用4個位元組。這樣做的目的,大家運行了便知道,神奇般的在我沒有呼叫fun函式的情況下進入了fun函式並輸出了I‘m Come In!!!字串。可能很多人就傻了,為什麼會這樣?我這裡並沒有呼叫。

原因很簡單,我們每個函式在執行完以後都會跳轉回來,回到呼叫此函式的下一條語句繼續往下執行,函式之所以能跳轉回來是因為我們在呼叫函式的時候就已經將要返回到的程式碼地址給儲存到函式棧記憶體中了。我這裡將陣列寫越界的目的就是為了將這個返回地址值改變成我的目標函式fun函式的地址(函式也是有首地址的)。這裡強制型別轉換fun函式首地址為無符號整數覆蓋掉main函式的返回地址。這樣在main函式返回時便會跳轉到fun函式並執行該函式。輸出字串。我們可以聯想一下,假如這個fun函式是我們的黑客想操作一些事情的函式,那將是非常危險的。這裡就是經典的“緩衝區溢位攻擊”的基本原理。

假如我這裡不是array陣列,而是一個字元陣列,我們在strcpy的時候沒有檢查長度,黑客通過修改函式傳入的字串引數,讓其拷貝越界,覆蓋掉返回地址,覆蓋的內容就是黑客自己實現的函式的地址。我們程式將神不知鬼不覺的呼叫它的函式。當然上面我寫的這個在執行輸出後,fun函式在返回時,由於不是正常呼叫,他的返回地址沒有誰給他壓入,將返回到錯誤的地址最後崩潰掉。這裡我沒有處理堆疊平衡和返回地址。處理之後將不會崩潰,跟正常流程一樣順利。

上面說了越界緩衝區溢位亂調函式,也是為了引入函式指標,上面的例子我們初識函式也是有自己的地址的。既然有地址,那麼指標必然就成立。既然是指標,又是普通函式,那麼我隨便怎麼轉換該指標都沒有問題。這也是CC++的魅力所在。我上面就輕輕鬆鬆轉換成了無符號整數然後覆蓋了返回地址。是不是很方便?那麼我們再看看正規的函式指標定義:

void fun( void )
{
    printf( "I'm Come In!!!/n" );
}

int main( void )
{
    typedef void( *PFUN )( void );   // 定義函式指標,這裡使用typedef別名,PFUN就被宣告為void返回,無引數型別函式的指標
    PFUN pfun = fun;
   
    ( *pfun )(); 

    pfun();           // 兩種呼叫方式都是一樣的
    
    return 0;

上面大家已經知道了函式指標的定義了吧,語法很簡單。先定義一個函式指標pfun,將值賦值為fun函式的地址,函式名也代表函式指標,此指標就是指向的fun函式開始的程式碼地址。這裡是程式碼地址。在我們的exe中,每一句程式碼都是有自己的程式碼地址的。這裡的程式碼值的是彙編每條指令。這裡我們不追究,只需要知道函式也是有首地址的。可以賦值給函式指標乃至任何一個指標。只不過賦值給函式指標之後我們就可以像( *pfun )();   pfun();這樣呼叫它。跟函式呼叫沒有什麼區別。 假如你給我將fun函式賦值給一個void*指標p:
void* p = ( void* )fun;  

p();   // error

這樣將是錯誤的,原因就不用說了吧。天下人都知道。

函式指標也很靈活,同樣也可以由引數,有返回值。跟普通函式沒有上面區別。

問題四:定義一個有引數,有返回值的函式指標,並呼叫它。

將函式指標作為引數也是有必要了解的:

typedef void( *PFUN )( void );

void fun( void )
{
    printf( "I'm Come In!!!/n" );
}

void call_func( PFUN pFun )
{
    pFun(); 
}

int main( void )
{
    call_func( fun );
    return 0;
}

上面的程式碼,反映了將函式指標作為引數傳遞給一個函式,讓這個函式在另外一個地方被執行。這個過程通常稱為回撥。fun可以稱為回撥函式。我們將fun的函式指標傳遞給call_func,然後call_func再呼叫這個fun函式。原理大家清楚了吧。

回撥函式在大型的專案中使用得非常多,最直接的就是我們的WIN32的訊息回撥函式。我們需要註冊我們自己定義的函式給作業系統,這裡的註冊其實就是作業系統提供了一個函式指標給我們。我們將提供的這個函式指標賦值為我們自定義的函式的指標。作業系統內部又在不斷的呼叫這個函式指標。因此我們就可以讓作業系統呼叫我們的自定義函數了。大家可以自己試著寫寫這樣的呼叫模型。比如一個函式指標的連結串列,裡面存放了很多函式指標,我們遍歷呼叫這個連結串列裡面的所有函式指標。這些指標我們都賦值成我們想要呼叫的函式。

這裡值得大家注意的是,使用函式指標的時候一定要小心,比如:

typedef int ( *PFUN )( void );

void fun( void )
{
    printf( "I'm Come In!!!/n" );
}

int call_func( PFUN pFun )
{
    int a = pFun();
    return a;
}

int main( void )
{
    int ret = call_func( ( PFUN )fun );
    return 0;
}

我將fun函式強制轉換成int返回型別的函式指標,然後呼叫。這樣執行完成後,ret的值將是廢棄的。不可預測的。原因很簡單,fun函式是沒有返回值的。這裡的返回值具體會是讀取的哪兒的值我們就不在這裡講解了,知道有這麼回事就可以了。這裡假如不強制轉換,編譯器也只是會給一個警告而已。這種用法是絕對錯誤的。所以我們在使用回撥函式的時候一定要注意引數的函式指標是宣告的指向什麼型別的函式。

另外函式的可變引數這裡就不講了,這不是重點,只是語法而已。大家通過查閱資料就可以明白了。

好了,函式我們就介紹完了。大家好好理解。有點長。又寫了我5個小時左右。。。。休息。。

【C/C++入門篇系列】