C++ 初識函數模板

2022-09-06 15:00:56

1. 前言

什麼是函數模板?

理解什麼是函數模板,須先搞清楚為什麼需要函數模板。

如果現在有一個需求,要求編寫一個求 2 個數位中最小數位的函數,這 2 個數位可以是 int型別,可以是 float 型別,可以是所有可以進行比較的資料型別……

常規編寫方案:針對不同的資料型別編寫不同的函數。

#include <iostream>
using namespace std;
//針對 int 型別 
int getMin(int num1,int num2) {
   return num1>num2?num2:num1; 
} 
//針對  float 型別
float getMin(float num1,float num2) {
   return num1>num2?num2:num1; 
}
//針對 double 型別 
double getMin(double num1,double num2) {
   return num1>num2?num2:num1; 
}  

int main() {
    //整型資料比較
    int min=getMin(10,4);
    cout<<min<<endl;
    //float 型別資料比較
    float minf=getMin(3.8f,2.9f);
	cout<<minf<<endl; 
    //double 型別資料比較
	double mind=getMin(1.8,2.1);
	cout<<mind<<endl; 
	return 0;
}

過載函數(當然上述幾個函數名也可以不相同)可以解決這個問題。顯然,上述 3 個函數的演演算法邏輯是一模一樣的,僅是函數的引數型別不一樣。既然函數的形式引數可以接受值不同的同型別資料,能否把函數形參的資料型別也引數化,用來接受不同的資料型別

函數模板實質就是引數化資料型別,稱這種程式設計模式為資料型別泛化程式設計。

Tips: 泛化的意思是一般化、抽象化,先不明確指定,需要時再指定。

如:我對班長說,我需要一名學生幫我搬課桌。這名學生到底是誰,我沒有明確,由班長具體化。換在函數模板中,表示函數模板需要一種資料型別的資料,具體是什麼資料型別,由使用者決定。

2. 初識函數模板

2.1 語法

在重構上述程式碼時,先了解一下函數模板的語法結構:

template <模板形式參數列>  返回型別 函數名(函數形式參數列)
{
函數體
}

語法結構說明:

  • template關鍵字說明了此函數是一個函數模板

  • template <> 的尖括號裡是模板參數列,也可稱此處的引數資料型別引數,用來對函數演演算法所針對的資料型別的泛化,表示可以接受不同的資料型別。

    Tips:模板引數列表中的引數可以是一個或多個泛化資料型別引數,也可以是一個或多個具體資料型別引數。

    泛化型別引數前面要加上 typename 關鍵字。

  • 後面便是函數的一般性說明,只是在函數中可以使用模板資料型別引數

    Tips: 函數模板中有 2 類引數,模板引數函數引數

使用函數模板重構上面求最小值的程式碼:

template<typename T> T getMin(T num1,T num2){
	return num1>num2?num2:num1; 
} 

說明:

  • typename T宣告了一個資料型別引數,用於泛化任一種資料型別,或者說 T 可以表示任意一種資料型別。

    Tips:typename 是 C++11 標準,也可以使用 class關鍵字,但建議不用,避免和類定義混淆。

  • T資料型別可以作為函數的引數型別返回值型別、以及作為演演算法實施過程中臨時變數的資料型別。

    Tips: T是一個變數識別符號,在遵循變數命名規則的前提下,可以起任意名稱。

2.2 範例化

函數模板如現實生活中製作陶瓷模具一樣,只有往模具中注入原材料,才能生成可實用的陶瓷。函數模板不是函數,僅是一個模板,不能直接呼叫,需要範例化後才能呼叫。

範例化:指編譯器根據開發者對函數模板注入的具體(實參)資料型別構造出一個真正的函數實體(範例),這個過程由編譯器自動完成,且範例化的函數對於開發者不可見。

int res= getMin<int>(1,6);
cout<<res<<endl;
//輸出結果:1

如上,編譯器通過函數模板<>內的int資料型別,範例化的函數可以對 int型別的資料進行演演算法操作。同理,下面的程式碼會讓編譯器範例化針對不同資料型別的資料進行演演算法操作的函數。

//範例化原型為 float  getMin(float num1,float num2){函數體} 的函數
float resf=getMin<float>(3.2f,8.2f);
cout<<resf<<endl;
//範例化原型為 double  getMin(double num1,double num2){函數體} 的函數
double resd=getMin<double>(1.2,0.2);
cout<<resd<<endl;
//範例化原型為 char  getMin(char num1,char num2){函數體} 的函數
char resc=getMin<char>('A','B');
cout<<resc<<endl;
//輸出結果分別為  3.2f  0.2  A 

使用函數模板的優點不言而喻,宣告一次,便可以實現針對不同資料型別的資料的操作。當然,中間會有匹配、範例化的代價。

Tips:高階業務層面的一勞永逸往往會以犧牲底層的效能為代價,但是,這是值得的。

除了通過顯示宣告資料型別提示編譯器範例化,也可以使用函數指標範例化。

typedef int(*PF)(int,int); // 1 
PF pf=getMin;  // 2
int res= pf(6,8);  //3
cout<<res;  //4

說明:

  • 1 處先定義一個函數指標型別。

  • 2 處這行程式碼,千萬不要理解是取函數模板的地址,編譯器在底層做了相應處理。

    編譯器會根據函數指標型別說明先範例化一個函數。

    再取範例化函數的記憶體地址,並賦值給 pf

  • 3 處以函數指標方式呼叫函數。

範例化時要注意的幾個問題:

  1. 範例化時,可能會有一個直觀問題:真的能指定任意一種資料型別範例化函數模板嗎?

答案是:任何高階層面的邏輯行為都不能脫離基礎知識的認知範疇,不同的資料型別有著語法系統賦予它的運算操作能力,當指定一個不支援函數模板內部演演算法操作的資料型別時,必然會出錯。

如宣告一個求 2 個數位相除的餘數的函數模板。

template<typename T> T getYuShu(T num1,T num2) {
	return num1 % num2;
}

如果指定 double 資料型別範例化 getYuShu 函數模板時,就會丟擲錯誤,因為 double資料型別不能使用 %運運算元。

double res=getYuShu<double>(6.2,2.4);  //出錯

Tips: 編譯器在範例化函數模板時,會遵循語法標準檢查給定的資料型別是否支援函數模板中的運算操作。

  1. 編譯器範例化的時機。

常規而言,編譯器會在程式中第一次需要函數模板的某個範例時對其進行編譯。但是,同一份程式碼中,可能會出現對同一個範例多次呼叫的需要,如下面的程式碼:

template <typename T > test(T num) {
	return num;
}
int f() {
	int res= test<int>(12);
	return res;
}
double f1() {
	int res= test<int>(24);
	return double(res);
}

ff1函數都需要使用 test<int>範例,於編譯器而,無法知道 ff1函數誰先會被呼叫(也就無法確定第一次編譯的時間點),但為了保證編譯期間完成範例化工作,早期C++編譯器採用對同一範例每一次出現的地方都編譯的策略,然後從多個編譯結果中選一個作為最終結果,顯然,編譯時間會大大延長。

C++充許顯式範例化宣告,用來顯示指定某一個函數模板的範例化的時間點,從而解決同一個範例被多次編譯的問題。其語法如下:

template 返回值型別 模板名<模板參數列>(函數形參列表);

針對上述函數模板可以編寫如下程式碼,告之編譯器編譯時間點。

template <typename T > test(T num) {
	return num;
}
//顯示指定範例化
template int test<int>(int);

Tips: 顯示宣告只對一個原始檔有效。

2.3 實參推導

所謂實參推導,在使用函數模板時省略<>,不明確指定資料型別引數,而是由編譯器根據函數的實參型別自動推匯出型別引數的真正型別。如下程式碼:

int res=getMin(4,7);

實參是int 型別, 編譯器由此推匯出 Tint型別,從而使用 int型別範例化函數模板,類似於下面的顯示宣告程式碼:

int res=getMin<int>(4,7);

實參推導可以像呼叫普通函數一樣使用函數模板。但是實參推導是有前提條件的:函數引數使用了型別引數的才能通過函數實參型別推導。如下的函數模板。

template <typename T1,typename T2> T2 myMax(T1 num1,T1 num2) {
	//函數體
}

因為 T2是作為函數模板的返回型別,是無法通過實參型別推匯出來的。如下圖所示:

使用如上函數模板,需要顯示指定具體的資料型別。

double res= myMax<int,double>(6,8); //正確

是否可以讓函數模板的型別引數一部分顯示指定,一部分由實參推導?

答案是可以,但是,要求在宣告函數模板時,把需要顯示指定的型別引數放在前面,可由實參推導的引數型別放在後面。把上面的函數模板的 T1、T2引數說明交換位置。

template <typename T2,typename T1> T2 myMax(T1 num1,T1 num2) {
	//函數體
}

範例化時,只需要顯示指定 T2的型別,T1型別由編譯器根據實參推導。如下程式碼可正確呼叫。

double res= myMax<double>(6,8); //正確

編譯器把 T2指定為 double型別,然後根據實參68推匯出 T1int型別。

瞭解什麼是實參推導後,使用時,需要知道實參推導是不支援自動型別轉換的。如下程式碼是錯誤的。

int res=getMin(4,7.5); //錯誤

編譯器認定實參 4int型別,實參7.5double型別,那麼是到底是使用 int 型別還是使用 double型別範例化 getMin 函數模板,會讓編譯器不知所措、左右為難。

Tips: 即使支援自動型別轉換,於編譯器而言也無法知道開發者是想使用 int 型別還是 double 型別。如此自動型別轉換沒有存在的意義。

對於上述問題可以採用如下幾種方案解決:

  • 通過強制型別操作把實參轉換成統一資料型別。

    int res=getMin(4,int(7.5));
    或者
    int res=getMin(double(4),7.5);
    
  • 顯示指定範例化時的資料型別。

    int res=getMin<int>(4,7.5);
    //或者
    int res=getMin<double>(4,7.5);
    
  • 如果有必要傳遞 2 個不同型別的引數,可需要修改函數模板,使其能接受 2 種型別引數。

    template<typename T1,typename T2> T1 getMin(T1 num1,T2 num2){
    	return num1>num2?num2:num1; 
    } 
    

3. 過載函數模板

C++中普通函數和函數模板可以一起過載,面對多個過載函數,編譯器需要提供相應的匹配策略。如下程式碼:

//普通函數
int getMax(int num1,int num2){
	return num1>num2?num1:num2; 
} 
//函數模板
template<typename T> T getMax(T num1,T num2) {
	return num1>num2?num1:num2;
}

如下呼叫時,編譯器是選擇普通函數還是函數模板?

int res= getMax(6,8);

函數實參是 int型別,相比較函數模板,普通函數不需要範例化可直接使用,編譯器會優先選擇普通函數。但是如下的呼叫,編譯器會選擇函數模板。

getMax(2.4,6.8); //呼叫 getMax<double>(實參推導)
getMax('a','b'); //呼叫 getMax<char>(實參推導)
getMax<>(7,3) //呼叫 getMax<int> (實參推導)
getMax<double>(4,9) //顯示指定

編譯器選擇函數模板的原則:

  • 如果函數模板能範例出一個完全與函數實參型別相匹配的函數,那麼就會選擇函數模板,如getMax(2.4,6.8); 呼叫。編譯器會根據函數模板範例化一個double getMax(double a,double b)函數與需求完全相匹配的函數。
  • 如果即想使用實參推導,且想使用函數模板而非普通函數,可以使用空 <>尖括號語法。如上的 getMax<>(7,7); 呼叫。一旦指定<>識別符號,顯示指定使用函數模板,無論其中是否有實參型別說明。

如下的函數呼叫,實參有 2 個,但 2者之間可以發生自動型別轉換。

charint之間可以相互轉換。

getMax('a',98);

編譯器會選擇誰?可以做一個實驗,把普通函數註釋,保留函數模板。

#include <iostream>
#include <cstring>
using namespace std;
//函數模板
template<typename T> T getMax(T num1,T num2) {
	return num1>num2?num1:num2;
}
int main(int argc, char** argv) {
    int t= getMax('a',98)
	return 0;
}

執行後:

再恢復普通函數後執行,程式碼可以正常執行。顯然,編譯器選擇的是普通函數。原因很簡單,在使用實參推導時,函數模板是不支援自動型別轉換,而普通函數表示沒有壓力。

總結一下,選擇時,編譯器會先考慮有沒有型別完全相匹配的普通函數,沒有,試著看能不能範例化一個完全匹配的函數。

4. 總結

本文只講到了函數模板的語法、範例化和過載 3 個方面的內容,除此之外,函數模板還有其它高階應用,受限於篇幅,本文僅拋磚引玉,有興趣者可以查閱相關檔案。