函數及其使用注意事項,C語言函數及使用注意事項詳解

2020-07-16 10:04:21
在 C 語言中,函數是構成 C 程式的基本功能單元,它是一個能夠獨立完成某種功能的程式塊,其中封裝了程式程式碼和資料,實現了更高階的抽象和資料隱藏。這樣程式設計者只需要關心函數的功能和使用方法,而不必關心函數功能的具體實現細節。

一個 C 程式由一個主函數(main 函數)與多個函數構成。其中,主函數 main()  可以呼叫任何函數,各函數之間也可以相互呼叫,但是一般函數不能呼叫主函數。所有函數都是平行、獨立的,不能巢狀定義,但可以巢狀呼叫。本章將重點論述函數設計的一些常用建議,其中包括函數的規劃、內部實現、引數與返回值等。

理解函數宣告

談到函數宣告,就不得不說這樣一個例子:有一段程式儲存在起始地址為 0 的一段記憶體上,要呼叫這段程式,該如何去做?答案如下:

(*(void(*) ()) 0)();

恐怕像這樣的表示式,無論是新程式設計師,還是經驗豐富的老程式設計師,都會感到不寒而慄。然而,構造這類表示式其實只有一條簡單的規則:按照使用的方式來宣告。

接下來看如下兩個簡單的宣告範例:
float f();
float *pf;
在上面的程式碼中,很顯然,f 是一個返回值為浮點型別的函數;而 pf 則是一個指向浮點數的指標。如果將它們簡單地組合起來,就可以得到如下兩種宣告方式:
float *f1();
float (*f2)();
上面兩者的區別在於:因為“()”結合優先順序高於“*”,也就是說“*f1()”等價於“*(f1())”,即f1是一個函數,它返回值型別為指向浮點數的指標;同理,f2 是一個函數指標,它所指向的函數的返回值為浮點型別。

在這裡需要特別注意的是,一旦知道了如何宣告一個給定型別的變數,該型別的型別轉換符就很容易得到:只需要去掉宣告中變數名和宣告末尾的分號,再將剩餘的部分用一個括號整個“封裝”起來,即:
float (*f2)();
因為 f2 是一個指向返回值為浮點型別的函數的指標,因此,該型別的型別轉換符如下:

(float (*)())

即它表示一個“指向返回值為浮點型別的函數的指標”的型別轉換符。

好了,現在繼續分析上面的例子:

(*(void(*) ())0)();

在這裡假定變數 fp 是一個函數指標,顯然“*fp”就是該指標所指向的函數。當然,“(*fp)()”就是呼叫該函數的方式(在 ANSI C 標準中,允許程式設計師簡寫為“fp()”這種形式)。在“(*fp)()”中,“*fp”兩側的括號非常重要,因為“()”結合優先順序高於“*”。如果“*fp”兩側沒有括號,那麼“*fp()”實際上與“*(fp())”的含義完全一致,ANSI C 把它作為“*((*fp)())”的簡寫形式。

根據問題描述,可以知道 0 是這個函數的入口地址,也就是說,0 是一個函數的指標。結合上面的“(*fp)()”,問題中的函數呼叫可以寫成如下形式:

(*0)();

大家都知道,函數指標變數不能是一個常數,很顯然上式並不能生效。因此,上式中的0必須被轉化為函數指標,一個指向返回值為 void 型別的函數的指標。也就是說,需要將 fp 的宣告修改成如下形式:

void (*fp)();

這樣,就可以得到該型別的型別轉換符:

(void (*)())

現在將常數 0 轉型為“指向返回值為 void 的函數的指標”型別就可以寫成如下形式:

(void (*)())0

最後,使用“(void(*)())0”來替換“(*fp)(0”中的fp或“(*0)()”中的0,就可以很簡單地得到下面的表示式:

(*(void (*)())0)();

為了便於大家理解,在這裡繼續對“(*(void(*)())0)()”做如下 4 點說明:
  1. 對於“void(*)()”,可以很簡單地看出這是一個函數指標型別,這個函數沒有引數(引數為空),並且也沒有返回值(返回值為 void);
  2. 對於“(void(*)())0”,這裡將 0 強制轉換為函數指標型別,其中 0 是一個地址,也就是說,一個函數存在首地址為 0 的一段區域內;
  3. 對於“(*(void(*)())0)”,這裡取 0 地址開始的一段記憶體中的內容,其內容就是儲存在首地址為 0 的一段區域內的函數;
  4. 對於“(*(void(*)())0)()”,很簡單,這當然就是函數呼叫了。

理解函數原型

在 ANSI C 標準中,允許採用函數原型方式對被呼叫的函數進行說明,其主要作用就是利用它在程式的編譯階段對呼叫函數的合法性進行全面檢查。

函數原型能告訴編譯器函數的名稱,函數有多少個引數,每個引數分別是什麼型別,函數的返回型別又是什麼等。當函數被呼叫時,編譯器可以根據這些資訊判斷實參個數與型別是否正確,函數的返回型別是否正確等。函數原型能讓編譯器及時發現函數呼叫時存在的語法錯誤。範例程式碼如下:
/*函數原型*/
char *Memcopy(char *dest, const char *src, size_t size);
/*函數定義*/
char *Memcopy(char *dest, const char *src, size_t size)
{
    assert((dest != NULL) && (src != NULL));
    char *retAddr = dest;
    while (size --> 0)
    {
            *(dest++) = *(src++);
    }
    return retAddr;
}
在上面的程式碼中,當呼叫 Memcopy() 函數時,編譯器就會檢查呼叫函數的實參是不是 3 個?每個引數的型別是否匹配?函數的返回型別是否正確?如果編譯程式發現函數的呼叫或定義與函數原型不匹配,編譯程式就會報告出錯或發出警告訊息。

在一般情況下,當被呼叫函數的定義出現在主呼叫函數之後時,應必須在呼叫語句之前給出函數原型。如果在被呼叫之前沒有給出函數原型,則編譯器會將第一次遇到的該函數定義作為函數的宣告,並將函數返回值型別預設為int型。

如果這樣,當函數返回值型別為整型時,是否無須給出函數原型呢?很顯然,這種偷懶的方法將使得編譯器無法對實參和形參進行匹配檢查。若呼叫函數時引數使用不當,編譯器也不會再給出善意的提醒,你也許會得意於程式的安全通過,但你很可能將面臨型別不匹配所帶來的系統崩潰的危險。

總之,在原始檔中說明函數原型提供了一種檢查函數是否被正確參照的機制。同時,目前許多流行的編譯程式都會檢查被參照的函數的原型是否已在原始檔中說明過,如果沒有,就會發出警告訊息。

盡量使函數的功能單一

在程式的函數設計中,我們所要遵循的首要設計原則就是“函數功能單一”。也就是說,一個函數應該只能夠完成一件事情,並且只能夠完成它自己的任務。函數功能應該越簡單越好,盡量避免設計多用途、面面俱到、多功能集於一身的複雜函數。

當然,如果你有一個概念上簡單的函數(這裡所謂的“簡單”是 simple 而不是 easy),它恰恰包含著一個很長的 case 語句,這樣你就不得不為這些不同的情況準備不同的處理,那麼這樣的長函數是允許的。但是,如果你有一個複雜的函數,並且一般程式設計師在沒有詳細文件的情況下很難讀懂這個函數,那麼應該努力簡化這個函數的功能與程式碼,適當拆分這個函數的功能,使用一些輔助函數,給它們取描述性的名字。

與此同時,人類的大腦一般能夠同時記住 7 個不同的東西,超過這個數目就會犯糊塗。因此,對於函數的區域性變數的數目也應該盡量減少,一般情況下最多 5~10 個。

下面我們來看一個函數的設計範例。
#include <stdio.h>
#include <stdlib.h>
typedef int ElementType;
typedef struct node
{
    ElementType data;
    struct node *next;
}StackNode, *LinkStack;
void InvertedSequence(int num)
{
    int i=0;
    int result=0;
    LinkStack ls;
    // 初始化
    ls = (LinkStack)malloc(sizeof(StackNode));
    ls->next = NULL;
    printf("資料輸入為:n");
    for(i=0; i<num; i++)
    {
        // 入棧
        StackNode *temp;
        temp = (StackNode *)malloc(sizeof(StackNode));
        if(temp != NULL)
        {
            temp->data = i;
            temp->next = ls->next;
            ls->next = temp;
            printf("%d ",i);
        }
    }
    printf("n資料輸出為:n");
    while (ls->next != NULL)
    {
        // 出棧
        StackNode *temp = ls->next;
        result = temp->data;
        ls->next = temp->next;
        free(temp);
        printf("%d ",result);
    }
    printf("n");
}
int main(void)
{
    InvertedSequence(20);
    return 0;
}
在 InvertedSequence(int num)  方法中,我們實現了鏈棧的常用操作,即包括初始化、入棧與出棧等操作,其執行結果為:

資料輸入為:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
資料輸出為:
19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0

很顯然,程式碼中的 InvertedSequence(int num) 多功能函數不僅很可能使理解、測試、維護函數等變得困難,並且也會使函數不具有好的可複用性,如果在後續的程式碼中遇到類似的功能需求,又將需要重寫此功能程式碼。由此可見,雖然這裡的 InvertedSequence(int num) 函數滿足了程式功能的需要,但是它卻違反了函數的功能單一原則。

因此,根據函數的功能單一原則,我們應該將該函數的相關鏈棧操作獨立進行設計,這樣不僅能夠使函數的功能變得簡單,而且能夠很好地保證函數有良好的扇入和扇出比例,特別是公用模組或底層模組中的函數一定要具有較大的扇入才能有效提高程式碼的可複用性。改正後的範例如程式碼為:
typedef int BOOL;
#define TRUE 1
#define FALSE 0
#define STACK_SIZE 100
typedef int ElementType;
typedef struct node
{
    ElementType data;
    struct node *next;
}StackNode,*LinkStack;
// 初始化
void InitStack(LinkStack ls)
{
    ls->next = NULL;
}
// 是否為空
BOOL IsEmpty(LinkStack ls)
{
    if(ls->next == NULL)
    {
        return TRUE;
    }
    else
    {
        return FALSE;
    }
}
// 入棧
BOOL Push(LinkStack ls, ElementType element)
{
    StackNode *temp;
    temp = (StackNode *)malloc(sizeof(StackNode));
    if(temp == NULL)
    {
        return FALSE;
    }
    temp->data = element;
    temp->next = ls->next;
    ls->next = temp;
    return TRUE;
}
// 出棧
BOOL Pop(LinkStack ls, ElementType *element)
{
    if(IsEmpty(ls))
    {
        return FALSE;
    }
    else
    {
        StackNode *temp = ls->next;
        *element = temp->data;
        ls->next = temp->next;
        free(temp);
        return TRUE;
    }
}
void InvertedSequence(int num)
{
    int i=0;
    int result=0;
    LinkStack ls;
    ls = (LinkStack)malloc(sizeof(StackNode));
    // 初始化
    InitStack(ls);
    printf("資料輸入為:n");
     for(i=0; i<num; i++)
    {
        // 入棧
        Push(ls,i);
        printf("%d ",i);
    }
    printf("n資料輸出為:n");
    while (!IsEmpty(ls))
    {
        // 出棧
        Pop(ls,&result);
        printf("%d ",result);
    }
    printf("n");
}
現在,通過對比以上兩端程式碼,可以很容易看出,後段程式碼不僅簡單易讀、易維護,而且其函數也具有更好的可複用性。嚴格遵循函數的功能單一原則,這樣不但能夠讓你更好地命名函數,也使理解和閱讀程式碼變得更加容易。

如果遇到一個特殊的情況不得不打破這個原則,可以停下來,思考一下是不是自己對這個“特殊情況”的理解還不夠深。函數應該很精確地執行一件事且只執行這一件事,明確函數功能(一個函數僅完成一件事情),精確(而不是近似)地實現函數設計。

避免把沒有關聯的語句放在一個函數中

在程式碼編寫中,我們時常會為了提高程式碼的可複用性而刻意地將不同函數中使用的相同程式碼語句提出來,抽象成一個新的函數。當然,如果這些程式碼的關聯度較高,並且完成同一個功能,那麼這種抽象是合理的。但是,如果僅僅是為了提高程式碼的可複用性,把沒有任何關聯的語句放在一起,就會給以後的程式碼維護、測試及升級等造成很大的不便,同時也使函數的功能不明確。範例程式碼如下:
void Init (void)
{
    /* 初始化矩形的長與寬 */
    Rect.length = 0;
    Rect.width = 0;
    /* 初始化“點”的坐標 */
    Point.x = 0;
    Point.y = 0;
}
很顯然,上面的函數 Init(void) 設計是不合理的,因為矩形的長、寬與點的坐標基本沒有任何關聯。因此,我們應該將其抽象為如下兩個函數:
/* 初始化矩形的長與寬 */
void InitRect(void)
{
    Rect.length = 0;
    Rect.width = 0;
}
/* 初始化“點”的坐標 */
void InitPoint(void)
{
    Point.x = 0;
    Point.y = 0;
}

函數的抽象級別應該在同一層次

先來看下面一段範例程式碼:
void Init( void )
{
    /*本地初始化*/
     ...
     InitRemote();
}
void InitRemote(void)
{
    /*遠端初始化*/
     ...
}
從表面上看,上面的 Init(void) 函數主要完成本地初始化與遠端初始化工作,在其功能實現上沒什麼不妥之處。但從設計觀點看,卻存在著一定的缺陷。從 Init(void) 函數中,我們可以看出,本地初始化與遠端初始化的地方是相當的。因此,如果遠端初始化作為獨立的函數存在,那麼本地初始化也應該作為獨立的函數存在。

很顯然,上面的 Init(void) 函數將本地初始化直接執行在本函數內部,而將遠端初始化封裝在一個獨立的函數內,並在這裡進行呼叫。這種設計是不妥的,兩個函數的抽象級別應該在同一層次,如下面的範例程式碼所示:
void Init(void)
{
    InitLocal();
    InitRemote();
}
void InitLocal(void)
{
    /*本地初始化*/
     ...
}
void InitRemote(void)
{
    /*遠端初始化*/
     ...
}

盡可能為簡單功能編寫函數

有時候,我們需要用函數去封裝僅用一兩行程式碼就可完成的功能。對於這樣的函數,單從程式碼量上看,好像沒有什麼封裝的必要。但是,用函數可使其功能明確化、具體化,從而增加程式可讀性,並且也方便程式碼的維護與測試。範例程式碼如下:
int Max(int x,int y)
{
    return(x>y?x:y);
}
int Min(int x,int y)
{
    return(x<y?x:y);
}
當然,也可以使用宏來代替上面的函數,程式碼如下:
#define MAX(x,y) (((x) > (y)) ? (x) : (y))
#define MIN(x,y) (((x) < (y)) ? (x) : (y))
在 C 程式中,我們可以適當地用宏程式碼來提高執行效率。宏程式碼本身不是函數,但使用起來與函數相似。前處理器用複製宏程式碼的方式代替函數呼叫,省去了引數壓棧、生成組合語言的 CALL 呼叫、返回引數、執行 return 等過程,從而提高了執行速度。

但是,使用宏程式碼最大的缺點就是容易出錯,前處理器在複製宏程式碼時常常產生意想不到的邊際效應。因此,儘管看起來宏要比函數簡單得多,但還是建議使用函數的形式來封裝這些簡單功能的程式碼。

避免多段程式碼重複做同一件事情

在原始檔中,如果存在著多段程式碼重複做同一件事情,那麼很可能在函數的劃分上存在著一定的問題。若此段程式碼各語句之間有實質性關聯並且是完成同一項功能的,那麼可以考慮把此段程式碼抽象成一個新的函數。

範例程式碼如下:
/*希爾排序法*/
void ShellSort(int v[],int n)
{
    int i,j,gap,temp;
    for(gap=n/2;gap>0;gap /= 2)
    {
        for(i=gap;i<n;i++)
        {
            for(j=i-gap;(j >= 0) && (v[j] > v[j+gap]);j -= gap )
            {
                temp=v[j];
                v[j]=v[j+gap];
                v[j+gap]=temp;
            }
        }
    }
}
/* 氣泡排序法 */
void BubbleSort (int v[],int n)
{
    int i,j,temp;
    for(j=0;j<n;j++)
    {
        for(i=0;i<(n-(j+1));i++)
        {
            if(v[i]>v[i+1])
            {
                temp=v[i];
                v[i]=v[i+1];
                v[i+1]=temp;
            }
        }
    }
}
在上面的範例程式碼中,函數 ShellSort(int v[],int n) 與函數 BubbleSort(int v[],int n) 分別實現了希爾排序與氣泡排序的功能。仔細觀察這兩個簡單的排序函數,不難發現,無論是 ShellSort(int v[],int n) 函數,還是 BubbleSort(int v[],int n) 函數,都會執行交換操作。因此,我們可以將它們的交換操作程式碼抽取出來,獨立成一個新的函數,範例程式碼如下:
/*交換*/
void Swap(int *i, int *j)
{
    int temp;
    temp=*i;
    *i=*j;
    *j=temp;
}
這樣,抽取出 Swap(int*i,int*j) 函數之後,不僅能夠避免不必要的程式碼重複,便於以後維護與升級程式碼,而且能夠使我們的程式碼具有更大的複用價值。

現在,我們可以直接在上面的排序函數中或其他任何需要交換操作的函數中呼叫這個交換函數 Swap(int*i,int*j),範例程式碼如下:
/*希爾排序法*/
void ShellSort(int v[],int n)
{
    int i,j,gap;
    for(gap=n/2;gap>0;gap /= 2)
    {
        for(i=gap;i<n;i++)
        {
            for(j=i-gap;(j >= 0) && (v[j] > v[j+gap]);j -= gap )
            {
                Swap(&v[j],&v[j+gap]);
            }
        }
    }
}
/* 氣泡排序法 */
void BubbleSort (int v[],int n)
{
    int i,j;
    for(j=0;j<n;j++)
    {
        for(i=0;i<(n-(j+1));i++)
        {
            if(v[i]>v[i+1])
            {
                Swap(&v[i],&v[i+1]);
            }
        }
    }
}

在呼叫函數時,必須對返回值進行判斷

在程式設計中,呼叫一個函數之後,必須檢查函數的返回值,以決定程式是繼續應用邏輯處理還是進行出錯處理。同時,這也帶來了一系列設計問題:如果出錯了怎麼辦?錯誤如何表達?該如何定義出錯處理的準則或機制?

很顯然,如果一個專案沒有一種有效的方法表達一個錯誤,就會出現對於出錯處理的混亂狀況。與此同時,在大型專案中,如果出現錯誤,僅僅通過C庫中已經定義的那麼幾個錯誤碼並不能有效表達應用錯誤。因此,我們需要針對不同的錯誤採用完全不同的出錯處理方法,設計出適合自己的出錯處理機制。

盡量減少函數本身或者函數間的遞迴呼叫

遞回作為一種演算法在程式設計語言中被廣泛應用,簡單地講,遞回就是函數呼叫自己,或者在自己函數呼叫的下級函數中呼叫自己。範例程式碼如下:
long fab(const int index)
{
    if(index == 1 || index == 2)
    {
        return 1;
    }
    else
    {
        return fab(index-1)+fab(index-2);
    }
}
遞回之所以能實現,是因為函數的每個執行過程都在堆疊中有自己的形參和區域性變數的副本,而這些副本和函數的其他執行過程毫不相干。所以遞回函數有一個最大的缺陷,那就是增加了系統的開銷。因為每呼叫一個函數,系統就需要為函數準備堆疊空間用於儲存引數資訊,如果頻繁進行遞回呼叫,系統需要為其開闢大量的堆疊空間。

與此同時,遞回呼叫特別是函數間的遞迴呼叫,會大大影響程式的可理解性。因此,我們在程式設計中應該盡量使用其他演算法來替代遞迴演算法。下面的範例演示了如何使用疊代演算法來替代遞迴演算法:
long fab(const int index)
{
    if(index == 1 || index == 20)
    {
        return 1;
    }
    else
    {
        long l1 = 1L;
        long l2 = 1L;
        long l3 = 0;
        /*疊代求值*/
        for(int i = 0;i < index-2;i ++)
        {
            l3 = l1 + l2;
            l1 = l2;
            l2 = l3;
        }
        return l3;
    }
}
在很多時候,因為遞回需要系統堆疊,所以空間消耗要遠比非遞回程式碼大很多。而且,如果遞迴深度太大,可能會導致系統資源不夠用。因此大家都有這樣一個觀點:能不用遞迴演算法就不用遞迴演算法,遞迴演算法都可以用疊代演算法來代替。

從某種程度上講,遞迴演算法確實是方便了程式設計師,而難為了機器。遞回可以通過數學公式很方便地轉換為程式,其優點就是易理解,容易程式設計。但遞回是用堆疊機制實現的,每深入一層,都要佔去一塊堆疊資料區域。因此,對巢狀層數深的一些演算法,遞回就會顯得力不從心,空間上也會以記憶體崩潰而告終。同時,因為遞回帶來了大量的函數呼叫,這增加了許多額外的時間開銷。

在理論上,雖然遞迴演算法和疊代演算法在時間複雜度方面是等價的(在不考慮函數呼叫開銷和函數呼叫產生的堆疊開銷)。但在實際開發環境中上,遞迴演算法確實要比疊代演算法效率低許多。但是不得不注意的是,雖然迭代演算法比遞迴演算法在效率上要高一些(它的執行時間只會因迴圈次數增加而增加,沒什麼額外開銷,空間上也沒有什麼額外的增加),但我們又不得不承認,將遞迴演算法轉換為疊代演算法的代價通常都是比較高的,而且,並不是所有的遞迴演算法都可以轉換為疊代演算法。同時,疊代演算法也存在著不容易理解,編寫複雜問題時困難等問題。

因此,“能不用遞迴演算法就不用遞迴演算法,遞迴演算法都可以用疊代演算法來代替”這樣的理解,還是應該辯證看待,切不能一概而論。一般而言,採用遞迴演算法需要的前提條件是當且僅當一個存在預期的收斂時,才可採用遞迴演算法;否則,就不能使用遞迴演算法。