C++ 中的泛型——template淺析

本文出自神農班,神農班宗旨及班規:https://mp.weixin.qq.com/s/Kzi_8qNqt_OUM_xCxCDlKA

個人博客:https://blog.N0tExpectErr0r.cn

小專欄:https://xiaozhuanlan.com/N0tExpectErr0r

C++ 的模板是一個比較複雜的領域,在 C++ 中的應用十分廣泛,它和 Java 中的泛型有相似之處,但本質上卻有非常大的不同。出於好奇,在阿拉神農老師的指導下,寫下了這篇文章,對 C++ 的模板進行一系列的學習。

在文章開始之前,各位可以先讀一下阿拉神農老師對模板的一些心得體會,可能會對模板有一個更加全面的理解:

image-20190920140753464

模板的引入及意義

首先我們來思考一個問題:爲什麼 C++ 需要引入模板?

作爲程序員,我們都具有一個非常優良的美德——懶。正是因爲懶,我們纔會想要去減少我們的工作量,從而發明了一系列的語言特性與工具從而降低開發的成本。C++ 相較於 C 語言最大的區別就是引入了面向對象,而面向對象的三大特性:封裝、繼承、多態,其實都有一個共同的目的——減少編碼的成本,提高開發效率。比如我們可以通過封裝來對一些重複的邏輯進行抽取,實現一些易於使用的類,來避免後期寫一些重複的代碼。

而引入模板,也是爲了減少編碼的成本。通過模板,我們可以對類型進行抽象,將一些多個類型共有的邏輯進行抽取,使得每個類型都能夠適用同一套邏輯,避免了我們大量的重複代碼編寫。

例如,現在我們有一個將兩個 int 值相加的函數 add

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

我們都知道,float 也是支持加法的,如果此時我們還需要實現一個對 float 相加的函數,就需要通過重載實現如下的函數:

float add(float a, float b) {
	return a+b;
}

但我們其實可以發現這兩段代碼除了類型不同,剩餘邏輯都是基本相同的,我們完全可以通過一些手段來對這個類型進行替換,從而做到只需要一次編寫,即可對不同類型的變量完成相同的工作。

說到對類型進行替換,我們首先想到的當然就是宏,通過宏我們可以實現在預編譯的過程中,對一些代碼中的片段進行替換。有了這樣一個想法,我們可以寫出如下的宏,實現對不同類型變量的 add 方法的函數聲明:

#include <iostream>
#define DECLARE_ADD_FUN(type)\
type add(type a, type b) {\
    return a+b;\
}

DECLARE_ADD_FUN(int)
DECLARE_ADD_FUN(double)

int main() {
   	std::cout<<add(1,3)<<std::endl;
    std::cout<<add(3.14, 1.024)<<std::endl;
}

這樣,我們就不再需要再重複進行函數的編寫了,可以通過宏定義對指定的類型根據模板函數生成對應的函數定義。

雖然解決了代碼大量編寫的問題,但使用宏定義進行這些函數或類的模板開發實際上是非常不方便的,主要有以下的原因:

  1. 在編寫宏定義時,編輯器並不會對宏定義中的代碼進行語法的檢查,只有使用宏定義時纔會意識到自己的代碼出現了語法錯誤,如果有一個極其複雜的宏則會給我們的開發帶來極大的困難。
  2. 出現錯誤時,僅僅根據宏定義使用時所提示的錯誤,是很難定位到具體的錯誤點的。

雖然宏定義的實現方式不夠優雅,但這種在編寫代碼時使用一些以後纔會指定的類型的思想還是非常值得我們借鑑的,這種思想現在通常被稱爲『泛型』。

爲了引入泛型思想,C++ 加入了模板這一特性,通過模板我們可以在 C++ 代碼編寫過程中,不關注於變量的具體類型,將一些對變量進行相似操作的邏輯抽象出來,將類型的綁定延遲到使用時進行。通過這樣的設計,類型就可以像我們編寫函數時的參數一樣,調用時再進行指定了。

比如,前面的 add 函數,我們就可以以如下的方式進行編寫:

template <typename T>
T add(T a, T b) {
    return a + b;
}

int main() {
	std::cout << add(1,3) << std::endl;
}

C++ 最初引入模板的目的正是爲了提供泛型機制,不過在經歷了許多前輩的探索後,人們發現了模板的更多可能性,通過模板的一些特性拓展出了許多十分複雜的領域(比如:模板元編程)。因此,我們不能僅僅認爲模板就是泛型,泛型只是模板的應用中其中的一個部分而已

我們使用模板的這種方式,被稱爲『元編程』。元編程是一種編程的抽象,它的核心思想是:我們可以通過編寫一段代碼 A,通過這段代碼 A 來生成真正能實現功能的代碼 B,這個編寫代碼 A 的過程就是元編程,也就是爲真正實現功能的代碼編寫它們的『元』。

模板就是這樣,我們在開發時編寫了一套模板,但這套模板並不是能爲我們運行時的程序提供具體的功能。當我們使用模板後,會在編譯的過程中對使用到的模板的具體類型進行『實例化』,這個實例化的過程,就會爲我們生成了一個真正實現功能的代碼。因此模板這個名字是非常貼切的,真正運行的代碼正是根據這套模板而生成的。

模板的基本語法及特性

C++ 的模板主要分爲兩類,模板函數與模板類,我們將它們分開進行討論,在進行討論之前,我們研究一下如何進行模板的聲明。

模板的聲明

template <typename T>

通過上面的語句,我們就完成了一個模板的聲明,其中 template 是 C++ 中的關鍵字,表明我們後續將會定義一個模板函數或模板類,而 < > 中的 typename T 則是模板的參數,這裏的 T 類似於函數的形參,只是這個模板參數的名字,可以任意指定。typename 則表明了這個模板參數的類型,一般我們會使用 classtypename,而整型也可以作爲這裏模板參數的類型,這點我們放到後面進行討論。

關於 typenameclass,它們幾乎是完全等價的。在最開始時,定義模板時只能使用 class,但由於 class 我們一般也會用來進行類的定義,因此很多人會將這兩者的概念混淆,認爲指定了 class 的模板參數意味着只能傳入類類型,而普通的數據類型如 int 則不能傳入,但事實卻不是如此。因此爲了避免這種概念混淆的情況,引入了 typename 這一關鍵字。

typename 與 class 的區別

幾乎等價意味着在某些特殊的情景下,它們仍然是有所不同的。這主要體現在了它們有一些額外的適用場景:

typename 的特殊用途在於當一個類具有子類,而我們想要使用到這個子類比如創建一個子類對象時:

template <typename T>
void func() {
    typename T::subClazz clazz;
}

我們可以通過 typename 來告訴編譯器我們要使用的是這個類的子類,而不是這個類中的一個靜態成員。

class 的額外應用場景我們都知道,那就是聲明一個類。

模板函數

模板函數以 template 的模板聲明開始,而模板參數列表中的類型可以在函數的參數、返回值、函數體內進行使用,比如我們前面所見到的 add 模板函數:

template <typename T>
T add(T a, T b) {
    return a + b;
}

當需要使用模板函數時,只需要以 模板函數名<類型>(參數); 的形式進行使用即可。

模板類

模板類與模板函數一樣,以 template 的模板聲明開始,比如下面這個支持不同類型元素的 List 類:

template <typename D>
class List {
private:
    D* data;
public:
    void add(const D& data);
    D& get(int index);
};

當我們需要使用模板類時,就可以通過 模板類名<類型> 的形式,作爲一個獨立的類型來使用了。同時,模板類中也是可以包含模板函數的,稱爲成員模板,這個模板函數的參數可以與該類的模板不同。

類型推斷

在使用函數的時候, <類型> 不是必須的,在一些情況下它是可以省略的。編譯器會嘗試從模板的使用處對模板參數中的類型嘗試進行推斷,比如調用前面的 add 函數時,編譯器就會從我們的兩個傳入的參數對 T 的類型進行推斷。

但有的情況下編譯器就無法對類型進行推斷了(比如我們採用了不同類型的參數傳入),此時就需要我們指定對應的類型:

int a = 10;
double b = 20;
add<double>(a, b);

同時,編譯器還不支持根據返回值進行類型的推斷,比如我們下面的這個 get 函數:

template<typename D>
D& get(int index) {
	return data[i];
}

如果我們用 int a = get(5); 這種方式去嘗試調用,編譯器則會報錯,因爲編譯器無法通過返回值的類型進行類型推斷,因此必須採用 int a = get<int>(5) 這樣的形式。

在多個模板參數的情況下,我們可以對模板的參數進行部分指定:

template <typename T0, typename T1>
void fun(T0 arg0, T1 arg1) {
	// ...
}

我們可以通過 fun<float>() 這樣的形式進行部分指定,這裏默認會認爲這個指定的類型 float 是模板參數中的第一個參數,因此編譯器會嘗試去推斷第二個參數 T1。也就是說,模板參數的部分指定默認是在模板參數中從前往後指定的

整型參數

在 C++ 的泛型中還對整型的參數進行了支持,這裏的整型不只是我們平時所指的 int,而是一個比較寬泛的概念,包括了布爾值、各種大小的整型甚至指針。

它的最簡單的應用就是作爲一個常數出現,例如下面的例子:

template <typename D, int size>
class List {
private:
    D data[size];
public:
    // ...
};

這樣我們就可以對這個 List 的初始大小進行指定,比如 List<int, 10>

不過需要注意的是,由於模板的替換是在編譯期間完成,因此這個參數需要是編譯期間就能夠確定的

整型參數還有許多的應用,比如下面的這個比較瘋狂的例子中,整型參數就是一個函數指針:

template <int(*fun)(int, int)>
class A {
public:
    int test(int a, int b) {
        return fun(a, b);
    }
};

int multi(int a, int b) {
    return a * b;
}

int main() {
    A<multi> a;
    std::cout<<a.test(3, 4);
}

可以看到,模板這個特性還是十分有趣的,有非常多的可能性。整型參數除了作爲常數,還有許多的用途,比如讓類型可以像整數一樣運算。

模板的實例化

首先,什麼是實例化?實例化就是一個使用具體的類型或值替換模板參數,從而通過模板產生具體的類或函數的過程,它往往發生在編譯或鏈接階段。主要可以分爲顯式實例化以及隱式實例化

那麼爲什麼要實例化呢?因爲我們要使用具體的某個模板。如果不進行實例化,它就不是一個具體的類或函數,我們無法對它進行使用。而模板經過了實例化,它就變成了一個具體的類或函數,可以供使用者使用。

顯式實例化

顯式實例化也就是我們主動通過某些方式進行實例化,其具體形式就是通過 template 關鍵字後加上函數或模板的聲明,並填入對應的模板參數。

template int add(int a, int b);
template class Array<int>;

隱式實例化

隱式實例化主要指在我們使用模板後,編譯器會根據我們使用的模板自動進行實例化,不再需要我們對其進行顯式的實例化。

int main() {
	add(30, 40);
}

到這裏可能各位會有疑惑,既然有了隱式實例化,那我們爲何還需要進行顯式的實例化呢?這點我也比較疑問,最後在知乎上找到了答案:C++函數模板,隱式實例化,顯式實例化,顯式具體化?

大致總結一下就是:顯式實例化的主要應用是在一些庫的設計中,當其模板參數基本是可以確定的時候,可以將其實現通過顯式實例化放在庫中,從而加快使用者的編譯速度,同時可以避免向使用者暴露其具體實現。C++ 的 string 就是通過 basic_string 顯式實例化得來的。

模板的特化

C++ 的模板還支持一種名叫特化的操作。特化,指的就是特例化,也就是爲模板編寫一些特例。例如我想針對 int 類型寫一套單獨的實現,區別於其他類型下的實現,就可以通過特化來實現。特化一般分爲兩種:全特化與偏特化。

全特化

在 C++ 中,加入我們希望一個函數,能夠在不同的類型下有不同的行爲,我們可以如何處理呢?例如這裏我們希望對一個函數來說,如果兩個參數是 int,則對它執行乘法,如果兩個參數是其他類型 ,我們對它進行加法。

也就是說,我們需要針對 int 類型的函數調用進行一些特殊的處理。我們這種 Java 程序員可能第一時間想到可以通過 instanceof 來對變量的類型進行判斷,但 C++ 並沒有提供給我們一個判斷變量類型的語法。此時,我們就可以介紹一個 C++ 模板的特殊使用方式——模板特化。

特化,顧名思義就是特殊化,也就是對一些特殊的類型提供一套單獨的代碼實現,我們這裏對函數和類進行分別討論。

模板函數特化

對於模板函數,如果我們要對它進行特化,只需要重新寫一個將 template<> 中的模板參數列表留空,將模板參數替換爲具體類型的函數即可。比如下面的例子:

template <typename T>
T add(T a, T b) {
    return a + b;
}

這裏有一個模板函數,我們只需要在類名中指定特化的類型 int,將用到模板參數 T 的地方換爲 int,並將模板參數列表留空的方法即可,這樣編譯器就會知道它是前面的模板的特化形式。

template <>
int add<int>(int a, int b) {
    return a * b;
}

可能這樣看上去與重載非常相似,但這裏實際上不併是重載,而是針對 int 類型的模板函數進行了特殊化的處理,這樣我們調用時,如果是被特化的類型,就會調用到我們實現的特化模板而不是原來的模板了。

這裏由於我們的函數參數中已經能夠推倒出我們的特化的類型爲 int,因此不再需要在函數名後添加 <int>

模板類特化

不單單是模板函數可以進行特化,模板類實際上也可以進行特化。我們只需要將 template<> 中的模板參數留空,併爲對應的類指定具體類型即可。比如下面的例子:

template <typename T>
class Add {
public:
    static T add(T a, T b) {
        return a + b;
    }
};

如果我們需要進行特化,只需要如下寫即可:

template <>
class Add<int> {
public:
    static int add(int a, int b) {
        return a + b;
    }
};

這樣我們就對 Add 類的模板進行了 int 類型的特化,編譯器並不會認爲它是重複的類定義,而是認爲它是上面的模板的一個特化形式。

偏特化

爲什麼前面的標題上要加上一個『全』呢?實際上,不論是上面的模板函數特化還是模板類特化,我們其實都是能夠在代碼編寫時確定其具體類型的,可以說模板被特化爲了一個具體的函數或類。比如我們可以簡單地認爲 Add<int> 就是一個具體的類型。

但有些情況下我們不只是想對一個具體類型進行特殊化處理,而是想要對許多有相同特徵的類型進行特殊化處理(比如我們想要對所有的指針進行特殊化處理),此時如果採用全特化,那我們需要對每個指針類型都寫一套一樣的模板,顯然是不合理的。

這時候,我們希望對一部分類型進行特殊化,但這並不是具體到某個類型的完全特化,因此我們往往稱它爲『偏特化』,它生成的往往是一系列的類所對應的模板。

比如下面的這個 add 函數,我們對它的參數進行了偏特化,使用 T 對應的指針 T* 作爲參數時,便會調用這個偏特化的版本:

template <typename T>
T add(T a, T b) {
    return a + b;
}

template <typename T>
T add(T* a, T* b) {
    return *a + *b;   
}

這樣當我們傳入的參數是指針時,程序便會調用到這個偏特化的版本。但顯然對於這個指針形式的調用,使用前面的原型版本也沒有問題,編譯器爲何就會調用到這個偏特化的版本呢?

這裏涉及到模板的匹配規則,較爲複雜,簡單點來說模板是從特殊到一般進行匹配的,類似於我們計算機網絡中的最長匹配規則,由於我們的偏特化版本更爲特殊,因此採用的是這個版本進行匹配。

如何區分

全特化和偏特化其實是比較相似的,我們該如何才能對它們二者進行區分呢?

從我的角度來說,全特化的『全』,就代表了一個全部的意思,也就是對模板參數列表進行了完全的特化,因此全特化的產物是一個可以完全確定的代碼片段,類似於實例化後的產物。

而偏特化則與全特化對應,只是對模板參數列表的部分進行了特化,它的產物並不是一個完全確定的代碼片段,而是另一個『元』,可以由它通過實例化生成各種具體的代碼。可以說它的產物是一類代碼。

這樣應該就可以很方便地對於全特化與偏特化進行區分了,例如對指針類型的模板參數進行特化,就是一種偏特化。因爲指針有各種不同類型的指針,因此它並不是一個可以完全確定的最終產物。

模板中的遞歸

有了前面的知識儲備,讓我們現在嘗試用模板實現一個簡單的二進制轉十進制的元函數。

大家第一個想到的可能是用模板函數來通過循環計算十進制的值。但由於我們使用模板元編程的目的是爲了將一些計算在編譯期完成,提高運行時效率,這裏使用循環只能生成一個對應數據的循環模板函數,具體值仍然需要運行時進行計算,這就與我們的目的背道而馳了。因此我們需要換個思路,採用遞歸的方式來實現:

template <unsigned long N>
class toBinary {
public:
    const static unsigned long value = toBinary<N/10>::value * 2 + N % 10;
};

template <>
class toBinary<0> {
public:
    const static unsigned long value = 0;
};

可以看到,在上面的例子中我們在 toBinary 這個模板類中又使用了同一個模板類,傳入了不同的參數,實現了遞歸使用模板的效果,從而計算出了二進制對應的十進制值。同時我們對值爲 0 時進行了特化,從而避免了遞歸的無限制進行。

看來模板是支持遞歸的。在模板元編程中,遞歸的使用是十分廣泛的

與 Java 泛型的異同

C++ 的模板與 Java 的泛型都是爲了引入泛型這種設計思想。在這次學習 C++ 模板之前,還沒有意識到原來 C++ 中的模板與 Java 的泛型竟然是那麼的不同,下面我們來梳理一下它們之間的異同:

不同點:

  • C++ 的模板是採用了一種類似宏的設計思想,在編譯的過程中對模板進行實例化,從而根據模板參數生成不同的代碼。而 Java 的泛型則更像是一種語法糖,它會在編譯期對泛型的類型進行擦除,最終在編譯出的字節碼中泛型會根據規則變爲 Object 或指定的父類。
  • C++ 的模板由於採用實例化的方式會造成代碼膨脹,而 Java 泛型則不會。
  • C++ 的模板支持整數類型的參數,而 Java 泛型則只支持類型參數。
  • C++ 的模板支持在實例化之前對未知的類成員進行使用,而 Java 的泛型則不支持,可以採用 super 等關鍵字實現類似的效果。
  • C++ 的一份模板對應了編譯後的多份代碼,而 Java 泛型則對應了同一份類型擦除後的代碼。
  • C++ 的模板無法在運行時支持新類型,而 Java 動態性則更強,可以在運行時支持一些新的類型(如 ClassLoader 加載的類)
  • 相對與 C++ 的模板,Java 的泛型由於類型擦除機制,因此在運行時是類型不安全的,例如可以通過反射在 List<Integer> 中插入 String 對象。
  • C++ 支持模板類型數組,而 Java 則不支持(因此 ArrayList 採用了 Object[]

相同點:

  • C++ 的模板與 Java 的泛型都是對泛型這一思想的實現
  • C++ 的模板與 Java 的泛型都是編譯期的實現

那麼爲什麼 Java 的泛型沒有做得比 C++ 的模板更安全,功能更強大呢?我認爲主要原因有以下幾點:

  1. 歷史原因,Java 中的泛型是在 JDK5 之後引入的,這樣設計可以兼容之前版本的代碼
  2. 更強大的功能意味着更復雜的語法,並且失去了引入泛型的初衷

參考資料

《深入理解 Android Java 虛擬機 ART》第五章》

C++模板深度解析 (轉載)

《C++模板元編程》

《CppTemplateTutorial》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章