函式進階功能說明

函式之功能

定義一個函式之目的有許多, 以下舉幾個來說明:

  1. 節省相同程式碼重覆撰寫的時間和空間: 在一個程式中常常會有一些重覆出現的程式片斷, 例如列印資料於螢幕或由鍵盤輸入的程式碼, 這些程式片斷有時會完全一樣, 有時會有一些小小的不同, 例如某些資料可能不同, 某些程序順序可能不同…, 但是當大部份都一樣的時候, 可以將其寫為函式, 以節省這些重覆的程式碼在程式裡到處出現。

    注意:

    1. 當有少部份資料或是動作不同時, 可以撰寫有參數的函式

    2. 其實最重要的是節省許多偵錯及維護的時間, 當同樣的程式片斷在程式中重覆出現許多次時, 程式設計者很難保證這些程式碼在各處出現時是完全一樣的, 也許原來的程式片斷是對的, 但是拷貝過去修改過以後的就不一定了, 因此這些看起來很像的程式段落必須視為完全獨立的程式來維護。

  2. 邏輯上協力達成某一功能的一連串程式碼, 以函式來整合為一語法單元: 在 C 程式中連續的每一個敘述對編譯器來講其實都是獨立的, 程式設計者在設計的時候也許讓連續的兩三個敘述共同來完成某一功能, 但是對於其他人來說, 邏輯上的關連性是隱藏的, 有的人看得到, 有的人看不到的。 如果希望你設計的程式是很容易看得懂、 容易維護、 容易擴充的, 那就不要吝嗇使用函式來將一些敘述實質地組合在一起, 讓它成為一個新的話法擴充單元。 (一開始學習 C 程式的時候很容易誤以為 printf()、 scanf(), 都是 C 語言語法的一部份, 可是其實它們都不是, 只是外加輔助函式庫中的一員, 由此可見函式是很理想的語法擴充方式。)

    注意: 由這個角度去看的話, 就算一個函式內的程式碼片段在程式中只出現過一次, 還是值得寫成一個函式的。

  3. 隔絕變數之作用範圍: 在 C 程式中每一個區塊都定義一個新的命名空間, 一個函式也是一個區塊結構, 因此其內也定義一個完整的命名空間。 沒有包含關係的區塊其各自命名空間完全獨立, 在這些獨立的命名空間中定義的變數互相沒有關係, 各自只有在自己的命名空間中的程式才看得到, 不會影響到其它命名空間中的程式, 如此變數內資料的影響力完全侷限在相關的程式碼內, 程式內資料與指令間可以減少許多隱含的錯綜複雜的關係, 程式偵錯與維護會變得比較容易。

  4. 運用函式呼叫的堆疊來製作遞迴演算法。

函式之定義

  1. 函式名稱: 要定義一個函式, 首先就是替它定一個名字, 每一個函式都有一個唯一的名字。 程式中任何地方透過這個名字來叫用它, 這個名字我們一般都會取一個具有代表性的名字, 能夠望文生義, 知道函式內部應該是做什麼工作的名字。 例如:printf 代表 printf formatted string, getch 代表 get character, 大部份標準函式庫內的函式名稱都在 8 個字元以下, 因此英文名字都被縮寫, 有時很難辨認, 這些函式大多有十多年的歷史了, 別太苛求。 現在你在定一個函式名稱的時候, 別太小氣, 給它一個清楚的名字。 例如:printfFormattedData(), getCharFromFile() ...., 命名的原則可以參考變數的命名原則

    注意: 以往常常是由於編譯器有函式名稱長度的限制, 所以名稱才必須很簡短。

  2. 函式傳回值的型態: 一個函式在執行完畢時可以利用 return 敘述傳回一個數值到呼叫此函式的地方, 例如:

    等號右邊 getCharFromFile() 是在呼叫一個函式, CPU 除了會執行很多敘述之外, 還會計算一個數值出來, 以便指定給等號左邊的變數 c, 這個數值是在函式內計算出來並且用 return 敘述傳回呼叫函式的, 例如:

    於是函式內變數 ch 內存放的資料就成為呼叫端等號右邊的數值, 可以儲存在等號左邊的變數內。

    在定義函式時我們必須定義傳回值的型態, 這有兩種目的:

    1. 在呼叫此函式的程式裡, 編繹器可以判斷傳回數值的型態, 看看需不需要做型態的轉換,例如:

        int intSquare(int);

      是一個自已定義的函式, 可以計算出傳入整數的平方, 並傳回此平方變數, 如果在主程式中用下面的方式呼叫:

        int x = 20; double y; y = intSquare(x);
      intSquare() 函式會順利地計算出平片以後的整數值, 但是這個二進位資料不能直接存入變數 y 中, 因為變數 y 是一個倍精準的浮點數, 整數資料要先轉換為浮點格式才可以, 編譯器實際上做了下面的轉換:

        y = (double) intSquare(x);

    2. 在此函式內, 當使用 return 敘述傳回一個數值時, 編譯器也要檢查這個數值的型態是否正確, 例如:
        double fun(...) { int x; . . return x; . . return 1; }
      上面函式中兩個 return 敘述, 編譯器都會自動修改為
        double fun(...) { int x; . . return (double) x; . . return 1.0f; }
      在傳回數值之前先把整數格式轉換為倍精準浮點格式。

    當然一個函式也可以不傳回任何數值, 例如:

    此種函式內在使用 return 敘述時不可以在 return 之後再加一個運算式, 此外在叫用此種函式時不可以使用在需要數值的地方,例如: 一個函式不能傳回一個陣列資料, 例如:
      typedef double DARY[20]; DARY fun(....) { ... } 或是 double[20] fun(....) { ... } 或是 double fun[20](....) { ... } 都是不正確的寫法。
    只能傳回陣列之指標,例如:
      double *fun(....) { ... }
    一個函式可以傳回使用者自定的結構 (struct) , 例如:
      struct intAryType { int num; int elems[100]; }; struct intAryType fun(...) { struct intAryType x; ... return x; }
    傳回結構時將拷貝該結構之所有資料內容。 不管該結構占多少記憶體位元組, 一律完全拷貝, 因此程式設計者應該綜合衡量效率、 邏輯之完整、 流程之清晰來選擇是否要傳回結構或是結構之指標

  3. 函式之參數: 大部份的情況下, 我們不會讓一個函式每次執行的時候都完完全全地做一模一樣的事情, 就算步驟一樣, 常常處理的資料也會改變, 有時甚至處理不同資料時的步驟也是不一樣的, 那麼這些資料是如何由呼叫端程式交給函式去做呢? 就是透過函式的參數囉!

    函式的參數和函式的傳回值是函式內部和呼叫此函式的程式兩端資料及結果傳遞的橋樑, 呼叫端程式要將資料交給函式處理必須籍由參數。 函式執行完畢後如果要將處理的資料交回去給呼叫端函式時, 也要透過參數或是傳回值兩者。

    如下例計算陣列中數字的平均值及標準差函式:

      double findStatistics(const double [100], const int count, double *stdDeviation) { int i; double mean = 0.0; for (i=0; i<count; i++) mean += data[i]; mean = mean / count; *stdDeviation = 0.0; for (i=0; i<count; i++) *stdDeviation += (data[i] - mean) * (data[i] - mean); *stdDeviation = *stdDeviation / (count -1); return mean; }
    上面的函式共有三個參數 data, count 及 stdDeviation, 前兩者是將資料傳進函式, 最後一個指標則是為了將資料傳回呼叫之函式。

    函式參數的宣告方法和一般的變數一樣, 需要指明其型態, 需要設計其名稱, 這個名稱和函式內部的變數一樣屬於同一個命名空間, 對於基本型態 (char, short , int , long, float, double) 及指標型態參數而言, 這樣子的定義會保留適當數量的位元組來存放資料, 函式內可以把資料暫存在這個地方, 也可以取用放在這裡的資料, 在函式被呼叫、開始執行之前 CPU 會先計算相對應每一個參數的引數運算式, 將每一個運算式的數值存入相對應的參數變數中, 若參數為陣列或是指標時則引數運算式的結果應該是一個記憶體位址, 否則應該是一個整數或是浮點數。

    對於陣列變數、 例如上面程式中的 double data[100] 而言, 只保留一個 double * 指標可以存放的空間, 並不配置 100 個 double 型態的變數。 這是函式參數和一般變數差異最大的地方。 data 是一個指標常數, 就儲存在上面配置的 double * 指標變數空間中。 此指標在函式呼叫時 CPU 由相對應的引數上求得一位址來初始化, 函式內部不能夠再去更改此指標, 函式內部可以透過此指標名稱以陣列的語法 data[i] 或是以指標的語法 *(data) 來存取呼叫此函式的程式內引數陣列的內容, 如果在函式內不允許修改該陣列內容的話, 函式參數宣告時必須宣告 const double data[100]。

    請注意函式參數對於函式內部而言, 除了有很特殊的初始化方式之外, 就像是一個一般的變數一樣, CPU 可以暫存資料在裡面, 也可以讀取裡面存放的資料, 當它們參予運算式時, 會依其定義的型態選擇適當的運算子以及型態轉換。

    函式參數的宣告對於呼叫端程式而言也具有很重要的意義。 例如:在 math 函式庫中 fabs() 函式的宣告是

      double fabs(double)

    因此對於

      int x; x = fabs(2);
    這兩列程式而言, 實際上編譯器會作

      x = (int) fabs((double) 2);

    的動作, 將資料的型態作適當的轉換後才作交換的動作。

  4. 函式主體

    函式主體是一對大括號中的複合敘述 (compound statement) 所構成, 這個區塊的最前面可以定義區域變數 (Local variables), 在 ANSI C 及 K&R C 中才要求必須在所有可執行的敘述之前定義變數, C++ 中並沒有這樣的要求, TURBO C 中如果你在 Options/ Compiler / C++ Options 選擇 C++ always 則你用的是 C++ 編譯器, 並不嚴格要求如此, 如果你選擇 CPP extension 的話, 視你的程式檔案的附檔名, 若是 *.c 的話使用 ANSI C 的編譯器。 若是 *.cpp 則使用 C++ 編譯器。

    變數定義時, 除了有加 static 的變數之外, 都是使用函式呼叫的堆疊來做為變數的儲存空間。 由於堆疊空間有限, 而且編譯器無法得知程式內演算法的運作狀況。 它無法幫你檢查是否在堆疊上有足夠空間來存放你定義的變數, 所以請小心使用。 除了區域變數之外, 也可以有函式原型的宣告, 但是不能有函式的定義。 (這是和 pascal 不一樣的地方,請留意!) 接在後面的就是可以執行的 C 敘述和下層的區塊 (block) 了。

函式原型 (prototype) 的宣告

如同前面函式參數宣告所描述的, 函式參數型態的宣告對於打算呼叫此函式的程式而言具有非常重要的意義, 如果交給函式的資料內容錯了的話函式裡面做得死去活來的不就完全浪費了嗎? 同樣地如果函式好不容易找出一個結果, 在交付結果的最後一刻因為雙方認知資料挌式的不同而使得資料錯誤的話, 也是毫無意義的。

那麼在呼叫一個函式的時候編譯器如何才能得知此函式到底要傳回什麼型態的資料、 到底該交付給它什麼型態的資料呢? 如想要呼叫的函式在呼叫之前先定義過了的話, 例如:

那麼編譯器理所當然地就知道函式 fun1() 第一個參數需要是整數, 第二個參數需要是浮點數, 傳回值是整數, 然後 安排 CPU 作下列的型態轉換:

但是 C 程式裡很可能出現:

  1. 想要呼叫的函式還沒有定義, 例如上例中如果在 fun1() 函式中呼叫 fun2() 函式。

  2. 交互呼叫,fun1()中呼叫 fun2(), fun2() 中又呼叫 fun1() 。

  3. 要呼叫的函式在其它的程式檔案中。

上面三種情況下, 編譯器在處理函式呼叫敘述時並沒有相關的型態資料, 如果是 ANSI C 或是 C++ 的話, 這種情況下編譯器會產生錯誤訊息, 如果是 K&R 的 C 編譯器的話會用預設的型態, 常常會導致不一致的錯誤 (例如函式本身以 ANSI C 編譯, 而函式呼叫時用 C 預設的型態, 則可能會有型態不一致的狀況發生, 比方說在 TURBC C 中如果你使用 math 函式庫中的 fabs() 或是 sqrt() 函式而沒有 #include <math.h> 的話, 會因為 C 中預設的回傳值型態是 int 而在執行時發現資料的錯誤。)

要保證正確的話, 請使用函式的原型宣告, 例如:

只要在函式 fun1() 被呼叫的敘述之前出現其原型之宣告, 編譯器就可以瞭解函式呼叫時參數該如何轉換, 函式傳回值來如何轉換。 如果希望限制這個函式的使用範圍, 你也可以在最內層區塊的最前端定義函式原型, 如此就只有在定義原型的區塊內可以正確地使用該函式。 例如:

注意: 在函式原型中參數的名稱可以省略, 因為對於呼叫程式來說有關係的是參數的型態而已。

函式引數之自動型態轉換 (type promotion rule)

在早期 K&R 的時代, 函式不需要宣告其原型, 參數傳遞時全靠呼叫時引數 (argement) 的型態來決定如何在堆疊上放置參數 (型態、格式、以及幾個位元組等等), 函式內則根據宣告的參數型態來存取堆疊上的資料, 萬一對應有誤的話就會出現資料解釋上的錯誤, 造成程式執行時邏輯的錯誤。 這種對應必須由程式設計者全權負責, 編蠌器一點兒忙都幫不上, 如果你希望傳進函式的是 4 個位元組的整數的話, 有幾種方式, 例如: 則在堆疊上可能只配置 2 個位元組放置 10 這個資料, 而在函式裡可能因為宣告 而希望在堆疊上看到 4 個位元組的資料, 於是發生執行時的錯誤。

對於 K&R 的 C 來說為了怕這種問題太常發生, 用所謂的 type promotion rule 來做一點保護, 乾脆限定在堆疊上只有幾種長度的資料格式, 一是二個位元組, 二是四個位元組, 三是八個位元組。 只要是 char、short、或是 int 型態就一律使用二個位元組, long 型態用四個位元組,float 或 double 都用八個位元組, 當然資料格式也要同步地做一些轉換, 在這情況下嚴格地來說函式的參數並沒有所謂的 float 型態, 而只有 double 型態, 就算你定義 float 型態的參數如下例:

上面程式中你會發現印出來的兩個值竟然不一樣, 一個是 4 ,一個是 8 ,驚訝吧! 不信的話找一台支援 K&R 的 C 編譯器的機器試看看! (例如:SUNOS 4.1.4 的 cc 就是這樣, gcc 不會這樣, FreeBSD 裡的 cc 也不會)。 不管怎樣還好現在通用的是 ANSI C , 沒有這個困擾了。 (注意上面列舉的位元組數與 OS 及機器相關。)

現在還存在的問題是類似像 printf() 函式的宣告及使用

這個 "…" 有著和 K&R C 傳參數時類似的因擾, 還好它唯一特殊的地方是不用 float 型態, 遇見浮點數一律轉換為 double 型態, 以 8 個位元組來存放。

全域變數

有些程式設計者偏愛使用全域變數來做為函式與呼叫端資料交換的媒介。 這方式的好處是不需要在呼叫函式的敘述中使用參數來傳遞資料, 而函式中可以任意地存取全變數的內容-------方便。 不過方便是有代價的, 這種情況下我們稱函式有 side effects。 也就是在呼叫一個函式時除了列出來的參數之外, 還有其它變數的內容參與運算, 其資料可能會改變, 使得函式的作用模糊, 不論是在程式偵錯、 程式修改、 程式修改、 程式維護、 或是程式移交時都會造成很大的困擾, 每一個函式都暗藏玄機。 閱讀、維護程式的時候很難建立一個資料的流程圖 (資料相關圖), 你閱讀程式的時候可能知道程式做了某些動作, 卻不知道對哪些資料做了這此動作。

陣列參數

如同前面的 findStatistics() 範例, 在函式的參數串列中宣告一個陣列和一般的陣列變數是非常不同的, CPU 在開始執行此函式的時候並沒有替此陣列配置陣列內容存放的空間, 而只是配置了一個指標的空間, 之所以宣告為陣列的原因是因為可以用陣列元素的存取運算元, 在函式內可以表達比較清楚的概念。 事實上如果直接宣告為指標的話, 也是可以使用陣列的存取遲算元 "[]", 只是感覺上有那麼一點點奇怪而已, 其實功能是一模一樣的。 例如: 如果宣告改為

其它的一點都不用改, 效果是完完全全一樣的, 關於陣列的宣告與指標的關係請參考陣列進階

很多人會有疑問說為什麼第一個大小不需要給, 第二個則一定要給? 簡單地說: 第二個大小 20, 是型態 int [20] 的一部分, 如果不指明的話, param 就不是每一個元素是 "20個整數的陣列" 的陣列了, 第一個大小則可以不寫, 因為根本就不需要知道此陣列有幾個元素 (不需配置記憶體), 詳情請參考陣列進階

函式指標:

當你定義一個函式如下: 你知道 numElems 代表一個數值, data 代表一個位址, i、sum 也都代表一個數值資料, 也就是說下面的 printf() 呼叫可以列印出這些個符號相對應的值或是資料:

那麼你知道

列印出來的資料代表什麼意義嗎?

是位址, 是 average() 這個函式載入在記憶體內的起始位址, 喔! 我們知道記憶體是一連串的位元沮, 每一個位元組有一個唯一的位址, 程式處理的資料如果放在記憶裡, 必須知道放在記憶體的什麼地方 (也就是位址), CPU 才能一個指令一個指令地讀入指令暫存器 (IR) 中執行, 因此程式也有一個記憶體中的起始位址, 程式本身由此位址開始存放。

在 C 程式裡當你寫一個函式的位址, 再接著寫一對小括號的參數串列的話就是代表著函式的呼叫 (去那個位址執行存放在那個位址的指令), 例如:

如果你寫

的效果也是一樣的, (不過還是別盡量蛇添足的好)。

我們也可以宣告一個指標變數來存放像 average 這樣子的函式位址, 例如:

注意

  1. 上面函式指標變數之宣告絕對不可以寫成 double *pfn(int, int[]); 這樣子的宣告代表一個名字叫做 pfn 的函式原型, 傳入兩個參數並傳回一個指向 double 變數的指標。

  2. 這樣子的宣告有時不太好理解, 也沒辦法同時宣告好幾個變數或是陣列變數, 所以通常都併用 typedef 敘述另外定義一個型態的別名, 例如:

      typedef double(*DFN)(int, int[]); DFN pfn1, pfn2, pfn3[20];

    上面的 pfn1, pfn2, pfn3[0] ... pfn3[19] 都是函式指標變數。

    言歸正傳,指標變數到底有什麼用途?

    由最簡單的例子說起, 我們用變數 x

      int x; printf("%d",x);

    而不用常數 3

      printf("%d",3);

    最主要是不希望永遠只列印 3, 而是不管變數 x 的內容為何我們都將它列印出來。 同樣的道理,我們運用指標變數 xp:

      int x; int *xp = &x; ... *xp = 10; 而不用變數 x int x; ... x = 10;
    最主要是不希望程式執行時 10 永遠存入變數 x 之中, 而是不管指標變數 xp 之內容為何變數的位址, *xp = 10; 可以將 10 存入那一個變數之中。

    對了, 是為了彈性, 同樣的程式碼, 可以因為變數內資料的不同而做出不同的事。 我們用

      average(10,x);

    當然可以呼叫 average() 函式, 但是用

      double (*pfn)(int,int[]); ... pfn = average; ... (*pfn)(10,x);
    除了呼叫 average() 函式之外, 更可以視程式的邏輯、 pfn 函式指標變數的內容來呼叫不同的函式 (但是參數相同)。 範例請見 qsort() 函式之應用。

    函式指標是由 C 到 C++ 物件化過程中很重要的一種機制, 運用它, C++ 很容易地達到物件導向程式中的多型 (polymorphism, 動態繫結、old code call new code), 使得程式的擴展性大大提昇, 軟體的重用性大大增強, 說它是基石應該沒有太大偏差。 詳細內容請參考 C++ 書籍。

    程式設計課程 首頁
    by Pei-yih Ting
    E-mail: pyting@cs.ntou.edu.tw