C++模板应用

给 int 写一个求最大值的函数,再给 double 写一遍,两份代码往往只差类型。遇到这种重复,就可以把类型提取成参数:

template <typename T>。

这里整理几个常见用法。代码块各自独立,版本要求写在第一行注释里。

函数模板

先从求最大值开始。比较和返回的逻辑不变,变化的是参数类型:

// C++11 及以上
#include <iostream>

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

int main() {
    std::cout << max_value(3, 7) << '\n';         // 7
    std::cout << max_value(2.5, 1.2) << '\n';     // 2.5
    std::cout << max_value<double>(3, 4.5) << '\n'; // 4.5
}

类型推导

typename T 声明类型参数。这里也可以写 class T,两种写法含义相同,class 并不限制实参必须是类。

前两次调用由编译器推导 T;第三次显式指定 T 为 double,整数实参会转换为 double。

类型限制

  • max_value(3, 4.5) 无法为同一个 T 推导出一致的类型,需要统一实参类型、显式指定模板实参,或重新设计为两个类型参数。
  • 模板对类型有要求:这个实现需要 < 比较,并按值传递和返回。它不能直接适用于所有类型;传入 C 风格字符串指针也不会自动变成字符串内容比较。

实际取最大值可以直接用 std::max。手写这个小函数,主要是看清楚类型推导发生在哪里,以及它会在哪里失败。

类模板

栈也有类似的重复:不管装的是整数还是字符串,入栈、出栈的过程都一样。把元素类型提出来,就有了 Stack<T>:

// C++11 及以上
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

template <typename T>
class Stack {
public:
    void push(T value) {
        data_.push_back(std::move(value));
    }

    void pop() {
        if (data_.empty()) {
            throw std::out_of_range("Stack is empty");
        }
        data_.pop_back();
    }

    const T& top() const {
        if (data_.empty()) {
            throw std::out_of_range("Stack is empty");
        }
        return data_.back();
    }

    bool empty() const noexcept {
        return data_.empty();
    }

private:
    std::vector<T> data_;
};

int main() {
    Stack<int> numbers;
    numbers.push(10);
    numbers.push(20);
    std::cout << numbers.top() << '\n'; // 20
    numbers.pop();
    std::cout << numbers.top() << '\n'; // 10

    Stack<std::string> messages;
    messages.push("hello template");
    std::cout << messages.top() << '\n';
}

Stack<int> 和 Stack<std::string> 是两个不同的类型。整数栈不能直接塞进字符串,这一步在编译时就会报错。平时需要栈时用 std::stack 就够了,上面这个版本只保留了几个基本操作。

引用的寿命

top() 返回内部元素的引用。如果保存了这个引用,之后的 push() 触发底层 vector 扩容,它就可能失效;出栈或销毁容器也要考虑同样的问题。返回引用省下了一次复制,但使用者需要留意对象还在不在。

常量参数

模板参数也可以是编译期常量。例如 std::array<T, N> 中,T 是元素类型,N 是编译期确定的元素个数。

// C++11 及以上
#include <array>
#include <cstddef>
#include <iostream>

template <typename T, std::size_t N>
T sum_array(const std::array<T, N>& values) {
    T result{};
    for (const auto& value : values) {
        result += value;
    }
    return result;
}

int main() {
    std::array<int, 4> values{{1, 2, 3, 4}};
    std::cout << sum_array(values) << '\n'; // 10
}

长度也是类型

调用时会同时推导出 T = int 和 N = 4。N 就是非类型模板参数:传入的不是类型,而是编译期常量。这个求和函数还要求 T{} 能作为累加初值,并且支持 +=,整数溢出也不会因为用了模板就自动解决。

这类参数适合固定尺寸矩阵、固定长度缓冲区,以及在编译期选择配置。std::array<int, 4> 和 std::array<int, 8> 是不同的类型;如果长度只在运行时确定,通常应使用 std::vector。

可变参数

输出一行日志时,可能传一个字符串,也可能混着传几个数字。参数个数不固定,可以用参数包接住:

// C++17 及以上:使用折叠表达式
#include <iostream>
#include <utility>

template <typename... Args>
void log_line(Args&&... args) {
    ((std::cout << std::forward<Args>(args)), ...);
    std::cout << '\n';
}

int main() {
    log_line("count=", 3, ", ratio=", 0.75);
    log_line("template ready");
    log_line(); // 输出一个空行
}

Args... 是模板参数包,args... 是函数参数包。折叠表达式将输出操作应用到每个实参;每个实参都必须能被输出到 std::cout。

完美转发

这里的 Args&& 是转发引用。std::forward<Args> 把实参原来的左值或右值属性传下去;它本身不移动对象,也不保证后面的函数不会复制。

工厂函数和回调包装经常会用到这种写法。上面的 log_line 只是把内容依次写到 std::cout,还不能当作完整的日志库:多个线程同时输出时,内容仍可能交错。

编译期分支

有些操作没法让所有类型走同一条路。比如整数直接输出,字符串则输出长度,这时可以用 C++17 的 if constexpr:

// C++17 及以上
#include <iostream>
#include <string>
#include <type_traits>

template <typename T>
void print_value(const T& value) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << "integer: " << value << '\n';
    } else {
        std::cout << "size: " << value.size() << '\n';
    }
}

int main() {
    print_value(42);                 // integer: 42
    print_value(std::string("C++")); // size: 3
}

对于 T = int,调用 value.size() 的分支不会被实例化。这是普通运行时 if 无法替代的用途。

分支的边界

没选中的分支仍要满足语法等基本规则。这个函数也不能接收任意类型:传入 double 会走到 else,但 double 没有 size(),照样编译失败。

类型约束

上面几个函数都有隐含的类型要求。等到实例化内部代码才发现不满足,报错经常很长。C++20 的 Concepts 可以把要求提前写到接口上:

// C++20 及以上
#include <concepts>
#include <iostream>

template <typename T>
concept Arithmetic = std::integral<T> || std::floating_point<T>;

template <Arithmetic T>
constexpr T square(T value) {
    return value * value;
}

int main() {
    static_assert(square(5) == 25);
    std::cout << square(1.5) << '\n'; // 2.25
    // square("hello"); // 取消注释会因不满足 Arithmetic 约束而编译失败
}

template <Arithmetic T> 限定了 T 必须满足 Arithmetic。传入字符串时,编译器可以直接指出约束不满足,不用等到乘法表达式出错。

约束不管数值

这个概念仍会接受 bool 和字符类型,因为它们也属于整数类型。如果不希望接收它们,需要继续补上限制。约束也不负责检查数值范围,平方运算仍可能溢出。

另外,函数上的 constexpr 表示符合条件的调用可以用于常量表达式,不是说所有调用都会在编译期执行。这里的 static_assert 要求编译期求值,普通调用则不一定。

模板特化

如果某个类型要单独处理,可以给模板写一个特化。例如单独识别 bool:

// C++11 及以上
#include <iostream>

template <typename T>
struct TypeLabel {
    static const char* name() { return "other"; }
};

template <>
struct TypeLabel<bool> {
    static const char* name() { return "bool"; }
};

int main() {
    std::cout << TypeLabel<int>::name() << '\n';  // other
    std::cout << TypeLabel<bool>::name() << '\n'; // bool
}

全特化与偏特化

template <> 表示显式全特化,只针对这里的 bool。类模板还支持偏特化,比如统一处理 T* 这样的指针类型。函数模板不支持偏特化,通常改用重载、Concepts 或 if constexpr。

如果只是一个函数内部的少量类型差异,if constexpr 通常更集中;如果整个类型的存储结构和接口都要变化,类模板特化更合适。

使用取舍

头文件与实例化

调用方进行隐式实例化时,通常需要看到模板的完整定义。如果头文件只有声明,定义被放进另一个 .cpp,常见结果是链接时找不到对应实例。

一般可以把定义放进头文件,或放进由头文件包含的 .tpp 文件。如果只支持有限几种类型,也可以在 .cpp 中提供定义并进行显式实例化。模板并非只能写在头文件里。

性能与体积

模板让具体类型在编译期可知,有利于内联和优化,也可以在合适的设计中避免虚函数分派。但最终速度取决于算法、优化设置、内存访问和生成代码,不能仅凭用了模板就认定更快。

不同实例可能增加编译时间和二进制体积。模板层次过深时,报错和维护成本也会上升。

何时使用

  • 多种类型共享同一套算法或数据结构,而且类型在编译时已知:适合模板。
  • 只有少数固定类型,行为差异明显:普通重载可能更直观。
  • 需要在运行时通过统一接口替换实现:可以考虑虚函数或类型擦除。
  • 只在单个函数附近写一段泛型逻辑:泛型 lambda 往往已经足够。

参考资料

以下链接指向持续更新的 C++ 工作草案,章节中可能包含晚于 C++20 的内容;本文示例的版本要求以各代码块标注为准。


C++模板应用
https://hb2cpc.top/2026/10/cpp-template-applications
作者
hb2cpc
发布于
2026年10月30日
许可协议