C語言中文網 目錄

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

在 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;
    }
}
在很多時候,因為遞歸需要系統堆棧,所以空間消耗要遠比非遞歸代碼大很多。而且,如果遞歸深度太大,可能會導致系統資源不夠用。因此大家都有這樣一個觀點:能不用遞歸算法就不用遞歸算法,遞歸算法都可以用迭代算法來代替。

從某種程度上講,遞歸算法確實是方便了程序員,而難為了機器。遞歸可以通過數學公式很方便地轉換為程序,其優點就是易理解,容易編程。但遞歸是用堆棧機制實現的,每深入一層,都要占去一塊堆棧數據區域。因此,對嵌套層數深的一些算法,遞歸就會顯得力不從心,空間上也會以內存崩潰而告終。同時,因為遞歸帶來了大量的函數調用,這增加了許多額外的時間開銷。

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

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

精美而實用的網站,提供C語言C++STLLinuxShellJavaGo語言等教程,以及socketGCCviSwing設計模式JSP等專題。

Copyright ?2011-2018 biancheng.net, 陜ICP備15000209號

底部Logo