带有 auto 类型的非类型模板参数

1. 非类型模板参数

简单来说,非类型模板参数就是模板中可以传入的不是类型的参数。可以用生活中的例子来解释,想象一下现在有一个饼干模具:

  • 类型模板参数:决定了模具的形状(圆形、方形、星形)
  • 非类型模板参数:决定了模具的大小(直径5cm、直径10cm)

在代码中,非类型模板参数允许我们在编译时传入具体的值,而不是类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T, int Size>  // T是类型参数,Size是非类型参数
class FixedArray
{
private:
T data[Size]; // 使用Size来指定数组大小

public:
int getSize() const
{
return Size;
}
};

// 使用示例
FixedArray<int, 10> arr10; // 创建一个包含10个int的数组
FixedArray<double, 100> arr100; // 创建一个包含100个double的数组

在 C++17 之前,当编写一个模板类或模板函数时,如果想让模板接收一个具体的值(比如一个数字、一个指针,而不是一个类型),必须显式地告诉 C++ 这个值的类型。

C++17 允许在非类型模板参数中使用 auto 关键字。这意味着编译器会自动推断传入的类型。我们不再需要关心传入的是 int, long, short 还是 size_t,只要它能用,编译器就会帮我们搞定。

基本语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 用于类模板
template<auto Value>
class MyClass
{
// 类定义
};

// 用于函数模板
template<auto Value>
void myFunction()
{
// 函数实现
}

允许的类型:

非类型模板参数本身(即使加了 auto)也只能接受以下类型:

  1. 整形int, char, long, bool 等)
  2. 枚举类型
  3. 指针类型
  4. 左值引用类型
  5. std::nullptr_t
  6. 浮点数类型(float, double)(C++17不允许,C++20 放宽了限制,允许浮点数)。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <type_traits>

// 使用 auto 作为非类型模板参数
template<auto Value>
struct AutoTemplate
{
static constexpr auto val = Value;
void print()
{
std::cout << "Value: " << val
<< ", Type: " << typeid(val).name() // 打印类型
<< std::endl;
}
};

int main()
{
AutoTemplate<42> intInstance;
AutoTemplate<'A'> charInstance;
AutoTemplate<true> doubleInstance;

intInstance.print();
charInstance.print();
doubleInstance.print();

return 0;
}

2. 典型应用

2.1 模版特化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream>

// 主模板
template<auto Value>
struct ValueProcessor
{
static void process()
{
std::cout << "Generic processing for value: " << Value << std::endl;
}
};

// 整型特化
template<int Value>
struct ValueProcessor<Value>
{
static void process()
{
std::cout << "Integral processing for value: " << Value
<< " (square: " << Value * Value << ")" << std::endl;
}
};

// 指针特化
template<auto* Ptr>
struct ValueProcessor<Ptr>
{
static void process()
{
std::cout << "Pointer processing for pointer: " << Ptr << std::endl;
}
};

// 特定值特化
template<>
struct ValueProcessor<42>
{
static void process()
{
std::cout << "Special processing for the answer to everything!" << std::endl;
}
};

int main()
{
ValueProcessor<false>::process(); // 通用处理
ValueProcessor<10>::process(); // 整型处理
ValueProcessor<nullptr>::process(); // 指针处理
ValueProcessor<42>::process(); // 特定值处理

static int x = 100;
ValueProcessor<&x>::process(); // 指针处理

return 0;
}

因为非类型模板参数必须是编译期常量,所以template<auto* Ptr> 中的 Ptr,必须在编译代码的时候就已经确定它的值,而 &x(取地址)只有当 x 是静态存储期的变量时编译器才能确定。

1
2
static int x = 100;
ValueProcessor<&x>::process(); // 指针处理

在非类型模板参数中使用指针时,C++ 标准(C++17 及 C++20)的核心要求是:该参数必须指向一个具有外部链接的对象。让我们看看 constexpr int x = 100; 定义在函数体内部时的链接属性:

1
2
3
4
5
6
void main() 
{
constexpr int x = 100;
// x 是局部变量,没有链接性,地址在运行时才能取到(相对栈帧)
ValueProcessor<&x>::process(); // 错误:x 没有外部链接
}

2.2 模拟类似 Python 的 .format 或者状态机

利用 C++17 的非类型模板参数这个特性,我们可以创建非常灵活的序列生成器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>

// 递归终止条件
template <auto... Args>
struct Sequence {};

// 递归展开
template <auto First, auto... Rest>
struct Sequence<First, Rest...>
{
void print()
{
std::cout << First << " ";
Sequence<Rest...> next;
next.print();
}
};

// 全特化版本,处理空参数
template <>
struct Sequence<>
{
void print()
{
std::cout << "(End)" << std::endl;
}
};

int main()
{
// 这里的参数可以是混合的类型:int, bool, char
Sequence<1, true, 'A', 42L> seq;

std::cout << "Sequence values: ";
seq.print();

return 0;
}

当你实例化 Sequence<1, true, 'A', 42L> seq; 时,编译器实际上像剥洋葱一样在编译期生成了不同的类。让我们看看生成的调用链(注意:print() 是在运行时执行的,但对象的类型是编译期生成的):

  • 第一次调用 (main函数中):

    • Sequence<1, true, 'A', 42L>print() 被调用。

    • First1

    • 动作:打印 1

    • 内部:创建 next 类型为 Sequence<true, 'A', 42L>

  • 第二次调用 (递归):

    • Sequence<true, 'A', 42L>print() 被调用。

    • Firsttrue

    • 动作:打印 1 (因为 boolcout 默认输出是 1)。

    • 内部:创建 next 类型为 Sequence<'A', 42L>

  • 第三次调用 (递归):

    • Sequence<'A', 42L>print() 被调用。

    • First'A'

    • 动作:打印 A

    • 内部:创建 next 类型为 Sequence<42L>

  • 第四次调用 (递归):

    • Sequence<42L>print() 被调用。

    • First42L

    • 动作:打印 42

    • 内部:创建 next 类型为 Sequence<> (包为空)。

  • 第五次调用 (终止):

    • Sequence<>print() 被调用。

    • 动作:匹配到特化版本,打印 (End)

    • 内部:无后续递归。

程序运行后的输出会是:

1
Sequence values: 1 1 A 42 (End)

true 被输出为 1,这是 std::coutbool 类型的标准行为(除非使用 std::boolalpha)。

这段代码的递归是在运行时 通过创建对象 next 和调用函数 next.print() 进行的。在 C++11/14 乃至优化较差的编译器下,这会导致函数调用栈的开销。如果列表很长(比如几千个元素),可能会导致栈溢出。为了消除运行时的函数递归开销,可以使用 constexpr 函数配合折叠表达式,将递归逻辑扁平化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <type_traits>

template <auto... Args>
struct Sequence
{
void print()
{
std::cout << "Sequence values: ";
// 使用折叠表达式展开参数包
((std::cout << Args << " "), ...);
std::cout << "(End)" << std::endl;
}

// 如果需要更详细的类型信息
void print_with_types()
{
std::cout << "Sequence with types: ";
((std::cout << "(" << Args << ":" << typeid(Args).name() << ") "), ...);
std::cout << std::endl;
}
};

int main()
{
Sequence<1, true, 'A', 42L> seq;
seq.print();

// 可选:打印带类型信息
// seq.print_with_types();

return 0;
}

折叠表达式说明:

  • ((std::cout << Args << " "), ...)一元右折叠,使用逗号运算符 , 作为操作符。
  • , :连接多个表达式,确保从左到右执行顺序,返回最后一个表达式的值
  • 这是 C++17 引入的特性,可以替代复杂的递归模板展开
折叠表达式详解