Skip to content

函数模板

假设我们现在要写一个 max 函数,它接受两个值,返回较大的那个。思路很直接——两行代码就能搞定。但如果我们的程序里同时需要比较 intdoublestd::string,那就要写三个版本:一个 max(int, int),一个 max(double, double),一个 max(std::string, std::string)。三个版本的逻辑完全一样,都是 (a > b) ? a : b,区别仅仅是参数类型不同。

这种"逻辑相同、类型不同"的重复代码,在实际项目中到处都是——排序、查找、交换、打印数组,几乎每个通用操作都会遇到。C++ 提供了一种机制让我们只写一次逻辑,然后由编译器自动为不同类型生成对应的函数版本,这就是函数模板(function template)。从这一章开始,我们正式进入 C++ 泛型编程的世界。

学习目标

完成本章后,你将能够:

  • [ ] 使用 template<typename T> 语法编写泛型函数
  • [ ] 理解模板实例化机制——隐式实例化与显式实例化的区别
  • [ ] 掌握类型推导规则,知道什么情况下推导会失败以及如何解决
  • [ ] 了解模板特化的基本概念
  • [ ] 在函数重载和模板之间做出合理选择

template<typename T>——泛型的起点

我们先从最简单的例子入手,写一个泛型的 max_value 函数(之所以不叫 max,是因为 std::max 已经存在于标准库中,直接同名容易在某些编译器上引起冲突——尤其是 Windows 上 <windows.h> 会定义一个 max 宏,那才是真正的血压拉满)。

cpp
template <typename T>
T max_value(T a, T b)
{
    return (a > b) ? a : b;
}

template <typename T> 告诉编译器:这是一个模板,T 是一个类型参数。紧跟其后的函数定义中,所有出现 T 的地方在实例化时都会被替换为实际的类型。当我们调用 max_value(3, 5) 时,编译器推导出 Tint,于是生成一个 int max_value(int, int) 的函数版本。调用 max_value(1.0, 2.0) 则生成 double max_value(double, double) 版本。整个过程对调用者来说是透明的。

typename 和 class 有什么区别

在模板参数列表里,typenameclass 是完全等价的——template <typename T>template <class T> 表达的是同一个意思,没有任何语义差异。早期 C++ 只支持 class 关键字,后来引入 typename 是为了消除"T 必须是一个类"的误解。T 可以是任何类型——内置类型(intdouble、指针)、自定义类、甚至函数指针都可以。现代 C++ 风格更倾向于用 typename,语义更准确,读起来也更清晰。

多个类型参数

有些场景下,一个类型参数不够用。比如我们想写一个函数,把一种类型的值转换为另一种类型:

cpp
template <typename Dest, typename Source>
Dest cast_to(Source value)
{
    return static_cast<Dest>(value);
}

模板参数的数量没有上限,但实际项目中超过两三个的情况比较少见——每多一个类型参数,调用者需要显式指定的可能性就更大,代码的可读性也会下降。

模板实例化——编译器帮你"写代码"

模板本身并不是代码——它是一份"代码配方"。只有当你实际调用模板函数时,编译器才会根据调用参数的类型,把模板"展开"成一份具体的函数定义。这个过程叫做模板实例化(template instantiation)。(感觉有点像宏是不是,笔者没记错的话,它最初最初的定位真是这个!)

cpp
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 显式实例化

上面那种"编译器根据调用参数自动推导类型并生成代码"的方式叫做隐式实例化,也是最常见的方式。但有时我们需要显式告诉编译器用什么类型,这就是显式实例化:

cpp
int result = max_value<double>(3, 5.0);  // 显式指定 T = double

这里 3int5.0double,两者类型不同,编译器无法把 T 同时推导为 intdouble——这个推导冲突我们在下一节详细讨论。通过在函数名后面加 <double>,我们显式指定了 T 的类型,编译器会将 3 隐式转换为 double,然后调用 max_value<double> 版本。

还有一种更少见的写法——显式实例化定义,它强制编译器在此处生成某个特定版本的代码,即使当前编译单元没有用到它:

cpp
template int max_value<int>(int, int);           // 显式实例化定义
template double max_value(double, double);       // 同上,省略模板参数列表

这种写法在库开发中偶尔会用到:把模板的实现放在 .cpp 文件中,然后显式实例化库需要导出的类型版本,这样用户代码就不需要看到模板的实现了。不过在日常应用开发中,我们几乎不需要手写显式实例化定义。

类型推导——编译器如何猜出 T

当调用 max_value(3, 5) 时,编译器看到参数 35 都是 int,于是推导 T = int。这个过程叫做模板参数推导(template argument deduction)。推导发生在编译期,对运行时没有任何开销。

推导的规则说起来很简单:每个模板参数都必须能被唯一确定。如果同一个 T 出现在多个参数中,那么这些参数的类型在去掉引用和顶层 const 之后必须完全一致,否则推导失败。

推导失败的典型场景

cpp
auto r = max_value(3, 5.0);  // 编译错误!

这段代码会直接报错。原因在于 3 的类型是 int,编译器推导出 T = int5.0 的类型是 double,编译器推导出 T = double。同一个 T 不能同时等于 intdouble,推导矛盾。

踩坑预警:模板推导失败时的报错信息通常非常长。编译器会列出它尝试过的所有重载和模板候选,然后告诉你"没有一个能匹配"。对于新手来说,这种几十行的报错信息相当劝退。解决方法是定位报错信息的最后一行——那里通常会指出具体哪个参数的类型不匹配。然后从调用点往回推导,检查每个实参的类型是否一致。

解决推导冲突有三种方式。第一种是显式指定模板参数,就像我们刚才看到的 max_value<double>(3, 5.0),强制 T = double3 会被隐式转换。第二种是手动转换参数类型:max_value(static_cast<double>(3), 5.0)。第三种是修改模板本身,使用两个独立的类型参数——不过这种做法要小心,我们稍后讨论。

两个类型参数的陷阱

有人可能会想:既然 intdouble 推导冲突,那就用两个类型参数好了。

cpp
template <typename T, typename U>
???.??? max_value_two(T a, U b)
{
    return (a > b) ? a : b;
}

问题出在返回类型上——如果 TintUdouble,那返回值到底是 int 还是 double?用 auto 可以让编译器自己推导,(a > b) ? a : b 在 C++ 中遵循三目运算符的类型推导规则,intdouble 会提升为 double,所以返回值是 double。但这只适用于简单情况,更复杂的场景下你可能需要 std::common_type_t<T, U> 来获取两个类型的公共类型:

cpp
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)允许我们为某个特定类型提供一个专门的实现:

cpp
// 通用模板
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) 需要加引号——那就用重载,每个版本的逻辑独立且清晰。

混合使用时的重载解析

模板和重载可以同时存在,编译器有一套确定的重载解析规则:首先收集所有候选函数(包括普通重载和模板实例化后的版本),然后根据类型匹配的精确度进行排序,选择最匹配的那个。如果有多个候选匹配度相同,就产生二义性错误。

cpp
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<>(...)

踩坑预警:混合使用重载和模板时,最容易踩的坑是二义性。假设你写了一个模板 template <typename T> T max_value(T, T) 和一个重载 double max_value(double, int),然后调用 max_value(1.0, 2)——编译器会发现模板可以推导为 T = double(第二个参数 2 隐式转换为 double),而重载版本也是精确匹配(doubleint),两者匹配度差不多,于是报二义性错误。解决办法是尽量保持接口简洁——如果用了模板,就不要再为同一套接口添加参数类型有微妙差异的重载。

踩坑预警:另一个常见的坑是模板和 C 风格字符串的交互。调用 max_value("hello", "world") 时,T 被推导为 const char*。如果你没有为 const char* 写特化版本,比较的是指针地址而不是字符串内容,结果完全取决于字符串在内存中的位置——可能每次运行都不一样,而且几乎肯定不是你期望的结果。

实战演练——func_template.cpp

现在我们把前面学的所有知识综合起来,写一个完整的示例程序。包含泛型的 max_valueswap_valueprint_array 三个函数,分别用 intdoublestd::string 实例化。

cpp
// 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 的按值传递,交换的只是副本,调用者完全无感。

验证运行

bash
g++ -Wall -Wextra -std=c++17 func_template.cpp -o func_template && ./func_template

预期输出:

text
=== 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) 正确返回 7max_value("banana", "apple") 走的是 const char* 特化版本,按字典序比较,"banana" 大于 "apple" 所以返回 "banana"swap_value 交换前后值正确互换;print_array 正确打印了三种不同类型数组的内容,且没有多余的尾部逗号。

练习

练习 1:泛型查找

实现一个泛型函数 find_index,在数组中查找某个值,返回其下标;如果没找到,返回 -1。函数签名大致为:

cpp
template <typename T, std::size_t kSize>
int find_index(const T (&arr)[kSize], const T& target);

要求用 intdoublestd::string 三种类型分别测试。思考:如果 T 是自定义类,这个函数能正常工作吗?自定义类需要满足什么条件?

练习 2:泛型排序

实现一个简单的泛型冒泡排序函数 bubble_sort,对数组进行原地排序。不需要自己实现比较逻辑——直接使用 operator>operator<。要求能对 intdoublestd::string 数组分别排序并打印结果。

练习 3:泛型累加器

实现一个泛型函数 accumulate_all,计算数组中所有元素的总和。思考返回类型的问题:如果数组元素是 int,总和可能超出 int 范围,该怎么处理?提示:可以添加一个模板参数作为累加器的类型。

小结

这一章我们学习了 C++ 函数模板的核心机制。template <typename T> 让我们只写一次逻辑,编译器会根据调用自动为不同类型生成对应的函数版本。模板实例化发生在编译期,没有运行时开销,但会产生代码膨胀。类型推导要求同一模板参数在所有出现的位置推导出相同类型,否则推导失败——此时可以用显式模板参数、类型转换或多个类型参数来解决。模板特化允许我们为特定类型提供专门的实现,弥补通用方案的不足。

几个关键经验:typenameclass 在模板参数列表中等价,但 typename 语义更清晰;混合使用重载和模板时要注意二义性;const char* 比较的是指针地址而非字符串内容,要么写特化要么用 std::string

下一章我们进入类模板——把泛型的能力从函数扩展到整个类。函数模板让我们写"类型无关的函数",类模板则让我们写"类型无关的类"。容器(vectormap)、智能指针(unique_ptrshared_ptr)、甚至 std::string 本质上都是类模板。理解了函数模板,学类模板会顺利很多——核心思想是一样的,只是作用范围从函数扩大到了类。

基于 VitePress 构建