函数模板
假设咱们现在要写一个 max 函数,它接受两个值,返回较大的那个。思路很直接——两行代码就能搞定。但如果咱们的程序里同时需要比较 int、double 和 std::string,那就要写三个版本:一个 max(int, int),一个 max(double, double),一个 max(std::string, std::string)。三个版本的逻辑完全一样,都是 (a > b) ? a : b,区别仅仅是参数类型不同。
这种"逻辑相同、类型不同"的重复代码,在实际项目里到处都是——排序、查找、交换、打印数组,几乎每个通用操作都会碰到。C++ 提供了一种机制,让咱们只写一次逻辑,编译器就能自动为不同类型生成对应的函数版本,这就是函数模板(function template)。从这一章开始,咱们正式进入 C++ 泛型编程的世界。
template<typename T>——泛型的起点
咱们先从最简单的例子入手,写一个泛型的 max_value 函数(之所以不叫 max,是因为 std::max 已经在标准库里了,直接同名容易在某些编译器上引起冲突——尤其是 Windows 上 <windows.h> 会定义一个 max 宏,那才是真正的血压拉满)。
template <typename T>
T max_value(T a, T b)
{
return (a > b) ? a : b;
}template <typename T> 告诉编译器:这是一个模板,T 是一个类型参数。紧跟其后的函数定义中,所有出现 T 的地方在实例化时都会被替换成实际类型。当咱们调用 max_value(3, 5) 时,编译器推导出 T 是 int,于是生成一个 int max_value(int, int) 的函数版本。调用 max_value(1.0, 2.0) 则生成 double max_value(double, double) 版本。整个过程对调用者来说是透明的。
typename 和 class 有什么区别
在模板参数列表里,typename 和 class 完全等价——template <typename T> 和 template <class T> 是一个意思,没有任何语义差异。早期 C++ 只支持 class 关键字,后来引入 typename,就是为了消除"T 必须是一个类"的误解。T 可以是任何类型——内置类型(int、double、指针)、自定义类,甚至函数指针都行。现代 C++ 风格更倾向用 typename,语义更准,读起来也清爽。
多个类型参数
有些场景下,一个类型参数不够用。比如咱们想写一个函数,把一种类型的值转成另一种:
template <typename Dest, typename Source>
Dest cast_to(Source value)
{
return static_cast<Dest>(value);
}模板参数的数量没有上限,但实际项目里超过两三个的情况不多见——每多一个类型参数,调用者要显式指定的可能性就更大,代码可读性也跟着下降。
模板实例化——编译器帮你"写代码"
模板本身并不是代码——它是一份"代码配方"。只有当您实际调用模板函数时,编译器才会根据调用参数的类型,把模板"展开"成一份具体的函数定义。这个过程叫做模板实例化(template instantiation)。(感觉有点像宏是不是?笔者没记错的话,它最初最初的定位真是这个!)
int x = max_value(3, 5); // T = int, 生成 int max_value(int, int)
double y = max_value(1.0, 2.0); // T = double, 生成 double max_value(double, double)上面两次调用,编译器生成了两个完全独立的函数。它们在编译后的二进制文件里各自存在,和手写两个重载函数的效果一样。这也是模板的核心代价——代码膨胀(code bloat)。如果您拿 20 种不同类型实例化同一个模板,编译器就会生成 20 份函数代码。对小型函数这不是问题,但对大型模板(比如某些 STL 算法的完整特化),代码体积可能明显增大。
隐式实例化 vs 显式实例化
上面那种"编译器根据调用参数自动推导类型并生成代码"的方式叫隐式实例化,也是最常见的方式。但有时咱们需要显式告诉编译器用哪个类型,这就是显式实例化:
int result = max_value<double>(3, 5.0); // 显式指定 T = double这里 3 是 int,5.0 是 double,两者类型不同,编译器没法把 T 同时推导成 int 和 double——这个推导冲突咱们在下一节详细聊。通过在函数名后面加 <double>,咱们显式指定了 T 的类型,编译器会把 3 隐式转换成 double,然后调用 max_value<double> 版本。
还有一种更少见的写法——显式实例化定义,它强制编译器在此处生成某个特定版本的代码,哪怕当前编译单元根本没用到它:
template int max_value<int>(int, int); // 显式实例化定义
template double max_value(double, double); // 同上,省略模板参数列表这种写法在库开发里偶尔会用到:把模板的实现放在 .cpp 文件中,然后显式实例化库需要导出的类型版本,这样用户代码就不需要看到模板实现了。不过在日常工作里,咱们几乎不需要手写显式实例化定义。
类型推导——编译器如何猜出 T
当调用 max_value(3, 5) 时,编译器看到参数 3 和 5 都是 int,于是推导 T = int。这个过程叫模板参数推导(template argument deduction)。推导发生在编译期,对运行时没有任何开销。
推导规则说起来很简单:每个模板参数都必须能被唯一确定。如果同一个 T 出现在多个参数里,那么这些参数的类型在去掉引用和顶层 const 之后必须完全一致,否则推导失败。
推导失败的典型场景
auto r = max_value(3, 5.0); // 编译错误!这段代码会直接报错。原因在于 3 的类型是 int,编译器推导出 T = int;5.0 的类型是 double,编译器推导出 T = double。同一个 T 没法同时等于 int 和 double,推导矛盾。
踩坑预警:模板推导失败时的报错信息通常非常长。编译器会列出它试过的所有重载和模板候选,然后告诉您"没有一个能匹配"。对新手来说,这种几十行的报错信息相当劝退。解决办法是定位报错信息的最后一行——那里通常会指出具体哪个参数的类型不匹配,然后从调用点往回推导,检查每个实参的类型是否一致。
解决推导冲突有三种方式。第一种是显式指定模板参数,就像咱们刚才看到的 max_value<double>(3, 5.0),强制 T = double,3 会被隐式转换。第二种是手动转换参数类型:max_value(static_cast<double>(3), 5.0)。第三种是修改模板本身,用两个独立的类型参数——不过这种做法要小心,咱们稍后讨论。
两个类型参数的陷阱
有人可能会想:既然 int 和 double 推导冲突,那就用两个类型参数好了。
template <typename T, typename U>
???.??? max_value_two(T a, U b)
{
return (a > b) ? a : b;
}问题出在返回类型上——如果 T 是 int,U 是 double,那返回值到底是 int 还是 double?用 auto 可以让编译器自己推导,(a > b) ? a : b 在 C++ 中遵循三目运算符的类型推导规则,int 和 double 会提升为 double,所以返回值是 double。但这只适用于简单情况,更复杂的场景下您可能需要 std::common_type_t<T, U> 来获取两个类型的公共类型:
template <typename T, typename U>
auto max_value_two(T a, U b) -> std::common_type_t<T, U>
{
return (a > b) ? a : b;
}std::common_type_t 定义在 <type_traits> 中,它会根据两个类型的隐式转换规则选出最合适的公共类型。不过说实话,日常使用中碰到混合类型比较,最简单的方式还是显式指定一种类型或者手动 cast,用不着搞这么复杂。
模板特化——当通用方案不合适时
咱们写的 max_value 对大多数类型都工作正常,但对于 const char*(C 风格字符串),它会比较两个指针的地址,而不是字符串内容。这种行为显然不是咱们想要的。
模板特化(template specialization)允许咱们为某个特定类型提供一个专门的实现:
// 通用模板
template <typename T>
T max_value(T a, T b)
{
return (a > b) ? a : b;
}
// const char* 的特化版本
template <>
const char* max_value<const char*>(const char* a, const char* b)
{
return (std::strcmp(a, b) > 0) ? a : b;
}template <> 表示这是一个完全特化——所有模板参数都已确定。当调用 max_value("hello", "world") 时,如果编译器推导出 T = const char*,它会优先使用特化版本而不是通用版本。
特化是个比较大的话题,涉及偏特化、SFINAE、concept 约束等内容。这里咱们只需要知道它的存在和基本语法就够了——后面在类模板那一章会深入讨论。
函数重载 vs 模板——什么时候用哪个
函数重载和函数模板都能实现"同名函数处理不同类型",但机制完全不同。函数重载是手动为每种类型写一个版本,编译器根据参数类型选择最匹配的那个。函数模板是写一个通用"配方",编译器根据调用自动生成对应版本。
选择的原则其实很直觉:如果所有类型的处理逻辑完全一样,只是类型不同,那就用模板,一个 max_value 模板比 20 个手写重载函数干净得多。如果不同类型的处理逻辑有本质差异(比如 print(int) 直接输出数字,print(std::string) 需要加引号),那就用重载,每个版本的逻辑独立又清晰。
混合使用时的重载解析
模板和重载可以同时存在,编译器有一套确定的重载解析规则:先把所有候选函数收集起来(普通重载、以及模板推导成功后生成的特化版本),再按类型匹配的精确度排序,选最匹配的那个。要是几个候选匹配度打平,通常会报二义性错误——但有个重要例外:当打平的是「非模板重载」和「模板特化」时,非模板重载优先,不会二义。下面这个例子就能看到这条规则。
template <typename T>
T max_value(T a, T b)
{
return (a > b) ? a : b;
}
// 普通重载:int 版本
int max_value(int a, int b)
{
std::cout << "int overload\n";
return (a > b) ? a : b;
}
int main()
{
max_value(3, 5); // 调用普通重载(精确匹配优先于模板)
max_value(1.0, 2.0); // 调用模板实例化(double 无重载版本)
max_value<>(3, 5); // 强制使用模板,跳过普通重载
}上面的例子正好演示了这条规则:max_value(3, 5) 两个候选都精确匹配,非模板重载赢;max_value(1.0, 2.0) 只有模板能匹配,走模板;想强制用模板,加空的尖括号 max_value<>(3, 5)。
踩坑预警:混合使用重载和模板时,最容易踩的坑是模板推导的「隐形失败」。假设您写了一个模板
template <typename T> T max_value(T, T)和一个重载double max_value(double, int),然后调用max_value(1.0, 2)。直觉上您可能担心二义,但实际跑一下:编译通过,正常返回2,根本没有二义。原因是模板参数推导不会为了让T统一而对参数做隐式转换,所以1.0推出T = double、2推出T = int,两者冲突,模板推导直接失败,连候选都进不去;最后只剩重载max_value(double, int)精确匹配,调它。真正的坑在后面:这行能编译,全靠重载兜底——哪天您重构删掉了这个重载,同一行会从「正常工作」变成「推导冲突、编译报错」,报错信息动辄几十行。所以混用模板和重载时,尽量保持接口简洁,用了模板就别再为同一套接口加参数类型只有微妙差异的重载。踩坑预警:另一个常见的坑是模板和 C 风格字符串的交互。调用
max_value("hello", "world")时,T被推导为const char*。如果您没为const char*写特化版本,比较的是指针地址而不是字符串内容,结果完全取决于字符串在内存中的位置——可能每次运行都不一样,而且几乎肯定不是您期望的结果。
实战演练——func_template.cpp
现在咱们把前面学的知识综合起来,写一个完整的示例程序。它包含泛型的 max_value、swap_value 和 print_array 三个函数,分别用 int、double 和 std::string 实例化。
展开代码 (共 99 行)收起代码
// func_template.cpp
// 编译: g++ -Wall -Wextra -std=c++17 func_template.cpp -o func_template
#include <cstring>
#include <iostream>
#include <string>
// ============================================================
// max_value:返回两个值中较大的一个
// ============================================================
template <typename T>
T max_value(T a, T b)
{
return (a > b) ? a : b;
}
// const char* 特化:按字典序比较字符串内容
template <>
const char* max_value<const char*>(const char* a, const char* b)
{
return (std::strcmp(a, b) > 0) ? a : b;
}
// ============================================================
// swap_value:交换两个值
// ============================================================
template <typename T>
void swap_value(T& a, T& b)
{
T temp = a;
a = b;
b = temp;
}
// ============================================================
// print_array:打印数组内容
// ============================================================
template <typename T, std::size_t kSize>
void print_array(const T (&arr)[kSize])
{
std::cout << "[";
for (std::size_t i = 0; i < kSize; ++i) {
std::cout << arr[i];
if (i + 1 < kSize) {
std::cout << ", ";
}
}
std::cout << "]";
}
// ============================================================
// main
// ============================================================
int main()
{
// --- max_value ---
std::cout << "=== max_value ===\n";
std::cout << "max_value(3, 7) = " << max_value(3, 7) << "\n";
std::cout << "max_value(2.5, 1.3) = " << max_value(2.5, 1.3)
<< "\n";
std::cout << "max_value(\"banana\", \"apple\") = "
<< max_value("banana", "apple") << "\n";
// 显式实例化:混合类型
std::cout << "max_value<double>(3, 5.7) = "
<< max_value<double>(3, 5.7) << "\n";
// --- swap_value ---
std::cout << "\n=== swap_value ===\n";
int a = 10, b = 20;
std::cout << "before: a=" << a << ", b=" << b << "\n";
swap_value(a, b);
std::cout << "after: a=" << a << ", b=" << b << "\n";
double x = 1.5, y = 2.5;
std::cout << "before: x=" << x << ", y=" << y << "\n";
swap_value(x, y);
std::cout << "after: x=" << x << ", y=" << y << "\n";
std::string s1 = "hello", s2 = "world";
std::cout << "before: s1=\"" << s1 << "\", s2=\"" << s2 << "\"\n";
swap_value(s1, s2);
std::cout << "after: s1=\"" << s1 << "\", s2=\"" << s2 << "\"\n";
// --- print_array ---
std::cout << "\n=== print_array ===\n";
int nums[] = {3, 1, 4, 1, 5, 9};
std::cout << "int[]: ";
print_array(nums);
std::cout << "\n";
double vals[] = {1.1, 2.2, 3.3};
std::cout << "double[]: ";
print_array(vals);
std::cout << "\n";
std::string names[] = {"Alice", "Bob", "Charlie"};
std::cout << "string[]: ";
print_array(names);
std::cout << "\n";
return 0;
}拆解几个关键点。print_array 用了数组引用参数 const T (&arr)[kSize],这不仅让编译器能推导出数组元素的类型 T,还能推导出数组长度 kSize,这样就不用额外传一个长度参数了。
swap_value 的参数是引用 T&,这样才能改到调用者的变量。如果参数是 T a, T b 的按值传递,交换的只是副本,调用者完全无感。
验证运行
g++ -Wall -Wextra -std=c++17 func_template.cpp -o func_template && ./func_template预期输出:
=== max_value ===
max_value(3, 7) = 7
max_value(2.5, 1.3) = 2.5
max_value("banana", "apple") = banana
max_value<double>(3, 5.7) = 5.7
=== swap_value ===
before: a=10, b=20
after: a=20, b=10
before: x=1.5, y=2.5
after: x=2.5, y=1.5
before: s1="hello", s2="world"
after: s1="world", s2="hello"
=== print_array ===
int[]: [3, 1, 4, 1, 5, 9]
double[]: [1.1, 2.2, 3.3]
string[]: [Alice, Bob, Charlie]核对几个关键结果:max_value(3, 7) 正确返回 7;max_value("banana", "apple") 走的是 const char* 特化版本,按字典序比较,"banana" 大于 "apple" 所以返回 "banana";swap_value 交换前后值正确互换;print_array 正确打印了三种不同类型数组的内容,且没有多余的尾部逗号。
练习
练习 1:泛型查找
实现一个泛型函数 find_index,在数组中查找某个值,返回其下标;如果没找到,返回 -1。函数签名大致为:
template <typename T, std::size_t kSize>
int find_index(const T (&arr)[kSize], const T& target);要求用 int、double、std::string 三种类型分别测试。思考:如果 T 是自定义类,这个函数能正常工作吗?自定义类需要满足什么条件?
练习 2:泛型排序
实现一个简单的泛型冒泡排序函数 bubble_sort,对数组进行原地排序。不需要自己实现比较逻辑——直接使用 operator> 或 operator<。要求能对 int、double、std::string 数组分别排序并打印结果。
练习 3:泛型累加器
实现一个泛型函数 accumulate_all,计算数组中所有元素的总和。思考返回类型的问题:如果数组元素是 int,总和可能超出 int 范围,该怎么处理?提示:可以添加一个模板参数作为累加器的类型。