關於C++當中泛型編程, 模板初階(函數模板, 類模板)詳解

C++泛型編程
在C++當中, 泛型編程是一個十分重要的概念, 那麼泛型編程到底是什麼? 而它的作用及優點在哪裏? 下面我們將一步步解釋C++當中的泛型編程, 函數模板和類模板

舉個簡單的例子, 在我們C語言當中, 如果我們想要實現一個交換函數, 比如下面這樣:

void Swap(int* a, int* b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

這是一個簡單的交換函數, 但是它只可以交換int類型的數據.
如果現在我們需要交換兩個double類型的數據, 比如

double a = 1.1, b = 2.2;
Swap(&a, &b);

這樣調用Swap函數, 編譯會報錯, 如圖
在這裏插入圖片描述
那麼好, 既然需要交換double類型的數據, 那我們再實現一個交換函數就好了, 如果是C語言, 那麼我們還要考慮不可以出現同名函數, 但是C++就不同了, C++當中實現了函數重載, 即可以出現同名函數, 但函數的參數不能相同.

void Swap(int* a, int* b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

void Swap(double* a, double* b)
{
	double tmp = *a;
	*a = *b;
	*b = tmp;
}

void Swap(char* a, char* b)
{
	char tmp = *a;
	*a = *b;
	*b = tmp;
}

利用函數重載, 我們可以實現對不同類型的交換, 與此同時存在的問題也就出現了:

  1. 首先我們實現的重載函數就只是類型發生了變化, 只要出現新的類型, 就要實現對應的函數, 這樣使得代碼複用率變得很低, 實現上也帶來了不必要的麻煩;
  2. 因爲類型的不同, 導致相應函數增多, 也讓代碼的維護性變得比較低, 有可能一個出錯, 導致所有的重載函數均出錯.

也正因爲這些問題的存在, C++當中實現了泛型編程.
泛型編程: 其實可以理解爲字面的意思廣泛類型(各種類型), 也就是編寫與類型無關的代碼, 是一種代碼複用的手段. 而模板就是泛型編程的基礎.
其中模板又分爲: 函數模板和類模板

函數模板

函數模板代表了一個函數家族, 這個函數模板和類型無關, 只有在被使用時纔會根據實參的類型來實例化對應的函數

函數模板的格式

template\<typename T1, typename T2, ......, typename Tn \>
返回值類型 函數名(參數列表)
{}

一般常用寫爲

template\<classTn T1, classTn T2, ......, classTn \>
返回值類型 函數名(參數列表)
{}

在函數模板的基礎上, 我們再來實現一個與類型無關的交換函數模板

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp;
}

之前我們已經說過, 泛型編程就是編寫與類型無關的函數, 爲什麼說它與類型無關呢?
我們來看這樣一段代碼

#include <iostream>

using namespace std;

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp //注意這裏的語法錯誤, 少了分號
}

int main()
{
	double a = 1.1, b = 2.2;

	system("pause");
	return 0;
}

既然語法出現了錯誤, 那編譯應該報錯纔對, 但實際上代碼成功編譯運行了.
在這裏插入圖片描述
再來改一下, 我們來調這個函數

#include <iostream>

using namespace std;

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp //注意這裏的語法錯誤, 少了分號
}

int main()
{
	double a = 1.1, b = 2.2;
	Swap(a, b);
	system("pause");
	return 0;
}

我們在調用這個函數時, 報錯了
在這裏插入圖片描述
這就對應了之前的東西與類型無關!
也就是說, 根據實際參數真正實例化對應的函數時, 纔會生成相關的代碼. 因此我們調用之後編譯器幫我們檢查出了這個語法錯誤, 不調用, 編譯器是不會管模板函數中的東西的. 當然我們要保證模板語法的正確, 雖然不調用編譯器不管裏面, 模板語法還是會檢查的

#include <iostream>

using namespace std;

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp; 
//這裏少了一個大括號  }

int main()
{
	double a = 1.1, b = 2.2;
	//仍然沒有調用
	system("pause");
	return 0;
}

在這裏插入圖片描述
這個例子其實就是在說明函數模板的原理, 函數模板其實就類似於一個模具, 編譯器能夠根據這個模具來產生特定具體類型的函數. 換個角度來說, 本來是實現這些對應不同數據類型的函數應該是我們自己去做的, 而現在編譯器幫我們做了這個重複的事情.

在編譯器的編譯階段, 編譯器會根據傳入的實參類型來自己推導出對應類型的函數以供使用, 因此也就看到了上面例子中的不調用, 也就不實現對應函數, 也就不管函數模板中的內容!
還按照double舉例來說明, 當我們用double類型使用函數模板的時候, 編譯器通過實參類型的推導, 將T確定爲double類型, 然後產生了一份專門處理double類型的代碼, 對於其他的類型也是這樣.

接下來, 我們來說明模板的隱式實例化和顯式實例化

同樣, 先看代碼:

#include <iostream>

using namespace std;

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp;
}

int main()
{
	double a = 1.1, b = 2.2;
	Swap(a, b);
	cout << a << ' ' << b << endl;

	int c = 1, d = 2;
	Swap(c, d);
	cout << c << ' ' << d << endl;

	char e = 'a', f = 'b';
	Swap(e, f);
	cout << e << ' ' << f << endl;

	system("pause");
	return 0;
}

上面其實就是隱式實例化, 編譯器根據實參類型推導模板參數的實際類型T.
上面的代碼也可以顯式實例化

double a = 1.1, b = 2.2;
Swap<double>(a, b);
cout << a << ' ' << b << endl;

int c = 1, d = 2;
Swap<int>(c, d);
cout << c << ' ' << d << endl;

char e = 'a', f = 'b';
Swap<char>(e, f);
cout << e << ' ' << f << endl;		

當然, 上面這樣的情況無論我們隱式實例化還是顯式實例化, 不會產生太大問題, 換個例子來看

#include <iostream>

using namespace std;

template<class T>
T Add(T& a, T& b)
{
	return a + b;
}

int main()
{
	int a1 = 10, a2 = 20;
	double d1 = 1.1, d2 = 2.2;
	Add(a1, d1);

	system("pause");
	return 0;
}

這樣調用就會產生問題, 編譯會報錯, 因爲編譯器看到Add(a1, d1)時, 首先根據a1將T推導爲int, 然後有根據d1將T推導爲 double, 但是模板的參數列表中只有一個T, 這樣就使得編譯器無法確定T到底是int還是double.

注意: 在模板當中, 編譯器並不會進行隱式類型轉化.

出現這樣的情況時, 我們就不得不進行顯示實例化了

Add<int>(a, b);

這樣就用顯示實例化來解決這個問題, 但是這就又牽扯到了別的問題.
編譯仍然報錯

在這裏插入圖片描述
我們指定了編譯器去實例化int類型的函數, 也就是下面這樣一個函數

int Add(int& a, int& b)
{
	return a + b;
}

也就是說, 我們要把d1的值傳給b, 注意我們用的是引用.
這裏有這樣一個過程

//隱式類型轉化將d1的值賦給一個int類型的臨時變量
int tmp = (int)d1;
//也就是說 給這個臨時變量起了一個別名(引用)
int& b = tmp; 
// 注意這一步是無法完成的, 因爲tmp是一個臨時變量, 而臨時變量具有常性, 本來我tmp是一個只讀的臨時變量, 不能因爲我給他起了別名b, 我就可以修改這個tmp變量了, 這樣顯然不合理.

因此, 應該是

int Add(const int& a, const int& b)
{
	return a + b;
}

這樣, 編譯就通過了, 就可以顯示實例化完成調用了, 完整代碼:

#include <iostream>

using namespace std;

template<class T>
T Add(T& a, const T& b)
{
	return a + b;
}

int main()
{
	int a1 = 10, a2 = 20;
	double d1 = 1.1, d2 = 2.2;
	Add<int>(a1, d1);
	system("pause");
	return 0;
}

除了顯示實例化調用之外, 還可以我們自己完成類型轉化

Add(a1, (int)d1);

類模板

有了上面函數模板的基礎, 我們再來說類模板, 其實大體上是一樣的, 這裏我們主要來說明C++中實現類模板的出現幫助我們解決了哪些問題.

這裏就拿數據結構棧來舉例說明, 假如我們是C語言當中想要實現一個棧, 一般會這麼去寫

typedef struct Stack_c
{
	int* _a;
	int _capacity;
	int _size;//記錄棧頂top
}Stack_c;

void Stack_c_init(Stack_c* s);
void Stack_c_push(Stack_c* s, int val);
void Stack_c_pop(Stack_c* s);
void Stack_destroy(Stack_c* s);

這裏只是舉例, 並沒實現功能.

緊接着我們就可以定義自己的棧了

int main()
{
	Stack_c s;
	Stack_c_init(&s);//首先要給棧初始化
	Stack_c_push(&s, 1);
	Stack_c_push(&s, 2);
	Stack_c_push(&s, 3);
	Stack_c_push(&s, 4);
	Stack_c_push(&s, 5);
	Stack_c_push(&s, 6);
	Stack_destroy(&s);//不用了要銷燬

	system("pause");
	return 0;
}

但是在C語言當中這樣的一個棧會存在很多問題.

  1. 忘記調用 init 初始化棧, 或者不用的時候忘記調用 destroy 銷燬棧.

  2. 沒有封裝, 誰都可以修改數據, 比如:

    s._capacity = 0;//非法修改, 不被允許的操作

  3. 爲了讓我們自己實現的棧可以存多種不同的類型, 我們可以

     typedef int SDataType;
     //typedef char SDataType;
     //typedef double SDataType;
     ...
     typedef struct Stack_c
     {
     	SDataType* _a;
     	int _capacity;
     	int _size;//記錄棧頂top
     }Stack_c;
    

    但是, 如果我們需要定義一個存int類型的棧, 還需要一個存 char 類型的棧, 我們就必須將所有代碼實現對應的再copy一份只修改類型.

接下來, 來看泛型編程類模板幫我們解決的問題, 首先來看一個 cpp 版本的自定義棧

template<class T>
class Stack_cpp
{
public:

	Stack_cpp();//可以在構造函數中實現棧的初始化
	void push(int val);
	void pop();
	~Stack_cpp();//可以在析構函數中實現棧的銷燬

private:
	T* _a;
	int _capacity;
	int _size;
};

這樣一個 cpp 版本的棧在定義時, 可以通過顯式實例化生成對應數據類型的棧, 並且在定義時自動調用構造函數完成初始化, 程序退出時自動調用析構函數完成銷燬清理.

Stack_cpp<int> s1;//實例化一個存儲int類型數據的棧
Stack_cpp<char> s2;//實例化一個存儲char類型數據的棧
Stack_cpp<double> s3;//實例化一個存儲double類型數據的棧

這樣的代碼比起C版本是不是簡潔了許多, 也不用考慮初始化和銷燬的問題.

在cpp這樣版本的一個棧裏, 由於類域 + 訪問限定符的存在, 使得我們無法訪問類中的私有成員變量, 實現了封裝, 禁止了類似C當中 s._capacity = 0 這樣的非法操作.

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