第5章 用函數封裝程序功能
在完成功能強大的工資程序V1.0之後,我們信心倍增,開始向C++世界的更深遠處探索。
現在,我們可以用各種數據類型定義變量來表達問題中所涉及的各種數據;用操作符連接這些變量對其進行運算;用程序流程控制結構來控制對這些數據的復雜處理過程,最終實現對數據進行處理得到結果,而這就是程序了。但是,隨著要處理的問題越來越復雜,程序的代碼自然也就會越來越復雜。如果把所有程序代碼都放到main()主函數中,主函數也會越來越復雜。這就像將所有東西都堆放到一個倉庫中,隨著東西越堆越多,倉庫慢慢就被各種東西堆滿了,顯得雜亂無章,管理起來非常困難。面對一個雜亂無章的倉庫,聰明的倉庫管理員提供了一個很好的辦法來進行管理:將東西分門別類地裝進箱子,然後在箱子上貼上標簽,通過這些箱子來對其進行管理。
這個好方法也可以用到程序設計中,把復雜的程序代碼按照功能不同裝進不同的箱子,也同樣可以讓整個程序結構清晰,更易於開發和維護。倉庫中用的箱子是木箱,而程序中用到的箱子,就是我們下面要介紹的“函數”。
在程序越來越復雜的時候,我們可以根據“分而治之”的原則,按照功能的不同將復雜的程序進行模塊劃分,完成相同功能的代碼被劃分到同一個模塊,最終形成一個函數。就像管理一個倉庫,我們總是將同類的東西放到同一個箱子中,然後通過管理這些箱子來管理整個倉庫中的物件。而在程序設計中,我們同樣也將那些相對獨立的完成某一特定功能的代碼放到一起而成為一個函數,然後通過這些函數的組合來完成一個比較大的功能。
舉一個簡單的例子:看書看得肚子餓了,我們想要泡方便面吃。這其實是一個很復雜的過程,我們先要洗鍋,然後燒水,水燒開後再泡面,吃完面後還要洗碗。如果把整個過程都表達在主函數中,那麼主函數會非常復雜,結構也會非常混亂。這時就可以將整個過程劃分為多個小步驟,然後把每一個小步驟用一個獨立的函數來表達,最後在主函數中通過組織調用這些函數來完成這個復雜過程,這樣可以使得程序的主函數變得簡單明了,結構也非常清晰。如圖5-1所示。
使用函數封裝功能的另外一個重要優勢是,函數可以被不同的模塊重復多次調用,從而實現代碼的復用。這就像一個箱子既可以放在這個倉庫,也可以放在另外一個倉庫。例如,泡面可以調用燒水函數,同樣煮飯也可以調用燒水函數,這樣只需要一個燒水函數,就可以滿足泡面和煮飯兩個過程對燒水功能的需要,既省時又省力。既然函數有這麼多的好處,那我們下面就來看看到底如何定義和使用函數。
圖5-1 將程序封裝到箱子,分而治之
要想使用變量來表達程序中的數據,我們必須先聲明和定義變量,然後才能使用。同樣的,要想使用函數來表達程序中的計算過程,我們同樣也需要先聲明和定義函數。在C++中,聲明一個函數的語法格式如下:
返回值類型標識符 函數名(形式參數表);
例如,下面的代碼就聲明了一個Add()函數,用來計算兩個整數的和:
int Add(int a, int b);
對照聲明函數的語法格式,下面具體來看看這個函數聲明中的各個部分。
函數在執行完畢後,往往需要給它的調用者返回一個數據,表示函數執行的結果或者其他意義。函數的返回值類型標識符就是這個返回數據的數據類型。比如上面的Add()函數,在執行完畢後需要向它的調用者返回加和結果數據,而這個結果數據的類型是int,所以我們在聲明中就指定其返回值類型為int,表示該函數在完成加法計算後,將向它的調用者返回一個int類型的數值,而這個值就是Add()函數加法計算的結果。所以我們通常利用這種方式來獲得函數的執行結果數據。而如果函數只是執行一些動作,無須返回結果數據,則可以使用“void”作為返回值的類型,表示這個函數沒有返回數據。
函數名就是為了標識一個函數而取的名字,就像給箱子貼上的標簽一樣,我們可以通過標簽找到箱子,也可以通過函數名調用這個函數執行其中的代碼。函數的命名規則跟變量的命名規則相同。如果說變量命名重在說明這個變量“是什麼”,那麼函數的命名則重在說明這個函數要“做什麼”,所以從這個意義上說函數名往往是一個動詞或動名詞。例如,在上面的例子中,函數要完成的是兩個數的加法運算,執行的是“加”這個動作,所以我們就將這個函數命名為Add(加)。
在調用函數的時候,往往要進行函數間的數據交換,向函數傳入或者從函數傳出一些數據。函數的參數就是用來進行數據交換的,而形式參數表是對函數參數的描述,它主要描述了參數的個數、具體的數據類型和參數名。其語法格式如下:
數據類型1 參數名1, 數據類型2 參數名2…
在上面的Add()加法函數聲明中,函數名之後括號內的“int a, int b”就是其形式參數表,它表示這個函數一共有兩個int類型的參數,參數名分別是a和b。Add()函數的形式參數表之所以要設計成這樣,是因為這個函數需要兩個int類型的數據作為被加數,所以為了向函數內傳遞所需的數據,形式參數表中就有了兩個int類型的參數,又為了加以區別,所以分別命名為a和b。在使用函數的形式參數表時,有以下幾個需要注意的地方。
(1) 形式參數要有明確的數據類型。
函數參數的定義與定義變量類似,總是先寫參數的數據類型,然後寫參數的名字。如上面例子中的“int a”,因為要向函數內傳遞一個int類型的數據,所以就用它做相應參數的數據類型,a是參數的名字。要向函數傳遞多個數據時,可以在形式參數表中定義多個參數,各個參數之間用逗號間隔。例如,上面例子中的“int a,int b”就定義了兩個參數a和b。在形式參數表中,每個參數必須有明確的數據類型說明。即使兩個參數的數據類型相同,也不能使用同一個數據類型說明符定義多個多個參數。例如,在上面的例子中,雖然參數a和b的數據類型相同,但是形式參數表不能寫成“int a, b”,這一點跟定義變量是有差別的。
最佳實踐:用const對參數進行修飾,防止參數被意外修改
跟我們在定義一些其值固定不變的變量時,使用const關鍵字對其進行修飾,可以防止其值被錯誤修改一樣,如果某個參數的值在整個函數執行過程中是固定不變的,比如那些只是負責向函數內部傳入數據的參數,我們也同樣可以使用const關鍵字對其進行修飾,這樣可以防止這個參數在函數執行過程中被意外地修改,從而避免錯誤的發生。例如:
// 用const關鍵字保護參數值不被修改 int Add(const int a, const int b) { // 錯誤:嘗試修改使用const修飾的參數 a = 1982; b = 1003; return a + b; }
在這裡,Add()函數的兩個參數只是起一個向函數內傳入數據的作用,在函數執行過程中,其值不應該被修改。所以我們在函數聲明中加上const關鍵字對其進行修飾,表示這是一個只讀的傳入參數。如果我們錯誤地在函數中對這個參數進行修改,編譯器就會給出相應的錯誤提示,從而防止參數的值被意外修改,避免錯誤的發生。
(2) 形式參數可以有默認值。
在定義變量時可以給定變量的初始值,同樣,在定義參數時也可以給參數一個初始值。擁有初始值的參數可以在調用的時候不給出具體的數值而使用這個初始值。例如,可以寫一個函數來判斷某個分數是否及格。及格與否,是通過當前分數與及格分數進行比較的結果,這就意味著這個函數需要兩個參數,一個向函數內傳遞當前分數,而另一個則負責傳遞及格分數。在絕大多數情況下,及格分數為60,這時就可以用60作為這個參數的默認值:
// 判斷某個分數是否超過及格分數,默認及格分數為60 bool IsPassed( int nScore,int nPass = 60 );
使用參數默認值,可以給函數的調用帶來很大的靈活性。大多數情況下,如果參數應該使用默認值,則可以在調用函數時省略擁有默認值的參數,直接使用其余參數對函數進行調用。這時,被省略的參數的值就是在聲明時指定的初始值。而在某些特殊情況下,又可以用其他的具體數值作為參數對函數進行調用,這時的參數值將不再是函數聲明中的初始值,而是函數調用時給定的具體數值。例如:
int nScore = 82; // 當前成績 // 使用參數的默認值 // 這時,被省略掉的nPass參數的值是函數聲明中的初始值60 // 也就相當於調用的是IsPassed(nSocre ,60) IsPassed(nSocre); // 成績不理想,調低及格分數。不使用參數的默認值,用具體數值來調用函數 // 這時nPass參數的值就是函數調用時給定的數值56 IsPassed(nSocre, 56);
這裡需要注意的是,擁有默認值的參數應該位於形式參數表的末尾位置。不能在形式參數表的開始或者中間位置定義擁有默認值的參數,例如:
bool max( int a = 0, int b ); // 錯誤的形式,默認參數不能在形式參數表的開始位置 bool max( int a, int b = 0 ); // 正確的形式,默認參數在形式參數表的末尾位置
(3) 沒有形式參數時可以用void代替。
一個函數的形式參數並不是必需的,當一個函數只是單純地完成某個動作,不需要通過函數參數與調用者之間進行數據傳遞時,函數的形式參數表就是多余的。這時既可以將形式參數表直接省略留空,也可以用“void”代替形式參數表,表明這個函數沒有形式參數表。例如:
// 將形式參數表留空 void DoSomeThing(); // 或者使用void代替形式參數表 void DoAnotherThing(void);
雖然這兩種形式都可以表示函數沒有參數,但是在調用的時候,卻有一定的差別:如果將形式參數表留空,在調用時就可以用任意實際參數調用這個函數。在形式上,好像函數調用使用了參數,但實際上這些參數根本不起任何作用;而如果用void作為函數的形式參數,那麼在調用的時候實際參數只能為空。例如:
// 正確:以字符串為參數調用形式參數表留空的函數 DoSomeThing("cook"); // 正確:以整數為參數調用形式參數表留空的同一函數 DoSomeThins(1982); // 錯誤:以整數為參數調研以void為形式參數的函數 DoAnotherThing(1982);
將函數的形式參數表留空或者使用void代替,都可以達到函數無參數的目的。只是使用void作為形式參數時,這種意圖更加強烈和明顯,不僅在函數聲明中用void明確表示這是一個無參數的函數,而且在函數調用時也不能使用任何參數。所以,如果我們想要明確地表達某個函數無參數的意思,最好使用void表示。
完成函數的聲明,只是將程序代碼裝進函數箱子的第一步,它相當於給這個函數箱子貼上了標簽,表明這個箱子中裝的是什麼功能的代碼(函數名),而這些代碼的執行又需要什麼數據(形式參數表),而最後又會向外返回什麼數據(返回值類型)。接下來的第二步才是關鍵,要完成函數的定義,也就是在函數內部用具體的程序代碼處理數據實現函數的功能,這樣才最終將程序代碼裝到了函數箱子中。函數的定義往往是緊接著函數的聲明進行的,其語法格式如下:
返回值類型標識符 函數名(形式參數表) { // 函數定義 }
在函數聲明之後緊接著用一對花括號“{}”括起來的代碼就是一個函數的定義,也稱為函數體。在函數體中,利用函數參數傳入的數據,我們用具體的程序代碼對其進行操作實現函數的具體功能,最後再用“return”關鍵字向函數的調用者返回執行結果數據。例如,可以這樣來定義上面聲明的Add()函數,對兩個數進行相加並返回它們的計算結果。
// 計算兩個數的和 int Add( int a, int b ) // 函數聲明 { // 函數定義 // 利用參數傳入的數據執行加和計算,實現對數據的處理 int nRes = a + b; // 用return關鍵字返回函數執行的結果數據 return nRes; }
在這段代碼中,函數聲明之後用花括號括起來的一段代碼就是Add()函數的函數體。這個函數體只有兩條語句,第一句“int nRes = a + b;”是計算參數a和b傳遞進來的兩個數的和,並將計算結果保存到變量nRes中,這樣就實現了這個函數加和(Add)的功能;第二句是“return nRes;”,也就是通過return關鍵字將nRes中保存的結果數據返回給函數的調用者。這樣,該函數體就實現了計算兩個數之和的功能。
這裡的return關鍵字是C++中很常用的一個關鍵字, “return”即“返回”的意思,當函數執行到return關鍵字時,函數將立即結束執行並返回,即使return後面還有代碼,也不會被執行。如果函數有返回值,它還會負責將結果數據返回給函數的調用者。return返回的結果類型必須和函數聲明中的返回值類型一致。根據程序的執行情況,同一個函數可以有多個return語句,用於不同的情況下返回不同的結果。當然,對於不需要返回結果的函數,可以不寫return 語句,函數體在執行完所有代碼後自然結束。
最佳實踐:聲明和定義相分離
在實際的開發實踐中,在某個源文件中定義的函數往往會在另一個源文件中被調用。另外,對於一些函數庫(比如DirectX)而言,我們希望他人可以使用函數庫中的函數,但又不希望將函數的實現細節暴露給使用者。這時我們通常采取的方法是:只向函數的使用者提供函數的聲明,而使用者根據函數聲明就知道如何對函數進行調用了。至於具體的函數實現,則寫在另外的函數實現文件中,或者是通過動態鏈接庫文件(比如,.dll文件)或靜態鏈接庫文件(比如,.lib文件)的形式提供給函數的使用者。這樣,一個程序的所有源代碼文件通常就被分成兩類:一類文件主要用來記錄函數或者類的聲明,提供給他人或者程序中另外的文件使用。這類文件被稱為頭文件(header file),通常以.h為文件名後綴;另外一類文件則用來定義函數和類,實現函數和類的具體功能,這類文件被稱為源文件(source file),多以.cpp為文件名後綴。
這樣,一個函數的聲明和定義被分別放在了兩個文件中,實現了接口和實現的相互分離。