第九章 函數

GitHub地址,歡迎 star

9.1 複習函數

首先,什麼是函數?函數(function)是完成特定任務的獨立程序代碼單元。語法規則定義了函數的結構和使用方式。雖然 C 中的函數和其他語言中的函數、子程序、過程作用相同,但是細節上略有不同。一些函數執行某些動作,如 printf() 把數據打印到屏幕上:一些函數找出一個值供程序使用,如,strlen() 把指定字符串的長度返回給程序。一般而言,函數可以同時具備以上兩種功能。

爲什麼要使用函數?首先,使用函數可以省去編寫重複代碼的苦差。如果程序要多次完成某項任務,那麼只需編寫一個合適的函數,就可以在需要時使用這個函數,或者在不同的程序使用該函數,就像許多程序中使用 putchar() 一個。其次,即使程序只完成某項任務一次,也值得使用函數。因爲函數讓程序更加模塊化,從而提高了程序代碼的可讀性,更方便後期修改、完善。

9.1.1 創建並使用簡單函數

我們的第 1 個目標是創建一個在一行打印 40 個星號的函數,並在一個打印表頭的程序中使用該函數。

#include <stdio.h>
#define NAME "GIGATHINK, INC."
#define ADDRESS "101 Megabuck Plaza"
#define PLACE "Megapolis, CA94904"
#define WIDTH 40

void starbar(void); /* 函數原型 */

int main(void)
{
    starbar();
    printf("%s\n", NAME);
    printf("%s\n", ADDRESS);
    printf("%s\n", PLACE);
    starbar(); /* 使用函數 */

    return 0;
}

void starbar(void) /* 定義函數 */
{
    int count;

    for(count = 1; count <= WIDTH; count++)
        putchar('*');
    putchar('\n');
}

該程序的輸出如下:

****************************************
GIGATHINK, INC.
101 Megabuck Plaza
Megapolis, CA94904
****************************************

9.1.2 分析程序

該程序要注意以下幾點。

  • 程序在 3 處使用了 starbar 標識符:函數原型(function prototype)告訴編譯器函數 startbar() 的類型:函數調用(function call)表明在此處執行函數:函數定義(function definition)明確地指定了函數要做什麼。
  • 函數和變量一樣,有多種類型。任何程序在使用函數之前都要聲明該函數的類型。因此,在 main() 函數定義的前面出現了下面的 ANSI C 風格的函數原型:void starbar(void); 圓括號表明 starbar 是一個函數名。第 1 個 void 是函數類型,void 類型表明函數沒有返回值。第 2 個 void(在圓括號中)表明該函數不帶參數。分號表明這是在聲明函數,不是定義函數。也就是說,這行聲明瞭程序將使用一個名爲 starbar()、沒有返回值、沒有參數的函數,並告訴編譯器在別處查找該函數的定義。對於不識別 ANSI C 風格原型的編譯器,只需聲明函數的類型,如下所示:void starbar(void); 注意,一些老版本的編譯器甚至連 void 都識別不了。如果使用這種編譯器,就要把沒有返回值的函數聲明爲 int 類型。當然,最好還是換一個新的編譯器。
  • 一般而言,函數原型指明瞭函數的返回值類型和函數接收的參數類型。這些信息稱爲該函數的簽名(signature)。對於 starbar() 函數而言,其簽名是該函數沒有返回值,沒有參數。
  • 程序把 starbar() 原型置於 main() 的前面。當然,也可以放在 main() 裏面的聲明變量處。放在哪個位置都可以。
  • 在 main() 中,執行到下面的語句時調用了 starbar() 函數:starbar(); 這是調用 void 類型函數的一種形式。當計算機執行到 starbar(); 語句時,會找到該函數的定義並執行其中的內容。執行完 starbar() 中的代碼後,計算機返回主調函數(calling function)繼續執行下一行。
  • 程序中 starbar() 和 main() 的定義形式相同。首先函數頭包含函數類型、函數名和圓括號,接着是左花括號、變量聲明、函數表達式語句,最後以右花括號結束。注意,函數頭中的 starbar() 後面沒有分號,告訴編譯器這是定義 starbar(),而不是調用函數或聲明函數原型。
  • 程序把 starbar() 和 main() 放在一個文件中。當然,也可以把它們分別放在兩個文件中。把函數都放在一個文件中的單文件形式比較容易編譯,而使用多個文件方便在不同的程序中使用同一個函數。如果把函數放在一個單獨的文件中,要把 #define 和 #include 指令也放入該文件。我們稍後會討論使用多個文件的情況。現在,先把所有的函數都放在一個文件中。main() 的右花括號告訴編譯器該函數結束的位置,後面的 starbar() 函數頭告訴編譯器 starbar() 是一個函數。
  • starbar() 函數中的變量 count 是局部變量(local variable),意思是該變量只屬於 starbar() 函數。可以在程序中的其他地方。

如果把 starbar() 看做是一個黑盒,那麼它的行爲是打印一行星號。不用給該函數提供任何輸入,因爲刁穎它不需要其他信息。而且,它沒有返回值,所有也不給 main() 提供(或返回)任何信息。簡而言之,starbar() 不需要與主調函數通信。

9.1.3 定義帶形式參數的函數

函數定義從下面的 ANSI C 風格的函數頭開始:void show_n_char(char ch, int num);

該行告知編譯器 void show_n_char() 使用兩個參數 ch 和 num,ch 是char 類型,num 是 int 類型。這兩個變量被稱爲形式參數(formal parameter),簡稱形參。和定義在函數中變量一樣,形式參數也是局部變量,屬該函數私有。這意味着在其他函數中使用同名變量不會引起名稱衝突。每次調用函數,就會給這些變量賦值。

注意,ANSI C 要求在每個變量前都聲明其類型。也就是說,不能像普通變量聲明那樣使用同一類型的變量列表:

void dibs(int x, y, z) /* 無效的函數頭 */
void dubs(int x, int y, int z) /* 有效的函數頭 */

ANSI C 也接受 ANSI C 之前的形式,但是將其視爲廢棄不用的形式:

void show_n_char(ch, num)
char ch;
int num;

這裏,圓括號只有參數名列表,而參數的類型在後面聲明。注意,普通的局部變量在左花括號之後聲明,而上面的變量在函數左花括號之前聲明。如果變量是同一類型,這種形式可以用逗號分隔變量名列表,如下所示:

void dibs(x, y, z)
int x, y, z; /* 有效 */

當前的標準正逐漸淘汰 ANSI 之前的形式。讀者對應此有所瞭解,以便能看懂以前編寫的程序,但是自己編寫程序時應使用現在的標準形式。

雖然 void show_n_char() 接受來自 main() 的值,但是它沒有返回值。因此,void show_n_char() 的類型是 void。

9.1.4 聲明帶形式參數函數的原型

在使用函數之前,要用 ANSI C 形式聲明函數原型:void show_n_char(char ch, int num);

當函數接收參數時,函數原型用逗號分割的列表指明參數的數量和類型。根據個人喜好,你也可以省略變量名:void show_n_char(char, int);。在原型中歐使用變量名並沒有實際創建變量,char 僅代表一個 char 類型的變量,以此類推。

再次提醒讀者注意,ANSI C 也接受過去的聲明函數形式,即圓括號內沒有參數列表:void show_n_char();

這種形式最終會從標準中剔除。即使沒有別剔除,現在函數原型的設計也更有優勢。瞭解這種形式的寫法是爲了以後讀得懂以前的代碼。

9.1.5 調用帶實際參數的函數

在函數調用中,實際參數(actual argument,簡稱實參)提供了 ch 和 num 的值。形式參數時被調函數(called function)中的變量,實際參數時主調函數(calling function)賦給被調函數的具體值。實際參數可以是常量、變量,或甚至是更復雜的表達式。無論實際參數是何種形式都被要求值,然後該值被拷貝給被調函數相應的形式參數。

注意 時間參數和形式參數

實際參數是出現在函數用圓括號中的表達式。形式參數是函數定義的函數頭中聲明的變量。調用函數時,創建了聲明爲形式參數的變量並初始化爲實際參數的求值結果。

9.1.6 黑盒視角

從黑盒的視角看 void show_n_char(),待顯示的字符和顯示的次數是輸入。執行後的結果是打印指定數量的字符。輸入以參數的形式被傳遞給函數。這些信息清楚地表明瞭如何在 main() 中使用該函數。而且,這也可以作爲編寫該函數的設計說明。

黑盒方法的核心部分是:ch、num 和 count 都是 void show_n_char() 私有的局部變量。如果在 main() 中使用同名變量,那麼它們相互獨立,互不影響。也就是說,如果 main() 有一個 count 變量,那麼改變他的值不會改變 void show_n_char() 中的 count,反之亦然。黑盒裏發生了什麼對主調函數是不可見的。

9.1.7 使用 return 從函數中返回值

前面介紹瞭如何把信息從主調函數傳遞給被調函數。反過來,函數的返回值可以把信息從被調函數傳回主調函數。爲進一步說明,我們將創建一個返回兩個參數中較小值的函數。由於函數被設計用來處理 int 類型的值,所以被命名爲 imin()。另外,還有創建一個簡單的 main(),用於檢查 imin() 是否正常工作。這種被設計用於測試函數的程序有時被稱爲驅動程序(driver),該驅動程序調用一個函數。如果函數成功通過了測試,就可以安裝在一個更重要的程序中使用。程序清單演示了這個驅動程序和返回最小值的函數。

/* 找出兩個整數中較小的一個 */
#include <stdio.h>
int imin(int,int);

int main(void)
{
    int evil1,evil2;

    printf("Enter a pair of integers (q to quit):\n");
    while(scanf("%d %d",&evil1,&evil2) == 2)
    {
        printf("The lesser of %d and %d is %d.\n",evil1,evil2,imin(evil1,evil2));
        printf("Enter a pair of integers (q to quit):\n");
    }
    printf("Bye.\n");
    return 0;
}

int imin(int n,int m)
{
    int min;

    if(n < m)
        min = n;
    else
        min = m;
    return min;
}

程序運行示例:
Enter a pair of integers (q to quit):
509 333
The lesser of 509 and 333 is 333.
Enter a pair of integers (q to quit):
-9393 6
The lesser of -9393 and 6 is -9393.
Enter a pair of integers (q to quit):
q
Bye.

關鍵字 return 後面的表達式的值就是函數的返回值。在該例中,該函數返回的值就是變量 min 的值。變量 min 屬於 imin() 函數私有,但是 return 語句把 min 的值傳回了主調函數。

許多 C 程序員都認爲只在函數末尾使用一次 return 語句比較好,因爲這樣做更方便瀏覽程序的人理解函數的控制流。但是,在函數中使用多個 return 語句也沒有錯。

另外,還可以這樣使用 return:return; 這條語句會導致終止函數,並把控制返回給主調函數。因爲 return 後面沒有任何表達式,所以沒有返回值,只有在 void 函數中才會用到這種形式。

9.1.8 函數類型

聲明函數時必須聲明函數的類型。帶返回值的函數類型應該與其返回值類型相同,而沒有返回值的函數應聲明爲 void 類型。類型聲明是函數定義的一部分。要記住,函數類型指的是返回值的類型,不是函數參數的類型。

ANSI C 標準庫中,函數被分成多個系列,每一系列都有各自的頭文件。這些頭文件中除了其他內容,還包含了本系列所有函數的聲明。

9.2 遞歸

C 允許函數調用它自己,這種調用過程稱爲遞歸(recursion)。遞歸有時難以捉摸,有時卻很方便實用。結束遞歸是使用遞歸的難點,因爲如果遞歸代碼中沒有終止遞歸的條件測試部分,一個調用自己的函數會無限遞歸。

可以使用循環的地方通常都可以使用遞歸。有時用循環解決問題比較好,但有時用遞歸更好。遞歸方案更簡潔,但效率卻沒有循環高。

9.2.1 演示遞歸

我們通過一個程序示例,來學習什麼是遞歸。程序清單中的 main() 函數調用 up_and_down() 函數,這次調用稱爲“第 1 級遞歸”。然後 up_and_down() 調用自己,這次調用稱爲“第 2 級遞歸”。接着第 2 級遞歸調用第 3 級遞歸,以此類推。該程序示例共有 4 級遞歸。爲了進一步深入研究遞歸時發生了什麼,程序不僅顯示了變量 n 的值,還顯示了儲存 n 的內存地址 &n(本章稍後會詳細討論 & 運算符,printf() 函數使用 %p 轉換說明打印地址,如果你得系統不支持這種格式,請使用 %u 或 %lu 代替 %p)。

/* 遞歸演示 */
#include <stdio.h>
void up_and_down(int);

int main(void)
{
    up_and_down(1);
    return 0;
}

void up_and_down(int n)
{
    printf("Level %d: n location %p\n", n, &n); // #1
    if(n < 4)
        up_and_down(n + 1);
    printf("Level %d: n location %p\n", n, &n); // #2
}

下面是在我們系統中的輸出:
Level 1: n location 0060FEF0
Level 2: n location 0060FED0
Level 3: n location 0060FEB0
Level 4: n location 0060FE90
Level 4: n location 0060FE90
Level 3: n location 0060FEB0
Level 2: n location 0060FED0
Level 1: n location 0060FEF0

9.2.2 遞歸的基本原理

初次接觸遞歸會覺得較難理解。爲了幫助讀者理解遞歸過程,下面以程序清單爲例講解幾個要點。

第 1,每級函數調用都有自己的變量。也就是說,第 1 級的 n 和第 2 級的 n 不同,所以程序創建了 4 個單獨的變量,每個變量名都是 n,但是它們的值各不形同。當程序最終返回 up_and_down() 的第 1 級調用時,最初的 n 仍然是它的初值 1。

第 2,每次函數調用都會返回一次。當函數執行完畢後,控制權將被傳回上一級遞歸。程序必須按順序逐級返回遞歸,從某級 up_and_down() 返回上一級的 up_and_down(),不能跳級回到 main() 中的第 1 級調用。

第 3,遞歸函數中位於遞歸調用之前的語句,均按被調函數的順序執行。例如,程序清單中的打印語句 #1 位於遞歸調用之前,它按照遞歸的順序:第 1 級、第 2 級、第 3 級和第 4 級,被執行了 4次。

第 4,遞歸函數中位於遞歸調用之後的語句,均按被調函數相反的順序執行。例如,打印語句 #2 位於遞歸調用之後,其執行的順序是第 4 級、第 3 級、第 2 級、第 1 級。遞歸調用的這種特性在解決涉及相反順序的編程問題時很有用。

第 5,雖然每級遞歸都有自己的變量,但是並沒有拷貝函數的代碼。程序按順序執行函數中的代碼,而遞歸調用就相當於又從頭開始執行函數的代碼。除了爲每次遞歸調用創建變量外,遞歸調用非常類似於一個循環語句。實際上,遞歸有時可用循環來代替,循環有時也能用遞歸來代替。

最後,遞歸函數必須包含能讓遞歸調用停止的語句。通常,遞歸函數都使用 if 或其他等價的測試條件在函數形參等於某特定值時終止遞歸。爲此,每次遞歸調用的形參都要使用不同的值。例如,程序清單中的 up_and_down(n) 調用 up_and_down(n + 1)。最終,實際參數等於 4 時,if 的測試條件(n < 4)爲假。

9.2.3 尾遞歸

最簡單的遞歸形式是把遞歸調用置於函數的末尾,即正好在 return 語句之前。這種形式的遞歸被稱爲尾遞歸(tail recursion),因爲遞歸調用在函數的末尾。尾遞歸是最簡單的遞歸形式,因爲它相當於循環。

下面要介紹的程序示例中,分別用循環和尾遞歸計算階乘。一個正整數的階乘(factorial)是從 1 到該整數的所有整數的乘積。如下程序清單,第 1 個函數使用 for 循環計算階乘,第 2 個函數使用遞歸計算階乘。

/* 使用循環和遞歸計算階乘 */
#include <stdio.h>
long fact(int n);
long rfact(int n);
int main(void)
{
    int num;

    printf("This program calculates factorials.\n");
    printf("Enter a value in the range 0 - 12 (q to quit):\n");
    while(scanf("%d",&num) == 1)
    {
        if(num < 0)
            printf("No negative numbers, please.\n");
        else if(num > 12)
            printf("Keep input under 13.\n");
        else
        {
            printf("loop: %d factorial = %ld\n", num, fact(num));
            printf("recursion: %d factorial = %ld\n", num, rfact(num));
        }
        printf("Enter a value in the range 0 - 12 (q to quit):\n");
    }
    printf("Bye.\n");
    return 0;
}

long fact(int n) // 使用循環的函數
{
    long ans;

    for(ans = 1; n > 1; n++)
        ans *= n;
    return ans;
}

long rfact(int n) // 使用遞歸的函數
{
    long ans;
    if(n > 0)
        ans = n * rfact(n - 1);
    else
        ans = 1;
    return ans;
}

測試驅動程序把輸入限制在 0 ~ 12。因爲 12! 已快接近 5 億,而 13! 比 62億還大,已超過我們系統中 long 類型能表示的範圍。要計算超過 12 的階乘,必須使用能表示更大範圍的類型,如 double 或 long long。

下面是該程序的運行示例:
This program calculates factorials.
Enter a value in the range 0 - 12 (q to quit):
5
loop: 5 factorial = 0
recursion: 5 factorial = 120
Enter a value in the range 0 - 12 (q to quit):
10
loop: 10 factorial = 0
recursion: 10 factorial = 3628800
Enter a value in the range 0 - 12 (q to quit):
q
Bye.

使用循環的函數把 ans 初始化爲 1,然後把 ans 與從 n ~ 2 的所有遞減整數相乘。根據階乘的公式,還應該乘以 1,但是這並不會改變結果。

現在考慮使用遞歸的函數。該函數的關鍵是 n! = n x (n - 1)!。可以這樣做是因爲 (n - 1)! 是 n - 1 ~ 1的所有正整數的乘積。因此,n 乘以 n - 1 就得到 n 的階乘。階乘的這一特性很適合使用遞歸。如果調用函數 rfact(),rfact(n) 是 n * rfact(n - 1)。因此,通過調用 rfact(n - 1) 來計算 rfact(n),如程序清單。當然,必須要在滿足某條件是結束遞歸,可以在 n 等於 0 時把返回值設爲 1。

程序清單中使用遞歸的輸出和使用循環的輸出相同。注意,雖然 rfact() 的遞歸調用不是函數的最後一行,但是當 n > 0 時,它是該函數執行的最後一條語句,因此它也是尾遞歸。

既然用遞歸和循環來計算都沒問題,那麼到底應該使用哪一個?一般而言,選擇循環比較好。首先,每次遞歸都會創建一組變量,所以遞歸使用的內存更多,而且每次遞歸調用都會創建的一組新變量放在棧中。遞歸調用的數量受限於內存空間。其次,由於每次函數調用要花費一定的時間,所以遞歸的執行速度較慢。那麼,演示這個程序示例的目的是什麼?因爲尾遞歸是遞歸中最簡單的形式,比較容易理解。在某些情況下,不能用簡單的循環代替遞歸,因此讀者還是要好好理解遞歸。

9.2.4 遞歸的優缺點

遞歸既有優點也有缺點。優點是遞歸爲某些編程問題提供了最簡單的解決方案。缺點是一些遞歸算法會快速消耗計算機的內存資源。另外,遞歸不方便閱讀和維護。我們用一個例子來說明遞歸的優缺點。我們要創建一個函數,接受正整數 n,返回相應的斐波那鍥數值。

首先,來看遞歸。遞歸提供一個簡單的定義。如果把函數命名爲 Fibonacci(),那麼如果 n 是 1 或 2,Fibonacci(n) 應返回 1:對於其他數值,則應返回 Fibonacci(n - 1) + Fibonacci(n - 2):

unsigned long Fibonacci(unsigned n)
{
    if(n > 2)
        return Fibonacci(n - 1) + Fibonacci(n - 2);
    else
        return 1;
}

這個遞歸函數只是重述了數學定義的遞歸。該函數使用了雙遞歸(double recursion),即函數每一級遞歸都要調用本身兩次。這暴露了一個問題。

爲了說明這個問題,假設調用 Fibonacci(40)。這是第 1 級遞歸調用,將創建一個變量 n。然後在該函數中要調用 Fibonacci() 兩次,在第 2 級遞歸中要分別創建兩個變量 n。這兩次調用中的每次調用又會進行兩次調用,因而在第 3 級遞歸中要創建 4 個名爲 n 的變量。此時總共創建了 7 個變量。由於每級遞歸創建的變量都是上一級遞歸的兩倍,所以變量的數量呈指數增長!在第 5 章中介紹過一個計算小麥粒數的例子,按指數增長很快就會產生非常大的值。在本例中,指數增長的變量數量很快就消耗計算機的大量內存,很可能導致程序崩潰。

雖然這是個極端的例子,但是該例說明:在程序中使用遞歸要特別注意,尤其是效率優先的程序。

所以的 C 函數皆平等

程序中的每個 C 函數與其他函數都是平等的。每個函數都可以調用其他函數,或被其他函數調用。這點與 Pascal 和 Modula-2 中的過程不同,雖然過程可以嵌套在另一個過程中,但是嵌套在不同過程中的過程之間不能相互調用。
main() 函數是否與其他函數不同?是的,main() 的確有點特殊。當 main() 與程序中的其他函數放在一起時,最開始執行的是 main() 函數中的第 1 條語句,但是這也是侷限之處。main() 也可以被自己或其他函數遞歸調用——儘管很少這樣做。

9.3 編譯源代碼文件的程序

使用多個函數最簡單的方法是把它們都放在同一個文件中,然後像編譯只有一個函數的文件那樣編譯該文件即可。其他方法因操作系統而異。

9.3.1 UNIX

假定在 UNIX 系統中安裝了 UNIX C 編譯器 cc。假設 file1.c 和 file2.c 是兩個內含 C 函數的文件,下面的命令將編譯兩個文並生成一個名爲 a.out 的可執行文件:cc file1.c file2.c

另外,還生成兩個名爲 file1.o 和 file2.o 的目標文件。如果後來改動了 file1.c,而 file2.c 不變,可以使用一下命令編譯第 1 個文件,並與第 2 個文件的目標代碼合併:cc file1.c file2.o

UNIX 系統的 make 命令可自動管理多文件程序,但是這超出了本書的討論範圍。

注意,OS X 的 Terminal 工具可以打開 UNIX 命令行環境,但是必須先下載命令行編譯器。

9.3.2 Linux

假定 Linux 系統安裝了 GNU C 編譯器 GCC。假設 file1.c 和 file2.c 是兩個內含 C 函數的文件,下面的命令將編譯兩個文件並生成名爲 a.out 的可執行文件:gcc file1.c file2.c

另外,還生成兩個名爲 file1.o 和 file2.o 的目標文件。如果後來改動了 file1.c,而 file2.c 不變,可以使用一下命令編譯第 1 個文件,並與第 2 個文件的目標代碼合併:gcc file1.c file2.o

9.3.3 DOS 命令行編譯器

絕大對數 DOS 命令行編譯器的工作原理和 UNIX 的 cc 命令類似,只不過使用不同的名稱而已。其中一個區別是,對象文件的擴展名是 .obj,而不是 .o。一些編譯器生成的不是目標代碼文件,而是彙編語言或其他特殊代碼的中間文件。

9.3.4 Windows 和蘋果的 IDE 編譯器

Windows 和 Macintosh 系統使用的集成開發環境中的編譯器是面向項目的。項目(project)描述的是特定程序使用的資源。資源包括源代碼文件。這種 IDE 中的編譯器要創建項目來運行單文件程序。對於多文件程序,要使用相應的菜單命令,把源代碼文件加入一個項目中。要確保所有的源代碼文件都在項目列表中列出。許多 IDE 都不用在項目列表中列出頭文件,因爲項目只管理使用的源代碼文件,源代碼文件中的 #include 指令管理該文件中使用的頭文件。但是,Xcode 要在項目中添加頭文件。

9.3.5 使用頭文件

如果把 main() 放在第 1 個文件中,把函數定義放在第 2 個文件中,那麼第 1 個文件仍然要使用函數原型。把函數原型放在頭文件中,就不用在每次使用函數文件時都寫出函數的原型。C 標準庫就是這樣做的,例如,把 I/O 函數原型放在 stdio.h 中,把數學函數原型放在 math.h 中。你也可以這樣用自定義的函數文件。

另外,程序中經常用 C 預處理器定義符號常量。這種定義只儲存了哪些包含 #define 指令的文件。如果把程序的一個函數放進一個獨立的文件中,你也可以使用 #define 指令訪問每個文件。最直接的方法是在每個文件中再次輸入指令,但是這個方法既耗時又容易出錯。另外,還會有維護的問題:如果修改了 #define 定義的值,就必須在每個文件中修改。更好的做法是,把 #define 指令放進頭文件,然後在每個源文件中使用 #include 指令包含該文件即可。

總之,把函數原型和已定義的字符常量放在頭文件中是一個良好的編程習慣。我們考慮一個例子:假設要管理 4 家酒店的客房服務,每家酒店的房價不同,但是每家酒店所有房間的房價相同。對應預訂住宿多天的客戶,第 2 天的房費是第 1 天的 95%,第 3 天是第 2 天的 95%,以此類推。設計一個程序讓用戶指點酒店和入住天數,然後計算並顯示總費用。同時,程序要實現一份菜單,允許用戶反覆輸入數據,除非用戶選擇退出。

程序清單 9.3.5.1、9.3.5.2、9.3.5.3 演示瞭如何編寫這樣的程序。第 1 個程序 清單包含 main() 函數,提供整個程序的組織結構。第 2 個程序清單包含支持的函數,我們假設這些函數在獨立的文件中。最後,第 3 個程序清單列出了一個頭文件,包含了該程序所用源文件中使用的自定義符號常量和函數原型。前面介紹過,在 UNIX 和 DOS 環境中,#include “hotels.h” 指令中的雙引號表明被包含的文件位於當前目錄中。如果使用 IDE,需要知道如何把頭文件合併成一個項目。
** 程序清單 9.3.5.1**

/** 9.3.5.1 -- 房間費率程序 */
#include <stdio.h>
#include "hotel.h" /* 定義符號常量,聲明函數 */

int main()
{
   int nights;
   double hotel_rate;
   int code;

   while((code = menu()) != QUIT)
   {
       switch(code)
       {
           case 1: hotel_rate = HOTEL1;
               break;
           case 2: hotel_rate = HOTEL2;
               break;
           case 3: hotel_rate = HOTEL3;
               break;
           case 4: hotel_rate = HOTEL4;
               break;
           default: hotel_rate = 0.0;
               printf("Oops!\n");
               break;
       }
       nights = getnights();
       showprice(hotel_rate,nights);
   }
   printf("Thank you and goodbye.\n");

   return 0;
}

** 程序清單 9.3.5.2**

/** 9.3.5.2 -- 酒店管理函數 */
#include <stdio.h>
#include "hotel.h"
int menu(void)
{
   int code, status;

   printf("\n%s%s\n", STARS, STARS);
   printf("Enter the number of the desired hotel:\n");
   printf("1)Fairfield Arms         2)Hotel Olympic\n");
   printf("3)Chertworthy Plaza      4)The Stockton\n");
   printf("5)quit\n");
   printf("%s%s\n", STARS, STARS);
   while((status = scanf("%d", &code)) != 1 || (code < 1 || code > 5))
   {
       if(status != 1)
           scanf("%*s"); // 處理非整數輸入
       printf("Enter an integer from 1 to 5, please.\n");
   }
   return code;
}

int getnights(void)
{
   int nights;

   printf("How many nights are needed? ");
   while(scanf("%d", &nights) != 1)
   {
       scanf("%*s"); // 處理非整數輸入
       printf("Please enter an integer, such as 2.\n");
   }
   return nights;
}

void showprice(double rate, int nights)
{
   int n;
   double total = 0.0;
   double factor = 1.0;

   for(n = 1; n <= nights; n++, factor *= DISCOUNT)
       total += rate * factor;
   printf("The total cost will be $%0.2f.\n", total);
}

** 程序清單 9.3.5.3**

#ifndef HOTEL_H_INCLUDED
#define HOTEL_H_INCLUDED

#define QUIT 5
#define HOTEL1 180.00
#define HOTEL2 225.00
#define HOTEL3 225.00
#define HOTEL4 355.00
#define DISCOUNT 0.95
#define STARS "**********************************"

// 顯示選擇列表
int menu(void);
// 返回預訂天數
int getnights(void);
// 根據費率、入住天數計算費用,並顯示結果
void showprice(double rate, int nights);

#endif // HOTEL_H_INCLUDED

下面是這個多文件程序的運行示例:

》********************************************************************
Enter the number of the desired hotel:
1)Fairfield Arms 2)Hotel Olympic
3)Chertworthy Plaza 4)The Stockton
5)quit
》********************************************************************
3
How many nights are needed? 1
The total cost will be $225.00.

》********************************************************************
Enter the number of the desired hotel:
1)Fairfield Arms 2)Hotel Olympic
3)Chertworthy Plaza 4)The Stockton
5)quit
》********************************************************************
4
How many nights are needed? 3
The total cost will be $1012.64.

》********************************************************************
Enter the number of the desired hotel:
1)Fairfield Arms 2)Hotel Olympic
3)Chertworthy Plaza 4)The Stockton
5)quit
》********************************************************************
5
Thank you and goodbye.

順帶一提,該程序中有幾處編寫得很巧妙。尤其是,menu() 和 getnights() 函數通過測試 scanf() 的返回值來跳過非數值數據,而且調用 scanf("%*s") 跳至下一個空白字符。注意,menu() 函數中是如何檢查非數值輸入和超出範圍的數據:(status = scanf("%d", &code)) != 1 || (code < 1 || code > 5)

以上代碼段利用了 C 語言的兩個規則:從左往右對邏輯表達式求值;一旦求值結果爲假,立即停止求值。在該例中,只有在 scanf() 成功讀入一個整數值後,纔會檢查 code 的值。

用不同的函數處理不同的任務是應檢查數據的有效性。當然,首次編寫 menu() 或 getnights() 函數是可以暫不添加這一功能,只寫一個簡單的 scanf() 即可。待基本版本運行正常後,再逐步改善各模塊。

提醒:這裏我用的是 Code Blocks 編譯器。
1、創建項目 File > New > Project
在這裏插入圖片描述
2、創建文件注意:File > New > File
在這裏插入圖片描述
標註的地方一定要記得勾選

9.4 查找地址:& 運算符

指針(pointer)是 C 語言最重要的概念之一,用於儲存變量的地址。前面使用的 scanf() 函數中就使用地址作爲參數。概括地說,如果主調函數不使用 return 返回的值,則必須通過地址才能修改主調函數中的值。接下來,我們將介紹帶地址參數的函數。首先介紹一元 & 運算符的用法。

一元 & 運算符給出變量的存儲地址。如果 pooh 是變量名,那麼 &pooh 是變量的地址。可以把地址看作是變量在內存中的位置。假設有下面的語句:pooh = 24;

假設 pooh 的存儲地址是 0B76,那麼,下面的語句:printf("%d %p\n", pooh, &pooh);。將輸出如下內容(%p 是輸出地址的轉換說明):24 0B76

程序清單中使用了這個運算符查看不同函數中的同名變量分別儲存在什麼位置。

/** 查看變量被儲存在何處 */
#include <stdio.h>
void mikado(int bah); /* 函數原型 */
int main()
{
    int pooh = 2, bah = 5; /* main() 的局部變量 */

    printf("In main(), pooh = %d and &pooh = %p\n", pooh, &pooh);
    printf("In main(), bah = %d and &bah = %p\n", bah, &bah);
    mikado(pooh);

    return 0;
}

void mikado(int bah) /* 定義函數 */
{
    int pooh = 10; /* mikado() 的局部變量 */
    printf("In mikado(), pooh = %d and &pooh = %p\n", pooh, &pooh);
    printf("In mikado(), bah = %d and &bah = %p\n", bah, &bah);
}

程序清單中使用 ANSI C 的 %p 格式打印地址。我的系統輸出如下:
In main(), pooh = 2 and &pooh = 0060FEFC
In main(), bah = 5 and &bah = 0060FEF8
In mikado(), pooh = 10 and &pooh = 0060FECC
In mikado(), bah = 2 and &bah = 0060FEE0

實現不同,%p 表示地址的方式也不同。然而,許多實現都如本例所示,以十六進制顯示地址。順帶一提,每個十六進制數對應 4 位,該例顯示 12 個十六進制數,對應 48 爲地址。

該例的輸出說明了什麼?首先,兩個 pooh 的地址不同,兩個 bah 的地址也不同。因此,和前面介紹的一樣,計算機把它們看成 4 個獨立的變量。其次,函數調用 mikado(pooh) 把時間參數的值(2)傳遞給形式參數。注意,這種傳遞只傳遞了值。涉及的兩個變量並未改變。

我們強調第 2 點,是因爲這並不是在所以語言中都成立。

9.5 指針簡介

指針(pointer)是一個值爲內存地址的變量(或數據對象)。正如 char 類型變量的值是字符,int 類型變量的值是整數,指針變量的值是地址。在 C 語言中,指針有許多用法。本章將介紹如何把指針作爲函數參數使用,以及爲何要這樣用。

假設一個指針變量名是 ptr,可以編寫如下語句:ptr = &pooh; // 把 pooh 的地址賦給 ptr

對於這條語句,我們說 ptr “指向” pooh。ptr 和 &pooh 的區別是 ptr 是變量,而 &pooh 是常量。或者,ptr 是可修改的左值,而 &pooh 是右值。還可以把 ptr 指向別處:ptr = &bah; // 把 ptr 指向 bah,而不是 pooh

現在 ptr 的值是 bah 的地址。

要創建指針變量,先要聲明指針變量的類型。假設想把 ptr 聲明爲儲存 int 類型變量地址的指針,就要使用下面介紹的新運算符。

9.5.1 簡介運算符:*

假設已知 ptr 指向 bah,如下所示:ptr = &bah;

然後使用間接運算符 * (indirection operator)找出儲存在 bah 中的值,該運算符有時也稱爲解引用運算符(dereferencing operator)。不用把間接運算符和二元乘法運算符(*)混淆,雖然它們使用的符號相同,但語法功能不同。val = *ptr; // 找出 ptr 指向的值

語句 ptr = &bah; 和 val = *ptr; 放在一起相當於下面的語句:val = bah;

由此可見,使用地址和間接運算符可以間接完成上面這條語句的功能,這也是“間接運算符”名稱的由來。

小結:與指針相關的運算符

地址運算符:&
一般註解;後跟一個變量名時,& 給出該變量的地址。
示例:&nurse 表示變量 nurse 的地址。
地址運算符:*
一般註解:後跟一個指針名或地址時,* 給出儲存在指針指向地址上的值。
示例:nurse = 22; ptr = &nurse; val = *ptr;
執行以上 3 條語句的最終結果是把 22 賦給 val。

9.5.2 聲明指針

相信讀者已經很熟悉如何聲明 int 類型和其他基本類型的變量,那麼如何聲明指針變量?下面是一些指針的聲明示例:

int * pi; // pi 是指向 int 類型變量的指針
char * pc; // pc 是指向 char 類型變量的指針
float * pf, *pg; // pf、pg 都是指向 float 類型變量的指針

類型說明符表明了指針所指向對象的類型,星號(*)表明聲明的變量是一個指針。int * pi; 聲明的意思是 pi 是一個指針,*pi 是 int 類型。

  • 和指針名之間的空格可有可無。通常,在聲明時使用空格,在解引用變量是省略空格。

pc 指向的值(*pc)是 char 類型。pc 本身是什麼類型?我們描述它的類型是“指向 char 類型的指針”。pc 的值是一個地址,在大部分系統內部,該地址由一個無符號整數表示。但是,不要把指針認爲是整數類型。一些處理整數的操作不能用來處理指針,反之亦然。

變量:名稱、地址和值

通過前面的討論發現,變量的名稱、地址和變量的值之間關係密切。
編寫程序時,可以認爲變量有兩個屬性:名稱和值。計算機編譯和加載程序後,認爲變量也有兩個屬性:地址和值。地址就是變量在計算機內部的名稱。
在許多語言中,地址都歸計算機管,對程序員隱藏。然而在 C 中,可以通過 & 運算符訪問地址,通過 * 運算符獲得地址上的值。
簡而言之,普通變量把值作爲基本量,把地址作爲通過 & 運算符獲得的派生量,而指針變量把地址作爲基本量,把值作爲通過 * 運算符獲得的派生量。
雖然打印地址可以滿足讀者好奇心,但是這並不是 & 運算符的主要用途。更重要的是使用 &、* 和指針可以操縱地址和地址上的內容。

小結:函數

形式:
典型的 ANSI C 函數的定義形式爲:
返回類型 名稱(形參聲明列表)
函數體
形參聲明列表是用逗號分隔的一系列變量聲明。除形參變量外,函數的其他變量均在函數體的花括號之內聲明。
傳遞值:
實參用於把值從主調函數傳遞給被調函數。如果變量 a 和 b 的值分別是 5 和 2,那麼調用:c = diff(a, b);
把 5 和 2 分別傳遞給變量 x 和 y。5 和 2 稱爲實際參數,diff() 函數定義中的變量 x 和 y 稱爲形式參數。使用關鍵字 return 把被調函數中的一個值傳回主調函數。被調函數一般不會改變主調函數中的變量,如果要改變,應使用指針作爲參數。
函數的返回類型:
函數的返回類型指的是函數返回值的類型。如果返回值的類型與聲明的返回值類型不匹配,返回值將被轉換成函數聲明的返回類型。
函數簽名:
函數的返回類型和形參列表構成了函數簽名。因此,函數簽名指定了傳入函數的值的類型和函數返回值的類型。

9.6 關鍵概念

如果想用 C 編出高效靈活的程序,必須理解函數。把大型程序組織成若干函數非常有用,甚至很關鍵。如果讓一個函數處理一個任務,程序會更好理解,更方便調式。要理解函數是如何把信息從一個函數傳遞到另一函數,也就是說,要理解函數參數和返回值的工作原理。另外,要明白函數形參和其他局部變量都屬於函數私有,因此,聲明在不同函數中的同名變量是完全不同的變量。而且,函數無法直接訪問其他函數中的變量。這種限制訪問保護了數據的完整性。但是,當確實需要在函數中訪問另一個函數的數據時,可以把指針作爲函數的參數。

9.7 本章小結

函數可以作爲組成大型程序的構件塊。每個函數都應該有一個單獨且定義好的功能。使用參數把值傳給函數,使用關鍵字 return 把值返回函數。如果函數返回的值不是 int 類型,則必須在函數定義和函數原型中指定函數的類型。如果需要在被函數中修改主調函數的變量,使用地址或指針作爲參數。

ANSI C 提供了一個強大的工具——函數原型,允許編譯器驗證函數調用中使用的參數個數和類型是否正確。

C 函數可以調用本身,這種調用方式被稱爲遞歸。一些編程問題要用遞歸來解決,但是遞歸不僅消耗內存多,效率不高,而且費時。

9.8 複習題

1、實際參數和形式參數的區別是什麼?

2、根據下面各函數的描述,分別編寫它們的 ANSI C 函數頭。注意,只需寫出函數頭,不用寫函數體。
a、donut() 接受一個 int 類型的參數,打印若干(參數指定數目)個 0;
b、gear() 接受兩個 int 類型的參數,返回 int 類型的值;
c、guess() 不接受參數,返回一個 int 類型的值;
d、stuff_it() 接受一個 double 類型的值和 double 類型變量的地址,把第 1 個值儲存在指定位置。

3、根據下面各函數的描述,分別編寫它們的 ANSI C 函數頭。注意,只需寫出函數頭,不用寫函數體。
a、n_to_char() 接受一個 int 類型的參數,返回一個 char 類型的值;
b、digit() 接受一個 double 類型的參數和一個 int 類型的參數,返回一個 int 類型的值;
c、which() 接受兩個可儲存 double 類型變量的地址,返回一個 double 類型的地址;
d、random() 不接受參數,返回一個 int 類型的值。

4、設計一個函數,返回兩整數之和

5、如果把複習題 4 改成返回兩個 double 類型的值之和,應如何修改函數?

6、設計一個名爲 alter() 的函數,接受兩個 int 類型的變量 x 和 y,把它們的值分別改成兩個變量之和以及兩變量之差。

7、下面的函數定義是否正確?

void salami(num)
{
    int num, count;
    for(count = 1; count <= num; num++)
        printf(" O salami mio!\n");
}

8、編寫一個函數,返回 3 個整數參數中的最大值。

9、給定下面的輸出:
Please choose one of the following:
1)copy files 2)move files
3)remove files 4)quit
Enter the number of your choice:
a、編寫一個函數,顯示一份有 4 個選項的菜單,提示用戶進行選擇(輸出如上所示)。
b、編寫一個函數,接受兩個 int 類型的參數分別表示上限和下限。該函數從用戶的輸入中讀取整數。如果整數超出規定上下限,函數再次打印菜單提示用戶輸入,然後獲取一個新值。如果用戶輸入的整數在規定範圍內,該函數則把該整數返回主調函數。如果用戶輸入一個非整數字符,該函數應返回 4。
c、使用本題 a 和 b 部分的函數編寫一個最小型的程序。最小型的意思是,該程序不需要實現菜單中各選項的功能,只需顯示這些選項並獲取有效的響應即可。

9.9 編程練習

1、設計一個函數 min(x, y),返回兩個 double 類型值的較小值。在一個簡單的驅動程序中測試該函數。

2、設計一個函數 chline(ch, i, j),打印指定的字符 j 行 i 列。在一個簡單的驅動程序中測試該函數。

3、編寫一個函數,接受 3 個參數:一個字符和兩個整數。字符參數是待打印的字符,第 1 個整數指定一行中打印字符的次數,第 2 個整數指定打印指定字符的行數。編寫一個調用該函數的程序。

4、兩數的調和平均數這樣計算:先得到兩數的倒數,然後計算兩個倒數的平均值,最後取計算結果的倒數。編寫一個函數,接受兩個 double 類型的參數,返回這兩個參數的調和平均數。

5、編寫並測試一個函數 larger_of(),該函數把兩個 double 類型變量的值替換爲較大的值。例如,larger_of(x, y) 會把 x 和 y 中較大的值重新賦給兩個變量。

6、編寫並測試一個函數,該函數以 3 個 double 變量的地址作爲參數,把最小值放入第 1 個函數,中間值放入第 2 個變量,最大值放入第 3 個變量。

7、編寫一個函數,從標準輸入中讀取字符,知道遇到文件結尾。程序要報告每個字符是否是字母。如果是,還要報告該字母在字母表中的數值位置。例如,c 和 C 在字母表中的位置都是 3。合併一個函數,以一個字符作爲參數,如果該字符是一個字母則返回一個數值位置,否則返回 -1。

8、如程序清單中,power() 函數返回一個 double 類型數的正整數次冪。改進該函數,使其能正確計算負冪。另外,函數要處理 0 的任何次冪都爲 0,任何數的 0 次冪都爲 1。要使用一個循環,並在程序中測試該函數。

/** 計算數的整數冪 */
#include <stdio.h>
double power(double n, int p);
int main()
{
    double x, xpow;
    int exp;

    printf("Enter a number and the positive integer power");
    printf(" to which\nthe number will be raised. Enter q");
    printf(" to quit.\n");
    while(scanf("%lf%d", &x, &exp) == 2)
    {
        xpow = power(x, exp);
        printf("%.3g to the power %d is %.5g\n", x, exp, xpow);
        printf("Enter next pair of number or q to quit.\n");
    }
    printf("Hope you enjoyed this power trip -- bye!\n");
    return 0;
}

double power(double n, int p)
{
    double pow = 1;
    int i;

    for(i = 1; i <= p; i++)
        pow *= n;
    return pow;
}

9、使用遞歸函數重寫編程練習 8。

10、爲了讓程序清單中的 to_binary() 函數更通用,編寫一個 to_base_n() 函數接收兩個在 2 ~ 10 範圍內的參數,然後以第 2 個參數中指點的進制打印第 1 個參數的數值。例如,to_base_n(129, 8) 顯示的結果爲 201,也就是 129 的八進制數。在一個完整的程序中測試該函數。

/** 以二進制形式打印整數 */
#include <stdio.h>
void to_binary(unsigned long n);
int main()
{
    unsigned long number;
    printf("Enter an integer (q to quit):\n");
    while(scanf("%lu", &number) == 1)
    {
        printf("Binary equivalent: ");
        to_binary(number);
        putchar('\n');
        printf("Enter an integer (q to quit):\n");
    }
    printf("Done.\n");
    return 0;
}

void to_binary(unsigned long n)
{
    int r;

    r = n % 2;
    if(n >= 2)
        to_binary(n / 2);
    putchar(r == 0 ? '0' : '1');
    return;
}

11、編寫並測試 Fibonacci() 函數,該函數用循環代替遞歸計算斐波那鍥數。

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