65.9K
CodeProject 正在变化。 阅读更多。
Home

C++ 元编程:温和的介绍

starIconstarIconstarIconstarIconstarIcon

5.00/5 (11投票s)

2021年3月23日

CPOL

28分钟阅读

viewsIcon

25125

downloadIcon

183

通过破解编译器来提高其优化能力,使您的代码更高效

metaprogramming

引言

我最近正在开发一个图形库,它提供了关于其各个方面的丰富编译时信息。您可以做一些事情,比如获取像素每个颜色通道的信息,例如它的名称,或者表示它需要多少位。

所有可能的事情都在编译时计算,因为这个库主要用于小型设备,它们无法承担运行时开销,而这种开销将允许该库在编译时提供大部分功能。

一个特别具有挑战性的部分是在编译时按名称查找像素的特定通道——当然是在编译时。我很不好意思地说这花了我大半天的时间,所以我写了这篇文章,希望能帮助您避免遇到同样的问题。

请注意,这里的所有内容都将使用 C++11 标准编译。C++11 在元编程方面没有后期标准那么多语法糖和特性,这实际上使其成为演示基本技术的好选择,而新语言特性不会掩盖其机制。

注意:模板实例化会创建很长的代码行。情况就是这样。我已尽力重新格式化代码,使其在此站点上易于呈现,但在代码格式化方面,我不得不降低优先级,专注于让代码适应网页宽度。我的意思是,代码格式化还有待改进,因为它的编写是为了避免在网站上到处换行,而不是为了代码结构的最佳可读性而编写的。

元编程速成课

我想先重申 Bjarne Stroustrup 谈到 C++ 时所说的一点——如果说 C++ 是关于什么的,那它就是关于模板,而不是关于对象作为主要范式。

用 C++ 编程就是使用模板。如果您不使用它们,您就是在回避一个主要的语言特性。本质上,如果您不使用模板,您就不是真正在使用 C++。如果您使用 C++ 是为了它的面向对象能力,请考虑其他语言,因为尽管 C++ 可以进行面向对象编程,但在将面向对象编程作为主要范式时,它并不是最有效的语言。

现在

// construct a 16-bit RGB color pixel
// in big endian format. with 5,6, 
// and 5 bits for R, G, and B, 
// respectively:
using rgb565be = typed_pixel<
    color_model::rgb,
    endian_mode::big_endian,
    channel_traits<channel_name::R,5>, // red
    channel_traits<channel_name::G,6>, // green
    channel_traits<channel_name::B,5>  // blue
    >;
// retrieve the red color channel (named "R")
using the_channel = 
    typename rgb565be::channel_type_by_name<
                channel_name::R>;

这段看似简单的代码对我来说实现起来非常具有挑战性,但我仍在提升我的元编程技能。无论如何,它所做的只是构建一个具有特定二进制格式的 3 通道 RGB 像素,然后通过像素的类型定义按名称查找 `R` 通道。它在运行时什么也不做——这完全是使用涉及元编程的编译时计算。如上所述,这种技术对于从可变参数模板中按名称检索命名参数很有用。

如果您从未用过可变参数模板,并且不知道参数包是什么,您可能需要点击这些链接获取更多信息并温习一下,因为我们将在本文中使用它们。事实上,它们是本文的核心,但我会在下面相当详细地解释它们。

然而,在我们讨论这些之前,我们需要涵盖一些基础知识。其中许多基础知识在 STL 中已经提供,但了解它们的工作原理很重要,因此我们将在本代码中实现类似的模板。

接近 O(log N)•0

这里的 `•0` 是操作理念。最有效的代码是永不运行的代码。元编程的原因是通过尽可能将工作卸载到编译器上,从而提高应用程序的效率。这利用了现代 C++ 编译器的先进特性及其强大的优化能力,并将这些能力扩展到远远超出其最初目的的范围。

这种对 C++ 编译器优化功能的“扩展使用”差不多就是黑客行为了。这是坏消息。代码反直觉,对外行来说简直是巫术,即使是经验丰富的开发人员也可能难以理解,但它的强大和灵活性在需要时可以弥补这些缺点。

尽管元编程存在缺点,但随着 C++ 的发展,元编程技术正作为语言的“一等”元素被纳入,使其更清晰、更集成,因此更容易理解和维护。因此,从 C++11 开始,掌握 C++ 元编程技术是一个好主意。

没有类型

类型并不真正存在。没错。它们不是“真实”的。我的意思是,你的运行中的二进制代码完全没有类型的概念。类型只是一个组织工具和一种机制,它告诉你如何解释它指向的数据。类型,充其量只是一个抽象的立面。例如,一个带有字段的 `struct` 只是让编译器知道每个数据字段在内存中的偏移量。当程序编译时,这些偏移量被“烘焙”到代码中,类型定义本身被丢弃。你访问的不是 `customer.name` - 你访问的是栈上的特定偏移量(或堆上的某个位置)。这就是为什么你需要像 RTTI 这样的东西来在运行时获取类型信息,以及符号来在调试器中获取类型信息。把类型想象成具体模型。它们给你的数据赋予形状,但一旦数据设置好,模型/类型就不再需要了。

这一点之所以重要,是因为本文中的技术有时会创建数百甚至数千种类型,而且常常是递归的。就其本身而言,这对您的运行代码没有影响,但它确实会影响编译代码所需的时间,因为编译器必须跟踪所有这些信息。

我创造,故我在。

“我创造,故我在”——你的编译器。创造就是计算。如果我们想在编译时解决一个算法,它通常涉及创建类型作为过程的一部分。我来解释

有两种主要方法可以使编译器为您进行非平凡的计算。您可以使用 `constexpr` 指示编译器某个函数可以在编译时执行,并且其结果可以在编译时使用。例如,这对于计算素数并让编译器处理提供值而不是在程序启动时计算您的静态查找表非常有用。编译时机制零开销,因此在 CPU 时间方面效率无限高。您仍然需要实际的表用于您的运行时代码,但这样,您就不必在运行时计算它。

另一种方法是模板实例化和折叠。折叠没什么花哨的。几乎每种语言都这样做。它只是在所有值在编译时已知的情况下求解方程的过程,例如

static const int a = 2;
static const int b = 3;
// c is "folded" to 6:
static const int c = a * b; 

模板实例化主要涉及模板(通常会折叠值),同时递归地实例化更多的模板,直到达到终止特化。这是一个简单的示例,它在编译时计算整数的 `pow()`

// analog to pow() at compile time
template<int X,int Y> struct power {
    constexpr static const long long value = 
            X * power<X,Y-1>::value;
};
// terminating specialization
template<int X> struct power<X,0> {
    constexpr static const long long value = 1;
};

请注意我们如何拥有基本模板和特化。对于任何递归模板实例化的终止条件,您几乎总是需要一个特化。在这里,我们的终止条件是 `Y = 0`,如第二个模板声明所示。

然而,这里的关键在于我们的第一个模板声明。看看我们是如何将当前 `X` 参数的乘法结果与我们模板的递归实例化结果折叠起来的,同时将 `Y` 减一。当 `Y` 达到零时,它将触发我们的特化,该特化只返回 `1`。这就是魔法。所有这些最终都被编译器折叠成一个最终值,因为每个值都是常量且在编译时已知。

对于 `power<2,8>`,我们最终折叠成 `2*2*2*2*2*2*2*2*1`。我们的模板实例化看起来像这样

struct power<2,8> {
    constexpr static const long long value = 
            2 * power<2,7>::value;
};
struct power<2,7> {
    constexpr static const long long value = 
            2 * power<2,6>::value;
};

...

struct power<2,1> {
    constexpr static const long long value = 
            2 * power<2,0>::value;
};
struct power<2,0> {
    constexpr static const long long value = 
            1;
};

一旦你掌握了它,这就相当简单了。这种递归实例化进行迭代的技术非常重要,因为元编程的很大一部分都依赖于它,没有它你走不了多远。

为了让您知道我没有在编译时开玩笑,请查看 Compiler Explorer 使用 `-fwhole-program -Os -std=c++11` 的输出

.LC0:
        .string "power<2,8> = %lli\r\n"
main:
        push    rax
        mov     esi, 256
        mov     edi, OFFSET FLAT:.LC0
        xor     eax, eax
        call    printf
        xor     eax, eax
        pop     rdx
        ret

这是使用上面提供的 C++ 代码编写的整个应用程序的汇编代码。请注意粗体行。编译器只是将 `power<2,8>::value` 替换为 `256`!大部分代码只是调用 `printf()` 然后从 `main()` 返回。

计算幂可能不是世界上最有用的事情,但计算三角函数呢?只需稍作努力,您就可以创建一个负幂“函数”来补充上述功能,然后另一个函数运行一个幂级数展开来计算,比如说,正切到一个特定的分辨率。这有点极端,但我只是在说明可能性。正如我之前所说,您不再需要浪费启动周期来计算静态查找表了。

您可能已经注意到上面使用了 `constexpr`。我在元编程时经常使用它——当然比我真正需要的多得多,但有充分的理由。虽然 `constexpr` 只是表明某事可以在编译时解析,但编译器通常不需要它来知道这一点。然而,如果您使用 `constexpr`,它不仅阐明了意图——这段代码是为编译器准备的,并且可能根本不会在运行时使用——它还强制执行了编译器对其愿意执行的代码施加的限制。例如,如果您在函数定义上使用它,您不能做像调用 `printf()` 这样的事情,因为那在编译时会做什么呢?那没有任何意义。您也不能做像无限循环这样的事情,原因显而易见。使用 `constexpr`,如果我越界了,编译器会报错,而不是简单地将函数放入运行时代码,然后在程序执行期间调用它。请注意我说的是它可以被在编译时调用。这样的字段和函数也可以被运行中的代码引用,在这种情况下,编译器将为运行时代码插入这些字段和函数的代码,无论编译器是否在编译时运行它。

代码膨胀的迷思

C++ 模板经常被批评引入代码膨胀。我认为一个原因是 STL 的设计目标是通用性,而不是效率上的优先级。我认为另一个原因是,很容易导致编译器复制代码,尤其是在设计不佳的模板中。最后,C++ 编译器过去在删除模板引入的所有“死代码”方面表现不佳,但对于现代编译器来说,情况根本不是这样。

真相,通常情况下,要复杂得多。

确实,编写糟糕的模板很容易,并且可能会导致编译器创建两个或更多几乎相同的代码副本。解决这个问题的方法是始终在诸如上面链接的 Compiler Explorer 中查看模板实例化的输出。我倾向于在一个单独的应用程序中将我的模板原型化到单个 `main.cpp` 文件中,以便于粘贴到 Compiler Explorer 中,这样我就可以隔离它们并查看它们实例化时产生的内容。

话虽如此,编写良好的模板可以极大地缩小您的代码,同时提高性能,甚至减少应用程序的内存占用。原因正如已经暗示的那样——最有效的代码是永不运行的代码,或者更好的是,永不编译到您的二进制文件中的代码。这就是我所说的“O(log n)•0”的意思。模板和 `constexpr` 函数元编程可以消除在运行时进行计算所需的内存和 CPU 能力。您可以生成特定代码以满足您的用例,并使用有效的元编程技术消除处理通用情况的代码。通过这种方式,您的代码可以通过模板保持其灵活性,同时提高代码的运行时效率。元编程有效地允许您“引导”编译器进行它原本不会知道的复杂优化。正如我们上面所做的那样,消除生成静态查找表的启动计算时间就是现实中的一个简单示例。

让位给 Typedef

如果您不熟悉 `using` 关键字的新用法,它现在取代了 `typedef` 成为更强大的类型别名替代方案。与 `typedef` 不同,`using` 可以用于为模板而不是仅仅模板实例化创建别名

using int_t = int; // equivalent to typedef int int_t;
// the following is a template alias
template<typename X,typename Y> using pair_vector 
                        = std::vector<std::pair<X,Y>>;

可变参数模板的元编程

可变参数模板接受任意数量的模板参数。它们是一个强大的功能,为您的代码增加了显著的灵活性。不幸的是,它们可能难以使用。

您可以使用如下语法声明一个可变参数模板

template<typename... Args> struct variadic_example {};

`Args` 被称为“参数包”,它可以被“展开”成一系列逗号分隔的值,代表传递给它的每个参数。它在词法上很奇怪,因为它确实展开成逗号分隔的值,而不是更“正式”的东西,比如任何真实的列表或数组。它让我想起了环境变量展开,如果您理解我的意思,我认为这样想会有所帮助。

参数包

“参数包”是可变参数模板参数列表末尾的可变参数列表。它们几乎可以在任何地方展开,以提供一个可变参数列表,无论是用于函数还是用于另一个模板实例化。尽管它们功能强大,但它们也有一些限制

  1. 没有直接的方法可以在不展开参数的情况下对其进行索引或搜索。
  2. 没有直接的方法可以在不展开参数的情况下对其进行过滤。
  3. 您不能在不展开参数包的情况下为其创建别名或“保存”它,也不能直接将其传递给另一个模板。
  4. 您可以使用参数包声明一个带有多个参数的函数,但不能使用它声明可变数量的 `struct` 或 `class` 成员。
  5. 使用它们创建带可变参数数量的函数,基本上要求您创建一个模板函数,然后递归调用它以依次处理每个参数,使用前面概述的递归迭代技术,但用于方法。这有点笨拙,效率不高,除非您将所有内容内联并且您的参数列表通常只有几个项目。
  6. 当您以这种方式通过展开参数声明函数签名时,您不能为函数参数指定名称。
  7. 参数包必须声明为模板的最后一个参数。

最新的标准已经放宽或取消了其中一些限制。

现在我们已经介绍了你不能用它做什么,让我们来介绍你能做什么。

可变参数模板最明显的用途可能是一个元组类型。由于元组可以包含任意数量的类型参数,因此它成为了可变参数模板的一个用例。值得注意的是,STL 中已经包含了一个 `tuple` 类,但为了演示它的工作原理,我们在这里将重新实现它

template<typename...Types> 
struct tuple { };

这是我们最基本的 `tuple` 实现。它不包含任何字段,因此它本身并没有多大用处。事实上,它并没有多大意义——我们用 `Types` 参数包做了什么?答案很简单:什么也没做。我们将对模板进行特化,以便此版本仅在 `Types` 不包含任何参数时才实例化。希望现在这更有意义了。

参数包“剥离”

我认为“剥离”不是一个官方术语,但它是我用来表达从参数包参数列表中弹出或“剥离”第一个参数并将其用于模板中的概念的词。这是一个如此常见的模式,值得一个名字。这个名字似乎和其他名字一样好。

我提到我们将对上述模板进行特化。通过这样做,我们将参与“剥离”参数包中的参数的过程,然后将剩余的参数转发给递归模板实例化

template<
            // this "peels" the first argument
            // from the parameter pack for 
            // processing here:
            typename Type,
            // declare a template parameter
            // pack:
            typename... Types
        >
struct tuple<Type, Types...> {
    // our current tuple value
    Type value;
    // here we instantiate a new tuple
    // with one fewer argument
    tuple<Types...> next;
    // we expand the parameter pack into 
    // arguments to our ctor. 
    constexpr inline tuple(
        const Type& value, 
        const Types& ... next)
        : value(value)
        , next(next...) {
    }
};

我已尽力对上面的模板的不同元素进行了简要解释,但它值得进一步解释,也许还需要一个例子。

首先,在这个模板特化中,我们在参数包 `Types` 之前有一个额外的 `typename` 参数 `Type`。这是为了方便剥离。我将解释它是如何工作的

假设我传递 `tuple`,它可以表示一个 3 参数的参数包,或者——注意了——一个单独的参数后跟一个 2 参数的参数包。它们是同一回事。编译器老实说并不关心,但它会首先选择最特化的模板,在这种情况下,就是 `Type=int` 的单个参数后跟一个 2 参数的包 (`Types[0]=double`, `Types[1]=char`)。这就是我们剥离的方式——我们创建一个模板,它接受一个前置参数,该参数与参数包中的参数类型相同。当编译器展开参数包时,它将展开成(如上所示)`int`, `double`, `char`。它不关心模板将第一个参数作为其自己的参数。它会将这个特定的逗号分隔列表传递给模板,并遵循它指向的任何地方——甚至是一个带有一个参数包的参数,然后是参数包本身的模板。这就是使其工作的魔力。

展开本质上是这样的

struct tuple_1 {
    int value;
    struct tuple_2 {
        double value;
        struct tuple_3 {
            char value;
            struct tuple_4 {
                // from the initial
                // empty declaration
            } next;
            inline constexpr tuple_3(
                const char& value1
            ) : value(value1) {}
        } next;
        inline constexpr tuple_2(
            const double& value1, 
            const char& value2) :
            value(value1),
            next(value2) { }
    } next;
    inline constexpr tuple_1(
        const int& value1,
        const double& value2,
        const char& value3) :
        value(value1), 
        next(value2,value3) { }
};
使用递归处理参数包

元组的每个字段都有自己的嵌套元组,其中包含剩余字段。这都是递归!记住这一点。这就是你处理可变参数模板参数的方式——递归实例化。

要解决涉及可变参数模板参数包的问题,答案是递归。在某些情况下,有方法可以通过替代方案(如类型继承)来避免递归。它们与递归技术达到相同的效果,但不会让编译器那么辛苦。本文不讨论优化编译时间,因此我们不会深入探讨这些技术。

请注意,我们的构造函数是内联的,并用 `constexpr` 声明。本质上,我们不想要它们,我们告诉编译器消除它们,因为它们没有帮助。它们是微不足道的,除了告诉编译器这可以通过一系列类型参数构造之外,没有给代码添加任何东西。为参数赋值添加函数调用只会增加开销,并且不会节省代码空间。此外,使用 `constexpr` 告诉编译器这个构造函数是微不足道的,因此它可以从编译时已知的所有值实例化,而无需实际在运行代码中实例化它。这基本上是我们告诉编译器消除构造函数的第二种方式,此外我们还开放了 ctor 以用于 `static const` 字段,作为额外的好处,这个元组可以在编译时完全使用,并且永远不会放入运行代码中。

我们可以像这样使用元组模板

tuple<bool,int>
tuple1(false,3);

tuple<
    float,
    char,
    const char*>    
tuple2(3.14, '+',"foo"); 

printf("tuple1.value = %s\r\n",
    tuple1.value?"true":"false");
printf("tuple1.next.value = %d\r\n",
    tuple1.next.value);
printf("tuple2.next.next.value = %s\r\n",
    tuple2.next.next.value);

虽然我们可以通过一系列 `next` 引用来访问字段,但这并不方便。字段没有名称,所以我们不能以这种方式访问它们,但是如果我们能通过索引获取它们呢?

索引参数包

现在我们遇到了一段稍微棘手的代码——通过索引从参数包中获取特定参数。

这个概念很容易理解,主要是因为我们之前已经用 `power` 模板做过类似的事情。我们从一个特定的计数开始——在这种情况下是期望的索引,然后递归地实例化模板,传递递减的索引,直到我们通过特化达到零。与我们处理 `power` 的方式唯一的不同是,我们在此过程中捕获了从模板参数包中剥离出来的参数,当然我们不需要乘法操作的 `X` 参数。

为了演示,我们将从一个公认的人为设计的例子开始,尽管如此,它仍然阐明了基本思想

template<
    size_t Index,
    typename Arg,
    typename... Args> 
struct arg_by_index {
    using type = typename 
        arg_by_index<Index-1,Args...>::type;
};
template<
    typename Arg,
    typename... Args> 
struct arg_by_index<0,Arg,Args...> {
    using type = Arg;
};

这里,我们有一个模板,以及该模板对于 `Index=0` 的特化。您可能已经注意到模板声明中的参数包和剥离模式,但请注意,没有不带前导 `typename Arg` 在参数包之前的模板声明。这是为了我们不接受 `Index` 参数之后的零参数。

在主模板声明中,我们正在创建一个别名,该别名递归地实例化一个索引递减的模板,并检索参数——一种被称为 `::type` 的类型。零的特化将处理实际参数的检索,您可以看到它确实做到了。如果您一直跟得上,这应该很容易理解。

我们这样使用它

printf("sizeof(arg_by_index<1... = %d\r\n",
    (int)sizeof(arg_by_index<1,short,char,long>::type));
    return 0;
}

上面将打印 `1`。然而,这并不是很有用。让我们看看如何索引到我们的元组类型,以获得更真实世界的示例

// tuple index helper base declaration
template<size_t Index,typename Type>
struct tuple_index_impl {};
// tuple index helper terminator specialization
// on index zero (specialization #1)
template<typename Type, typename... Types>
struct tuple_index_impl<0,tuple<Type,Types...>> {
    // indicates the type of the tuple itself
    using tuple_type = tuple<Type, Types...>;
    // indicates the first value type in the tuple
    using value_type = Type;
    // retrieve the tuple's value
    constexpr inline static value_type value(
                                tuple_type &t) {
        return t.value;
    }
    // set the tuple's value
    constexpr inline static void value(
            tuple_type &t,const value_type& v) {
        t.value=v;
    }
};
// specialization #2 (most used)
template<
        size_t Index,
        typename Type,
        typename... Types
    >
struct tuple_index_impl<Index, tuple<Type, Types...>> { 
    using tuple_type = tuple<Type, Types...>;
    using value_type = typename tuple_index_impl<
            Index - 1, 
            tuple<Types...>>::value_type;
    constexpr inline static value_type value(
                                tuple_type &t) {
        return tuple_index_impl<
            Index - 1, 
            tuple<Types...>>::value(t.next);
    }
    constexpr inline static void value(
            tuple_type &t,const value_type& v) {
        tuple_index_impl<
            Index - 1, 
            tuple<Types...>>::value(t.next,v);
    }
};
// static tuple by index getter method template
template<
    size_t Index, 
    typename TupleType
>
typename tuple_index_impl<Index, TupleType>::value_type 
    tuple_index(TupleType &t) {
    return tuple_index_impl<Index, TupleType>::value(t);
}
// static tuple by index setter method template
template<
    size_t Index, 
    typename TupleType
>
void tuple_index(
        TupleType &t,
        const typename tuple_index_impl<
                Index, 
                TupleType>::value_type& v) {
    return tuple_index_impl<Index, TupleType>::value(t,v);
}

这要复杂得多,但索引部分相对简单——只是还有更多事情正在发生。让我们来回顾一下。

考虑以下内容

tuple<bool,int> tuple1(false,3);
printf("tuple_index<1>(tuple1) = %d\r\n",
    tuple_index<1>(tuple1));

在这里,我们从 `tuple` 中请求第二个项,一个 `int`。

这首先调用模板函数 `tuple_index<1,tuple>(tuple1)`。请注意,第二个模板参数实际上是隐式生成的。编译器自动选择了它,因为它可以从函数的第一个参数强制转换。甚至 C# 也会以这种方式强制转换其泛型函数的参数。它使代码更容易编写,也更清晰易读,即使需要一点时间来适应。

本质上,任何时候你省略模板函数的尾随参数,它都会尝试使用传递给函数的第一个参数的类型来解析它。这允许你“重载”模板函数,使其根据你传入的参数为你生成正确的模板。

无论如何,`tuple_index()` getter 声明,尽管它很丑陋,但它只是实例化了 `tuple_index_impl` 几次*——一次是为了获取指定 `Index` 处元组字段的类型,并将其用作模板方法的返回类型,另一次是为了调用 `value(t)` 以检索指定索引处的当前值。*不必担心使用相同的参数多次实例化相同的模板。C++ 编译器必须缓存这些实例化,使得它们都必须返回相同的实例化类型。因此,在第一次实例化之后,进一步的实例化只是对已创建类型表的查找。通常,您可以在这些情况下创建别名,并简单地引用它,但对于上面,我觉得没有理由用只会在这里使用的额外名称来污染命名空间。

现在我们来解决核心问题——`tuple_index_impl<>` 模板。对于非空元组,且 `Index` 非零时,编译器将选择特化 #2。对于 `tuple_index<1>(tuple1)`,它将选择上述模板的第二个特化。

在该特化中,它捕获传入的当前元组的类型——记住每个元组实际上是一个值和每个剩余字段的嵌套元组——在 `tuple_type` 中。更重要的是,`value_type` 别名递归地实例化 `tuple_index_impl<>`,将递减的 `Index` 一路传递到特化 #1。这应该看起来很熟悉,因为我们已经不止一次这样做了。

该模板为主特化声明了两个 `value()` 方法重载。这些用于获取和设置值。请注意,第二个特化会递归调用,直到在 `Index` 为零时命中第一个特化,就像我们对 `value_type` 所做的那样。所有这些函数都是内联的,因为它们没有给运行代码添加任何东西——它们是微不足道的,所以包含它们不会节省任何空间。它们唯一需要的作用是引导编译器在正确的偏移量处生成正确的 `mov` 指令。之后,它们就可以被丢弃,而不是承担创建栈帧然后调用 `mov` 指令,然后销毁栈帧的开销。

现在我们已经解开了它正在做什么,我想我们可以继续最后一个技巧——将字符串附加到模板,然后在编译时对它们进行匹配。

弦理论

你不能直接将字符串作为模板参数传递。你的编译器会向你抱怨,这对任何人来说都不是一件有趣的事情。然而,仅仅因为你不能做某事并不意味着你不能做某事。毕竟,这是 C++。在 C++ 中,所有“你不能那样做”的真正含义是“你当然可以做,如果你愿意对你的编译器和你的代码库做一些可疑的事情。”

在这里,我们将放宽将字符串传递给模板的限制,我们将通过将字符串包装在类型中,并将这些类型传递给模板来实现。以下内容应该能使其清晰

#define STRING(x)                           \
    struct {                                \
        constexpr static inline const char  \
                  *value() { return x; }    \
    };

您可以像这样声明一个字符串

using foo = STRING("foo");

你可以在模板中这样接收它

template<typename String> 
static void print_str() {
    printf("%s",String::value());
}
print_str<foo>(); // prints "foo"

这并不是很聪明,但它是一个起点。我们真正想做的是在编译时匹配字符串。如果能做到这一点,我们就可以将一个字符串键或名称附加到我们传递给可变参数模板的每个类型上,然后通过这些可变参数搜索匹配的字符串,并返回关联的参数。

让我们通过第一步简化问题——从可变参数模板中获取字符串参数的索引。首先,使用初始模板声明

// helper that fetches the index of a string
template <size_t Index, 
    typename Match, 
    typename... Strings>
struct index_of_string_impl;

这里,我们有一个 `Index` 用于计数索引,一个 `Match` 类型名,它包含我们的“`string`”类型,最后是一个“`string`”列表。现在我们需要一个不带任何“`string`”的版本。我们用这个版本来表示“未找到”的情况

template <size_t Index, typename Match>
struct index_of_string_impl<Index, Match> {
    // this is what we return when it's
    // not found
    static constexpr size_t value = -1;
};

现在是核心部分——一般情况

template <size_t Index, 
        typename Match, 
        typename String, 
        typename... Strings>
struct index_of_string_impl<
        Index, 
        Match, 
        String, 
        Strings...> {
    static constexpr size_t value = is_same<Match, String>::value ?
                    // if it's a match return the current Index,
                    Index
                    :
                    // if it's not a match, instantiate the next
                    // template in the series with an incremented
                    // Index, and the remaining Strings, and then
                    // fetch it's value. This will wind up being
                    // the matched index, or -1 depending if the
                    // Match is found or not
                    index_of_string_impl<Index + 1, 
                                        Match, 
                                        Strings...>::value;
};

注释应该能让大部分内容清晰明了。基本上,我们只是递归地剥离和计数,直到 `is_same<>` 的 `value` 最终为 `true`,但 `is_same<>` 做了什么?

`is_same<>` 模板由 STL 提供,但我们在这里重新审视它,以便您理解它

// represents a boolean type
// std has this
template <bool Value>
struct boolean_type {
    constexpr static const bool value = Value;
};


// indicates whether or not two types are the same
// std has this
template <typename T, typename U>
struct is_same : boolean_type<false> {};
// specialization of is_same:
template <typename T>
struct is_same<T, T> : boolean_type<true>{};

我不会坐在这里告诉你这是 STL 使用的实现。几乎肯定不是,但它是一个功能等效的实现,这才是重要的。`boolean_type<>` 模板只是一种稍微更灵活的表示布尔值的方式。这里没有必要。我们可以直接在 `is_same<>` 本身上面放一个 `bool value` 字段。不过,STL 更像上面的代码,所以我想展示它,以防您在实际项目中遇到它。

这里有趣的部分是 `is_same<>` 的特化。当您将相同类型作为第一个和第二个参数传递时,由于 `is_same`,编译器足够聪明,会选择它。在前面的函数中,我们使用它来确定传入的“字符串”类型是否与参数列表中的类型相同。

最后,我们只需将 `index_of_string_impl<>` 助手函数封装在一个更友好的函数中

template <typename Match, typename... Strings>
using index_of_string = 
    index_of_string_impl<0, Match, Strings...>;

然后我们可以这样使用它

using foobar_str = STRING("foobar");
using foo_str = STRING("foo");
using baz_str = STRING("baz");

printf("index of \"%s\" within string list: %d\r\n",
        foobar_str::value(),
        (int)index_of_string<
            foo_str, // match arg
            foobar_str, // string #1
            foo_str, // string #2
            baz_str // string #3
          >::value);

您可能已经注意到这通过比较类型而不是字符串来工作。事实是,这种技术存在一个明显的限制——您不能简单地匹配任意字符串。例如,以下内容将不会返回直观的结果

using match = STRING("foobar");
using foobar_str = STRING("foobar");
using foo_str = STRING("foo");
using baz_str = STRING("baz");

printf("index of \"%s\" within string list: %d\r\n",
        foobar_str::value(),
        (int)index_of_string<
            match, // match arg
            foobar_str, // string #1
            foo_str, // string #2
            baz_str // string #3
  >::value);

这将返回 -1,因为 `match` 类型不存在于列表中。它实际上不关心它包含的字符串,也不关心它恰好匹配 `foobar_str` 中的字符串。它只关心类型。在 C++11 中,在编译时没有真正的方法可以绕过这一点,但它仍然有用。考虑您有一系列已知字符串的情况。我们将以颜色为例

// an ersatz "enum" that holds types
struct color_name {
    using white = STRING("white");
    using black = STRING("black");
    using red = STRING("red");
    using green = STRING("green");
    using yellow = STRING("yellow");
    using purple = STRING("purple");
    using blue = STRING("blue");
    using cyan = STRING("cyan");
};

现在我们继续进行一些补充声明,一个用于表示颜色值的 `color_traits` 模板,另一个伪“枚举” `color`,用于像上面那样保存已知的颜色。

template<
    typename Name,
    unsigned char Red,
    unsigned char Green,
    unsigned char Blue
    >
struct color_traits {
    using name_type = Name;
    constexpr inline static const char* name() { 
        return Name::value(); 
    }
    constexpr static const unsigned char red = Red;
    constexpr static const unsigned char green = Green;
    constexpr static const unsigned char blue = Blue;
    constexpr static const unsigned long int value = 
                            (red<<16)|(green<<8)|blue;
};
struct color {
    using white = color_traits<color_name::white,
                                0xFF,0xFF,0xFF>;
    using black = color_traits<color_name::black,
                                0x00,0x00,0x00>;
    using red = color_traits<color_name::red,
                                0xFF,0x00,0x00>;
    using green = color_traits<color_name::green,
                                0x00,0xFF,0x00>;
    using yellow = color_traits<color_name::yellow,
                                0xFF,0xFF,0x00>;
    using purple = color_traits<color_name::purple,
                                0xFF,0x00,0xFF>;
    using blue = color_traits<color_name::blue,
                                0x00,0x00,0XFF>;
    using cyan = color_traits<color_name::cyan,
                                0x00,0xFF,0XFF>;
};

你会注意到 `color_traits` 实例化有一个名称,以及与之关联的红色、绿色和蓝色通道值。你可能想知道我们为什么要有一个名称,但请继续听我说,当我们进行更多声明时,就会清楚了

template<typename... ColorTraits>
struct palette {
    using type = palette<ColorTraits...>;
    constexpr static const int size = sizeof...(ColorTraits);
};

现在我们可以声明

using french_flag_palette = 
    palette<
        color::blue,
        color::white,
        color::red
    >;
// the Americans, just to be Americans, 
// decide to use our own color of blue, 
// which we also call "blue", but is
//  slightly darker, because 'murica
using usa_flag_palette = 
    palette<
        color::red,
        color::white,
        color_traits<
            color_name::blue,
            0,0,0xE0>
    >;

我们为两面不同的旗帜声明了两个由红、白、蓝组成的调色板。法国国旗的颜色顺序与美国国旗不同,美国的“蓝色”稍暗,但我们仍然称之为蓝色,就像我们仍然称美式橄榄球为足球一样。因为谁能阻止我们呢?无论如何,两面旗帜都有白色、蓝色和红色。

现在我们想做

printf("French flag color at index one is: %s\r\n",
    french_flag_palette::color_by_index<1>::name());

根据我们已经学到的知识,这足够简单。我们只需要通过索引从参数包中检索一个参数。看

template<size_t Index,
        typename ColorTrait, 
        typename... ColorTraits> 
struct color_by_index_impl {
    using color = typename 
        color_by_index_impl<Index-1,ColorTraits...>::color;
};
template<typename ColorTrait,
        typename ... ColorTraits> 
struct color_by_index_impl<0,ColorTrait,ColorTraits...> {
    using color = ColorTrait;
};

现在,这个辅助模板和特化允许我们从 `palette` 类模板内部为 `color_by_index<>` 声明一个 `using`

template<typename... ColorTraits>
struct palette {
    ...
    template<size_t Index>
    using color_by_index = typename 
        color_by_index_impl<Index,ColorTraits...>::color;
    ...
};

现在,更有趣的是,让我们按名称获取颜色索引

// will print 2
printf("USA flag palette blue index is %d\r\n",
    (int)usa_flag_palette::color_index_by_name<color_name::blue>::value);

最后,拥有一个名字的用途可能对您来说越来越清楚了。基本上,我们可以将它看作是一个标记,用于标识一个类型。请记住,这里有两种不同的蓝色,一种在法国国旗上,一种在美国国旗上。通过 `color_name::blue` 搜索将返回其中一个。

现在是启用 `color_index_by_name<>` 的模板

// helper that fetches the index of a color by name
template <size_t Index, 
    typename Match, 
    typename... ColorTraits>
struct color_index_by_name_impl;

template <size_t Index, typename Match>
struct color_index_by_name_impl<Index, Match> {
    // this is what we return when it's
    // not found
    static constexpr size_t value = -1;
};
// this is the meat of color_index_by_name_impl:
template <size_t Index, 
        typename Match, 
        typename ColorTrait, 
        typename... ColorTraits>
struct color_index_by_name_impl<
        Index, 
        Match, 
        ColorTrait, 
        ColorTraits...> {
    static constexpr size_t value = is_same<Match, typename ColorTrait::name_type>::value ?
                    Index
                    :
                    color_index_by_name_impl<Index + 1, 
                                        Match, 
                                        ColorTraits...>::value;
};

这几乎是直接从搜索字符串列表的代码复制粘贴过来的,所以我不会花太多时间讲解。现在,像以前一样,我们只需在 `palette` 模板类上为这个辅助函数添加一个别名成员

template<typename... ColorTraits>
struct palette {
    ...
    // from before:
    template<size_t Index>
    using color_by_index = typename color_by_index_impl<Index,ColorTraits...>::color;
    
    template<typename Name>
    using color_index_by_name = color_index_by_name_impl<0,Name,ColorTraits...>;
    
    // free bonus member:
    template<typename Name>
    using color_by_name = color_by_index<color_index_by_name<Name>::value>;
    ...
};

这里,我们像以前一样,简单地用一个 `using` 模板“包装”了模板实例化,并且作为一个额外的好处,我们添加了 `color_by_name` 成员,它根据名称检索一个 `color_trait` 类型。

现在,抓紧你的帽子。

游戏、设置和匹配

我们将利用我们所学到的一切进行最后的冲刺——集合计算。

我们将为调色板实现子集/超集、相等和无序相等比较,这些比较都在编译时根据颜色名称进行匹配。

现在我们已经完成了所有上述工作,这实际上非常容易,但想想通过这样的技术可以使编译器完成多少工作。

超集/子集实现是最简单的

template <typename PaletteType, typename...ColorTraits>
struct is_subset_palette_impl;
template <typename PaletteType>
struct is_subset_palette_impl<PaletteType> {
    // this is what we return when there
    // are no items left
    static constexpr bool value = true;
};
template <typename PaletteType, 
        typename ColorTrait, 
        typename... ColorTraits>
class is_subset_palette_impl<
        PaletteType,
        ColorTrait, 
        ColorTraits...> {
    using result = typename PaletteType::color_index_by_name<typename ColorTrait::name_type>;
    using next = typename ::is_subset_palette_impl<PaletteType,ColorTraits...>;
public:       
    static constexpr bool value = (-1!=result::value) &&
                    next::value;
};

我们有一些私有成员可能有点奇怪,但事实是,我需要一些“暂存”字段来保存中间实例化,因为 C++ 编译器在解析所有内联内容时遇到困难。我不需要将它们设为私有,但我认为最好隐藏它们,因为它们除了哄骗编译器消化我给它的代码之外没有其他目的。在这里,`next` 是操作性实例化——那个递归地检查 `ColorTraits` 的每个成员的实例化。`result` 也非常重要,因为它根据名称从另一个调色板中检索颜色的索引。我们将其与 -1 进行比较以查看颜色是否存在,并且还检查下一个值是否也为 `true`。当没有更多的 `ColorTraits` 参数时,我们返回“true”作为布尔 `&&` 运算符链中的最终值。

现在和以前一样,我们在 `palette` 上创建一个别名成员来调用上面的内容

template<typename PaletteOther> 
using is_superset_of = is_subset_palette_impl<PaletteOther,ColorTraits...>;
template<typename PaletteOther> 
using is_subset_of = typename PaletteOther::is_superset_of<type>;

我没有像以前那样粘贴周围的 `palette` 模板类定义,但你懂的。

在此基础上,我们可以进行无序相等比较,这最终只涉及在两个方向上执行 `is_superset_of<>`

template <typename PaletteTypeLhs,typename PaletteTypeRhs>
class unordered_equals_palette_impl {
    using lhs = typename PaletteTypeLhs::is_superset_of<PaletteTypeRhs>;
    using rhs = typename PaletteTypeRhs::is_superset_of<PaletteTypeLhs>;
public:
    constexpr static bool value = lhs::value &&
        rhs::value;  
};

那些私有成员又出现了。这次只是为了保持代码可读性。

最后,我们在 `palette` 模板类中有一个别名

template<typename PaletteOther>
using unordered_equals = unordered_equals_palette_impl<type,PaletteOther>;

现在我们可以做这样的事情

// returns true
printf("French palette is superset of USA palette = %s\r\n",
    french_flag_palette::is_superset_of<usa_flag_palette>::value?"true":"false");
// returns true
printf("Unordered equals, French vs USA palettes = %s\r\n",
    usa_flag_palette::unordered_equals<french_flag_palette>::value?"true":"false");

很棒,但现在有序相等呢?你可能会认为这比无序相等更容易解决,但这里有个问题。传统上,你会在同一时间遍历两个集合,比较每个索引处的每个项目,假设计数相同,如果所有都为真则返回真。然而,没有直接的方法可以同时解包两组参数包,所以你不能轻易地像这样遍历两组。

我最终所做的修改是 `is_subset_palette_impl<>`,创建了一个版本,它比较项目的索引以确保它们匹配,而不仅仅是在不为 `-1` 时返回 true。这对编译器来说工作量更大,但无所谓。我不是来和编译器交朋友的

template <
    typename PaletteType,
    size_t Index, 
    typename...ColorTraits>
struct is_equal_palette_impl;
template <
    typename PaletteType,
    size_t Index>
struct is_equal_palette_impl<PaletteType,Index> {
    // this is what we return when there
    // are no items left
    static constexpr bool value = true;
};
template <typename PaletteType, 
        size_t Index,
        typename ColorTrait, 
        typename... ColorTraits>
class is_equal_palette_impl<
        PaletteType,
        Index,
        ColorTrait, 
        ColorTraits...> {
    using result = typename 
        PaletteType::color_index_by_name<typename ColorTrait::name_type>;
    using next = typename 
        ::is_equal_palette_impl<PaletteType,Index+1,ColorTraits...>;
public:       
    static constexpr bool value = result::value==Index && next::value;
};
template <typename PaletteTypeLhs,typename... ColorTraits>
class equals_palette_impl {
    using compare = typename 
        ::is_equal_palette_impl<PaletteTypeLhs,0,ColorTraits...>;
public:
    constexpr static bool value = 
        PaletteTypeLhs::size==sizeof...(ColorTraits) &&
        compare::value;
};

这里的 `Index` 用于从零开始计数。这样,我们就能跟踪我们在第一个列表中的索引,然后将它与从 `color_index_by_name<>` 返回的索引进行比较。只有当它们匹配时,我们才返回 true。

自然,我们为 `palette` 添加一个别名

template<typename PaletteOther>
using equals = equals_palette_impl<PaletteOther,ColorTraits...>;

我们这样使用它

// returns false
printf("Ordered equals, French vs USA palettes = %s\r\n",
    usa_flag_palette::equals<french_flag_palette>::value?"true":"false");

完整的 `palette` 模板如下所示

template<typename... ColorTraits>
struct palette {
    using type = palette<ColorTraits...>;
    constexpr static const int size = sizeof...(ColorTraits);
    template<size_t Index>
    using color_by_index = typename color_by_index_impl<Index,ColorTraits...>::color;
    template<typename Name>
    using color_index_by_name = color_index_by_name_impl<0,Name,ColorTraits...>;
    template<typename Name>
    using color_by_name = color_by_index<color_index_by_name<Name>::value>;
    template<typename PaletteOther> 
    using is_superset_of = is_subset_palette_impl<PaletteOther,ColorTraits...>;
    template<typename PaletteOther> 
    using is_subset_of = typename PaletteOther::is_superset_of<type>;
    template<typename PaletteOther>
    using unordered_equals = unordered_equals_palette_impl<type,PaletteOther>;
    template<typename PaletteOther>
    using equals = equals_palette_impl<PaletteOther,ColorTraits...>;
};

就像我之前说的,这有点做作,但不需要太多的想象力就能找出在哪里可以使用这些技术来使你的代码更灵活和/或更高效。

不要因为尝试以这种方式解决问题而气馁。我很不好意思告诉您,这里一些更简单的技术我花了多长时间才开发出来。它确实需要您侧向而不是正面地解决问题,但随着练习会变得更容易。最好的练习方法就是尝试。

我喜欢把这些问题看作是益智游戏,这有助于让它变得更有趣而不是令人沮丧。它不总是奏效,但就像我说的,它有帮助。

历史

  • 2021年3月23日 - 初次提交
© . All rights reserved.