Hana 是一个面向 C++ 元编程的头文件库,适用于对类型和值进行计算。它提供的功能是完善的 Boost.MPL 和 Boost.Fusion 库提供的功能的超集。通过利用 C++11/14 的实现技术和习惯用法,Hana 拥有更快的编译时间和与之前的元编程库相当或更好的运行时性能,同时在这个过程中显著提高了表达能力。Hana 非常容易以一种临时的方式进行扩展,并且它提供了与 Boost.Fusion、Boost.MPL 和标准库的开箱即用互操作性。
Hana 是一个头文件库,没有外部依赖项(甚至没有其他 Boost 库)。因此,在您自己的项目中使用 Hana 非常容易。基本上,只需下载项目并将 include/
目录添加到编译器的头文件搜索路径,您就完成了。但是,如果您希望干净地安装 Hana,您有几个选择
/usr/local
,对于 Windows 为 C:/Program Files
)。如果您希望将 Hana 安装到自定义位置,您可以使用如果您只想为 Hana 做出贡献,您可以在 README 中查看如何为开发最佳设置您的环境。
如果您使用 CMake,那么依赖 Hana 从未如此简单。手动安装后,Hana 会创建一个 HanaConfig.cmake
文件,该文件使用所有必需的设置导出 hana
接口库目标。您只需要使用 CMake 手动安装 Hana,使用 find_package(Hana)
,然后将您自己的目标链接到 hana
目标。以下是一个最小的示例
如果您在非标准位置安装了 Hana,您可能需要调整 CMAKE_PREFIX_PATH
。例如,如果您将 Hana “手动” 安装到另一个项目的本地位置,就会发生这种情况。在这种情况下,您需要告诉 CMake 在哪里找到 HanaConfig.cmake
文件,方法是使用
其中 INSTALLATION_PREFIX_FOR_HANA
是 Hana 安装位置的路径。
该库依赖于 C++14 编译器和标准库,但不需要其他任何东西。但是,我们只保证支持以下列出的编译器,这些编译器会持续测试
编译器/工具链 | 状态 |
---|---|
Clang >= 7 | 完全正常工作;在每次推送到 GitHub 时进行测试 |
Xcode >= 11 | 完全正常工作;在每次推送到 GitHub 时进行测试 |
GCC >= 8 | 完全正常工作;在每次推送到 GitHub 时进行测试 |
VS2017 >= 更新 7 | 完全正常工作;在每次推送到 GitHub 时进行测试 |
更具体地说,Hana 需要支持以下 C++14 功能的编译器/标准库(非详尽列举)
constexpr
<type_traits>
头文件的全部 C++14 类型特征使用上面未列出的编译器可能会正常工作,但不能保证对这些编译器的支持。有关特定平台的更多信息,请访问 维基百科。
如果您遇到问题,请查看 常见问题解答 和 维基百科。搜索 问题 中是否存在您的问题也是一个好主意。如果这些方法都没有帮助,请随时在 Gitter 上与我们聊天,或创建一个新的问题。建议在 StackOverflow 上使用 boost-hana 标签提问关于用法的问题。如果您遇到您认为是错误的问题,请创建一个问题。
当 Boost.MPL 首次出现时,它为 C++ 程序员带来了巨大的解脱,因为它将大量的模板黑客行为抽象到一个可用的接口后面。这一突破极大地促进了 C++ 模板元编程的普及,如今这种技术已深深扎根于许多重要的项目中。最近,C++11 和 C++14 为该语言带来了许多重大变化,其中一些变化使元编程变得更加容易,而另一些变化则极大地拓宽了库的设计空间。因此,自然会产生一个问题:使用元编程的抽象仍然可取吗?如果是,哪些抽象?在研究了像 MPL11 这样的不同选项之后,答案最终以库的形式自行出现;Hana。Hana 的关键见解是,类型和值的操纵不过是一枚硬币的两面。通过统一这两个概念,元编程变得更加容易,我们面前也打开了激动人心的新可能性。
但要真正理解 Hana 的全部含义,必须了解 C++ 中不同类型的计算。我们将重点关注四种不同的计算类型,尽管可能进行更细粒度的划分。首先,我们有运行时计算,这是我们在 C++ 中使用的常见计算。在这个领域,我们有运行时容器、运行时函数和运行时算法
在该象限内进行编程的常用工具箱是 C++ 标准库,它提供可重用算法和在运行时运行的容器。从 C++11 开始,第二种类型的计算成为可能:constexpr
计算。在那里,我们有 constexpr
容器、constexpr
函数和 constexpr
算法
std::array
的 operator==
必须标记为 constexpr
,但情况并非如此(即使在 C++14 中)。基本上,constexpr
计算不同于运行时计算,因为它的简单程度足以由编译器进行评估(实际上是解释)。通常,任何不执行对编译器评估器过于“不友好”的操作(例如抛出异常或分配内存)的函数,都可以标记为 constexpr
,而无需任何进一步的更改。这使得 constexpr
计算非常类似于运行时计算,只是 constexpr
计算更加受限,并且获得了在编译时进行评估的能力。不幸的是,没有用于 constexpr
编程的常用工具箱,即,没有广泛采用的用于 constexpr
编程的“标准库”。但是,对于那些对 constexpr
计算有一定兴趣的人来说,可能值得查看 Sprout 库。
第三种类型的计算是非同质计算。非同质计算不同于普通计算,因为普通计算的容器保存的是同质对象(所有对象具有相同的类型),而非同质计算的容器可能保存不同类型的对象。此外,此计算象限中的函数是非同质函数,这是一个复杂的术语,用于描述模板函数。同样,我们也有非同质算法来操纵非同质容器和函数
如果您觉得操作异构容器过于奇怪,就将其视为对 std::tuple
操作的强化版本。在 C++03 世界中,用于执行这种计算的最佳库是 Boost.Fusion,它提供了多种数据结构和算法来操作异构数据集合。我们将在这里考虑的第四个也是最后一个计算象限是类型级计算象限。在这个象限中,我们有类型级容器、类型级函数(通常称为元函数)和类型级算法。在这里,所有操作都在类型上进行:容器保存类型,元函数以类型作为参数,并返回类型作为结果。
类型级计算领域已被相当广泛地探索,C++03 中类型级计算的事实上的解决方案是一个名为 Boost.MPL 的库,它提供了类型级容器和算法。对于低级类型转换,自 C++11 以来,<type_traits>
标准头文件中提供的元函数也可以使用。
因此一切都很好,但是这个库到底是什么呢?现在我们已经通过澄清 C++ 中可用的计算类型来设定了舞台,答案可能会让您觉得非常简单。Hana 的目的是将计算的第 3 个象限和第 4 个象限合并。更具体地说,Hana 是一个(冗长的)构造性证明,异构计算严格来说比类型级计算更强大,因此我们可以通过等效的异构计算来表达任何类型级计算。这种构造分两个步骤完成。首先,Hana 是一个功能齐全的异构算法和容器库,有点类似于现代化的 Boost.Fusion。其次,Hana 提供了一种将任何类型级计算转换为等效的异构计算并返回的方法,这使得异构计算的完整机制可以用于类型级计算,而无需任何代码重复。当然,这种统一的最大优势是用户所看到的,正如您自己将要见证的那样。
本节的目的是从非常高的层面上以相当快的速度介绍该库的主要概念;如果您不理解即将出现的每个内容,请不要担心。但是,本教程假设读者已经至少熟悉基本的元编程和 C++14 标准。首先,让我们包含该库
除非另有说明,否则文档假设在示例和代码段之前存在上述行。另请注意,提供了更细粒度的标头,将在 标头组织 部分进行说明。出于快速入门的目的,让我们现在包含一些额外的标头并定义一些我们将在下面需要的可爱的动物类型
如果您正在阅读本文档,您可能已经知道 std::tuple
和 std::make_tuple
。Hana 提供了自己的元组和 make_tuple
这将创建一个元组,它类似于数组,但它可以保存具有不同类型的元素。可以保存具有不同类型的元素的容器(如这种)被称为异构容器。虽然标准库提供了一些操作来操作 std::tuple
,但 Hana 提供了一些操作和算法来操作它自己的元组
1_c
是一个 C++14 用户定义的字面量,用于创建 编译时数字。这些用户定义的字面量包含在 boost::hana::literals
命名空间中,因此需要 using
指令。请注意我们如何将 C++14 通用 lambda 传递给 transform
;这是必需的,因为 lambda 首先将使用 Fish
调用,然后使用 Cat
调用,最后使用 Dog
调用,它们都具有不同的类型。Hana 提供了 C++ 标准库提供的大多数算法,只是它们在元组和相关的异构容器上工作,而不是在 std::vector
及其朋友上工作。除了处理异构值外,Hana 还使您可以使用自然语法执行类型级计算,所有这些都在编译时进行,并且没有任何开销。这将编译并按照您的预期执行
type_c<...>
不是类型!它是一个 C++14 变量模板,它生成表示 Hana 中类型的对象。这将在关于 类型计算 的部分中进行说明。除了异构和编译时序列外,Hana 还提供了一些功能,让您过去的元编程噩梦成为过去。例如,可以使用一行代码检查结构成员是否存在,而无需依赖 笨拙的 SFINAE 技巧
正在编写序列化库吗?别哭了,我们帮您搞定了。可以非常轻松地将反射添加到用户定义的类型中。这允许迭代用户定义类型的成员,使用程序化接口查询成员等等,而没有任何运行时开销
这很酷,但我已经能听到您抱怨难以理解的错误消息。但是,事实证明 Hana 是为人类而构建的,而不是为专业的模板元程序员构建的,这表明了这一点。让我们故意搞砸并看看会给我们带来什么样的混乱。首先,是错误
现在,是惩罚
还不错吧?但是,由于小示例非常适合展示,而无需真正做一些有用的事情,所以让我们检查一个现实世界中的示例。
在本节中,我们的目标是实现一种能够处理 boost::any
的 switch
语句。给定一个 boost::any
,目标是调度到与 any
的动态类型相关的函数
s
后缀来创建 std::string
,而不会产生语法开销。这是一个标准定义的 C++14 用户定义字面量。由于 any
持有一个 char
,因此使用其中的 char
调用了第二个函数。如果 any
持有一个 int
而不是 char
,则使用其中的 int
调用第一个函数。当 any
的动态类型与任何已涵盖的类型都不匹配时,将调用 default_
函数。最后,switch
的结果是调用与 any
动态类型关联的函数的结果。该结果的类型被推断为所有提供函数的结果的通用类型。
现在我们来看看如何使用 Hana 实现这个工具。第一步是将每个类型与一个函数关联起来。为此,我们将每个 case_
表示为一个 hana::pair
,其第一个元素是类型,第二个元素是函数。此外,我们(任意地)决定将 default_
情况表示为一个 hana::pair
,它将一个虚拟类型映射到一个函数。
为了提供我们上面展示的接口,switch_
必须返回一个接受 case 的函数。换句话说,switch_(a)
必须是一个函数,它接受任意数量的 case(即 hana::pair
),并执行将 a
分派到正确函数的逻辑。这可以通过让 switch_
返回一个 C++14 通用 lambda 来轻松实现。
然而,由于参数包不是非常灵活,所以我们将 case 放入一个元组中,以便我们可以操作它们。
注意在定义 cases
时如何使用 auto
关键字;让编译器推断元组的类型并使用 make_tuple
通常比手动计算类型更容易。下一步是从其余 case 中分离默认 case。这就是事情开始变得有趣的地方。为此,我们使用 Hana 的 find_if
算法,它有点类似于 std::find_if
。
find_if
接收一个 tuple
和一个谓词,并返回元组中第一个满足谓词的元素。结果以 hana::optional
的形式返回,它非常类似于 std::optional
,区别在于该可选值是否为空是在编译时知道的。如果谓词对 tuple
的任何元素都不满足,find_if
将返回 nothing
(一个空值)。否则,它将返回 just(x)
(一个非空值),其中 x
是第一个满足谓词的元素。与 STL 算法中使用的谓词不同,这里使用的谓词必须是泛型的,因为元组的元素是异构的。此外,该谓词必须返回 Hana 所谓的 IntegralConstant
,这意味着谓词的结果必须在编译时已知。这些细节在关于 跨阶段算法 的部分中进行了说明。在谓词内部,我们只是将 case 的第一个元素的类型与 type_c<default_t>
进行比较。如果你还记得我们使用 hana::pair
来编码 case,这仅仅意味着我们在所有提供的 case 中查找默认 case。但是如果沒有提供默认 case 该怎么办?我们当然应该在编译时失败!
注意我们如何在与 nothing
的比较结果上使用 static_assert
,即使 default_
是一个非 constexpr
对象?大胆地说,Hana 确保在运行时不会丢失任何在编译时已知的的信息,这显然是 default_
情况的存在。下一步是收集非默认 case 的集合。为此,我们使用 filter
算法,它只保留满足谓词的序列中的元素。
下一步是找到第一个与 any
动态类型匹配的 case,然后调用与该 case 关联的函数。最简单的方法是使用带变长参数包的经典递归。当然,我们可能可以将 Hana 算法以一种复杂的方式交织在一起,但有时用基本技术从头开始编写是最好的方法。为此,我们将使用 unpack
函数调用一个实现函数,该函数带有 rest
元组的内容。
unpack
接收一个 tuple
和一个函数,并使用 tuple
的内容作为参数调用该函数。unpack
的结果是调用该函数的结果。在我们的例子中,该函数是一个通用 lambda,它反过来调用 process
函数。我们在这里使用 unpack
的原因是将 rest
元组转换为参数包,参数包比元组更容易递归处理。在我们继续 process
函数之前,解释 second(*default_)
是怎么回事是值得的。正如我们之前解释的那样,default_
是一个可选值。像 std::optional
一样,这个可选值重载解引用运算符(以及箭头运算符),以允许访问 optional
内部的值。如果可选值为为空(nothing
),则会触发编译时错误。由于我们知道 default_
不是空的(我们就在上面检查过),所以我们所做的是简单地将与默认情况关联的函数传递给 process
函数。现在我们准备进行最后一步,即实现 process
函数。
这个函数有两个重载:一个重载用于至少要处理一个 case 的情况,另一个基础情况重载用于只有默认 case 的情况。正如我们所期望的那样,基础情况只是调用默认函数并返回该结果。另一个重载稍微有趣一些。首先,我们检索与该 case 关联的类型并将其存储在 T
中。这个 decltype(...)::type
舞蹈可能看起来很复杂,但实际上非常简单。粗略地说,它接受一个表示为对象的类型(一个 type<T>
)并将其拉回到类型级别(一个 T
)。有关详细信息,请参阅关于 类型级计算 的部分。然后,我们比较 any
的动态类型是否与这个 case 匹配,如果匹配,我们将使用转换为正确类型的 any
调用与这个 case 关联的函数。否则,我们只是使用其余 case 递归调用 process
。很简单,不是吗?以下是最终解决方案。
快速入门就到这里了!本示例只介绍了几个有用的算法(find_if
、filter
、unpack
)和异构容器(tuple
、optional
),但请放心,还有更多内容。教程的下一部分将以友好的方式逐步介绍与 Hana 相关的通用概念,但如果您想立即开始编码,可以使用以下备忘单作为快速参考。该备忘单包含最常用的算法和容器,以及对每个容器的简要描述。
容器 | 描述 |
---|---|
tuple | 通用索引异构序列,长度固定。将其用作异构对象的 std::vector 。 |
optional | 表示一个可选值,即可以为空的值。这有点像std::optional ,只不过空状态在编译时就已知。 |
映射 | 无序关联数组,将(唯一)编译时实体映射到任意对象。这就像std::unordered_map ,但用于异构对象。 |
集合 | 无序容器,包含唯一的键,这些键必须是编译时实体。这就像std::unordered_set ,但用于异构对象。 |
范围 | 表示编译时数字的区间。这就像std::integer_sequence ,但更好。 |
对 | 容器,包含两个异构对象。就像std::pair ,但压缩了空类型的存储空间。 |
字符串 | 编译时字符串。 |
类型 | 表示 C++ 类型的容器。这是类型和值统一的根源,对于 MPL 风格的计算(类型级计算)很有意义。 |
整型常量 | 表示编译时数字。这与std::integral_constant 非常相似,只是hana::integral_constant 还定义了运算符和更多语法糖。 |
延迟 | 封装延迟值或计算。 |
基本元组 | hana::tuple 的精简版本。不符合标准,但编译时效率更高。 |
函数 | 描述 |
---|---|
adjust(sequence, value, f) | 对序列中与某个值相等的每个元素应用函数,并返回结果。 |
adjust_if(sequence, predicate, f) | 对序列中满足某个谓词的每个元素应用函数,并返回结果。 |
{all,any,none}(sequence) | 返回序列中所有/任何/无元素是否为真值。 |
{all,any,none}_of(sequence, predicate) | 返回序列中所有/任何/无元素是否满足某个谓词。 |
append(sequence, value) | 将元素附加到序列。 |
at(sequence, index) | 返回序列中的第 n 个元素。索引必须是IntegralConstant 。 |
back(sequence) | 返回非空序列的最后一个元素。 |
concat(sequence1, sequence2) | 连接两个序列。 |
contains(sequence, value) | 返回序列是否包含给定对象。 |
count(sequence, value) | 返回与给定值相等的元素数量。 |
count_if(sequence, predicate) | 返回满足谓词的元素数量。 |
drop_front(sequence[, n]) | 从序列中删除前n 个元素,如果length(sequence) <= n ,则删除整个序列。n 必须是IntegralConstant 。如果未提供,n 默认为 1。 |
drop_front_exactly(sequence[, n]) | 从序列中删除前n 个元素。n 必须是IntegralConstant ,序列必须至少有n 个元素。如果未提供,n 默认为 1。 |
drop_back(sequence[, n]) | 从序列中删除最后n 个元素,如果length(sequence) <= n ,则删除整个序列。n 必须是IntegralConstant 。如果未提供,n 默认为 1。 |
drop_while(sequence, predicate) | 当谓词满足时,从序列中删除元素。谓词必须返回IntegralConstant 。 |
fill(sequence, value) | 用某个值替换序列的所有元素。 |
filter(sequence, predicate) | 删除所有不满足谓词的元素。谓词必须返回IntegralConstant 。 |
find(sequence, value) | 找到序列中第一个与某个值相等的元素,并返回just 它,否则返回nothing 。请参见hana::optional 。 |
find_if(sequence, predicate) | 找到序列中第一个满足谓词的元素,并返回just 它,否则返回nothing 。请参见hana::optional 。 |
flatten(sequence) | 扁平化序列的序列,有点像std::tuple_cat 。 |
fold_left(sequence[, state], f) | 从左侧累积序列的元素,可选地使用提供的初始状态。 |
fold_right(sequence[, state], f) | 从右侧累积序列的元素,可选地使用提供的初始状态。 |
fold(sequence[, state], f) | 等效于fold_left ;为了与 Boost.MPL 和 Boost.Fusion 保持一致而提供。 |
for_each(sequence, f) | 对序列的每个元素调用一个函数。返回void 。 |
front(sequence) | 返回非空序列的第一个元素。 |
group(sequence[, predicate]) | 将序列中满足(或都不满足)某个谓词的相邻元素分组。谓词默认为相等性,在这种情况下元素必须是Comparable 。 |
index_if(sequence, predicate) | 找到序列中第一个满足谓词的元素的索引,并返回just 它,否则返回nothing 。请参见hana::optional 。 |
insert(sequence, index, element) | 在给定索引处插入元素。索引必须是IntegralConstant 。 |
insert_range(sequence, index, elements) | 在给定索引处插入元素序列。索引必须是IntegralConstant 。 |
is_empty(sequence) | 返回序列是否为空,作为IntegralConstant 。 |
length(sequence) | 返回序列的长度,作为IntegralConstant 。 |
lexicographical_compare(sequence1, sequence2[, predicate]) | 对两个序列进行字典序比较,可选地使用自定义谓词,默认使用hana::less 。 |
maximum(sequence[, predicate]) | 返回序列中最大的元素,可选地根据谓词。如果没有提供谓词,元素必须是Orderable 。 |
minimum(sequence[, predicate]) | 返回序列中最小的元素,可选地根据谓词。如果没有提供谓词,元素必须是Orderable 。 |
partition(sequence, predicate) | 将序列划分为一对元素,这些元素满足某个谓词,以及不满足该谓词的元素。 |
prepend(sequence, value) | 将元素前置到序列。 |
remove(sequence, value) | 删除所有等于给定值的元素。 |
remove_at(sequence, index) | 删除给定索引处的元素。索引必须是IntegralConstant 。 |
remove_if(sequence, predicate) | 删除所有满足谓词的元素。谓词必须返回IntegralConstant 。 |
remove_range(sequence, from, to) | 删除给定[from, to) 半开区间内索引处的元素。索引必须是IntegralConstant 。 |
replace(sequence, oldval, newval) | 用某个其他值替换序列中与某个值相等的元素。 |
replace_if(sequence, predicate, newval) | 用某个值替换序列中满足某个谓词的元素。 |
reverse(sequence) | 反转序列中元素的顺序。 |
reverse_fold(sequence[, state], f) | 等效于fold_right ;为了与 Boost.MPL 和 Boost.Fusion 保持一致而提供。 |
size(sequence) | 等效于length ;为了与 C++ 标准库保持一致而提供。 |
slice(sequence, indices) | 返回一个新序列,包含原始序列中给定索引处的元素。 |
slice_c<from, to>(sequence) | 返回一个新序列,包含原始序列中[from, to) 范围内的索引处的元素。 |
sort(sequence[, predicate]) | 对序列中的元素进行排序(稳定地),可选地根据谓词。如果没有提供谓词,元素必须是Orderable 。 |
take_back(sequence, number) | 获取序列中最后 n 个元素,如果length(sequence) <= n ,则获取整个序列。n 必须是IntegralConstant 。 |
take_front(sequence, number) | 获取序列中前 n 个元素,如果length(sequence) <= n ,则获取整个序列。n 必须是IntegralConstant 。 |
take_while(sequence, predicate) | 当某个谓词满足时,获取序列中的元素,并返回它们。 |
transform(sequence, f) | 对序列的每个元素应用函数,并返回结果。 |
unique(sequence[, predicate]) | 从序列中删除所有连续的重复项。谓词默认为相等性,在这种情况下元素必须是Comparable 。 |
unpack(sequence, f) | 使用序列的内容调用函数。等效于f(x1, ..., xN) 。 |
zip(s1, ..., sN) | 将N 个序列压缩成一个元组序列。所有序列必须具有相同的长度。 |
zip_shortest(s1, ..., sN) | 将N 个序列压缩成一个元组序列。结果序列的长度与最短的输入序列相同。 |
zip_with(f, s1, ..., sN) | 使用N 元函数压缩N 个序列。所有序列必须具有相同的长度。 |
zip_shortest_with(f, s1, ..., sN) | 使用N 元函数压缩N 个序列。结果序列的长度与最短的输入序列相同。 |
在本教程的剩余部分,您将遇到代码片段,其中使用了不同类型的断言,例如BOOST_HANA_RUNTIME_CHECK
和BOOST_HANA_CONSTANT_CHECK
。与任何合理的assert
宏一样,它们基本上检查给定的条件是否满足。但是,在异构编程的背景下,有些信息是在编译时已知的,而另一些信息只在运行时才知道。在上下文中使用的断言的确切类型告诉您断言的条件是否可以在编译时知道,或者它是否必须在运行时计算,这是一个非常宝贵的信息。以下是本教程中使用的不同类型的断言,以及对其特性的简要描述。有关更多详细信息,您应该查看关于断言的参考。
断言 | 描述 |
---|---|
BOOST_HANA_RUNTIME_CHECK | 断言一个直到运行时才知道的条件。此断言提供最弱的保证形式。 |
BOOST_HANA_CONSTEXPR_CHECK | 断言一个条件,如果允许在常量表达式中使用 lambda,则该条件将是constexpr 。换句话说,它不是static_assert 的唯一原因是语言限制,即 lambda 不能出现在常量表达式中,这在 C++17 中可能会被解除。 |
static_assert | 断言一个constexpr 条件。这比BOOST_HANA_CONSTEXPR_CHECK 更强大,因为它要求该条件是常量表达式,因此它确保表达式中使用的算法是constexpr 友好的。 |
BOOST_HANA_CONSTANT_CHECK | 断言一个布尔IntegralConstant 。此断言提供最强的保证形式,因为IntegralConstant 可以转换为constexpr 值,即使它本身不是constexpr 。 |
本节介绍了IntegralConstant
的重要概念以及 Hana 元编程范式的理念。让我们从一个相当奇怪的问题开始。什么是integral_constant
?
std::integral_constant
的文档。一个有效的答案是integral_constant
表示数字的类型级编码,或者更一般地说,表示任何整型对象的编码。为了说明,我们可以使用模板别名非常轻松地在该表示中定义一个关于数字的后继函数
这就是人们通常认为integral_constant
的方式;作为可以用于模板元编程的类型级实体。另一种看待integral_constant
的方式是作为表示整型constexpr
值的运行时对象
这里,虽然one
没有标记为constexpr
,但它持有的抽象值(一个constexpr 1
)仍然可以在编译时获得,因为该值编码在one
的类型中。实际上,即使one
不是constexpr
,我们也可以使用decltype
来检索它表示的编译时值
但是为什么我们要将integral_constant
s 视为对象而不是类型级实体呢?为了了解原因,考虑我们现在如何实现与之前相同的后继函数
您注意到什么新东西了吗?区别在于,我们不是使用模板别名在类型级实现succ
,而是使用模板函数在值级实现它。此外,我们现在可以使用与普通 C++ 语法相同的语法执行编译时算术。这种将编译时实体视为对象而不是类型的观点是 Hana 表达能力的关键。
MPL 定义了算术运算符,这些运算符可用于对integral_constant
s 进行编译时计算。此类操作的典型示例是plus
,它的大致实现如下
通过将integral_constant
s 视为对象而不是类型,从元函数到函数的转换非常简单
必须强调一个重要的事实,即此运算符不会返回一个普通的整数。相反,它返回一个值初始化的对象,其类型包含加法的结果。该对象中包含的唯一有用信息实际上是在其类型中,我们正在创建一个对象,因为它允许我们使用这种不错的值级语法。事实证明,我们可以通过使用C++14 变量模板来简化integral_constant
的创建,从而使这种语法更好
现在我们正在谈论一个在初始类型级方法上明显提高的表达能力,不是吗?但还有更多;我们还可以使用C++14 用户定义的文字使此过程更加简单
Hana 提供了自己的integral_constant
s,这些integral_constant
s 定义了与我们上面展示的相同的算术运算符。Hana 还提供变量模板以轻松创建不同类型的integral_constant
s:int_c
、long_c
、bool_c
等... 这允许您省略否则需要值初始化这些对象的后缀{}
大括号。当然,还提供了_c
后缀;它是hana::literals
命名空间的一部分,您必须将其导入到您的命名空间中才能使用它
这样,您就可以进行编译时算术,而无需费力处理笨拙的类型级特性,并且您的同事现在将能够理解正在发生的事情。
为了说明它的好处,让我们实现一个在编译时计算二维欧几里得距离的函数。作为提醒,二维平面中两点的欧几里得距离由下式给出
\[ \mathrm{distance}\left((x_1, y_1), (x_2, y_2)\right) := \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} \]
首先,以下是使用类型级方法(使用 MPL)实现它时的样子
是的……现在,让我们使用上面介绍的值级方法来实现它
这个版本看起来可能更简洁。但是,这还不是全部。请注意,distance
函数看起来与您为动态值编写计算欧几里得距离的函数完全相同?实际上,由于我们对动态和编译时算术使用相同的语法,因此为其中一个编写的通用函数将适用于两者!
无需更改任何代码,我们就可以在运行时值上使用 distance
函数,并且一切正常。这就是 DRY 的优势。
一旦我们拥有编译时算术运算,下一个想到的可能是编译时分支。在元编程中,如果某个条件为真,则编译一段代码,否则编译另一段代码,这往往非常有用。如果你听说过 static_if,这听起来应该很熟悉,事实上这正是我们正在谈论的。否则,如果你不知道为什么我们可能想要在编译时进行分支,请考虑以下代码(改编自 N4461)
这段代码使用构造函数的正确语法形式创建 std::unique_ptr
。为了实现这一点,它使用了 SFINAE,并且需要两个不同的重载。现在,任何理智的人第一次看到这个代码都会问为什么不能简单地写
原因是编译器需要编译 if
语句的两个分支,无论条件如何(即使它在编译时已知)。但是当 T
不可从 Args...
构造时,第二个分支将无法编译,这将导致严重的编译错误。我们真正需要的是一种方法来告诉编译器不要编译条件为真的第二个分支,以及条件为假的第一个分支。
为了模拟这一点,Hana 提供了一个 if_
函数,它的工作原理有点像正常的 if
语句,除了它接受一个可以是 IntegralConstant
的条件,并返回两个值中由条件选择的一个值(这两个值可能具有不同的类型)。如果条件为真,则返回第一个值,否则返回第二个值。一个有点自负的例子如下
hana::true_c
和 hana::false_c
只是表示编译时真值和编译时假值的布尔 IntegralConstant
。这里,one_two_three
等于 123
,hello
等于 "hello"
。换句话说,if_
有点像三元条件运算符 ? :
,除了 :
两边的类型可能不同
好的,这很巧妙,但它如何真正帮助我们编写由编译器延迟实例化的完整分支呢?答案是将我们想要编写的 if
语句的两个分支表示为泛型 lambda,并使用 hana::if_
返回我们想要执行的分支。以下是我们如何重写 make_unique
这里,传递给 hana::if_
的第一个值是表示我们要在条件为真时执行的分支的泛型 lambda,第二个值是我们在其他情况下要执行的分支。hana::if_
只返回由条件选择的分支,我们立即使用 std::forward<Args>(args)...
调用该分支(它是一个泛型 lambda)。因此,最终会调用正确的泛型 lambda,x...
为 args...
,我们返回该调用的结果。
这种方法之所以有效,是因为每个分支的主体只能在所有 x...
的类型已知时实例化。事实上,由于该分支是泛型 lambda,因此参数的类型直到 lambda 被调用时才会被知道,编译器必须等到 x...
的类型被知道之后才能对 lambda 的主体进行类型检查。由于错误的 lambda 在条件不满足时从未被调用(hana::if_
会处理这个问题),因此永远不会对该 lambda 的主体进行类型检查,也不会发生编译错误。
if_
中的分支是 lambda。因此,它们实际上是与 make_unique
函数不同的函数。出现在这些分支内部的变量必须被 lambda 捕获或作为参数传递给它们,因此它们会受到捕获或传递方式的影响(按值、按引用等)。由于这种将分支表示为 lambda 然后调用它们的模式非常普遍,因此 Hana 提供了一个 eval_if
函数,其目的是使编译时分支更容易。eval_if
源于这样一个事实,即在一个 lambda 中,人们可以接收输入数据作为参数,也可以从上下文中捕获它。但是,为了模拟语言级别的 if
语句,从封闭作用域隐式捕获变量通常更自然。因此,我们希望编写的是
这里,我们使用 [&]
从封闭作用域捕获 args...
变量,这使我们不必引入新的 x...
变量并将它们作为参数传递给分支。但是,这有两个问题。首先,这将无法获得正确的结果,因为 hana::if_
最终将返回一个 lambda 而不是返回调用该 lambda 的结果。为了解决这个问题,我们可以使用 hana::eval_if
而不是 hana::if_
这里,我们使用 [&]
按引用捕获封闭的 args...
,并且我们不需要接收任何参数。此外,hana::eval_if
假设其参数是可以调用的分支,它将负责调用由条件选择的分支。但是,这仍然会导致编译失败,因为 lambda 的主体不再依赖,即使最终只使用一个,也会对两个分支进行语义分析。这个问题的解决方案是使 lambda 的主体人为地依赖于某些东西,以阻止编译器在 lambda 实际被使用之前进行语义分析。为了使这成为可能,hana::eval_if
将使用一个标识函数(一个返回其参数不变的函数)调用选择的分支,如果该分支接受这样的参数
这里,分支的主体按惯例接受一个名为 _
的附加参数。这个参数将由 hana::eval_if
提供给所选分支。然后,我们使用 _
作为我们要在每个分支的主体中使其依赖的变量上的函数。发生的事情是 _
始终是一个返回其参数不变的函数。但是,编译器不可能在 lambda 实际被调用之前知道它,因此它无法知道 _(args)
的类型。这阻止了编译器进行语义分析,并且不会发生编译错误。此外,由于 _(x)
保证等效于 x
,因此我们知道我们实际上并没有通过使用这个技巧来改变分支的语义。
虽然使用这个技巧可能看起来很麻烦,但它在处理分支内的许多变量时非常有用。此外,不需要用 _
包裹所有变量;只有参与需要延迟类型检查的表达式的变量才需要包裹,其他变量则不需要包裹。关于 Hana 中的编译时分支,还有一些需要了解的地方,但你可以通过查看 hana::eval_if
、hana::if_
和 hana::lazy
的参考来深入了解。
为什么我们要将自己局限于算术运算和分支?当你开始将 IntegralConstant
视为对象时,用更多通常有用的函数来增强它们的接口变得合理。例如,Hana 的 IntegralConstant
定义了一个 times
成员函数,可用于调用某个函数一定次数,这对于循环展开特别有用
在上面的代码中,对 f
的 10 次调用在编译时展开。换句话说,这等效于编写
IntegralConstant
的另一个不错的用法是为索引异构序列定义美观的运算符。而 std::tuple
必须使用 std::get
访问,hana::tuple
可以使用标准库容器常用的 operator[]
访问
它的工作原理很简单。基本上,hana::tuple
定义了一个 operator[]
,它接受一个 IntegralConstant
而不是一个普通整数,类似于
这是关于 IntegralConstant
的部分的结尾。本节介绍了 Hana 新的元编程方法背后的感觉;如果你喜欢到目前为止看到的内容,本教程的其余部分应该让你感觉宾至如归。
在这一点上,如果你有兴趣像使用 MPL 一样进行类型级计算,你可能想知道 Hana 如何帮助你。不要绝望。Hana 提供了一种方法,通过将类型表示为值来执行类型级计算,并且具有很高的表达能力,就像我们将编译时数字表示为值一样。这是一种完全不同的元编程方法,如果你想精通 Hana,应该尝试暂时放下你以前使用 MPL 的习惯。
但是,请注意,现代 C++ 特性,如 自动推断的返回类型 在许多情况下消除了对类型计算的需求。因此,在考虑进行类型计算之前,你应该问问自己,是否有更简单的方法来实现你想要实现的目标。在大多数情况下,答案是肯定的。但是,当答案是否定的时,Hana 将为你提供强大的工具来完成需要完成的任务。
Hana 对类型级计算方法的关键在于与编译时算术方法相同。基本上,这个想法是通过将编译时实体包装到某种容器中来将它们表示为对象。对于 IntegralConstant
,编译时实体是整数类型的常量表达式,我们使用的包装器是 integral_constant
。在本节中,编译时实体将是类型,我们将使用的包装器称为 type
。就像我们对 IntegralConstant
所做的那样,让我们从定义一个可以用来表示类型的虚拟模板开始
basic_type
,因为我们只是构建了 Hana 提供的实际功能的简化版本。虽然这看起来完全没有用,但它实际上足以开始编写看起来像函数的元函数。实际上,请考虑以下 std::add_pointer
和 std::is_pointer
的替代实现
我们刚刚编写了看起来像函数的元函数,就像我们在上一节中将编译时算术元函数编写为异构 C++ 运算符一样。以下是如何使用它们
请注意,我们现在可以使用正常的函数调用语法执行类型级计算?这类似于将值用于编译时数字允许我们使用正常的 C++ 运算符执行编译时计算的方式。就像我们对 integral_constant
所做的那样,我们还可以更进一步,使用 C++14 变量模板来提供创建类型的语法糖
hana::type_c
变量模板的实现方式,因为有一些细微之处;为了便于解释,这里对事情进行了简化。请查看 hana::type
的参考,以确切了解你对 hana::type_c<...>
的期望。但这对我们有什么好处?好吧,由于 type_c<...>
只是一个对象,我们可以将其存储在异构序列(如元组)中,我们可以将其移动并将其传递给(或从其返回)函数,并且我们基本上可以执行任何需要对象的操作
make_tuple(type_c<T>...)
会很麻烦。出于这个原因,Hana 提供了 tuple_t<T...>
变量模板,它是 make_tuple(type_c<T>...)
的语法糖。此外,请注意,由于上面的元组实际上只是一个普通的异构序列,我们可以对该序列应用异构算法,就像我们可以对 int
元组那样。此外,由于我们只是操作对象,我们现在可以使用完整的语言,而不仅仅是类型级可用的语言子集。例如,请考虑从类型序列中删除所有不是引用或指针的类型的任务。使用 MPL,我们必须使用占位符表达式来表达谓词,这很笨拙
现在,由于我们正在操作对象,我们可以使用完整的语言并使用通用 lambda,从而导致更易于阅读的代码
由于 Hana 统一地处理所有异构容器,因此这种将类型表示为值的 方法也具有以下优点:现在只需要一个库来进行异构计算和类型级计算。实际上,虽然我们通常需要两个不同的库来执行几乎相同的任务,但现在我们只需要一个库。再次考虑使用谓词过滤序列的任务。使用 MPL 和 Fusion,我们必须这样做
使用 Hana,只需要一个库。请注意我们如何使用相同的 filter
算法和相同的容器,并且只调整谓词,以便它可以在值上操作
但这还不是全部。实际上,对类型级和值级计算使用统一的语法,可以使异构容器接口更加一致。例如,请考虑创建将类型与值关联的异构映射,然后访问其元素的简单任务。使用 Fusion,发生的事情对于没有经验的人来说并不容易理解
但是,使用类型和值的统一语法,相同的事情变得更加清晰
虽然 Hana 的方法需要更多代码行,但它也可能更易于阅读,并且更接近人们期望初始化映射的方式。
到目前为止,我们可以将类型表示为值,并使用通常的 C++ 语法对这些对象执行类型级计算。这很好,但它并不十分有用,因为我们无法从对象表示中获取正常的 C++ 类型。例如,我们如何声明一个类型为类型计算结果的变量?
现在,没有简单的方法可以做到。为了更容易实现这一点,我们丰富了上面定义的 basic_type
容器的接口。它不再是一个空 struct
,现在我们将其定义为
basic_type
变成一个元函数。这样,我们可以使用decltype
轻松访问由type_c<...>
对象表示的实际 C++ 类型。
一般来说,使用 Hana 进行类型级元编程是一个三步过程。
hana::type_c<...>
将类型包装为对象。decltype(...)::type
解开结果。现在,您一定在想,这太繁琐了。实际上,它由于以下几个原因而非常易于管理。首先,这种包装和解包只需要在一些非常薄的边界处发生。
此外,由于您可以在计算内部利用对象(无需包装/解包)的优势,包装和解包的成本会在整个计算中摊销。因此,对于复杂的类型计算,这种三步过程的语法噪声在计算内部使用值的表达能力提升的背景下很快就会变得可以忽略不计。此外,使用值而不是类型意味着我们可以避免在整个地方键入typename
和template
,这在经典元编程中占用了大量的语法噪声。
另一个要点是,并不总是需要这三个完整步骤。实际上,有时只需要进行类型级计算并查询有关结果的内容,而无需将结果作为正常的 C++ 类型获取。
在这种情况下,我们能够跳过第三步,因为我们不需要访问由result
表示的实际类型。在其他情况下,可以避免第一步,例如使用tuple_t
时,它没有比任何其他纯类型级方法更多的语法噪声。
对于持怀疑态度的读者,让我们考虑一下查找类型序列中最小的类型的任务。这是一个非常好的示例,展示了只有类型的小型计算,我们预计新范式将在此处受到最大程度的困扰。您将看到,即使对于小型计算,一切仍然易于管理。首先,让我们使用 MPL 实现它。
结果非常易读(对于熟悉 MPL 的任何人来说)。现在让我们使用 Hana 实现相同的功能。
正如您所见,三步过程的语法噪声几乎完全被其他计算隐藏起来了。
我们以函数形式介绍的第一个类型级计算看起来像这样
虽然看起来更复杂,但我们也可以将其写成
但是,这种实现强调了我们实际上是在模拟现有的元函数,并将其简单地表示为函数。换句话说,我们正在将元函数(std::add_pointer
)提升到值的世界,方法是创建我们自己的add_pointer
函数。事实证明,这种提升过程是一个通用的过程。实际上,对于任何元函数,我们都可以写出几乎相同的内容。
这种机械转换很容易抽象成一个通用的提升器,它可以处理任何MPL 元函数,如下所示。
更一般地说,我们希望允许具有任意数量参数的元函数,这将我们带到以下不太幼稚的实现。
Hana 提供了一个类似的通用元函数提升器,称为hana::metafunction
。一个小改进是hana::metafunction<F>
是一个函数对象,而不是一个重载函数,因此可以将其传递给高阶算法。它也是Metafunction
这个稍强概念的模型,但现在可以安全地忽略。我们在本节中探讨的过程不仅适用于元函数;它也适用于模板。实际上,我们可以定义
Hana 为模板提供了通用的提升器,名为hana::template_
,并且它还为MPL 元函数类提供了通用的提升器,名为hana::metafunction_class
。这为我们提供了一种统一的方式来将“遗留”类型级计算表示为函数,因此使用经典类型级元编程库编写的任何代码几乎都可以毫无困难地与 Hana 一起使用。例如,假设您有一大块基于 MPL 的代码,并且您希望与 Hana 交互。执行此操作的过程并不比用 Hana 提供的提升器包装您的元函数更难。
但是,请注意,并非所有类型级计算都可以使用 Hana 提供的工具原封不动地提升。例如,std::extent
无法提升,因为它需要非类型模板参数。由于在 C++ 中没有办法统一处理非类型模板参数,因此必须诉诸使用特定于该类型级计算的手写函数对象。
<type_traits>
中的类型特征时,请不要忘记包含std::integral_constant
的桥接头文件(<boost/hana/ext/std/integral_constant.hpp>
)。然而,在实践中,这应该不是问题,因为绝大多数类型级计算都可以轻松提升。最后,由于<type_traits>
头文件提供的元函数使用非常频繁,Hana 为其中的每一个都提供了一个提升版本。这些提升的特征位于hana::traits
命名空间中,它们位于<boost/hana/traits.hpp>
头文件中。
这是关于类型计算部分的结尾。虽然这种类型级编程的新范式可能一开始很难理解,但随着您越来越多的使用,它将变得更有意义。您还将开始欣赏它如何模糊类型和值之间的界限,打开新的激动人心的可能性并简化许多任务。
静态自省,正如我们将在本文中讨论的,是指程序在编译时检查对象类型的能力。换句话说,它是一个与类型在编译时交互的编程接口。例如,您是否曾经想要检查某个未知类型是否具有名为foo
的成员?或者,您是否曾经需要迭代struct
的成员?
如果您在编程生涯中写过一些模板,您很有可能遇到过检查成员是否存在的第一问题。同样,任何尝试实现对象序列化甚至只是漂亮打印的人都会遇到第二个问题。在大多数动态语言(如 Python、Ruby 或 JavaScript)中,这些问题得到了完全解决,并且程序员每天都使用内省来简化许多任务。但是,作为一名 C++ 程序员,我们没有语言支持这些功能,这使得一些任务比应有的难度要大得多。虽然语言支持可能需要解决此问题,但 Hana 使一些常见的内省模式更容易访问。
给定一个未知类型的对象,有时希望检查此对象是否具有某个名称的成员(或成员函数)。这可用于执行更复杂的重载类型。例如,考虑在支持它的对象上调用 toString
方法的问题,但为不支持它的对象提供另一个默认实现
我们如何以通用方式实现对 obj.toString()
有效性的检查(以便它可以重用在其他函数中,例如)?通常,我们会陷入编写某种基于 SFINAE 的检测
这可以工作,但意图并不十分清楚,大多数没有深入了解模板元编程的人会认为这是黑魔法。然后,我们可以实现 optionalToString
如下
if
语句的两个分支都将被编译。如果 obj
没有 toString
方法,则 if
分支的编译将失败。我们将在稍后解决此问题。除了上述 SFINAE 技巧,Hana 提供了一个 is_valid
函数,它可以与 C++14 通用 lambda 相结合,以获得相同事物的更清晰的实现
这让我们得到一个函数对象 has_toString
,它返回给定表达式在传递给它的参数上是否有效。结果以 IntegralConstant
返回,因此 constexpr
性在这里不是问题,因为函数的结果无论如何都表示为一个类型。现在,除了更简洁(这是一行代码!),意图也更加清晰。其他好处是,has_toString
可以传递给高阶算法,并且也可以在函数作用域中定义,因此无需使用实现细节污染命名空间作用域。以下是我们现在编写 optionalToString
的方式
更干净,对吧?但是,正如我们之前所说,此实现实际上不会起作用,因为无论 obj
是否具有 toString
方法,if
语句的两个分支都必须始终进行编译。有多种可能的选择,但最经典的选择是使用 std::enable_if
has_toString
返回 IntegralConstant
的事实来编写 decltype(...)::value
,它是一个常量表达式。由于某种原因,has_toString(obj)
不被认为是常量表达式,即使我认为它应该是一个常量表达式,因为我们从未从 obj
读取(参见关于 高级 constexpr 的部分)。虽然此实现完全有效,但它仍然非常麻烦,因为它需要编写两个不同的函数并通过使用 std::enable_if
显式地执行 SFINAE 循环。但是,您可能还记得关于 编译时分支 的部分,Hana 提供了一个 if_
函数,它可用于模拟 static_if 的功能。以下是使用 hana::if_
编写 optionalToString
的方式
现在,前面的示例仅涵盖了检查非静态成员函数是否存在的情况。但是,is_valid
可用于检测几乎任何类型表达式的有效性。为了完整起见,我们现在列出常见的有效性检查用例以及如何使用 is_valid
来实现它们。
我们将要查看的第一个习惯用法是检查非静态成员是否存在。我们可以像之前示例中那样做
注意我们如何将 x.member
的结果强制转换为 void
?这样做是为了确保我们的检测也适用于不能从函数返回的类型,如数组类型。此外,将引用用作通用 lambda 的参数非常重要,因为否则需要 x
是 可复制构造的,这不是我们试图检查的内容。当可用对象时,这种方法简单且最方便。但是,当检查器旨在在没有对象的情况下使用时,以下替代实现可能更合适
此有效性检查器不同于我们之前看到的检查器,因为通用 lambda 不再期望一个普通的对象;它现在期望一个 type
(它是一个对象,但仍然表示一个类型)。然后,我们使用 <boost/hana/traits.hpp>
标头中的 hana::traits::declval
提升元函数 来创建由 t
表示的类型的右值,然后我们可以使用它来检查非静态成员是否存在。最后,我们不是向 has_member
传递实际对象(如 Foo{}
或 Bar{}
),而是传递 type_c<...>
。此实现非常适合没有对象存在的情况。
检查静态成员是否存在很简单,这里提供它是为了完整性
同样,我们期望向检查器传递 type
。在通用 lambda 内部,我们使用 decltype(t)::type
来获取由 t
对象表示的实际 C++ 类型,如关于 类型计算 的部分所述。然后,我们在该类型中获取静态成员并将其强制转换为 void
,与非静态成员的原因相同。
检查嵌套类型名是否存在并不困难,但比前面的情况稍微复杂一些
有人可能想知道为什么我们使用 -> hana::type<typename-expression>
而不是简单地使用 -> typename-expression
。再次,原因是我们想支持不能从函数返回的类型,如数组类型或不完全类型。
检查嵌套模板名是否存在类似于检查嵌套类型名是否存在,不同之处在于我们在通用 lambda 中使用 template_<...>
变量模板而不是 type<...>
也可以检查模板特化是否有效,但我们现在向 is_valid
传递 template_<...>
而不是 type<...>
,因为这是我们想要检查的内容
在 C++ 中,仅当表达式格式正确时才执行某些操作是一种非常常见的模式。实际上,optionalToString
函数只是以下模式的一个实例,该模式非常通用。
为了封装这种模式,Hana 提供了 sfinae
函数,该函数允许执行表达式,但仅当表达式格式正确时。
在这里,我们创建了一个 maybe_add
函数,它只是一个用 Hana 的 sfinae
函数包装的通用 lambda 表达式。maybe_add
是一个函数,它接受两个输入并返回 just
通用 lambda 表达式的结果(如果该调用格式正确),否则返回 nothing
。just(...)
和 nothing
都属于一种称为 hana::optional
的容器类型,它本质上是一个编译时 std::optional
。总的来说,maybe_add
在道德上等同于以下返回 std::optional
的函数,只是检查是在编译时完成的。
事实证明,我们可以利用 sfinae
和 optional
来实现 optionalToString
函数,如下所示。
首先,我们将 toString
用 sfinae
函数包装起来。因此,maybe_toString
是一个函数,它要么返回 just(x.toString())
(如果格式正确),要么返回 nothing
。其次,我们使用 .value_or()
函数从容器中提取可选值。如果可选值为 nothing
,.value_or()
返回传递给它的默认值;否则,它返回 just
中的值(这里为 x.toString()
)。这种将 SFINAE 视为可能失败的计算的特殊情况的做法非常简洁有效,尤其是在 sfinae
函数可以通过 hana::optional
Monad
组合的情况下,有关详细信息请参考文档。
你是否曾经想遍历用户定义类型的成员?本节的目的是向你展示如何使用 Hana 很容易地做到这一点。为了允许使用用户定义的类型,Hana 定义了 Struct
概念。一旦用户定义的类型成为该概念的模型,就可以遍历该类型对象的成员并查询其他有用信息。为了将用户定义的类型转换为 Struct
,可以使用几种方法。首先,你可以使用 BOOST_HANA_DEFINE_STRUCT
宏定义用户定义类型的成员。
此宏定义了两个成员(name
和 age
),它们具有给定的类型。然后,它在 Person::hana
嵌套的 struct
中定义一些样板代码,这对于使 Person
成为 Struct
概念的模型是必需的。没有定义构造函数(因此保留了 POD 性),成员按它们在这里出现的顺序定义,该宏也可以与模板 struct
一起使用,并且可以在任何范围内使用。还要注意,你可以在使用宏之前或之后自由地向 Person
类型添加更多成员。但是,仅使用宏定义的成员将在反思 Person
类型时被选中。够简单吧?现在,可以以编程方式访问 Person
。
对 Struct
的迭代就像对 Struct
是一个对序列一样进行,其中对的第一个元素是与成员关联的键,第二个元素是成员本身。当 Struct
通过 BOOST_HANA_DEFINE_STRUCT
宏定义时,与任何成员关联的键都是一个编译时 hana::string
,表示该成员的名称。这就是为什么与 for_each
一起使用的函数接受一个参数 pair
,然后使用 first
和 second
来访问对的子部分。此外,请注意如何在成员名称上使用 to<char const*>
函数?这将编译时字符串转换为 constexpr char const*
,以便可以 cout
。由于总是使用 first
和 second
来获取对的子部分可能很烦人,因此我们还可以使用 fuse
函数包装我们的 lambda 表达式,使其成为一个二元 lambda 表达式。
现在,它看起来更加干净。正如我们刚才提到的,Struct
被视为一种对序列,用于迭代。实际上,Struct
甚至可以像一个关联数据结构一样进行搜索,该数据结构的键是成员的名称,而值是成员本身。
_s
用户定义的文字创建一个编译时 hana::string
。它位于 boost::hana::literals
命名空间中。请注意,它还不是标准的一部分,但 clang 和 GCC 支持它。如果你想要保持 100% 标准,可以使用 BOOST_HANA_STRING
宏代替。Struct
和 hana::map
之间的主要区别在于,map
可以修改(可以添加和删除键),而 Struct
是不可变的。但是,你可以轻松地使用 to<map_tag>
将 Struct
转换为 hana::map
,然后就可以以更灵活的方式对其进行操作。
使用 BOOST_HANA_DEFINE_STRUCT
宏来调整 struct
很方便,但有时无法修改需要调整的类型。在这种情况下,可以使用 BOOST_HANA_ADAPT_STRUCT
宏以一种临时方式调整 struct
。
BOOST_HANA_ADAPT_STRUCT
宏必须在全局范围内使用。效果与 BOOST_HANA_DEFINE_STRUCT
宏完全相同,只是你不需要修改要调整的类型,这有时很有用。最后,还可以通过使用 BOOST_HANA_ADAPT_ADT
宏定义自定义访问器。
通过这种方式,用于访问 Struct
成员的名称将是指定的名称,并且在检索该成员时,将在 Struct
上调用关联的函数。在我们继续讨论使用这些反思功能的具体示例之前,还应该提到,struct
可以通过不使用宏来进行调整。此高级接口用于定义 Struct
,例如可用于指定不是编译时字符串的键。高级接口在 Struct
概念的文档中进行了描述。
现在,让我们继续讨论一个具体示例,该示例使用我们刚刚介绍的反思功能来将自定义对象打印为 JSON。我们的最终目标是实现以下内容。
并且经过 JSON 美化器处理后的输出应该如下所示。
首先,让我们定义几个实用程序函数,以方便字符串操作。
quote
和 to_json
的重载非常直观。然而,join
函数可能需要一些解释。基本上,intersperse
函数接受一个序列和一个分隔符,并返回一个新的序列,该序列在原始序列的每个元素对之间包含分隔符。换句话说,我们将一个形如 [x1, ..., xn]
的序列转换为形如 [x1, sep, x2, sep, ..., sep, xn]
的序列。最后,我们使用 _ + _
函数对象折叠结果序列,这等效于 std::plus<>{}
。由于我们的序列包含 std::string
(我们假设它包含 std::string
),因此这将导致将序列中的所有字符串连接成一个大字符串。现在,让我们定义如何打印一个 Sequence
首先,我们使用 transform
算法将我们的对象序列转换为 JSON 格式的 std::string
序列。然后,我们将该序列用逗号连接,并用 []
将其括起来以表示 JSON 表示法中的序列。够简单吧?现在让我们看看如何打印用户定义的类型
在这里,我们使用 keys
方法检索包含用户定义类型成员名称的 tuple
。然后,我们将该序列 transform
为 "name" : member
字符串序列,然后将其 join
并用 {}
括起来,用于表示 JSON 表示法中的对象。就这样!
本节解释了关于 Hana 容器的一些重要概念:如何创建它们,其元素的生命周期以及其他问题。
虽然在 C++ 中创建对象的常用方法是使用其构造函数,但异构编程使事情变得更加复杂。实际上,在大多数情况下,人们对要创建的异构容器的实际类型不感兴趣(甚至不知道)。在其他时候,人们可以明确地写出该类型,但这将是冗余的或繁琐的。出于这个原因,Hana 使用了一种不同的方法,该方法借鉴了 std::make_tuple
来创建新的容器。就像可以用 std::make_tuple
创建一个 std::tuple
一样,可以用 hana::make_tuple
创建一个 hana::tuple
。但是,更一般地说,Hana 中的容器可以用 make
函数创建
实际上,make_tuple
只是 make<tuple_tag>
的快捷方式,因此当您不在 Hana 的命名空间中时,您不必键入 boost::hana::make<boost::hana::tuple_tag>
。简而言之,make<...>
在整个库中用于创建不同类型的对象,从而概括了 std::make_xxx
函数族。例如,可以用 make<range_tag>
创建一个编译时整数的 hana::range
这些以
_tag
结尾的类型是表示一组异构容器(hana::tuple
、hana::map
等)的虚拟类型。标签在关于 Hana 核心 的部分中进行了介绍。
为了方便起见,只要 Hana 的某个组件提供了 make<xxx_tag>
函数,它也会提供 make_xxx
快捷方式以减少键入。此外,在本例中可以提出的一个有趣的观点是 r
是 constexpr
。一般来说,只要容器只用常量表达式初始化(对于 r
来说就是这样),该容器就可以标记为 constexpr
。
到目前为止,我们只用 make_xxx
函数族创建了容器。但是,有些容器确实提供构造函数作为其接口的一部分。例如,可以像创建 std::tuple
一样创建 hana::tuple
当构造函数(或任何成员函数)是公共接口的一部分时,它们将在每个容器的基础上进行记录。但是,在一般情况下,不应想当然地认为容器可以像上面构造元组一样构造。例如,尝试以这种方式创建 hana::range
将不起作用
实际上,我们甚至无法在那种情况下指定我们想要创建的对象的类型,因为 hana::range
的确切类型是实现定义的,这让我们进入下一节。
本节的目的是澄清可以从 Hana 容器的类型中期待什么。实际上,到目前为止,我们总是通过使用 make_xxx
函数族以及 auto
来让编译器推断容器的实际类型。但一般来说,关于容器的类型,我们可以说些什么呢?
答案是这取决于情况。有些容器具有明确定义的类型,而其他容器则没有指定其表示形式。在本例中,由 make_tuple
返回的对象的类型是明确定义的,而由 make_range
返回的类型是实现定义的
这在每个容器的基础上都有说明;当容器具有实现定义的表示形式时,其描述中将包含一个说明可以从该表示形式中期待什么的说明。存在若干原因导致未指定容器的表示形式;它们在 理由 中进行了解释。当容器的表示形式是实现定义的时,必须小心,不要对它做任何假设,除非这些假设在容器的文档中明确允许。例如,假设可以安全地从容器继承或容器中的元素按其模板参数列表中指定的顺序存储通常是不安全的。
虽然有必要,但将某些容器的类型留为未指定会使某些事情变得非常难以实现,例如对异构容器进行函数重载
出于这个原因(以及其他原因),提供了 is_a
实用程序。is_a
允许检查类型是否使用其标签是特定类型的容器,而不管容器的实际类型如何。例如,上面的示例可以改写为
这样,f
的第二个重载只会在 R
是标签为 range_tag
的类型的类型时匹配,而不管该范围的确切表示形式如何。当然,is_a
可以与任何类型的容器一起使用:tuple
、map
、set
等等。
在 Hana 中,容器拥有自己的元素。当创建一个容器时,它会对用于初始化它的元素进行复制并将它们存储在容器中。当然,通过使用移动语义可以避免不必要的复制。由于这些拥有语义,容器中对象的生存期与容器的生存期相同。
与标准库中的容器非常类似,Hana 中的容器希望其元素是对象。出于这个原因,不能在其中存储引用。当必须将引用存储在容器中时,应该使用 std::reference_wrapper
与上一节介绍了关于异构容器的一般但重要的概念一样,本节介绍了关于异构算法的一般概念。
Hana 中的算法总是返回一个包含结果的新容器。这使得人们可以轻松地通过简单地使用第一个的结果作为第二个的输入来链接算法。例如,要将函数应用于元组的每个元素,然后反转结果,只需连接 reverse
和 transform
算法即可
这与标准库中的算法不同,在标准库中,必须为底层序列提供迭代器。由于在原理中记录的原因,基于迭代器的设计被考虑过,但很快就被放弃,转而采用更适合异构编程的特定上下文的可组合且高效的抽象。
人们可能还会认为,从算法中返回拥有其元素的完整序列会导致大量不需要的复制。例如,当使用reverse
和transform
时,人们可能会认为在调用transform
后会进行中间复制
为了确保这种情况不会发生,Hana 大量使用了完美转发和移动语义,因此它可以提供几乎最佳的运行时性能。因此,不是进行复制,而是在reverse
和transform
之间进行移动
最终目标是,使用 Hana 编写的代码应该等同于精心编写的代码,只是它应该更易于编写。性能注意事项将在其自身的部分中深入解释。
Hana 中的算法不是惰性的。当调用算法时,它会完成其工作并返回一个包含结果的新序列,故事就结束了。例如,对大型序列调用permutations
算法是一个愚蠢的想法,因为 Hana 实际上会计算所有排列
相比之下,Boost.Fusion 中的算法返回视图,这些视图通过引用保存原始序列,并在需要时应用算法,因为序列的元素是被访问的。这会导致微妙的生命周期问题,例如,一个视图引用一个已被销毁的序列。Hana 的设计假设,在大多数情况下,我们无论如何都需要访问序列中的所有元素或几乎所有元素,因此性能并不是支持惰性的主要论据。
Hana 中的算法在它们扩展成的运行时代码方面有点特殊。本节的目标不是解释到底生成了什么代码(这取决于编译器),而是让人对这些东西有所了解。基本上,Hana 算法就像展开版本的等效经典算法。实际上,由于处理的序列的边界在编译时是已知的,因此我们可以展开序列上的循环,这是有意义的。例如,让我们考虑for_each
算法
如果xs
是运行时序列而不是元组,那么它的长度只能在运行时才知道,上述代码必须实现为循环
}
f(xs[3_c]);
for
循环生成。换句话说,以下操作没有意义any_of
算法的使用为了使这能够工作,必须包含<boost/hana/ext/std/integral_constant.hpp>
中包含的std::integral_constant
的外部适配器。
false
pred
,而 something 必须是常量表达式,但pred
是一个 lambda(而 lambda 不能在常量表达式中被调用)。但是,这些对象中是否有一个具有整型类型在编译时是明确已知的,因此我们预计计算答案只涉及编译时计算。实际上,这就是 Hana 的工作方式,上述算法扩展成类似于以下内容正如你将从下一节关于跨阶段计算中推断出的那样,any_of
的实现实际上必须比这更通用。但是,这个对孩子的谎言非常适合教学目的。
transform
算法,它被指定为(对于元组):hana::transform(hana::make_tuple(x1, ..., xn), f) == hana::make_tuple(f(x1), ..., f(xn))
make_tuple
是一个函数,并且由于函数参数的求值顺序未指定,因此在元组的每个元素上调用f
的顺序也是未指定的。如果坚持使用纯函数,一切都会正常工作,并且生成的代码通常更容易理解。但是,一些特殊的算法(如for_each
)确实期望使用不纯函数,并且它们保证求值顺序。实际上,一个只接受纯函数的for_each
算法几乎是毫无用处的。当算法可以接受不纯函数或保证某些求值顺序时,该算法的文档将明确提到这一点。但是,默认情况下,不能把任何保证视为理所当然。本节介绍跨阶段计算和算法的概念。实际上,我们已经在快速入门中使用了跨阶段算法,例如使用filter
,但我们当时没有解释到底发生了什么。但在介绍跨阶段算法之前,让我们先定义一下我们所说的跨阶段。我们这里提到的阶段是程序的编译和执行。在 C++ 中,就像在大多数静态类型语言中一样,编译时和运行时之间有着明显的区别;这被称为阶段区分。当我们谈论跨阶段计算时,我们指的是某种程度上跨越这些阶段执行的计算;也就是说,部分在编译时执行,部分在运行时执行。
constexpr
容器的length
函数定义: length.hpp:34
constexpr
,因为它包含运行时std::string
。尽管如此,即使它没有在常量表达式上调用,length
仍然返回可以在编译时使用的东西。如果你仔细想想,元组的大小在编译时是已知的,无论它的内容是什么,因此只有在编译时让我们可以使用这些信息才有意义。如果这看起来令人惊讶,请考虑std::tuple
和std::tuple_size
由于元组的大小在其类型中编码,因此无论元组是 constexpr
还是否,它始终在编译时可用。在 Hana 中,这是通过让 length
返回一个 IntegralConstant
来实现的。由于 IntegralConstant
的值在它的类型中编码,因此 length
的结果包含在它返回的对象的类型中,因此长度在编译时是已知的。由于 length
从运行时值(容器)到编译时值(IntegralConstant
),length
是跨阶段算法的简单示例(简单,因为它实际上并没有操作元组)。另一个与 length
非常相似的算法是 is_empty
算法,它返回容器是否为空。
更一般地说,任何接受一个在运行时已知其值的容器但查询可在编译时已知的算法都应该能够返回一个 IntegralConstant
或其他类似的编译时值。让我们通过考虑 any_of
算法来稍微复杂化一下,我们已经在上一节中遇到过这个算法。
在这个例子中,结果在编译时无法得知,因为谓词返回一个 bool
,它是两个 std::string
的比较结果。由于 std::string
不能在编译时进行比较,因此谓词必须在运行时操作,并且算法的总体结果也只能在运行时得知。但是,假设我们使用 any_of
以及以下谓词。
首先,由于谓词仅查询有关元组每个元素类型的 信息,因此很明显它的结果可以在编译时得知。由于元组中元素的数量在编译时也是已知的,因此算法的总体结果理论上可以在编译时得知。更准确地说,发生的事情是谓词返回一个初始化为 std::is_same<...>
的值,它继承自 std::integral_constant
。Hana 识别这些对象,并且算法的编写方式保留了谓词结果的 编译时
性质。最终,any_of
因此返回一个 IntegralConstant
,其中包含算法的结果,我们以一种巧妙的方式使用编译器的类型推导,使其看起来很简单。因此,它等同于编写(但你需要提前知道算法的结果!)
好的,所以一些算法能够在它们的输入满足关于 编译时
性质的某些约束时返回编译时值。但是,其他算法更具限制性,它们要求它们的输入满足关于 编译时
性质的某些约束,如果没有这些约束,它们就无法正常运行。filter
就是一个例子,它接受一个序列和一个谓词,并返回一个新的序列,该序列仅包含满足谓词的那些元素。filter
要求谓词返回一个 IntegralConstant
。虽然这个要求似乎很严格,但如果你仔细想想就会发现它确实很有道理。实际上,由于我们从异构序列中删除了一些元素,因此结果序列的类型取决于谓词的结果。因此,谓词的结果必须在编译时得知,才能让编译器为返回的序列分配类型。例如,考虑一下我们尝试按以下方式过滤一个异构序列时会发生什么。
很明显,我们知道谓词只会对第二个元素返回 false,因此结果应该是一个 [Fish, Dog]
元组。但是,编译器无法知道这一点,因为谓词的结果是运行时计算的结果,而运行时计算是在编译器完成工作很久以后才发生的。因此,编译器没有足够的信息来确定算法的返回类型。但是,我们可以使用任何结果在编译时可用的谓词来 filter
相同的序列。
由于谓词返回一个 IntegralConstant
,因此我们可以在编译时知道要保留异构序列中的哪些元素。因此,编译器能够弄清楚算法的返回类型。其他算法如 partition
和 sort
的工作方式类似;特殊的算法要求始终在文档中说明,使用之前请阅读算法的参考文档,以避免意外情况。
这就是关于算法部分的结尾。虽然这构成对算法内部阶段交互的相当完整的解释,但通过阅读关于 constexpr
的 高级部分 以及 Constant
和 IntegralConstant
的参考,可以获得更深入的理解。
constexpr
函数对象,而不是模板函数。这允许将它们传递给更高阶的算法,这非常有用。但是,由于这些函数对象是在头文件中的命名空间范围内定义的,因此我们需要支持 C++17 内联变量以避免 ODR 违规(通过在不同的翻译单元中定义相同对象的方式)。在 C++14 模式下编译时,内联变量不可用,每个翻译单元将看到不同的算法对象,因此算法函数对象的地址不保证在翻译单元之间是唯一的。从技术上讲,这是一种 ODR 违规,但除非你依赖于地址相同,否则它不会咬你。简而言之,如果你在 C++14 模式下编译,不要依赖于 Hana 提供的任何全局对象的地址的唯一性。C++ 程序员喜欢性能,所以这里有一个专门用于性能的整个部分。由于 Hana 位于运行时和编译时计算的边界,因此我们不仅对运行时性能感兴趣,而且对编译时性能也感兴趣。由于这两个主题几乎是分开的,因此我们将在下面分别进行处理。
hana::map
和 hana::set
)由于其简单的实现,在编译时表现得非常糟糕,并且它们的运行时行为似乎在某些情况下也存在问题。改进这种情况在 TODO 列表中。C++ 元编程带来了它自身的糟糕之处。与之相关的一个最烦人且众所周知的问题是编译时间过长。Hana 声称比其前身具有更高的编译时效率;这是一个大胆的声明,我们将尝试对其进行支持。当然,Hana 无法创造奇迹;元编程是 C++ 模板系统的副产品,编译器并非旨在用作某种元语言的解释器。但是,通过使用最先进的和经过大量基准测试的技术,Hana 能够最大限度地减少对编译器的负担。
在我们深入研究之前,让我简要说明一下在 Hana 中用于衡量编译时性能的方法。以前的元编程库通过查看编译器必须执行的实例化次数来衡量其元算法和元序列的编译时复杂度。虽然易于理解,但这种衡量编译时复杂度的方法实际上并没有给我们关于编译时间的太多信息,而编译时间是我们最终想要最小化的。基本上,造成这种情况的原因是,模板元编程是如此扭曲的计算模型,以至于很难找到一种标准方法来衡量算法的性能。因此,我们更喜欢在每个支持的编译器上对所有内容进行基准测试,并在该编译器上选择最佳实现,而不是提供毫无意义的复杂度分析。另请注意,我们在此处提供的基准测试非常精确。实际上,即使我们没有进行多次测量并取平均值或类似的东西来减少不确定性,但基准测试在重新生成时也非常稳定,这表明具有合理的精度。现在,让我们深入探讨。
首先,Hana 最小化了对预处理器的依赖。除了在许多情况下产生更清晰的错误消息之外,这还减少了头文件的总体解析和预处理时间。此外,由于 Hana 只支持最先进的编译器,因此库中几乎没有解决方法,这使得库更简洁、更小。最后,Hana 最小化了对任何外部依赖的依赖。特别是,它只在少数特定情况下使用其他 Boost 库,并且在很大程度上不依赖于标准库。这样做有几个原因(除了包含时间之外);它们在 理由 中有说明。
下面是一个图表,显示了包含不同库所需的时间。图表显示了包含每个库中(非外部)公共 API 中所有内容所需的时间。例如,对于 Hana,这意味着 <boost/hana.hpp>
头文件,它排除了外部适配器。对于其他库(如 Boost.Fusion),这意味着包含 boost/fusion/
目录中的所有公共头文件,但不包括外部库(如 MPL)的适配器。
除了减少预处理时间外,Hana 还使用现代技术以最有效的编译时方式实现异构序列和算法。在跳到算法的编译时性能之前,我们将看看创建异构序列的编译时成本。实际上,由于我们将介绍在序列上运行的算法,因此我们必须了解创建序列本身的成本,因为这将影响算法的基准测试。下图展示了创建 n
个异构元素的序列的编译时成本。
基准测试方法是始终以最有效的方式创建序列。对于 Hana 和 std::tuple
,这仅仅意味着使用相应的 make_tuple
函数。但是,对于 MPL,这意味着创建一个大小不超过 20 的 mpl::vectorN
,然后使用 mpl::push_back
创建更大的向量。我们对 Fusion 序列使用类似的技术。这样做的原因是 Fusion 和 MPL 序列具有固定的尺寸限制,并且这里使用的方法被发现是创建更长序列的最快方法。
为了完整性,我们还展示了创建具有 n
个元素的 std::array
的编译时成本。但是,请注意 std::array
只能保存单一类型的元素,因此我们在这里比较的是苹果和橘子。如您所见,创建 std::array
的成本是恒定的,并且基本上不存在(非零开销仅仅是包含 <array>
头文件)。因此,虽然 Hana 在其他异构容器上提供了改进的编译时间,但如果您只需要使用同构容器,请坚持使用它们;这样您的编译时间会快得多。
您还可以看到,创建序列存在着不可忽略的成本。实际上,正如您将在后面的图表中看到的,这确实是进行异构计算最昂贵的部分。因此,当您查看下面的图表时,请牢记仅仅创建序列的成本。还要注意,这里只介绍了最重要的算法,但 Metabench 项目为几乎所有 Hana 的算法提供了编译时性能的微基准测试。此外,我们提供的基准测试比较了几个不同的库。但是,由于 Hana 和 Fusion 可以使用值,而不仅仅是类型,因此将它们的算法与 MPL 等仅类型库进行比较是不公平的。实际上,Hana 和 Fusion 算法更强大,因为它们还允许执行运行时效果。但是,Fusion 和 Hana 之间的比较是公平的,因为这两个库都同样强大(严格来说)。最后,我们无法展示 std::tuple
的算法基准测试,因为标准没有提供等效的算法。当然,我们可以使用 Hana 的外部适配器,但这将不是一个忠实的比较。
元编程中普遍存在的第一个算法是 transform
。它接受一个序列和一个函数,并返回一个新的序列,其中包含将函数应用于每个元素的结果。下面的图表展示了将 transform
应用于 n
个元素的序列的编译时性能。x
轴表示序列中元素的数量,y
轴表示编译时间(以秒为单位)。还要注意,我们在每个库中都使用 transform
的等效项;例如,我们没有通过 Boost.Fusion 适配器使用 Hana 的 transform
,因为我们真正想要做的是将它们的实现与我们的实现进行基准测试。
在这里,我们可以看到 Hana 的元组比所有其他替代方案都表现得更好。这主要是因为我们使用 C++11 可变参数包展开在内部实现该算法,这是非常高效的。
在我们继续之前,需要提一下有关 Fusion 算法基准测试方法的内容。Fusion 中的一些算法是延迟的,这意味着它们实际上并没有执行任何操作,而是简单地返回原始数据的修改视图。这就是 fusion::transform
的情况,它只是返回一个转换后的视图,该视图将函数应用于原始序列的每个元素,当访问这些元素时。如果我们想要对任何东西进行基准测试,我们需要强制执行该视图的计算,就像最终在实际代码中访问序列的元素时会发生的那样。但是,对于具有多层复杂计算,延迟方法可能会产生截然不同的编译时配置文件。当然,这种差异在微基准测试中表现不佳,因此请记住,这些基准测试只揭示了大局的一部分。为了完整性起见,在本文的其余部分,我们将提到 Fusion 算法何时是延迟的,这样您就知道我们何时人为地强制执行算法的计算以进行基准测试。
第二类重要的算法是折叠。折叠可用于实现许多其他算法,例如 count_if
、minimum
等等。因此,折叠算法良好的编译时性能确保了这些派生算法良好的编译时性能,这就是我们在这里只介绍折叠的原因。还要注意,所有非单子折叠变体在编译时都比较等效,因此我们只介绍左折叠。下面的图表展示了将 fold_left
应用于 n
个元素的序列的编译时性能。x
轴表示序列中元素的数量,y
轴表示编译时间(以秒为单位)。用于折叠的函数是一个什么都不做的虚拟函数。在实际代码中,您可能会使用非平凡的操作进行折叠,因此曲线会比这更差。但是,这些是微基准测试,因此它们只显示算法本身的性能。
我们在这里介绍的第三个也是最后一个算法是 find_if
算法。这个算法很难有效地实现,因为它需要在满足给定谓词的第一个元素处停止。出于同样的原因,现代技术并不能真正帮助我们,因此该算法构成了对 Hana 实现质量的良好测试,而不考虑 C++14 为我们提供的免费午餐。
如您所见,Hana 的性能优于 Fusion,并且与 MPL 一样好,但与 MPL 不同,Hana 的 find_if
也可以用于值。这总结了关于编译时性能的部分。如果您想查看我们没有介绍的算法的性能,Metabench 项目为大多数 Hana 算法提供了编译时基准测试。
Hana 被设计为在运行时非常高效。但在深入研究细节之前,让我们先澄清一点。Hana 作为一个元编程库,它允许操作类型和值,因此谈论运行时性能并不总是很有意义。实际上,对于类型级计算和对 IntegralConstant
的计算,运行时性能根本不是问题,因为计算的结果包含在类型中,这是一种纯粹的编译时实体。换句话说,这些计算只涉及编译时工作,并且没有生成任何代码在运行时执行这些计算。只有在异构容器和算法中操作运行时值的情况下,讨论运行时性能才有意义,因为这只有在编译器必须生成一些运行时代码的情况下才会发生。因此,我们将在本文的其余部分只研究这类计算。
与我们对编译时基准测试所做的一样,用于衡量 Hana 中运行时性能的方法是数据驱动的,而不是分析性的。换句话说,我们不是试图通过统计算法作为输入大小的函数执行的基本操作的数量来确定算法的复杂度,而是简单地对最有趣的情况进行测量,并观察它的行为。这样做有几个原因。首先,我们不希望 Hana 的算法在大型输入上被调用,因为这些算法在异构序列上工作,而这些序列的长度必须在编译时已知。例如,如果您尝试在包含 100k 个元素的序列上调用 find_if
算法,那么您的编译器在尝试生成该算法的代码时会崩溃。因此,算法不能在非常大的输入上被调用,因此分析方法将失去许多吸引力。其次,处理器已经发展成为非常复杂的机器,而您能够榨取的实际性能实际上受控于比算法执行的步骤数多得多的因素。例如,缓存行为不佳或分支预测错误可能会使一个理论上有效的算法变成一个慢速执行者,尤其是对于小型输入。由于 Hana 导致大量展开发生,因此更要仔细考虑这些因素,任何分析方法都可能只会让我们误以为自己很有效率。相反,我们想要硬数据,以及漂亮的图表来显示它!
我们希望对几个不同的方面进行基准测试。首先,我们显然希望对算法的执行时间进行基准测试。其次,由于整个库中使用了按值语义,我们还想确保复制的数据量最少。最后,我们希望确保使用 Hana 不会由于展开而导致过多的代码膨胀,如 部分 中解释的那样。
就像我们只研究了编译时性能的几个关键算法一样,我们也将重点关注几个算法的运行时性能。对于每个基准测试的方面,我们将比较不同库实现的算法。我们的目标是始终至少与 Boost.Fusion 一样高效,Boost.Fusion 在运行时性能方面接近最佳。为了比较,我们还展示了在运行时序列上执行的相同算法,以及在编译时已知长度但 transform
算法不使用显式循环展开的序列上执行的相同算法。这里展示的所有基准测试都在Release CMake 配置中完成,这会处理传递适当的优化标志(通常是 -O3
)。让我们从以下图表开始,该图表显示了 transform
不同类型序列所需的时间
fusion::transform
通常是延迟的,我们为了基准测试而强制执行它的计算。如您所见,Hana 和 Fusion 几乎位于同一水平线上。std::array
对大型集合数据集稍慢,而 std::vector
对大型集合明显更慢。由于我们还希望注意代码膨胀,让我们看看为完全相同的场景生成的可执行文件的大小
如您所见,代码膨胀似乎不是问题,至少不是在这样的微基准测试中可以检测到的问题。现在让我们看一下 fold
算法,该算法非常常用
在这里,您可以看到每个人都表现得差不多,这是一个好兆头,表明 Hana 至少没有搞砸事情。同样,让我们看看可执行文件的大小
再次强调,代码大小并没有爆炸。因此,至少对于 Hana(以及 Fusion,因为它们具有相同的问题)的适度使用,代码膨胀不应成为主要问题。我们刚刚展示的图表中的容器包含随机生成的int
,这些int
的复制成本很低,非常适合微基准测试。但是,当我们在元素复制成本很高的容器上链接多个算法时会发生什么?更一般地说,问题是:当算法传递一个临时对象时,它是否会抓住机会避免不必要的复制?考虑
为了回答这个问题,我们将查看在对大约 1k 个字符的字符串进行上述代码基准测试时生成的图表。但是,请注意,对标准库算法进行基准测试实际上没有意义,因为它们不返回容器。
fusion::reverse
通常是惰性的,为了进行基准测试,我们强制执行其评估。如您所见,Hana 比 Fusion 更快,这可能是因为实现中更一致地使用移动语义。如果我们没有为reverse
提供临时容器,Hana 就无法执行任何移动,并且两个库的性能将相似。
这部分关于运行时性能的内容到此结束。希望您现在相信 Hana 是为了速度而构建的。性能对我们很重要:如果您遇到 Hana 导致生成不良代码的情况(并且错误不在编译器上),请打开一个问题,以便解决该问题。
Hana 提供了一些现有库的开箱即用集成。具体来说,这意味着您可以通过简单地包含适当的标题来在 Hana 的算法中使用这些库中的一些容器,从而在 Hana 和外部组件之间建立桥梁。这对于将现有代码从例如 Fusion/MPL 移植到 Hana 非常有用。
但是,使用外部适配器存在一些陷阱。例如,在使用 Hana 一段时间后,您可能会习惯于使用正常的比较运算符来比较 Hana 元组,或者对 Hana integral_constant
进行算术运算。当然,无法保证这些运算符也为外部适配器定义(一般来说,它们不会被定义)。因此,您必须坚持使用 Hana 提供的实现这些运算符的函数。例如
相反,您应该使用以下内容
但有时情况会更糟。一些外部组件定义了运算符,但它们不一定与 Hana 中的运算符具有相同的语义。例如,比较两个长度不同的std::tuple
,在使用operator==
时会报错
另一方面,比较长度不同的 Hana 元组只会返回一个错误的IntegralConstant
这是因为std::tuple
定义了自己的运算符,它们的语义不同于 Hana 的运算符。解决方案是在知道需要与其他库协同工作时,坚持使用 Hana 的命名函数而不是使用运算符
使用外部适配器时,还应注意不要忘记包含正确的桥接头文件。例如,假设我想使用 Boost.MPL 向量与 Hana 协同使用。我包含了相应的桥接头文件
现在,但是,假设我使用mpl::size
来查询向量的长度,然后将其与某个值进行比较。我也可以使用hana::length
,一切都会正常,但为了这个示例,请耐心等待
造成这种失败的原因是mpl::size
返回一个 MPL IntegralConstant,除非您包含正确的桥接头文件,否则 Hana 无法识别这些头文件。因此,您应该执行以下操作
结论是,在与外部库协同工作时,您必须小心处理的对象。最后的陷阱是外部库中的实现限制。许多较旧的库对可以使用它们创建的异构容器的最大大小有限制。例如,一个人可能无法创建一个包含超过FUSION_MAX_LIST_SIZE
个元素的 Fusion 列表。显然,这些限制被 Hana 继承了,例如,尝试计算包含 5 个元素的fusion::list
的排列(结果列表将包含 120 个元素)将以一种可怕的方式失败
除了本节中解释的陷阱之外,使用外部适配器应该与使用普通的 Hana 容器一样简单明了。当然,只要有可能,您应该尝试坚持使用 Hana 的容器,因为它们通常更易于使用,并且通常经过优化。
本节的目的是对 Hana 的核心进行高级概述。这个核心基于标签的概念,该概念借鉴了 Boost.Fusion 和 Boost.MPL 库,但被 Hana 推广得更远。然后,这些标签用于多种目的,例如算法定制、文档分组、改进错误消息以及将容器转换为其他容器。由于其模块化设计,Hana 可以非常轻松地以一种非正式的方式进行扩展。事实上,库的所有功能都是通过一种非正式的定制机制提供的,该机制将在本文中进行说明。
异构编程本质上是使用具有不同类型的对象进行编程。但是,很明显,一些对象族,虽然具有不同的表示形式(C++ 类型),但密切相关。例如,std::integral_constant<int, n>
类型对于每个不同的n
都不同,但在概念上它们都代表着同一个东西;一个编译时数字。std::integral_constant<int, 1>{}
和 std::integral_constant<int, 2>{}
类型不同的事实,只是我们在使用它们的类型来编码这些对象的值的副作用。实际上,在操作一个std::integral_constant<int, ...>
序列时,您很可能实际上将其视为一个虚构的integral_constant
类型的同构序列,忽略对象的实际类型,并假装它们都是带有不同值的integral_constant
。
为了反映这种现实,Hana 提供了表示其异构容器和其他编译时实体的标签。例如,Hana 的所有integral_constant<int, ...>
都有不同的类型,但它们都共享同一个标签,integral_constant_tag<int>
。这使程序员能够以单一类型的概念进行思考,而不是尝试以对象的实际类型进行思考。具体来说,标签是作为空struct
实现的。为了使它们脱颖而出,Hana 采用了在这些标签的名称后添加_tag
后缀的约定。
tag_of<T>::type
或等效的tag_of_t<T>
获取类型T
的对象的标签。标签是对普通 C++ 类型的扩展。实际上,默认情况下,类型T
的标签是T
本身,库的核心是为这些情况而设计的。例如,hana::make
期待一个标签或一个实际类型;如果您向它发送类型T
,它将执行逻辑操作并使用您传递给它的参数构造一个类型为T
的对象。但是,如果您向它传递一个标签,您应该为该标签专门化make
,并提供您自己的实现,如下所述。由于标签是对普通类型的扩展,我们最终主要以标签而不是普通类型的角度进行推理,并且文档有时会互换使用类型、数据类型和标签这些词语。
标签分派是一种通用的编程技术,用于根据传递给函数的参数类型选择函数的正确实现。覆盖函数行为的常用机制是重载。不幸的是,在处理具有不同基本模板的相关类型族时,或者当模板参数的类型未知(是类型还是非类型模板参数?)时,这种机制并不总是方便。例如,考虑尝试为所有 Boost.Fusion 向量重载一个函数
如果你了解 Boost.Fusion,那你可能也知道它不能直接工作。这是因为 Boost.Fusion 向量并不一定是 boost::fusion::vector
模板的特化。Fusion 向量也存在编号形式,它们都是不同的类型
这是 C++03 中缺乏可变参数模板所要求的实现细节,它泄漏到了接口中。这很不幸,但我们需要一种方法来解决它。为此,我们使用了一个包含三个不同组件的架构
tag_of
元函数访问此标记。具体来说,对于任何类型 T
,tag_of<T>::type
是用于分派它的标记。transform
或 unpack
。xxx_impl
(对于接口函数 xxx
)的单独模板来完成,该模板具有嵌套的 apply
静态函数,如下所示。当公共接口函数 xxx
被调用时,它将获取它希望在其上分派调用的参数的标记,然后将调用转发到与这些标记相关的 xxx_impl
实现。例如,让我们实现一个基本设置,用于分派将参数打印到流的函数的标记。首先,我们定义公共接口函数和可以专门化的实现
现在,让我们定义一个需要标记分派来定制 print
行为的类型。虽然存在一些 C++14 示例,但它们过于复杂,无法在本教程中展示,因此我们将使用作为几种不同类型的 C++03 元组来说明该技术
嵌套的 using hana_tag = vector_tag;
部分是一种简短的方式来控制 tag_of
元函数的结果,因此控制 vectorN
类型的标记。这在 tag_of
的参考中有所解释。最后,如果你想定制所有 vectorN
类型的 print
函数的行为,你通常需要编写类似以下内容
现在,使用标记分派,您可以依赖于所有 vectorN
共享相同的标记,并且只专门化 print_impl
结构
一个好处是,所有 vectorN
现在都可以被 print
函数统一对待,代价是在创建数据结构时(指定每个 vectorN
的标记)和在创建初始 print
函数时(使用 print_impl
设置标记分派系统)需要一些样板代码。此技术还有其他优点,例如能够在接口函数中检查先决条件,而不必在每个定制实现中执行此操作,这将非常繁琐
print
函数没有多大意义,但考虑一个获取序列中第 n
个元素的函数;您可能希望确保索引不在边界之外。此技术还使将接口函数提供为函数对象而不是普通重载函数变得更加容易,因为只有接口函数本身必须经过定义函数对象的麻烦。函数对象比重载函数有几个优点,例如能够在更高阶算法中使用或作为变量使用
您可能知道,能够同时为许多类型实现算法非常有用(这正是 C++ 模板的目标!)。然而,更重要的是能够为满足某个条件的许多类型实现算法。C++ 模板目前缺乏这种限制其模板参数的能力,但一项名为 概念 的语言特性正在推出,其目标是解决这个问题。
考虑到类似的东西,Hana 的算法支持比上面解释的更高级的标记分派。此层允许我们为满足某些谓词的所有类型“专门化”算法。例如,假设我们想为表示某种序列的所有类型实现上面的 print
函数。现在,我们没有简单的方法来做到这一点。但是,Hana 的算法的标记分派设置略有不同,因此我们可以编写以下内容
其中 Tag represents some kind of sequence
仅需是一个布尔表达式,表示 Tag
是否是序列。我们将在下一节中看到如何创建此类谓词,但现在让我们假设它正常工作。不详细介绍此标记分派是如何设置的,上面的专门化只有在谓词满足时才会被选中,并且如果找不到更好的匹配。因此,例如,如果我们的 vector_tag
要满足谓词,我们最初针对 vector_tag
的实现仍然优先于基于 hana::when
的专门化,因为它代表了一个更好的匹配。一般来说,任何不使用 hana::when
的专门化(无论是显式还是部分)都优先于使用 hana::when
的专门化,这被设计为从用户的角度尽可能地不让人感到意外。这几乎涵盖了 Hana 中关于标记分派的所有内容。下一节将解释如何为元编程创建 C++ 概念,然后可以将其与 hana::when
结合使用以实现高度的表现力。
Hana 中概念的实现非常简单。从本质上讲,一个概念只是一个模板 struct
,它继承自一个布尔 integral_constant
,表示给定类型是否是概念的模型
然后,可以通过查看 Concept<T>::value
来测试类型 T
是否是 Concept
的模型。很简单,对吧?现在,虽然从 Hana 的角度来看,实现检查的方式不必是任何特定的方式,但本节的其余部分将解释它在 Hana 中通常是如何完成的,以及它如何与标记分派交互。然后,您就可以根据需要定义自己的概念,或者至少更好地理解 Hana 的内部工作原理。
通常,由 Hana 定义的概念要求任何模型实现一些标记分派函数。例如,Foldable
概念要求任何模型至少定义 hana::unpack
和 hana::fold_left
之一。当然,概念通常还定义语义要求(称为定律),这些要求必须由其模型满足,但这些定律不受(也无法被)概念检查。但是,我们如何检查某些函数是否正确实现?为此,我们将不得不稍微修改我们在上一节中定义的标记分派方法的方式。让我们回到我们的 print
示例,并尝试为那些可以被 print
的对象定义一个 Printable
概念。我们的最终目标是拥有一个模板结构,例如
要了解 print_impl<...>
是否已定义,我们将修改 print_impl
,使其在未被覆盖时继承自一个特殊的基类,我们只需检查 print_impl<T>
是否继承自该基类
当然,当我们用自定义类型专门化 print_impl
时,我们不会继承自该 special_base_class
类型
如您所见,Printable<T>
实际上只检查 print_impl<T>
结构是否被自定义类型专门化。特别是,它甚至不检查嵌套的 ::apply
函数是否已定义或它是否语法有效。假设如果一个人为自定义类型专门化 print_impl
,则嵌套的 ::apply
函数存在且是正确的。如果不是,则当尝试对该类型的对象调用 print
时,将触发编译错误。Hana 中的概念做出相同的假设。
由于这种从特殊基类继承的模式在 Hana 中非常普遍,库提供了一个名为 hana::default_
的虚拟类型,可以用来代替 special_base_class
。然后,可以使用 hana::is_default
来代替 std::is_base_of
,这看起来更漂亮。有了这种语法糖,代码现在变成了
这就是关于标记分派函数和概念之间交互的所有内容。但是,Hana 中的一些概念并不完全依赖于特定标记分派函数的定义来确定类型是否为该概念的模型。当概念仅仅通过定律和细化概念引入语义保证,而没有额外的语法要求时,就会发生这种情况。定义这样的概念可能出于多种原因有用。首先,有时会发生,如果我们能假设一些语义保证 X 或 Y,则算法可以更有效地实现,因此我们可能会创建一个概念来强制执行这些保证。其次,有时当我们有额外的语义保证时,可以自动定义多个概念的模型,这为用户节省了手动定义这些模型的麻烦。例如,这就是 Sequence
概念的情况,它基本上为 Iterable
和 Foldable
添加了语义保证,进而允许我们为从 Comparable
到 Monad
的无数概念定义模型。
对于这些概念,通常有必要专门化 boost::hana
命名空间中的相应模板结构,为自定义类型提供一个模型。这样做就像提供一个印章,表示该概念要求的语义保证受到自定义类型的尊重。需要显式专门化的概念将记录该事实。就这样!这就是关于 Hana 中的概念的所有内容,它结束了关于 Hana 核心部分的这一节。
库的设计是模块化的,同时保持获取基本功能所需的必须包含的头文件数量合理地低。库的结构也是故意保持简单的,因为我们都喜欢简单。以下是头文件组织的概览。如果您需要更多详细信息,也可以在左侧的面板中(在 头文件 标签下)找到库提供的所有头文件的列表。
boost/hana.hpp
boost/hana/
XXX
的容器或算法,对应的头文件是 boost/hana/XXX.hpp
。boost/hana/concept/
boost/hana/core/
make
和 to
)的机制。boost/hana/fwd/
boost/hana/
目录的镜像,除了所有头文件只包含向前声明和文档之外。例如,要包含 hana::tuple
容器,可以使用 boost/hana/tuple.hpp
头文件。但是,如果一个人只需要该容器的向前声明,可以使用 boost/hana/fwd/tuple.hpp
头文件来代替。请注意,boost/hana/ext/
和 boost/hana/functional/
中的头文件的向前声明未提供。boost/hana/functional/
boost/hana/ext/
此目录包含外部库的适配器。对于命名空间 ns
中名为 xxx
的组件,外部适配器位于 boost/hana/ext/ns/xxx.hpp
头文件中。例如,std::tuple
的外部适配器位于 boost/hana/ext/std/tuple.hpp
头文件中,而 boost::mpl::vector
的外部适配器位于 boost/hana/ext/boost/mpl/vector.hpp
中。
请注意,这些头文件中只包含适配外部组件所需的严格最小值(例如,向前声明)。这意味着当有人想要使用外部组件时,仍然应该包含该组件的定义。例如
boost/hana/experimental/
此目录包含实验性功能,这些功能可能或可能不会在某个时间点进入库,但被认为足够有用,可以公开提供。此子目录中的功能位于 hana::experimental
命名空间中。另外,不要指望这些功能稳定;它们可能会在库的发布版本之间被移动、重命名、更改或删除。这些功能也可能需要额外的外部依赖项;每个功能都记录了它需要的额外依赖项(如果有)。
由于可能存在额外的依赖项,这些头文件也不包含在库的主头文件中。
boost/hana/detail/
detail/
中的内容不会保证稳定,因此您不应该使用它。您现在拥有开始使用库所需的一切。从现在开始,掌握库只是了解如何使用库提供的通用概念和容器的问题,最好通过查看参考文档来完成。在某个时间点,您可能还想创建自己的概念和数据类型,以更好地满足您的需求;放手去做,库就是为此而设计的。
使用异构对象的编程本质上是函数式的——由于无法修改对象的类型,因此必须引入一个新的对象,这排除了变异。与以前的设计模仿 STL 的元编程库不同,Hana 使用函数式编程风格,这是其表现力的很大一部分来源。但是,因此,参考中介绍的许多概念对于没有函数式编程知识的 C++ 程序员来说将是不熟悉的。参考试图通过尽可能地使用直觉来使这些概念易于理解,但请记住,最大的回报通常是一些努力的成果。
多年来,我制作了一些关于 Hana 和更一般的元编程的材料。您可能会发现其中一些有用
我关于 Hana 和元编程的所有演讲的完整列表可在 此处 获取。此外,还提供了一个 Hana 文档的非官方中文翻译,可从 此处 获取。
使用 Hana 的项目越来越多。查看这些项目可以帮助您了解如何最佳地使用该库。以下是其中的一些项目(如果您想将您的项目列在此处,请 打开问题)
这部分结束了本教程文档。希望您喜欢使用该库,请考虑 贡献 以使其变得更好!
– Louis
与大多数泛型库一样,Hana 中的算法通过它们所属的概念进行文档化(Foldable
、Iterable
、Searchable
、Sequence
等)。然后,不同的容器在它们自己的页面上进行文档化,并且它们模拟的概念在那里进行文档化。一些容器模拟的概念定义了哪些算法可以与这种容器一起使用。
更具体地说,参考的结构(可在左侧菜单中获取)如下:
optional
的 maybe
。在您对 Hana 有所了解后,您可能只想找到特定函数、概念或容器的参考。如果您知道要查找的内容的名称,可以使用文档任何页面右上角的搜索框。我个人的经验是,当您已经知道其名称时,这是迄今为止找到所需内容的最快速方法。
正如您将在参考中看到的,多个函数提供以半正式数学语言记录的签名。我们正在努力以这种方式记录所有函数,但这可能需要一段时间。使用的符号是定义函数的常用数学符号。具体来说,一个函数 Return f(Arg1, ..., ArgN);
可以使用数学符号等效地定义为
\[ \mathtt{f} : \mathtt{Arg}_1 \times \dots \times \mathtt{Arg}_n \to \mathtt{Return} \]
但是,不是记录函数的实际参数和返回类型,而是使用参数和返回标签来编写这些签名。这样做是由于异构环境,其中对象的实际类型通常毫无意义,并且无助于推断函数返回或接受的内容。例如,不是将 integral_constant
的 equal
函数记录为
\[ \mathtt{equal} : \mathtt{integral\_constant<T, n>} \times \mathtt{integral\_constant<T, m>} \to \mathtt{integral\_constant<bool, n == m>} \]
这实际上没有帮助(因为它实际上只展示了实现),而是使用 integral_constant_tag
进行记录,它充当所有 integral_constant
的“类型”。请注意,由于 equal
是 Comparable
概念的一部分,因此它没有专门针对 hana::integral_constant
进行记录,但思路是一样的
\[ \mathtt{equal} : \mathtt{integral\_constant\_tag<T>} \times \mathtt{integral\_constant\_tag<T>} \to \mathtt{integral\_constant\_tag<bool>} \]
这清楚地传达了比较两个 integral_constant
会返回另一个包含 bool
的 integral_constant
的意图。总的来说,这种对对象实际表示的抽象使我们能够以高级方式推断函数,即使它们的实际返回和参数类型是异构的,并且没有帮助。最后,大多数函数期望容器元素具有一些属性。例如,这是 sort
算法的情况,它显然要求容器元素是 Orderable
。通常,我们会将非谓词版本的 sort
的签名写为
\[ \mathtt{sort} : \mathtt{S} \to \mathtt{S} \\ \text{where S is a Sequence} \]
但是,这无法表达 S
的内容是 Orderable
的要求。为了表达这一点,我们使用以下符号
\[ \mathtt{sort} : \mathtt{S(T)} \to \mathtt{S(T)} \\ \text{where S is a Sequence and T is Orderable} \]
一种看待它的方式是假装 S
(序列标签)实际上是由序列元素的标签 T
参数化的。我们还假装所有元素都具有相同的标签 T
,这在一般情况下并非如此。现在,通过声明 T
必须是 Orderable
,我们表达了序列元素必须是 Orderable
的事实。此符号以不同的形式使用来表达不同类型的要求。例如,cartesian_product
算法接受一个序列的序列,并返回这些序列的笛卡尔积作为序列的序列。使用我们的符号,这可以很容易地传达
\[ \mathtt{cartesian\_product} : \mathtt{S(S(T))} \to \mathtt{S(S(T))} \\ \text{where S is a Sequence} \]
我要感谢以下个人和组织以各种方式为 Hana 做出贡献
参考文档使用了一些特定于此库的术语。此外,有时会以伪代码形式提供函数的简化实现,实际实现有时难以理解。本节定义了参考和用于描述某些函数的伪代码中使用的术语。
表示对象被最佳地转发。这意味着如果 x
是一个参数,则使用 std::forward
转发它,如果它是一个捕获的变量,则在封闭的 lambda 为右值时,将其从中移动。
还要注意,当 x
可以从中移动时,decltype(auto)
函数中的语句 return forwarded(x);
并不意味着会返回 x
的右值引用,这将创建一个悬空引用。相反,这意味着 x
是按值返回的,该值使用 std::forward
的 x
构建。
这在 lambda 中用于表示捕获的变量使用完美转发进行初始化,就像使用了 [x(forwarded(x))...]() { }
一样。
这意味着记录的函数使用 标签调度,因此确切的实现取决于与函数关联的概念的模型。
这表示用户不应依赖实体(通常为类型)的确切实现。特别是,这意味着除了文档中明确写明的之外,用户不能假设任何内容。通常,实现定义实体所满足的概念将被记录在案,因为否则无法对其实施任何操作。具体来说,假设实现定义实体的太多内容可能不会导致错误,但当更新到 Hana 的新版本时,很可能会导致代码崩溃。
本节记录了一些设计选择的理由。它还可以作为一些(不太)常见问题的常见问题解答。如果您认为应该在此列表中添加内容,请在 GitHub 上打开一个问题,我们将考虑改进文档或在此处添加问题。
这样做有几个原因。首先,Hana 是一个非常基础的库;我们基本上用对异构类型的支持重新实现了核心语言和标准库。在浏览代码时,人们很快就会意识到很少需要其他库,并且几乎所有内容都需要从头开始实现。此外,由于 Hana 非常基础,因此更有必要将依赖项保持在最低限度,因为这些依赖项将传递给用户。关于对 Boost 的最低依赖性,一个主要论点是可移植性。但是,作为一个尖端的库,Hana 只针对最新的编译器。因此,我们可以依赖于现代构造,而使用 Boost 带给我们的可移植性大多会代表多余的负担。
基于迭代器的设计有其自身的优点,但众所周知,它们也会降低算法的可组合性。此外,异构编程的上下文带来了许多点,使得迭代器变得不那么有趣。例如,递增迭代器必须返回一个具有不同类型的新的迭代器,因为指向序列中新对象的类型的类型可能不同。事实证明,用迭代器实现大多数算法会导致更差的编译时性能,仅仅是因为元编程的执行模型(使用编译器作为解释器)与 C++ 的运行时执行模型(处理器访问连续内存)非常不同。
首先,这为实现提供了更大的回旋余地,以便通过使用特定容器的巧妙表示来执行编译时和运行时优化。例如,包含类型为 T
的同构对象的元组可以用类型为 T
的数组来实现,这在编译时更有效。其次,也是最重要的是,事实证明,知道一个异构容器的类型并不像您想象的那么有用。事实上,在异构编程的上下文中,计算返回的对象的类型通常也是计算的一部分。换句话说,如果没有真正执行算法,就无法知道算法返回的对象的类型。例如,考虑 find_if
算法
如果谓词对元组的某个元素满足,则 result 将等于 just(x)
。否则,result
将等于 nothing
。但是,result 的 nothing
性在编译时是已知的,这要求 just(x)
和 nothing
具有不同的类型。现在,假设您想显式地写下结果的类型
为了拥有关于 some_type
是什么的知识,您需要实际执行算法,因为 some_type
取决于谓词对容器中某个元素是否满足。换句话说,如果您能够编写上面的内容,那么您已经知道算法的结果是什么,并且您不再需要在第一步中执行算法。在 Boost.Fusion 中,这个问题通过使用单独的 result_of
命名空间来解决,该命名空间包含一个元函数,用于计算给定传递给它的参数的类型的任何算法的结果类型。例如,上面的示例可以用 Fusion 重写为
请注意,我们基本上在进行两次计算;一次是在 result_of
命名空间中,另一次是在正常的 fusion
命名空间中,这非常冗余。在 auto
和 decltype
出现之前,这些技术对于执行异构计算是必要的。但是,自从现代 C++ 出现以来,在异构编程的上下文中对显式返回值类型的需求在很大程度上已经过时,而了解容器的实际类型通常并不那么有用。
不,这不是我女朋友的名字!我只是需要一个简短且美观的名称,人们容易记住,Hana 就出现了。我还注意到 Hana 在日语中是花的意思,在韩语中是一的意思。由于 Hana 非常漂亮,并且将类型级和异构编程统一在一个范式下,因此回顾起来,这个名字似乎非常合适 :-).
由于 Hana 在元组上定义了许多算法,因此一种可能的方法是只使用 std::tuple
并只提供算法,而不是也提供我们自己的元组。提供我们自己的元组的原因主要是性能。事实上,到目前为止测试过的所有 std::tuple
实现都具有非常糟糕的编译时性能。此外,为了获得真正出色的编译时性能,我们需要在一些算法中利用元组的内部表示,这需要定义我们自己的。最后,如果我们使用的是 std::tuple
,那么一些糖,例如 operator[]
就无法提供,因为该运算符必须定义为成员函数。
在决定使用名称 X
时,我尝试平衡以下内容(没有特定的顺序)
X
在 C++ 中有多么习惯?X
在编程世界的其他地方有多么习惯?X
的名字有多好,无论历史原因如何X
的感受如何X
的感受如何X
,例如名称冲突或标准保留的名称当然,良好的命名一直都很难,并将永远都很难。名称一直以来都受到作者自身偏见的影响,并将永远如此。尽管如此,我仍然试图以合理的方式选择名称。
与命名相比,命名是相当主观的,而函数的参数顺序通常很容易确定。基本上,经验法则是“容器排在首位”。在 Fusion 和 MPL 中一直都是这样,这对于大多数 C++ 程序员来说是直观的。此外,在高阶算法中,我尝试将函数参数放在最后,这样多行 lambda 看起来很漂亮
我们可以使用多种不同的技术来为库提供定制点,而选择使用标签调度。为什么?首先,我想要一个两层调度系统,因为这允许来自第一层(由用户调用的层)的函数实际上是函数对象,这允许将它们传递给高阶算法。使用具有两层的调度系统还允许为第一层添加一些编译时健全性检查,这将改善错误消息。
现在,选择标签调度而不是其他具有两层技术的原因有几个。首先,必须明确说明某些标签如何成为某个概念的模型,这将责任交给了用户,以确保该概念的语义要求得到满足。其次,在检查某个类型是否为某个概念的模型时,我们基本上检查是否实现了一些关键函数。特别是,我们检查该概念的最小完整定义中的函数是否已实现。例如,Iterable<T>
检查为 T
实现的 is_empty
、at
和 drop_front
函数是否已实现。但是,如果没有标签调度,检测到这一点的唯一方法是基本上检查以下表达式在可 SFINAE 上下文中是否有效
不幸的是,这需要实际执行算法,这可能会触发严重的编译时错误或损害编译时性能。此外,这需要选择一个任意的索引 N
来调用 at
:如果 Iterable
为空怎么办?使用标签调度,我们可以只询问 at_impl<T>
、is_empty_impl<T>
和 drop_front_impl<T>
是否已定义,并且在实际调用其嵌套 ::apply
函数之前不会发生任何事情。
这将需要 (1) 用任意的对象填充最短的序列,或者 (2) 用用户在调用 zip_longest
时提供的对象填充最短的序列。由于没有要求所有压缩的序列都具有类似类型的元素,因此无法在所有情况下都提供一个一致的填充对象。应该提供一个填充对象的元组,但我认为这可能过于复杂,不值得现在实现。如果您需要此功能,请在 GitHub 上打开一个问题。
由于 C++ 概念提案将概念映射到布尔 constexpr
函数,因此 Hana 将其概念定义为这样的函数而不是具有嵌套 ::value
的结构体是有意义的。事实上,这是最初的选择,但必须进行修改,因为模板函数有一个限制,使其不那么灵活。具体来说,模板函数不能传递给高阶元函数。换句话说,不可能写出以下内容
这种代码在某些情况下非常有用,例如检查两种类型是否具有共同的嵌入模型,来表达一个概念。
当概念被视为布尔类型的 constexpr
函数时,无法以泛型方式编写此代码。然而,当概念只是模板结构体时,我们可以使用模板模板参数。
在 C++ 中,编译时和运行时的边界很模糊,这一点在 C++14 引入 广义常量表达式 后尤为明显。然而,能够操作异构对象的关键在于理解这个边界,然后根据需要跨越它。本节旨在澄清 constexpr
的作用,理解它可以解决哪些问题,以及哪些问题它无法解决。本节涵盖有关常量表达式的更高级概念,只有对 constexpr
有深入理解的读者才应该尝试阅读本节内容。
让我们从一个具有挑战性的问题开始。以下代码应该编译吗?
答案是否定的,Clang 给出的错误信息如下:
解释是,在 f
函数体内部,t
不是一个常量表达式,因此不能用作 static_assert
的操作数。原因是编译器无法生成这样的函数。为了理解这个问题,考虑一下当我们用具体类型实例化 f
模板时会发生什么:
f<int>
的代码,并将该代码的地址存储在 fptr
中。fptr
指向一个接受整型参数的函数。很明显,编译器无法生成 f<int>
的代码,该代码应该在 t != 1
时触发 static_assert
,因为我们还没有指定 t
的值。更糟糕的是,生成的函数应该能够处理常量表达式和非常量表达式。
很明显,fptr
的代码无法生成,因为它需要能够在运行时值上使用 static_assert
,这是没有意义的。此外,请注意,将函数声明为 constexpr
与否并不重要;将 f
声明为 constexpr
只能说明,当参数是常量表达式时,f
的结果是一个常量表达式,但这并不能让你从 f
函数体内部得知是否用常量表达式调用了它。换句话说,我们想要的应该是这样的:
在这个假设的情况下,编译器会在 f
的函数体内部知道 t
是一个常量表达式,并且 static_assert
可以正常工作。然而,在当前的语言中,constexpr
参数不存在,添加它们会带来非常具有挑战性的设计和实现问题。这个小实验的结论是,参数传递会剥离 constexpr
性。目前可能不清楚这种剥离的后果,将在下面进行解释。
参数不是常量表达式意味着我们不能将其用作非类型模板参数、数组边界、static_assert
以及其他需要常量表达式的语句。此外,这意味着函数的返回类型不能依赖于参数的值,如果你仔细思考一下,这并不新鲜。
事实上,函数的返回类型只能依赖于参数的类型,constexpr
无法改变这一事实。这一点对我们至关重要,因为我们感兴趣的是操作异构对象,最终意味着根据函数的参数返回不同类型的对象。例如,一个函数可能希望在一个情况下返回一个类型为 T
的对象,而在另一个情况下返回一个类型为 U
的对象;从我们的分析中,我们现在知道这些“情况”必须依赖于参数的类型中编码的信息,而不是它们的值。
为了在参数传递过程中保留 constexpr
性,我们必须将 constexpr
值编码到一个类型中,然后将一个不一定为 constexpr
的该类型的对象传递给函数。该函数必须是一个模板,然后可以访问该类型中编码的 constexpr
值。
让我问一个棘手的问题。以下代码有效吗?
答案是肯定的,但原因一开始可能不明显。这里发生的是,我们有一个非 constexpr
类型的 int n
,以及一个接受对参数引用的 constexpr
函数 f
。大多数人认为它不应该工作的原因是 n
不是 constexpr
。然而,我们在 f
内部并没有对 n
做任何操作,因此实际上没有理由它不应该工作!这有点像在 constexpr
函数内部使用 throw
。
只要执行 throw
的代码路径没有被执行,调用结果就可以是一个常量表达式。类似地,我们可以在 f
内部做任何我们想做的事情,只要我们不执行需要访问参数 n
的代码路径即可,因为 n
不是一个常量表达式。
Clang 给出的第二个调用错误信息是:
现在让我们更进一步,考虑一个更微妙的例子。以下代码有效吗?
与我们最初的情况唯一的区别是,f
现在通过值而不是通过引用接受参数。然而,这造成了巨大的差异。实际上,我们现在要求编译器复制 n
并将此副本传递给 f
。但是,n
不是 constexpr
,因此它的值只有在运行时才知道。编译器如何在运行时才知道值的变量的编译时制作副本?当然,它做不到。事实上,Clang 给出的错误信息明确说明了正在发生的事情。