Hana 是一个仅头文件的 C++ 元编程库,适用于类型和值上的计算。它提供的功能是成熟的 Boost.MPL 和 Boost.Fusion 库的超集。通过利用 C++11/14 的实现技术和惯用法,Hana 在编译时间和运行时性能方面优于或等于之前的元编程库,同时显著提高了表达力。Hana 易于以 ad-hoc 的方式进行扩展,并开箱即用与 Boost.Fusion、Boost.MPL 和标准库进行互操作。
Hana 是一个仅头文件的库,没有外部依赖(甚至不需要 Boost 的其他部分)。因此,在您自己的项目中使用 Hana 非常简单。基本上,只需下载项目并将 include/
目录添加到编译器的头文件搜索路径中即可。但是,如果您想干净地安装 Hana,有几种选择:
/usr/local
,Windows 为 C:/Program Files
)。如果您想在自定义位置安装 Hana,可以使用:如果您只想为 Hana 做贡献,可以在 README 中找到如何最佳设置开发环境的信息。
如果您使用 CMake,依赖 Hana 变得前所未有的简单。手动安装后,Hana 会创建一个 HanaConfig.cmake
文件,该文件导出了 hana
接口库目标以及所有必需的设置。您只需手动安装 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 >= Update 7 | 完全可用;在每次推送到 GitHub 时进行测试 |
更具体地说,Hana 需要一个支持以下 C++14 功能(但不限于此)的编译器/标准库:
constexpr
<type_traits>
头文件中的所有 C++14 类型特性使用上面未列出的编译器可能会有效,但不保证支持。有关特定平台的更多信息可在 Wiki 上找到。
如果您遇到问题,请查阅 FAQ 和 Wiki。搜索 Issues 也是个好主意。如果这些都无法解决问题,请随时在 Gitter 上与我们交流,或创建一个新的 issue。 StackOverflow 上带有 boost-hana 标签是提问用法的首选地点。如果您认为遇到了 bug,请创建一个 issue。
当 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 提供了自己的 tuple 和 make_tuple
:
这会创建一个 tuple,它就像一个数组,但它可以容纳不同类型的元素。像这样可以容纳不同类型元素的容器称为异构容器。虽然标准库为操作 std::tuple
提供了很少的操作,但 Hana 提供了许多操作和算法来操作其自己的 tuple:
1_c
是一个 C++14 用户定义字面量,用于创建一个编译时数字。这些用户定义字面量包含在 boost::hana::literals
命名空间中,因此需要 using
指令。请注意,我们传递了一个 C++14 泛型 Lambda 给 transform
;这是必需的,因为 Lambda 将首先被 Fish
调用,然后是 Cat
,最后是 Dog
,它们都有不同的类型。Hana 提供了 C++ 标准库中提供的大多数算法,只是它们作用于 tuple 和相关的异构容器,而不是 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 的动态类型不匹配任何覆盖的 case 时,将调用 default_
函数。最后,switch
的结果是调用与 any
的动态类型关联的函数的结果。该结果的类型被推导为所有提供的函数结果的公共类型。
现在我们将研究如何使用 Hana 实现这个实用程序。第一步是将每个类型与一个函数关联起来。为此,我们将每个 case_
表示为一个 hana::pair
,其第一个元素是类型,第二个元素是函数。此外,我们(任意)决定将 default_
case 表示为映射一个虚拟类型的 hana::pair
到一个函数。
为了提供我们上面显示的接口,switch_
将不得不返回一个接受 cases 的函数。换句话说,switch_(a)
必须是一个接受任意数量 cases(它们是 hana::pair
s)并执行逻辑来分派 a
到正确函数的函数。这可以通过让 switch_
返回一个 C++14 泛型 Lambda 来轻松实现。
然而,由于参数包的灵活性不高,我们将 cases 放入一个 tuple 中,以便我们可以操作它们:
注意 auto
关键字在定义 cases
时是如何使用的;让编译器推导 tuple 的类型并使用 make_tuple
通常比手动处理类型更容易。下一步是将 default case 与其余 cases 分开。这时事情就开始变得有趣了。为了做到这一点,我们使用 Hana 的 find_if
算法,它的工作方式类似于 std::find_if
。
find_if
接受一个 tuple
和一个谓词,返回 tuple 中满足谓词的第一个元素。结果作为 hana::optional
返回,它非常类似于 std::optional
,只是可选值是否为空在编译时可知。如果 tuple 的任何元素都不满足谓词,find_if
返回 nothing
(一个空值)。否则,它返回 just(x)
(一个非空值),其中 x
是第一个满足谓词的元素。与 STL 算法中使用的谓词不同,这里使用的谓词必须是通用的,因为 tuple 的元素是异构的。此外,该谓词必须返回 Hana 所谓的*整数常量*(IntegralConstant
),这意味着谓词的结果必须在编译时可知。这些细节在 跨阶段算法 部分进行了解释。在谓词内部,我们只需将 case 的第一个元素的类型与 type_c<default_t>
进行比较。如果您还记得我们使用 hana::pair
来编码 cases,这意味着我们正在查找所有提供的 cases 中的 default case。但是如果没有提供 default case 怎么办?当然,我们应该在编译时失败!
请注意,我们可以在与 nothing
的比较结果上使用 static_assert
,即使 default_
是一个非 constexpr
对象?Hana 大胆地确保在编译时已知的信息不会丢失到运行时,这显然是一个 default_
case 的存在。下一步是收集非 default cases 的集合。为此,我们使用 filter
算法,它只保留满足谓词的序列元素。
下一步是找到与 any
的动态类型匹配的第一个 case,然后调用与该 case 关联的函数。最简单的方法是使用经典的递归和可变参数包。当然,我们也许可以通过一种复杂的方式交织 Hana 算法来实现这一点,但有时做事情最好的方法是使用基本技术从头开始编写。为此,我们将通过使用 unpack
函数来调用一个实现函数,该函数使用 rest
tuple 的内容。
unpack
接受一个 tuple
和一个函数,并使用 tuple
的内容作为参数调用该函数。unpack
的结果是调用该函数的结果。在我们的例子中,函数是一个泛型 lambda,它反过来调用 process
函数。我们在这里使用 unpack
的原因是为了将 rest
tuple 转换为参数包,与 tuple 相比,参数包更容易递归处理。在我们继续 process
函数之前,值得解释一下 second(*default_)
的作用。如前所述,default_
是一个可选值。与 std::optional
类似,此可选值重载了解引用运算符(以及箭头运算符)以允许访问 optional
中的值。如果 optional 为空(nothing
),则会触发编译时错误。由于我们知道 default_
不为空(我们刚刚在上面检查过),我们所做的就是简单地将与 default case 关联的函数传递给 process
函数。现在我们准备好进行最后一步,即实现 process
函数。
此函数有两个重载:一个用于至少有一个 case 要处理的情况,以及一个用于只有 default case 的基础情况重载。正如我们所期望的,基础情况只是调用 default 函数并返回该结果。另一个重载稍微有趣一些。首先,我们检索与该 case 关联的类型并将其存储在 T
中。这种 decltype(...)::type
的组合可能看起来很复杂,但实际上非常简单。粗略地说,它将一个表示为对象的类型(一个 type<T>
)拉回到类型级别(一个 T
)。细节将在 类型级别计算 部分进行解释。然后,我们比较 any
的动态类型是否与此 case 匹配,如果匹配,则调用与此 case 关联的函数,并将 any
转换为正确的类型。否则,我们只需递归地调用 process
和其余的 cases。很简单,不是吗?这是最终解决方案:
快速入门到此为止!这个例子只介绍了几个有用的算法(find_if
、filter
、unpack
)和异构容器(tuple
、optional
),但请放心,还有更多。接下来的教程章节将以友好的方式逐步介绍 Hana 的一般概念,但如果您想立即开始编码,可以使用以下速查表。此速查表包含最常用的算法和容器,以及每个算法的简要说明。
容器 | 描述 |
---|---|
tuple | 通用基于索引的固定长度异构序列。将此用作异构对象的 std::vector 。 |
可选 | 表示可选值,即可能为空的值。这有点像 std::optional ,只是空值在编译时可知。 |
map | 无序关联数组,映射(唯一的)编译时实体到任意对象。这就像异构对象的 std::unordered_map 。 |
集合 | 无序容器,保存必须是编译时实体的唯一键。这就像异构对象的 std::unordered_set 。 |
范围 | 表示编译时数字的间隔。这有点像 std::integer_sequence ,但更好。 |
pair | 包含两个异构对象的容器。类似于 std::pair ,但压缩了空类型的存储。 |
字符串 | 编译时字符串。 |
type | 表示 C++ 类型的容器。这是类型和值统一的根源,对于 MPL 式的计算(类型级别计算)很有价值。 |
integral_constant | 表示编译时数字。这与 std::integral_constant 非常相似,只是 hana::integral_constant 还定义了运算符和更多的语法糖。 |
lazy | 封装了一个惰性值或计算。 |
basic_tuple | 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 个序列压缩成一个 tuple 序列。所有序列的长度必须相同。 |
zip_shortest(s1, ..., sN) | 将 N 个序列压缩成一个 tuple 序列。结果序列的长度是输入序列中最短的那个。 |
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
视为对象而不是类型实体呢?为了说明原因,考虑一下我们现在如何实现与之前相同的后继函数。
您注意到什么新东西了吗?区别在于,而不是在类型级别使用模板别名实现 succ
,我们现在使用模板函数在值级别实现它。此外,我们现在可以使用与常规 C++ 相同的语法执行编译时算术。这种将编译时实体视为对象而不是类型的方式是 Hana 表达能力的*关键*。
MPL 定义了 算术运算符,可用于使用 integral_constant
执行编译时计算。此类操作的一个典型示例是 plus
,它大致实现为:
通过将 integral_constant
s 视为对象而不是类型,从元函数到函数的转换非常直接:
强调这一事实非常重要,即此运算符不返回普通整数。相反,它返回一个值初始化的对象,其类型包含加法的结果。包含在该对象中的唯一有用信息实际上在其类型中,并且我们正在创建一个对象,因为它允许我们使用这种漂亮的*值级别*语法。事实证明,我们可以通过使用 C++14 变量模板 来简化 integral_constant
的创建,从而使此语法更加出色。
现在我们正在谈论与初始类型级别方法相比,*表达力*有了可见的提升,不是吗?但还有更多;我们还可以使用 C++14 用户定义字面量 来进一步简化此过程。
Hana 提供了自己的 integral_constant
s,它们像上面展示的那样定义了算术运算符。Hana 还提供了变量模板来轻松创建不同类型的 integral_constant
s:int_c
、long_c
、bool_c
等等。这允许您省略这些对象通常需要的尾部 {}
括号。当然,_c
后缀也已提供;它是 hana::literals
命名空间的一部分,在使用它之前您必须将其导入您的命名空间。
这样,您就可以进行编译时算术,而无需应对笨拙的类型级别特有问题,并且您的同事现在就能理解发生了什么。
为了说明它能有多好,让我们实现一个在编译时计算 2D 欧几里得距离的函数。作为提醒,2D 平面上两点的欧几里得距离由以下公式给出:
\[ \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
s,分别表示编译时真值和编译时假值。在这里,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 的主体进行类型检查。因为当条件不满足时(hana::if_
会处理这一点),错误 Lambda 永远不会被调用,所以会失败的 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_
。
Here, we capture the enclosing args...
by reference using [&]
, and we do not need to receive any arguments. Also, hana::eval_if
assumes that its arguments are branches that can be called, and it will take care of calling the branch that is selected by the condition. However, this will still cause a compilation failure, because the bodies of the lambdas are not dependent anymore, and semantic analysis will be done for both branches even though only one would end up being used. The solution to this problem is to make the bodies of the lambdas artificially dependent on something, to prevent the compiler from being able to perform semantic analysis before the lambda is actually used. To make this possible, hana::eval_if
will call the selected branch with an identity function (a function that returns its argument unchanged), if the branch accepts such an argument
Here, the bodies of the branches take an additional argument called _
by convention. This argument will be provided by hana::eval_if
to the branch that was selected. Then, we use _
as a function on the variables that we want to make dependent within the body of each branch. What happens is that _
will always be a function that returns its argument unchanged. However, the compiler can't possibly know it before the lambda has actually been called, so it can't know the type of _(args)
. This prevents the compiler from being able to perform semantic analysis, and no compilation error happens. Plus, since _(x)
is guaranteed to be equivalent to x
, we know that we're not actually changing the semantics of the branches by using this trick.
While using this trick may seem cumbersome, it can be very useful when dealing with many variables inside a branch. Furthermore, it is not required to wrap all variables with _
; only variables that are involved in an expression whose type-checking has to be delayed must be wrapped, but the other ones are not required. There are still a few things to know about compile-time branching in Hana, but you can dig deeper by looking at the reference for hana::eval_if
, hana::if_
and hana::lazy
.
Why should we limit ourselves to arithmetic operations and branching? When you start considering IntegralConstant
s as objects, it becomes sensible to augment their interface with more functions that are generally useful. For example, Hana's IntegralConstant
s define a times
member function that can be used to invoke a function a certain number of times, which is especially useful for loop unrolling
In the above code, the 10 calls to f
are expanded at compile-time. In other words, this is equivalent to writing
Another nice use of IntegralConstant
s is to define good-looking operators for indexing heterogeneous sequences. Whereas std::tuple
must be accessed with std::get
, hana::tuple
can be accessed using the familiar operator[]
used for standard library containers
How this works is very simple. Basically, hana::tuple
defines an operator[]
taking an IntegralConstant
instead of a normal integer, in a way similar to
This is the end of the section on IntegralConstant
s. This section introduced the feel behind Hana's new way of metaprogramming; if you liked what you've seen so far, the rest of this tutorial should feel just like home.
At this point, if you are interested in doing type-level computations as with the MPL, you might be wondering how is Hana going to help you. Do not despair. Hana provides a way to perform type-level computations with a great deal of expressiveness by representing types as values, just like we represented compile-time numbers as values. This is a completely new way of approaching metaprogramming, and you should try to set your old MPL habits aside for a bit if you want to become proficient with Hana.
However, please be aware that modern C++ features like auto-deduced return type remove the need for type computations in many cases. Hence, before even considering to do a type computation, you should ask yourself whether there's a simpler way to achieve what you're trying to achieve. In most cases, the answer will be yes. However, when the answer is no, Hana will provide you with nuclear-strength facilities to do what needs to be done.
The key behind Hana's approach to type-level computations is essentially the same as the approach to compile-time arithmetic. Basically, the idea is to represent compile-time entities as objects by wrapping them into some kind of container. For IntegralConstant
s, the compile-time entities were constant expressions of an integral type and the wrapper we used was integral_constant
. In this section, the compile-time entities will be types and the wrapper we'll be using is called type
. Just like we did for IntegralConstant
s, let's start by defining a dummy template that could be used to represent a type
basic_type
here because we're only building a naive version of the actual functionality provided by Hana.While this may seem completely useless, it is actually enough to start writing metafunctions that look like functions. Indeed, consider the following alternate implementations of std::add_pointer
and std::is_pointer
We've just written metafunctions that look like functions, just like we wrote compile-time arithmetic metafunctions as heterogeneous C++ operators in the previous section. Here's how we can use them
Notice how we can now use a normal function call syntax to perform type-level computations? This is analogous to how using values for compile-time numbers allowed us to use normal C++ operators to perform compile-time computations. Like we did for integral_constant
, we can also go one step further and use C++14 variable templates to provide syntactic sugar for creating types
hana::type_c
variable template is implemented because of some subtleties; things were dumbed down here for the sake of the explanation. Please check the reference for hana::type
to know exactly what you can expect from a hana::type_c<...>
.But what does that buy us? Well, since a type_c<...>
is just an object, we can store it in a heterogeneous sequence like a tuple, we can move it around and pass it to (or return it from) functions, and we can do basically anything else that requires an object
make_tuple(type_c<T>...)
can be annoying when there are several types. For this reason, Hana provides the tuple_t<T...>
variable template, which is syntactic sugar for make_tuple(type_c<T>...)
.Also, notice that since the above tuple is really just a normal heterogeneous sequence, we can apply heterogeneous algorithms on that sequence just like we could on a tuple of int
s, for example. Furthermore, since we're just manipulating objects, we can now use the full language instead of just the small subset available at the type-level. For example, consider the task of removing all the types that are not a reference or a pointer from a sequence of types. With the MPL, we would have to use a placeholder expression to express the predicate, which is clunky
Now, since we're manipulating objects, we can use the full language and use a generic lambda instead, which leads to much more readable code
Since Hana handles all heterogeneous containers uniformly, this approach of representing types as values also has the benefit that a single library is now needed for both heterogeneous computations and type level computations. Indeed, whereas we would normally need two different libraries to perform almost identical tasks, we now need a single library. Again, consider the task of filtering a sequence with a predicate. With MPL and Fusion, this is what we must do
With Hana, a single library is required. Notice how we use the same filter
algorithm and the same container, and only tweak the predicate so it can operate on values
Having a unified syntax for type-level and value-level computations allows us to achieve greater consistency in the interface of heterogeneous containers. For example, consider the simple task of creating a heterogeneous map associating types to values, and then accessing an element of it. With Fusion, what's happening is far from obvious to the untrained eye
However, with a unified syntax for types and values, the same thing becomes much clearer
While Hana's way takes more lines of codes, it is also arguably more readable and closer to how someone would expect to initialize a map.
So far, we can represent types as values and perform type-level computations on those objects using the usual C++ syntax. This is nice, but it is not very useful because we have no way to get back a normal C++ type from an object representation. For example, how could we declare a variable whose type is the result of a type computation?
Right now, there is no easy way to do it. To make this easier to achieve, we enrich the interface of the basic_type
container that we defined above. Instead of being an empty struct
, we now define it as
basic_type
a metafunction in the MPL sense.This way, we can use decltype
to easily access the actual C++ type represented by a type_c<...>
object
In general, doing type-level metaprogramming with Hana is a three step process
hana::type_c<...>
decltype(...)::type
Now, you must be thinking that this is incredibly cumbersome. In reality, it is very manageable for several reasons. First, this wrapping and unwrapping only needs to happen at some very thin boundaries.
Furthermore, since you get the advantage of working with objects (without having to wrap/unwrap) inside the computation, the cost of wrapping and unwrapping is amortized on the whole computation. Hence, for complex type computations, the syntactic noise of this three step process quickly becomes negligible in light of the expressiveness gain of working with values inside that computation. Also, using values instead of types means that we can avoid typing typename
and template
all around the place, which accounted for a lot of syntactic noise in classic metaprogramming.
Another point is that the three full steps are not always required. Indeed, sometimes one just needs to do a type-level computation and query something about the result, without necessarily fetching the result as a normal C++ type
In this case, we were able to skip the third step because we did not need to access the actual type represented by result
. In other cases, the first step can be avoided, like when using tuple_t
, which has no more syntactic noise than any other pure type-level approach
For skeptical readers, let's consider the task of finding the smallest type in a sequence of types. This is a very good example of a short type-only computation, which is where we would expect the new paradigm to suffer the most. As you will see, things stay manageable even for small computations. First, let's implement it with the MPL
The result is quite readable (for anyone familiar with the MPL). Let's now implement the same thing using Hana
As you can witness, the syntactic noise of the 3-step process is almost completely hidden by the rest of the computation.
The first type-level computation that we introduced in the form of a function looked like
While it looks more complicated, we could also write it as
However, this implementation emphasizes the fact that we're really emulating an existing metafunction and simply representing it as a function. In other words, we're lifting a metafunction (std::add_pointer
) to the world of values by creating our own add_pointer
function. It turns out that this lifting process is a generic one. Indeed, given any metafunction, we could write almost the same thing
This mechanical transformation is easy to abstract into a generic lifter that can handle any MPL Metafunction as follows
More generally, we'll want to allow metafunctions with any number of arguments, which brings us to the following less naive implementation
Hana provides a similar generic metafunction lifter called hana::metafunction
. One small improvement is that hana::metafunction<F>
is a function object instead of an overloaded function, so one can pass it to higher-order algorithms. It is also a model of the slightly more powerful concept of Metafunction
, but this can safely be ignored for now. The process we explored in this section does not only apply to metafunctions; it also applies to templates. Indeed, we could define
Hana provides a generic lifter for templates named hana::template_
, and it also provides a generic lifter for MPL MetafunctionClasses named hana::metafunction_class
. This gives us a way to uniformly represent "legacy" type-level computations as functions, so that any code written using a classic type-level metaprogramming library can almost trivially be used with Hana. For example, say you have a large chunk of MPL-based code and you'd like to interface with Hana. The process of doing so is no harder than wrapping your metafunctions with the lifter provided by Hana
However, note that not all type-level computations can be lifted as-is with the tools provided by Hana. For example, std::extent
can't be lifted because it requires non-type template parameters. Since there is no way to deal with non-type template parameters uniformly in C++, one must resort to using a hand-written function object specific to that type-level computation
std::integral_constant
s (<boost/hana/ext/std/integral_constant.hpp>
) when using type traits from <type_traits>
directly.In practice, however, this should not be a problem since the vast majority of type-level computations can be lifted easily. Finally, since metafunctions provided by the <type_traits>
header are used so frequently, Hana provides a lifted version for every one of them. Those lifted traits are in the hana::traits
namespace, and they live in the <boost/hana/traits.hpp>
header
This is the end of the section on type computations. While this new paradigm for type level programming might be difficult to grok at first, it will make more sense as you use it more and more. You will also come to appreciate how it blurs the line between types and values, opening new exciting possibilities and simplifying many tasks.
Static introspection, as we will discuss it here, is the ability of a program to examine the type of an object at compile-time. In other words, it is a programmatic interface to interact with types at compile-time. For example, have you ever wanted to check whether some unknown type has a member named foo
? Or perhaps at some point you have needed to iterate on the members of a struct
?
If you have written a bit of templates in your life, chances are very high that you came across the first problem of checking for a member. Also, anyone having tried to implement object serialization or even just pretty printing has come across the second problem. In most dynamic languages like Python, Ruby or JavaScript, these problems are completely solved and introspection is used every day by programmers to make a lot of tasks simpler. However, as a C++ programmer, we do not have language support for those things, which makes several tasks much harder than they should be. While language support would likely be needed to properly tackle this problem, Hana makes some common introspection patterns much more accessible.
Given an object of an unknown type, it is sometimes desirable to check whether this object has a member (or member function) with some name. This can be used to perform sophisticated flavors of overloading. For example, consider the problem of calling a toString
method on objects that support it, but providing another default implementation for objects that do not support it
How could we implement a check for the validity of obj.toString()
as above in a generic fashion (so it can be reused in other functions, for example)? Normally, we would be stuck writing some kind of SFINAE-based detection
This works, but the intent is not very clear and most people without a deep knowledge of template metaprogramming would think this is black magic. Then, we could implement optionalToString
as
if
statement will be compiled. If obj
does not have a toString
method, the compilation of the if
branch will fail. We will address this issue in a moment.Instead of the above SFINAE trick, Hana provides a is_valid
function that can be combined with C++14 generic lambdas to obtain a much cleaner implementation of the same thing
This leaves us with a function object has_toString
which returns whether the given expression is valid on the argument we pass to it. The result is returned as an IntegralConstant
, so constexpr
-ness is not an issue here because the result of the function is represented as a type anyway. Now, in addition to being less verbose (that's a one liner!), the intent is much clearer. Other benefits are the fact that has_toString
can be passed to higher order algorithms and it can also be defined at function scope, so there is no need to pollute the namespace scope with implementation details. Here is how we would now write optionalToString
Much cleaner, right? However, as we said earlier, this implementation won't actually work because both branches of the if
always have to be compiled, regardless of whether obj
has a toString
method. There are several possible options, but the most classical one is to use std::enable_if
has_toString
returns an IntegralConstant
to write decltype(...)::value
, which is a constant expression. For some reason, has_toString(obj)
is not considered a constant expression, even though I think it should be one because we never read from obj
(see the section on advanced constexpr).While this implementation is perfectly valid, it is still pretty cumbersome because it requires writing two different functions and going through the hoops of SFINAE explicitly by using std::enable_if
. However, as you might remember from the section on compile-time branching, Hana provides an if_
function that can be used to emulate the functionality of static_if. Here is how we could write optionalToString
with hana::if_
Now, the previous example covered only the specific case of checking for the presence of a non-static member function. However, is_valid
can be used to detect the validity of almost any kind of expression. For completeness, we now present a list of common use cases for validity checking along with how to use is_valid
to implement them.
The first idiom we'll look at is checking for the presence of a non-static member. We can do it in a similar way as we did for the previous example
Notice how we cast the result of x.member
to void
? This is to make sure that our detection also works for types that can't be returned from functions, like array types. Also, it is important to use a reference as the parameter to our generic lambda, because that would otherwise require x
to be CopyConstructible, which is not what we're trying to check. This approach is simple and the most convenient when an object is available. However, when the checker is intended to be used with no object around, the following alternate implementation can be better suited
This validity checker is different from what we saw earlier because the generic lambda is not expecting an usual object anymore; it is now expecting a type
(which is an object, but still represents a type). We then use the hana::traits::declval
lifted metafunction from the <boost/hana/traits.hpp>
header to create an rvalue of the type represented by t
, which we can then use to check for a non-static member. Finally, instead of passing an actual object to has_member
(like Foo{}
or Bar{}
), we now pass a type_c<...>
. This implementation is ideal for when no object is lying around.
Checking for a static member is easy, and it is provided for completeness
Again, we expect a type
to be passed to the checker. Inside the generic lambda, we use decltype(t)::type
to fetch the actual C++ type represented by the t
object, as explained in the section on type computations. Then, we fetch the static member inside that type and cast it to void
, for the same reason as we did for non-static members.
Checking for a nested type name is not hard, but it is slightly more convoluted than the previous cases
One might wonder why we use -> hana::type<typename-expression>
instead of simply -> typename-expression
. Again, the reason is that we want to support types that can't be returned from functions, like array types or incomplete types.
Checking for a nested template name is similar to checking for a nested type name, except we use the template_<...>
variable template instead of type<...>
in the generic lambda
Checking whether a template specialization is valid can be done too, but we now pass a template_<...>
to is_valid
instead of a type<...>
, because that's what we want to make the check on
Doing something only if an expression is well-formed is a very common pattern in C++. Indeed, the optionalToString
function is just one instance of the following pattern, which is very general
To encapsulate this pattern, Hana provides the sfinae
function, which allows executing an expression, but only if it is well-formed
Here, we create a maybe_add
function, which is simply a generic lambda wrapped with Hana's sfinae
function. maybe_add
is a function which takes two inputs and returns just
the result of the generic lambda if that call is well-formed, and nothing
otherwise. just(...)
and nothing
both belong to a type of container called hana::optional
, which is essentially a compile-time std::optional
. All in all, maybe_add
is morally equivalent to the following function returning a std::optional
, except that the check is done at compile-time
It turns out that we can take advantage of sfinae
and optional
to implement the optionalToString
function as follows
First, we wrap toString
with the sfinae
function. Hence, maybe_toString
is a function which either returns just(x.toString())
if that is well-formed, or nothing
otherwise. Secondly, we use the .value_or()
function to extract the optional value from the container. If the optional value is nothing
, .value_or()
returns the default value given to it; otherwise, it returns the value inside the just
(here x.toString()
). This way of seeing SFINAE as a special case of computations that might fail is very clean and powerful, especially since sfinae
'd functions can be combined through the hana::optional
Monad
, which is left to the reference documentation.
Have you ever wanted to iterate over the members of a user-defined type? The goal of this section is to show you how Hana can be used to do it quite easily. To allow working with user-defined types, Hana defines the Struct
concept. Once a user-defined type is a model of that concept, one can iterate over the members of an object of that type and query other useful information. To turn a user-defined type into a Struct
, a couple of options are available. First, you may define the members of your user-defined type with the BOOST_HANA_DEFINE_STRUCT
macro
This macro defines two members (name
and age
) with the given types. Then, it defines some boilerplate inside a Person::hana
nested struct
, which is required to make Person
a model of the Struct
concept. No constructors are defined (so POD-ness is retained), the members are defined in the same order as they appear here and the macro can be used with template struct
s just as well, and at any scope. Also note that you are free to add more members to the Person
type after or before you use the macro. However, only members defined with the macro will be picked up when introspecting the Person
type. Easy enough? Now, a Person
can be accessed programmatically
Iteration over a Struct
is done as if the Struct
was a sequence of pairs, where the first element of a pair is the key associated to a member, and the second element is the member itself. When a Struct
is defined through the BOOST_HANA_DEFINE_STRUCT
macro, the key associated to any member is a compile-time hana::string
representing the name of that member. This is why the function used with for_each
takes a single argument pair
, and then uses first
and second
to access the subparts of the pair. Also, notice how the to<char const*>
function is used on the name of the member? This converts the compile-time string to a constexpr char const*
so it can cout
ed. Since it can be annoying to always use first
and second
to fetch the subparts of the pair, we can also use the fuse
function to wrap our lambda and make it a binary lambda instead
Now, it looks much cleaner. As we just mentioned, Struct
s are seen as a kind of sequence of pairs for the purpose of iteration. In fact, a Struct
can even be searched like an associative data structure whose keys are the names of the members, and whose values are the members themselves
_s
user-defined literal creates a compile-time hana::string
. It is located in the boost::hana::literals
namespace. Note that it is not part of the standard yet, but it is supported by Clang and GCC. If you want to stay 100% standard, you can use the BOOST_HANA_STRING
macro instead.The main difference between a Struct
and a hana::map
is that a map can be modified (keys can be added and removed), while a Struct
is immutable. However, you can easily convert a Struct
into a hana::map
with to<map_tag>
, and then you can manipulate it in a more flexible way.
Using the BOOST_HANA_DEFINE_STRUCT
macro to adapt a struct
is convenient, but sometimes one can't modify the type that needs to be adapted. In these cases, the BOOST_HANA_ADAPT_STRUCT
macro can be used to adapt a struct
in an ad-hoc manner
BOOST_HANA_ADAPT_STRUCT
macro must be used at global scope.The effect is exactly the same as with the BOOST_HANA_DEFINE_STRUCT
macro, except you do not need to modify the type you want to adapt, which is sometimes useful. Finally, it is also possible to define custom accessors by using the BOOST_HANA_ADAPT_ADT
macro
This way, the names used to access the members of the Struct
will be those specified, and the associated function will be called on the Struct
when retrieving that member. Before we move on to a concrete example of using these introspection features, it should also be mentioned that struct
s can be adapted without using macros. This advanced interface for defining Struct
s can be used for example to specify keys that are not compile-time strings. The advanced interface is described in the documentation of the Struct
concept.
Let's now move on with a concrete example of using the introspection capabilities we just presented for printing custom objects as JSON. Our end goal is to have something like this
And the output, after passing it through a JSON pretty-printer, should look like
First, let's define a couple of utility functions to make string manipulation easier
The quote
and the to_json
overloads are pretty self-explanatory. The join
function, however, might need a bit of explanation. Basically, the intersperse
function takes a sequence and a separator, and returns a new sequence with the separator in between each pair of elements of the original sequence. In other words, we take a sequence of the form [x1, ..., xn]
and turn it into a sequence of the form [x1, sep, x2, sep, ..., sep, xn]
. Finally, we fold the resulting sequence with the _ + _
function object, which is equivalent to std::plus<>{}
. Since our sequence contains std::string
s (we assume it does), this has the effect of concatenating all the strings of the sequence into one big string. Now, let's define how to print a Sequence
First, we use the transform
algorithm to turn our sequence of objects into a sequence of std::string
s in JSON format. Then, we join that sequence with commas and we enclose it with []
to denote a sequence in JSON notation. Simple enough? Let's now take a look at how to print user-defined types
Here, we use the keys
method to retrieve a tuple
containing the names of the members of the user-defined type. Then, we transform
that sequence into a sequence of "name" : member
strings, which we then join
and enclose with {}
, which is used to denote objects in JSON notation. And that's it!
This section explains several important notions about Hana's containers: how to create them, the lifetime of their elements and other concerns.
While the usual way of creating an object in C++ is to use its constructor, heterogeneous programming makes things a bit more complicated. Indeed, in most cases, one is not interested in (or even aware of) the actual type of the heterogeneous container to be created. At other times, one could write out that type explicitly, but it would be redundant or cumbersome to do so. For this reason, Hana uses a different approach borrowed from std::make_tuple
to create new containers. Much like one can create a std::tuple
with std::make_tuple
, a hana::tuple
can be created with hana::make_tuple
. However, more generally, containers in Hana may be created with the make
function
In fact, make_tuple
is just a shortcut for make<tuple_tag>
so you don't have to type boost::hana::make<boost::hana::tuple_tag>
when you are out of Hana's namespace. Simply put, make<...>
is is used all around the library to create different types of objects, thus generalizing the std::make_xxx
family of functions. For example, one can create a hana::range
of compile-time integers with make<range_tag>
These types with a trailing
_tag
are dummy types representing a family of heterogeneous containers (hana::tuple
,hana::map
, etc..). Tags are documented in the section on Hana's core.
For convenience, whenever a component of Hana provides a make<xxx_tag>
function, it also provides the make_xxx
shortcut to reduce typing. Also, an interesting point that can be raised in this example is the fact that r
is constexpr
. In general, whenever a container is initialized with constant expressions only (which is the case for r
), that container may be marked as constexpr
.
So far, we have only created containers with the make_xxx
family of functions. However, some containers do provide constructors as part of their interface. For example, one can create a hana::tuple
just like one would create a std::tuple
When constructors (or any member function really) are part of the public interface, they will be documented on a per-container basis. However, in the general case, one should not take for granted that a container can be constructed as the tuple was constructed above. For example, trying to create a hana::range
that way will not work
In fact, we can't even specify the type of the object we'd like to create in that case, because the exact type of a hana::range
is implementation-defined, which brings us to the next section.
The goal of this section is to clarify what can be expected from the types of Hana's containers. Indeed, so far, we always let the compiler deduce the actual type of containers by using the make_xxx
family of functions along with auto
. But in general, what can we say about the type of a container?
The answer is that it depends. Some containers have well defined types, while others do not specify their representation. In this example, the type of the object returned by make_tuple
is well-defined, while the type returned by make_range
is implementation-defined
This is documented on a per-container basis; when a container has an implementation-defined representation, a note explaining exactly what can be expected from that representation is included in the container's description. There are several reasons for leaving the representation of a container unspecified; they are explained in the rationales. When the representation of a container is implementation-defined, one must be careful not to make any assumptions about it, unless those assumption are explicitly allowed in the documentation of the container. For example, assuming that one can safely inherit from a container or that the elements in the container are stored in the same order as specified in its template argument list is generally not safe.
While necessary, leaving the type of some containers unspecified makes some things very difficult to achieve, like overloading functions on heterogeneous containers
The is_a
utility is provided for this reason (and others). is_a
allows checking whether a type is a precise kind of container using its tag, regardless of the actual type of the container. For example, the above example could be rewritten as
This way, the second overload of f
will only match when R
is a type whose tag is range_tag
, regardless of the exact representation of that range. Of course, is_a
can be used with any kind of container: tuple
, map
, set
and so on.
In Hana, containers own their elements. When a container is created, it makes a copy of the elements used to initialize it and stores them inside the container. Of course, unnecessary copies are avoided by using move semantics. Because of those owning semantics, the lifetime of the objects inside the container is the same as that of the container.
Much like containers in the standard library, containers in Hana expect their elements to be objects. For this reason, references may not be stored in them. When references must be stored inside a container, one should use a std::reference_wrapper
instead
Much like the previous section introduced general but important notions about heterogeneous containers, this section introduces general notions about heterogeneous algorithms.
Algorithms in Hana always return a new container holding the result. This allows one to easily chain algorithms by simply using the result of the first as the input of the second. For example, to apply a function to every element of a tuple and then reverse the result, one simply has to connect the reverse
and transform
algorithms
This is different from the algorithms of the standard library, where one has to provide iterators to the underlying sequence. For reasons documented in the rationales, an iterator-based design was considered but was quickly dismissed in favor of composable and efficient abstractions better suited to the very particular context of heterogeneous programming.
One might also think that returning full sequences that own their elements from an algorithm would lead to tons of undesirable copies. For example, when using reverse
and transform
, one could think that an intermediate copy is made after the call to transform
To make sure this does not happen, Hana uses perfect forwarding and move semantics heavily so it can provide an almost optimal runtime performance. So instead of doing a copy, a move occurs between reverse
and transform
Ultimately, the goal is that code written using Hana should be equivalent to clever hand-written code, except it should be enjoyable to write. Performance considerations are explained in depth in their own section.
Hana 中的算法不是惰性的。当调用一个算法时,它会完成它的工作并返回一个包含结果的新序列,故事就此结束。例如,在一个大序列上调用 permutations
算法是个不明智的想法,因为 Hana 会实际计算出所有的排列。
相比之下,Boost.Fusion 中的算法返回视图,这些视图通过引用持有原始序列,并在访问序列的元素时按需应用算法。这会导致微妙的生命周期问题,比如拥有一个引用了已被销毁的序列的视图。Hana 的设计假设在大多数情况下,我们无论如何都想访问序列中的所有或几乎所有元素,因此性能并不是支持惰性的一个重要论据。
Hana 中的算法在它们扩展到的运行时代码方面有点特别。本小节的目的不是要精确解释生成了什么代码(这取决于编译器),而是要给人一种感觉。基本上,Hana 算法就像是等效的经典算法的展开版本。事实上,由于处理序列的边界在编译时是已知的,因此展开遍历序列的循环是有意义的。例如,我们来考虑 for_each
算法。
如果 xs
是一个运行时序列而不是一个元组,那么它的长度只能在运行时知道,上面的代码必须实现为一个循环:
然而,在我们的例子中,序列的长度在编译时是已知的,所以我们不必在每次迭代时检查索引。因此,我们只能写:
这里的主要区别在于,由于没有索引,所以每一步都不会进行边界检查和索引递增;循环被有效地展开了。在某些情况下,这对于性能来说是可取的。在其他情况下,它会因为导致代码大小的增长而损害性能。一如既往,性能是一个棘手的问题,你是否真的希望循环展开发生应该根据具体情况来处理。总的来说,处理容器中所有(或一部分)元素的算法会被展开。事实上,如果你仔细想想,对于异构序列来说,展开是唯一的方法,因为序列的不同元素可能有不同的类型。正如你可能已经注意到的,我们没有使用对元组的普通索引,而是使用了编译时索引,这无法通过普通的 for
循环生成。换句话说,下面的代码是没有意义的:
默认情况下,Hana 假设函数是纯函数。纯函数是没有副作用的函数。换句话说,它的作用仅由其返回值决定。特别是,这样的函数不能访问任何比函数单次调用生命周期更长的状态。这些函数具有非常好的属性,比如能够对它们进行数学推理、重新排序甚至消除调用等等。除非另有说明,否则 Hana 使用的所有函数(即用于高阶算法的函数)都应该是纯函数。特别是,传递给高阶算法的函数不保证会被调用特定的次数。此外,执行顺序通常没有指定,因此不应该被认为是理所当然的。如果这种关于函数调用的保证缺乏似乎很疯狂,请考虑以下对 any_of
算法的使用:
<boost/hana/ext/std/integral_constant.hpp>
中 std::integral_constant
的外部适配器。根据上一节关于展开的说明,此算法应扩展为类似以下内容:
当然,上面的代码不能直接工作,因为我们在一个必须是常量表达式的东西中调用 pred
,而 pred
是一个 lambda(lambda 不能在常量表达式中调用)。但是,这些对象中是否有任何对象具有整数类型是在编译时清楚的,因此我们期望计算答案只涉及编译时计算。事实上,Hana 就是这样做的,并且上面的算法被扩展为类似以下内容:
any_of
的实现必须比这更通用。然而,这种 欺骗性的简化 对于教育目的来说是完美的。如你所见,谓词甚至从未被执行过;只使用了它在特定对象上的结果类型。关于求值顺序,考虑 transform
算法,它被指定为(对于元组):
由于 make_tuple
是一个函数,并且函数的参数的求值顺序未指定,因此 f
在元组的每个元素上被调用的顺序也是未指定的。如果坚持使用纯函数,一切都会正常工作,并且结果代码通常更容易理解。然而,一些例外算法,如 for_each
,确实需要非纯函数,并且它们保证了求值顺序。事实上,一个只接受纯函数的 for_each
算法几乎是无用的。当一个算法可以接受非纯函数或保证某种求值顺序时,该算法的文档会明确提及。但是,默认情况下,不应假定任何保证。
本节介绍了跨阶段计算和算法的概念。事实上,我们已经在 快速入门 中使用过跨阶段算法,例如 filter
,但当时我们没有详细解释。但在介绍跨阶段算法之前,让我们先定义我们所说的跨阶段。我们这里所说的阶段是指程序的编译和执行。在 C++ 中,和大多数静态类型语言一样,编译时和运行时之间有明确的区别;这被称为阶段区分。当我们谈论跨阶段计算时,我们的意思是某种程度的计算是在这些阶段之间执行的;也就是说,一部分在编译时执行,一部分在运行时执行。
正如我们在前面的例子中看到的,一些函数即使在运行时值上调用时,也能返回可用于编译时的值。例如,让我们考虑将 length
函数应用于非 constexpr
容器:
显然,元组不能设为 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
并带有以下谓词:
<boost/hana/ext/std/integral_constant.hpp>
中 std::integral_constant
的外部适配器。首先,由于谓词只是查询每个元组元素类型的相关信息,因此其结果可以在编译时得知。由于元组的元素数量也在编译时得知,因此算法的总体结果理论上可以在编译时得知。更精确地说,发生的情况是谓词返回一个初始化的 std::is_same<...>
,它继承自 std::integral_constant
。Hana 识别这些对象,并且算法的编写方式可以保留谓词结果的 compile-time
性。最终,any_of
因此返回一个持有算法结果的 IntegralConstant
,我们使用编译器的类型推导巧妙地使其看起来很轻松。因此,编写如下内容是等效的(但那时您将需要已经知道算法的结果!):
好的,所以一些算法能够在输入满足某些关于 compile-time
性质的约束时返回编译时值。然而,其他算法更具限制性,并且它们要求其输入满足关于 compile-time
性质的某些约束,否则它们根本无法运行。例如 filter
,它接受一个序列和一个谓词,并返回一个新序列,其中只包含满足谓词的元素。filter
要求谓词返回一个 IntegralConstant
。虽然这个要求可能看起来很严格,但如果你仔细想想,它确实有道理。事实上,因为我们正在从异构序列中删除一些元素,所以结果序列的类型取决于谓词的结果。因此,谓词的结果必须在编译时已知,以便编译器能够为返回的序列分配类型。例如,考虑一下当我们尝试像这样过滤一个异构序列时会发生什么:
显然,我们知道谓词只在第二个元素上返回 false,因此结果应该是一个 [Fish, Dog]
元组。然而,编译器无从得知这一点,因为谓词的结果是运行时计算的结果,而这发生在编译器完成工作之后很久。因此,编译器没有足够的信息来确定算法的返回类型。但是,我们可以使用任何谓词来过滤相同的序列,只要该谓词的结果在编译时可用:
由于谓词返回一个 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 算法何时是惰性的,以便你知道我们何时人为地强制评估算法以进行基准测试。
第二类重要的算法是折叠(folds)。折叠可用于实现许多其他算法,如 count_if
、minimum
等。因此,折叠算法良好的编译时性能确保了这些派生算法良好的编译时性能,这就是为什么我们只在这里展示折叠。另请注意,所有非单子折叠变体在编译时方面都有些等效,因此我们只展示左折叠。以下图表展示了将 fold_left
应用于包含 n
个元素的序列的编译时性能。x
轴表示序列中的元素数量,y
轴表示以秒为单位的编译时间。用于折叠的函数是一个什么都不做的虚拟函数。在实际代码中,你可能会用一个非平凡的操作来折叠,所以曲线会比这更糟。然而,这些是微基准测试,因此它们只显示算法本身的性能。
第三个也是最后一个我们在这里展示的算法是 find_if
算法。这个算法很难高效实现,因为它需要停止在第一个满足给定谓词的元素上。出于同样的原因,现代技术在这里并没有真正帮助我们,所以这个算法构成了 Hana 实现质量的一个好测试,而没有考虑到 C++14 给予我们的免费午餐。
正如你所看到的,Hana 的性能优于 Fusion,与 MPL 相当,但 Hana 的 find_if
可以与值一起使用,而 MPL 的不行。这结束了编译时性能一节。如果你想查看我们尚未介绍的算法的性能,Metabench 项目提供了大多数 Hana 算法的编译时基准测试。
Hana 被设计为在运行时非常高效。但在深入细节之前,让我们先澄清一件事。Hana 是一个元编程库,允许同时操作类型和值,因此讨论运行时性能有时根本没有意义。事实上,对于类型级别的计算和 IntegralConstant
上的计算,运行时性能根本不是问题,因为计算的结果包含在一个类型中,而类型是纯粹的编译时实体。换句话说,这些计算只涉及编译时工作,甚至不会生成任何代码来在运行时执行这些计算。唯一有意义地讨论运行时性能的情况是在异构容器和算法中操作运行时值时,因为这是编译器必须生成一些运行时代码的唯一情况。因此,在本节的其余部分,我们将只研究这种类型的计算。
就像我们为编译时基准测试所做的那样,衡量 Hana 中运行时性能的方法是数据驱动的,而不是分析性的。换句话说,我们不是通过计算算法相对于输入大小执行的基本操作的数量来确定算法的复杂性,而是简单地测量最有趣的案例并观察其行为。这样做有几个原因。首先,我们不期望 Hana 的算法在大型输入上被调用,因为这些算法作用于长度必须在编译时已知的异构序列。例如,如果你试图在一个包含 10 万个元素的序列上调用 find_if
算法,你的编译器在尝试生成该算法的代码时将直接崩溃。因此,算法不能在非常大的输入上调用,分析方法也就失去了很多吸引力。其次,处理器已经发展成为相当复杂的设备,你能够实现的实际性能远不止算法执行的步骤数。例如,不良的缓存行为或分支预测错误可能会使理论上高效的算法变成慢动作,尤其是在输入较小的情况下。由于 Hana 会进行大量的展开,因此这些因素必须更加仔细地考虑,任何分析方法可能只会让我们相信我们是高效的。相反,我们想要硬数据,以及漂亮的图表来展示它们!
我们将需要基准测试的几个方面。首先,我们将显然需要基准测试算法的执行时间。其次,由于库中普遍使用的按值语义,我们还希望确保复制的数据量最少。最后,我们希望确保使用 Hana 不会因为展开而导致过多的代码膨胀,正如在 算法 部分中所解释的那样。
就像我们只研究了几个关键算法的编译时性能一样,我们将专注于少数算法的运行时性能。对于每个基准测试的方面,我们将比较不同库实现的算法。我们的目标是至少与 Boost.Fusion 一样高效,Boost.Fusion 在运行时性能方面接近最优。作为比较,我们还展示了在运行时序列上执行的相同算法,以及在长度在编译时已知但其 transform
算法不使用显式循环展开的序列上执行的相同算法。此处展示的所有基准测试均在Release CMake 配置中进行,该配置会处理传递正确的优化标志(通常是 -O3
)。让我们从以下图表开始,该图表显示了转换不同类型序列所需的执行时间:
fusion::transform
通常是惰性的,并且我们为了基准测试的目的强制评估它。如你所见,Hana 和 Fusion 几乎是相同的。对于较大的数据集,std::array
稍慢,而对于较大的数据集,std::vector
明显更慢。由于我们也需要注意代码膨胀,让我们看看同一场景下生成的可执行文件的大小:
如你所见,代码膨胀似乎不是一个问题,至少在这个微基准测试中无法检测到。现在让我们看看 fold
算法,它经常被使用:
在这里,你可以看到大家表现都差不多,这是一个好迹象,表明 Hana 至少没有搞砸事情。再次,让我们看看可执行文件的大小:
这里再次,代码大小没有爆炸。所以,至少对于中等使用 Hana(以及 Fusion,因为它们有相同的问题),代码膨胀不应该是一个主要问题。图中所示的容器包含随机生成的 int
,它们很容易复制并适用于微基准测试。但是,当我们在元素复制成本很高的容器上链接多个算法时会发生什么?更普遍地说,问题是:当算法传递一个临时对象时,它是否会抓住机会避免不必要的复制?考虑:
为了回答这个问题,我们将查看在对大约 1k 个字符的字符串进行基准测试时生成的图表。但是,请注意,对标准库算法进行此基准测试并没有太大意义,因为它们不返回容器。
fusion::reverse
通常是惰性的,并且我们为了基准测试的目的强制评估它。如你所见,Hana 比 Fusion 快,可能是因为在实现中更一致地使用了移动语义。如果我们没有向 reverse
提供临时容器,Hana 就无法进行移动,并且两个库的表现都会相似。
这结束了运行时性能一节。希望你现在已经确信 Hana 是为速度而设计的。性能对我们很重要:如果你遇到 Hana 导致生成糟糕代码的情况(并且错误不在编译器),请打开一个 issue,以便问题得到解决。
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 列表中创建超过 FUSION_MAX_LIST_SIZE
个元素的列表。显然,这些限制会继承给 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
后缀的约定。
T
的对象的标签可以通过使用 tag_of<T>::type
或等效的 tag_of_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++ 模板目前缺乏这种约束模板参数的能力,但是一个名为 concepts 的语言特性正在推广,其目标是解决这个问题。
考虑到类似的东西,Hana 的算法支持比上面解释的标签分派多一层。这一层允许我们为所有满足某个谓词的类型“特化”一个算法。例如,假设我们想为代表某种序列的所有类型实现上面的 print
函数。现在,我们没有简单的方法可以做到这一点。然而,Hana 算法的标签分派的设置方式与上面所示略有不同,因此我们可以这样写:
其中 Tag represents some kind of sequence
只需一个布尔表达式,表示 Tag
是否为序列。我们将在下一节看到如何创建这样的谓词,但现在让我们假设它就是有效的。不深入研究标签分派的设置细节,上面的特化只会在满足谓词且找不到更好匹配时被选中。因此,例如,如果我们的 vector_tag
满足谓词,我们为 vector_tag
编写的初始实现将仍然优先于基于 hana::when
的特化,因为它代表了更好的匹配。一般来说,任何特化(无论是显式的还是部分的)不使用 hana::when
都将优先于使用 hana::when
的特化,后者被设计成尽可能不让用户感到意外。这几乎涵盖了 Hana 中标签分派的所有内容。下一节将解释如何创建 C++ concepts 以用于元编程,然后可以与 hana::when
结合使用以实现相当大的表达能力。
Hana 中 concepts 的实现非常简单。它的核心是一个模板 struct
,它继承自一个布尔 integral_constant
,表示给定类型是否模型了该 concept。
然后,可以通过查看 Concept<T>::value
来测试类型 T
是否是 Concept
的模型。很简单,对吧?现在,虽然实现检查的方式对 Hana 来说不一定有什么特别之处,但本节的其余部分将解释 Hana 中通常如何完成,以及它如何与标签分派交互。然后你应该能够定义你自己的 concepts,或者至少更好地理解 Hana 的内部工作原理。
通常,Hana 定义的 concept 要求任何模型实现一些经过标签分派的函数。例如,Foldable
concept 要求任何模型至少定义 hana::unpack
或 hana::fold_left
中的一个。当然,concepts 通常还定义了模型必须满足的语义要求(称为定律),但这些定律不能(也不能)由 concept 检查。但是,我们如何检查某些函数是否已正确实现?为此,我们需要稍微修改前面章节中所示的标签分派方法的定义方式。让我们回到我们的 print
示例,尝试为那些可以被 print
的对象定义一个 Printable
concept。我们的最终目标是拥有一个模板结构,如:
为了知道 print_impl<...>
是否已定义,我们将修改 print_impl
,使其在未被覆盖时继承一个特殊的基类,我们将简单地检查 print_impl<T>
是否继承自该基类:
当然,当我们使用自定义类型特化 print_impl
时,我们不继承自那个 special_base_class
类型。
正如你所见,Printable<T>
实际上只检查 print_impl<T>
结构是否被自定义类型特化了。特别是,它甚至不检查嵌套的 ::apply
函数是否已定义或在语法上是否有效。它假定,如果有人为自定义类型特化了 print_impl
,那么嵌套的 ::apply
函数就存在且是正确的。如果不存在,当尝试打印该类型对象的 print
时,会触发编译错误。Hana 中的 Concepts 也做出相同的假设。
由于这种继承自特殊基类的模式在 Hana 中相当普遍,库提供了一个名为 hana::default_
的虚拟类型,可以代替 special_base_class
。然后,可以使用 hana::is_default
代替 std::is_base_of
,后者看起来更美观。有了这种语法糖,代码现在就变成了:
关于标签分派函数和 Concepts 之间的交互,就只有这些需要了解了。然而,Hana 中的一些 Concepts 不仅仅依赖于特定标签分派函数的定义来判断一个类型是否是该 Concepts 的模型。当一个 Concepts 仅仅通过定律和精炼 Concepts 来引入语义保证,但没有额外的语法要求时,就可能发生这种情况。定义这样一个 Concepts 可能有多种原因。首先,有时算法可以更有效地实现,如果我们能假设一些语义保证 X 或 Y,那么我们就可以创建一个 Concepts 来强制执行这些保证。其次,当有额外的语义保证时,有时可以自动定义多个 Concepts 的模型,从而为用户节省了手动定义这些模型的麻烦。例如,Sequence
Concepts 就是这种情况,它基本上为 Iterable
和 Foldable
增加了语义保证,进而允许我们定义从 Comparable
到 Monad
的各种 Concepts 的模型。
对于这些 Concepts,通常需要特化 boost::hana
命名空间中相应的模板结构体,为自定义类型提供模型。这样做就像提供一个印章,表明 Concepts 所需的语义保证得到了自定义类型的尊重。需要显式特化的 Concepts 会记录该事实。所以就是这样了!这就是关于 Hana 中 Concepts 的所有内容,本节关于 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/
中的任何内容都不保证稳定,因此您不应使用它。现在您已经拥有开始使用该库所需的一切。从现在开始,精通该库只是理解如何使用它提供的通用 Concepts 和容器的问题,这最好通过查阅参考文档来完成。稍后,您可能还想创建自己的 Concepts 和数据类型以更好地满足您的需求;请尽管去做,该库就是为此设计的。
使用异构对象进行编程本质上是函数式的——由于不可能修改对象的类型,因此必须引入一个新对象,这排除了变异。与以前的设计模型为 STL 的元编程库不同,Hana 使用函数式编程风格,这是其表达能力的重要来源。然而,因此,参考文档中介绍的许多 Concepts 对于不了解函数式编程的 C++ 程序员来说可能是不熟悉的。参考文档会尽可能使用直观的方式来介绍这些 Concepts,但请记住,最高的收益通常需要付出一些努力。
多年来,我创作了一些关于 Hana 和更广泛的元编程的材料。您可能会发现其中一些很有用:
我做的关于 Hana 和元编程的演讲的完整列表可以在 这里 找到。还有一个 Hana 文档的非官方中文翻译版本,可以在 这里 找到。
越来越多的项目在使用 Hana。了解这些项目可以帮助您更好地了解如何使用该库。这里有一些项目(如果您希望您的项目被列出,请 提交一个 issue):
至此,教程部分的内容就结束了。希望您喜欢使用该库,并请考虑 贡献 使其变得更好!
– Louis
与大多数通用库一样,Hana 中的算法通过它们所属的 Concepts 进行文档记录(Foldable
、Iterable
、Searchable
、Sequence
等)。然后,不同的容器将在其自己的页面上进行文档记录,其中记录了它们建模的 Concepts。某些容器建模的 Concepts 定义了可以使用该容器的算法。
更具体地说,参考文档(可在左侧菜单中找到)的结构如下:
optional
的 maybe
。当您对 Hana 有了更多了解后,可能会想查找特定函数、Concepts 或容器的参考。如果您知道要查找的内容的名称,可以使用文档任意页面右上角的搜索框。我的个人经验是,当您已经知道名称时,这是查找所需内容的最快方法。
正如您在参考文档中看到的,一些函数提供了用半形式化数学语言记录的签名。我们正在以这种方式记录所有函数,但这可能需要一段时间。使用的表示法是定义函数的标准数学表示法。具体来说,函数 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
Concepts 的一部分,所以它实际上并不是专门为 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{其中 S 是一个 Sequence} \]
然而,这未能表达 S
的内容是 Orderable
的要求。为了表达这一点,我们使用以下表示法:
\[ \mathtt{sort} : \mathtt{S(T)} \to \mathtt{S(T)} \\ \text{其中 S 是一个 Sequence 且 T 是 Orderable} \]
一种看待它的方式是假设序列标签 S
实际上由序列元素标签 T
参数化。我们也假设所有元素都具有相同的标签 T
,但这通常不是事实。现在,通过说明 T
必须是 Orderable
,我们表达了序列的元素必须是 Orderable
的事实。这种表示法以不同的形式用于表达不同类型的要求。例如,cartesian_product
算法接受一个序列的序列,并以序列的序列的形式返回这些序列的笛卡尔积。使用我们的表示法,这可以非常容易地传达:
\[ \mathtt{cartesian\_product} : \mathtt{S(S(T))} \to \mathtt{S(S(T))} \\ \text{其中 S 是一个 Sequence} \]
我想感谢以下个人和组织在不同方面为 Hana 做出的贡献:
参考文档使用了一些特定于该库的术语。此外,有时会用伪代码提供函数的简化实现,实际实现有时可能有点难以理解。本节定义了参考文档和用于描述某些函数的伪代码中使用的术语。
表示对象被优化转发。这意味着如果 x
是一个参数,它被 std::forward
,如果它是一个捕获的变量,当包含的 lambda 是一个右值时,它将从其中移动。
另外请注意,当 x
可以被移动时,函数中 decltype(auto)
的语句 return forwarded(x);
并不意味着将返回 x
的右值引用,这会产生一个悬空引用。相反,它意味着 x
按值返回,该值使用 std::forward
ed 的 x
构建。
这在 lambda 中用于表示捕获的变量是使用完美转发初始化的,就像使用了 [x(forwarded(x))...]() { }
。
这意味着文档中记录的函数使用了 标签分派,因此确切的实现取决于与函数关联的概念的模型。
这表示实体的确切实现(通常是类型)不应被用户依赖。特别是,这意味着除了文档中明确写出的内容之外,不能假设任何内容。通常,实现定义实体所满足的 Concepts 会被记录下来,因为否则用户将无法对其进行任何操作。具体来说,假设一个实现定义实体过多,可能不会对您造成致命打击,但在更新到新版本 Hana 时,它很可能会破坏您的代码。
本节记录了一些设计选择的基本原理。它也作为一些(不那么)常见问题的常见问题解答。如果您认为应该在此列表中添加内容,请提交一个 GitHub issue,我们将考虑改进文档或将问题添加到此处。
这样做的原因有几个。首先,Hana 是一个非常基础的库;我们基本上是用对异构类型的支持来重新实现核心语言和标准库。在代码审查过程中,人们很快就会发现其他库很少需要,几乎所有东西都必须从头开始实现。此外,由于 Hana 非常基础,保持依赖项最小化就更有动力了,因为这些依赖项将传递给用户。关于最小化对 Boost 的依赖,使用它的一个主要论点是可移植性。然而,作为一个前沿库,Hana 只针对非常新的编译器。因此,我们可以负担使用现代构造,并且使用 Boost 提供的可移植性在很大程度上代表了沉重负担。
基于迭代器的设计有其优点,但它们也以降低算法的可组合性而闻名。此外,异构编程的上下文带来了许多使迭代器吸引力大大降低的因素。例如,递增迭代器必须返回一个具有不同类型的新迭代器,因为序列中它指向的新对象的类型可能不同。事实证明,用迭代器实现大多数算法在编译时性能方面会更差,仅仅是因为元编程的执行模型(使用编译器作为解释器)与 C++ 的运行时执行模型(处理器访问连续内存)截然不同。
首先,它为实现通过使用特定容器的智能表示来进行编译时和运行时优化提供了更大的灵活性。例如,包含同类型对象 T
的元组可以实现为类型 T
的数组,这在编译时更有效。其次,也是最重要的一点,事实证明,了解一个异构容器的类型并没有您想象的那么有用。确实,在异构编程的上下文中,由计算返回的对象类型通常是计算的一部分。换句话说,在不实际执行算法的情况下,无法知道算法返回的对象类型。例如,考虑 find_if
算法:
如果元组的某个元素满足谓词,则 result 将等于 just(x)
。否则,result
将等于 nothing
。然而,结果的 nothing
性是在编译时已知的,这要求 just(x)
和 nothing
具有不同的类型。现在,假设您想显式地写出结果的类型:
为了知道 some_type
是什么,您需要实际执行算法,因为 some_type
取决于谓词是否在容器的某个元素上得到满足。换句话说,如果您能够写出上面的内容,那么您已经知道了算法的结果,并且您根本不需要执行该算法。在 Boost.Fusion 中,这个问题通过拥有一个单独的 result_of
命名空间来解决,该命名空间包含一个元函数,该元函数在给定传递给它的参数类型的情况下计算任何算法的结果类型。例如,上面的示例可以与 Fusion 重写为:
请注意,我们基本上是计算了两次;一次在 result_of
命名空间中,一次在正常的 fusion
命名空间中,这是高度冗余的。在 auto
和 decltype
出现之前,这些技术对于执行异构计算是必需的。然而,随着现代 C++ 的出现,在异构编程的上下文中显式返回类型已在很大程度上过时,并且了解容器的实际类型通常不是那么有用。
不,这不是我女朋友的名字!我只是需要一个简短且好看的名字,人们容易记住,Hana 就这样出现了。我还注意到 Hana 在日语中是花的意思,在韩语中是一的意思。鉴于 Hana 很漂亮,并且在一个统一的范式下统一了类型级别和异构编程,回想起来,这个名字似乎非常合适 :-)
由于 Hana 定义了许多关于 tuple 的算法,一种可能的方法是简单地使用 std::tuple
并仅提供算法,而不是还提供我们自己的 tuple。提供我们自己的 tuple 的主要原因是为了性能。事实上,到目前为止测试过的所有 std::tuple
实现都有非常糟糕的编译时性能。此外,为了获得真正惊人的编译时性能,在某些算法中我们需要利用 tuple 的内部表示,这需要我们自己定义。
在决定名称 X
时,我会尝试平衡以下几点(不分先后顺序):
X
在 C++ 中有多么惯用?X
在其他编程世界中有多么惯用?X
实际上是一个多好的名字,无论历史原因如何X
的感觉如何?X
的感觉如何?X
,例如名称冲突或标准保留的名称?当然,好的命名是并且将永远是困难的。名称是并且将永远被作者的偏见所玷污。尽管如此,我还是试图以合理的方式选择名称。
与命名(相当主观)不同,函数参数的顺序通常很容易确定。基本上,经验法则是“容器放在第一位”。Fusion 和 MPL 一直如此,这对大多数 C++ 程序员来说都很直观。此外,在高阶算法中,我试图将函数参数放在最后,以便多行 lambda 看起来更美观:
我们可以使用几种不同的技术为库提供自定义点,并选择了标签分派。为什么?首先,我想要一个两层分派系统,因为这允许第一层函数(用户调用的函数)实际上是函数对象,这允许将它们传递给高阶算法。使用两层分派系统还可以为第一层添加一些编译时健全性检查,从而改进错误消息。
现在,选择标签分派而不是其他两层技术有几个原因。首先,必须明确说明某个标签如何成为 Concepts 的模型,这使得用户有责任确保 Concepts 的语义要求得到满足。其次,在检查一个类型是否是某个 Concepts 的模型时,我们基本上是检查一些关键函数是否已实现。特别是,我们检查 Concepts 的最小完整定义中的函数是否已实现。例如,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
时用用户提供的对象填充最短的序列。由于没有任何要求所有被 zip 的序列都具有相同类型的元素,因此在所有情况下都无法提供一个单一的、一致的填充对象。应该提供一个填充对象元组,但我认为目前这可能过于复杂,不值得尝试。如果您需要此功能,请提交一个 GitHub issue。
由于 C++ Concepts 提案将 Concepts 映射到布尔 constexpr
函数,因此 Hana 将其 Concepts 定义为这样而不是带有嵌套 ::value
的结构体似乎是合乎逻辑的。事实上,这是第一个选择,但由于模板函数的一个限制使其灵活性较低,因此不得不进行修改。具体来说,模板函数不能传递给高阶元函数。换句话说,无法编写以下代码:
这种代码在某些上下文中非常有用,例如检查两个类型是否具有建模 Concepts 的共同嵌入:
当 Concepts 是布尔 constexpr
函数时,无法泛型地编写此代码。然而,当 Concepts 仅仅是模板结构体时,我们可以使用模板模板参数:
在 C++ 中,编译时和运行时之间的界限是模糊的,随着 C++14 引入通用常量表达式,这一事实变得更加明显。然而,能够操作异构对象,关键在于理解这个边界,然后随心所欲地跨越它。本节的目标是理清 constexpr
的问题;了解它能解决什么问题,不能解决什么问题。本节涵盖了关于常量表达式的高级概念;只有对 constexpr
有深刻理解的读者才应尝试阅读。
让我们从一个具有挑战性的问题开始。以下代码是否应该编译?
答案是否定的,Clang 提供的错误信息如下:
解释是,在 f
的主体内部,t
不是一个常量表达式,因此不能用作 static_assert
的操作数。原因是编译器无法生成这样的函数。为了理解这个问题,考虑当我们将 f
模板实例化为具体类型时应该发生什么:
显然,编译器无法生成 f<int>
的代码,如果 t != 1
,它应该会触发一个 static_assert
,因为我们还没有指定 t
。更糟糕的是,生成的函数应该同时处理常量表达式和非常量表达式:
显然,fptr
的代码无法生成,因为它需要能够对运行时值进行 static_assert
,这没有意义。此外,请注意,无论您是否将函数设为 constexpr
,都没有关系;将 f
设为 constexpr
只会说明当其参数是常量表达式时,f
的结果是常量表达式,但这仍然不能让您从 f
的主体中知道您是用常量表达式调用它的。换句话说,我们想要的是类似这样的东西:
在这个假设的情况下,编译器将知道 t
是 f
主体中的一个常量表达式,并且 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 提供的错误信息非常明确地说明了正在发生的情况: