【C++】模板深入进阶
模板深入进阶
- 模板深入进阶
- github地址
- 前言
- 1. typename和class的区别
- 1.1 模板参数声明中的等价性
- 1.2 依赖类型名的显式指明
- 1.3 结论
- 2. 非类型模板参数
- 2.1 非类型模板参数的用法
- 定义一个固定大小的模板栈
- 定义方式与注意事项:
- 2.2 非类型模板参数的应用——array容器
- 3. 模板的特化
- 3.1 概念
- 3.2 函数模板特化
- 3.3 类模板特化
- 3.3.1 全特化
- 3.3.2 偏特化
- 3.3.3 类模板特化的应用
- 4. 模板的分离编译
- 4.1 什么是分离编译?
- 4.2 模板的分离编译
- 4.3 回顾C/C++可执行程序的形成过程
- 1. 预处理 (Preprocessing)
- 2. 编译 (Compilation)
- 3. 汇编 (Assembly)
- 4. 链接 (Linking)
- 4.4 分析
- 4.5 解决方法
- 5. 模板的总结
- 1. 优点
- 2. 缺点
- 应用建议
- 6. 结语
模板深入进阶
github地址
有梦想的电信狗
前言
在学习模板进阶前,请先移步往期文章【模板初识】,学习使用模板的基本概念和用法。
模板初识
模板的几大好处:
- 控制容器中存放的数据的类型
- 模板控制代码的逻辑:
- 如适配器模式:传入不同的容器或迭代器类型,可以适配出不同的容器和迭代器
- 只要是传类型,就可以用模板,模板不仅仅是类型参数化,还有更多的用法和功能,比如适配器和仿函数
1. typename和class的区别
在C++模板中,typename
和class
的区别主要体现在以下方面:
- 模板参数声明中的等价性
- 依赖类型名的显式指明时需特殊指定
1.1 模板参数声明中的等价性
-
在声明模板类型参数时,
typename
和class
完全等价,可以互换使用。 -
示例:
template<class T> class A {}; // 合法 template<typename T> class A {}; // 合法 // 以上两种模板的声明方式,效果等同
-
两者的唯一区别是代码风格。
typename
可以更明确地表示参数可以是任意类型(包括基本类型),而class
可能暗示参数是一个类(但实际无约束)。
1.2 依赖类型名的显式指明
-
在模板内部,若引用一个依赖模板参数的嵌套类型(即依赖类型名)(内嵌类型),必须使用
typename
,以消除歧义。 -
有如下场景,需要实现一个对
vector容器
的遍历函数一般实现如下:
-
// 遍历vector<int>void Print(const vector<int>& v) {vector<int>::const_iterator it = v.begin();while (it != v.end()) {cout << *it << " ";++it;}cout << endl;}
- 以上方式可以实现对
vector<int>
类型的遍历。但该实现的缺点也很明显,只能遍历vector<int>
类型的vector
,这与模板推崇的泛型编程思想相违背 - 我们可以使用模板来实现对存放各种类型数据的
vector
实现遍历
- 以上方式可以实现对
-
实现如下:
//template<class T> template<typename T> void Print(const vector<T>& v) {vector<T>::const_iterator it = v.begin();while (it != v.end()) {cout << *it << " ";++it;}cout << endl; } // 编译看看?!
但此时出现了奇怪的报错:“const_iterator”: 类型 从属名称的使用必须以“typename”为前缀
先暂且不关注这个错误,我们思考,要实现对存放泛型数据T的vector
进行遍历,既然都考虑泛型了,那不妨直接实现对所有支持const迭代器的容器的遍历,这样的实现更模板化。
实现如下:
//template<class Container>
template<typename Container>
void Print(const Container& con) {Container::const_iterator it = con.begin(); // 所有的容器均支持 const_iterator 即 const迭代器while (it != con.end()) {cout << *it << " ";++it;}cout << endl;
}
-
这里的模板参数名定义为
Container
,我们期望实现对所有支持const_iterator
迭代器的容器进行变量,但编译过后依旧出现了错误,且报错原因和实现vector<T>
的遍历时报错原因相同。这是为什么?
-
解决方案如下:在模板内部,若引用一个依赖模板参数的嵌套类型(即依赖类型名)(内嵌类型),必须使用
typename
,以消除歧义。typename Container::const_iterator
。在Container::const_iterator
前加上typename
即可解决。 -
template<typename Container> void Print(const Container& con) {typename Container::const_iterator it = con.begin(); // 所有容器支持 const_iterator const迭代器while (it != con.end()) {cout << *it << " ";++it;}cout << endl;}//加上typename后编译正常
-
接下来逐步分析:
-
不加
typename
时,编译前要先进行词法分析,此时由于Container
是模板类型,进行语法分析时(编译前),模板尚未实例化为具体的类型。语法分析时,编译器无法确定Container::const_iterator
这样的写法,是访问类内的静态成员变量,还是访问类中 嵌套定义的内部类或typedef
的类型,编译器无法确定,就会报错。 -
C++
中,通过类名::
后接成员名的方式**既可以访问static成员变量,也可以访问类中的内嵌类型!!!**例如: -
class MyClass {public:// 类型enum Color { Red, Green, Blue }; // 嵌套枚举class InnerClass {}; // 嵌套类typedef int MyInt; // 类型别名// 变量static int staticVar; // 静态成员变量 类内声明,类外定义};int MyClass::staticVar = 42; // 类外 定义// 访问方式 : 直接通过类名访问// 内嵌类型MyClass::Color color = MyClass::Red; // 访问枚举值MyClass::InnerClass obj; // 实例化嵌套类MyClass::MyInt x = 5; // 使用类型别名// 静态成员变量MyClass::staticVar = 10; // 类外初始化
-
typename Container::const_iterator
这里必须加上typename
。因为编译器编译前进行语法分析时,如果不加typename
,Container
是类模板,未进行编译,尚未完成实例化。Container::const_iterator
这种写法,实例化Container之后才能确定Container::const_iterator
表示的是类型还是类内的static
成员变量。 -
但语法分析阶段,模板
Container
未完成实例化,且编译器不能确定Container::const_iterator
表示的是类型还是类内的static
成员变量,就会报错。
-
加上
typename
是告诉编译器,这里的typename Container::const_iterator
表示的是类内的内嵌类型,这样语法分析阶段就确定了Container::const_iterator
表示的是类型,之后等模板实例化后(编译期间),再去类内直接找对应的类型 -
若省略
typename
,编译器可能将Container::const_iterator
解析为静态成员变量而非类型,导致语法错误。 -
总结:
- 只要是取未实例化的模板内的内容,都不能确定 取的是 类内的内嵌类型 还是
static
成员变量
- 只要是取未实例化的模板内的内容,都不能确定 取的是 类内的内嵌类型 还是
-
此场景还可以使用
auto
解决。auto it = con.begin();
这是因为auto推导所得到的结果,表示的一定是类型,这样就不会引起编译器的歧义了。- 但
auto
并不能解决所有场景下的这类问题 因为auto
不能用于模板参数列表中,因此在模板参数列表中使用容器模板化时需要加上typename
- 但
1.3 结论
- 优先使用
typename
:在声明模板参数时,typename
更清晰;在依赖类型名时必须使用。 - 保留
class
的场景:模板参数声明中,若需兼容旧代码或遵循习惯用法时保留class
。
2. 非类型模板参数
模板参数分类:类型形参与非类型形参
-
类型形参:即出现在模板参数列表中,跟在
class
或者typename
之后的参数类型名称 -
非类型形参,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
2.1 非类型模板参数的用法
定义一个固定大小的模板栈
传统实现:
// 定义一个固定大小的模板栈
#define N 20
template<class T>
class Stack {
public:// 成员函数
private:T _base[N];int _top;
};
int main() {// 我如果想要定义两个大小不同的静态栈,该如何实现?Stack<int> st1;Stack<int> st2;return 0;
}
显而易见,这种实现方式只能定义一个固定大小的栈,但我如果想要两个大小不同的静态栈,该如何实现呢?
非类型模板参数可以解决这个问题。
非类型模板参数定义固定大小的模板栈:
// 使用非类型 模板参数定义一个静态栈
// 非类型模板参数, 1 必须是常量, 2 必须是整形家族 (size_t、char、int、unsigned int)
template<class T, size_t N>
class Stack {
public:void func() {//N = 20; // N 是常量,不能修改}
private:T _array[N];int _top;
};
int main() {// 这样便可以定义不同大小的静态模板栈Stack<int, 10> st1;Stack<int, 20> st2;// 有些编译器会进行按需实例化,如果我没有调用func,那么就不会对func进行检查。此行为称为按需实例化// 即使func内修改了N, 因为由于一些编译器的按需实例化,也无法对错误的行为进行报错st1.func();
}
定义方式与注意事项:
template<class T, size_t N>
只需在模板参数列表中,定义所需要的非类型模板参数即可。- 需要注意的是:
- 浮点数、类对象以及字符串不允许作为非类型模板参数。非类型模板参数必须是常量, 必须是整形家族的类型
size_t、char、int、unsigned int
- 非类型的模板参数必须在编译期就能确认结果
- 函数或类模板中定义过非类型模板参数后,函数或类中不能对非类型模板参数进行修改
- 浮点数、类对象以及字符串不允许作为非类型模板参数。非类型模板参数必须是常量, 必须是整形家族的类型
2.2 非类型模板参数的应用——array容器
和vector
不同,array
是C++11
中添加的静态数组。该静态数组使用了非类型模板参数的方法实现
但实际上,静态数组array
的适用场景非常有限。
比如,我想使用固定大小的数组,完全可以使用C风格的数组实现。
int main(){int a[8] = {0}; // C语言风格的实现//array<int, 10> a; //标准库array的实现a[0] = 0;for (auto e : a)cout << e << " ";cout << endl;return 0;
}
-
C语言的原生数组和
array
也有一些区别:-
array
对越界的检查十分严格,越界的读和写都能检查出来 -
普通的C风格的数组,不能越界检查读,少部分越界写可以检查出来
-
std::array
的优点是可以支持模板
-
-
std::array
对越界的检查十分严格,越界读写都能检查,但这些功能vector
也都能实现,只在使用时,vector
不需要扩容了,需要将其使用为定长度的vector
。这也是array
相对鸡肋的地方,并且使用vector
时还可以进行初始化,std::array
并不会进行初始化。
int main(){//array<int, 10> a;vector<int> v(10, 0);for (auto e : v)cout << e << " ";cout << endl;
}
- 笔者认为,
std::array
和普通数组相比,优势便在于标准库中,提供了一系列的重载函数和模板支持,可以方便我们使用。但vector
中也有类似的重载,这也正是array
被吐槽的原因。
array中的重载函数:
有了vector
的基础,array
提供的调用接口我们一看便知,此处不再详细解释:
- 总结:
- 实际开发中,恰当的使用
vector
既可以实现静态数组的功能,也可以提供动态数组的便利。vector
确实更加好用,已使用vector
时,完全没有必要使用array
3. 模板的特化
3.1 概念
通常情况下,使用模板可以实现一些与类型无关的代码,但对于一些特殊类型可能会得到错误的结果,需要特殊处理,比如:实现一个专门用来进行小于比较的函数模板
// 函数模板
template<class T>
bool Less(T left, T right) {return left < right;
}
有以下场景,我想传某个变量的地址,但不想按照变量地址的大小比,而是要按变量的值去比
int main() {cout << Less(1, 2) << endl;int a = 1, b = 2;cout << Less(&a, &b) << endl;double c = 2.2, d = 1.1;cout << Less(&c, &d) << endl;return 0;
}
可以看到,Less
函数绝大多数情况下都可以正常比较,但是在特殊场景下就得到错误的结果。上述示例中,c显然大于d,但是Less
内部并没有比较c和d的内容,而比较的是c和d的地址,这就无法达到预期的结果而错误。
此时,就需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。
模板的特化:在该模板中,某些类型需要特殊处理且要频繁调用,专门为该类型编写一个特定的模板函数或模板类来实现功能
模板特化中分为函数模板特化与类模板特化
3.2 函数模板特化
函数模板的特化步骤:
- 必须要先有一个基础的函数模板
- 模板特化时,关键字
template
后面接一对空的尖括号<>
- 函数名后跟一对尖括号,尖括号中指定需要特化的类型
- 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误
对上述的Less
模板函数进行特化:
// 已有的 基础的函数模板
template<class T> // 1
bool Less(T left, T right) {return left < right;
}// 函数模板特化的标准写法
template<>
bool Less<int*>(int* left, int* right) {return *left < *right;
}
通过已有的函数模板,为传入int*
类型时进行特殊化处理,就是特化。特化之后我们再传int*
时,就会调用特化版本的Less<int*>
函数进行比较,从而达到我们预期想要的结果。
int main() {cout << Less(1, 2) << endl; // 调用 bool Less(T left, T right)int a = 1, b = 2;cout << Less(&a, &b) << endl; // 调用 bool Less<int*>(int* left, int* right)double c = 2.2, d = 1.1;cout << Less(&c, &d) << endl; // 调用 bool Less(T left, T right)return 0;
}
但此时聪明的宝子就会发现了,其实没有必要进行函数模板的特化,函数模板的特化也是要实现一个新的函数,并加上
template<>
。
既然也是要实现一个新的、类似的函数,仅参数类型和功能不同,倒不如直接写一个新的函数,和之前的函数构成函数重载,同样解决了我们的问题,且有效避免了模板导致的代码膨胀。如下:
bool Less(int* left, int* right) {return *left < *right;
}
- 函数重载可以实现**
int
之间的比较或者int*
之间的值比较**。
但至此依旧没有解决问题,因为此时只能该函数只能接受两个int*
类型的变量,double*
呢?
-
因此最终我们应当实现两个模板的
Less函数
版本,模板函数之间构成重载-
一个
Less
函数用于接收任意类型的值,并进行比较。-
template<class T>bool Less(T left, T right) {return left < right;}
-
-
一个
Less
函数用于接收任意类型的指针,通过地址进行变量值的比较。-
template<class T>bool Less(T* left, T* right) {return *left < *right;}
-
-
-
一览
-
// 模板值比较和模板指针比较template<class T>bool Less(T left, T right) {return left < right;}template<class T>bool Less(T* left, T* right) {return *left < *right;}
-
两个函数同时存在,构成重载,我们便实现了任意可比较类型的比较。
- 给
Less
传入值时,按照值比较。 - 给
Less
传入地址时,按照地址所指向的值进行比较。
- 给
一般情况下:如果函数模板遇到不能处理或者处理有误的类型,为了实现简单,通常都是将该函数利用重载直接实现。
因为对于一些参数类型复杂的函数模板,函数模板特化时,函数的参数会很复杂。而函数重载实现简单明了,代码的可读性高,容易书写
- 因此函数模板不建议特化,常用函数重载来替代。
3.3 类模板特化
- 和函数模板特化类似,类模板也有特化
- 有如下类模板,
T1
和T2
都可以是任意类型,我们可以根据具体的需求对类模板进行特化。如对参数类型是<int, double>
是进行特殊处理,可进行特化
// 初始类
template<class T1, class T2>
class Data {
public:Data() { cout << "Data<T1, T2>" << endl; }
private:T1 _d1;T2 _d2;
};
3.3.1 全特化
- 全特化:即是将模板参数列表中所有的参数都确定化,对某种特定的参数类型组合进行特殊处理。
- 如下:将
<class T1, class T2>
特化为<int, double>
。 - 模板参数列表中的参数被全部特殊化,称为全特化
// 基础类模板
template<class T1, class T2>
class Data {
public:Data() { cout << "Data<T1, T2>" << endl; }
private:T1 _d1;T2 _d2;
};// 对Data的特殊类型<int, double>做处理 类模板的特化
// 全特化
template<>
class Data<int, double> {
public:Data() { cout << "Data<int, double>" << endl; }
private:
};
可以看到:
-
我们显式指定模板参数类型为
<int,double>
后,模板实例化为我们特化的class Data<int, double>
版本。 -
而显式指定模板参数类型为
<int,int>
时,模板实例化为Data<T1, T2>
3.3.2 偏特化
偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。用以下模板类实例:
template<class T1, class T2>
class Data {
public:Data() { cout << "Data<T1, T2>" << endl; }
private:T1 _d1;T2 _d2;
};
偏特化有以下两种表现方式:
-
部分特化:将模板参数类表中的一部分参数特化
template<class T1, class T2> class Data { public:Data() { cout << "Data<T1, T2>" << endl; } private:T1 _d1;T2 _d2; }; // 偏特化 偏特化部分模板参数 template<class T> class Data<T, double> { public:Data() { cout << "Data<T, double>" << endl; } private: };
可以看到,我们特化了部分模板参数,Data<std::vector<int>, double>
使类模板实例化为我们实现的特化版本Data<T, double>
-
参数更进一步的限制:偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一个特化版
本- 偏特化为指针类型:只允许传入地址
// 偏特化 对类型进一步做限制 template<class T1, class T2> class Data<T1*, T2*> { public:Data() { cout << "Data<T1*, T2*>" << endl; } private: };
- 偏特化为引用类型:只通过引用访问
template<class T1, class T2>
class Data<T1&, T2&> {
public:Data() { cout << "Data<T1&, T2&>" << endl; }
private:
};
-
- 偏特化为指针引用交叉类型:部分特殊场景可能使用
// <T1&, T2*>
template<class T1, class T2>
class Data<T1&, T2*> {
public:Data() { cout << "Data<T1&, T2*>" << endl; }
private:
};
// <T1*, T2&>
template<class T1, class T2>
class Data<T1*, T2&> {
public:Data() { cout << "Data<T1*, T2&>" << endl; }
private:
};
- 测试代码
int main() {cout << "偏特化 偏特化部分模板参数" << endl;Data<int, int> d1; // 未特化 Data<T1, T2>Data<int, double> d2; // 特化 Data<int, double>Data<Data<int, int>, double> d3; // 特化一次 Data<T1, T2> Data<T, double>Data<Data<vector<int>, int>, double> d4; // 特化两次 Data<T1, T2> Data<T, double>cout << "偏特化 对类型进一步限制" << endl;Data<int*, double*> d5; // 特化 Data<T1*, T2*>Data<void*, void*> d6; // 特化 Data<T1*, T2*>Data<int*, double&> d7; // 特化 Data<T1*, T2&>Data<char&, double&> d8; // 特化 Data<T1&, T2&>Data<char&, double*> d9; // 特化 Data<T1&, T2*>return 0;
}
3.3.3 类模板特化的应用
- 回顾之前实现的日期类,现在仅将需要用到的部分实现:
class Date {//友元函数声明 可以使类外的函数 不受访问权限的控制friend ostream& operator<<(std::ostream& out, const Date& d);friend ostream& operator<<(std::ostream& out, const Date* d);
public:Date(int year, int month, int day) {//要从构造处和输入处检查非法日期if (month > 0 && month < 13 && day > 0 && day < GetMonthDay(year, month)) {_year = year;_month = month;_day = day;}else {cout << "非法日期" << endl;assert(false);}}int GetMonthDay(int year, int month) const {const static int daysArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };//每次调用函数都需要在栈区创建数组,干脆将他变成静态数组//四年一润,百年不润 四百年一润if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))) {return 29;}else {return daysArray[month];}}bool operator<(const Date& d) const {if (this->_year < d._year)return true;else if (this->_year == d._year && this->_month < d._month)return true;else if (this->_year == d._year && this->_month == d._month && this->_day < d._day)return true;elsereturn false;}
private:int _year = 2025;int _month = 6;int _day = 1;
};// <<和>>重载
ostream& operator<<(std::ostream& out, const Date& d) {out << d._year << "年" << d._month << "月" << d._day << "日";return out;
}
ostream& operator<<(std::ostream& out, const Date* d) {out << d->_year << "年" << d->_month << "月" << d->_day << "日";return out;
}
-
有以下专用于进行小于比较的模板类,需要满足T类内重载了 < 运算符
-
template<class T>class Less {public:// 需要满足T类内重载了 < 运算符bool operator()(const T& x, const T& y) const {return x < y;}};
-
测试代码,观察现象:
-
int main() {Date d1(2025, 6, 10);Date d2(2025, 6, 6);Date d3(2025, 6, 8);vector<Date> v1;v1.push_back(d1);v1.push_back(d2);v1.push_back(d3);// 可以直接排序,结果是日期升序sort(v1.begin(), v1.end(), Less<Date>());for (auto& e : v1)cout << e << " ";cout << endl;vector<Date*> v2;v2.push_back(&d1);v2.push_back(&d2);v2.push_back(&d3);// 此处排序的结果是 vector 中元素出现的顺序 因为vector中元素的地址是递增的sort(v2.begin(), v2.end(), Less<Date*>());for (auto& e : v2)cout << e << " ";cout << endl;return 0;}
-
-
第一次排序的结果是:日期的升序。
Date类
中重载了<
运算符,可以按照日期的值进行比较 -
第二次排序的结果是:vector中元素出现的顺序,并没有按照日期的升序进行排序
为什么呢?
template<class T>
class Less {
public:// 需要满足T类内重载了 < 运算符bool operator()(const T& x, const T& y) const {return x < y;}
};
这是因为我们的比较类Less只实现了值的比较,传入Date*
的指针时,会按照地址的大小进行比较。由于vector
中的元素的地址是递增的,也就出现了vector
中元素顺次出现的结果
此处需要在排序过程中,让sort
比较v2中存放地址指向的日期对象,但是走Less模板,sort
在排序时实际比较的是v2
中指针的地址的大小,因此无法达到预期。
这时候类模板的特化就派上用场了,可以将Less
类为Date*
特化一个特殊版本,将传入的指针解引用,进行值比较。
// 对Less类模板按照指针方式特化
template<>
class Less<Date*> {
public:bool operator()(Date* x, Date* y) const {return *x < *y;}
};
-
特化之后,比较的结果就正确了。
-
需要注意的是,如果是自定义类型的大小比较,需要特化出来的类内,实现<运算的重载
- 一般情况下,模板特化都是特化一些极小的类,内部的成员变量很少
- 特化类中成员变量,不必与基础类中的成员变量相同,因为那是两个不同的类,实现了各自相应的功能即可
4. 模板的分离编译
4.1 什么是分离编译?
一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。
4.2 模板的分离编译
我们将我们之前实现的模板栈进行声明和定义分离编译:
- 头文件
stack.h
,存放类的设计和成员函数的声明
#pragma once
#include <iostream>
#include <deque>
// 适配器模式
namespace m_stack {template<class T, class Container = std::deque<T> >class stack {// 该stack类内 无需实现构造函数和析构函数, 因为类内的成员均为自定义类型,// 编译器生成的默认构造和析构函数,会自动调用自定义类型的默认构造和析构public:// 模板声明和定义分离void push(const T& ele);void pop();// 以下是不进行声明和定义分离的函数,方便做对比T& top() {return _con.back();}size_t size() const {return _con.size();}bool empty() const {return _con.empty();}private:Container _con; // 这是自定义类型,该自定义类型内需要实现各种构造函数和析构函数};
}
- 源文件
stack.cpp
,仅将push
和pop
声明和定义分离编译,可以说明问题就足够了。
#include "stack.h"
// 多个命名空间自动合并
// 模板声明和定义分离,会发生链接错误
namespace m_stack {// 定义时,要给定push所在的类域,push存在于类 stack<class T, class Container>中// 而T又未声明,因此要重新给出模板参数列表template<class T, class Container> // 声明处给了 Container 的默认容器,定义处就不要给了void stack<T, Container>::push(const T& ele) {_con.push_back(ele);}template<class T, class Container>void stack<T, Container>::pop() {_con.pop_back();}
}
- 模板在进行声明和定义分离编译时,要在每个函数之前加上
template<class T, class Container>
,也就是添加当前函数对应的模板参数列表,因为模板参数位于模板参数列表中,不加上的话,不能使用模板参数,且编译器会将此函数认为成普通函数。 - main函数调用
#include <iostream>
#include <string>
#include "stack.h"
using namespace std;int main() {m_stack::stack<int> st1;st1.push(1);st1.push(2); //调用push,链接错误// 链接错误,在符号表中,找不到push函数的地址st1.top(); // topst1.size(); //调用size无链接错误return 0;
}
查看编译结果:
- 出现了无法解析的外部命令这样的链接错误。
无法解析的外部符号 "public: void __cdecl m_stack::stack<int,class std::deque<int,class std::allocator<int> > >::push(int const &)" (?push@?$stack@HV?$deque@HV?$allocator@H@std@@@std@@@m_stack@@QEAAXAEBH@Z),函数 main 中引用了该符号
- 此为链接错误,找不到我们实现的
stack
中的push
函数。这是为什么呢?
4.3 回顾C/C++可执行程序的形成过程
从C/C++
源文件.c或.cpp源文件形成可执行程序需要经历预处理、编译、汇编、链接四个过程,接下来依次介绍各个时期的特点以及Linux
下的编译器gcc/g++
是如何实现这些过程的。
1. 预处理 (Preprocessing)
输入文件: .c
/.cpp
源文件
输出文件: .i
文件(预处理后的文本文件,预处理后依然是.c
/.cpp
文件)
工具: 预处理器
核心过程:
- 展开所有
#include
指令,递归插入头文件内容 - 处理
#define
宏定义,执行文本替换。 - 条件编译处理(
#ifdef
,#ifndef
,#endif
等) - 删除所有注释(
//
和/* */
),删除所有空行和空白。 - 添加行标记(
#line
指令)供调试使用 - 处理
#pragma
编译器指令
- 使用gcc编译器仅完成预处理步骤的命令
示例:gcc -E main.c -o main.i
2. 编译 (Compilation)
输入文件: .i
文件
输出文件: .s
文件(汇编代码文件)
工具: 编译器(如 gcc
, clang
)
- 编译是消耗时间和资源最多的步骤。
核心过程:
- 词法分析:将源代码分解为
Token
流,检查语法 - 语法分析:构建抽象语法树
(AST)
- 语义分析:类型检查、作用域验证等
- 中间代码生成:生成平台无关的中间表示(如 LLVM IR)
- 代码优化:进行死代码消除、循环优化等
- 目标代码生成:生成特定 CPU 架构的汇编代码
了解了以上过程,我们认识到,**宏不会进行类型检查和语法分析
**的原因是:
-
宏进行的是文本替换,在预处理阶段进行。
-
而语法检查是在编译阶段进行的。
因此常说宏是类型不安全的
将预处理后的文件翻译成汇编语言指令:
示例:
gcc -S main.i -o main.s
3. 汇编 (Assembly)
- 将汇编指令翻译成机器指令的过程。
输入文件:.s
文件
输出文件:.o
/.obj
文件(二进制目标文件)
工具: 汇编器
核心过程:
- 将助记符形式的汇编代码转换为机器指令(二进制操作码)
- 生成符号表(记录函数/变量地址信息)
- 生成重定位表(标记需要链接时修正的地址)
- 生成节区信息(
text/data/bss
等段)
可执行文件的格式:
- Linux:
ELF
格式(Executable and Linkable Format) - Windows:
COFF
格式(Common Object File Format)
示例:gcc -c main.s -o main.o
4. 链接 (Linking)
输入文件: .o
文件 + 静态库或动态库(.a
/.lib
)
输出文件: 可执行文件(.exe
(windows下) 或有执行权限的文件
(Linux下
))
工具: 链接器
核心过程:
- 符号解析:匹配所有未定义符号与其定义,在符号表中查找仅声明但未定义的函数是否有定义
- 地址分配:
- 地址回填:给每个目标文件分配运行时内存地址
- 数据段合并:合并相同类型的节区(如多个.text段合并)
- 重定位修正:根据实际地址修改代码中的引用
- 解析库依赖:
- 静态链接:直接将库代码复制到可执行文件中
- 动态链接:生成导入表记录共享库信息
链接类型:
类型 | 特点 | 文件扩展名 |
---|---|---|
静态链接 | 代码体积大,无运行时依赖 | .a (Linux) .lib (Windows) |
动态链接 | 代码体积小,需要运行时环境支持 | .so (Linux) .dll (Windows) |
示例:gcc main.o -o main
在进行多文件编译时,对每个文件进行单独编译,最终一起链接。
4.4 分析
根据以上过程,我们得知,预处理阶段,头文件会被展开到被
include
的位置。
- 由于预处理阶段进行的是头文件的对应文件的展开,因此预处理后的文件仍然是.cpp文件
- 预处理后的
Stack.cpp
文件如下:
//被包含进来的头文件的内容#include <iostream>
#include <deque>
// 适配器模式
namespace m_stack {template<class T, class Container = std::deque<T> >class stack {public:// 模板声明和定义分离void push(const T& ele);void pop();T& top() {return _con.back();}size_t size() const {return _con.size();}bool empty() const {return _con.empty();}private:Container _con; // 这是自定义类型,该类内无需实现构造函数和析构函数// 该类内,编译器默认生成的构造函数和析构函数,会自动调用自定义类的构造函数和析构函数};
}
// 源文件的内容
// #include "stack.h",包含的头文件会被展开
// 多个命名空间自动合并
// 模板声明和定义分离,会发生链接错误
namespace m_stack {// 声明处给了 Container 的默认容器,定义处就不要给了template<class T, class Container>void stack<T, Container>::push(const T& ele) {_con.push_back(ele);}template<class T, class Container>void stack<T, Container>::pop() {_con.pop_back();}
}
-
之后再对包含了头文件的每个源文件,分别隔离开进行单独编译:
- 此处需要编译的源文件有:
Stack.cpp
:头文件已展开main.cpp
:头文件已展开
- 此处需要编译的源文件有:
-
链接时,需要去其他文件的符号表中找当前文件中只有声明但没有定义的函数:
分析可得:在头文件展开后的.cpp源文件中:
- 对于声明和定义分离编译的普通函数,该
.cpp
文件内既有声明,也有定义,编译过后编译器可以根据函数的定义,在符号表中形成该函数的地址,链接时便可以找到编译时只有声明但没有定义的函数的地址。该行为正确,不会发生链接错误。 - 对于声明和定义分离编译的模板函数,该
.cpp
文件内同样既有声明,也有定义,但却发生了链接错误。原因是:编译时,模板函数的模板参数列表(例如:template<class T, class Container = std::deque<T> >
),由于编译时是单个.cpp
文件隔离开编译的,模板函数(或模板类)的模板参数列表中的模板类型class T
和class Container
,未完成实例化,编译器不知道T
和Container
指代的是什么类型。函数参数的类型不明确,编译器无法为当前模板函数生成地址。最终链接时,要找编译时只有声明,而没有定义的模板函数的地址,未实例化的模板函数的地址编译器是无法生成的,因此在当前文件的符号表中找不到模板函数的地址,最终发生链接错误。
对这里的push
和pop
函数来说,push
和pop
函数的的地址无法确定
-
编译期间:函数的声明是承诺,声明的语法正确时,编译就会通过
-
普通的函数,有声明但没有定义时,会发生链接错误
声明和定义分离的模板类或函数,.cpp
文件中有声明,有定义,但由于模板未完成实例化,函数或类的参数类型不确定,无法为当前函数生成地址,导致符号表中没有当前函数的地址,最终发生链接错误
因此:涉及模板的类或函数,只有在模板实例化后,才会在符号表中形成对应的地址。
4.5 解决方法
经过以上分析,我们得知,导致链接错误的原因是由于模板未完成实例化导致的,因此一种解决思路就是,帮助模板完成实例化,称为显式实例化
- 解决办法 1. 显式实例化
// stack.cpp文件
// 为使用了的模板实例提供显式实例化
namespace m_stack {// 声明处给了 Container 的默认容器,定义处就不要给了template<class T, class Container>void stack<T, Container>::push(const T& ele) {_con.push_back(ele);}template<class T, class Container>void stack<T, Container>::pop() {_con.pop_back();}// 解决办法 1. 显式实例化template class stack<int>; // 对类模板进行显式实例化template class stack<double>; // 对类模板进行显式实例化// 显式实例化治标不治本,每增加一个类型,就要显式实例化一份
}// main.cpp文件,main函数调用
int main() {// 显式实例化后,不报错m_stack::stack<int> st1;st1.push(1);st1.push(2); st1.top(); st1.size(); return 0;
}
-
显式实例化治标不治本,每增加一个类型,就要显式实例化一份。需要在模板定义的位置显式实例化。这种方法不实用,不推荐使用
-
模板要声明和定义分离,推荐一种更为实用的方法:在头文件中进行声明和定义分离
模板不支持直接的分离编译,有更好的解决方法。STL
中是将 声明和定义分离开,但放在了同一个文件中
-
将声明和定义放到以.hpp或者.h为后缀的文件。推荐使用该方法。
-
.hpp
就是声明和实现放在一个文件中的头文件,常用与实现模板的分离编译,用.hpp
来暗示类的声明和定义实现在同一个文件中。- 短小的函数直接写在类内,在类中实现
- 较长的函数在当前文件中,分离实现
-
将我们的实现的栈改写为
.hpp
实现如下: -
//Stack.hpp#pragma once#include <iostream>#include <deque>// 适配器模式namespace m_stack {template<class T, class Container = std::deque<T> >class stack {public://stack() {}// 模板声明和定义分离void push(const T& ele);void pop();T& top() {// return _con[size-1]; //这是错误写法 不能用[] 因为适配器是list时,没有[]重载return _con.back();}size_t size() const {return _con.size();}bool empty() const {return _con.empty();}private:Container _con; // 这是自定义类型,该类内无需实现构造函数和析构函数};}// 多个命名空间自动合并namespace m_stack {// 声明处给了 Container 的默认容器,定义处就不要给了template<class T, class Container>void stack<T, Container>::push(const T& ele) {_con.push_back(ele);}template<class T, class Container>void stack<T, Container>::pop() {_con.pop_back();}}
-
基于以上实现,模板的分离编译便不会报错了。
- 将声明和定义放到一个文件 “xxx.hpp” 里面或者xxx.h其实也是可以的。推荐使用该方式
5. 模板的总结
1. 优点
- 代码复用与泛型编程支持
模板允许编写与数据类型无关的通用代码,适用于多种类型的数据结构或算法,避免了为每种类型重复编写相似逻辑。例如,标准模板库(STL)中的容器(如std::vector
)和算法(如std::sort
)均通过模板实现,显著提升了开发效率。 - 类型安全与性能优化
模板在编译时生成具体类型的代码,编译器会进行严格的类型检查,避免了运行时类型错误。同时,由于模板生成的代码是类型特化的,其性能接近手动编写的类型专用代码,避免了虚函数等动态多态的开销。 - 灵活性与扩展性
模板支持高级特性如模板元编程(TMP),可在编译时完成复杂计算(如斐波那契数列计算),减少运行时负担。此外,通过特化(全特化、偏特化)和可变参数模板,可以针对特定类型或场景定制行为,增强代码适应性。适配器模式和仿函数也为模板带来了极大的灵活性 - 减少冗余代码
模板通过实例化机制消除重复代码,例如一个排序算法模板可适用于所有支持比较操作的类型,降低了维护成本。
2. 缺点
- 编译时间与代码膨胀
每次模板实例化都会生成新的代码,导致编译时间显著增加,尤其在大规模项目中。同时,多类型实例化可能使可执行文件体积膨胀(即“代码膨胀”)。 - 复杂性与调试困难
模板代码(尤其是嵌套模板或元编程)可读性较差,且编译错误信息冗长晦涩,难以定位问题。例如,类型不匹配错误可能涉及多层模板展开信息。 - 跨平台兼容性问题
不同编译器对模板的支持存在差异(如旧版本编译器不支持部分特性),且模板生成的二进制代码可能因编译器版本不同导致ABI不兼容,影响动态库的跨平台使用。 - 动态多态限制
模板基于静态多态,所有类型必须在编译时确定,无法像虚函数那样实现运行时动态分派。
应用建议
- 适用场景:通用库开发(如STL)、高性能算法、需要静态多态的场景。
- 规避问题:在跨平台项目中谨慎使用高级模板特性,合理控制模板实例化范围以减少编译时间,并优先使用C++20模块化特性改善编译效率。
6. 结语
模板是 C++
提供的最具威力的语言特性之一,它不仅极大地提升了代码的复用性与灵活性,更为泛型编程、元编程等高级编程范式提供了强有力的支撑。从 typename
与 class
的微妙差异,到非类型模板参数的实用技巧;从函数与类模板的特化,再到模板分离编译中踩过的“坑”……每一步都体现了模板背后的设计哲学与工程智慧。
在实际开发中,模板让我们可以用最少的代码应对最多的类型变化,它既能在编译期提供类型安全的保障,又能在运行期带来接近手写代码的极致性能。而对于“Less函数到底该重载还是特化”、“类模板应当如何偏特化”等问题的思考,也帮助我们建立起良好的设计意识与架构思维。
以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步
分享到此结束啦
一键三连,好运连连!
你的每一次互动,都是对作者最大的鼓励!
征程尚未结束,让我们在广阔的世界里继续前行!
🚀