深入理解 C++ 模板:从基础语法到模板元编程(下)

  1. 本文假定读者已经阅读过上篇文章(模板基础),了解了模板基础及其他 C++ 基础知识
  2. 本文所示代码默认引入了 iostream 库,且不使用 using namespace std;
  3. 本文所有代码均在 Rider 中运行,且默认使用 msvc 编译器、C++11 标准、Debug x86 配置
  4. 本文将使用 Compiler Explorer 在线编译器作代码演示并给出链接

在上一篇文章中,我们已经掌握了 C++ 模板的基础知识,包括其基本语法和特化机制。这些工具赋予了我们编写泛型代码的能力,使其能够跨越不同数据类型工作。然而,这还没能发挥出 C++ 模板的全部功力。

模板元编程(Template Metaprogramming,TMP)是 C++ 模板系统的核心,它允许我们在编译时进行计算和决策。这使得我们能够编写更加灵活和高效的代码,同时也能够实现一些在运行时难以实现的功能。本文要讲的,正是模板元编程。

本文将带领读者踏上一段深入 C++ 模板世界的旅程,探索 C++ 语言在过去几十年中引入的各项高级技术。这不仅是一份技术功能的清单,更是一部关于 C++ 泛型编程思想的演进史。我们将从最基础的编译时“探针”——类型萃取(Type Traits)开始,它让我们得以在编译期间“审视”类型。

1. 类型萃取(Type Traits):在编译时审视类型

在构建复杂的模板库或泛型算法时,首要任务是能够在编译时获取有关模板参数类型的信息。例如,一个泛型数学函数可能需要知道其参数是否为整数、浮点数,或是一个指针。C++11 通过 <type_traits> 头文件提供了强大的工具集来完成这项任务,这就是类型萃取。

它是一种编译时技术,允许编译器在编译阶段检查、比较和修改类型,所以它本质上是一种编译时反射(Compile-Time Reflection)机制。
它允许我们询问关于类型 T 的各种问题:“T 是否为整数?”、“T 是否为指针?”、“T 是否带有 const 限定符?”等。

在权威网站 cppreference 中是这样描述的:

请添加图片描述

可不要把它跟 RTTI(Run-Time Type Information)搞混了!RTTI 是运行时的一种机制,两者的目的和实现方式完全不同。

<type_traits> 中,主要使用模板结构体,配合两种主要的静态成员以提供查询结果:

  • ::value:对于用于查询类型属性或关系的萃取,它们通常包含一个名为 value 的静态 bool 常量成员。如果查询结果为真,其值为 true,否则为 false。例如,std::is_integral<int>::value 的值为 true
  • ::type:对于用于转换类型的萃取,它们通常包含一个名为 type 的嵌套类型别名(使用 typedefusing 定义)。这个别名代表了转换后的新类型。例如,std::remove_reference<int&>::type 就是 int

为了提升代码的可读性和简洁性,C++14 和 C++17 引入了变量模板(_v 后缀)和别名模板(_t 后缀)作为便捷的辅助工具。这些辅助模板极大地“美化”了代码,使我们不再需要显式访问 ::value::type 成员。


// C++11 风格
bool is_pointer_old = std::is_pointer<int>::value;
using pointer_to_int_old = std::add_pointer<int>::type;

// C++17 风格 (使用 _v 和 _t 后缀)
bool is_pointer_new = std::is_pointer_v<int>;
using pointer_to_int_new = std::add_pointer_t<int>;

看!这种演进体现了 C++ 标准委员会致力于降低模板元编程复杂性的持续努力。在现代 C++ 代码中,应优先使用 _v_t 后缀的辅助模板。

1.1 类型萃取的实现

刚刚讲到了 <type_traits> 主要使用模板结构体及其两种主要的静态成员,现在就让我们深入底层源码,看看它具体是如何实现的(以 C++17 或以后的标准为例)。

在 IDE 中随便写一个 <type_traits> 库的函数,例如 std::is_integral,然后按住 Ctrl 键点击跳转,就可以看到它的源码。
一路溯源,你会发现其实在 <type_traits> 之下,还有 <xtr1common> 这个库,而它,正是 <type_traits>基石

<xtr1common> 中只定义了类型萃取最基本的东西,所以代码量很少,只有两百多行:

请添加图片描述

我们只讲几个关键的,它在一开始,定义了一个 integral_constant 结构体模板,有一个类型参数 _Ty 和一个该类型的非类型参数 _Val

template <class _Ty, _Ty _Val>
struct integral_constant {
    static constexpr _Ty value = _Val;

    using value_type = _Ty;
    using type       = integral_constant;

    constexpr operator value_type() const noexcept {
        return value;
    }

    constexpr value_type operator()() const noexcept {
        return value;
    }
};

这一段可能有点难以理解,主要是那两个成员函数,其实就是把 integral_constant 实例当做一个变量,可以直接使用,还可以像函数一样调用,两种用法返回的值都是 _Val,而它的类型就是 _Ty,也就是 value_type

先说第一个吧,operator value_type() 其实是一个隐式转换运算符(作用类似 (double)3 一样),它允许我们在编译时将 integral_constant 实例转换为一个 value_type 类型变量使用;而第二个 value_type operator()() 重载了括号运算符,当我们调用 integral_constant() 时,会返回 value_type(即 _Ty)类型的值 value(即 _Val)。它们将在后续的代码中发挥重要作用。

为了更好的理解,我们写一个简单的例子:

int main()
{
	using three_t = std::integral_constant<int, 3>;
	using five_t = std::integral_constant<int, 5>;

	three_t three;
	five_t five;

	auto fifteen = three_t() * five_t();    // 15
	bool is_same = fifteen == three * five; // true
}

接着,是布尔类型的结构体模板,实际上是 integral_constant 的偏特化。

template <bool _Val>
using bool_constant = integral_constant<bool, _Val>;

using true_type  = bool_constant<true>;
using false_type = bool_constant<false>;

判断两个类型是否相同就用到了 bool_constant

template <class, class>
constexpr bool is_same_v = false;
template <class _Ty>
constexpr bool is_same_v<_Ty, _Ty> = true;

template <class _Ty1, class _Ty2>
struct is_same : bool_constant<is_same_v<_Ty1, _Ty2>> {};

这里用到了偏特化,在“两个类型都是 _Ty ”这一特殊情况时,is_same_v 的值为 trueis_same 实际上很少用到。


回到 <type_traits> 中,我们有时会有去除一个变量引用类型的需求,这时就需要用到 remove_reference 结构体模板:

template <class _Ty>
struct remove_reference {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty;
};

template <class _Ty>
struct remove_reference<_Ty&> {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty&;
};

template <class _Ty>
struct remove_reference<_Ty&&> {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty&&;
};

template <class _Ty>
using remove_reference_t = typename remove_reference<_Ty>::type;

这段代码分别对无引用、左值引用和右值引用进行特化,返回的类型都是无引用的 _Ty
有了它,我们就可以写出 moveforward 了:

template <class _Ty>
constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
template <class _Ty>
constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept {
    return static_cast<_Ty&&>(_Arg);
}

template <class _Ty>
constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept {
    static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
    return static_cast<_Ty&&>(_Arg);
}

现在再看这些源码应该就是手到擒来了,std::forward 中的 is_lvalue_reference_v 的源码如下,也很好懂:

template <class>
constexpr bool is_lvalue_reference_v = false;

template <class _Ty>
constexpr bool is_lvalue_reference_v<_Ty&> = true;

template <class _Ty>  // 很少使用
struct is_lvalue_reference : bool_constant<is_lvalue_reference_v<_Ty>> {};

1.2 类型萃取的分类

<type_traits> 文件足足有 2606 行,我肯定是不会全部讲的,有了上面这些应该足以理解绝大多数的类型萃取是如何实现的了。

在此,我想总结一下 <type_traits> 中类型萃取的分类。

1. 一元类型萃取(Unary Type Traits)

这类萃取用于查询单个类型的属性。

  • 基本类型查询
    • std::is_integral_v<T>:检查 T 是否为整数类型
    • std::is_floating_point_v<T>:检查 T 是否为浮点类型
    • std::is_arithmetic_v<T>:检查 T 是否为算术类型(整数或浮点数)
    • std::is_class_v<T>:检查 T 是否为类或结构体类型
    • std::is_pointer_v<T>:检查 T 是否为指针类型
  • 属性查询
    • std::is_const_v<T>:检查 T 是否为常量类型
    • std::is_volatile_v<T>:检查 T 是否为易变类型
    • std::is_trivial_v<T>:检查 T 是否为平凡类型
    • std::is_pod_v<T>:检查 T 是否为 Plain Old Data 类型

2. 二元类型萃取(Binary Type Traits)

这类萃取用于判断两个类型之间的关系。

  • std::is_same_v<T, U>:检查 TU 是否为完全相同的类型
  • std::is_base_of_v<Base, Derived>:检查 Base 是否为 Derived 的基类(或者是同一类型)
  • std::is_convertible_v<From, To>:检查 From 类型的值是否能隐式转换为 To 类型

3. 转换萃取(Transformation Traits)

这类萃取接受一个类型并返回一个被修改过的新类型。

  • std::remove_const_t<T>:移除 T 的顶层 const 限定符

  • std::remove_reference_t<T>:移除 T 的引用

  • std::add_lvalue_reference_t<T>:为 T 添加左值引用

  • std::make_unsigned_t<T>:将有符号整数类型 T 转换为对应的无符号类型

  • std::common_type_t<T, U...>:确定一组类型 T, U... 的共同类型,即所有这些类型都可以被安全转换到的类型。

类型萃取不仅是独立的工具,它们更是后续将要讨论的所有高级模板技术的基石。它们提供的编译时布尔值和类型信息,是这些高级控制结构做出决策的数据来源,构成了一条清晰的技术演进链。

2. SFINAE:不是错误的“替换失败”

在类型萃取为我们提供了“审视”类型的能力之后,下一个逻辑问题是:如何利用这些信息来控制模板的行为?在 C++11 及以后的标准中,最经典(也最复杂)的机制之一就是 SFINAE。

2.1 SFINAE 的原理

初看这个单词可能会感觉很奇怪,我们也不用纠结它的发音,它其实是 “Substitution Failure Is Not An Error” 的缩写,即“替换失败不是个错误”。这是一个关于 C++ 模板重载解析的核心规则。简而言之,当编译器尝试为一个模板推导和替换模板参数时,如果这个替换过程导致了无效的类型或表达式,编译器不会立即报告一个硬性编译错误。相反,它会默默地将这个无法成功替换的模板版本从候选函数(或类)的集合中移除,然后继续尝试其他可行的重载。

这个规则的初衷是为了让泛型代码更加健壮。例如:

// #1: 只有当T有一个名为`type`的嵌套类型时,此模板才有效
template <typename T>
void test(typename T::type) {
    std::cout << "T has a nested type 'type'\n";
}

// #2: 一个通用的可变参数模板作为备选方案
template <typename T>
void test() {    // 省略号用于接收任意数量的参数
    std::cout << "Fallback for T\n";
}

struct HasType { using type = int; };
struct NoType {};

int main() {
    test<HasType>(0);    // 匹配#1成功,因为HasType::type是有效的
    test<HasType>(0, 1); // 匹配#1失败,因为参数数量不匹配,SFINAE生效,匹配#2
    test<NoType>(0);     // 匹配#1失败,因为NoType::type无效,SFINAE生效,匹配#2
    test<int>(0, 1);     // 匹配#1失败,因为int::type无效,SFINAE生效,匹配#2
}

然而,SFINAE 的应用有一个至关重要的限制,即所谓的“直接上下文(immediate context)”规则。只有在函数签名的直接替换过程中(即返回类型、参数类型、模板参数列表)发生的失败才属于 SFINAE。如果替换成功,但导致函数体内部的某个深层实现出现问题,那么这将是一个硬性的编译错误。例如:

// #1
template<typename T>
void Print(typename T::type t)
{
	std::cout << t;
}

// #2
template<typename T>
void Print(T t)
{
	std::cout << t;
}

class A{};

int main() {
	Print(A());
}

这段代码中,Print(A()) 匹配 #1 失败,因为 A 没有 type 成员,SFINAE 生效,匹配 #2,但 #2 中调用了 std::cout << t,而 std::cout 无法打印 A 类型,所以这是一个硬性的编译错误:

Error C2679 : 二元“<<”: 没有找到接受“T”类型的右操作数的运算符(或没有可接受的转换)
...

如果你自己动手尝试过,会发现这段报错非常长!其实这就是编译器在模板实例化时不断匹配各种 << 运算符重载的过程,每一次失败都会有记录,直到所有匹配都失败,它才会一股脑地将所有失败信息全部输出。

所以你能看到这段报错信息中其实有很多的“无法将参数…转换为…”:

...无法将参数 2 从“T”转换为“const void *”
...无法将参数 2 从“T”转换为“long double”
...无法将参数 2 从“T”转换为“double”
...无法将参数 2 从“T”转换为“float”
...无法将参数 2 从“T”转换为“unsigned __int64”
...

而如果是在模板实例化之前,参数类型推导失败,则只会有类型推导失败的错误信息,因为到此就停止了:

template <typename T>
void Print(T a, T b)
{
	std::cout << a << b;
}

class A{};
class B{};

int main()
{
	Print(A(), B());
}

报错:

0>------- Started building project: Test
C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\bin\HostX64\x86\CL.exe /c /ZI /JMC /nologo /W3 /WX- /diagnostics:column /sdl /Od /Oy- /D WIN32 /D _DEBUG /D _CONSOLE /D _UNICODE /D UNICODE /Gm- /EHsc /RTC1 /MDd /GS /fp:precise /Zc:wchar_t /Zc:forScope /Zc:inline /permissive- /Fo"Debug\\" /Fd"Debug\vc143.pdb" /external:W3 /Gd /TP /analyze- /FC /errorReport:prompt run.cc
run.cc
0>run.cc(16,2): Error C2672 : “Print”: 未找到匹配的重载函数
    X:\Projects\C++\Test\run.cc(6,6):
    可能是“void Print(T,T)”
        X:\Projects\C++\Test\run.cc(16,2):
        “void Print(T,T)”: 模板 参数“T”不明确
            X:\Projects\C++\Test\run.cc(16,2):
            可能是“B”
            X:\Projects\C++\Test\run.cc(16,2):
            或    “A”
        X:\Projects\C++\Test\run.cc(16,2):
        “void Print(T,T)”: 无法从“B”推导出“T”的 模板 参数

0>------- Finished building project: Test. Succeeded: False. Errors: 1. Warnings: 0
Build completed in 00:00:02.128

这个就简洁明了了许多,对吧?

SFINAE 的这种特性,使得富有创造力的 C++ 程序员们很快意识到,这个规则可以被“利用”:如果我们能根据类型的某些属性(通过类型萃取获得)来故意制造替换失败,我们就能精确地控制哪个模板重载被启用。这催生了现代 C++ 模板元编程的一个重要分支,而 std::enable_if 正是为此而生的标准工具。

2.2 std::enable_if_t

SFINAE 是一个语言规则,而 std::enable_if_t 是 C++14 标准库中提供的一个模板元函数,其设计的唯一目的就是为了方便地利用 SFINAE 规则,达到约束参数类型的效果:

std::enable_if_t 的定义非常简单:

template <bool _Test, class _Ty = void>
struct enable_if {}; // no member "type" when !_Test

template <class _Ty>
struct enable_if<true, _Ty> { // type is _Ty for _Test
    using type = _Ty;
};

// C++14 中引入的别名模板
template <bool _Test, class _Ty = void>
using enable_if_t = typename enable_if<_Test, _Ty>::type;

它有两个模板参数:一个 bool 常量 _Test 和一个类型 _Ty(默认为 void)。其工作机制是:

  • 如果 _Testtruestd::enable_if<true, _Ty> 会有一个名为 type 的公开成员类型别名,其类型为 _Ty

  • 如果 _Testfalsestd::enable_if<false, _Ty> 匹配的是那个空的主模板,它没有名为 type 的成员。

因此,当我们在需要类型的地方使用 typename std::enable_if<Condition, Type>::type 时,只有当 Conditiontrue 时,才会返回 type 成员,否则访问一个不存在的 ::type 成员就会导致替换失败,从而触发 SFINAE。

下面介绍几个 std::enable_if_t 的典型应用场景:

1. 作为返回类型

template<typename T>
std::enable_if_t<std::is_integral_v<T>, bool>
IsOdd(T i) { return i % 2!= 0; }

int main()
{
	bool a = IsOdd(1);     // a为true
	bool b = IsOdd(2.1);   // 未找到匹配的重载函数 [!code error]
}

在此例中,IsOdd(1) 模板类型参数推断为 intstd::is_integral_v<int> 为真,所以 _Test 为真,可以获取到 type 成员,能够匹配成功;而 IsOdd(2.1) 模板类型参数推断为 doublestd::is_integral_v<double> 为假,所以 _Test 为假,没有 type 成员,导致替换失败,此时又没有其他重载,所以报错。

2. 作为函数参数

template<typename T>
void PrintValue(T value,
                std::enable_if_t<std::is_integral_v<T>, int> = 0)
{
    std::cout << "整数类型: " << value << '\n';
}

template<typename T>
void PrintValue(T value,
                std::enable_if_t<std::is_floating_point_v<T>, double> = 0.0)
{
    std::cout << "浮点类型: " << value << '\n';
}

int main()
{
    PrintValue(42);        // 整数类型: 42
    PrintValue(3.14);      // 浮点类型: 3.14
    // PrintValue("hello"); // 未找到匹配的重载函数 [!code error]
}

在这个例子中,我们通过函数参数的默认值来利用 SFINAE。该模板只允许 intdouble 类型,其他类型都会导致报错。注意这里为 std::enable_if_t 设置了默认值,表示它虽然在函数参数列表中,但我们可以不必理会。

3. 作为模板参数

template <typename T,
          typename = std::enable_if_t<std::is_arithmetic_v<T>>>
T Add(T a, T b)
{
	return a + b;
}

int main()
{
	int res1 = Add(1, 2);
	double res2 = Add(1.1, 2.2);
	auto res3 = Add(std::string("1"), "2"); // 未找到匹配的重载函数 [!code error]
    auto res4 = std::string("1") + "2";     // res4 == "12"
}

把它放到模板参数中也是一样可以进行类型约束的,比如这里,std::string 和字符串字面量原本是可以相加的,但因为不是算数类型,std::enable_if_t<std::is_arithmetic_v<T>> 获取 type 失败,所以报错。

需要注意的是,不能通过仅改变模板参数默认值来重载函数模板,因为默认参数不参与函数签名的构成,这会导致重定义错误,例如我们像函数参数的例子那样在模板参数中分别指定 intdouble 的默认值:

template<typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void PrintValue(T value)
{
	std::cout << "整数类型: " << value << '\n';
}

template<typename T, typename = std::enable_if_t<std::is_floating_point_v<T>>>
void PrintValue(T value)
{
	std::cout << "浮点类型: " << value << '\n';
}

报错:

Error C2995 : “void PrintValue(T)”: 函数模板已经定义
    X:\Projects\Test\run.cc(5,6):
    参见“PrintValue”的声明

Error C2572 : “PrintValue”: 重定义默认参数 : 参数 2
    X:\Projects\Test\run.cc(5,6):
    参见“PrintValue”的声明

小结

三种使用方法大同小异,都是在定义函数时加上了模板参数的类型约束,唯一不同的是 std::enable_if_t 作为模板参数时,不能重载多个类似的函数模板。

2.3 缺点

尽管 SFINAE 功能强大,但它的缺点也显而易见:

  • 高冗余性:实现多个分支需要编写多个几乎相同的函数模板声明,只有 std::enable_if_t 部分不同。
  • 代码分散:相关的逻辑被分散在多个独立的函数重载中,降低了代码的内聚性。
  • 可读性差std::enable_if_t 的语法对于不熟悉模板元编程的开发者来说非常晦涩。
  • 糟糕的错误信息:如果传入的类型不匹配任何一个 std::enable_if_t 条件,编译器通常会给出一个“找不到匹配的函数”的错误,而不会告诉用户具体是哪个约束没有被满足 。

SFINAE 就像一个在模板系统入口处的“语法门卫”,它根据类型是否持有正确的“通行证”来决定是否放行某个重载进入最终的候选名单。它只能控制“谁能进”,但无法改变“进去之后做什么”。这种局限性,正是 C++17 的 if constexpr 和 C++20 的 Concepts 旨在解决的核心痛点。

不过,在此之前,我想先讲解编译时计算。

在 C++17 引入 if constexpr 之前,还有一种技术叫做“标签派发”(Tag Dispatch),它也是通过函数重载来实现编译时分支,与 std::enable_if_t 类似。受限于篇幅,本文不会介绍。

3. 编译时计算:压榨编译器

模板元编程不仅限于类型操作,它还使这一惊人的操作成为了可能:在编译阶段执行计算(Compile-Time Computation)。这意味着某些计算结果可以在程序运行之前就被确定下来,并作为常量嵌入到最终的可执行文件中。这对于性能优化、常量生成和算法配置等方面具有重要意义。

诚然,我们也可以通过预编译命令来实现编译时(预编译)计算,比如下面这个计算阶乘的例子:

#include <iostream>
#define FACTORIAL_0 1
#define FACTORIAL_1 1
#define FACTORIAL_2 (2 * FACTORIAL_1)
#define FACTORIAL_3 (3 * FACTORIAL_2)
#define FACTORIAL_4 (4 * FACTORIAL_3)
#define FACTORIAL_5 (5 * FACTORIAL_4)

#define CONCAT(a, b) a##b
#define FACTORIAL(n) CONCAT(FACTORIAL_, n)

int main() {
    int fact5 = FACTORIAL(5);  // 在预处理阶段展开为 (5*(4*(3*(2*1))))=120

// 使用条件编译进行不同的计算
#if FACTORIAL(3) > 5
    std::cout << "3! > 5\n";
#else
    std::cout << "3! <= 5\n";
#endif
}

但这种方式有许多缺点:

  • 类型不安全:宏只是简单的文本替换,没有类型检查
  • 作用域问题:宏没有作用域概念,可能意外影响其他代码
  • 调试困难:预处理器在编译之前工作,调试器无法跟踪宏展开的过程
  • 可读性差:复杂的宏定义难以理解和维护
  • 功能有限:无法进行复杂的逻辑运算和类型操作

在现代 C++中,我们有两种主要的方式来实现编译时计算:传统模板元编程和更现代的 constexpr 函数。

3.1 传统模板元编程

在 C++11 引入 constexpr 之前,编译时计算完全依赖于模板的递归实例化和特化,只能用 enum 来表示编译期常量。这种编程范式与常规的 C++ 代码截然不同,它更像是一种嵌入在 C++ 中的纯函数式编程语言,拥有独特的语法和规则。

其核心技术包括:

  • 递归:通过模板的递归实例化来模拟循环
  • 状态:由于编译时没有可变状态,计算结果通过静态成员(如 ::value)或类型别名(如 ::type)在递归调用之间传递
  • 终止条件:通过模板特化(全特化或偏特化)来定义递归的基准情况(base case),从而终止递归

计算阶乘是展示传统 TMP 最经典的例子(代码链接):

template <int N>
struct factorial {
    enum { value = N * factorial<N - 1>::value };
};

template <>
struct factorial<0> {
    enum { value = 1 };
};

int main()
{
    constexpr auto value = factorial<5>::value;
}

编译结果如下,可以看到 factorial<5>::value 确实在编译时被计算为 120:

请添加图片描述

3.2 constexpr 函数

constexpr 可以用于变量和函数,表示它们可以在编译时被求值。constexpr 函数的发展历程本身也体现了 C++ 的演进:

  • C++11:constexpr 函数受到严格限制,被戏称为“draconic”(严苛的)。函数体内只能包含一条 return 语句,不允许有局部变量、循环或分支语句 。

  • C++14/17:这些限制被大幅放宽。constexpr 函数现在可以包含局部变量、if 语句、switch 语句和循环,使其在外观和功能上与普通函数越来越相似 。

让我们用 constexpr 函数重写阶乘计算(代码链接):

#include <iostream>

// C++14/17 风格的 constexpr 函数
constexpr unsigned long long factorial(int n) {
    if (n <= 1) {
        return 1;
    }
    unsigned long long result = 1;
    for (int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

int main() {
    auto six_factorial = factorial(6);
}

编译结果如下,符合预期。:

请添加图片描述

这段代码的可读性与传统 TMP 版本相比有了天壤之别。它看起来就像一个普通的 C++ 函数。

随着 C++ 标准的不断更新,越来越多的库函数也都用上了 constexpr 函数,比如:

// C++17
template< class T, std::size_t N >
constexpr std::size_t size( const T (&array)[N] ) noexcept;

到 C++20 时,<algorithm> 头文件中的许多函数都用上了 constexpr 函数,这使得我们可以在编译时更加轻松地完成复杂计算:

  • std::findstd::find_ifstd::find_if_not
  • std::replacestd::replace_if
  • std::sortstd::minstd::max
  • std::fillstd::fill_n

一个关于 constexpr 的关键点是:将一个函数声明为 constexpr,只是赋予了它在编译时运行的潜力,但并不保证它一定会在编译时运行。它能否在编译时运行,取决于调用它时的上下文 :

  • 如果 constexpr 函数的所有参数都是编译时常量(如字面量或其他 constexpr 变量),并且其结果被用于需要编译时常量的上下文中(如 static_assert、数组大小、模板非类型参数),那么编译器必须在编译时计算它。
  • 如果任何一个参数是运行时变量,那么这个 constexpr 函数就会像一个普通函数一样,在运行时被调用。

这种“双模式”特性是 constexpr 的一大优势。同一个函数定义可以服务于编译时和运行时两种场景,这完美体现了 C++ 的零开销抽象和代码复用原则 。

4. if constexpr:模板元编程的未来

曾经,如果我们希望实现编译时(预编译)分支,就必须借助预编译命令 #if#else#endif 来实现,比如:

#define DEBUG 1

int main()
{
#if DEBUG
	std::cout << "Debug mode";
#else
	std::cout << "Release mode";
#endif
}

在 C++17 引入 if constexpr 之后,我们就可以更加优雅地实现编译时分支:

template <typename T>
auto get_value(T t) {
	if constexpr (std::is_pointer_v<T>) {
		std::cout << "pointer: ";
		return *t;
	} else {
		std::cout << "non pointer: ";
		return t;
	}
}

int main()
{
	int a = 10;
	int* b = &a;
	std::cout << get_value(a) << '\n'; // non pointer: 10
	std::cout << get_value(b) << '\n'; // pointer: 10
}

再说回 SFINAE,现在我们就可以用 if constexpr 来代替 std::enable_if_t 来实现类型约束,比如上面整型和浮点数重载的例子(代码链接):

template <typename T>
void PrintValue(T value)
{
	if constexpr(std::is_integral_v<T>)
	{
		std::cout << "整型" << value;
	}
	else if constexpr(std::is_floating_point_v<T>)
	{
		std::cout << "浮点型" << value;
	}
	else
	{
		std::cout << "unknown";
	}
}

int main()
{
	PrintValue(10);     // 整型10
	PrintValue(2.5);    // 浮点型2.5
    PrintValue("Fuxi");	// unknown
}

是不是感觉瞬间清爽了许多?观察编译后的结果,虽然我们只写了一个函数模板,但由于有 if constexpr 的存在,编译器会根据不同的类型生成不同的函数实例,这就类似 #if 等预编译命令,从而巧妙地实现了原本 SFINAE 的功能。

请添加图片描述

对比之下,if constexpr 的优势一目了然:

  • 可读性:所有逻辑都集中在一个函数内,并且以顺序、线性的方式呈现,意图清晰明了。
  • 可维护性:如果需要增加对其他类型(例如自定义的数字类)的支持,只需在函数体内增加一个 else if constexpr 分支,而无需添加一个全新的、复杂的 SFINAE 重载。
  • 意图的直接性:代码直接表达了“如果类型是 X,就做 A;否则如果类型是 Y,就做 B”,这比 SFINAE 通过重载解析的间接机制要清晰得多。

注意,这里必须强调的一点是:if constexpr 和 SFINAE 是两种完全不同的机制 。SFINAE
作用于重载解析阶段,用于从候选函数集中筛选函数。而 if constexpr
作用于模板实例化阶段,在函数已经被选定之后,用于决定函数体内的哪些部分被编译。这个技术上的区别,导致了它们在编程风格上的哲学转变:SFINAE 鼓励将不同逻辑分散到多个函数中,而 if constexpr 则鼓励将相关逻辑聚合在单个函数内,从而提高了代码的内聚性和可理解性。

5. Concepts:模板编程的契约设计

尽管 if constexpr 极大地改善了在模板内部编写条件逻辑的方式,但它并未解决一个更根本的问题:如何清晰、直接地在模板的“接口”上声明其对类型参数的要求。长久以来,C++ 模板因其糟糕的错误信息和隐晦的类型要求而饱受诟病 。C++20 引入的 <concepts> 头文件,正是为了彻底解决这一历史难题而设计的。

你可能还是有疑惑:模板不正是用于泛型编程的吗,为啥还要约束类型呢?

C++ 模板的设计初衷就是为了泛型编程。但问题在于,最初的模板设计过于“泛滥”,缺乏约束,这在实践中带来了巨大的痛苦。在没有 <concepts> 的时代,模板的要求是隐式的,它们“隐藏”在模板的实现细节中。当开发者尝试使用一个不满足这些隐式要求的类型来实例化模板时,编译器会深入模板内部进行替换,直到某个操作失败为止。其结果,就是那堵臭名昭著的“模板错误信息墙”——成百上千行令人费解的、层层嵌套的错误日志,让开发者难以定位问题的根源。

C++20 的 Concepts 并非要改变模板的泛型初衷,而是为了给这份“泛滥”的泛型能力套上一个“缰绳”,使其更安全、更易用、更强大。

<concepts> 通过以下方式解决了这些问题:

  • 明确的接口:它允许我们为模板参数定义一组明确的、具名的约束。这些约束成为了模板接口的一部分,就像函数参数的类型一样清晰 。

  • 改善的错误信息:当一个类型不满足 concept 时,编译器不再深入模板内部,而是直接在调用点报告一个简洁明了的错误,指出“模板参数 T 不满足 MyConcept 约束” 。

  • 更强的表达力:它提供了一种正式的语言机制来表达对类型的语义要求,而不仅仅是语法上的要求。

从根本上说,<concepts> 旨在将模板编程从一种基于“语法试错”(duck typing)的模式,转变为一种基于“契约设计”(design by contract)的模式。

5.1 定义与使用 <concepts>

C++20 引入了两个新的关键字:concept 用于定义一个概念,requires 用于施加约束。

定义

一个 concept 的定义本质上是一个编译时布尔谓词。约束表达式可以是一个简单的 bool 常量,通常由类型萃取或其他 constexpr 表达式得出。例如,标准库中的 std::integral 概念可以这样自定义:

#include <concepts>
#include <type_traits>

template <typename T>
concept Integral = std::is_integral_v<T>;

使用

一旦定义了 concept,我们有四种主要的方式来约束一个函数模板:

  1. 尾随 requires 子句(Trailing requires clause)
template <typename T>
void func(T arg) requires Integral<T> { /*... */ }
  1. 前置 requires 子句(Leading requires clause)
template <typename T>
    requires Integral<T>
void func(T arg) { /*... */ }
  1. 约束模板参数(Constrained template parameter)
template <Integral T>
void func(T arg) { /*... */ }
  1. 缩写函数模板(Abbreviated function template)
// 不用写 template
void func(Integral auto arg) { /*... */ }

这四种语法在功能上是等价的,但提供了不同程度的简洁性和表达力。特别是第四种,它使得编写简单的泛型函数几乎和编写非泛型函数一样简单。

或许你不相信第四种也是函数模板,我们可以看看不调用它的编译结果,注意要使用 -std=c++20代码链接):

请添加图片描述

组合

requires 子句支持使用逻辑与(&&)和逻辑或(||)来组合多个 concept,形成更复杂的约束:

template <typename T>
    requires Integral<T>        // 自定义 concept
    || std::floating_point_v<T> // 标准库 concept
void numeric_func(T arg) { /*... */ }

当然,我们也可以换一种方式:

template <typename T>
concept Integral = std::is_integral_v<T>;

template <typename T>
concept FloatintPoint = std::is_floating_point_v<T>;

template <typename T>
concept Combine = Integral<T> || FloatintPoint<T>;

5.2 requires 表达式:定义精细化的要求

Concepts 的真正威力在于,它们不仅能包装简单的类型萃取,还能通过 requires 表达式来定义一组非常具体和细化的语法要求,比如:

template<class T>
concept IsVector = requires (T a)
{
    a[0];           // 要求 T 有 operator[] 方法
    a.begin();      // 要求 T 有 begin 方法
    a.reserve(1);   // 要求 T 有 reserve 方法
    a.data();       // 要求 T 有 data 方法
};

template<IsVector T>
void Test(T v)
{
	std::cout << "is vector";
}

int main()
{
	std::vector<int> v = {1, 2, 3, 4, 5};
	Test(v);    // 输出 is vector
	int arr[] = {1, 2, 3, 4, 5};
	Test(arr);  // 报错:因为约束而替换失败,requires(int* a){...} is false [!code error]
}

5.3 重载与代码简化

Concepts 为函数模板重载提供了强大而清晰的机制。当多个模板对同一个调用都有效时,编译器会选择那个约束最严格(most constrained)的重载版本。约束的严格程度由“约束子集”关系决定。例如,SignedIntegralIntegral 更严格,因为所有满足 SignedIntegral 的类型都满足 Integral,但反之不然:

#include <concepts>
#include <iostream>

void PrintCategory(std::integral auto) {
    std::cout << "Generic integral\n";
}

void PrintCategory(std::signed_integral auto) {
    std::cout << "Signed integral\n";
}

int main() {
    PrintCategory(10u); // 只能调用 std::integral 版本
    PrintCategory(-10); // 调用 std::signed_integral 版本 (更严格)
	PrintCategory(3.3); // 报错:未满足任何约束 [!code error]
}

这种基于概念的重载解析,正是 SFINAE 试图以一种间接方式实现的目标,但 Concepts 的语法要清晰直观得多。

Concepts,特别是缩写函数模板的语法,极大地降低了编写泛型代码的门槛。它统一并简化了过去由繁琐的约束机制,提供了一个单一、内聚且强大的语言特性,标志着 C++ 泛型编程进入了一个全新的时代。

6. 总结

在这篇文章中,我们穿越了 C++ 模板元编程的演进之路,从基础的类型探查工具到定义未来泛型代码的先进理念。这段旅程清晰地揭示了 C++ 语言为解决模板约束这一核心挑战所付出的不懈努力。

我们的探索始于类型萃取(Type Traits),这些编译时的探针为我们提供了审视和转换类型的底层能力。在此基础上,SFINAE 应运而生,它利用巧妙的语言规则,以一种间接但有效的方式实现了早期的模板约束。

C++11 及后续标准带来了转折点。constexpr 将编译时值计算从晦涩的模板递归中解放出来,使其语法回归 C++ 的常规风格。而 C++17 的 if constexpr 则是一场优雅的革命,它用清晰的编译时分支逻辑取代了 SFINAE 在实现选择上的主要用途,极大地提升了代码的可读性和内聚性。

最终,我们抵达了 C++20 的 Concepts。它不是对旧有技术的简单改进,而是一次彻底的范式重塑。Concepts 为模板提供了明确的接口和契约,将约束从隐晦的实现细节提升为语言的一等公民。它不仅解决了长期困扰开发者的模板错误信息问题,更通过清晰的语法和强大的表达力,使得编写健壮、可读的泛型代码如此轻而易举。

回顾这条从类型萃取到 Concepts 的演进路径,我们看到的是一个从“特设技巧”(ad-hoc techniques)到“系统化设计”(systematic design)的转变。现代 C++ 为泛型编程提供了一个清晰、强大且统一的工具集。

参考文献

  1. 雾里看花:真正意义上的理解 C++ 模板
  2. C++ Template Programming
  3. Metaprogramming library - cppreference
  4. Standard library header <type_traits> - cppreference
  5. Standard library header <concepts> - cppreference
  6. SFINAE - cppreference
  7. constexpr specifier - cppreference
  8. Standard library header <algorithm> - cppreference
  9. What are C++20 concepts and constraints? How to use them?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

伏^羲

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值