【C++修炼】STL容器—模板进阶:非类型模板参数、模板特化、模板分离编译

模板

1. 非类型模板参数

模板参数分为:类型形参与非类型形参。

  • 类型形参:跟在 class 或 typename 后的参数类型名称,作为类型使用的模板参数。
  • 非类型形参:在模板中可作为常量使用的模板参数。

例如,C++将C的静态数组封装成了容器 array,其模板参数 N 就是非类型形参。

注:array 最大好处之一是越界检查机制。静态数组只能在数组末尾设置标记位,对越界访问进行抽查,因为静态数组访问元素底层是指针解引用。array 定义的数组访问元素是通过运算符重载 operator\[\],底层是函数,方便使用各种手段进行越界检查,例如 assert 断言。其次是效率,array 定义的是静态数组,在栈上,vector 定义的是动态数组,在堆上,栈上开空间比堆上开空间效率高。

下面通过示例演示非类型形参 ,参考 array 模拟实现一个C++版本的静态数组

代码示例 1.1

cpp 复制代码
#include <iostream>

namespace david // 使用命名空间隔离,避免与标准库的std::array冲突
{
	// T即类型形参    N即非类型形参
    // 模板参数可以给缺省值,参考stack与queue的Container
	template<class T, size_t N = 10>
	class array
	{
	public:
		T& operator[](size_t index)
		{
			return _array[index]; // 返回index下标对应元素
		}
		const T& operator[](size_t index) const
		{
			return _array[index]; // 返回index下标对应元素
		}
		size_t size() const
		{
			return _size;
		}
		bool empty() const
		{
			return 0 == _size;
		}
	private:
		T _array[N]; // N可以作常量用,可用常量定义静态数组
		size_t _size = N; // 记录元素个数
	};
}

int main()
{
	const int M = 10; 
	int arr2[M]; // C静态数组需定义常量使用
	david::array<int, 10> arr; // array直接传参,通过非类型模板参数创建静态数组
	for (int i = 0; i < 10; i++)
	{
		arr[i] = i + 1;
		// 调用运算符重载operator[],同 arr.operator[](i)
		arr2[i] = i + 11;
	}
	for (int i = 0; i < arr.size(); i++)
	{
		std::cout << arr[i] << " ";
	}
	std::cout << std::endl;
	for (int i = 0; i < 10; i++)
	{
		std::cout << arr2[i] << " ";
	}
	std::cout << std::endl;
	return 0;
}

运行结果:

代码示例 1.2

cpp 复制代码
// 可以只用非类型形参作模板参数
template<size_t N = 10>
class array
{
public:
	int& operator[](size_t index)
	{
		return _array[index];
	}
	const int& operator[](size_t index) const
	{
		return _array[index];
	}
	size_t size() const
	{
		return _size;
	}
	bool empty() const
	{
		return 0 == _size;
	}
private:
	int _array[N];
	size_t _size = N;
};

// 需注意,C++20以前稍微老一些的C++版本,不支持下面写法
array a;
// 支持这样写
array<> a;
// 为保证版本兼容性,推荐第二种写法,都通用

N 是常量,可以被定义成不同值,本质还是利用了模板。编译器根据类模板生成了不同的类,该模板参数被替换成不同的常量。

非类型模板参数主要用途就是作为固定值,或作为常量定义数组大小等。但需要注意:

  • 非类型模板参数仅用于整型(short/unsigned short、int/unsigned int、long/unsigned long、long long/unsigned long long、size_t、char、bool等)。不可用于浮点数(float、double等,C++20才开始支持)/类对象/字符串(char*/const char*、string等)。
  • 非类型的模板参数必须在编译期就能确认结果(传参时尽量传常量)。

代码示例 1.3

cpp 复制代码
1. 必须是常量或常量表达式
template<int N>
class Array
{
    // ...
};
// 可以作非类型参数,10 是编译期已知的
Array<10> a1;              
// 可以作非类型参数,sizeof 在编译期就能算出来
Array<sizeof(int)> a2;     
// 可以作非类型参数,M也是常量
const int M = 20;
Array<M * 2> a3;           

2. 不能是运行时才能确定的变量
// 不可作非类型参数,x是普通变量,编译期无法确认x的值
int x = 10;
Array<x> a4;               

3. 函数参数也不行
// 不可作非类型参数,n是运行时的函数参数
void func(int n) 
{
    Array<n> a;            
}

2. 模板的特化

我们知道,模板是一些与类型无关的通用代码,编译器根据实参进行类型推导,根据模板生成具体的类或函数。但编译器生成的并不尽如人意,一些特殊类型可能得到错误结果。因此,需要用到模板的特化。即针对特殊情况,专门进行一些特殊化处理。

2.1 函数模板的特化

模板分为函数模板和类模板,首先是函数模板。需要说明,函数模板不太建议使用,因为有很多"坑",而且也有其他替代方案,但确实是种解决方式。

下面是示例,一个日期类,存储年月日,并实现了小于不等式的日期比较。

代码示例 2.1.1

cpp 复制代码
#include <iostream>
using namespace std;

class Date
{
public:
	Date(int y = 1900, int m = 1, int d = 1)
		: _year(y),
		_month(m),
		_day(d)
	{ }
	bool operator<(const Date& d) const
	{
		if (_year < d._year) 
			return true;
		if (_year == d._year && _month < d._month) 
			return true;
		if (_year == d._year && _month == d._month && _day < d._day) 
			return true;
		return false;
	}
private:
	int _year;  // 年
	int _month; // 月
	int _day;   // 日
};

// 函数模板 -- 参数匹配
template<class T>
bool Less(T left, T right)
{
	return (left < right); // 小于不等式
}

int main()
{
	cout << "Less(1, 2): " << Less(1, 2) << endl;   // 可以比较,结果正确
	Date d1(2022, 7, 7);
	Date d2(2022, 7, 8);
	cout << "Less(d1, d2): " << Less(d1, d2) << endl;  // 可以比较,结果正确
	Date* p1 = &d1;
	Date* p2 = &d2;
	cout << "Less(p1, p2): " << Less(p1, p2) << endl;  // 可以比较,结果错误
	cout << "Less(*p1, *p2): " << Less(*p1, *p2) << endl;
	return 0;
}

运行结果(Debug编译条件下):

第3条传的是 p1 和 p2 两个指针,根据模板的定义,编译器应自行完成类型推导,生成对应具体函数,函数内部需要对指针指向的内容进行比较,即具体日期,但结果显然出现了错误,与其他3条不一致。

此问题是模板典型的坑之一。编译器所生成的不符合用户所希望的。因为模板参数 T 被编译器认为是 int*,从而导致比较的是两日期类对象的地址,而非对象本身(栈向下增长,p2指针变量后定义,在不考虑编译优化的情况下,指针地址一般小于p1)。

解决该问题,除了像第4条一样老老实实解引用传对象本身,还可以对该函数模板进行特化 。即++在原模板的基础上,针对特殊类型所进行特殊化的实现方式++。

函数模板的特化步骤:

  1. 必须要先有一个基础的函数模板;
  2. 关键字 template 后接一对空的尖括号 <>;
  3. 函数名后跟一对尖括号 <>,尖括号 <> 中指定需要特化的类型;
  4. 函数形参列表必须和模板的基础参数类型相同,如果不同编译器可能会报一些奇怪的错误。

代码示例 2.1.2

cpp 复制代码
#include <iostream>
using namespace std;

class Date
{
public:
	Date(int y = 1900, int m = 1, int d = 1)
		: _year(y),
		_month(m),
		_day(d)
	{ }
	bool operator<(const Date& d) const
	{
		if (_year < d._year) 
			return true;
		if (_year == d._year && _month < d._month) 
			return true;
		if (_year == d._year && _month == d._month && _day < d._day) 
			return true;
		return false;
	}
private:
	int _year;  // 年
	int _month; // 月
	int _day;   // 日
};

// 对 Less 函数模板进行特化
template<class T>
bool Less(T left, T right) // 1、必须要先有一个基础的函数模板
{
	cout << "bool Less(T left, T right)" << endl;
	return (left < right);
}

template<> // 2、关键字 template 后接一对空的尖括号 <>
bool Less<Date*>(Date* left, Date* right) 
{
	// 3、函数名后跟一对尖括号 <>,尖括号 <> 中指定需要特化的类型
	// Less<Date*>
    // 4、函数形参列表必须和模板的基础参数类型相同
	// (T left, T right)与(Date* left, Date* right),T与Date*类型相对应
	cout << "bool Less<Date*>(Date* left, Date* right)" << endl;
	return *left < *right;
}

int main()
{
	cout << "Less(1, 2): " << Less(1, 2) << endl;
	Date d1(2022, 7, 7);
	Date d2(2022, 7, 8);
	cout << "Less(d1, d2): " << Less(d1, d2) << endl;
	// 现在结果正确,调用特化之后的版本,而不走模板生成了
	Date* p1 = &d1;
	Date* p2 = &d2;
	cout << "Less(p1, p2): " << Less(p1, p2) << endl;
	return 0;
}

运行结果:

相当于用 template<> 指明是模板的特化,函数名后的尖括号 <> 指明特殊处理的具体类型(Date*),凡该特殊类型(Date*)都走特化,特殊处理。

该特化的效果似乎不错,但仍然是上面的观点,不建议使用。实际上在模板初阶说过,模板是"有现成的用现成的,有更匹配的用更匹配的",完全可以通过下面这种方式实现同样的效果。

代码示例 2.1.3

cpp 复制代码
#include <iostream>
using namespace std;

class Date
{
public:
	Date(int y = 1900, int m = 1, int d = 1)
		: _year(y),
		_month(m),
		_day(d)
	{ }
	bool operator<(const Date& d) const
	{
		if (_year < d._year) 
			return true;
		if (_year == d._year && _month < d._month) 
			return true;
		if (_year == d._year && _month == d._month && _day < d._day) 
			return true;
		return false;
	}
private:
	int _year;  // 年
	int _month; // 月
	int _day;   // 日
};

// 函数模板
template<class T>
bool Less(T left, T right)
{
	cout << "bool Less(T left, T right)" << endl;
	return (left < right);
}

// 函数模板的特化
// template<>
// bool Less<Date*>(Date* left, Date* right)
// {
//	 cout << "bool Less<Date*>(Date* left, Date* right)" << endl;
//	 return (*left < *right);
// }

// 自行实现的具体类型的函数
bool Less(Date* left, Date* right) 
{
	cout << "bool Less(Date* left, Date* right)" << endl;
	return (*left < *right);
}

int main()
{
	cout << "Less(1, 2): " << Less(1, 2) << endl;
	Date d1(2022, 7, 7);
	Date d2(2022, 7, 8);
	cout << "Less(d1, d2): " << Less(d1, d2) << endl;
	Date* p1 = &d1;
	Date* p2 = &d2;
	cout << "Less(p1, p2): " << Less(p1, p2) << endl;
	return 0;
}

运行结果:

上面的特化无非是给模板指定具体类型,自行实现具体函数,编译器也会直接使用现成的函数,也算是特殊处理了,效果是差不多的,而且更加安全可靠。(注:当函数模板的特化和现成具体类型的函数同时存在时,仍然使用现成的)

关于不建议的理由,通过示例演示说明。

我们知道,模板作为通用代码,其模板参数 T 自然需要支持各种类型。类型一般分为内置类型 和自定义类型 。谈到自定义类型,就需要考虑拷贝 问题。T 为自定义类型,对象是否可能占用内存过大?是否可能涉及动态内存?是否实现了相应的拷贝构造、析构函数等?这类情况大有可能,一般情况下尽量避免拷贝,这时就必须考虑引用了(引用是为变量/对象起别名,不占用空间),同时为了防止对象在函数内部被随意修改,通常会加上 const 引用。

代码示例 2.1.4

cpp 复制代码
// 函数模板
template<class T>
bool Less(const T& left, const T& right)
{
	cout << "bool Less(T left, T right)" << endl;
	return (left < right);
}

// 函数模板的特化
template<>
bool Less<Date*>(const Date*& left, const Date*& right)
{
	cout << "bool Less<Date*>(Date* left, Date* right)" << endl;
	return (*left < *right);
}

C2912 中的"不是函数模板的专用化",意思是该函数不是该模板的特化。为什么?

模板中对形参进行 const 引用,T 为 Date*,正确的类型应该是 Date* const& left,对指针的常量引用 const&,引用的是指针变量 Date*,const 修饰的是引用本身(指针变量);const Date*& 的 const 修饰的是指针指向的内容,即 Date 对象,引用的是指向常量对象的指针,这是不同的类型。

注:实际上 (const T& left, const T& right) 也可以写成 (T const& left, T const& right),例如 const int a = 10 或 int const a = 10,const 在类型前或类型后都是可以的,这里将 const 放在类型后可能更明了一些。T 是 Date*,需要对该指针类型进行 const 引用。因此特化中正确写法应该是 Date* const& left。

分析半天分析清楚了上述复杂关系,填写了正确的类型,再来看下面示例。

代码示例 2.1.5

cpp 复制代码
#include <iostream>
using namespace std;

class Date
{
public:
	Date(int y = 1900, int m = 1, int d = 1)
		: _year(y),
		_month(m),
		_day(d)
	{ }
	bool operator<(const Date& d) const
	{
		if (_year < d._year) 
			return true;
		if (_year == d._year && _month < d._month) 
			return true;
		if (_year == d._year && _month == d._month && _day < d._day) 
			return true;
		return false;
	}
private:
	int _year;  // 年
	int _month; // 月
	int _day;   // 日
};

template<class T>
bool Less(const T& left, const T& right)
{
	cout << "bool Less(T left, T right)" << endl;
	return (left < right);
}

// 改写正确后的特化
template<>
bool Less<Date*>(Date* const& left, Date* const& right)
{
	cout << "bool Less<Date*>(Date* left, Date* right)" << endl;
	return (*left < *right);
}

int main()
{
	cout << "Less(1, 2): " << Less(1, 2) << endl;
	Date d1(2022, 7, 7);
	Date d2(2022, 7, 8);
	cout << "Less(d1, d2): " << Less(d1, d2) << endl;

	Date* p1 = &d1;
	Date* p2 = &d2;
	cout << "Less(p1, p2): " << Less(p1, p2) << endl;
    
    // 如果再将普通指针改为 const Date* 呢?
	const Date* p3 = &d1;
	const Date* p4 = &d2;
	cout << "Less(p3, p4): " << Less(p3, p4) << endl;
	return 0;
}

运行结果:

可以看到,第4条没有调用特化,仍然走的是模板生成,给出了错误结果。因为特化针对的是 Date*,不是 const Date*。其次,特化中形参类型是 Date* const&,指针类型仍是 Date*,const 修饰的是引用。const Date* 指针是不能传给 Date* 指针的,这涉及了权限放大的问题。因此需要实现 Date* 和 const Date* 两个特化才对。

注:Date* 指向的内容不被保护,可被任意修改或访问,而 const Date* 则保护了指针指向的内容,只可被访问不可被修改,若 const Date* 传给 Date*,权限将从只读变成可读可写,这种权限放大是不安全的,因此不被允许。Date* const 传给 Date* 则是允许的,因为 const 是向左结合的,左边为空才向右结合。Date* const 的 const 向左结合修饰的是 *,修饰的是指针变量本身,即指针的指向,而非指向的内容,指针变量的权限变为只读,内容的权限仍是可读可写,不受影响,用其他 Date* 指针指向该内容,不涉及内容的权限更改,自然可以。

代码示例 2.1.6

cpp 复制代码
#include <iostream>
using namespace std;

class Date
{
public:
	Date(int y = 1900, int m = 1, int d = 1)
		: _year(y),
		_month(m),
		_day(d)
	{
	}
	bool operator<(const Date& d) const
	{
		if (_year < d._year)
			return true;
		if (_year == d._year && _month < d._month)
			return true;
		if (_year == d._year && _month == d._month && _day < d._day)
			return true;
		return false;
	}
private:
	int _year;  // 年
	int _month; // 月
	int _day;   // 日
};

// 原有函数模板
template<class T>
bool Less(const T& left, const T& right)
{
	cout << "bool Less(T left, T right)" << endl;
	return (left < right);
}

// 改写正确后的Date*的特化
template<>
bool Less<Date*>(Date* const& left, Date* const& right)
{
	cout << "bool Less<Date*>(Date* const& left, Date* const& right)" << endl;
	return (*left < *right);
}

// const Date*的特化
template<>
bool Less<const Date*>(const Date* const& left, const Date* const& right)
{
	cout << "bool Less<const Date*>(const Date* const& left, const Date* const& right)" << endl;
	return (*left < *right);
}

int main()
{
	cout << "Less(1, 2): " << Less(1, 2) << endl;
	Date d1(2022, 7, 7);
	Date d2(2022, 7, 8);
	cout << "Less(d1, d2): " << Less(d1, d2) << endl;

	Date* p1 = &d1;
	Date* p2 = &d2;
	cout << "Less(p1, p2): " << Less(p1, p2) << endl;

	// const Date* 调用 const Date* 的特化
	const Date* p3 = &d1;
	const Date* p4 = &d2;
	cout << "Less(p3, p4): " << Less(p3, p4) << endl;
	// Date* const 调用 Date* 的特化
	Date* const p5 = &d1;
	Date* const p6 = &d2;
	cout << "Less(p5, p6): " << Less(p5, p6) << endl;
	return 0;
}

运行结果:

可以看到,分别针对 Date* 和 const Date* 实现两个特化之后,终于全部调用成功。

可见函数模板的通用性反而使得它需要非常严格的类型匹配。这种严格恰恰造就了函数模板的特化在参数类型匹配上的脆弱性,极易导致"写了特化但编译器不调用"的隐蔽Bug。因此,对于函数模板,建议优先选择重载 或函数对象/仿函数 。只建议对类模板进行特化。

代码示例 2.1.7

cpp 复制代码
// 推荐使用函数重载,而非函数模板的特化
// 原有函数模板
template<class T>
bool Less(const T& left, const T& right)
{
	cout << "bool Less(T left, T right)" << endl;
	return (left < right);
}

// 重载 Date* 类型的具体函数
bool Less(Date* left, Date* right)
{
	cout << "bool Less(Date* left, Date* right)" << endl;
	return (*left < *right);
}

// 重载 const Date* 类型的具体函数
bool Less(const Date* left, const Date* right)
{
	cout << "bool Less(const Date* left, const Date* right)" << endl;
	return (*left < *right);
}

2.2 类模板的特化

2.2.1 基础用法

缺省参数分为半缺省和全缺省,类模板的特化 有点类似,分为偏特化和全特化。

  • 全特化:将所有模板参数都确定化。
  • 偏特化/半特化:任何针对模版参数进一步进行条件限制设计的特化版本。

需注意,特化建立在原模板的基础之上,类模板也不例外。

代码示例 2.2.1

cpp 复制代码
#include <iostream>
using namespace std;

// 原类模板:两个模板参数 T1 T2
template<class T1, class T2>
class Data
{
public:
	Data() // 构造函数
	{
		cout << "Data<T1, T2>" << endl;
	}
private:
	T1 _d1;
	T2 _d2;
};

// 类模板的全特化:需对所有模板参数确定化,在类名之后指明所有参数具体类型
template<> // template后跟空的<>
class Data<int, char> // 指明T1、T2的具体类型
{
public:
	Data() // 构造函数
	{
		cout << "Data<int, char>" << endl;
	}
private:
	int _d1;
	char _d2;
};

// 类模板的偏特化/半特化:可对部分模板参数进行特化
template<class T1> // 只对T2进行特化,<>内只需要写T1即可
class Data<T1, double> // T2确定化为double
{
public:
	Data() // 构造函数
	{
		cout << "Data<T1, double>" << endl;
	}
private:
	T1 _d1;
	double _d2;
};

int main()
{
	Data<int, int> d1;     // 走类模板
	Data<int, char> d2;    // T1为int,T2为char,走全特化
	Data<int, double> d3;  // T2为double,走偏特化
	Data<char, double> d4; // T2为double,走偏特化
	return 0;
}

运行结果:

需注意,多个偏特化 之间会冲突,但偏特化和全特化 之间不会冲突。模板的原则依旧是"有现成的使用现成的",全特化更具体,确定化的参数更多,编译器优先选择全特化。总结一句话就是:有特化走特化,没有特化走模板;有全特化走全特化,没有全特化走偏特化,多个偏特化会报错。

例如 C2752,其中,部分专用化指的就是"偏特化",意思是多个偏特化的参数类型可匹配,编译器无法抉择调用哪个偏特化,发生调用冲突。

代码示例 2.2.2

cpp 复制代码
#include <iostream>
using namespace std;

// 原类模板
template<class T1, class T2>
class Data
{
public:
	Data() { cout << "Data<T1, T2>" << endl; }
private:
	T1 _d1;
	T2 _d2;
};

// 全特化Data<int, char>
template<>
class Data<int, char>
{
public:
	Data() { cout << "Data<int, char>" << endl; }
private:
	int _d1;
	char _d2;
};
// 偏特化Data<T1, char>,与全特化不冲突Data<int, char>,优先调用全特化
template<class T1>
class Data<T1, char>
{
public:
	Data() { cout << "Data<T1, char>" << endl; }
private:
	T1 _d1;
	char _d2;
};
// 偏特化Data<char, T2>,与偏特化冲突Data<T1, char>,报错
template<class T2>
class Data<char, T2>
{
public:
	Data() { cout << "Data<char, T2>" << endl; }
private:
	char _d1;
	T2 _d2;
};

int main()
{
	// 全特化Data<int, char>、偏特化Data<T1, char>、偏特化Data<char, T2>

    // 优先全特化Data<int, char>,与偏特化不冲突Data<T1, char>
	Data<int, char> d1;    
    // 走偏特化Data<T1, char>
	Data<double, char> d2; 
    // 走偏特化Data<char, T2>
	Data<char, int> d3;    
    // Data<T1, char>与Data<char, T2>冲突
	// Data<char, char> d4;   
	return 0;
}

运行结果:

2.2.2 更多偏特化用法

偏特化要求的是针对模版参数进一步进行条件限制 。除了对部分模板参数确定化,还有下面这些玩法。

代码示例 2.2.2.1

cpp 复制代码
// 对参数进一步限制
#include <iostream>
using namespace std;

// 原类模板
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<T1*, T2*> // 仅作出条件限制,类名之后指明模板参数为指针类型
{
public:
	Data() { cout << "Data<T1*, T2*>" << endl; }
private:
	T1 _d1;
	T2 _d2;
};
// 两个参数偏特化为引用类型
template <class T1, class T2>
class Data<T1&, T2&> // 指明模板参数都为引用类型
{
public:
	Data(const T1& d1, const T2& d2) // 构造函数:引用必须初始化
		:_d1(d1), _d2(d2)
	{
		cout << "Data<T1&, T2&>" << endl;
	}
private:
	const T1& _d1;
	const T2& _d2;
};
// 1个引用1个指针
template <class T1, class T2>
class Data<T1&, T2*>
{
public:
	Data(const T1& d1)
		:_d1(d1)
	{
		cout << "Data<T1&, T2*>" << endl;
	}
private:
	const T1& _d1;
	T2* _d2;
};

int main()
{
	Data<int, double> d1;      // 走模板
	Data<int*, int*> d2;       // 调用特化的指针版本
	Data<int&, int&> d3(1, 2); // 调用特化的引用版本
	Data<int&, int*> d4(1);    // 调用1个引用1个指针的特化
	return 0;
}

运行结果:

需要强调: 不管是指针版本的特化还是引用版本的特化,T1 和 T2 代表的是 int,而不是 int* 或 int&,方便灵活使用参数类型。

通常,当传给类模板的参数类型是指针或者引用时,就可以利用该特性进行特殊处理。例如前面函数模板的特化,我们提过两个替代方案,一个是重载 ,一个是使用函数对象 或者说仿函数:定义一个类,在其内部重载 () 运算符,让它表现得像一个函数。

代码示例 2.2.2.2

cpp 复制代码
// 日期类
class Date
{
public:
	Date(int y = 1900, int m = 1, int d = 1)
		: _year(y), _month(m), _day(d)
	{ }
	bool operator<(const Date& d) const
	{
		if (_year < d._year) 
			return true;
		if (_year == d._year && _month < d._month) 
			return true;
		if (_year == d._year && _month == d._month && _day < d._day) 
			return true;
		return false;
	}
private:
	int _year;  // 年
	int _month; // 月
	int _day;   // 日
};

// 原类模板(这个类同时也是仿函数,处理普通类型)
template<class T>
class Less
{
public:
    bool operator()(const T& left, const T& right) const
    {
        return left < right;
    }
};

// 类模板的偏特化(处理指针类型)
template<class T>
class Less<T*>
{
public:
    bool operator()(const T* left, const T* right) const
    {
        return *left < *right; 
    }
};

C++有一个术语叫重载决议(Overload Resolution)。所谓重载决议 指的就是编译器会先根据你传入的参数判断,是否存在具体函数,如果存在具体函数,就不会调用函数模板。而函数模板的特化,必须要编译器先完成重载决议 ,再进行特化匹配。

代码示例 2.2.2.3

cpp 复制代码
// 主模板
template<class T>
void print(T t) { std::cout << "主模板\n"; }

// 特化版本(针对 int*)
template<>
void print<int*>(int* t) { std::cout << "int* 特化\n"; }

// 如果正好有一个普通函数
void print(int* p) { std::cout << "普通函数\n"; }

int main() 
{
    int* p = nullptr;
    // 重载决议发生在特化匹配之前
    // 编译器先判断使用普通函数还是函数模板
    // 如果使用函数模板,再判断是否使用函数模板的特化
    print(p); // 特化匹配前就决定了使用普通函数
}

因此,编译器只会调用普通函数,特化版本永远不会被调用,而且编译器不会报任何警告。这就是所谓的**"写了特化但编译器不调用"**。

但类模板不存在重载决议,因为C++ 标准规定,同一作用域内,类模板的名字不能和普通类、变量、函数的名字相同 。语法层面就断绝了重载决议的坑。更何况模板不可能没有模板参数,函数可以不传参或者强制类型转换,但类模板的调用必须指明模板参数 Less<int> obj,且指定什么就是什么,普通类则是直接调用 Less obj。

3. 模板的分离编译问题

一个程序(项目)可能很大,不可能只写到一个文件,也不方便团队协作。项目通常由若干个源文件 共同实现,每个源文件单独编译生成目标文件 (Linux下.o后缀,Windows下.obj后缀),最后将所有目标文件链接起来形成单一的可执行文件 (Windows下.exe后缀,Linux下无后缀),这个过程称为分离编译模式。

C/C++ 程序编译的四个核心阶段:

  1. 预处理阶段:头文件展开/宏替换/条件编译/删除注释。得到 .i 中间文件。
  2. 编译阶段:进行语法分析和语义分析,得到中间代码,经过优化后生成汇编代码。得到 .s 中间文件。
  3. 汇编阶段:将汇编指令逐条翻译为二进制机器码,生成符号表(记录当前文件中定义的全局变量、函数名以及它们对应的内存偏移地址)。得到目标文件。
  4. 链接阶段:链接器将多个目标文件和库文件(静态库/动态库)合并,进行符号解析和重定位,把符号引用替换为真实的内存地址,最终生成可执行文件。

代码示例 3.1

头文件

cpp 复制代码
// 头文件 func.h
// 模板的声明
template<class T>
T Add(const T& left, const T& right);

// 普通函数的声明
void func(int left = 1, int right = 1);

源文件

cpp 复制代码
// 源文件 func.cpp
#include "iostream"

// 模板的定义
template<class T>
T Add(const T& left, const T& right)
{
    return left + right;
}

// 普通函数的定义
void func(int left, int right)
{
    std::cout << left << " " << right << std::endl;
    std::cout << "void func(int left, int right)" << std::endl;
}
cpp 复制代码
// 源文件 test.cpp
#include "func.h"

int main()
{
    Add(1, 2);
    Add(1.0, 2.0);
    func();
    return 0;
}

运行结果:

只调用普通函数

同时调用函数模板

图中报错是因为链接阶段符号解析失败,导致链接错误,错误信息提示无法识别 main 中相关符号 Add。

经过前两个阶段的预处理和编译,test.cpp 中头文件 func.h 展开但只能找到声明,定义在源文件 func.cpp 中。汇编阶段,test.cpp 中的 Add 和 func 填入符号表,符号只能对应占位符/相对偏移地址,因为此时仍未链接,各个文件仍然单独存在,找不到 Add 和 func 的定义,无法确认实际内存地址。到了链接阶段,合并多个目标文件,进行符号解析,需要查找 Add 和 func 的定义是否在其他文件。

于是,问题来了,为什么 func 能找到而 Add 找不到?按理说 func.c 中确实存在 Add 的定义。

代码示例 3.2

test.cpp(预编译,头文件展开后)

cpp 复制代码
// 有模板的声明,但没有定义
template<class T>
T Add(const T& left, const T& right);

void func(int left = 1, int right = 1);

int main()
{
    // 有模板的使用
    Add(1, 2);     // 传int类型,模板参数T为int
    Add(1.0, 2.0); // 传double类型,模板参数T为double
    func();
    return 0;
}

func.cpp

cpp 复制代码
#include "iostream"

// 有模板的声明,也有模板的定义,但没有模板的使用
template<class T>
T Add(const T& left, const T& right)
{
    return left + right;
}

void func(int left, int right)
{
    std::cout << left << " " << right << std::endl;
    std::cout << "void func(int left, int right)" << std::endl;
}

实际上,C++ 标准规定:模板的实例化必须在当前翻译单元(即当前 .cpp 文件)内完成。函数模板只有实例化后,生成具体函数语句,才会被翻译为汇编指令,才可以和普通函数一样拥有地址。

test.cpp 中有声明,有使用(模板参数T为int或double),但没有定义就无法进行模板的实例化,无法被翻译为汇编指令,无法拥有地址,符号解析自然失败。func.cpp 中有声明,有定义,却没有使用,也就无法实例化(模板参数T未知)。

当 test.cpp 编译时,编译器看到了 Add(1, 2),它知道需要生成一个 Add<int> 的函数,但它翻遍了当前文件也没找到函数体。它不会跑到 func.cpp 去找,因为各个文件在链接前是互相隔离的。

到了链接阶段,链接器只认二进制机器码和符号表中的符号地址 。但 func.cpp 虽然有模板的定义,却没有任何地方调用它,编译器压根就没给它生成任何机器码。链接器拿着一堆空气,自然没法把目标文件 test.o 里的符号和 func.o 对应起来。

有两种解决方法:

  1. 显式实例化;
  2. 不进行分离编译,模板的定义放进头文件(最常用)。

代码示例 3.3

func.cpp

cpp 复制代码
#include "iostream"

// 有模板的声明,也有模板的定义,但没有模板的使用
template<class T>
T Add(const T& left, const T& right)
{
    return left + right;
}

void func(int left, int right)
{
    std::cout << left << " " << right << std::endl;
    std::cout << "void func(int left, int right)" << std::endl;
}

// func.cpp 底部加上这两行
// 1、函数前加上template关键字,同时为了与模板的特化进行区分,不用加<>
// 2、函数名后加<>,同时在<>内指明具体类型
// 3、模板参数,如T,全部替换为具体类型
// 4、可以不显式写明形参,如left和right,此处不是调用,而是强制模板实例化该类型的函数
template int Add<int>(const int&, const int&);
template double Add<double>(const double&, const double&);
  1. 函数前加上 template 关键字,同时为了与模板的特化进行区分,不用加 <>;
  2. 函数名后加 <>,同时在 <> 内指明具体类型;
  3. 模板参数,如 T,全部替换为具体类型;
  4. 可以不显式写明形参,如 left 和 right,此处不是调用,只是用于强制模板实例化该类型的函数。

这样,即使 test.cpp 中仍然没有模板的实例化,但 func.cpp 中有了模板的实例化,在链接时,test.o 和 func.o 合并,test.o 需要 int 和 double 类型的实例化,符号表早已在 func.o 中找到 Add<int> 符号和 Add<double> 符号对应的地址。

当然,更推荐模板不进行分离编译。

代码示例 3.4

test.cpp

cpp 复制代码
#include "func.h"

int main()
{
    Add(1, 2);
    Add(1.0, 2.0);
    func();
    return 0;
}

func.h

cpp 复制代码
// 模板的声明定义不分离,直接将定义放入头文件
template<class T>
T Add(const T& left, const T& right)
{
    return left + right;
}

// 普通函数的声明
void func(int left = 1, int right = 1);
相关推荐
骑士雄师1 小时前
结构化思维》第一、二章 → prompt review skill 优化启发清单
笔记
bitbrowser1 小时前
Spotify网页版提示受保护内容未启用,解决思路分享?
经验分享·指纹浏览器
牛战士coding1 小时前
Java 对象写入 Redis 后类型变了?Jackson 多态序列化问题复盘
java·开发语言·redis
总线通信小百科1 小时前
100BASE-T1媒体转换器选型:接口、主从模式与供电怎么判断?
经验分享·测试工具
幼儿园蛋小黄1 小时前
嵌入式学习总结:从 I2C 到 SPI 的驱动开发实战
驱动开发·学习
Chasing__Dreams1 小时前
python杂识--24--Nuitak打包exe
开发语言·python
老当益壮梁奶奶1 小时前
驱动学习(二):从零开始:驱动工程搭建与字符设备驱动框架详解(基于IMX6ULL)
c语言·arm开发·驱动开发·嵌入式硬件·学习
yuniko-n1 小时前
【JUC】读写锁
java·开发语言
niucloud-admin1 小时前
NIUCLOUD LITE AI开发文档--基础开发规范
开发语言·php