前言
模板形参分两类:类型形参(type parameter),写法是typename T或class T;以及非类型形参(non-type template parameter,常缩写为 NTTP),写法是把一个具体的类型写在模板形参列表里,比如int N、std::size_t N、auto N。
非类型形参最常见的几个误解是:
- "非类型形参就是整数。"整数只是最常见的一类。指向对象的指针、指向函数的指针、成员指针、左值引用、
std::nullptr_t都可以做非类型形参;C++20 起浮点数和符合条件的类类型也可以。
- "实参可以是任何值。"不行。非类型实参必须是一个转换后的常量表达式(converted constant expression)——编译期就能算出具体值的东西。运行期的变量、函数返回值一律不行。
- "模板形参列表里写
typename是必需的。"在 C++17 里,template <typename T>和template <class T>完全等价;而在 C++20 里,对于非类型形参还可以直接写template <auto N>让编译器自己推导类型。
本文按"C++17 允许哪些类别 →template <auto>怎么用 → C++20 又放开了什么"的顺序讲,示例以 C++17 为基准,在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译。
一、C++17:非类型形参能填哪些类型
1.1 允许的类别
标准里对非类型形参的类型有明确列举。下表按标准版本对照("有链接"指对象必须具有外部或内部链接):
"结构类型"(structural type)是 C++20 新引入的术语:一个字面类型(literal type),它的所有基类和非静态数据成员都是public且非mutable,并且这些类型本身也是结构类型(或者是它们的数组)。
1.2 最典型的用法:用整数形参固定容量
// fixed_buffer.cpp —— g++ -std=c++17 -Wall -Wextra fixed_buffer.cpp -o fixed_buffer
#include <cstddef>
#include <iostream>
template <typename T, std::size_t N>
class FixedBuffer {
public:
static constexpr std::size_t capacity = N; // C++17 起隐式 inline,无需类外定义
bool push_back(const T& v) {
if (size_ >= N) return false;
data_[size_++] = v;
return true;
}
std::size_t size() const noexcept { return size_; }
const T& operator[](std::size_t i) const noexcept { return data_[i]; }
private:
T data_[N]; // 注意:N 为 0 时这行是 ill-formed 的
std::size_t size_ = 0;
};
int main() {
FixedBuffer<int, 4> buf;
for (int i = 1; i <= 5; ++i) {
std::cout << i << (buf.push_back(i) ? " ok" : " full") << '\n';
}
std::cout << "size = " << buf.size() << '\n'; // 4
std::cout << "capacity = " << FixedBuffer<int, 4>::capacity << '\n';
}
N就是这个模板的非类型形参。它的值成了类型的一部分,所以FixedBuffer<int, 4>和FixedBuffer<int, 8>是两个完全不同的类型——它们之间没有隐式转换,也不能互相赋值。
1.3 三种更容易被忽略的形态
第一,指向函数的指针。这可以用来做"策略参数化",把调用在编译期就定下来:
// policy.cpp —— g++ -std=c++17 -Wall -Wextra policy.cpp -o policy
#include <iostream>
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }
template <int (*Op)(int, int)>
int apply(int a, int b) { return Op(a, b); }
int main() {
std::cout << apply<add>(2, 3) << '\n'; // 5
std::cout << apply<mul>(2, 3) << '\n'; // 6
}
函数名add在这里被隐式转换成函数指针,这是转换后的常量表达式允许的标准转换之一。因为函数名是模板实参的一部分,apply<add>和apply<mul>是两个不同的实例,Op(a, b)这个调用在编译期就能确定目标、可以被内联。
第二,枚举类型。用enum class当策略开关比用裸整数更安全:
enum class Rounding { Floor, Ceil };
template <Rounding R>
struct Rounder {
static int apply(double x) {
const int i = static_cast<int>(x);
if (R == Rounding::Floor) return i;
return (x > static_cast<double>(i)) ? i + 1 : i;
}
};
第三,用非类型形参做编译期递归。这是最经典的教科书用法:
#include <cstddef>
#include <type_traits>
template <std::size_t N>
struct Factorial {
static constexpr std::size_t value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> { // 显式特化作为递归终点
static constexpr std::size_t value = 1;
};
static_assert(Factorial<5>::value == 120, "");
static_assert(Factorial<10>::value == 3628800, "");
Factorial<5>的实例化会递归触发Factorial<4>一路到Factorial<0>,最后落到那个显式特化上。关键点是特化必须出现在第一次引发隐式实例化之前,否则编译器会去实例化主模板的Factorial<0>,而0 - 1在std::size_t上是回绕的,会导致无限递归直到编译器报"模板实例化深度超过上限"。
二、template <auto N>:让编译器推导形参类型
C++17 引入了auto形式的非类型形参,省去了写两遍类型的麻烦。没有auto时你得这样:
template <typename T, T N> struct Old { }; // C++11 起可用,T 和 N 都得写
有了auto之后:
// auto_nttp.cpp —— g++ -std=c++17 -Wall -Wextra auto_nttp.cpp -o auto_nttp
#include <iostream>
#include <type_traits>
template <auto N>
struct Constant {
static constexpr auto value = N;
using type = decltype(N);
};
int main() {
std::cout << Constant<42>::value << '\n'; // 42
std::cout << (Constant<42>::value == Constant<42>::value) << '\n'; // 1
static_assert(std::is_same_v<Constant<42>::type, int>, "");
static_assert(std::is_same_v<Constant<42u>::type, unsigned>, "");
static_assert(std::is_same_v<Constant<'x'>::type, char>, "");
}
template <auto N>的关键语义是:形参的类型由实参推导出来,且推导结果严格等于实参的类型。所以Constant<42>、Constant<42u>、Constant<'x'>是三份不同的特化,value的类型分别是int、unsigned、char。这和"写死int N"完全不同——写死int时,Constant<42u>会把unsigned隐式转成int。
auto也可以和普通形参混用,还可以对非类型形参做限制的写法是 C++20 的受约束的非类型形参(constrained non-type template parameter):
// 需要 C++20
#include <concepts>
template <std::integral auto N>
struct OnlyIntegral {
static constexpr auto value = N;
};
这样OnlyIntegral<42>可以,OnlyIntegral<3.14>会报错。
三、C++20:浮点数与类类型
本节全部内容都需要 C++20 及以上。
第一个扩展是浮点非类型形参,写法很直接:
// 需要 C++20
template <double Scale>
struct Scaler {
double operator()(double x) const { return x * Scale; }
};
Scaler<2.5> s; // 2.5 是 double 类型的常量表达式
注意这里有个容易被忽略的细节:模板实参的类型必须和形参类型匹配得上。Scaler<2.5>的形参类型是double,实参2.5也是double,没问题;但如果你写成template <float F>然后传2.5(这是double),就会因为不允许的浮点窄化而报错。
第二个扩展是类类型,这是 C++20 里最有价值的一条:可以把"编译期字符串"做成模板实参。要求是那个类必须是结构类型。
// fixed_string.cpp —— 需要 C++20
// g++ -std=c++20 -Wall -Wextra fixed_string.cpp -o fixed_string
#include <cstddef>
#include <iostream>
#include <string_view>
template <std::size_t N>
struct FixedString {
char data[N]{};
constexpr FixedString(const char (&s)[N]) {
for (std::size_t i = 0; i < N; ++i) data[i] = s[i];
}
constexpr std::string_view view() const noexcept {
return std::string_view(data, N - 1); // 去掉结尾的 '\0'
}
// 某些编译器实现 P0732 的早期版本要求结构类型有 defaulted 的比较运算符
bool operator==(const FixedString&) const = default;
};
template <FixedString Name>
struct Named {
static constexpr std::string_view name = Name.view();
};
int main() {
std::cout << Named<"hello">::name << '\n'; // hello
std::cout << Named<"world">::name << '\n'; // world
}
逐条核对为什么它能成立:
FixedString是字面类型:有constexpr构造函数,析构函数是平凡的。
- 它唯一的非静态数据成员
data是public且非mutable,类型是char[N]——数组元素是标量类型,满足结构类型的要求。
- 构造函数接受
const char (&)[N],所以"hello"会推导出N = 6(含结尾的'\0')。这也是view()里用N - 1的原因。
Named<"hello">和Named<"world">是两个不同的类型,::name是编译期常量,不产生任何运行期开销。
C++17 的替代写法是把字符拆成参数包:
// C++17 版本
#include <string_view>
template <char... Cs>
struct Chars {
static std::string_view view() {
static const char buf[] = {Cs..., '\0'};
return std::string_view(buf, sizeof...(Cs));
}
};
// 用法:Chars<'h','e','l','l','o'>::view()
写起来啰嗦,但完全不需要 C++20。真正的限制是:在 C++17 里你不能直接写Chars<"hello">,因为字符串字面量既不是整型也不是指针类型的常量表达式(它没有链接),不能作为非类型实参。
常见坑点
| 1 | 用运行期变量做非类型实参:int n = 4; FixedBuffer<int, n> b; | 加constexpr:constexpr int n = 4;,非类型实参必须是转换后的常量表达式 |
| 2 | 让N可能取到 0:template <typename T, std::size_t N> struct B { T d[N]; }; | N == 0时T d[N]是 ill-formed;加static_assert(N > 0, "N must be positive"); |
| 3 | C++17 下用浮点非类型实参:template <double D> struct R {}; | 浮点非类型形参要 C++20;C++17 用整数定点(如乘以 1000 存成int)替代 |
| 4 | 用字符串字面量作const char*非类型实参:template <const char* S> struct T {}; T<"hi"> t; | 字符串字面量没有链接,不能作为指针类型的非类型实参;C++20 用FixedString,C++17 用char...参数包 |
| 5 | 在头文件里用static(内部链接)对象的地址作非类型实参 | 每个翻译单元里地址可能不同,会实例化出"值相同但实体不同"的特化,导致 ODR 违规(UB);改用 C++17 的inline constexpr变量 |
| 6 | 传窄化转换:template <int N> struct S {}; S<3.0> s; | 转换后的常量表达式不允许窄化转换,3.0到int是窄化,直接编译错误;写S<3> |
| 7 | 以为std::size_t形参会挡住负数:S<-1> | 整数转换里-1到std::size_t的值是可表示的、不算窄化,S<-1>会静默变成一个极大的N;该报错的地方自己加static_assert |
| 8 | 递归模板忘了写终点特化(如Factorial<0>) | 整数回绕会让递归停不下来,直到超过编译器实例化深度上限;显式特化必须写在首次实例化之前 |
第 5 条展开一下,它是最隐蔽的一类。设一个头文件里写了:
// ❌ 内部链接:每个 .cpp 里都是一份独立的 kMode
static const int kMode = 1;
template <const int* P>
struct Selector { static constexpr int value = *P; };
如果某个.cpp里写了Selector<&kMode>,另一个.cpp里也写了Selector<&kMode>,两处的kMode不是同一个对象,模板实参的值也就不同。对模板来说这就是两个不同的特化,但它们的名字一样,属于 ODR 违规,标准规定是未定义行为。正确做法是 C++17 引入的 inline 变量:
// ✅ 外部链接,全程序只有一份
inline constexpr int kMode = 1;
总结
| 用整数固定容量 / 做编译期循环 | template <std::size_t N> | C++11 |
| 随便哪种常量都能当形参,类型自动推导 | template <auto N> | C++17 |
| 用函数指针做编译期策略 | template <int (*Op)(int, int)> | C++11 |
用enum class做开关 | template <Rounding R> | C++11 |
| 浮点常量参与计算 | template <double Scale> | C++20 |
| 编译期字符串 | 包装成结构类型的FixedString | C++20 |
| 约束非类型形参的类别 | template <std::integral auto N> | C++20 |
非类型形参的本质是"把值提升到类型系统里":一旦某个值成了模板实参,它就参与了类型同一性判断,也就意味着编译器可以把它折叠进代码、做完全的特化与内联。代价是每换一个值就多一份实例化,所以它最适合"取值范围小、且每次变化都需要重新生成代码"的场景——容量、维度、策略开关、编译期字符串,都属此类。
记住三条硬规矩就够了:实参必须是转换后的常量表达式(不能窄化);指针和引用类形参指向的对象必须有链接;C++20 之前不能用浮点和类类型。踩坑最多的地方从来不是语法,而是链接性——同一个名字在不同翻译单元里指向不同对象,会让模板悄悄实例化出两份本该相同的特化。