模版参数
类型模版参数
类型模板参数用于指定一个类型。类型模板参数在模板定义时使用typename
或class
关键字来声明。
简单写一个静态的栈:
// 类型模版参数
// 定义一个静态栈
#define N 10
template<typename T>
class StaticStack
{
private:
T stack[N];
int top;
public:
// ...
};
通过改变N的大小,可以改变栈的大小,但如果我们定义两个不同大小的栈呢?
显然宏定义这样是做不到的,所以我们要介绍非类型模版参数。
非类型参数
非类型模板参数用于指定一个值(常量),而不是类型。非类型模板参数可以是整数、指针、引用或枚举类型等。在模板定义时,这些参数的类型需要显式指定。
C++20之前,只允许整数作非类型模版参数
// 值模版参数
template<typename T, size_t N>
class StaticStack {
private:
T stack[N];
int top;
public:
// ...
void func()
{
N++; // N是常量
}
};
// 实例化
int main()
{
StaticStack<int, 10> s1;
StaticStack<double, 20> s2;
// s1.func();
return 0;
}
这是编译器帮助我们实现了两个类型的静态栈,但并不会在模版实例化的时候检查语法错误。
这里我通过 func() 来解释:
至于func() 中对常量N自增1,++需要左值,理应报错,但是并没有,这是因为实际上没有实例化这个func函数,编译器做的是 按需实例化。
按需实例化
在这个例子中,
func()
函数没有被调用,所以编译器不会实例化它,也不会检查其中的错误。如果你在main()
函数中调用了s1.func()
,那么编译器会在实例化StaticStack<int, 10>
的时候检查func()
函数中的代码,并报出N++
的错误。
模版实例化
对于模版类的实例化:
#include <iostream> template<typename T> class MyClass { public: void func1() { std::cout << "func1" << std::endl; } void func2() { // 这个函数中有一个语法错误 int a = "This is an error"; // 错误:将字符串赋值给整数 } }; int main() { MyClass<int> obj; obj.func1(); // 仅调用func1,不会触发func2的语法检查 // obj.func2(); // 注释掉的代码,不会触发语法检查 return 0; }
在这个代码中,
func2
包含一个明显的语法错误(将字符串赋值给整数)。如果你编译并运行这段代码,由于func2
没有被实例化和调用,编译器不会报错:g++ -o test main.cpp ./test
输出:
func1
如果你取消对
func2
的注释并再次编译,编译器会发现语法错误并报错:这表明模板内部的语法错误只有在模板被实例化并使用时才会被编译器检测到。
对于普通类的实例化:
对于非模板类(普通类),其成员函数也是在实际调用时才会生成代码。如果某个成员函数没有被调用,编译器通常不会为该函数生成代码。不过,由于编译器在编译普通类时会对所有成员函数的定义进行检查,因此即使未调用的成员函数存在语法错误,编译时也会报错。
#include <iostream> class MyClass { public: void func1() { std::cout << "func1" << std::endl; } void func2() { std::cout << "func2" << std::endl; } }; int main() { MyClass obj; obj.func1(); // 只调用func1 // 未调用func2,编译器仍会检查func2的定义,但不会生成func2的代码 return 0; }
在这个例子中,
MyClass
的实例obj
只调用了func1
,编译器会检查func2
的定义,但不会生成func2
的代码。
总结
- 模板类:只有被实际调用的成员函数才会被实例化和生成代码。未被调用的成员函数不会被实例化。
- 普通类:编译器会检查所有成员函数的定义,但只有被实际调用的成员函数才会生成代码。
这就是为什么在模板类中,如果某个成员函数(如func()
)没有被调用,它就不会被实例化。而在普通类中,即使某个成员函数没有被调用,编译器仍然会检查其定义,但不会生成相应的代码。
这里提及一下:在C++中,函数的代码(包括成员函数)在编译时只生成一次,不会因为创建了多个对象而重复生成。这是因为函数代码是共享的,每个对象的成员函数调用都会指向同一段代码。编译器在编译时生成函数的机器码,然后所有对象在调用该函数时都使用相同的机器码。
非类型模版参数的示例:
通过不同的模版参数和参数列表的不用来看看编译器如何选择模版并实例化:
模板参数的匹配规则:
- 模板的匹配和重载是基于模板参数的类型、数量和模板参数的实际类型,而不仅仅是参数列表的形式。
- 当模板参数的类型不同(如
size_t
和int
),但非类型模板参数的值相同,编译器仍会将它们视为不同的模板实例。重定义问题:
- 当两个模板函数的非类型模板参数类型不同但参数列表相同时,虽然它们是不同的模板实例,但如果调用时无法明确区分使用哪个模板实例,则会引发编译器的重载冲突错误。
错误示例:
参数列表相同,模版参数类型不同
#include <iostream>
#include <string>
using namespace std;
template <size_t N>
void printnum(int num)
{
cout << "template <size_t N>: " << N << endl;
}
template <int N>
void printnum(int num)
{
cout << "template <int N>: " << N << endl;
}
int main()
{
printnum<10>(10); // 编译器不清楚选择哪一个模版
return 0;
}
不同的非类型模板参数类型会导致不同的模板实例,如果这些模板实例的参数列表相同,则会引发重载冲突,编译器可能会报告错误,因为它无法在调用时明确选择哪一个模板实例。这意味着虽然它们不是重定义,但在特定的调用情境下,它们会导致编译错误。
要解决这个问题,需要确保在模板函数的模板参数列表中有足够的区别,以便编译器可以正确地选择要实例化的模板函数。
模版参数相同,参数列表不同
template <size_t N>
void printnum(int num)
{
cout << "template <size_t N> -- void printnum(int num)" << endl;
}
template <size_t N>
void printnum(char num)
{
cout << "template <size_t N> -- void printnum(char num)" << endl;
}
int main()
{
printnum<10>(10);
printnum<10>('a');
return 0;
}
输出:
template <size_t N> -- void printnum(int num)
template <size_t N> -- void printnum(char num)
正确示例:
//相同非类型模版参数,不同类型参数列表
#include <iostream>
#include <string>
using namespace std;
template <size_t N>
void printnum(int num)
{
cout << "void printnum(int num): " << N << endl;
}
template <size_t N>
void printnum(char num)
{
cout << "void printnum(char num): " << N << endl;
}
template <size_t N = 666>
void printnum(const char* num)
{
cout << "void printnum(const char* num): " << N << endl;
}
int main()
{
printnum<10>(10);
printnum<20>('a');
printnum("hello");// 使用缺省值666
return 0;
}
输出:
void printnum(int num): 10
void printnum(char num): 20
void printnum(const char* num): 666
进一步说明
模版实例化之前,编译器不会检查类中具体的细节,只会检查“外壳”
#include <iostream>
#include <vector>
using namespace std;
template <typename T>
void printVector(const vector<T>& v)
{
typename vector<T>::const_iterator it = v.begin();
while (it != v.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
int main()
{
vector<int> vec = {1, 2, 3, 4, 5};
printVector(vec);
return 0;
}
然后如果仅仅将 int 替换成 T,编译器会报错。(不加typename)
#include <iostream>
#include <vector>
using namespace std;
template <typename T>
void PRINT(const std::vector<T>& v) {
typename std::vector<T>::const_iterator it = v.begin();
while (it != v.end())
{
std::cout << *it << " ";
++it;
}
std::cout << std::endl;
}
int main() {
std::vector<double> vec = {1.1, 2.2, 3.3, 4.4};
PRINT(vec);
return 0;
}
这是因为在模板定义中,编译器在解析时并不知道 T
是什么类型,因此也不知道 vector<T>::const_iterator
是一个类型还是一个静态成员。
模板特化的三种形式
类模板特化
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
// 通用类模板
template<typename T1, typename T2>
class MyClass
{
public:
void display()
{
std::cout << "通用模板" << std::endl;
}
};
// 完全特化 - T1和T2都是int
template<>
class MyClass<int, int>
{
public:
void display()
{
std::cout << "完全特化模板 <int, int>" << std::endl;
}
};
// 部分特化 - 第二个模板参数是int
template<typename T>
class MyClass<T, int>
{
public:
void display()
{
std::cout << "部分特化模板,第二个类型是int" << std::endl;
}
};
// 部分特化 - 第一个模板参数是int
template<typename T>
class MyClass<int, T>
{
public:
void display()
{
std::cout << "部分特化模板,第一个类型是int" << std::endl;
}
};
// 部分特化 - 两个模板参数相同
template<typename T>
class MyClass<T, T>
{
public:
void display()
{
std::cout << "部分特化模板,两个类型相同" << std::endl;
}
};
// 指针类型特化 - 两个模板参数都是指针类型
template<typename T1, typename T2>
class MyClass<T1*, T2*>
{
public:
void display()
{
std::cout << "指针类型特化模板" << std::endl;
}
};
// 指针类型特化 - 第一个模板参数是指针类型
template<typename T>
class MyClass<T*, int>
{
public:
void display()
{
std::cout << "部分特化模板,第一个类型是指针,第二个类型是int" << std::endl;
}
};
// 指针类型特化 - 第二个模板参数是指针类型
template<typename T>
class MyClass<int, T*>
{
public:
void display()
{
std::cout << "部分特化模板,第一个类型是int,第二个类型是指针" << std::endl;
}
};
int main()
{
MyClass<double, double> obj1;
obj1.display(); // 输出:部分特化模板,两个类型相同
MyClass<int, int> obj2;
obj2.display(); // 输出:完全特化模板 <int, int>
MyClass<float, int> obj3;
obj3.display(); // 输出:部分特化模板,第二个类型是int
MyClass<int, float> obj4;
obj4.display(); // 输出:部分特化模板,第一个类型是int
MyClass<char, char> obj5;
obj5.display(); // 输出:部分特化模板,两个类型相同
MyClass<int*, float*> obj6;
obj6.display(); // 输出:指针类型特化模板
MyClass<double*, int> obj7;
obj7.display(); // 输出:部分特化模板,第一个类型是指针,第二个类型是int
MyClass<int, char*> obj8;
obj8.display(); // 输出:部分特化模板,第一个类型是int,第二个类型是指针
return 0;
}
函数模板特化
基础函数模板
首先,定义一个函数模板。这是一个通用的函数定义,适用于所有模板参数类型。
template <typename T>
void printValue(const T& value)
{
std::cout << "General template: " << value << std::endl;
}
完全特化函数模板
例如,以下代码为 int
类型提供了一个特定的实现:
template <>
void printValue<int>(const int& value)
{
std::cout << "Specialized template for int: " << value << std::endl;
}
部分特化函数模板(不允许)
C++ 不允许对函数模板进行部分特化。部分特化仅适用于类模板。如果需要对函数模板进行条件处理,通常可以通过重载来实现。
// 使用函数模板
int main() {
printValue(10); // 调用特化版本:输出: Specialized template for int: 10
printValue(3.14); // 调用基础版本:输出: General template: 3.14
printValue("Hello"); // 调用基础版本:输出: General template: Hello
return 0;
}
变量模板特化
适用于所有模板参数类型。
template<typename T>
constexpr T myVar = T{};
完全特化变量模板
为特定类型提供一个不同的值。
template<>
constexpr int myVar<int> = 42;
部分特化变量模板(不允许)
C++ 不允许对变量模板进行部分特化。对于变量模板,只有完全特化是允许的。
// 使用变量模板
int main() {
std::cout << myVar<double> << std::endl; // 输出: 0 (double的默认值)
std::cout << myVar<int> << std::endl; // 输出: 42 (int的特化值)
return 0;
}
模版分离编译
模板分离编译是C++中处理模板代码时的一个重要概念。模板分离编译涉及如何组织和编译模板代码,以便在编译多个源文件时保持正确性并避免链接错误。
模板分离编译的挑战
模板实例化:
- 模板是代码生成器,它们在编译期间根据特定的模板参数生成代码。每当模板被实例化(即用特定类型或值替换模板参数),编译器都会生成实际的代码。
- 如果模板代码只在一个源文件中可见,其他源文件在实例化模板时可能找不到模板的实现,从而导致链接错误。
编译单元:
- C++的编译单元(通常是一个
.cpp
文件)在编译时是独立的。模板代码如果放在头文件中,编译器会在每个包含该头文件的源文件中生成模板的实例化代码。如果模板代码只在某个源文件中,其他源文件将无法访问到这个代码,从而导致编译问题。
解决方案
为了处理模板分离编译,C++提供了一些标准化的方法来确保模板实例化正确且可访问:
1. 将模板定义放在头文件中声明之后
为了确保模板实例化能在所有需要的编译单元中正确生成,模板的定义(包括实现)通常被放置在头文件中。这允许每个包含模板头文件的编译单元看到完整的模板实现。
方式:模板的声明和实现通常分开写,虽然它们都位于同一个头文件中。实现可以放在头文件的后面或单独用.tpp
文件包含。
MyTemplate.h
:
// MyTemplate.h
#pragma once
#include <iostream>
using namespace std;
template<typename T>
class MyTemplate
{
public:
void doSomething();
};
template<typename T>
void MyTemplate<T>::doSomething()
{
// 实现细节
cout << "Doing something with " << typeid(T).name() << endl;
}
或者
// MyTemplate.h #pragma once #include <iostream> using namespace std; template<typename T> class MyTemplate { public: void doSomething(); }; #include "MyTemplate.tpp" // 包含实现文件
// MyTemplate.tpp template<typename T> void MyTemplate<T>::doSomething() { std::cout << "Doing something with " << typeid(T).name() << std::endl; }
main.cpp
:
#define _CRT_SECURE_NO_WARNINGS 1
#include "MyTemplate.h"
int main()
{
MyTemplate<int> obj1;
MyTemplate<double> obj2;
obj1.doSomething(); // 输出: Doing something with int
obj2.doSomething(); // 输出: Doing something with double
return 0;
}
2. 显式实例化 (Explicit Instantiation)
当模板实现非常庞大或复杂时,不希望将其放在头文件中,可以使用显式实例化。将模板的实现放在一个.cpp
文件中,并在该文件中显式实例化特定类型的模板实例。这种方法减少了头文件的大小和编译时间,但需要在头文件中声明该模板。这种方法通常用于大型项目,以减少编译时间和编译单元的大小。(为了避免重复定义的问题,显式实例化通常放在某个 .cpp
文件中,而不是头文件中)
MyTemplate.h
:
#pragma once
template<typename T>
class MyTemplate
{
public:
void doSomething(); // 声明成员函数,不实现
};
MyTemplate.cpp
:
#define _CRT_SECURE_NO_WARNINGS 1
#include "MyTemplate.h"
#include <iostream>
using namespace std;
template<typename T>
void MyTemplate<T>::doSomething()
{
cout << "Doing something with " << typeid(T).name() << endl;
}
// 显式实例化
template class MyTemplate<int>;
template class MyTemplate<double>;
main.cpp
:
#define _CRT_SECURE_NO_WARNINGS 1
#include "MyTemplate.h"
int main()
{
MyTemplate<int> obj1;
MyTemplate<double> obj2;
obj1.doSomething(); // 输出: Doing something with int
obj2.doSomething(); // 输出: Doing something with double
return 0;
}
显式实例化的作用
集中实例化:通过在一个源文件中显式实例化模板,编译器只会在这个特定的源文件中生成模板实例的代码。这意味着,所有包含模板声明的其他源文件都不会再次生成相同模板实例的代码。
减少重复:由于模板实例化代码只在一个地方生成,链接器将只会使用这一份实例化代码。这避免了重复的代码生成,减小了二进制文件的体积,并减少了编译时间。
- 共享机制:显式实例化后的模板实例化代码会被编译器视为可供全局使用的代码。当其他源文件中需要使用该模板实例时,它们只会引用这份已经生成的实例化代码,而不会再次生成。这使得所有使用该模板实例的源文件实际上都在引用同一个实例化对象。
3. 内联模板实现 (Inline Templates)
将模板的实现直接放在头文件中(即模板定义和实现都在同一个头文件中)是一种常见的做法。这是因为模板的实现必须在编译时可用,以便编译器能够根据具体的模板参数生成代码。
方式:模板的实现直接跟在声明之后,没有分开处理。这种方式与普通的C++函数内联类似,但用于模板时,通常是为了简化代码结构。
MyTemplate.h
:
template<typename T>
class MyTemplate
{
public:
void doSomething()
{
// 实现直接在声明中
std::cout << "Doing something with " << typeid(T).name() << std::endl;
}
};
main.cpp
:
#define _CRT_SECURE_NO_WARNINGS 1
#include "MyTemplate.h"
int main()
{
MyTemplate<int> obj1;
MyTemplate<double> obj2;
obj1.doSomething(); // 输出: Doing something with int
obj2.doSomething(); // 输出: Doing something with double
return 0;
}
总结
模板分离编译涉及到确保模板代码在多个编译单元中可见,并能正确生成模板实例化的代码。解决方法包括:
- 将模板定义和实现放在头文件中,以确保所有编译单元都能看到完整的模板实现。
- 使用显式实例化来控制哪些模板实例化被编译。
- 将实现直接内联到头文件中(尤其适用于简单的模板)。
注意事项:
如果没有显式实例化,同一个头文件被多个.cpp文件包含,并且这些文件都使用了MyTemplate<int>,编译器会在每个使用的地方隐式实例化该模板,可能导致多次实例化,增加二进制文件大小。
文件结构
我们将有以下文件:
- MyTemplate.h — 模板类的头文件(声明和实现)。
- File1.cpp — 包含并使用模板的第一个
.cpp
文件。- File2.cpp — 包含并使用模板的第二个
.cpp
文件。- main.cpp — 主程序文件,包含并使用模板。
编译和链接过程
在这个示例中:
File1.cpp
和File2.cpp
都包含了MyTemplate.h
,并且都使用了MyTemplate<int>
。main.cpp
也包含了MyTemplate.h
,并且也使用了MyTemplate<int>
。隐式实例化过程:
- 编译器在编译
File1.cpp
时,会在File1.cpp
内部隐式实例化MyTemplate<int>
,生成对应的代码。- 编译器在编译
File2.cpp
时,也会在File2.cpp
内部隐式实例化MyTemplate<int>
,再次生成一份相同的代码。- 编译器在编译
main.cpp
时,还会再一次隐式实例化MyTemplate<int>
。结果分析
- 由于
MyTemplate<int>
被在多个.cpp
文件中使用,并且每个文件都包含了MyTemplate.h
,编译器会在每个.cpp
文件中生成一份MyTemplate<int>
的实例代码。- 这会导致在最终的二进制文件中存在多份
MyTemplate<int>
的代码,增加了文件大小。链接器的去重(Deduplication)
- 在链接阶段,现代链接器会尝试去除这些重复的模板实例化代码,确保最终的二进制文件中只有一份
MyTemplate<int>
的代码。- 但是,去重过程并不是完全免费的:编译时的重复实例化会增加编译时间,链接器去重也可能增加链接时间。
结语:
通过本篇,我们了解:
① 模板特化允许我们为特定的模板参数类型定制行为,使得模板在处理特殊情况时更加灵活。
② 模板实例化可以隐式或显式地进行,显式实例化有助于集中管理模板实例,避免重复实例化带来的编译和链接问题。
③ 显式实例化是一种重要的优化手段,尤其在大型项目中,可以有效减少代码冗余,优化编译时间,并确保多个编译单元共享同一实例化对象。
④ 模板的分离编译与管理是实际开发中的一大难题,但通过合理使用显式实例化等技术手段,可以大大提高项目的可维护性和编译效率。