C++ 練氣期之函數探幽

2022-08-09 12:03:22

1. 函數基礎

一個C++程式中,往往需要包含若干個函數,可以說函數C++程式的基礎組成元件,是程式中的頭等公民。

如果要理解程式中使用函數的具體意義,則需要了解語言發展過程中致力要解決的 2 問題:

  • 一是完善語言的內建功能庫(API),讓開發者不為通用功能所幹擾。

  • 另就是通過特定的程式碼組織方案提升程式的可伸縮性、可維護性、可複用性以及安全性。

隨著專案規模的增大,分離程式碼,重構整體結構尤為重要。

函數的出現,從某種意義上講,其首要任務便是分離主函數中的程式碼,通過構建有層次性的程式碼,從而提升程式的健壯性。當然,通過函數分離程式碼是有準則的,其準則便是以重用邏輯為核心。

分離是一個大前提,有了這個大前提,便是分離的方案。

如函數設計理念、類設計理念、微服務設計理念……都是分離的思路,只是各自面對的是不同規模的專案。

1.1 使用函數

C++中使用函數分 2 步:

  • 定義函數:定義過程強調函數功能的實現。定義函數時,C++底層執行時系統並不會為函數中的區域性變數分配空間。
  • 呼叫函數:呼叫函數也就是使用函數提供的功能。此期間執行時系統才會為函數中的區域性變數分配空間。

C++定義呼叫2 個過程有順序要求,也就是必須先義再呼叫。

#include <iostream>
using namespace std;
/*
* 定義函數:
* 側重設計理念:此函數的功能是什麼?或者說,通過使用此函數能獲取到什麼樣的幫助 
* 如下設計一個顯示個體資訊的函數
* 當然在設計過程時,需遵循函數的語法要求   
*/ 
void showInfo(char names[10]){
	cout<<"你好:"<<names<<endl; 
}

int main(int argc, char** argv) {
	char myNames[10]="果殼"; 
    //呼叫時,函數中的程式碼方被啟用
	showInfo(myNames); 
	return 0;
}

如上程式碼,當在main函數中呼叫showInfo函數時,showInfo需要在主函數之前定義,否則編譯器會丟擲錯誤。

如果非要把函數的定義放在呼叫語法之後,也不是不可以。可通過把函數的設計過程再分拆成 2 個步驟實施:

  • 宣告函數原型:函數原型只包含函數的基礎說明資訊,並不包含函數功能體。通過原型宣告,提前告訴編譯器,此函數是存在的。
  • 函數功能定義:功能實現。

如下所示:

#include <iostream>
using namespace std;
//宣告函數原型
void showInfo(char names[10]);
int main(int argc, char** argv) {
	char myNames[10]="果殼"; 
	//呼叫函數 
	showInfo(myNames); 
	return 0;
}
//函數定義可以放在函數呼叫之後
void showInfo(char names[10]){
	cout<<"你好:"<<names<<endl; 
}

原型宣告語句只要是在呼叫之前就可以。

1.2 函數的作用域

函數和變數的區別:

  • 變數中儲存的是資料。變數的儲存位置可能是在棧中(stack area)、堆中(heap area)或全域性資料區域(data area)。
  • 函數儲存的是邏輯程式碼。函數的儲存位置是在程式碼區(code area)。

函數的作用域與變數的作用域不同,變數因宣告位置和儲存位置不同,其作用域則會有多種情況。而函數只可能儲存存在程式碼區,C++不允許函數巢狀定義,且只能在檔案中定義。從某種意義上講,函數的定義只有全域性概念,而無區域性概念。

如上文所述,如果函數是定義在呼叫之前,不存在呼叫不到問題,但如果定義是在呼叫之後,則需要宣告原型後才能呼叫到,宣告位置不同時,此函數的可見性也不一樣。如下述程式碼

#include <iostream>
using namespace std;
int main(int argc, char** argv) {
	char myNames[10]="果殼"; 
	//呼叫不到 
	showInfo(myNames ); 
	//宣告函數原型 
	void showInfo(char names[10]);
	//可呼叫到函數 
	showInfo(myNames); 
	return 0;
}
//函數定義在呼叫之後,呼叫時,則需要先宣告
void showInfo(char names[10]){
	cout<<"你好:"<<names<<endl; 
}

通過這種機制,可以限制函數的使用位置。

本文是從廣義角度討論函數,並不涉及類中函數的作用域問題。因類可以對函數進一步封裝,可以限制函數的使用範圍。

一個函數被定義後,此函數除了能在定義它的檔案中使用外,在其它檔案中同樣能使用。如下在函數01.cpp檔案中有 showInfo函數。

此函數可以在函數02.cpp中使用,但是需要有提前宣告語句。

1.3 函數的基礎特性

函數為基礎單元組織程式程式碼的方案,稱為程式導向程式設計。

程式導向指通過複用、嚴格規定函數的呼叫流程達到精簡程式碼的目的。程式導向的特點是:輕資料,重邏輯,會導致各種不同語意的資料混亂在一起。

其原罪在於函數設計時的基本思想:

  • 不在意資料來源、不區分資料具體語意。僅站在重用邏輯角度對程式碼進行重構。
  • 沒有提出資料維護概念,這些因素會導致程式中不同語意的資料分散或聚集在某一個區域,正因這種缺陷的存在,有了引入類管理機制的動機。

如下呼叫函數時,傳遞給函數的無論是一名學生或一隻小狗的姓名,函數沒有標準機制對資料語意加以區分。函數把資料和邏輯徹底分開,一個函數並不刻意關聯(服務於)某種特定語意的資料。

int main(int argc, char** argv) {
	//顯示學生資訊 
	char names[10]="張三";
	showInfo(names);
	//顯示小狗的資訊 
	char dogNames[10]="小花" ;
	showInfo(dogNames); 
	return 0;
}

2. 函數中的引數

C++中給函數傳遞引數有 3 種方案。

2.1 值傳遞

如下定義了一個交換 2 個變數中資料的函數。

#include <iostream>
using namespace std;
//交換函數
void swap(int num1,int num2){
	int tmp=num1;
	num1=num2;
	num2=tmp;
} 
int main(int argc, char** argv) {
	int num1=45;
	int num2=23;
	cout<<"交換之前:"<<num1<<":"<<num2<<endl; 
	swap(num1,num2);
	cout<<"交換之前:"<<num1<<":"<<num2<<endl; 
}

在主函數中呼叫swap函數時,引數傳遞採用值傳遞方案。執行程式後,主函數中的 2 個變數的值沒有得到交換。

為什麼沒有交換成功?得先從值傳遞的特點說起:

  • 在呼叫函數時,通過把資料(值)從一個變數複製到另一個變數的方式完成資料傳輸。當資料量較大時,對效能會有影響。
  • 函數中對形參變數中資料的修改並不會影響到呼叫處實參變數中資料的變化 。

呼叫函數時,底層執行時系統會給函數在棧中分配一個執行空間,此空間稱為棧幀。棧幀與棧幀之間是隔離的。如下圖所示:

swap函數和main有各自的棧幀,main把自己的資料複製一份後交給swap 函數,swap的邏輯操作的是自己內部變數中的資料。

2.2 傳遞指標

對於上述程式碼,能否做到通過呼叫swap函數達到交換主函數中變數的效果?

可以通過傳遞指標的方案,傳遞指標的特點:

  • 呼叫函數時,傳遞變數在記憶體中的地址(指標),相當於把進入變數的鑰匙傳遞過去。
  • 函數中進行資料操作時,通過指標直接對原呼叫處變數中的資料進行修改。
  • 通過傳遞指標可以實現函數之間資料的共用(長臂管轄)。

傳遞指標的優點:

  • 通過減少資料的傳輸提升函數呼叫的效能。
  • 適合於需要直接修改呼叫處資料的場合。

傳遞指標的缺點:

  • 打破函數的封裝性,讓函數可以存取函數之外(另一個函數)中的變數。
  • 因指標底層的複雜性。存在理解上的壁壘和操作上的易出錯性。
#include <iostream>
using namespace std;
//指標型別做為形參
void swap(int* num1,int* num2){
	int tmp=*num1;
	*num1=*num2;
	*num2=tmp;
}
 
int main(int argc, char** argv) {
	int num1=45;
	int num2=23;
	cout<<"交換之前:"<<num1<<":"<<num2<<endl; 
    //傳遞變數的地址(指標)
	swap(&num1,&num2);
	cout<<"交換之前:"<<num1<<":"<<num2<<endl; 
}

下面通過圖示解釋指標傳遞的過程。

主函數中的num1num2儲存的是具體的資料:4523。是int資料型別。

swap函數中的num1num2的型別是指標型別,分別儲存了主函數中num1num2的記憶體地址,或者說是擁有造訪主函數中num1num2變數的另一種途徑。

本質上講,變數名和變數的地址是C++提供的 2 種存取變數的方案。

變數名存取可認為是間接存取,指標存取可認為是直接存取。

理論上講,指標存取要快於變數名存取。

指標是一類資料,除了可以作為函數的引數,也可以作為函數的返回值。但在使用時,請注意如下的問題。

#include <iostream>
using namespace std;
//函數返回指標型別 
int * f(){
	int tmp=20;
	return &tmp;
} 
int ma3222in(int argc, char** argv) {	
	int* p=f();
	cout<<*p<<endl;
}

如上程式碼,呼叫 f函數時,返回 f 函數中 tmp 的地址。因 tmp是區域性變數,其作用域只在函數內部有效,通過指標繞開了編譯器檢查,雖然能得到結果是 20,但務必不要這麼使用。執行時系統也會顯示警告。

[Warning] address of local variable 'tmp' returned [-Wreturn-local-addr]

如下程式碼,可能就得不到函數呼叫後想要的結果:

#include <iostream>
using namespace std; 
int * f(){
	int tmp=20;
	return &tmp;
} 
int f1(){
	int num=50;
	return num;
}
int main(int argc, char** argv) {	
	int* p=f();
	f1();
	cout<<*p<<endl;
}

如上輸出結果是 50,而不是 20。原因是 f函數執行完畢後,其空間被回收,且分配給 f1繼續使用,這時p所指向位置的資料是 f1執行後的結果。

所以,切記不要返回區域性變數的地址。

2.3 參照傳遞

除了通過傳遞指標,C++還有一個傳遞參照的方案,同樣可實現傳遞指標所能達到的效果。

使用指標有很多優勢,也有明顯的缺陷,指標有自己的記憶體空間,會給理解指標以及運用指標帶來了難度。參照C++的新概念,能提供指標能實現的效果,但比指標更輕量級。

參照和指標的區別:

  • 參照是變數的別名,相當於給變數另起了一個名字,參照和變數名一樣是識別符號,在記憶體中沒有實體存在。指標是一種型別,有自己的記憶體空間。
  • 指標可以不賦值,而參照必須為其指定其參照的變數(必須初始化)。有空指標概念,沒有空參照概念。
  • 有多級指標的概念,而不存在多參照的概念,不能給一個參照再命名一個參照。

參照和指標一樣,都會打破函數的封裝性。如下程式碼,使用參照作為函數的引數。

#include <iostream>
using namespace std;
//參照做為形參
void swap(int&  num1,int& num2){
	int tmp=num1;
	num1=num2;
	num2=tmp;
}
 
int main(int argc, char** argv) {
	int num1=45;
	int num2=23;
	cout<<"交換之前:"<<num1<<":"<<num2<<endl; 
    //呼叫時
	swap(num1,num2);
	cout<<"交換之前:"<<num1<<":"<<num2<<endl; 
}

呼叫時,和值傳遞是一樣的,所以,在沒有看到函數原型時,具體是參照、還是值傳遞會讓人誤判。

有一點要注意,參照不是資料型別,所以,下面的程式碼是有錯誤的。

#include <iostream>
using namespace std;
void swap(int&  num1,int& num2){
	int tmp=num1;
	num1=num2;
	num2=tmp;
}
void swap(int  num1,int num2){
	int tmp=num1;
	num1=num2;
	num2=tmp;
}
 
int main(int argc, char** argv) {
	int num1=45;
	int num2=23;
	cout<<"交換之前:"<<num1<<":"<<num2<<endl; 
	swap(num1,num2);
	cout<<"交換之前:"<<num1<<":"<<num2<<endl; 
}

對於編譯器而講,認為這 2 個函數是一樣的,不會當成函數過載。

可以使用參照作為函數的返回值。

當使用參照作為返回值時,不能返回區域性變數,參照必須是建立在變數存在的基礎之上。變數都不存在,給它起一個別名,是沒有任何實際意義的。

#include <iostream>
using namespace std;
//返回變數的參照
int& f2(){
	int tmp=20;
	return tmp;
}
int main(int argc, char** argv) {
    //參照失敗,函數執行完畢後,其區域性變數也回收
	int &  ref= f2();
	print(ref);
}

如果不希望函數內部修改參照所指向的變數中的值,可以使用 const限定參照。如下程式碼會提示,不能通過參照修改變數。

3. 函數指標

使用函數名呼叫函數,是常規呼叫方式。函數儲存在程式碼區,也有其記憶體地址,函數名儲存的就是函數在記憶體中的地址,也就是函數的指標。

#include <iostream>
using namespace std;
int f1(int a){
	return a+1;
}
int main(int argc, char** argv) {
    //f1 中儲存是函數地址
    cout<<f1<<endl;
    // f1()才是呼叫函數
    cout<<f1(7)<<endl;
	return 0;
}

所以,如上主函數中的兩行程式碼本質上是不同的。

  • f1中儲存的是函數指標。
  • f1()表示通過 f1找到函數在記憶體的位置,並執行其程式碼。

可以宣告一個函數指標變數,用來儲存函數的記憶體地址。

#include <iostream>
using namespace std;

int f1(int a) {
	return a+1;
}

int main(int argc, char** argv) {
	//函數指標變數
	int (* f)(int);
    //儲存函數地址
	f=f1;
    //通過函數指標呼叫函數
	cout<<f(10)<<endl;
    // f(10)和(*f)(10)是兩種合法的呼叫方式
    cout<<(*f)(10)<<endl;
	return 0;
}

以上程式碼如果只用於普通函數呼叫,意義並不大。函數指標的意義可以讓函數作為引數、作為函數的返回值。可以認為函數在C++中是一類特殊型別,可以如資料一樣進行傳遞。

3.1 函數作為引數

如下程式碼,讓一個函數作為另一個函數的引數。

#include <iostream>
using namespace std;
//f1 有 3 個引數,一個是函數指標,2 個int 型別
int f1(int (*f)(int ,int), int a,int b) {
    int res= f(a,b);
    return res;
}

int f2(int a,int b){
	return a+b;
}

int main(int argc, char** argv) {
    int num1=10;
    int num2=20;
    //把 f2 作為 f1 的引數
    int res= f1(f2,num1,num2);
	cout<<res<<endl;
	return 0;
}

輸出結果:30

3.2 函數作為返回值

把函數作為另一函數的返回型別,需要提前使用 typedef定義函數型別 。

#include <iostream>
using namespace std;

int f2(int a,int b){
	return a+b;
}
//定義函數型別
typedef int (*ftype)(int ,int);
//返回函數型別
ftype f3(){
	return f2;
}

int main(int argc, char** argv) {
    int num1=10;
    int num2=20;
    int (*f)(int ,int);
    f= f3();
	cout<<f(num1,num2)<<endl;
	return 0;
}

輸出結果:30

4. 總結

本文主要講解函數的引數傳遞問題和函數指標。

引數傳遞有 3 種方案:

  • 值傳遞
  • 指標傳遞
  • 參照傳遞。

只要涉及到記憶體儲存,就會有地址一說,函數指標指函數在記憶體中的儲存位置,C++允許開發者使用函數指標傳遞函數本身。這是非常了不起的特性。