Boost.Hana  1.7.1
您的元编程标准库
用户手册

描述


Hana 是一个面向 C++ 元编程的头文件库,适用于对类型和值进行计算。它提供的功能是完善的 Boost.MPLBoost.Fusion 库提供的功能的超集。通过利用 C++11/14 的实现技术和习惯用法,Hana 拥有更快的编译时间和与之前的元编程库相当或更好的运行时性能,同时在这个过程中显著提高了表达能力。Hana 非常容易以一种临时的方式进行扩展,并且它提供了与 Boost.Fusion、Boost.MPL 和标准库的开箱即用互操作性。

先决条件和安装


Hana 是一个头文件库,没有外部依赖项(甚至没有其他 Boost 库)。因此,在您自己的项目中使用 Hana 非常容易。基本上,只需下载项目并将 include/ 目录添加到编译器的头文件搜索路径,您就完成了。但是,如果您希望干净地安装 Hana,您有几个选择

  1. 安装 Boost
    从 Boost 1.61.0 开始,Hana 包含在 Boost 发行版中,因此安装该发行版将使您能够使用 Hana。
  2. 手动安装
    您可以从官方 GitHub 仓库 下载代码,并通过从项目根目录发出以下命令手动安装库(需要 CMake
    mkdir build && cd build
    cmake ..
    cmake --build . --target install
    这将把 Hana 安装到您平台的默认安装目录(对于 Unix 为 /usr/local,对于 Windows 为 C:/Program Files)。如果您希望将 Hana 安装到自定义位置,您可以使用
    cmake .. -DCMAKE_INSTALL_PREFIX=/custom/install/prefix

如果您只想为 Hana 做出贡献,您可以在 README 中查看如何为开发最佳设置您的环境。

注意
不要将独立安装的 Hana(即,不是通过 Boost 安装的 Hana)与完整安装的 Boost 混合使用。Boost 中提供的 Hana 和独立的 Hana 可能发生冲突,您将无法知道哪个版本在哪个地方使用。这会导致麻烦。

针对 CMake 用户的说明

如果您使用 CMake,那么依赖 Hana 从未如此简单。手动安装后,Hana 会创建一个 HanaConfig.cmake 文件,该文件使用所有必需的设置导出 hana 接口库目标。您只需要使用 CMake 手动安装 Hana,使用 find_package(Hana),然后将您自己的目标链接到 hana 目标。以下是一个最小的示例

cmake_minimum_required(VERSION 3.0)
project(external CXX)
find_package(Hana REQUIRED)
add_executable(external main.cpp)
target_link_libraries(external hana)

如果您在非标准位置安装了 Hana,您可能需要调整 CMAKE_PREFIX_PATH。例如,如果您将 Hana “手动” 安装到另一个项目的本地位置,就会发生这种情况。在这种情况下,您需要告诉 CMake 在哪里找到 HanaConfig.cmake 文件,方法是使用

list(APPEND CMAKE_PREFIX_PATH "${INSTALLATION_PREFIX_FOR_HANA}")
cmake ... -DCMAKE_PREFIX_PATH=${INSTALLATION_PREFIX_FOR_HANA}

其中 INSTALLATION_PREFIX_FOR_HANA 是 Hana 安装位置的路径。

编译器要求

该库依赖于 C++14 编译器和标准库,但不需要其他任何东西。但是,我们只保证支持以下列出的编译器,这些编译器会持续测试

编译器/工具链状态
Clang >= 7完全正常工作;在每次推送到 GitHub 时进行测试
Xcode >= 11完全正常工作;在每次推送到 GitHub 时进行测试
GCC >= 8完全正常工作;在每次推送到 GitHub 时进行测试
VS2017 >= 更新 7完全正常工作;在每次推送到 GitHub 时进行测试

更具体地说,Hana 需要支持以下 C++14 功能的编译器/标准库(非详尽列举)

  • 泛型 lambda 表达式
  • 通用 constexpr
  • 可变模板
  • 自动推断的返回值类型
  • 来自 <type_traits> 头文件的全部 C++14 类型特征

使用上面未列出的编译器可能会正常工作,但不能保证对这些编译器的支持。有关特定平台的更多信息,请访问 维基百科

支持


如果您遇到问题,请查看 常见问题解答维基百科。搜索 问题 中是否存在您的问题也是一个好主意。如果这些方法都没有帮助,请随时在 Gitter 上与我们聊天,或创建一个新的问题。建议在 StackOverflow 上使用 boost-hana 标签提问关于用法的问题。如果您遇到您认为是错误的问题,请创建一个问题。

简介


当 Boost.MPL 首次出现时,它为 C++ 程序员带来了巨大的解脱,因为它将大量的模板黑客行为抽象到一个可用的接口后面。这一突破极大地促进了 C++ 模板元编程的普及,如今这种技术已深深扎根于许多重要的项目中。最近,C++11 和 C++14 为该语言带来了许多重大变化,其中一些变化使元编程变得更加容易,而另一些变化则极大地拓宽了库的设计空间。因此,自然会产生一个问题:使用元编程的抽象仍然可取吗?如果是,哪些抽象?在研究了像 MPL11 这样的不同选项之后,答案最终以库的形式自行出现;Hana。Hana 的关键见解是,类型和值的操纵不过是一枚硬币的两面。通过统一这两个概念,元编程变得更加容易,我们面前也打开了激动人心的新可能性。

C++ 计算象限

但要真正理解 Hana 的全部含义,必须了解 C++ 中不同类型的计算。我们将重点关注四种不同的计算类型,尽管可能进行更细粒度的划分。首先,我们有运行时计算,这是我们在 C++ 中使用的常见计算。在这个领域,我们有运行时容器、运行时函数和运行时算法

auto f = [](int i) -> std::string {
return std::to_string(i * i);
};
std::vector<int> ints{1, 2, 3, 4};
std::vector<std::string> strings;
std::transform(ints.begin(), ints.end(), std::back_inserter(strings), f);
assert((strings == std::vector<std::string>{"1", "4", "9", "16"}));

在该象限内进行编程的常用工具箱是 C++ 标准库,它提供可重用算法和在运行时运行的容器。从 C++11 开始,第二种类型的计算成为可能:constexpr 计算。在那里,我们有 constexpr 容器、constexpr 函数和 constexpr 算法

constexpr int factorial(int n) {
return n == 0 ? 1 : n * factorial(n - 1);
}
template <typename T, std::size_t N, typename F>
transform(std::array<T, N> array, F f) {
// ...
}
constexpr std::array<int, 4> ints{{1, 2, 3, 4}};
constexpr std::array<int, 4> facts = transform(ints, factorial);
static_assert(facts == std::array<int, 4>{{1, 2, 6, 24}}, "");
对 Hana 的 std::array 进行适配。
定义: array.hpp:64
注意
为了使上述代码实际工作,std::arrayoperator== 必须标记为 constexpr,但情况并非如此(即使在 C++14 中)。

基本上,constexpr 计算不同于运行时计算,因为它的简单程度足以由编译器进行评估(实际上是解释)。通常,任何不执行对编译器评估器过于“不友好”的操作(例如抛出异常或分配内存)的函数,都可以标记为 constexpr,而无需任何进一步的更改。这使得 constexpr 计算非常类似于运行时计算,只是 constexpr 计算更加受限,并且获得了在编译时进行评估的能力。不幸的是,没有用于 constexpr 编程的常用工具箱,即,没有广泛采用的用于 constexpr 编程的“标准库”。但是,对于那些对 constexpr 计算有一定兴趣的人来说,可能值得查看 Sprout 库。

第三种类型的计算是非同质计算。非同质计算不同于普通计算,因为普通计算的容器保存的是同质对象(所有对象具有相同的类型),而非同质计算的容器可能保存不同类型的对象。此外,此计算象限中的函数是非同质函数,这是一个复杂的术语,用于描述模板函数。同样,我们也有非同质算法来操纵非同质容器和函数

auto to_string = [](auto t) {
std::stringstream ss;
ss << t;
return ss.str();
};
fusion::vector<int, std::string, float> seq{1, "abc", 3.4f};
fusion::vector<std::string, std::string, std::string>
strings = fusion::transform(seq, to_string);
assert(strings == fusion::make_vector("1"s, "abc"s, "3.4"s));
constexpr auto to_string
等同于 to<string_tag>; 为方便起见提供。
定义: string.hpp:194

如果您觉得操作异构容器过于奇怪,就将其视为对 std::tuple 操作的强化版本。在 C++03 世界中,用于执行这种计算的最佳库是 Boost.Fusion,它提供了多种数据结构和算法来操作异构数据集合。我们将在这里考虑的第四个也是最后一个计算象限是类型级计算象限。在这个象限中,我们有类型级容器、类型级函数(通常称为元函数)和类型级算法。在这里,所有操作都在类型上进行:容器保存类型,元函数以类型作为参数,并返回类型作为结果。

template <typename T>
struct add_const_pointer {
using type = T const*;
};
using types = mpl::vector<int, char, float, void>;
using pointers = mpl::transform<types, add_const_pointer<mpl::_1>>::type;
static_assert(mpl::equal<
pointers,
mpl::vector<int const*, char const*, float const*, void const*>
>::value, "");
constexpr auto equal
返回一个表示 x 是否等于 y 的 Logical。
定义: equal.hpp:64
constexpr auto value
返回与常量相关联的编译时值。
定义: value.hpp:54

类型级计算领域已被相当广泛地探索,C++03 中类型级计算的事实上的解决方案是一个名为 Boost.MPL 的库,它提供了类型级容器和算法。对于低级类型转换,自 C++11 以来,<type_traits> 标准头文件中提供的元函数也可以使用。

这个库是做什么的?

因此一切都很好,但是这个库到底是什么呢?现在我们已经通过澄清 C++ 中可用的计算类型来设定了舞台,答案可能会让您觉得非常简单。Hana 的目的是将计算的第 3 个象限和第 4 个象限合并。更具体地说,Hana 是一个(冗长的)构造性证明,异构计算严格来说比类型级计算更强大,因此我们可以通过等效的异构计算来表达任何类型级计算。这种构造分两个步骤完成。首先,Hana 是一个功能齐全的异构算法和容器库,有点类似于现代化的 Boost.Fusion。其次,Hana 提供了一种将任何类型级计算转换为等效的异构计算并返回的方法,这使得异构计算的完整机制可以用于类型级计算,而无需任何代码重复。当然,这种统一的最大优势是用户所看到的,正如您自己将要见证的那样。

快速入门


本节的目的是从非常高的层面上以相当快的速度介绍该库的主要概念;如果您不理解即将出现的每个内容,请不要担心。但是,本教程假设读者已经至少熟悉基本的元编程和 C++14 标准。首先,让我们包含该库

#include <boost/hana.hpp>
namespace hana = boost::hana;
包含所有库组件,除了外部库的适配器。
包含库中所有内容的命名空间。
定义: accessors.hpp:20

除非另有说明,否则文档假设在示例和代码段之前存在上述行。另请注意,提供了更细粒度的标头,将在 标头组织 部分进行说明。出于快速入门的目的,让我们现在包含一些额外的标头并定义一些我们将在下面需要的可爱的动物类型

#include <cassert>
#include <iostream>
#include <string>
struct Fish { std::string name; };
struct Cat { std::string name; };
struct Dog { std::string name; };

如果您正在阅读本文档,您可能已经知道 std::tuplestd::make_tuple。Hana 提供了自己的元组和 make_tuple

auto animals = hana::make_tuple(Fish{"Nemo"}, Cat{"Garfield"}, Dog{"Snoopy"});

这将创建一个元组,它类似于数组,但它可以保存具有不同类型的元素。可以保存具有不同类型的元素的容器(如这种)被称为异构容器。虽然标准库提供了一些操作来操作 std::tuple,但 Hana 提供了一些操作和算法来操作它自己的元组

using namespace hana::literals;
// 使用运算符 [] 而不是 std::get 来访问元组元素。
Cat garfield = animals[1_c];
// 对元组执行高级算法(这类似于 std::transform)
auto names = hana::transform(animals, [](auto a) {
return a.name;
});
assert(hana::reverse(names) == hana::make_tuple("Snoopy", "Garfield", "Nemo"));
constexpr auto reverse
反转序列。
定义: reverse.hpp:33
注意
1_c 是一个 C++14 用户定义的字面量,用于创建 编译时数字。这些用户定义的字面量包含在 boost::hana::literals 命名空间中,因此需要 using 指令。

请注意我们如何将 C++14 通用 lambda 传递给 transform;这是必需的,因为 lambda 首先将使用 Fish 调用,然后使用 Cat 调用,最后使用 Dog 调用,它们都具有不同的类型。Hana 提供了 C++ 标准库提供的大多数算法,只是它们在元组和相关的异构容器上工作,而不是在 std::vector 及其朋友上工作。除了处理异构值外,Hana 还使您可以使用自然语法执行类型级计算,所有这些都在编译时进行,并且没有任何开销。这将编译并按照您的预期执行

auto animal_types = hana::make_tuple(hana::type_c<Fish*>, hana::type_c<Cat&>, hana::type_c<Dog>);
auto no_pointers = hana::remove_if(animal_types, [](auto a) {
return hana::traits::is_pointer(a);
});
static_assert(no_pointers == hana::make_tuple(hana::type_c<Cat&>, hana::type_c<Dog>), "");
注意
type_c<...> 不是类型!它是一个 C++14 变量模板,它生成表示 Hana 中类型的对象。这将在关于 类型计算 的部分中进行说明。

除了异构和编译时序列外,Hana 还提供了一些功能,让您过去的元编程噩梦成为过去。例如,可以使用一行代码检查结构成员是否存在,而无需依赖 笨拙的 SFINAE 技巧

auto has_name = hana::is_valid([](auto&& x) -> decltype((void)x.name) { });
static_assert(has_name(garfield), "");
static_assert(!has_name(1), "");

正在编写序列化库吗?别哭了,我们帮您搞定了。可以非常轻松地将反射添加到用户定义的类型中。这允许迭代用户定义类型的成员,使用程序化接口查询成员等等,而没有任何运行时开销

// 1. 为“Person”提供内省功能
struct Person {
BOOST_HANA_DEFINE_STRUCT(Person,
(std::string, name),
(int, age)
);
};
// 2. 编写一个通用的序列化器(请忍受 std::ostream 作为示例)
auto serialize = [](std::ostream& os, auto const& object) {
hana::for_each(hana::members(object), [&](auto member) {
os << member << std::endl;
});
};
// 3. 使用它
Person john{"John", 30};
serialize(std::cout, john);
// 输出
// John
// 30
constexpr auto for_each
对可折叠的每个元素执行操作,每次都丢弃结果。
定义: for_each.hpp:39
constexpr auto members
返回一个包含 Struct 成员的 Sequence。
定义: members.hpp:30

这很酷,但我已经能听到您抱怨难以理解的错误消息。但是,事实证明 Hana 是为人类而构建的,而不是为专业的模板元程序员构建的,这表明了这一点。让我们故意搞砸并看看会给我们带来什么样的混乱。首先,是错误

auto serialize = [](std::ostream& os, auto const& object) {
hana::for_each(os, [&](auto member) {
// ^^ Oops!
os << member << std::endl;
});
};

现在,是惩罚

error: static_assert failed "hana::for_each(xs, f) requires 'xs' to be Foldable"
static_assert(Foldable<S>::value,
^ ~~~~~~~~~~~~~~~~~~
note: in instantiation of function template specialization
'boost::hana::for_each_t::operator()<
std::__1::basic_ostream<char> &, (lambda at [snip])>' requested here
hana::for_each(os, [&](auto member) {
^
note: in instantiation of function template specialization
'main()::(anonymous class)::operator()<Person>' requested here
serialize(std::cout, john);
^
constexpr auto in
返回该键是否出现在结构中。
定义: contains.hpp:70

还不错吧?但是,由于小示例非常适合展示,而无需真正做一些有用的事情,所以让我们检查一个现实世界中的示例。

一个现实世界中的示例

在本节中,我们的目标是实现一种能够处理 boost::anyswitch 语句。给定一个 boost::any,目标是调度到与 any 的动态类型相关的函数

boost::any a = 'x';
std::string r = switch_(a)(
case_<int>([](auto i) { return "int: "s + std::to_string(i); }),
case_<char>([](auto c) { return "char: "s + std::string{c}; }),
default_([] { return "unknown"s; })
);
assert(r == "char: x"s);
constexpr auto any
返回结构体中是否任何键值为真。
定义: any.hpp:30
注意
在文档中,我们通常会在字符串字面量上使用 s 后缀来创建 std::string,而不会产生语法开销。这是一个标准定义的 C++14 用户定义字面量

由于 any 持有一个 char,因此使用其中的 char 调用了第二个函数。如果 any 持有一个 int 而不是 char,则使用其中的 int 调用第一个函数。当 any 的动态类型与任何已涵盖的类型都不匹配时,将调用 default_ 函数。最后,switch 的结果是调用与 any 动态类型关联的函数的结果。该结果的类型被推断为所有提供函数的结果的通用类型。

boost::any a = 'x';
auto r = switch_(a)(
case_<int>([](auto) -> int { return 1; }),
case_<char>([](auto) -> long { return 2l; }),
default_([]() -> long long { return 3ll; })
);
// r 被推断为 long long
static_assert(std::is_same<decltype(r), long long>{}, "");
assert(r == 2ll);

现在我们来看看如何使用 Hana 实现这个工具。第一步是将每个类型与一个函数关联起来。为此,我们将每个 case_ 表示为一个 hana::pair,其第一个元素是类型,第二个元素是函数。此外,我们(任意地)决定将 default_ 情况表示为一个 hana::pair,它将一个虚拟类型映射到一个函数。

template <typename T>
auto case_ = [](auto f) {
return hana::make_pair(hana::type_c<T>, f);
};
struct default_t;
auto default_ = case_<default_t>;

为了提供我们上面展示的接口,switch_ 必须返回一个接受 case 的函数。换句话说,switch_(a) 必须是一个函数,它接受任意数量的 case(即 hana::pair),并执行将 a 分派到正确函数的逻辑。这可以通过让 switch_ 返回一个 C++14 通用 lambda 来轻松实现。

template <typename Any>
auto switch_(Any& a) {
return [&a](auto ...cases_) {
// ...
};
}

然而,由于参数包不是非常灵活,所以我们将 case 放入一个元组中,以便我们可以操作它们。

template <typename Any>
auto switch_(Any& a) {
return [&a](auto ...cases_) {
auto cases = hana::make_tuple(cases_...);
// ...
};
}

注意在定义 cases 时如何使用 auto 关键字;让编译器推断元组的类型并使用 make_tuple 通常比手动计算类型更容易。下一步是从其余 case 中分离默认 case。这就是事情开始变得有趣的地方。为此,我们使用 Hana 的 find_if 算法,它有点类似于 std::find_if

template <typename Any>
auto switch_(Any& a) {
return [&a](auto ...cases_) {
auto cases = hana::make_tuple(cases_...);
auto default_ = hana::find_if(cases, [](auto const& c) {
return hana::first(c) == hana::type_c<default_t>;
});
// ...
};
}
constexpr auto first
返回一对的第一个元素。
定义: first.hpp:33
constexpr auto find_if
查找与满足谓词的第一个键关联的值。
定义: find_if.hpp:41

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 该怎么办?我们当然应该在编译时失败!

template <typename Any>
auto switch_(Any& a) {
return [&a](auto ...cases_) {
auto cases = hana::make_tuple(cases_...);
auto default_ = hana::find_if(cases, [](auto const& c) {
return hana::first(c) == hana::type_c<default_t>;
});
static_assert(default_ != hana::nothing,
"switch is missing a default_ case");
// ...
};
}

注意我们如何在与 nothing 的比较结果上使用 static_assert,即使 default_ 是一个非 constexpr 对象?大胆地说,Hana 确保在运行时不会丢失任何在编译时已知的的信息,这显然是 default_ 情况的存在。下一步是收集非默认 case 的集合。为此,我们使用 filter 算法,它只保留满足谓词的序列中的元素。

template <typename Any>
auto switch_(Any& a) {
return [&a](auto ...cases_) {
auto cases = hana::make_tuple(cases_...);
auto default_ = hana::find_if(cases, [](auto const& c) {
return hana::first(c) == hana::type_c<default_t>;
});
static_assert(default_ != hana::nothing,
"switch is missing a default_ case");
auto rest = hana::filter(cases, [](auto const& c) {
return hana::first(c) != hana::type_c<default_t>;
});
// ...
};
}
constexpr auto filter
使用自定义谓词过滤单子结构。
定义: filter.hpp:65

下一步是找到第一个与 any 动态类型匹配的 case,然后调用与该 case 关联的函数。最简单的方法是使用带变长参数包的经典递归。当然,我们可能可以将 Hana 算法以一种复杂的方式交织在一起,但有时用基本技术从头开始编写是最好的方法。为此,我们将使用 unpack 函数调用一个实现函数,该函数带有 rest 元组的内容。

template <typename Any>
auto switch_(Any& a) {
return [&a](auto ...cases_) {
auto cases = hana::make_tuple(cases_...);
auto default_ = hana::find_if(cases, [](auto const& c) {
return hana::first(c) == hana::type_c<default_t>;
});
static_assert(default_ != hana::nothing,
"switch is missing a default_ case");
auto rest = hana::filter(cases, [](auto const& c) {
return hana::first(c) != hana::type_c<default_t>;
});
return hana::unpack(rest, [&](auto& ...rest) {
return process(a, a.type(), hana::second(*default_), rest...);
});
};
}
constexpr auto unpack
使用 Foldable 的元素作为参数来调用函数。
定义: unpack.hpp:79
constexpr auto second
返回一对的第二个元素。
定义: second.hpp:32

unpack 接收一个 tuple 和一个函数,并使用 tuple 的内容作为参数调用该函数。unpack 的结果是调用该函数的结果。在我们的例子中,该函数是一个通用 lambda,它反过来调用 process 函数。我们在这里使用 unpack 的原因是将 rest 元组转换为参数包,参数包比元组更容易递归处理。在我们继续 process 函数之前,解释 second(*default_) 是怎么回事是值得的。正如我们之前解释的那样,default_ 是一个可选值。像 std::optional 一样,这个可选值重载解引用运算符(以及箭头运算符),以允许访问 optional 内部的值。如果可选值为为空(nothing),则会触发编译时错误。由于我们知道 default_ 不是空的(我们就在上面检查过),所以我们所做的是简单地将与默认情况关联的函数传递给 process 函数。现在我们准备进行最后一步,即实现 process 函数。

template <typename Any, typename Default>
auto process(Any&, std::type_index const&, Default& default_) {
return default_();
}
template <typename Any, typename Default, typename Case, typename ...Rest>
auto process(Any& a, std::type_index const& t, Default& default_,
Case& case_, Rest& ...rest)
{
using T = typename decltype(+hana::first(case_))::type;
return t == typeid(T) ? hana::second(case_)(*boost::unsafe_any_cast<T>(&a))
: process(a, t, default_, rest...);
}

这个函数有两个重载:一个重载用于至少要处理一个 case 的情况,另一个基础情况重载用于只有默认 case 的情况。正如我们所期望的那样,基础情况只是调用默认函数并返回该结果。另一个重载稍微有趣一些。首先,我们检索与该 case 关联的类型并将其存储在 T 中。这个 decltype(...)::type 舞蹈可能看起来很复杂,但实际上非常简单。粗略地说,它接受一个表示为对象的类型(一个 type<T>)并将其拉回到类型级别(一个 T)。有关详细信息,请参阅关于 类型级计算 的部分。然后,我们比较 any 的动态类型是否与这个 case 匹配,如果匹配,我们将使用转换为正确类型的 any 调用与这个 case 关联的函数。否则,我们只是使用其余 case 递归调用 process。很简单,不是吗?以下是最终解决方案。

#include <boost/hana.hpp>
#include <boost/any.hpp>
#include <cassert>
#include <string>
#include <typeindex>
#include <typeinfo>
#include <utility>
namespace hana = boost::hana;
//! [cases]
template <typename T>
auto case_ = [](auto f) {
return hana::make_pair(hana::type_c<T>, f);
};
struct default_t;
auto default_ = case_<default_t>;
//! [cases]
//! [process]
template <typename Any, typename Default>
auto process(Any&, std::type_index const&, Default& default_) {
return default_();
}
template <typename Any, typename Default, typename Case, typename ...Rest>
auto process(Any& a, std::type_index const& t, Default& default_,
Case& case_, Rest& ...rest)
{
using T = typename decltype(+hana::first(case_))::type;
return t == typeid(T) ? hana::second(case_)(*boost::unsafe_any_cast<T>(&a))
: process(a, t, default_, rest...);
}
//! [process]
//! [switch_]
template <typename Any>
auto switch_(Any& a) {
return [&a](auto ...cases_) {
auto cases = hana::make_tuple(cases_...);
auto default_ = hana::find_if(cases, [](auto const& c) {
return hana::first(c) == hana::type_c<default_t>;
});
static_assert(default_ != hana::nothing,
"switch is missing a default_ case");
auto rest = hana::filter(cases, [](auto const& c) {
return hana::first(c) != hana::type_c<default_t>;
});
return hana::unpack(rest, [&](auto& ...rest) {
return process(a, a.type(), hana::second(*default_), rest...);
});
};
}
//! [switch_]

快速入门就到这里了!本示例只介绍了几个有用的算法(find_iffilterunpack)和异构容器(tupleoptional),但请放心,还有更多内容。教程的下一部分将以友好的方式逐步介绍与 Hana 相关的通用概念,但如果您想立即开始编码,可以使用以下备忘单作为快速参考。该备忘单包含最常用的算法和容器,以及对每个容器的简要描述。

备忘单

备注

  • 大多数算法都适用于类型和值(参见关于 类型计算 的部分)。
  • 算法始终将其结果作为新的容器返回;永远不会进行就地修改(参见关于 算法 的部分)。
  • 所有算法都是 constexpr 函数对象。
容器描述
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_CHECKBOOST_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

template<class T, T v>
struct integral_constant {
static constexpr T value = v;
typedef T value_type;
typedef integral_constant type;
constexpr operator value_type() const noexcept { return value; }
constexpr value_type operator()() const noexcept { return value; }
};
注意
如果您对此完全陌生,您可能想看看std::integral_constant文档

一个有效的答案是integral_constant表示数字的类型级编码,或者更一般地说,表示任何整型对象的编码。为了说明,我们可以使用模板别名非常轻松地在该表示中定义一个关于数字的后继函数

template <typename N>
using succ = integral_constant<int, N::value + 1>;
using one = integral_constant<int, 1>;
using two = succ<one>;
using three = succ<two>;
// ...
constexpr auto one
环乘法的单位元。
定义: one.hpp:30

这就是人们通常认为integral_constant的方式;作为可以用于模板元编程的类型级实体。另一种看待integral_constant的方式是作为表示整型constexpr值的运行时对象

auto one = integral_constant<int, 1>{};

这里,虽然one没有标记为constexpr,但它持有的抽象值(一个constexpr 1)仍然可以在编译时获得,因为该值编码在one的类型中。实际上,即使one不是constexpr,我们也可以使用decltype来检索它表示的编译时值

auto one = integral_constant<int, 1>{};
constexpr int one_constexpr = decltype(one)::value;

但是为什么我们要将integral_constants 视为对象而不是类型级实体呢?为了了解原因,考虑我们现在如何实现与之前相同的后继函数

template <typename N>
auto succ(N) {
return integral_constant<int, N::value + 1>{};
}
auto one = integral_constant<int, 1>{};
auto two = succ(one);
auto three = succ(two);
// ...

您注意到什么新东西了吗?区别在于,我们不是使用模板别名在类型级实现succ,而是使用模板函数在值级实现它。此外,我们现在可以使用与普通 C++ 语法相同的语法执行编译时算术。这种将编译时实体视为对象而不是类型的观点是 Hana 表达能力的关键。

编译时算术

MPL 定义了算术运算符,这些运算符可用于对integral_constants 进行编译时计算。此类操作的典型示例是plus,它的大致实现如下

template <typename X, typename Y>
struct plus {
using type = integral_constant<
decltype(X::value + Y::value),
>;
};
using three = plus<integral_constant<int, 1>,
integral_constant<int, 2>>::type;
constexpr auto plus
在幺半群上的结合二元运算。
定义: plus.hpp:47

通过将integral_constants 视为对象而不是类型,从元函数到函数的转换非常简单

template <typename V, V v, typename U, U u>
constexpr auto
operator+(integral_constant<V, v>, integral_constant<U, u>)
{ return integral_constant<decltype(v + u), v + u>{}; }
auto three = integral_constant<int, 1>{} + integral_constant<int, 2>{};

必须强调一个重要的事实,即此运算符不会返回一个普通的整数。相反,它返回一个值初始化的对象,其类型包含加法的结果。该对象中包含的唯一有用信息实际上是在其类型中,我们正在创建一个对象,因为它允许我们使用这种不错的值级语法。事实证明,我们可以通过使用C++14 变量模板来简化integral_constant的创建,从而使这种语法更好

template <int i>
constexpr integral_constant<int, i> int_c{};
auto three = int_c<1> + int_c<2>;

现在我们正在谈论一个在初始类型级方法上明显提高的表达能力,不是吗?但还有更多;我们还可以使用C++14 用户定义的文字使此过程更加简单

template <char ...digits>
constexpr auto operator"" _c() {
// 解析数字并返回一个 integral_constant
}
auto three = 1_c + 2_c;

Hana 提供了自己的integral_constants,这些integral_constants 定义了与我们上面展示的相同的算术运算符。Hana 还提供变量模板以轻松创建不同类型的integral_constants:int_clong_cbool_c 等... 这允许您省略否则需要值初始化这些对象的后缀{}大括号。当然,还提供了_c后缀;它是hana::literals命名空间的一部分,您必须将其导入到您的命名空间中才能使用它

using namespace hana::literals;
auto three = 1_c + 2_c;

这样,您就可以进行编译时算术,而无需费力处理笨拙的类型级特性,并且您的同事现在将能够理解正在发生的事情。

示例:欧几里得距离

为了说明它的好处,让我们实现一个在编译时计算二维欧几里得距离的函数。作为提醒,二维平面中两点的欧几里得距离由下式给出

\[ \mathrm{distance}\left((x_1, y_1), (x_2, y_2)\right) := \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} \]

首先,以下是使用类型级方法(使用 MPL)实现它时的样子

template <typename P1, typename P2>
struct distance {
using xs = typename mpl::minus<typename P1::x,
typename P2::x>::type;
using ys = typename mpl::minus<typename P1::y,
typename P2::y>::type;
using type = typename sqrt<
typename mpl::plus<
typename mpl::multiplies<xs, xs>::type,
typename mpl::multiplies<ys, ys>::type
>::type
>::type;
};
static_assert(mpl::equal_to<
distance<point<mpl::int_<3>, mpl::int_<5>>,
point<mpl::int_<7>, mpl::int_<2>>>::type,
mpl::int_<5>
>::value, "");
constexpr auto minus
减去群中的两个元素。
定义: minus.hpp:51

是的……现在,让我们使用上面介绍的值级方法来实现它

template <typename P1, typename P2>
constexpr auto distance(P1 p1, P2 p2) {
auto xs = p1.x - p2.x;
auto ys = p1.y - p2.y;
return sqrt(xs*xs + ys*ys);
}
BOOST_HANA_CONSTANT_CHECK(distance(point(3_c, 5_c), point(7_c, 2_c)) == 5_c);
#define BOOST_HANA_CONSTANT_CHECK(...)
等效于 BOOST_HANA_CONSTANT_ASSERT,但不受 BOOST_HANA_CONFIG_DISABLE_ASSERTI...的影响。
定义: assert.hpp:239

这个版本看起来可能更简洁。但是,这还不是全部。请注意,distance函数看起来与您为动态值编写计算欧几里得距离的函数完全相同?实际上,由于我们对动态和编译时算术使用相同的语法,因此为其中一个编写的通用函数将适用于两者!

auto p1 = point(3, 5); // 现在是动态值
auto p2 = point(7, 2); //
BOOST_HANA_RUNTIME_CHECK(distance(p1, p2) == 5); // same function works!
#define BOOST_HANA_RUNTIME_CHECK(...)
等效于 BOOST_HANA_RUNTIME_ASSERT,但不受 BOOST_HANA_CONFIG_DISABLE_ASSERTIO... 的影响。
定义: assert.hpp:209

无需更改任何代码,我们就可以在运行时值上使用 distance 函数,并且一切正常。这就是 DRY 的优势。

编译时分支

一旦我们拥有编译时算术运算,下一个想到的可能是编译时分支。在元编程中,如果某个条件为真,则编译一段代码,否则编译另一段代码,这往往非常有用。如果你听说过 static_if,这听起来应该很熟悉,事实上这正是我们正在谈论的。否则,如果你不知道为什么我们可能想要在编译时进行分支,请考虑以下代码(改编自 N4461

template <typename T, typename ...Args>
std::enable_if_t<std::is_constructible<T, Args...>::value,
std::unique_ptr<T>> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
template <typename T, typename ...Args>
std::enable_if_t<!std::is_constructible<T, Args...>::value,
std::unique_ptr<T>> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T{std::forward<Args>(args)...});
}

这段代码使用构造函数的正确语法形式创建 std::unique_ptr。为了实现这一点,它使用了 SFINAE,并且需要两个不同的重载。现在,任何理智的人第一次看到这个代码都会问为什么不能简单地写

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
else
return std::unique_ptr<T>(new T{std::forward<Args>(args)...});
}

原因是编译器需要编译 if 语句的两个分支,无论条件如何(即使它在编译时已知)。但是当 T 不可Args... 构造时,第二个分支将无法编译,这将导致严重的编译错误。我们真正需要的是一种方法来告诉编译器不要编译条件为真的第二个分支,以及条件为假的第一个分支。

为了模拟这一点,Hana 提供了一个 if_ 函数,它的工作原理有点像正常的 if 语句,除了它接受一个可以是 IntegralConstant 的条件,并返回两个值中由条件选择的一个值(这两个值可能具有不同的类型)。如果条件为真,则返回第一个值,否则返回第二个值。一个有点自负的例子如下

auto one_two_three = hana::if_(hana::true_c, 123, "hello");
auto hello = hana::if_(hana::false_c, 123, "hello");
constexpr auto if_
根据条件有条件地返回两个值之一。
定义: if.hpp:41
注意
hana::true_chana::false_c 只是表示编译时真值和编译时假值的布尔 IntegralConstant

这里,one_two_three 等于 123hello 等于 "hello"。换句话说,if_ 有点像三元条件运算符 ? :,除了 : 两边的类型可能不同

// 两种情况下都会失败,因为两个分支的类型不兼容
auto one_two_three = hana::true_c ? 123 : "hello";
auto hello = hana::false_c ? 123 : "hello";

好的,这很巧妙,但它如何真正帮助我们编写由编译器延迟实例化的完整分支呢?答案是将我们想要编写的 if 语句的两个分支表示为泛型 lambda,并使用 hana::if_ 返回我们想要执行的分支。以下是我们如何重写 make_unique

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return hana::if_(std::is_constructible<T, Args...>{},
[](auto&& ...x) { return std::unique_ptr<T>(new T(std::forward<Args>(x)...)); },
[](auto&& ...x) { return std::unique_ptr<T>(new T{std::forward<Args>(x)...}); }
)(std::forward<Args>(args)...);
}

这里,传递给 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 语句,从封闭作用域隐式捕获变量通常更自然。因此,我们希望编写的是

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return hana::if_(std::is_constructible<T, Args...>{},
[&] { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); },
[&] { return std::unique_ptr<T>(new T{std::forward<Args>(args)...}); }
);
}

这里,我们使用 [&] 从封闭作用域捕获 args... 变量,这使我们不必引入新的 x... 变量并将它们作为参数传递给分支。但是,这有两个问题。首先,这将无法获得正确的结果,因为 hana::if_ 最终将返回一个 lambda 而不是返回调用该 lambda 的结果。为了解决这个问题,我们可以使用 hana::eval_if 而不是 hana::if_

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return hana::eval_if(std::is_constructible<T, Args...>{},
[&] { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); },
[&] { return std::unique_ptr<T>(new T{std::forward<Args>(args)...}); }
);
}
constexpr auto eval_if
根据条件有条件地执行两个分支之一。
定义: eval_if.hpp:139

这里,我们使用 [&] 按引用捕获封闭的 args...,并且我们不需要接收任何参数。此外,hana::eval_if 假设其参数是可以调用的分支,它将负责调用由条件选择的分支。但是,这仍然会导致编译失败,因为 lambda 的主体不再依赖,即使最终只使用一个,也会对两个分支进行语义分析。这个问题的解决方案是使 lambda 的主体人为地依赖于某些东西,以阻止编译器在 lambda 实际被使用之前进行语义分析。为了使这成为可能,hana::eval_if 将使用一个标识函数(一个返回其参数不变的函数)调用选择的分支,如果该分支接受这样的参数

template <typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return hana::eval_if(std::is_constructible<T, Args...>{},
[&](auto _) { return std::unique_ptr<T>(new T(std::forward<Args>(_(args))...)); },
[&](auto _) { return std::unique_ptr<T>(new T{std::forward<Args>(_(args))...}); }
);
}
constexpr unspecified _
创建表示 C++ 运算符的简单函数。
定义: placeholder.hpp:70

这里,分支的主体按惯例接受一个名为 _ 的附加参数。这个参数将由 hana::eval_if 提供给所选分支。然后,我们使用 _ 作为我们要在每个分支的主体中使其依赖的变量上的函数。发生的事情是 _ 始终是一个返回其参数不变的函数。但是,编译器不可能在 lambda 实际被调用之前知道它,因此它无法知道 _(args) 的类型。这阻止了编译器进行语义分析,并且不会发生编译错误。此外,由于 _(x) 保证等效于 x,因此我们知道我们实际上并没有通过使用这个技巧来改变分支的语义。

虽然使用这个技巧可能看起来很麻烦,但它在处理分支内的许多变量时非常有用。此外,不需要用 _ 包裹所有变量;只有参与需要延迟类型检查的表达式的变量才需要包裹,其他变量则不需要包裹。关于 Hana 中的编译时分支,还有一些需要了解的地方,但你可以通过查看 hana::eval_ifhana::if_hana::lazy 的参考来深入了解。

为什么止步于此?

为什么我们要将自己局限于算术运算和分支?当你开始将 IntegralConstant 视为对象时,用更多通常有用的函数来增强它们的接口变得合理。例如,Hana 的 IntegralConstant 定义了一个 times 成员函数,可用于调用某个函数一定次数,这对于循环展开特别有用

__attribute__((noinline)) void f() { }
int main() {
hana::int_c<10>.times(f);
}

在上面的代码中,对 f 的 10 次调用在编译时展开。换句话说,这等效于编写

f(); f(); ... f(); // 10 次
注意
始终 小心 手动展开循环或手动进行其他此类优化。在大多数情况下,你的编译器可能比你更擅长优化。如有疑问,请进行基准测试。

IntegralConstant 的另一个不错的用法是为索引异构序列定义美观的运算符。而 std::tuple 必须使用 std::get 访问,hana::tuple 可以使用标准库容器常用的 operator[] 访问

auto values = hana::make_tuple(1, 'x', 3.4f);
char x = values[1_c];
constexpr auto values
返回映射值的序列,顺序未定义。
定义: map.hpp:199

它的工作原理很简单。基本上,hana::tuple 定义了一个 operator[],它接受一个 IntegralConstant 而不是一个普通整数,类似于

template <typename N>
constexpr decltype(auto) operator[](N const&) {
return std::get<N::value>(*this);
}

这是关于 IntegralConstant 的部分的结尾。本节介绍了 Hana 新的元编程方法背后的感觉;如果你喜欢到目前为止看到的内容,本教程的其余部分应该让你感觉宾至如归。

类型计算


在这一点上,如果你有兴趣像使用 MPL 一样进行类型级计算,你可能想知道 Hana 如何帮助你。不要绝望。Hana 提供了一种方法,通过将类型表示为值来执行类型级计算,并且具有很高的表达能力,就像我们将编译时数字表示为值一样。这是一种完全不同的元编程方法,如果你想精通 Hana,应该尝试暂时放下你以前使用 MPL 的习惯。

但是,请注意,现代 C++ 特性,如 自动推断的返回类型 在许多情况下消除了对类型计算的需求。因此,在考虑进行类型计算之前,你应该问问自己,是否有更简单的方法来实现你想要实现的目标。在大多数情况下,答案是肯定的。但是,当答案是否定的时,Hana 将为你提供强大的工具来完成需要完成的任务。

类型作为对象

Hana 对类型级计算方法的关键在于与编译时算术方法相同。基本上,这个想法是通过将编译时实体包装到某种容器中来将它们表示为对象。对于 IntegralConstant,编译时实体是整数类型的常量表达式,我们使用的包装器是 integral_constant。在本节中,编译时实体将是类型,我们将使用的包装器称为 type。就像我们对 IntegralConstant 所做的那样,让我们从定义一个可以用来表示类型的虚拟模板开始

template <typename T>
struct basic_type {
// 空(现在)
};
basic_type<int> Int{};
basic_type<char> Char{};
// ...
注意
我们在这里使用名称 basic_type,因为我们只是构建了 Hana 提供的实际功能的简化版本。

虽然这看起来完全没有用,但它实际上足以开始编写看起来像函数的元函数。实际上,请考虑以下 std::add_pointerstd::is_pointer 的替代实现

template <typename T>
constexpr basic_type<T*> add_pointer(basic_type<T> const&)
{ return {}; }
template <typename T>
constexpr auto is_pointer(basic_type<T> const&)
{ return hana::bool_c<false>; }
template <typename T>
constexpr auto is_pointer(basic_type<T*> const&)
{ return hana::bool_c<true>; }

我们刚刚编写了看起来像函数的元函数,就像我们在上一节中将编译时算术元函数编写为异构 C++ 运算符一样。以下是如何使用它们

basic_type<int> t{};
auto p = add_pointer(t);

请注意,我们现在可以使用正常的函数调用语法执行类型级计算?这类似于将值用于编译时数字允许我们使用正常的 C++ 运算符执行编译时计算的方式。就像我们对 integral_constant 所做的那样,我们还可以更进一步,使用 C++14 变量模板来提供创建类型的语法糖

template <typename T>
constexpr basic_type<T> type_c{};
auto t = type_c<int>;
auto p = add_pointer(t);
constexpr type< T > type_c
创建一个表示 C++ 类型 T 的对象。
定义: type.hpp:128
注意
这并不完全是 hana::type_c 变量模板的实现方式,因为有一些细微之处;为了便于解释,这里对事情进行了简化。请查看 hana::type 的参考,以确切了解你对 hana::type_c<...> 的期望。

此表示的优点

但这对我们有什么好处?好吧,由于 type_c<...> 只是一个对象,我们可以将其存储在异构序列(如元组)中,我们可以将其移动并将其传递给(或从其返回)函数,并且我们基本上可以执行任何需要对象的操作

auto types = hana::make_tuple(hana::type_c<int*>, hana::type_c<char&>, hana::type_c<void>);
auto char_ref = types[1_c];
BOOST_HANA_CONSTANT_CHECK(char_ref == hana::type_c<char&>);
注意
当有多个类型时,编写 make_tuple(type_c<T>...) 会很麻烦。出于这个原因,Hana 提供了 tuple_t<T...> 变量模板,它是 make_tuple(type_c<T>...) 的语法糖。

此外,请注意,由于上面的元组实际上只是一个普通的异构序列,我们可以对该序列应用异构算法,就像我们可以对 int 元组那样。此外,由于我们只是操作对象,我们现在可以使用完整的语言,而不仅仅是类型级可用的语言子集。例如,请考虑从类型序列中删除所有不是引用或指针的类型的任务。使用 MPL,我们必须使用占位符表达式来表达谓词,这很笨拙

using types = mpl::vector<int, char&, void*>;
using ts = mpl::copy_if<types, mpl::or_<std::is_pointer<mpl::_1>,
std::is_reference<mpl::_1>>>::type;
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 占位符表达式
static_assert(mpl::equal<ts, mpl::vector<char&, void*>>::value, "");

现在,由于我们正在操作对象,我们可以使用完整的语言并使用通用 lambda,从而导致更易于阅读的代码

auto types = hana::tuple_t<int*, char&, void>;
auto ts = hana::filter(types, [](auto t) {
return is_pointer(t) || is_reference(t);
});
BOOST_HANA_CONSTANT_CHECK(ts == hana::tuple_t<int*, char&>);

由于 Hana 统一地处理所有异构容器,因此这种将类型表示为值的 方法也具有以下优点:现在只需要一个库来进行异构计算和类型级计算。实际上,虽然我们通常需要两个不同的库来执行几乎相同的任务,但现在我们只需要一个库。再次考虑使用谓词过滤序列的任务。使用 MPL 和 Fusion,我们必须这样做

// 类型(MPL)
using types = mpl::vector<int*, char&, void>;
using ts = mpl::copy_if<types, mpl::or_<std::is_pointer<mpl::_1>,
std::is_reference<mpl::_1>>>::type;
// 值(Fusion)
auto values = fusion::make_vector(1, 'c', nullptr, 3.5);
auto vs = fusion::filter_if<std::is_integral<mpl::_1>>(values);

使用 Hana,只需要一个库。请注意我们如何使用相同的 filter 算法和相同的容器,并且只调整谓词,以便它可以在值上操作

// 类型
auto types = hana::tuple_t<int*, char&, void>;
auto ts = hana::filter(types, [](auto t) {
return is_pointer(t) || is_reference(t);
});
// 值
auto values = hana::make_tuple(1, 'c', nullptr, 3.5);
auto vs = hana::filter(values, [](auto const& t) {
return is_integral(hana::typeid_(t));
});

但这还不是全部。实际上,对类型级和值级计算使用统一的语法,可以使异构容器接口更加一致。例如,请考虑创建将类型与值关联的异构映射,然后访问其元素的简单任务。使用 Fusion,发生的事情对于没有经验的人来说并不容易理解

auto map = fusion::make_map<char, int, long, float, double, void>(
"char", "int", "long", "float", "double", "void"
);
std::string Int = fusion::at_key<int>(map);

但是,使用类型和值的统一语法,相同的事情变得更加清晰

auto map = hana::make_map(
hana::make_pair(hana::type_c<char>, "char"),
hana::make_pair(hana::type_c<int>, "int"),
hana::make_pair(hana::type_c<long>, "long"),
hana::make_pair(hana::type_c<float>, "float"),
hana::make_pair(hana::type_c<double>, "double")
);
std::string Int = map[hana::type_c<int>];

虽然 Hana 的方法需要更多代码行,但它也可能更易于阅读,并且更接近人们期望初始化映射的方式。

使用此表示

到目前为止,我们可以将类型表示为值,并使用通常的 C++ 语法对这些对象执行类型级计算。这很好,但它并不十分有用,因为我们无法从对象表示中获取正常的 C++ 类型。例如,我们如何声明一个类型为类型计算结果的变量?

auto t = add_pointer(hana::type_c<int>); // 可能是一个复杂的类型计算
using T = the-type-represented-by-t;
T var = ...;

现在,没有简单的方法可以做到。为了更容易实现这一点,我们丰富了上面定义的 basic_type 容器的接口。它不再是一个空 struct,现在我们将其定义为

template <typename T>
struct basic_type {
using type = T;
};
注意
这等效于在 MPL 意义上将 basic_type 变成一个元函数。

这样,我们可以使用decltype轻松访问由type_c<...>对象表示的实际 C++ 类型。

auto t = add_pointer(hana::type_c<int>);
using T = decltype(t)::type; // 获取 basic_type<T>::type
T var = ...;

一般来说,使用 Hana 进行类型级元编程是一个三步过程。

  1. 使用hana::type_c<...>将类型包装为对象。
  2. 使用值语法执行类型转换。
  3. 使用decltype(...)::type解开结果。

现在,您一定在想,这太繁琐了。实际上,它由于以下几个原因而非常易于管理。首先,这种包装和解包只需要在一些非常薄的边界处发生。

auto t = hana::type_c<T>;
auto result = huge_type_computation(t);
using Result = decltype(result)::type;

此外,由于您可以在计算内部利用对象(无需包装/解包)的优势,包装和解包的成本会在整个计算中摊销。因此,对于复杂的类型计算,这种三步过程的语法噪声在计算内部使用值的表达能力提升的背景下很快就会变得可以忽略不计。此外,使用值而不是类型意味着我们可以避免在整个地方键入typenametemplate,这在经典元编程中占用了大量的语法噪声。

另一个要点是,并不总是需要这三个完整步骤。实际上,有时只需要进行类型级计算并查询有关结果的内容,而无需将结果作为正常的 C++ 类型获取。

auto t = hana::type_c<T>;
auto result = type_computation(t);
BOOST_HANA_CONSTANT_CHECK(is_pointer(result)); // 跳过第三步

在这种情况下,我们能够跳过第三步,因为我们不需要访问由result表示的实际类型。在其他情况下,可以避免第一步,例如使用tuple_t时,它没有比任何其他纯类型级方法更多的语法噪声。

auto types = hana::tuple_t<int*, char&, void>; // 跳过第一步
auto pointers = hana::transform(types, [](auto t) {
return add_pointer(t);
});

对于持怀疑态度的读者,让我们考虑一下查找类型序列中最小的类型的任务。这是一个非常好的示例,展示了只有类型的小型计算,我们预计新范式将在此处受到最大程度的困扰。您将看到,即使对于小型计算,一切仍然易于管理。首先,让我们使用 MPL 实现它。

template <typename ...T>
struct smallest
: mpl::deref<
typename mpl::min_element<
mpl::vector<T...>,
mpl::less<mpl::sizeof_<mpl::_1>, mpl::sizeof_<mpl::_2>>
>::type
>
{ };
template <typename ...T>
using smallest_t = typename smallest<T...>::type;
static_assert(std::is_same<
smallest_t<char, long, long double>,
char
>::value, "");

结果非常易读(对于熟悉 MPL 的任何人来说)。现在让我们使用 Hana 实现相同的功能。

template <typename ...T>
auto smallest = hana::minimum(hana::make_tuple(hana::type_c<T>...), [](auto t, auto u) {
return hana::sizeof_(t) < hana::sizeof_(u);
});
template <typename ...T>
using smallest_t = typename decltype(smallest<T...>)::type;
static_assert(std::is_same<
smallest_t<char, long, long double>, char
>::value, "");

正如您所见,三步过程的语法噪声几乎完全被其他计算隐藏起来了。

通用提升过程

我们以函数形式介绍的第一个类型级计算看起来像这样

template <typename T>
constexpr auto add_pointer(hana::basic_type<T> const&) {
return hana::type_c<T*>;
}

虽然看起来更复杂,但我们也可以将其写成

template <typename T>
constexpr auto add_pointer(hana::basic_type<T> const&) {
return hana::type_c<typename std::add_pointer<T>::type>;
}

但是,这种实现强调了我们实际上是在模拟现有的元函数,并将其简单地表示为函数。换句话说,我们正在将元函数(std::add_pointer)提升到值的世界,方法是创建我们自己的add_pointer函数。事实证明,这种提升过程是一个通用的过程。实际上,对于任何元函数,我们都可以写出几乎相同的内容。

template <typename T>
constexpr auto add_const(hana::basic_type<T> const&)
{ return hana::type_c<typename std::add_const<T>::type>; }
template <typename T>
constexpr auto add_volatile(hana::basic_type<T> const&)
{ return hana::type_c<typename std::add_volatile<T>::type>; }
template <typename T>
constexpr auto add_lvalue_reference(hana::basic_type<T> const&)
{ return hana::type_c<typename std::add_lvalue_reference<T>::type>; }
// 等等...

这种机械转换很容易抽象成一个通用的提升器,它可以处理任何MPL 元函数,如下所示。

template <template <typename> class F, typename T>
constexpr auto metafunction(hana::basic_type<T> const&)
{ return hana::type_c<typename F<T>::type>; }
auto t = hana::type_c<int>;
BOOST_HANA_CONSTANT_CHECK(metafunction<std::add_pointer>(t) == hana::type_c<int*>);
constexpr auto metafunction
将 MPL 风格的元函数提升到元函数。
定义: type.hpp:437

更一般地说,我们希望允许具有任意数量参数的元函数,这将我们带到以下不太幼稚的实现。

template <template <typename ...> class F, typename ...T>
constexpr auto metafunction(hana::basic_type<T> const& ...)
{ return hana::type_c<typename F<T...>::type>; }
metafunction<std::common_type>(hana::type_c<int>, hana::type_c<long>) == hana::type_c<long>
);

Hana 提供了一个类似的通用元函数提升器,称为hana::metafunction。一个小改进是hana::metafunction<F>是一个函数对象,而不是一个重载函数,因此可以将其传递给高阶算法。它也是Metafunction这个稍强概念的模型,但现在可以安全地忽略。我们在本节中探讨的过程不仅适用于元函数;它也适用于模板。实际上,我们可以定义

template <template <typename ...> class F, typename ...T>
constexpr auto template_(hana::basic_type<T> const& ...)
{ return hana::type_c<F<T...>>; }
template_<std::vector>(hana::type_c<int>) == hana::type_c<std::vector<int>>
);
constexpr auto template_
将模板提升到元函数。
定义: type.hpp:406

Hana 为模板提供了通用的提升器,名为hana::template_,并且它还为MPL 元函数类提供了通用的提升器,名为hana::metafunction_class。这为我们提供了一种统一的方式来将“遗留”类型级计算表示为函数,因此使用经典类型级元编程库编写的任何代码几乎都可以毫无困难地与 Hana 一起使用。例如,假设您有一大块基于 MPL 的代码,并且您希望与 Hana 交互。执行此操作的过程并不比用 Hana 提供的提升器包装您的元函数更难。

template <typename T>
struct legacy {
using type = ...; // 您真正不想处理的内容
};
auto types = hana::make_tuple(...);
auto use = hana::transform(types, hana::metafunction<legacy>);

但是,请注意,并非所有类型级计算都可以使用 Hana 提供的工具原封不动地提升。例如,std::extent 无法提升,因为它需要非类型模板参数。由于在 C++ 中没有办法统一处理非类型模板参数,因此必须诉诸使用特定于该类型级计算的手写函数对象。

auto extent = [](auto t, auto n) {
return std::extent<typename decltype(t)::type, hana::value(n)>{};
};
BOOST_HANA_CONSTANT_CHECK(extent(hana::type_c<char>, hana::int_c<1>) == hana::size_c<0>);
BOOST_HANA_CONSTANT_CHECK(extent(hana::type_c<char[1][2]>, hana::int_c<1>) == hana::size_c<2>);
注意
使用<type_traits>中的类型特征时,请不要忘记包含std::integral_constant的桥接头文件(<boost/hana/ext/std/integral_constant.hpp>)。

然而,在实践中,这应该不是问题,因为绝大多数类型级计算都可以轻松提升。最后,由于<type_traits>头文件提供的元函数使用非常频繁,Hana 为其中的每一个都提供了一个提升版本。这些提升的特征位于hana::traits命名空间中,它们位于<boost/hana/traits.hpp>头文件中。

BOOST_HANA_CONSTANT_CHECK(hana::traits::add_pointer(hana::type_c<int>) == hana::type_c<int*>);
BOOST_HANA_CONSTANT_CHECK(hana::traits::common_type(hana::type_c<int>, hana::type_c<long>) == hana::type_c<long>);
BOOST_HANA_CONSTANT_CHECK(hana::traits::is_integral(hana::type_c<int>));
auto types = hana::tuple_t<int, char, long>;
BOOST_HANA_CONSTANT_CHECK(hana::all_of(types, hana::traits::is_integral));
constexpr auto all_of
返回结构的所有键是否满足谓词。
定义: all_of.hpp:38

这是关于类型计算部分的结尾。虽然这种类型级编程的新范式可能一开始很难理解,但随着您越来越多的使用,它将变得更有意义。您还将开始欣赏它如何模糊类型和值之间的界限,打开新的激动人心的可能性并简化许多任务。

注意
好奇或持怀疑态度的读者应该考虑查看附录中介绍的 MPL 的最小重新实现。

自省


静态自省,正如我们将在本文中讨论的,是指程序在编译时检查对象类型的能力。换句话说,它是一个与类型在编译时交互的编程接口。例如,您是否曾经想要检查某个未知类型是否具有名为foo的成员?或者,您是否曾经需要迭代struct的成员?

struct Person {
std::string name;
int age;
};
Person john{"John", 30};
for (auto& member : john)
std::cout << member.name << ": " << member.value << std::endl;
// name: John
// age: 30

如果您在编程生涯中写过一些模板,您很有可能遇到过检查成员是否存在的第一问题。同样,任何尝试实现对象序列化甚至只是漂亮打印的人都会遇到第二个问题。在大多数动态语言(如 Python、Ruby 或 JavaScript)中,这些问题得到了完全解决,并且程序员每天都使用内省来简化许多任务。但是,作为一名 C++ 程序员,我们没有语言支持这些功能,这使得一些任务比应有的难度要大得多。虽然语言支持可能需要解决此问题,但 Hana 使一些常见的内省模式更容易访问。

检查表达式有效性

给定一个未知类型的对象,有时希望检查此对象是否具有某个名称的成员(或成员函数)。这可用于执行更复杂的重载类型。例如,考虑在支持它的对象上调用 toString 方法的问题,但为不支持它的对象提供另一个默认实现

template <typename T>
std::string optionalToString(T const& obj) {
if (obj.toString() 是一个有效的表达式)
return obj.toString();
else
return "toString 未定义";
}
注意
虽然这种技术的绝大多数用例将在标准的未来版本中由 概念精简 解决,但仍然会有一些情况,其中快速而脏的检查比创建完整的概念更方便。

我们如何以通用方式实现对 obj.toString() 有效性的检查(以便它可以重用在其他函数中,例如)?通常,我们会陷入编写某种基于 SFINAE 的检测

template <typename T, typename = void>
struct has_toString
: std::false_type
{ };
template <typename T>
struct has_toString<T, decltype((void)std::declval<T>().toString())>
: std::true_type
{ };

这可以工作,但意图并不十分清楚,大多数没有深入了解模板元编程的人会认为这是黑魔法。然后,我们可以实现 optionalToString 如下

template <typename T>
std::string optionalToString(T const& obj) {
return obj.toString();
else
return "toString 未定义";
}
注意
当然,此实现实际上不会起作用,因为 if 语句的两个分支都将被编译。如果 obj 没有 toString 方法,则 if 分支的编译将失败。我们将在稍后解决此问题。

除了上述 SFINAE 技巧,Hana 提供了一个 is_valid 函数,它可以与 C++14 通用 lambda 相结合,以获得相同事物的更清晰的实现

auto has_toString = hana::is_valid([](auto&& obj) -> decltype(obj.toString()) { });

这让我们得到一个函数对象 has_toString,它返回给定表达式在传递给它的参数上是否有效。结果以 IntegralConstant 返回,因此 constexpr 性在这里不是问题,因为函数的结果无论如何都表示为一个类型。现在,除了更简洁(这是一行代码!),意图也更加清晰。其他好处是,has_toString 可以传递给高阶算法,并且也可以在函数作用域中定义,因此无需使用实现细节污染命名空间作用域。以下是我们现在编写 optionalToString 的方式

template <typename T>
std::string optionalToString(T const& obj) {
if (has_toString(obj))
return obj.toString();
else
return "toString 未定义";
}

更干净,对吧?但是,正如我们之前所说,此实现实际上不会起作用,因为无论 obj 是否具有 toString 方法,if 语句的两个分支都必须始终进行编译。有多种可能的选择,但最经典的选择是使用 std::enable_if

template <typename T>
auto optionalToString(T const& obj)
-> std::enable_if_t<decltype(has_toString(obj))::value, std::string>
{ return obj.toString(); }
template <typename T>
auto optionalToString(T const& obj)
-> std::enable_if_t<decltype(!has_toString(obj))::value, std::string>
{ return "toString 未定义"; }
注意
我们使用 has_toString 返回 IntegralConstant 的事实来编写 decltype(...)::value,它是一个常量表达式。由于某种原因,has_toString(obj) 不被认为是常量表达式,即使我认为它应该是一个常量表达式,因为我们从未从 obj 读取(参见关于 高级 constexpr 的部分)。

虽然此实现完全有效,但它仍然非常麻烦,因为它需要编写两个不同的函数并通过使用 std::enable_if 显式地执行 SFINAE 循环。但是,您可能还记得关于 编译时分支 的部分,Hana 提供了一个 if_ 函数,它可用于模拟 static_if 的功能。以下是使用 hana::if_ 编写 optionalToString 的方式

template <typename T>
std::string optionalToString(T const& obj) {
return hana::if_(has_toString(obj),
[](auto& x) { return x.toString(); },
[](auto& x) { return "toString 未定义"; }
)(obj);
}

现在,前面的示例仅涵盖了检查非静态成员函数是否存在的情况。但是,is_valid 可用于检测几乎任何类型表达式的有效性。为了完整起见,我们现在列出常见的有效性检查用例以及如何使用 is_valid 来实现它们。

非静态成员

我们将要查看的第一个习惯用法是检查非静态成员是否存在。我们可以像之前示例中那样做

auto has_member = hana::is_valid([](auto&& x) -> decltype((void)x.member) { });
struct Foo { int member[4]; };
struct Bar { };
BOOST_HANA_CONSTANT_CHECK(has_member(Foo{}));
BOOST_HANA_CONSTANT_CHECK(!has_member(Bar{}));

注意我们如何将 x.member 的结果强制转换为 void?这样做是为了确保我们的检测也适用于不能从函数返回的类型,如数组类型。此外,将引用用作通用 lambda 的参数非常重要,因为否则需要 x可复制构造的,这不是我们试图检查的内容。当可用对象时,这种方法简单且最方便。但是,当检查器旨在在没有对象的情况下使用时,以下替代实现可能更合适

auto has_member = hana::is_valid([](auto t) -> decltype(
(void)hana::traits::declval(t).member
) { });
struct Foo { int member[4]; };
struct Bar { };
BOOST_HANA_CONSTANT_CHECK(has_member(hana::type_c<Foo>));
BOOST_HANA_CONSTANT_CHECK(!has_member(hana::type_c<Bar>));

此有效性检查器不同于我们之前看到的检查器,因为通用 lambda 不再期望一个普通的对象;它现在期望一个 type(它是一个对象,但仍然表示一个类型)。然后,我们使用 <boost/hana/traits.hpp> 标头中的 hana::traits::declval 提升元函数 来创建由 t 表示的类型的右值,然后我们可以使用它来检查非静态成员是否存在。最后,我们不是向 has_member 传递实际对象(如 Foo{}Bar{}),而是传递 type_c<...>。此实现非常适合没有对象存在的情况。

静态成员

检查静态成员是否存在很简单,这里提供它是为了完整性

auto has_member = hana::is_valid([](auto t) -> decltype(
(void)decltype(t)::type::member
) { });
struct Foo { static int member[4]; };
struct Bar { };
BOOST_HANA_CONSTANT_CHECK(has_member(hana::type_c<Foo>));
BOOST_HANA_CONSTANT_CHECK(!has_member(hana::type_c<Bar>));

同样,我们期望向检查器传递 type。在通用 lambda 内部,我们使用 decltype(t)::type 来获取由 t 对象表示的实际 C++ 类型,如关于 类型计算 的部分所述。然后,我们在该类型中获取静态成员并将其强制转换为 void,与非静态成员的原因相同。

嵌套类型名

检查嵌套类型名是否存在并不困难,但比前面的情况稍微复杂一些

auto has_member = hana::is_valid([](auto t) -> hana::type<
typename decltype(t)::type::member
//^^^^^^^^ 需要此项,因为存在依赖上下文
> { });
struct Foo { struct member; /* 未定义! */ };
struct Bar { };
BOOST_HANA_CONSTANT_CHECK(has_member(hana::type_c<Foo>));
BOOST_HANA_CONSTANT_CHECK(!has_member(hana::type_c<Bar>));

有人可能想知道为什么我们使用 -> hana::type<typename-expression> 而不是简单地使用 -> typename-expression。再次,原因是我们想支持不能从函数返回的类型,如数组类型或不完全类型。

嵌套模板

检查嵌套模板名是否存在类似于检查嵌套类型名是否存在,不同之处在于我们在通用 lambda 中使用 template_<...> 变量模板而不是 type<...>

auto has_member = hana::is_valid([](auto t) -> decltype(hana::template_<
decltype(t)::type::template member
// ^^^^^^^^ 需要此项,因为存在依赖上下文
>) { });
struct Foo { template <typename ...> struct member; };
struct Bar { };
BOOST_HANA_CONSTANT_CHECK(has_member(hana::type_c<Foo>));
BOOST_HANA_CONSTANT_CHECK(!has_member(hana::type_c<Bar>));

模板特化

也可以检查模板特化是否有效,但我们现在向 is_valid 传递 template_<...> 而不是 type<...>,因为这是我们想要检查的内容

template <typename T, typename U>
struct Foo;
template <typename T>
struct Bar;
auto is_binary_template = hana::is_valid([](auto trait) -> decltype(
trait(hana::type_c<void>, hana::type_c<void>)
) { });
BOOST_HANA_CONSTANT_CHECK(is_binary_template(hana::template_<Foo>));
BOOST_HANA_CONSTANT_CHECK(!is_binary_template(hana::template_<Bar>));
constexpr auto trait
integral(metafunction<F>) 的别名,提供以方便使用。
定义: type.hpp:539
注意
这样做不会导致模板实例化。因此,它只会检查给定模板是否可以与提供的模板参数一起使用,而不是检查使用这些参数实例化的模板是否有效。一般来说,没有办法以编程方式进行检查。

控制 SFINAE

在 C++ 中,仅当表达式格式正确时才执行某些操作是一种非常常见的模式。实际上,optionalToString 函数只是以下模式的一个实例,该模式非常通用。

template <typename T>
auto f(T x) {
if (涉及 x 的某个表达式格式正确)
return 涉及 x 的某些内容;
else
return 其他内容;
}

为了封装这种模式,Hana 提供了 sfinae 函数,该函数允许执行表达式,但仅当表达式格式正确时。

auto maybe_add = hana::sfinae([](auto x, auto y) -> decltype(x + y) {
return x + y;
});
maybe_add(1, 2); // hana::just(3)
std::vector<int> v;
maybe_add(v, "foobar"); // hana::nothing

在这里,我们创建了一个 maybe_add 函数,它只是一个用 Hana 的 sfinae 函数包装的通用 lambda 表达式。maybe_add 是一个函数,它接受两个输入并返回 just 通用 lambda 表达式的结果(如果该调用格式正确),否则返回 nothingjust(...)nothing 都属于一种称为 hana::optional 的容器类型,它本质上是一个编译时 std::optional。总的来说,maybe_add 在道德上等同于以下返回 std::optional 的函数,只是检查是在编译时完成的。

auto maybe_add = [](auto x, auto y) {
if (x + y 格式正确)
return std::optional<decltype(x + y)>{x + y};
else
return std::optional<???>{};
};

事实证明,我们可以利用 sfinaeoptional 来实现 optionalToString 函数,如下所示。

template <typename T>
std::string optionalToString(T const& obj) {
auto maybe_toString = hana::sfinae([](auto&& x) -> decltype(x.toString()) {
return x.toString();
});
return maybe_toString(obj).value_or("toString not defined");
}

首先,我们将 toStringsfinae 函数包装起来。因此,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 宏定义用户定义类型的成员。

struct Person {
BOOST_HANA_DEFINE_STRUCT(Person,
(std::string, name),
(int, age)
);
};

此宏定义了两个成员(nameage),它们具有给定的类型。然后,它在 Person::hana 嵌套的 struct 中定义一些样板代码,这对于使 Person 成为 Struct 概念的模型是必需的。没有定义构造函数(因此保留了 POD 性),成员按它们在这里出现的顺序定义,该宏也可以与模板 struct 一起使用,并且可以在任何范围内使用。还要注意,你可以在使用宏之前或之后自由地向 Person 类型添加更多成员。但是,仅使用宏定义的成员将在反思 Person 类型时被选中。够简单吧?现在,可以以编程方式访问 Person

Person john{"John", 30};
hana::for_each(john, [](auto pair) {
std::cout << hana::to<char const*>(hana::first(pair)) << ": "
<< hana::second(pair) << std::endl;
});
// name: John
// age: 30

Struct 的迭代就像对 Struct 是一个对序列一样进行,其中对的第一个元素是与成员关联的键,第二个元素是成员本身。当 Struct 通过 BOOST_HANA_DEFINE_STRUCT 宏定义时,与任何成员关联的键都是一个编译时 hana::string,表示该成员的名称。这就是为什么与 for_each 一起使用的函数接受一个参数 pair,然后使用 firstsecond 来访问对的子部分。此外,请注意如何在成员名称上使用 to<char const*> 函数?这将编译时字符串转换为 constexpr char const*,以便可以 cout。由于总是使用 firstsecond 来获取对的子部分可能很烦人,因此我们还可以使用 fuse 函数包装我们的 lambda 表达式,使其成为一个二元 lambda 表达式。

hana::for_each(john, hana::fuse([](auto name, auto member) {
std::cout << hana::to<char const*>(name) << ": " << member << std::endl;
}));
constexpr auto fuse
将一个接受多个参数的函数转换为一个可以使用编译时...调用的函数。
定义: fuse.hpp:40

现在,它看起来更加干净。正如我们刚才提到的,Struct 被视为一种对序列,用于迭代。实际上,Struct 甚至可以像一个关联数据结构一样进行搜索,该数据结构的键是成员的名称,而值是成员本身。

std::string name = hana::at_key(john, "name"_s);
BOOST_HANA_RUNTIME_CHECK(name == "John");
int age = hana::at_key(john, "age"_s);
constexpr auto at_key
返回与结构中给定键关联的值,或失败。
定义: at_key.hpp:51
注意
_s 用户定义的文字创建一个编译时 hana::string。它位于 boost::hana::literals 命名空间中。请注意,它还不是标准的一部分,但 clang 和 GCC 支持它。如果你想要保持 100% 标准,可以使用 BOOST_HANA_STRING 宏代替。

Structhana::map 之间的主要区别在于,map 可以修改(可以添加和删除键),而 Struct 是不可变的。但是,你可以轻松地使用 to<map_tag>Struct 转换为 hana::map,然后就可以以更灵活的方式对其进行操作。

auto map = hana::insert(hana::to<hana::map_tag>(john), hana::make_pair("last name"_s, "Doe"s));
std::string name = map["name"_s];
BOOST_HANA_RUNTIME_CHECK(name == "John");
std::string last_name = map["last name"_s];
BOOST_HANA_RUNTIME_CHECK(last_name == "Doe");
int age = map["age"_s];
constexpr insert_t insert
在序列中给定索引处插入一个值。
定义: insert.hpp:29

使用 BOOST_HANA_DEFINE_STRUCT 宏来调整 struct 很方便,但有时无法修改需要调整的类型。在这种情况下,可以使用 BOOST_HANA_ADAPT_STRUCT 宏以一种临时方式调整 struct

namespace not_my_namespace {
struct Person {
std::string name;
int age;
};
}
BOOST_HANA_ADAPT_STRUCT(not_my_namespace::Person, name, age);
注意
BOOST_HANA_ADAPT_STRUCT 宏必须在全局范围内使用。

效果与 BOOST_HANA_DEFINE_STRUCT 宏完全相同,只是你不需要修改要调整的类型,这有时很有用。最后,还可以通过使用 BOOST_HANA_ADAPT_ADT 宏定义自定义访问器。

namespace also_not_my_namespace {
struct Person {
std::string get_name();
int get_age();
};
}
BOOST_HANA_ADAPT_ADT(also_not_my_namespace::Person,
(name, [](auto const& p) { return p.get_name(); }),
(age, [](auto const& p) { return p.get_age(); })
);

通过这种方式,用于访问 Struct 成员的名称将是指定的名称,并且在检索该成员时,将在 Struct 上调用关联的函数。在我们继续讨论使用这些反思功能的具体示例之前,还应该提到,struct 可以通过不使用宏来进行调整。此高级接口用于定义 Struct,例如可用于指定不是编译时字符串的键。高级接口在 Struct 概念的文档中进行了描述。

示例:生成 JSON

现在,让我们继续讨论一个具体示例,该示例使用我们刚刚介绍的反思功能来将自定义对象打印为 JSON。我们的最终目标是实现以下内容。

struct Car {
BOOST_HANA_DEFINE_STRUCT(Car,
(std::string, brand),
(std::string, model)
);
};
struct Person {
BOOST_HANA_DEFINE_STRUCT(Person,
(std::string, name),
(std::string, last_name),
(int, age)
);
};
Car bmw{"BMW", "Z3"}, audi{"Audi", "A4"};
Person john{"John", "Doe", 30};
auto tuple = hana::make_tuple(john, audi, bmw);
std::cout << to_json(tuple) << std::endl;

并且经过 JSON 美化器处理后的输出应该如下所示。

[
{
"name": "John",
"last_name": "Doe",
"age": 30
},
{
"brand": "Audi",
"model": "A4"
},
{
"brand": "BMW",
"model": "Z3"
}
]

首先,让我们定义几个实用程序函数,以方便字符串操作。

template <typename Xs>
std::string join(Xs&& xs, std::string sep) {
return hana::fold(hana::intersperse(std::forward<Xs>(xs), sep), "", hana::_ + hana::_);
}
std::string quote(std::string s) { return "\"" + s + "\""; }
template <typename T>
auto to_json(T const& x) -> decltype(std::to_string(x)) {
return std::to_string(x);
}
std::string to_json(char c) { return quote({c}); }
std::string to_json(std::string s) { return quote(s); }
constexpr auto fold
等效于 fold_left;提供以方便使用。
定义: fold.hpp:35
constexpr auto intersperse
在有限序列的每个元素对之间插入一个值。
定义: intersperse.hpp:41

quoteto_json 的重载非常直观。然而,join 函数可能需要一些解释。基本上,intersperse 函数接受一个序列和一个分隔符,并返回一个新的序列,该序列在原始序列的每个元素对之间包含分隔符。换句话说,我们将一个形如 [x1, ..., xn] 的序列转换为形如 [x1, sep, x2, sep, ..., sep, xn] 的序列。最后,我们使用 _ + _ 函数对象折叠结果序列,这等效于 std::plus<>{}。由于我们的序列包含 std::string(我们假设它包含 std::string),因此这将导致将序列中的所有字符串连接成一个大字符串。现在,让我们定义如何打印一个 Sequence

template <typename Xs>
std::string> to_json(Xs const& xs) {
auto json = hana::transform(xs, [](auto const& x) {
return to_json(x);
});
return "[" + join(std::move(json), ", ") + "]";
}

首先,我们使用 transform 算法将我们的对象序列转换为 JSON 格式的 std::string 序列。然后,我们将该序列用逗号连接,并用 [] 将其括起来以表示 JSON 表示法中的序列。够简单吧?现在让我们看看如何打印用户定义的类型

template <typename T>
std::string> to_json(T const& x) {
auto json = hana::transform(hana::keys(x), [&](auto name) {
auto const& member = hana::at_key(x, name);
return quote(hana::to<char const*>(name)) + " : " + to_json(member);
});
return "{" + join(std::move(json), ", ") + "}";
}
constexpr keys_t keys
返回一个包含数据结构成员名称的 Sequence。
定义: keys.hpp:29

在这里,我们使用 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 函数创建

auto xs = hana::make<hana::tuple_tag>(1, 2.2, 'a', "bcde"s);

实际上,make_tuple 只是 make<tuple_tag> 的快捷方式,因此当您不在 Hana 的命名空间中时,您不必键入 boost::hana::make<boost::hana::tuple_tag>。简而言之,make<...> 在整个库中用于创建不同类型的对象,从而概括了 std::make_xxx 函数族。例如,可以用 make<range_tag> 创建一个编译时整数的 hana::range

constexpr auto r = hana::make<hana::range_tag>(hana::int_c<3>, hana::int_c<10>);
static_assert(r == hana::make_range(hana::int_c<3>, hana::int_c<10>), "");

这些以 _tag 结尾的类型是表示一组异构容器(hana::tuplehana::map 等)的虚拟类型。标签在关于 Hana 核心 的部分中进行了介绍。

为了方便起见,只要 Hana 的某个组件提供了 make<xxx_tag> 函数,它也会提供 make_xxx 快捷方式以减少键入。此外,在本例中可以提出的一个有趣的观点是 rconstexpr。一般来说,只要容器只用常量表达式初始化(对于 r 来说就是这样),该容器就可以标记为 constexpr

到目前为止,我们只用 make_xxx 函数族创建了容器。但是,有些容器确实提供构造函数作为其接口的一部分。例如,可以像创建 std::tuple 一样创建 hana::tuple

hana::tuple<int, double, char, std::string> xs{1, 2.2, 'a', "bcde"s};

当构造函数(或任何成员函数)是公共接口的一部分时,它们将在每个容器的基础上进行记录。但是,在一般情况下,不应想当然地认为容器可以像上面构造元组一样构造。例如,尝试以这种方式创建 hana::range 不起作用

hana::range<???> xs{hana::int_c<3>, hana::int_c<10>};

实际上,我们甚至无法在那种情况下指定我们想要创建的对象的类型,因为 hana::range 的确切类型是实现定义的,这让我们进入下一节。

容器类型

本节的目的是澄清可以从 Hana 容器的类型中期待什么。实际上,到目前为止,我们总是通过使用 make_xxx 函数族以及 auto 来让编译器推断容器的实际类型。但一般来说,关于容器的类型,我们可以说些什么呢?

auto xs = hana::make_tuple(1, '2', "345");
auto ints = hana::make_range(hana::int_c<0>, hana::int_c<100>);
// 关于 `xs` 和 `ints` 的类型,我们可以说些什么呢?

答案是这取决于情况。有些容器具有明确定义的类型,而其他容器则没有指定其表示形式。在本例中,由 make_tuple 返回的对象的类型是明确定义的,而由 make_range 返回的类型是实现定义的

hana::tuple<int, char, char const*> xs = hana::make_tuple(1, '2', "345");
auto ints = hana::make_range(hana::int_c<0>, hana::int_c<100>);
// 但是,不能指定 ints 的类型

这在每个容器的基础上都有说明;当容器具有实现定义的表示形式时,其描述中将包含一个说明可以从该表示形式中期待什么的说明。存在若干原因导致未指定容器的表示形式;它们在 理由 中进行了解释。当容器的表示形式是实现定义的时,必须小心,不要对它做任何假设,除非这些假设在容器的文档中明确允许。例如,假设可以安全地从容器继承或容器中的元素按其模板参数列表中指定的顺序存储通常是不安全的。

对容器类型进行重载

虽然有必要,但将某些容器的类型留为未指定会使某些事情变得非常难以实现,例如对异构容器进行函数重载

template <typename T>
void f(std::vector<T> xs) {
// ...
}
template <typename ...???>
void f(unspecified-range-type<???> r) {
// ...
}

出于这个原因(以及其他原因),提供了 is_a 实用程序。is_a 允许检查类型是否使用其标签是特定类型的容器,而不管容器的实际类型如何。例如,上面的示例可以改写为

template <typename T>
void f(std::vector<T> xs) {
// ...
}
template <typename R, typename = std::enable_if_t<hana::is_a<hana::range_tag, R>()>>
void f(R r) {
// ...
}

这样,f 的第二个重载只会在 R 是标签为 range_tag 的类型的类型时匹配,而不管该范围的确切表示形式如何。当然,is_a 可以与任何类型的容器一起使用:tuplemapset 等等。

容器元素

在 Hana 中,容器拥有自己的元素。当创建一个容器时,它会对用于初始化它的元素进行复制并将它们存储在容器中。当然,通过使用移动语义可以避免不必要的复制。由于这些拥有语义,容器中对象的生存期与容器的生存期相同。

std::string hello = "Hello";
std::vector<char> world = {'W', 'o', 'r', 'l', 'd'};
// hello 被复制,world 被移动进来
auto xs = hana::make_tuple(hello, std::move(world));
// s 是对 xs 中 hello 副本的引用。
// 只要 xs 被销毁,它就会变成悬空引用。
std::string& s = xs[0_c];

与标准库中的容器非常类似,Hana 中的容器希望其元素是对象。出于这个原因,不能在其中存储引用。当必须将引用存储在容器中时,应该使用 std::reference_wrapper

std::vector<int> ints = { /* 大量的 int 向量 */ };
std::vector<std::string> strings = { /* 大量的字符串向量 */ };
auto map = hana::make_map(
hana::make_pair(hana::type_c<int>, std::ref(ints)),
hana::make_pair(hana::type_c<std::string>, std::ref(strings))
);
auto& v = map[hana::type_c<int>].get();

关于算法的概论


与上一节介绍了关于异构容器的一般但重要的概念一样,本节介绍了关于异构算法的一般概念。

按值语义

Hana 中的算法总是返回一个包含结果的新容器。这使得人们可以轻松地通过简单地使用第一个的结果作为第二个的输入来链接算法。例如,要将函数应用于元组的每个元素,然后反转结果,只需连接 reversetransform 算法即可

auto to_str = [](auto const& x) {
std::stringstream ss;
ss << x;
return ss.str();
};
auto xs = hana::make_tuple(1, 2.2, 'a', "bcde");
hana::reverse(hana::transform(xs, to_str)) == hana::make_tuple("bcde", "a", "2.2", "1")
);

这与标准库中的算法不同,在标准库中,必须为底层序列提供迭代器。由于在原理中记录的原因,基于迭代器的设计被考虑过,但很快就被放弃,转而采用更适合异构编程的特定上下文的可组合且高效的抽象。

人们可能还会认为,从算法中返回拥有其元素的完整序列会导致大量不需要的复制。例如,当使用reversetransform 时,人们可能会认为在调用transform 后会进行中间复制

hana::transform(xs, to_str) // <-- 这里会复制到 reverse(...) 中吗?
);

为了确保这种情况不会发生,Hana 大量使用了完美转发和移动语义,因此它可以提供几乎最佳的运行时性能。因此,不是进行复制,而是在reversetransform 之间进行移动

hana::transform(xs, to_str) // <-- 不,而是从临时对象中移动!
);

最终目标是,使用 Hana 编写的代码应该等同于精心编写的代码,只是它应该更易于编写。性能注意事项将在其自身的部分中深入解释。

(非)惰性

Hana 中的算法不是惰性的。当调用算法时,它会完成其工作并返回一个包含结果的新序列,故事就结束了。例如,对大型序列调用permutations 算法是一个愚蠢的想法,因为 Hana 实际上会计算所有排列

auto perms = hana::permutations(hana::make_tuple(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
// perms 有 3 628 800 个元素,你的编译器刚刚崩溃了
constexpr auto permutations
返回给定序列的所有排列的序列。
定义: permutations.hpp:34

相比之下,Boost.Fusion 中的算法返回视图,这些视图通过引用保存原始序列,并在需要时应用算法,因为序列的元素是被访问的。这会导致微妙的生命周期问题,例如,一个视图引用一个已被销毁的序列。Hana 的设计假设,在大多数情况下,我们无论如何都需要访问序列中的所有元素或几乎所有元素,因此性能并不是支持惰性的主要论据。

生成了什么?

Hana 中的算法在它们扩展成的运行时代码方面有点特殊。本节的目标不是解释到底生成了什么代码(这取决于编译器),而是让人对这些东西有所了解。基本上,Hana 算法就像展开版本的等效经典算法。实际上,由于处理的序列的边界在编译时是已知的,因此我们可以展开序列上的循环,这是有意义的。例如,让我们考虑for_each 算法

auto xs = hana::make_tuple(0, 1, 2, 3);

如果xs 是运行时序列而不是元组,那么它的长度只能在运行时才知道,上述代码必须实现为循环

for (int i = 0; i < xs.size(); ++i) {
f(xs[i]);
}

}

但是,在我们的例子中,序列的长度在编译时是已知的,因此我们不必在每次迭代时检查索引。因此,我们只需编写
f(xs[0_c]);
f(xs[1_c]);
f(xs[2_c]);

f(xs[3_c]);

这里的主要区别是,在每一步都没有进行边界检查和索引递增,因为不再有索引;循环实际上已经被展开。在某些情况下,出于性能原因,这可能是可取的。在其他情况下,这可能不利于性能,因为它会导致代码大小增加。与往常一样,性能是一个棘手的问题,你是否真的想进行循环展开应该在逐个案例的基础上解决。作为一个经验法则,处理容器所有元素(或子集)的算法都会被展开。实际上,如果你仔细想想,这种展开是处理异构序列的唯一方法,因为序列的不同元素可能具有不同的类型。正如你可能已经注意到的,我们没有使用元组中的普通索引,而是使用了编译时索引,这些索引不能通过正常的for 循环生成。换句话说,以下操作没有意义
f(xs[i]);
}

for (??? i = 0_c; i < xs.size(); ++i) {

副作用和纯度

默认情况下,Hana 假设函数是纯函数。纯函数是指完全没有副作用的函数。换句话说,它是一个函数,其对程序的影响完全由其返回值决定。特别是,这种函数不能访问任何超过单次调用函数生命周期的状态。这些函数具有非常好的属性,例如能够对它们进行数学推理,重新排序甚至消除调用等等。除了另有说明外,所有与 Hana 一起使用的函数(即在高阶算法中使用的函数)都应该是纯函数。特别是,传递给高阶算法的函数不能保证被调用任何特定的次数。此外,执行顺序通常没有指定,因此不应将其视为理所当然。如果这种关于函数调用的缺乏保证看起来很疯狂,请考虑以下对any_of 算法的使用
auto r = hana::any_of(hana::make_tuple("hello"s, 1.2, 3), [](auto x) {
});
return std::is_integral<decltype(x)>{};
boost::hana::any_of
constexpr auto any_of
返回结构中的任何键是否满足谓词。
注意
定义: any_of.hpp:37

为了使这能够工作,必须包含<boost/hana/ext/std/integral_constant.hpp> 中包含的std::integral_constant 的外部适配器。

根据之前关于展开的部分,此算法应该扩展成类似于以下内容
auto xs = hana::make_tuple("hello"s, 1.2, 3);
auto pred = [](auto x) { return std::is_integral<decltype(x)>{}; };
auto r = hana::bool_c<
pred(xs[0_c]) ? true
pred(xs[1_c]) ? true
pred(xs[2_c]) ? true
>;
return std::is_integral<decltype(x)>{};

false

根据之前关于展开的部分,此算法应该扩展成类似于以下内容
auto xs = hana::make_tuple("hello"s, 1.2, 3);
auto pred = [](auto x) { return std::is_integral<decltype(x)>{}; };
当然,上述代码不能按原样工作,因为我们在 something 中调用了pred,而 something 必须是常量表达式,但pred 是一个 lambda(而 lambda 不能在常量表达式中被调用)。但是,这些对象中是否有一个具有整型类型在编译时是明确已知的,因此我们预计计算答案只涉及编译时计算。实际上,这就是 Hana 的工作方式,上述算法扩展成类似于以下内容
decltype(pred(xs[0_c]))::value ? true
decltype(pred(xs[1_c]))::value ? true
pred(xs[2_c]) ? true
>;
return std::is_integral<decltype(x)>{};
注意
decltype(pred(xs[2_c]))::value ? true

正如你将从下一节关于跨阶段计算中推断出的那样,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++ 中,就像在大多数静态类型语言中一样,编译时和运行时之间有着明显的区别;这被称为阶段区分。当我们谈论跨阶段计算时,我们指的是某种程度上跨越这些阶段执行的计算;也就是说,部分在编译时执行,部分在运行时执行。

struct Fish { std::string name; };
struct Cat { std::string name; };
struct Dog { std::string name; };
auto animals = hana::make_tuple(Fish{"Nemo"}, Cat{"Garfield"}, Dog{"Snoopy"});
正如我们在前面的例子中看到的那样,一些函数能够返回一些可以在编译时使用的东西,即使它们是在运行时值上调用的。例如,让我们考虑应用于非constexpr 容器的length 函数
// ^^^^^^^ 不是编译时值
BOOST_HANA_CONSTANT_CHECK(hana::length(animals) == hana::size_c<3>);
boost::hana::length
constexpr auto length
返回可折叠结构中的元素数量。

定义: length.hpp:34

显然,元组不能设置为constexpr,因为它包含运行时std::string。尽管如此,即使它没有在常量表达式上调用,length 仍然返回可以在编译时使用的东西。如果你仔细想想,元组的大小在编译时是已知的,无论它的内容是什么,因此只有在编译时让我们可以使用这些信息才有意义。如果这看起来令人惊讶,请考虑std::tuplestd::tuple_size
std::tuple<int, char, std::string> xs{1, '2', std::string{"345"}};
std::tuple
用于 std::tuples 的适配器。

由于元组的大小在其类型中编码,因此无论元组是 constexpr 还是否,它始终在编译时可用。在 Hana 中,这是通过让 length 返回一个 IntegralConstant 来实现的。由于 IntegralConstant 的值在它的类型中编码,因此 length 的结果包含在它返回的对象的类型中,因此长度在编译时是已知的。由于 length 从运行时值(容器)到编译时值(IntegralConstant),length 是跨阶段算法的简单示例(简单,因为它实际上并没有操作元组)。另一个与 length 非常相似的算法是 is_empty 算法,它返回容器是否为空。

// ^^^^^^^^^^^^^^^^^^^^^^^ 断言在编译时完成
constexpr auto is_empty
返回可迭代对象是否为空。
定义: is_empty.hpp:33

更一般地说,任何接受一个在运行时已知其值的容器但查询可在编译时已知的算法都应该能够返回一个 IntegralConstant 或其他类似的编译时值。让我们通过考虑 any_of 算法来稍微复杂化一下,我们已经在上一节中遇到过这个算法。

bool any_garfield = hana::any_of(animals, [](auto animal) {
return animal.name == "Garfield"s;
});

在这个例子中,结果在编译时无法得知,因为谓词返回一个 bool,它是两个 std::string 的比较结果。由于 std::string 不能在编译时进行比较,因此谓词必须在运行时操作,并且算法的总体结果也只能在运行时得知。但是,假设我们使用 any_of 以及以下谓词。

auto any_cat = hana::any_of(animals, [](auto x) {
return std::is_same<decltype(x), Cat>{};
});
注意
定义: any_of.hpp:37

首先,由于谓词仅查询有关元组每个元素类型的 信息,因此很明显它的结果可以在编译时得知。由于元组中元素的数量在编译时也是已知的,因此算法的总体结果理论上可以在编译时得知。更准确地说,发生的事情是谓词返回一个初始化为 std::is_same<...> 的值,它继承自 std::integral_constant。Hana 识别这些对象,并且算法的编写方式保留了谓词结果的 编译时 性质。最终,any_of 因此返回一个 IntegralConstant,其中包含算法的结果,我们以一种巧妙的方式使用编译器的类型推导,使其看起来很简单。因此,它等同于编写(但你需要提前知道算法的结果!)

hana::integral_constant<bool, true> any_cat = hana::any_of(animals, [](auto x) {
return std::is_same<decltype(x), Cat>{};
});

好的,所以一些算法能够在它们的输入满足关于 编译时 性质的某些约束时返回编译时值。但是,其他算法更具限制性,它们要求它们的输入满足关于 编译时 性质的某些约束,如果没有这些约束,它们就无法正常运行。filter 就是一个例子,它接受一个序列和一个谓词,并返回一个新的序列,该序列仅包含满足谓词的那些元素。filter 要求谓词返回一个 IntegralConstant。虽然这个要求似乎很严格,但如果你仔细想想就会发现它确实很有道理。实际上,由于我们从异构序列中删除了一些元素,因此结果序列的类型取决于谓词的结果。因此,谓词的结果必须在编译时得知,才能让编译器为返回的序列分配类型。例如,考虑一下我们尝试按以下方式过滤一个异构序列时会发生什么。

auto animals = hana::make_tuple(Fish{"Nemo"}, Cat{"Garfield"}, Dog{"Snoopy"});
auto no_garfield = hana::filter(animals, [](auto animal) {
return animal.name != "Garfield"s;
});

很明显,我们知道谓词只会对第二个元素返回 false,因此结果应该是一个 [Fish, Dog] 元组。但是,编译器无法知道这一点,因为谓词的结果是运行时计算的结果,而运行时计算是在编译器完成工作很久以后才发生的。因此,编译器没有足够的信息来确定算法的返回类型。但是,我们可以使用任何结果在编译时可用的谓词来 filter 相同的序列。

auto mammals = hana::filter(animals, [](auto animal) {
return hana::type_c<decltype(animal)> != hana::type_c<Fish>;
});

由于谓词返回一个 IntegralConstant,因此我们可以在编译时知道要保留异构序列中的哪些元素。因此,编译器能够弄清楚算法的返回类型。其他算法如 partitionsort 的工作方式类似;特殊的算法要求始终在文档中说明,使用之前请阅读算法的参考文档,以避免意外情况。

这就是关于算法部分的结尾。虽然这构成对算法内部阶段交互的相当完整的解释,但通过阅读关于 constexpr高级部分 以及 ConstantIntegralConstant 的参考,可以获得更深入的理解。

警告
Hana 的算法是 constexpr 函数对象,而不是模板函数。这允许将它们传递给更高阶的算法,这非常有用。但是,由于这些函数对象是在头文件中的命名空间范围内定义的,因此我们需要支持 C++17 内联变量以避免 ODR 违规(通过在不同的翻译单元中定义相同对象的方式)。在 C++14 模式下编译时,内联变量不可用,每个翻译单元将看到不同的算法对象,因此算法函数对象的地址不保证在翻译单元之间是唯一的。从技术上讲,这是一种 ODR 违规,但除非你依赖于地址相同,否则它不会咬你。简而言之,如果你在 C++14 模式下编译,不要依赖于 Hana 提供的任何全局对象的地址的唯一性。

性能考虑


C++ 程序员喜欢性能,所以这里有一个专门用于性能的整个部分。由于 Hana 位于运行时和编译时计算的边界,因此我们不仅对运行时性能感兴趣,而且对编译时性能也感兴趣。由于这两个主题几乎是分开的,因此我们将在下面分别进行处理。

注意
本节中介绍的基准测试会在我们推送到存储库时自动更新。如果你注意到结果无法承受这里做出的声明,请打开一个 GitHub 问题;这可能是一个性能下降。
警告
截至撰写本文时,并非所有 Hana 的容器都经过优化。实现 Hana 是一个足够大的挑战,容器最初是简单地编写,现在正在经过严格的优化过程。特别是,关联容器(hana::maphana::set)由于其简单的实现,在编译时表现得非常糟糕,并且它们的运行时行为似乎在某些情况下也存在问题。改进这种情况在 TODO 列表中。

编译时性能

C++ 元编程带来了它自身的糟糕之处。与之相关的一个最烦人且众所周知的问题是编译时间过长。Hana 声称比其前身具有更高的编译时效率;这是一个大胆的声明,我们将尝试对其进行支持。当然,Hana 无法创造奇迹;元编程是 C++ 模板系统的副产品,编译器并非旨在用作某种元语言的解释器。但是,通过使用最先进的和经过大量基准测试的技术,Hana 能够最大限度地减少对编译器的负担。

注意
虽然 Hana 比 C++11 之前的元编程库具有更好的编译时间,但仅支持类型级计算的现代库(如 Brigand)可以提供更好的编译时间,但代价是一般性。实际上,Hana 操纵运行时值的能力是以编译时性能为代价的,无论我们多么努力地减轻它。如果你想将 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 算法何时是延迟的,这样您就知道我们何时人为地强制执行算法的计算以进行基准测试。

注意
我们目前正在考虑为 Hana 添加延迟视图。如果此功能对您很重要,请通过评论 此问题告知我们。

第二类重要的算法是折叠。折叠可用于实现许多其他算法,例如 count_ifminimum 等等。因此,折叠算法良好的编译时性能确保了这些派生算法良好的编译时性能,这就是我们在这里只介绍折叠的原因。还要注意,所有非单子折叠变体在编译时都比较等效,因此我们只介绍左折叠。下面的图表展示了将 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 导致大量展开发生,因此更要仔细考虑这些因素,任何分析方法都可能只会让我们误以为自己很有效率。相反,我们想要硬数据,以及漂亮的图表来显示它!

注意
与编译时性能一样,我们正在强制执行一些通常是延迟的 Fusion 算法的计算。同样,根据计算的复杂性,延迟算法可能会导致生成截然不同的代码或使用不同的设计,无论好坏。在查看这些运行时基准测试时请牢记这一点。如果性能对您的应用程序绝对至关重要,您应该在从 Fusion 切换到 Hana之前之后进行分析。如果 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的复制成本很低,非常适合微基准测试。但是,当我们在元素复制成本很高的容器上链接多个算法时会发生什么?更一般地说,问题是:当算法传递一个临时对象时,它是否会抓住机会避免不必要的复制?考虑

auto xs = hana::make_tuple("some"s, "huge"s, "string"s);
// 不应该对 xs 的元素进行复制:它们应该只被移动。
auto ys = hana::reverse(std::move(xs));

为了回答这个问题,我们将查看在对大约 1k 个字符的字符串进行上述代码基准测试时生成的图表。但是,请注意,对标准库算法进行基准测试实际上没有意义,因为它们不返回容器。

注意
请记住,fusion::reverse 通常是惰性的,为了进行基准测试,我们强制执行其评估。

如您所见,Hana 比 Fusion 更快,这可能是因为实现中更一致地使用移动语义。如果我们没有为reverse提供临时容器,Hana 就无法执行任何移动,并且两个库的性能将相似。

这部分关于运行时性能的内容到此结束。希望您现在相信 Hana 是为了速度而构建的。性能对我们很重要:如果您遇到 Hana 导致生成不良代码的情况(并且错误不在编译器上),请打开一个问题,以便解决该问题。

与外部库集成


Hana 提供了一些现有库的开箱即用集成。具体来说,这意味着您可以通过简单地包含适当的标题来在 Hana 的算法中使用这些库中的一些容器,从而在 Hana 和外部组件之间建立桥梁。这对于将现有代码从例如 Fusion/MPL 移植到 Hana 非常有用。

// 在旧代码中,这曾经接收一个 Fusion 序列。
// 现在,它可以是 Hana 序列或 Fusion 序列。
template <typename Sequence>
void f(Sequence const& seq) {
hana::for_each(seq, [](auto const& element) {
std::cout << element << std::endl;
});
}
注意
  • 目前,只提供将其他库中的数据类型用于 Hana 的适配器;不提供反向适配器(在其他库中使用 Hana 容器)。
  • Fusion 和 MPL 适配器仅保证在与所用 Hana 版本匹配的 Boost 版本上工作。

但是,使用外部适配器存在一些陷阱。例如,在使用 Hana 一段时间后,您可能会习惯于使用正常的比较运算符来比较 Hana 元组,或者对 Hana integral_constant 进行算术运算。当然,无法保证这些运算符也为外部适配器定义(一般来说,它们不会被定义)。因此,您必须坚持使用 Hana 提供的实现这些运算符的函数。例如

auto r = std::ratio<3, 4>{} + std::ratio<4, 5>{}; // 错误,运算符未定义!
为 Hana 调整 std::ratio。
定义: ratio.hpp:58

相反,您应该使用以下内容

#include <ratio>
namespace hana = boost::hana;
定义 boost::hana::plus。
调整 std::ratio 以与 Hana 协同使用。

但有时情况会更糟。一些外部组件定义了运算符,但它们不一定与 Hana 中的运算符具有相同的语义。例如,比较两个长度不同的std::tuple,在使用operator==时会报错

std::make_tuple(1, 2, 3) == std::make_tuple(1, 2); // 编译器错误

另一方面,比较长度不同的 Hana 元组只会返回一个错误的IntegralConstant

hana::make_tuple(1, 2, 3) == hana::make_tuple(1, 2); // hana::false_c

这是因为std::tuple 定义了自己的运算符,它们的语义不同于 Hana 的运算符。解决方案是在知道需要与其他库协同工作时,坚持使用 Hana 的命名函数而不是使用运算符

hana::equal(std::make_tuple(1, 2, 3), std::make_tuple(1, 2)); // hana::false_c

使用外部适配器时,还应注意不要忘记包含正确的桥接头文件。例如,假设我想使用 Boost.MPL 向量与 Hana 协同使用。我包含了相应的桥接头文件

#include <boost/hana/ext/boost/mpl/vector.hpp> // 桥接头文件
using Vector = mpl::vector<int, char, float>;
static_assert(hana::front(Vector{}) == hana::type_c<int>, "");
调整 boost::mpl::vector 以与 Hana 协同使用。
constexpr auto front
返回非空可迭代对象的第一个元素。
定义: front.hpp:32
注意
这些桥接头文件的具体布局在有关头文件组织的部分中有说明。

现在,但是,假设我使用mpl::size 来查询向量的长度,然后将其与某个值进行比较。我也可以使用hana::length,一切都会正常,但为了这个示例,请耐心等待

using Size = mpl::size<Vector>::type;
static_assert(hana::equal(Size{}, hana::int_c<3>), ""); // 失败!

造成这种失败的原因是mpl::size 返回一个 MPL IntegralConstant,除非您包含正确的桥接头文件,否则 Hana 无法识别这些头文件。因此,您应该执行以下操作

using Size = mpl::size<Vector>::type;
static_assert(hana::equal(Size{}, hana::int_c<3>), "");
调整 Boost.MPL IntegralConstants 以与 Hana 协同使用。

结论是,在与外部库协同工作时,您必须小心处理的对象。最后的陷阱是外部库中的实现限制。许多较旧的库对可以使用它们创建的异构容器的最大大小有限制。例如,一个人可能无法创建一个包含超过FUSION_MAX_LIST_SIZE个元素的 Fusion 列表。显然,这些限制被 Hana 继承了,例如,尝试计算包含 5 个元素的fusion::list 的排列(结果列表将包含 120 个元素)将以一种可怕的方式失败

auto list = fusion::make_list(1, 2, 3, 4, 5);
auto oh_jeez = hana::permutations(list); // 可能不会成功

除了本节中解释的陷阱之外,使用外部适配器应该与使用普通的 Hana 容器一样简单明了。当然,只要有可能,您应该尝试坚持使用 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 向量重载一个函数

template <typename ...T>
void function(boost::fusion::vector<T...> v) {
// whatever
}
Boost.Fusion 向量的适配器。
定义: vector.hpp:48

如果你了解 Boost.Fusion,那你可能也知道它不能直接工作。这是因为 Boost.Fusion 向量并不一定是 boost::fusion::vector 模板的特化。Fusion 向量也存在编号形式,它们都是不同的类型

boost::fusion::vector1<T>
boost::fusion::vector2<T, U>
boost::fusion::vector3<T, U, V>
...

这是 C++03 中缺乏可变参数模板所要求的实现细节,它泄漏到了接口中。这很不幸,但我们需要一种方法来解决它。为此,我们使用了一个包含三个不同组件的架构

  1. 一个元函数,将一个唯一的标记与相关类型族中的每个类型相关联。在 Hana 中,可以使用 tag_of 元函数访问此标记。具体来说,对于任何类型 Ttag_of<T>::type 是用于分派它的标记。
  2. 一个属于库公共接口的函数,我们希望能够为其提供定制的实现。在 Hana 中,这些函数是与概念相关的算法,例如 transformunpack
  3. 一个函数的实现,以传递给函数的参数的标记参数化。在 Hana 中,这通常通过使用名为 xxx_impl(对于接口函数 xxx)的单独模板来完成,该模板具有嵌套的 apply 静态函数,如下所示。

当公共接口函数 xxx 被调用时,它将获取它希望在其上分派调用的参数的标记,然后将调用转发到与这些标记相关的 xxx_impl 实现。例如,让我们实现一个基本设置,用于分派将参数打印到流的函数的标记。首先,我们定义公共接口函数和可以专门化的实现

template <typename Tag>
struct print_impl {
template <typename X>
static void apply(std::ostream&, X const&) {
// 可能有一些默认实现
}
};
template <typename X>
void print(std::ostream& os, X x) {
using Tag = typename hana::tag_of<X>::type;
}
auto print
返回给定对象的字符串表示形式。
定义: printable.hpp:69
constexpr auto apply
使用给定参数调用 Callable。
定义: apply.hpp:40

现在,让我们定义一个需要标记分派来定制 print 行为的类型。虽然存在一些 C++14 示例,但它们过于复杂,无法在本教程中展示,因此我们将使用作为几种不同类型的 C++03 元组来说明该技术

struct vector_tag;
struct vector0 {
using hana_tag = vector_tag;
static constexpr std::size_t size = 0;
};
template <typename T1>
struct vector1 {
T1 t1;
using hana_tag = vector_tag;
static constexpr std::size_t size = 1;
template <typename Index>
auto const& operator[](Index i) const {
static_assert(i == 0u, "index out of bounds");
return t1;
}
};
template <typename T1, typename T2>
struct vector2 {
T1 t1; T2 t2;
using hana_tag = vector_tag;
static constexpr std::size_t size = 2;
// 使用 Hana 作为后端来简化示例。
template <typename Index>
auto const& operator[](Index i) const {
return *hana::make_tuple(&t1, &t2)[i];
}
};
// 等等...
constexpr auto size
等同于 length;为与标准库保持一致而提供。
定义: size.hpp:30

嵌套的 using hana_tag = vector_tag; 部分是一种简短的方式来控制 tag_of 元函数的结果,因此控制 vectorN 类型的标记。这在 tag_of 的参考中有所解释。最后,如果你想定制所有 vectorN 类型的 print 函数的行为,你通常需要编写类似以下内容

void print(std::ostream& os, vector0)
{ os << "[]"; }
template <typename T1>
void print(std::ostream& os, vector1<T1> v)
{ os << "[" << v.t1 << "]"; }
template <typename T1, typename T2>
void print(std::ostream& os, vector2<T1, T2> v)
{ os << "[" << v.t1 << ", " << v.t2 << "]"; }
// 等等...

现在,使用标记分派,您可以依赖于所有 vectorN 共享相同的标记,并且只专门化 print_impl 结构

template <>
struct print_impl<vector_tag> {
template <typename vectorN>
static void apply(std::ostream& os, vectorN xs) {
auto N = hana::size_c<vectorN::size>;
os << "[";
N.times.with_index([&](auto i) {
os << xs[i];
if (i != N - hana::size_c<1>) os << ", ";
});
os << "]";
}
};

一个好处是,所有 vectorN 现在都可以被 print 函数统一对待,代价是在创建数据结构时(指定每个 vectorN 的标记)和在创建初始 print 函数时(使用 print_impl 设置标记分派系统)需要一些样板代码。此技术还有其他优点,例如能够在接口函数中检查先决条件,而不必在每个定制实现中执行此操作,这将非常繁琐

template <typename X>
void print(std::ostream& os, X x) {
// **** 检查一些先决条件 ****
// 先决条件只需要在这里检查;实现
// 可以假设它们的论据始终是合理的。
using Tag = typename hana::tag_of<X>::type;
}
注意
检查先决条件对于 print 函数没有多大意义,但考虑一个获取序列中第 n 个元素的函数;您可能希望确保索引不在边界之外。

此技术还使将接口函数提供为函数对象而不是普通重载函数变得更加容易,因为只有接口函数本身必须经过定义函数对象的麻烦。函数对象比重载函数有几个优点,例如能够在更高阶算法中使用或作为变量使用

// 定义函数对象只需要执行一次,并且实现不必
// 担心静态初始化和其他痛苦的技巧。
struct print_t {
template <typename X>
void operator()(std::ostream& os, X x) const {
using Tag = typename hana::tag_of<X>::type;
}
};
constexpr print_t print{};

您可能知道,能够同时为许多类型实现算法非常有用(这正是 C++ 模板的目标!)。然而,更重要的是能够为满足某个条件的许多类型实现算法。C++ 模板目前缺乏这种限制其模板参数的能力,但一项名为 概念 的语言特性正在推出,其目标是解决这个问题。

考虑到类似的东西,Hana 的算法支持比上面解释的更高级的标记分派。此层允许我们为满足某些谓词的所有类型“专门化”算法。例如,假设我们想为表示某种序列的所有类型实现上面的 print 函数。现在,我们没有简单的方法来做到这一点。但是,Hana 的算法的标记分派设置略有不同,因此我们可以编写以下内容

template <typename Tag>
struct print_impl<Tag, hana::when<Tag represents some kind of sequence>> {
template <typename Seq>
static void apply(std::ostream& os, Seq xs) {
// 任何序列的一些实现
}
};

其中 Tag represents some kind of sequence 仅需是一个布尔表达式,表示 Tag 是否是序列。我们将在下一节中看到如何创建此类谓词,但现在让我们假设它正常工作。不详细介绍此标记分派是如何设置的,上面的专门化只有在谓词满足时才会被选中,并且如果找不到更好的匹配。因此,例如,如果我们的 vector_tag 要满足谓词,我们最初针对 vector_tag 的实现仍然优先于基于 hana::when 的专门化,因为它代表了一个更好的匹配。一般来说,任何使用 hana::when 的专门化(无论是显式还是部分)都优先于使用 hana::when 的专门化,这被设计为从用户的角度尽可能地不让人感到意外。这几乎涵盖了 Hana 中关于标记分派的所有内容。下一节将解释如何为元编程创建 C++ 概念,然后可以将其与 hana::when 结合使用以实现高度的表现力。

模拟 C++ 概念

Hana 中概念的实现非常简单。从本质上讲,一个概念只是一个模板 struct,它继承自一个布尔 integral_constant,表示给定类型是否是概念的模型

template <typename T>
struct Concept
: hana::integral_constant<bool, whether T models Concept>
{ };

然后,可以通过查看 Concept<T>::value 来测试类型 T 是否是 Concept 的模型。很简单,对吧?现在,虽然从 Hana 的角度来看,实现检查的方式不必是任何特定的方式,但本节的其余部分将解释它在 Hana 中通常是如何完成的,以及它如何与标记分派交互。然后,您就可以根据需要定义自己的概念,或者至少更好地理解 Hana 的内部工作原理。

通常,由 Hana 定义的概念要求任何模型实现一些标记分派函数。例如,Foldable 概念要求任何模型至少定义 hana::unpackhana::fold_left 之一。当然,概念通常还定义语义要求(称为定律),这些要求必须由其模型满足,但这些定律不受(也无法被)概念检查。但是,我们如何检查某些函数是否正确实现?为此,我们将不得不稍微修改我们在上一节中定义的标记分派方法的方式。让我们回到我们的 print 示例,并尝试为那些可以被 print 的对象定义一个 Printable 概念。我们的最终目标是拥有一个模板结构,例如

template <typename T>
struct Printable
: hana::integral_constant<bool, 是否定义了 print_impl<T 的标记>>
{ };

要了解 print_impl<...> 是否已定义,我们将修改 print_impl,使其在未被覆盖时继承自一个特殊的基类,我们只需检查 print_impl<T> 是否继承自该基类

struct special_base_class { };
template <typename T>
struct print_impl : special_base_class {
template <typename ...Args>
static constexpr auto apply(Args&& ...) = delete;
};
template <typename T>
struct Printable
: hana::integral_constant<bool,
!std::is_base_of<special_base_class, print_impl<hana::tag_of_t<T>>>::value
>
{ };

当然,当我们用自定义类型专门化 print_impl 时,我们不会继承自该 special_base_class 类型

struct Person { std::string name; };
template <>
struct print_impl<Person> /* 不要继承自 special_base_class */ {
// ... 实现 ...
};
static_assert(Printable<Person>::value, "");
static_assert(!Printable<void>::value, "");

如您所见,Printable<T> 实际上只检查 print_impl<T> 结构是否被自定义类型专门化。特别是,它甚至不检查嵌套的 ::apply 函数是否已定义或它是否语法有效。假设如果一个人为自定义类型专门化 print_impl,则嵌套的 ::apply 函数存在且是正确的。如果不是,则当尝试对该类型的对象调用 print 时,将触发编译错误。Hana 中的概念做出相同的假设。

由于这种从特殊基类继承的模式在 Hana 中非常普遍,库提供了一个名为 hana::default_ 的虚拟类型,可以用来代替 special_base_class。然后,可以使用 hana::is_default 来代替 std::is_base_of,这看起来更漂亮。有了这种语法糖,代码现在变成了

template <typename T>
struct print_impl : hana::default_ {
template <typename ...Args>
static constexpr auto apply(Args&& ...) = delete;
};
template <typename T>
struct Printable
: hana::integral_constant<bool,
!hana::is_default<print_impl<hana::tag_of_t<T>>>::value
>
{ };

这就是关于标记分派函数和概念之间交互的所有内容。但是,Hana 中的一些概念并不完全依赖于特定标记分派函数的定义来确定类型是否为该概念的模型。当概念仅仅通过定律和细化概念引入语义保证,而没有额外的语法要求时,就会发生这种情况。定义这样的概念可能出于多种原因有用。首先,有时会发生,如果我们能假设一些语义保证 X 或 Y,则算法可以更有效地实现,因此我们可能会创建一个概念来强制执行这些保证。其次,有时当我们有额外的语义保证时,可以自动定义多个概念的模型,这为用户节省了手动定义这些模型的麻烦。例如,这就是 Sequence 概念的情况,它基本上为 IterableFoldable 添加了语义保证,进而允许我们为从 ComparableMonad 的无数概念定义模型。

对于这些概念,通常有必要专门化 boost::hana 命名空间中的相应模板结构,为自定义类型提供一个模型。这样做就像提供一个印章,表示该概念要求的语义保证受到自定义类型的尊重。需要显式专门化的概念将记录该事实。就这样!这就是关于 Hana 中的概念的所有内容,它结束了关于 Hana 核心部分的这一节。

头文件组织


库的设计是模块化的,同时保持获取基本功能所需的必须包含的头文件数量合理地低。库的结构也是故意保持简单的,因为我们都喜欢简单。以下是头文件组织的概览。如果您需要更多详细信息,也可以在左侧的面板中(在 头文件 标签下)找到库提供的所有头文件的列表。

  • boost/hana.hpp
    这是库的主头文件,它包含库的整个公共接口。请注意,此头文件不包含外部适配器、实验性功能和实现细节,但是,因为其中一些需要额外的依赖项。
  • boost/hana/
    这是库的主要目录,包含库提供的每个内容的定义。库提供的每个算法和容器都有自己的头文件。对于名为 XXX 的容器或算法,对应的头文件是 boost/hana/XXX.hpp
    • boost/hana/concept/
      此子目录包含 Hana 概念的定义。这些头文件提供了一种方法来检查对象是否为相应概念的模型,并且它们有时还为其他相关概念提供默认实现,这些实现将在每个概念的基础上进行说明。它们还包含与该概念相关的所有算法。
    • boost/hana/core/
      此子目录包含标记分派和其他相关实用程序(如 maketo)的机制。
    • 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 中。

      请注意,这些头文件中只包含适配外部组件所需的严格最小值(例如,向前声明)。这意味着当有人想要使用外部组件时,仍然应该包含该组件的定义。例如

      #include <tuple> // 仍然需要创建元组
      namespace hana = boost::hana;
      int main() {
      constexpr std::tuple<int, char, float> xs{1, '2', 3.0f};
      static_assert(hana::front(xs) == 1, "");
      }
      将 std::tuple 适配为与 Hana 一起使用。
      定义 boost::hana::front。
    • boost/hana/experimental/
      此目录包含实验性功能,这些功能可能或可能不会在某个时间点进入库,但被认为足够有用,可以公开提供。此子目录中的功能位于 hana::experimental 命名空间中。另外,不要指望这些功能稳定;它们可能会在库的发布版本之间被移动、重命名、更改或删除。这些功能也可能需要额外的外部依赖项;每个功能都记录了它需要的额外依赖项(如果有)。

      由于可能存在额外的依赖项,这些头文件也不包含在库的主头文件中。

    • boost/hana/detail/
      此目录包含内部所需的实用程序。detail/ 中的内容不会保证稳定,因此您不应该使用它。

结论


您现在拥有开始使用库所需的一切。从现在开始,掌握库只是了解如何使用库提供的通用概念和容器的问题,最好通过查看参考文档来完成。在某个时间点,您可能还想创建自己的概念和数据类型,以更好地满足您的需求;放手去做,库就是为此而设计的。

公平警告:前方是函数式编程

使用异构对象的编程本质上是函数式的——由于无法修改对象的类型,因此必须引入一个新的对象,这排除了变异。与以前的设计模仿 STL 的元编程库不同,Hana 使用函数式编程风格,这是其表现力的很大一部分来源。但是,因此,参考中介绍的许多概念对于没有函数式编程知识的 C++ 程序员来说将是不熟悉的。参考试图通过尽可能地使用直觉来使这些概念易于理解,但请记住,最大的回报通常是一些努力的成果。

相关资料

多年来,我制作了一些关于 Hana 和更一般的元编程的材料。您可能会发现其中一些有用

  • 2016 年 Meeting C++ 上关于元编程的主题演讲 (幻灯片/视频)
  • 2016 年 C++Now 上关于 Hana 中使用的高级元编程技术的演讲 (幻灯片/视频)
  • 2016 年 C++Now 上关于使用 Hana 的元编程介绍 (幻灯片/视频)
  • C++Now 2014 大会上关于 MPL11 库的演讲。这是 Hana 的起源。(幻灯片 / 视频
  • 我的学士学位论文是对使用范畴论的 C++ 元编程的正式化。论文可在 此处 获取,相关演示的幻灯片可在 此处 获取。不幸的是,两者均仅提供法语版本。

我关于 Hana 和元编程的所有演讲的完整列表可在 此处 获取。此外,还提供了一个 Hana 文档的非官方中文翻译,可从 此处 获取。

使用 Hana 的项目

使用 Hana 的项目越来越多。查看这些项目可以帮助您了解如何最佳地使用该库。以下是其中的一些项目(如果您想将您的项目列在此处,请 打开问题

  • Dyno:一个基于策略的类型擦除库。在幕后使用 Hana 进行 vtable 生成和概念映射模拟。
  • yap:一个构建在 Hana 之上的表达式模板库。
  • NBDL:一个用于跨网络管理应用程序状态的库。在幕后使用 Hana 完成一些事情。
  • ECST:一个实验性的多线程编译时实体组件系统,在幕后使用 Hana 完成一些事情。

这部分结束了本教程文档。希望您喜欢使用该库,请考虑 贡献 以使其变得更好!

– Louis

使用参考


与大多数泛型库一样,Hana 中的算法通过它们所属的概念进行文档化(FoldableIterableSearchableSequence 等)。然后,不同的容器在它们自己的页面上进行文档化,并且它们模拟的概念在那里进行文档化。一些容器模拟的概念定义了哪些算法可以与这种容器一起使用。

更具体地说,参考的结构(可在左侧菜单中获取)如下:

  • 核心
    核心模块的文档,其中包含创建概念、数据类型和相关实用程序所需的一切。如果您需要扩展该库,这与您相关,否则您可能可以忽略它。
  • 概念
    库中提供的 所有概念 的文档。每个概念
    • 记录为了模拟该概念必须绝对实现哪些函数。必须提供的函数集被称为最小完整定义
    • 记录该概念的任何模型必须满足的语义约束。这些约束通常被称为定律,它们使用半正式的数学语言表达。当然,这些定律无法自动检查,但您仍然应该确保满足它们。
    • 记录它精炼的概念(如果有)。有时,一个概念足够强大,可以提供它精炼的模型概念的模型,或者至少提供其关联函数中某些函数的实现。在这种情况下,该概念将记录它提供的精炼概念的哪些函数以及如何提供。此外,有时精炼概念的模型可能是唯一的,在这种情况下它可以自动提供。当这种情况发生时,它将被记录,但您无需执行任何特殊操作即可获得该模型。
  • 数据类型
    库中提供的所有数据结构的文档。每个数据结构都记录它模拟的概念以及如何模拟。它还记录与其绑定但与任何概念无关的方法,例如 optionalmaybe
  • 函数式
    通常在纯函数式环境中很有用的通用目的函数对象。这些目前未绑定到任何概念或容器。
  • 外部适配器
    所有外部库适配器的文档。这些适配器被文档化为 Hana 提供的本机类型,但显然 Hana 只提供它们与库之间的兼容性层。
  • 配置选项
    可用于调整库全局行为的宏。
  • 断言
    用于执行各种类型的断言的宏。
  • 字母索引
    库中提供的所有内容的字母索引。
  • 头文件
    库提供的 所有头文件 的列表。
  • 细节
    实现细节;不要去那里。任何未记录或在此组中记录的内容均不保证稳定。

在您对 Hana 有所了解后,您可能只想找到特定函数、概念或容器的参考。如果您知道要查找的内容的名称,可以使用文档任何页面右上角的搜索框。我个人的经验是,当您已经知道其名称时,这是迄今为止找到所需内容的最快速方法。

函数签名

正如您将在参考中看到的,多个函数提供以半正式数学语言记录的签名。我们正在努力以这种方式记录所有函数,但这可能需要一段时间。使用的符号是定义函数的常用数学符号。具体来说,一个函数 Return f(Arg1, ..., ArgN); 可以使用数学符号等效地定义为

\[ \mathtt{f} : \mathtt{Arg}_1 \times \dots \times \mathtt{Arg}_n \to \mathtt{Return} \]

但是,不是记录函数的实际参数和返回类型,而是使用参数和返回标签来编写这些签名。这样做是由于异构环境,其中对象的实际类型通常毫无意义,并且无助于推断函数返回或接受的内容。例如,不是将 integral_constantequal 函数记录为

\[ \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 的“类型”。请注意,由于 equalComparable 概念的一部分,因此它没有专门针对 hana::integral_constant 进行记录,但思路是一样的

\[ \mathtt{equal} : \mathtt{integral\_constant\_tag<T>} \times \mathtt{integral\_constant\_tag<T>} \to \mathtt{integral\_constant\_tag<bool>} \]

这清楚地传达了比较两个 integral_constant 会返回另一个包含 boolintegral_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 做出贡献

  • Zach Laine 和 Matt Calabrese 提出最初的想法,使用函数调用语法进行类型级计算,如他们 BoostCon 演示 中所述 (幻灯片 1) (幻灯片 2).
  • Joel Falcou 在我参与 Google Summer of Code 计划期间连续两年指导我开发 Hana,Niall Douglas 是 Boost 的 GSoC 管理员,帮助我加入该计划,最后感谢 Google 提供了很棒的 GSoC 计划。
  • Boost 指导委员会 为我提供了一笔赠款,让我在 2015 年冬季继续开发 Hana,作为之前一年 GSoC 的延伸。
  • 几位 C++Now 与会者和 Boost 邮件列表 成员就该项目进行了有见地的对话、评论和提问。

术语表


参考文档使用了一些特定于此库的术语。此外,有时会以伪代码形式提供函数的简化实现,实际实现有时难以理解。本节定义了参考和用于描述某些函数的伪代码中使用的术语。

  • forwarded(x)

    表示对象被最佳地转发。这意味着如果 x 是一个参数,则使用 std::forward 转发它,如果它是一个捕获的变量,则在封闭的 lambda 为右值时,将其从中移动。

    还要注意,当 x 可以从中移动时,decltype(auto) 函数中的语句 return forwarded(x); 并不意味着会返回 x 的右值引用,这将创建一个悬空引用。相反,这意味着 x 是按值返回的,该值使用 std::forwardx 构建。

  • perfect-capture

    这在 lambda 中用于表示捕获的变量使用完美转发进行初始化,就像使用了 [x(forwarded(x))...]() { } 一样。

  • tag-dispatched

    这意味着记录的函数使用 标签调度,因此确切的实现取决于与函数关联的概念的模型。

  • implementation-defined

    这表示用户不应依赖实体(通常为类型)的确切实现。特别是,这意味着除了文档中明确写明的之外,用户不能假设任何内容。通常,实现定义实体所满足的概念将被记录在案,因为否则无法对其实施任何操作。具体来说,假设实现定义实体的太多内容可能不会导致错误,但当更新到 Hana 的新版本时,很可能会导致代码崩溃。

理念/常见问题解答


本节记录了一些设计选择的理由。它还可以作为一些(不太)常见问题的常见问题解答。如果您认为应该在此列表中添加内容,请在 GitHub 上打开一个问题,我们将考虑改进文档或在此处添加问题。

为什么限制使用外部依赖项?

这样做有几个原因。首先,Hana 是一个非常基础的库;我们基本上用对异构类型的支持重新实现了核心语言和标准库。在浏览代码时,人们很快就会意识到很少需要其他库,并且几乎所有内容都需要从头开始实现。此外,由于 Hana 非常基础,因此更有必要将依赖项保持在最低限度,因为这些依赖项将传递给用户。关于对 Boost 的最低依赖性,一个主要论点是可移植性。但是,作为一个尖端的库,Hana 只针对最新的编译器。因此,我们可以依赖于现代构造,而使用 Boost 带给我们的可移植性大多会代表多余的负担。

为什么没有迭代器?

基于迭代器的设计有其自身的优点,但众所周知,它们也会降低算法的可组合性。此外,异构编程的上下文带来了许多点,使得迭代器变得不那么有趣。例如,递增迭代器必须返回一个具有不同类型的新的迭代器,因为指向序列中新对象的类型的类型可能不同。事实证明,用迭代器实现大多数算法会导致更差的编译时性能,仅仅是因为元编程的执行模型(使用编译器作为解释器)与 C++ 的运行时执行模型(处理器访问连续内存)非常不同。

为什么将一些容器的表示实现定义?

首先,这为实现提供了更大的回旋余地,以便通过使用特定容器的巧妙表示来执行编译时和运行时优化。例如,包含类型为 T 的同构对象的元组可以用类型为 T 的数组来实现,这在编译时更有效。其次,也是最重要的是,事实证明,知道一个异构容器的类型并不像您想象的那么有用。事实上,在异构编程的上下文中,计算返回的对象的类型通常也是计算的一部分。换句话说,如果没有真正执行算法,就无法知道算法返回的对象的类型。例如,考虑 find_if 算法

auto tuple = hana::make_tuple(1, 'x', 3.4f);
auto result = hana::find_if(tuple, [](auto const& x) {
return hana::traits::is_integral(hana::typeid_(x));
});

如果谓词对元组的某个元素满足,则 result 将等于 just(x)。否则,result 将等于 nothing。但是,result 的 nothing 性在编译时是已知的,这要求 just(x)nothing 具有不同的类型。现在,假设您想显式地写下结果的类型

some_type result = hana::find_if(tuple, [](auto const& x) {
return hana::traits::is_integral(hana::typeid_(x));
});

为了拥有关于 some_type 是什么的知识,您需要实际执行算法,因为 some_type 取决于谓词对容器中某个元素是否满足。换句话说,如果您能够编写上面的内容,那么您已经知道算法的结果是什么,并且您不再需要在第一步中执行算法。在 Boost.Fusion 中,这个问题通过使用单独的 result_of 命名空间来解决,该命名空间包含一个元函数,用于计算给定传递给它的参数的类型的任何算法的结果类型。例如,上面的示例可以用 Fusion 重写为

using Container = fusion::result_of::make_vector<int, char, float>::type;
Container tuple = fusion::make_vector(1, 'x', 3.4f);
using Predicate = mpl::quote1<std::is_integral>;
using Result = fusion::result_of::find_if<Container, Predicate>::type;
Result result = fusion::find_if<Predicate>(tuple);

请注意,我们基本上在进行两次计算;一次是在 result_of 命名空间中,另一次是在正常的 fusion 命名空间中,这非常冗余。在 autodecltype 出现之前,这些技术对于执行异构计算是必要的。但是,自从现代 C++ 出现以来,在异构编程的上下文中对显式返回值类型的需求在很大程度上已经过时,而了解容器的实际类型通常并不那么有用。

为什么是 Hana?

不,这不是我女朋友的名字!我只是需要一个简短且美观的名称,人们容易记住,Hana 就出现了。我还注意到 Hana 在日语中是的意思,在韩语中是的意思。由于 Hana 非常漂亮,并且将类型级和异构编程统一在一个范式下,因此回顾起来,这个名字似乎非常合适 :-).

为什么定义我们自己的元组?

由于 Hana 在元组上定义了许多算法,因此一种可能的方法是只使用 std::tuple 并只提供算法,而不是也提供我们自己的元组。提供我们自己的元组的原因主要是性能。事实上,到目前为止测试过的所有 std::tuple 实现都具有非常糟糕的编译时性能。此外,为了获得真正出色的编译时性能,我们需要在一些算法中利用元组的内部表示,这需要定义我们自己的。最后,如果我们使用的是 std::tuple,那么一些糖,例如 operator[] 就无法提供,因为该运算符必须定义为成员函数。

如何选择名称?

在决定使用名称 X 时,我尝试平衡以下内容(没有特定的顺序)

  • X 在 C++ 中有多么习惯?
  • X 在编程世界的其他地方有多么习惯?
  • X 的名字有多好,无论历史原因如何
  • 作为库作者,我对 X 的感受如何
  • 库的用户对 X 的感受如何
  • 是否有技术原因不能使用 X,例如名称冲突或标准保留的名称

当然,良好的命名一直都很难,并将永远都很难。名称一直以来都受到作者自身偏见的影响,并将永远如此。尽管如此,我仍然试图以合理的方式选择名称。

如何决定参数顺序?

与命名相比,命名是相当主观的,而函数的参数顺序通常很容易确定。基本上,经验法则是“容器排在首位”。在 Fusion 和 MPL 中一直都是这样,这对于大多数 C++ 程序员来说是直观的。此外,在高阶算法中,我尝试将函数参数放在最后,这样多行 lambda 看起来很漂亮

algorithm(container, [](auto x) {
return ...;
});
// 比这更好
algorithm([](auto x) {
return ...;
}, container);

为什么是标签调度?

我们可以使用多种不同的技术来为库提供定制点,而选择使用标签调度。为什么?首先,我想要一个两层调度系统,因为这允许来自第一层(由用户调用的层)的函数实际上是函数对象,这允许将它们传递给高阶算法。使用具有两层的调度系统还允许为第一层添加一些编译时健全性检查,这将改善错误消息。

现在,选择标签调度而不是其他具有两层技术的原因有几个。首先,必须明确说明某些标签如何成为某个概念的模型,这将责任交给了用户,以确保该概念的语义要求得到满足。其次,在检查某个类型是否为某个概念的模型时,我们基本上检查是否实现了一些关键函数。特别是,我们检查该概念的最小完整定义中的函数是否已实现。例如,Iterable<T> 检查为 T 实现的 is_emptyatdrop_front 函数是否已实现。但是,如果没有标签调度,检测到这一点的唯一方法是基本上检查以下表达式在可 SFINAE 上下文中是否有效

implementation_of_at(std::declval<T>(), std::declval<N>())
implementation_of_is_empty(std::declval<T>())
implementation_of_drop_front(std::declval<T>())

不幸的是,这需要实际执行算法,这可能会触发严重的编译时错误或损害编译时性能。此外,这需要选择一个任意的索引 N 来调用 at:如果 Iterable 为空怎么办?使用标签调度,我们可以只询问 at_impl<T>is_empty_impl<T>drop_front_impl<T> 是否已定义,并且在实际调用其嵌套 ::apply 函数之前不会发生任何事情。

为什么不提供 zip_longest?

这将需要 (1) 用任意的对象填充最短的序列,或者 (2) 用用户在调用 zip_longest 时提供的对象填充最短的序列。由于没有要求所有压缩的序列都具有类似类型的元素,因此无法在所有情况下都提供一个一致的填充对象。应该提供一个填充对象的元组,但我认为这可能过于复杂,不值得现在实现。如果您需要此功能,请在 GitHub 上打开一个问题。

为什么概念不是 constexpr 函数?

由于 C++ 概念提案将概念映射到布尔 constexpr 函数,因此 Hana 将其概念定义为这样的函数而不是具有嵌套 ::value 的结构体是有意义的。事实上,这是最初的选择,但必须进行修改,因为模板函数有一个限制,使其不那么灵活。具体来说,模板函数不能传递给高阶元函数。换句话说,不可能写出以下内容

template <??? Concept>
struct some_metafunction {
// ...
};

这种代码在某些情况下非常有用,例如检查两种类型是否具有共同的嵌入模型,来表达一个概念。

template <??? Concept, typename T, typename U>
struct have_common_embedding {
// 检查 T 和 U 是否都满足 Concept,并且拥有一个共同的类型,该类型也满足 Concept
};

当概念被视为布尔类型的 constexpr 函数时,无法以泛型方式编写此代码。然而,当概念只是模板结构体时,我们可以使用模板模板参数。

template <template <typename ...> class Concept, typename T, typename U>
struct have_common_embedding {
// 检查 T 和 U 是否都满足 Concept,并且拥有一个共同的类型,该类型也满足 Concept
};

附录 I:高级 constexpr


在 C++ 中,编译时和运行时的边界很模糊,这一点在 C++14 引入 广义常量表达式 后尤为明显。然而,能够操作异构对象的关键在于理解这个边界,然后根据需要跨越它。本节旨在澄清 constexpr 的作用,理解它可以解决哪些问题,以及哪些问题它无法解决。本节涵盖有关常量表达式的更高级概念,只有对 constexpr 有深入理解的读者才应该尝试阅读本节内容。

Constexpr 剥离

让我们从一个具有挑战性的问题开始。以下代码应该编译吗?

template <typename T>
void f(T t) {
static_assert(t == 1, "");
}
constexpr int one = 1;
f(one);

答案是否定的,Clang 给出的错误信息如下:

error: static_assert 表达式不是 积分 常量表达式
static_assert(t == 1, "");
^~~~~~
constexpr auto integral
将元函数转换为一个接受类型并返回默认构造对象的函数。
定义: type.hpp:513

解释是,在 f 函数体内部,t 不是一个常量表达式,因此不能用作 static_assert 的操作数。原因是编译器无法生成这样的函数。为了理解这个问题,考虑一下当我们用具体类型实例化 f 模板时会发生什么:

// 在这里,编译器应该生成 f<int> 的代码,并将该代码的地址存储在 fptr 中。
// fptr 指向一个接受整型参数的函数。
void (*fptr)(int) = f<int>;

很明显,编译器无法生成 f<int> 的代码,该代码应该在 t != 1 时触发 static_assert,因为我们还没有指定 t 的值。更糟糕的是,生成的函数应该能够处理常量表达式和非常量表达式。

void (*fptr)(int) = f<int>; // 假设这是可能的
int i = ...; // 用户输入
fptr(i);

很明显,fptr 的代码无法生成,因为它需要能够在运行时值上使用 static_assert,这是没有意义的。此外,请注意,将函数声明为 constexpr 与否并不重要;将 f 声明为 constexpr 只能说明,当参数是常量表达式时,f 的结果是一个常量表达式,但这并不能让你从 f 函数体内部得知是否用常量表达式调用了它。换句话说,我们想要的应该是这样的:

template <typename T>
void f(constexpr T t) {
static_assert(t == 1, "");
}
constexpr int one = 1;
f(one);

在这个假设的情况下,编译器会在 f 的函数体内部知道 t 是一个常量表达式,并且 static_assert 可以正常工作。然而,在当前的语言中,constexpr 参数不存在,添加它们会带来非常具有挑战性的设计和实现问题。这个小实验的结论是,参数传递会剥离 constexpr。目前可能不清楚这种剥离的后果,将在下面进行解释。

Constexpr 保留

参数不是常量表达式意味着我们不能将其用作非类型模板参数、数组边界、static_assert 以及其他需要常量表达式的语句。此外,这意味着函数的返回类型不能依赖于参数的,如果你仔细思考一下,这并不新鲜。

template <int i>
struct foo { };
auto f(int i) -> foo<i>; // 很明显,这不会工作

事实上,函数的返回类型只能依赖于参数的类型,constexpr 无法改变这一事实。这一点对我们至关重要,因为我们感兴趣的是操作异构对象,最终意味着根据函数的参数返回不同类型的对象。例如,一个函数可能希望在一个情况下返回一个类型为 T 的对象,而在另一个情况下返回一个类型为 U 的对象;从我们的分析中,我们现在知道这些“情况”必须依赖于参数的类型中编码的信息,而不是它们的

为了在参数传递过程中保留 constexpr 性,我们必须将 constexpr 值编码到一个类型中,然后将一个不一定为 constexpr 的该类型的对象传递给函数。该函数必须是一个模板,然后可以访问该类型中编码的 constexpr 值。

待办事项
改进此解释,并讨论包装在类型中的非积分常量表达式。

副作用

让我问一个棘手的问题。以下代码有效吗?

template <typename T>
constexpr int f(T& n) { return 1; }
int n = 0;
constexpr int i = f(n);

答案是肯定的,但原因一开始可能不明显。这里发生的是,我们有一个非 constexpr 类型的 int n,以及一个接受对参数引用的 constexpr 函数 f。大多数人认为它不应该工作的原因是 n 不是 constexpr。然而,我们在 f 内部并没有对 n 做任何操作,因此实际上没有理由它不应该工作!这有点像在 constexpr 函数内部使用 throw

constexpr int sqrt(int i) {
if (i < 0) throw "i 应该是非负数";
return ...;
}
constexpr int two = sqrt(4); // ok:没有尝试抛出异常
constexpr int error = sqrt(-4); // 错误:无法在常量表达式中抛出异常

只要执行 throw 的代码路径没有被执行,调用结果就可以是一个常量表达式。类似地,我们可以在 f 内部做任何我们想做的事情,只要我们不执行需要访问参数 n 的代码路径即可,因为 n 不是一个常量表达式。

template <typename T>
constexpr int f(T& n, bool touch_n) {
if (touch_n) n + 1;
return 1;
}
int n = 0;
constexpr int i = f(n, false); // ok
constexpr int j = f(n, true); // 错误

Clang 给出的第二个调用错误信息是:

error: constexpr 变量 'j' 必须由常量表达式初始化
constexpr int j = f(n, true); // 错误
^ ~~~~~~~~~~
note: 在常量表达式中不允许读取非 const 变量 'n' in
if (touch_n) n + 1;
^

现在让我们更进一步,考虑一个更微妙的例子。以下代码有效吗?

template <typename T>
constexpr int f(T n) { return 1; }
int n = 0;
constexpr int i = f(n);

与我们最初的情况唯一的区别是,f 现在通过值而不是通过引用接受参数。然而,这造成了巨大的差异。实际上,我们现在要求编译器复制 n 并将此副本传递给 f。但是,n 不是 constexpr,因此它的值只有在运行时才知道。编译器如何在运行时才知道值的变量的编译时制作副本?当然,它做不到。事实上,Clang 给出的错误信息明确说明了正在发生的事情。

error: constexpr 变量 'i' 必须由常量表达式初始化
constexpr int i = f(n);
^ ~~~~
note: 在常量表达式中不允许读取非 const 变量 'n' in
constexpr int i = f(n);
^
待办事项
解释一下,即使副作用产生的表达式没有被访问,副作用也不允许出现在常量表达式中。