Skip to content

函数模板:同一份逻辑,别复制三遍 ​

总算,我们可以开始正式的入门函数模板了。简单的复习一下:类型本身在变的时候,继承帮不上忙,把类型当参数交给编译器就行。

说是这样说,但是好像还没落地实际,对吧。那好。咱们就从模板的最简单的一种使用入手,也就是大名鼎鼎的函数模板,开始!

无需假设,您现在就是干!起手写一个 max_value 函数吧,笔者要求,它接受两个值,返回较大的那个。

插一个缓冲,您顿一下,思考一下这个问题,判断是不是用模板解决,最为合理呢?

第二个缓冲是:之所以不叫 max,是因为 std::max 已经在标准库里了,直接同名容易在某些编译器上引起冲突——尤其是 Windows 上 <windows.h> 会定义一个 max 宏,那才是真正的血压拉满。之前被耍过,这也是为什么笔者真的很不喜欢Windows。越举的事情,太多!

其实思路很直接,两行代码就能搞定。但如果咱们的程序里同时需要比较 int、double 和 std::string,那就要写三个版本:一个 max_value(int, int),一个 max_value(double, double),一个 max_value(std::string, std::string)。三个版本的逻辑完全一样,都是 (a > b) ? a : b,区别仅仅是参数类型不同。合理性非常必要了,来走起!

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

其实并不困难:

C++
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 有什么区别 ​

欸!会有一些老一点的教材是写template<class T>的写法的,我可以用最直白,最不绕弯子,最一阵见血的方式告诉你:在模板参数列表里,typename 和 class 完全等价!

template <typename T> 和 template <class T> 是一个意思,没有任何语义差异。早期 C++ 只支持 class 关键字,后来才引入 typename,就是为了消除"T 必须是一个类"的误解(甚至之后,用来强调咱们之后写的一大堆东西是一个类型,编译器不要插手认为他是个变量!)。

T 可以是任何类型,内置类型(int、double、指针)、自定义类,甚至函数指针都行。现代 C++ 风格更倾向用 typename,咱们读起来语义更准,也清爽。

多个类型参数 ​

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

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

模板参数的数量没有上限,但咱们在实际项目里超过两三个的情况不多见:每多一个类型参数,调用者要显式指定的可能性就更大,代码可读性也跟着下降。

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

模板本身并不是代码,它是一份"代码配方"。 你告诉他咋做,他给您搞个大的!

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

C++
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),嵌入式的朋友就需要尤其注意这个问题。因为不恰当或者是意外的实例化很容易导致您的代码烧不上Flash了。

隐式实例化 vs 显式实例化 ​

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

C++
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> 版本。

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

C++
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 之后必须完全一致,否则推导失败。

推导失败的典型场景 ​

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

咱们看这段代码会直接报错。原因在于 3 的类型是 int,编译器推导出 T = int;5.0 的类型是 double,编译器推导出 T = double。同一个 T 没法同时等于 int 和 double,推导矛盾。

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

clang,我记得对这里的报错处理就非常的优雅,gcc 不知道是否改进,之前出错一点,代码你就别想读了。当然concept的出现,让我们的报错可以更加舒服,直接告诉你不符合某个概念,从编译器的可读性提升到了语法指示的可读性。我认为是不错的,也是咱们后续更加激进的现代C++的一个重要的阐述。

解决推导冲突有三种方式。

第一种是显式指定模板参数,就像咱们刚才看到的 max_value<double>(3, 5.0),强制 T = double,3 会被隐式转换。

第二种是手动转换参数类型:max_value(static_cast<double>(3), 5.0)。

第三种是修改模板本身,用两个独立的类型参数——不过这种做法要小心,咱们稍后讨论。

两个类型参数的陷阱 ​

咱们可能会想:既然 int 和 double 推导冲突,那就用两个类型参数好了。

C++
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> 来获取两个类型的公共类型:

C++
template <typename T, typename U>
auto max_value_two(T a, U b) -> std::common_type_t<T, U> // 是的,common就是共有的意思,可以被一致处理的类型
{
    return (a > b) ? a : b;
}

std::common_type_t 定义在 <type_traits> 中,它会根据两个类型的隐式转换规则选出最合适的公共类型。不过咱们日常使用中碰到混合类型比较,最简单的方式还是显式指定一种类型或者手动 cast,用不着搞这么复杂。

模板特化——当通用方案不合适时 ​

咱们写的 max_value 对大多数类型都工作正常,但对于 const char*(C 风格字符串),它会比较两个指针的地址,而不是字符串内容。我相信大伙对比较字符串所在的地址半毛钱兴趣没有,肯定不是我们想要的!

所以,这就需要我们请出来**模板特化(template specialization)**这个概念了,为什么?因为他允许咱们为某个特定类型提供一个专门的实现。

C++
// 通用模板
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; // 编译器在注意到您传递了原生的const char*的时候,走这里
}

template <> 表示这是一个完全特化,所有模板参数都已确定。咱们调用 max_value("hello", "world") 时,如果编译器推导出 T = const char*,它会优先使用特化版本而不是通用版本。

特化是个比较大的话题,涉及偏特化、SFINAE、concept 约束等内容。这里咱们只需要知道它的存在和基本语法就够了,后面在类模板那一章会深入讨论。

函数重载 vs 模板——什么时候用哪个 ​

函数重载和函数模板都能实现"同名函数处理不同类型",但机制完全不同。函数重载是咱们手动为每种类型写一个版本,编译器根据参数类型选择最匹配的那个。函数模板是写一个通用"配方",编译器根据调用自动生成对应版本。

选择的原则其实很直觉:如果所有类型的处理逻辑完全一样,只是类型不同,那就用模板,一个 max_value 模板比 20 个手写重载函数干净得多。如果不同类型的处理逻辑有本质差异(比如 print(int) 直接输出数字,print(std::string) 需要加引号),那就用重载,每个版本的逻辑咱们写起来独立又清晰。

混合使用时的重载解析 ​

模板和重载可以同时存在,咱们看编译器有一套确定的重载解析规则:先把所有候选函数收集起来(普通重载、以及模板推导成功后生成的特化版本),再按类型匹配的精确度排序,选最匹配的那个。要是几个候选匹配度打平,通常会报二义性错误——但有个重要例外:当打平的是"非模板重载"和"模板特化"时,非模板重载优先,不会二义。下面这个例子就能看到这条规则。

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

验证运行 ​

这份完整代码就在下面,点「动手试一试」直接跑,不用开终端:

Compiler Explorer

实战演练:func_template.cpp

在线运行 func_template.cpp,对照下面的核对点。试着给 print_array 再喂一个别的类型的数组,或者删掉 const char* 特化看看会发生什么。

code/examples/vol1/17_function_templates.cpp

咱们核对几个关键结果:max_value(3, 7) 正确返回 7;max_value("banana", "apple") 走的是 const char* 特化版本,按字典序比较,"banana" 大于 "apple" 所以返回 "banana";swap_value 交换前后值正确互换;print_array 正确打印了三种不同类型数组的内容,且没有多余的尾部逗号。

练习 ​

练习 1:泛型查找 ​

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

C++
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 范围,该怎么处理?提示:可以添加一个模板参数作为累加器的类型。

pdf-latest-4-g85128cc · 85128cc · 2026-10-05