C++ 模板函数

7 阅读9分钟

C++ 模板函数 (Function Templates) 是 C++ 泛型编程 (Generic Programming) 的基石。

核心思想: 模板函数允许您编写一个“蓝图”或“配方”,这个蓝图可以用来自动生成针对不同数据类型的函数版本。

这解决了 C++ 作为强类型语言的一个痛点:代码复用。


1. 为什么需要模板函数?(The Problem)

假设您想写一个函数,返回两个数中较大的一个。在 C++ 中,您必须为每种类型都写一个重载:

C++

// 针对 int
int max(int a, int b) {
    return (a > b) ? a : b;
}

// 针对 double
double max(double a, double b) {
    return (a > b) ? a : b;
}

// 针对 std::string
std::string max(const std::string& a, const std::string& b) {
    return (a > b) ? a : b;
}

这些函数的逻辑完全一样,但因为类型不同,您被迫复制粘贴了三次代码。这违反了 "DRY" (Don't Repeat Yourself) 原则,维护起来非常困难。


2. 如何定义模板函数?(The Solution)

模板函数允许您只写一次这个“蓝图”。

C++

#include <iostream>
#include <string>

// 1. 模板声明
// "template <typename T>" 告诉编译器:
// “T”是一个占位符,它代表一个“类型”,具体是什么类型由调用者决定。
// (注意: "typename" 和 "class" 在这里是完全等价的)
template <typename T>
// 2. 模板函数定义
// 函数签名和函数体都使用 T,就像 T 是一个已知类型一样
T my_max(T a, T b) {
    return (a > b) ? a : b;
}

int main() {
    // 3. 模板的调用
    std::cout << "Int: " << my_max(10, 20) << std::endl;
    std::cout << "Double: " << my_max(3.14, 2.71) << std::endl;
    std::cout << "String: " << my_max(std::string("hello"), std::string("world")) << std::endl;
    
    // my_max("hello", "world") 不会按预期工作,我们稍后会讲
}

输出:

Int: 20
Double: 3.14
String: world

3. 模板是如何工作的:实例化与推导

这是理解模板最关键的一步。您写的 my_max 模板本身不是一个函数,它只是一个“配方”。

A. 模板实例化 (Instantiation)

当编译器在 main 函数中看到 my_max(10, 20) 时,它会执行以下操作:

  1. 模板参数推导 (Deduction): 编译器分析参数。10int20 也是 int。因此,编译器推导出:T 必须是 int

  2. 函数实例化 (Instantiation): 编译器“激活”蓝图。它自动在内部生成(并编译)一个全新的、具体的函数,就像您手动写的一样:

    C++

    // 编译器在内部自动生成的代码:
    int my_max_for_int(int a, int b) {
        return (a > b) ? a : b;
    }
    
  3. 调用: 编译器将 my_max(10, 20) 替换为对这个新生成的 my_max_for_int 的调用。

同理,当编译器看到 my_max(3.14, 2.71),它推导出 Tdouble,并生成 double 版本的函数。

重点: 这一切都发生在编译时。最终的可执行文件中包含了多个 my_max 版本(一个 int 版,一个 double 版...)。模板不会带来任何运行时开销。

B. 模板参数推导的“陷阱”

模板参数推导必须是精确且无歧义的。

C++

// 编译错误!
my_max(10, 20.5); 

为什么?

  • 编译器看 10:推导出 T 应该是 int
  • 编译器看 20.5:推导出 T 应该是 double
  • 编译器陷入困境:T 到底是 int 还是 double?它无法决定,于是报错:'T' is ambiguous

如何解决?

方法1:显式类型转换 (Cast)

C++

// 把 int 转成 double
my_max(static_cast<double>(10), 20.5); // T 被推导为 double

方法2:显式指定模板参数

这是更清晰的方式。您直接告诉编译器 T 应该是什么,跳过推导。

C++

// 使用尖括号 <> 显式指定 T
my_max<double>(10, 20.5);

现在编译器知道 T 必须是 double,它会自动将 10 隐式转换为 10.0


4. 进阶特性

A. 多个模板参数

您可以有多个类型参数。

C++

template <typename T, typename U>
void print_pair(T first, U second) {
    std::cout << "First: " << first << ", Second: " << second << std::endl;
}

// 调用:
print_pair(10, "hello"); // T 推导为 int, U 推导为 const char*
print_pair(3.14, 100);   // T 推导为 double, U 推导为 int

B. 模板函数重载 (Overloading)

您可以同时拥有一个模板函数和一个同名的普通函数。

C++

// 模板版本
template <typename T>
void say(T message) {
    std::cout << "Template: " << message << std::endl;
}

// 普通函数(非模板)版本
void say(int message) {
    std::cout << "Specialized for Int: " << message << std::endl;
}

// 调用:
say("Hello"); // T = const char*, 匹配模板
say(3.14);    // T = double, 匹配模板
say(10);      // 匹配了 int 版本的普通函数

重载规则: 如果一个非模板函数(普通函数)能完美匹配参数,编译器会优先选择非模板函数say(10) 精确匹配 say(int),所以它不会去实例化模板。

C. 模板函数特化 (Specialization)

这是模板中一个非常强大的功能。

我们回头看 my_max。如果我们这样调用:

const char* s1 = "hello";

const char* s2 = "world";

my_max(s1, s2);

会发生什么?

T 被推导为 const char*(C 风格字符串,即指针)。

模板被实例化为:

const char* my_max(const char* a, const char* b) {

return (a > b) ? a : b; // 比较的是两个指针的地址!

}

这完全不是我们想要的!我们想比较字符串的内容,而不是它们的内存地址。

我们需要为 const char* 提供一个特化 (Specialization) 版本。

C++

#include <cstring> // for strcmp

// 1. 通用蓝图 (Generic Template)
template <typename T>
T my_max(T a, T b) {
    std::cout << "(Using Generic Template) ";
    return (a > b) ? a : b;
}

// 2. 特化版本 (Specialization)
// template<> 告诉编译器:这是一个特化
// my_max<const char*> 明确指出这是针对 T = const char* 的版本
template <>
const char* my_max<const char*>(const char* a, const char* b) {
    std::cout << "(Using const char* Specialization) ";
    return (std::strcmp(a, b) > 0) ? a : b;
}

int main() {
    std::cout << my_max(10, 20) << std::endl;
    std::cout << my_max("apple", "banana") << std::endl; // 调用特化版本
}

输出:

(Using Generic Template) 20
(Using const char* Specialization) banana

通过特化,我们为特定类型提供了“定制”的实现,同时保留了通用蓝图的便利性。


5. 模板与现代 C++ (连接我们之前的话题)

您之前学过的“完美转发”和“移动语义”,它们的核心应用场景就是在模板函数中。

可变参数模板 (Variadic Templates)

可变参数模板 (Variadic Templates) 是 C++11 引入的一项极其强大的特性,它是现代 C++ 泛型编程的核心支柱。

简单来说,它允许您定义一个参数数量不固定、参数类型也不固定的函数或类。

在 C 语言中,我们有 printf(...),但它是类型不安全的。C++ 的可变参数模板则是类型安全的,并且都在编译时处理,没有运行时开销。


1. 核心语法:省略号 ...

理解可变参数模板,关键在于理解省略号 ... 出现的位置和含义。

我们把它分为三个概念:

  1. 模板参数包 (Template Parameter Pack):

    template <typename... Args>

    这里的 Args 不是一个类型,而是一堆类型的集合(比如 int, double, string)。

  2. 函数参数包 (Function Parameter Pack):

    void func(Args... args)

    这里的 args 不是一个变量,而是一堆变量的集合。

  3. 包展开 (Pack Expansion):

    args...

    这是在函数体内部使用时。意思是:“把这堆参数解开,逗号分隔,一个一个放这里”。


2. 如何使用?(方式一:递归法 - C++11 标准)

在 C++17 之前,处理可变参数的标准方式是递归。这就像剥洋葱:每次处理第一个参数,然后把剩下的参数传给下一次调用。

我们需要写两个函数:

  1. 递归终止函数 (Base Case):处理参数为空的情况。
  2. 递归函数 (Recursive Case):处理头部参数,递归调用尾部。

C++

#include <iostream>

// 1. 递归终止函数 (当参数包为空时调用)
void print_all() {
    std::cout << "(End)" << std::endl;
}

// 2. 递归函数
// T first:  接收第一个参数
// Args... rest: 接收剩下的所有参数
template <typename T, typename... Args>
void print_all(T first, Args... rest) {
    std::cout << first << " "; // 处理第一个参数
    
    // 递归调用:把剩下的参数包展开,传给下一次
    print_all(rest...); 
}

int main() {
    // 调用过程:
    // 1. print_all(10, 3.14, "hello") -> 打印 10, 调用 print_all(3.14, "hello")
    // 2. print_all(3.14, "hello")     -> 打印 3.14, 调用 print_all("hello")
    // 3. print_all("hello")           -> 打印 hello, 调用 print_all()
    // 4. print_all()                  -> 调用终止函数,打印 (End)
    print_all(10, 3.14, "hello");
}

3. 如何使用?(方式二:折叠表达式 - C++17 现代版)

递归写法虽然经典,但写起来很麻烦(还要写个空函数)。C++17 引入了折叠表达式 (Fold Expressions) ,让代码极其简洁。

不需要递归,不需要终止函数,一行代码搞定。

C++

#include <iostream>

template <typename... Args>
void print_all_modern(Args... args) {
    // 折叠表达式语法: ( ... 运算符 包 )
    // 这里使用的是逗号运算符
    ((std::cout << args << " "), ...);
    std::cout << "(End)" << std::endl;
}

template <typename... Args>
auto sum_all(Args... args) {
    // 二元折叠:(包 运算符 ...)
    // 计算 args1 + args2 + args3 ...
    return (args + ...); 
}

int main() {
    print_all_modern(10, 3.14, "hello"); // 输出: 10 3.14 hello (End)
    
    int total = sum_all(1, 2, 3, 4, 5); // 计算 1+2+3+4+5
    std::cout << "Sum: " << total << std::endl; // 输出 15
}

((std::cout << args << " "), ...) 会被编译器自动展开为:

(std::cout << arg1 << " "), (std::cout << arg2 << " "), (std::cout << arg3 << " ")。


4. 终极应用:完美转发 + 可变参数

这正是我们之前讨论的 std::make_uniquestd::threademplace_back 的实现原理。

它们通过:

  1. 可变参数模板:接收任意数量的参数。
  2. 完美转发:保持参数的左值/右值属性。
  3. 包展开:将参数传给目标构造函数。

我们来手写一个简化版的 create_object 工厂函数:

C++

#include <utility> // for std::forward
#include <iostream>
#include <string>

class User {
public:
    User(int id, const std::string& name) {
        std::cout << "User created: " << id << ", " << name << std::endl;
    }
};

// 工厂函数
// T: 目标类型
// Args...: 构造函数需要的参数包
template <typename T, typename... Args>
T* create_object(Args&&... args) { // 1. 接收任意参数 (转发引用)
    
    return new T(std::forward<Args>(args)...); // 2. 完美转发 + 包展开
    
    // 假设传入 (1, "Gem")
    // 展开后变为: new User(std::forward<int>(arg1), std::forward<string>(arg2));
}

int main() {
    // 我们传入了 2 个参数
    User* u1 = create_object<User>(101, "Gemini");
    
    // 我们也可以传入 0 个参数 (如果 User 有默认构造函数的话)
    // User* u2 = create_object<User>(); 
    
    delete u1;
}

5. sizeof... 运算符

还有一个小工具:如果您想知道传进来了多少个参数,可以使用 sizeof... 运算符(注意三个点在后面)。

C++

template <typename... Args>
void count_args(Args... args) {
    std::cout << "参数个数: " << sizeof...(args) << std::endl;
    std::cout << "类型个数: " << sizeof...(Args) << std::endl;
}

总结

可变参数模板是现代 C++ 的“胶水”。

  • template <typename... Args> :定义一个能装各种类型的包。
  • Args... args:定义一个能装各种值的包。
  • args... :把包里的东西倒出来。
  • 配合 std::forward:您可以写出极其通用的包装函数(Wrapper),它不关心参数有多少,也不关心参数是什么,它只是负责把它们原封不动地传递给别人。

这就是为什么现代 C++ 库(如 STL)看起来功能如此强大且通用的核心原因。