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

描述


Hana 是一个仅限头的 C++ 元编程库,适用于类型和值上的计算。它提供的功能是成熟的 Boost.MPLBoost.Fusion 库的超集。通过利用 C++11/14 的实现技术和惯用法,Hana 在编译时间和运行时性能方面比以前的元编程库更快,同时显著提高了表达能力。Hana 易于以 ad-hoc 方式进行扩展,并提供开箱即用的与 Boost.Fusion、Boost.MPL 和标准库的互操作性。

先决条件和安装


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

  1. 安装 Boost
    Hana 从 Boost 1.61.0 开始包含在 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 >= Update 7完全可用;在每次推送到 GitHub 时进行测试

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

  • 通用 Lambda
  • 通用 constexpr
  • 变量模板
  • 自动推导的返回类型
  • <type_traits> 头文件中的所有 C++14 类型特征

使用上面未列出的编译器可能会起作用,但不能保证对这些编译器的支持。有关特定平台的更多信息可以在 wiki 上找到。

支持


如果您有问题,请查阅 FAQwiki。搜索 issues 也是一个好主意。如果这些都无济于事,请随时在 Gitter 上与我们聊天,或创建一个新的 issue。 StackOverflow 上的 boost-hana 标签是询问用法问题的首选地点。如果您遇到您认为是一个 bug 的问题,请创建一个 issue。

简介


当 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}}, "");
std::array 的 Hana 适配器。
定义: 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 的逻辑值。
定义: 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;
// 使用 operator[] 而不是 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) {
// ^^ 哎呀!
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::strings,而无需语法开销。这是一个标准定义的 C++14 用户定义字面量

由于 any 包含一个 char,因此第二个函数将使用其中包含的 char 被调用。如果 any 包含一个 int 而不是 char,则第一个函数将使用其中包含的 int 被调用。当 any 的动态类型与任何提供的 case 不匹配时,将调用 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_ case 表示为映射到函数的 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::pairs)并执行逻辑以将 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");
// ...
};
}

注意我们如何在 default_ 是一个非 constexpr 对象的情况下,对与 nothing 的比较结果使用 static_assert?Hana 大胆地确保了在编译时已知的信息不会丢失到运行时,这显然是 default_ case 的存在。下一步是收集非默认 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
返回 pair 的第二个元素。
定义: second.hpp:32

unpack 接受一个 tuple 和一个函数,并使用 tuple 的内容作为参数调用该函数。unpack 的结果是调用该函数的结果。在我们的例子中,该函数是一个通用的 lambda,它又调用 process 函数。我们之所以在此处使用 unpack,是为了将 rest tuple 转换为参数包,这比 tuple 更容易进行递归处理。在继续介绍 process 函数之前,有必要解释一下 second(*default_) 的作用。如前所述,default_ 是一个可选值。与 std::optional 类似,这个可选值重载了解引用运算符(以及箭头运算符),以允许访问 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 要处理的情况,以及一个用于只有 default case 的基本情况重载。正如我们所料,基本情况只是调用默认函数并返回其结果。另一个重载稍微有趣一些。首先,我们检索与该 case 关联的类型并将其存储在 T 中。这种 decltype(...)::type 的写法可能看起来很复杂,但实际上很简单。大致来说,它接受一个表示为对象的类型(type<T>)并将其提取回类型级别(T)。详细信息在 类型级别计算 部分进行了解释。然后,我们比较 any 的动态类型是否与此 case 匹配,如果匹配,我们调用与该 case 关联的函数,并将 any 强制转换为正确的类型。否则,我们只需使用剩余的 cases 递归调用 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
可选 表示一个可选值,即可以为空的值。这有点像 std::optional,只不过空值在编译时已知。
map 无序关联数组,将(唯一的)编译时实体映射到任意对象。这对于异构对象来说就像 std::unordered_map
集合 无序容器,包含必须是编译时实体的唯一键。这对于异构对象来说就像 std::unordered_set
范围 容器,表示编译时数字的间隔。这类似于 std::integer_sequence,但更好。
pair 容器,包含两个异构对象。类似于 std::pair,但会压缩空类型的存储。
字符串 编译时字符串。
type 表示 C++ 类型的容器。这是类型和值统一的根源,对于 MPL 风格的计算(类型级别计算)很有意义。
integral_constant 表示编译时数字。这与 std::integral_constant 非常相似,不同之处在于 hana::integral_constant 还定义了运算符和更多的语法糖。
lazy 封装了惰性值或计算。
basic_tuple hana::tuple 的精简版。不符合标准,但编译时效率更高。
函数描述
adjust(sequence, value, f) 将函数应用于序列中与给定值相等的每个元素,并返回结果。
adjust_if(sequence, predicate, f) 将函数应用于满足某些谓词的序列中的每个元素,并返回结果。
{all,any,none}(sequence) 返回序列中的所有/任何/无元素是否为真值。
{all,any,none}_of(sequence, predicate) 返回序列中的所有/任何/无元素是否满足某个谓词。
append(sequence, value) 将一个元素附加到序列。
at(sequence, index) 返回序列的第 n 个元素。索引必须是 IntegralConstant
back(sequence) 返回非空序列的最后一个元素。
concat(sequence1, sequence2) 连接两个序列。
contains(sequence, value) 返回序列是否包含给定对象。
count(sequence, value) 返回与给定值相等的元素的数量。
count_if(sequence, predicate) 返回满足谓词的元素的数量。
drop_front(sequence[, n]) 从序列中删除前 n 个元素,或者如果 length(sequence) <= n 则删除整个序列。n 必须是 IntegralConstant。如果未提供 n,则默认为 1。
drop_front_exactly(sequence[, n]) 从序列中删除前 n 个元素。n 必须是 IntegralConstant,并且序列至少包含 n 个元素。如果未提供 n,则默认为 1。
drop_back(sequence[, n]) 从序列中删除后 n 个元素,或者如果 length(sequence) <= n 则删除整个序列。n 必须是 IntegralConstant。如果未提供 n,则默认为 1。
drop_while(sequence, predicate) 在满足谓词时从序列中删除元素。谓词必须返回 IntegralConstant
fill(sequence, value) 用某个值替换序列的所有元素。
filter(sequence, predicate) 移除所有不满足谓词的元素。谓词必须返回 IntegralConstant
find(sequence, value) 查找序列中与某个值相等的第一个元素,并返回 just 它,否则返回 nothing。请参阅 hana::optional
find_if(sequence, predicate) 查找序列中满足谓词的第一个元素,并返回 just 它,否则返回 nothing。请参阅 hana::optional
flatten(sequence) 展平一个序列的序列,有点像 std::tuple_cat
fold_left(sequence[, state], f) 从左侧累积序列的元素,可以选择提供初始状态。
fold_right(sequence[, state], f) 从右侧累积序列的元素,可以选择提供初始状态。
fold(sequence[, state], f) 等同于 fold_left;为与 Boost.MPL 和 Boost.Fusion 保持一致而提供。
for_each(sequence, f) 对序列中的每个元素调用一个函数。返回 void
front(sequence) 返回非空序列的第一个元素。
group(sequence[, predicate]) 对序列中满足(或不满足)某个谓词的相邻元素进行分组。谓词默认为相等,在这种情况下,元素必须是 Comparable
index_if(sequence, predicate) 查找序列中满足谓词的第一个元素的索引,并返回 just 它,否则返回 nothing。请参阅 hana::optional
insert(sequence, index, element) 在给定索引处插入一个元素。索引必须是 IntegralConstant
insert_range(sequence, index, elements) 在给定索引处插入一个元素序列。索引必须是 IntegralConstant
is_empty(sequence) IntegralConstant 的形式返回序列是否为空。
length(sequence) IntegralConstant 的形式返回序列的长度。
lexicographical_compare(sequence1, sequence2[, predicate]) 按字典顺序比较两个序列,可以选择使用自定义谓词,默认使用 hana::less
maximum(sequence[, predicate]) 返回序列中的最大元素,可以选择根据谓词进行。如果没有提供谓词,元素必须是 Orderable
minimum(sequence[, predicate]) 返回序列中的最小元素,可以选择根据谓词进行。如果没有提供谓词,元素必须是 Orderable
partition(sequence, predicate) 将序列划分为满足某个谓词的元素对和不满足该谓词的元素对。
prepend(sequence, value) 将一个元素添加到序列的开头。
remove(sequence, value) 移除所有等于给定值的元素。
remove_at(sequence, index) 移除给定索引处的元素。索引必须是 IntegralConstant
remove_if(sequence, predicate) 移除所有满足谓词的元素。谓词必须返回 IntegralConstant
remove_range(sequence, from, to) 移除给定 [from, to) 半开区间内的索引处的元素。索引必须是 IntegralConstant
replace(sequence, oldval, newval) 将序列中与某个值相等的元素替换为另一个值。
replace_if(sequence, predicate, newval) 将序列中满足某个谓词的元素替换为某个值。
reverse(sequence) 反转序列中元素的顺序。
reverse_fold(sequence[, state], f) 等同于 fold_right;为与 Boost.MPL 和 Boost.Fusion 保持一致而提供。
size(sequence) 等同于 length;为与 C++ 标准库保持一致而提供。
slice(sequence, indices) 返回一个新序列,其中包含原始序列中给定索引处的元素。
slice_c<from, to>(sequence) 返回一个新序列,其中包含原始序列中 [from, to) 区间内的索引处的元素。
sort(sequence[, predicate]) (稳定地)对序列中的元素进行排序,可以选择根据谓词进行。如果没有提供谓词,元素必须是 Orderable
take_back(sequence, number) 取序列的后 n 个元素,或者如果 length(sequence) <= n 则取整个序列。n 必须是 IntegralConstant
take_front(sequence, number) 取序列的前 n 个元素,或者如果 length(sequence) <= n 则取整个序列。n 必须是 IntegralConstant
take_while(sequence, predicate) 在满足某个谓词时取序列中的元素,并返回它们。
transform(sequence, f) 将函数应用于序列中的每个元素并返回结果。
unique(sequence[, predicate]) 移除序列中所有连续的重复项。谓词默认为相等,在这种情况下,元素必须是 Comparable
unpack(sequence, f) 使用序列的内容调用函数。等同于 f(x1, ..., xN)
zip(s1, ..., sN) N 个序列压缩成一个元组序列。所有序列的长度必须相同。
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 宏一样,它们基本上会检查它们接收到的条件是否得到满足。然而,在异构编程的上下文中,有些信息在编译时已知,而另一些信息仅在运行时已知。所使用的断言的确切类型告诉您所断言的条件是可以在编译时知道的,还是必须在运行时计算,这是一条非常有价值的信息。以下是教程中使用的不同类型的断言及其特殊性的简要说明。有关更多详细信息,您应该查阅 断言参考

assertion描述
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_constant 视为对象而不是类型级别的实体呢?要了解原因,请考虑我们如何实现与之前相同的后继函数。

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_constant 进行编译时计算。此类操作的一个典型示例是 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
Monoid 上的结合二元操作。
定义: plus.hpp:47

通过将 integral_constant 视为对象而不是类型,从元函数到函数的转换非常直接。

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_constant,它们像上面所示一样定义了算术运算符。Hana 还提供了变量模板来轻松创建不同类型的 integral_constantint_clong_cbool_c 等。这允许您省略这些对象通常需要的尾部 {} 花括号。当然,_c 后缀也可用;它是 hana::literals 命名空间的一部分,在使用它之前必须将其导入到您的命名空间中。

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

这样,您就可以进行编译时算术,而不必苦苦应对笨拙的类型级别怪癖,而且您的同事现在将能够理解正在发生的事情。

示例:欧几里得距离

为了说明它变得有多好,让我们实现一个函数,在编译时计算 2D 欧几里得距离。回顾一下,2D 平面上两个点的欧几里得距离由下式给出:

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

首先,这是使用类型级别方法(使用 MPL)的外观。

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); // 同一个函数有效!
#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_c and hana::false_c 只是分别代表编译时真值和编译时假值的布尔 IntegralConstant

这里,one_two_three 等于 123,而 hello 等于 "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 的主体进行类型检查。由于当条件不满足时(hana::if_ 会处理这种情况),有错误(erroneous)的 lambda 永远不会被调用,因此不会对可能导致失败的 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 的参考文档来深入了解。

为什么只到这里?

为什么我们要将自己限制在算术运算和分支上?当你开始将 IntegralConstants 视为对象时,用更多通常有用的函数来增强它们的接口是很有意义的。例如,Hana 的 IntegralConstants 定义了一个 times 成员函数,可用于调用函数一定次数,这对于循环展开尤其有用。

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

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

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

IntegralConstants 的另一个很好的用途是为索引异构序列定义美观的运算符。而 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 的值序列,顺序不确定。
定义: map.hpp:199

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

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

这是关于 IntegralConstants 部分的结束。本节介绍了 Hana 新式元编程的背后理念;如果你喜欢到目前为止看到的内容,那么本教程的其余部分应该会让你感觉宾至如归。

类型计算


此时,如果你有兴趣进行类似 MPL 的类型级计算,你可能会想 Hana 将如何帮助你。不要绝望。Hana 提供了一种方法,通过将类型表示为值来执行类型级计算,就像我们将编译时数字表示为值一样。这是一种全新的元编程方法,如果你想精通 Hana,应该暂时放下你旧的 MPL 习惯。

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

类型作为对象

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

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<...> 只是一个对象,我们可以将其存储在像 tuple 这样的异构序列中,我们可以移动它,可以将其传递给函数(或从函数返回),并且基本上可以做任何需要对象的事情。

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;
};
注意
这等同于使 basic_type 成为 MPL 意义上的元函数。

这样,我们就可以使用 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, "");

正如你所见,3 步过程的语法噪音几乎完全被其余计算所隐藏。

通用提升过程

我们在函数形式中介绍的第一个类型级计算看起来像:

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>;
}

然而,这个实现强调了我们实际上是在模拟一个现有的元函数,并将其简单地表示为一个函数。换句话说,我们通过创建自己的 add_pointer 函数来将一个元函数(std::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 Metafunction,如下所示:

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 风格的元函数提升为 Metafunction。
定义: 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_
将模板提升为 Metafunction。
定义: type.hpp:406

Hana 提供了一个名为 hana::template_ 的通用模板提升器,并且还提供了一个名为 hana::metafunction_class 的通用 MPL MetafunctionClasses 提升器。这使我们能够统一地将“旧式”类型级计算表示为函数,因此使用经典类型级元编程库编写的任何代码几乎都可以轻松地与 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_constants 的桥接头文件(<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
Returns whether all the keys of the structure satisfy the predicate.
Definition: 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() is a valid expression)
return obj.toString();
else
return "toString not defined";
}
注意
此技术的大多数用例将在标准的未来修订版中通过concepts lite得到解决,但仍将有一些情况,快速而粗略的检查比创建完整的 concept 更方便。

我们如何以通用方式实现上面对 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 not defined";
}
注意
当然,此实现实际上将不起作用,因为 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 not defined";
}

更简洁了,对吧?但是,正如我们之前所说,这个实现实际上将不起作用,因为 if 的两个分支总是必须被编译,而不管 obj 是否有 toString 方法。有几种可能的选择,但最经典的选项是使用 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 not defined"; }
注意
我们利用 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 not defined"; }
)(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 的参数很重要,否则将要求 xCopyConstructible,这并非我们想要检查的。这种方法很简单,并且在对象可用时最方便。但是,当检查器旨在与没有对象一起使用时,以下备用实现可能更合适。

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(它是一个对象,但仍然代表一个类型)。然后,我们使用 hana::traits::declval 提升元函数来自 <boost/hana/traits.hpp> 头文件来创建由 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
//^^^^^^^^ needed because of the dependent context
> { });
struct Foo { struct member; /* not defined! */ };
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
// ^^^^^^^^ needed because of the dependent context
>) { });
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>));

模板特化

检查模板特化是否有效也可以做到,但现在我们将 template_<...> 传递给 is_valid 而不是 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 是一个函数,它接受两个输入,如果调用格式正确,则返回通用 lambda 的结果(just),否则返回 nothingjust(...)nothing 都属于一种称为 hana::optional 的容器类型,它本质上是一个编译时 std::optional。总而言之,maybe_add 在概念上等同于以下返回 std::optional 的函数,只是检查是在编译时完成的。

auto maybe_add = [](auto x, auto y) {
if (x + y is well formed)
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");
}

首先,我们使用 sfinae 函数包装 toString。因此,maybe_toString 是一个函数,它要么返回 just(x.toString())(如果格式正确),要么返回 nothing(否则)。其次,我们使用 .value_or() 函数从容器中提取可选值。如果可选值为 nothing,则 .value_or() 返回给它的默认值;否则,它返回 just 中的值(此处为 x.toString())。将 SFINAE 视为可能失败的计算的特例是一种非常干净且强大的方式,特别是由于 sfinae 的函数可以通过 hana::optional Monad 组合,这将在参考文档中介绍。

自省用户定义类型

您是否曾经想过迭代用户定义类型的成员?本节的目标是向您展示 Hana 如何轻松完成此操作。为了能够处理用户定义类型,Hana 定义了 Struct concept。一旦用户定义类型成为该 concept 的模型,就可以迭代该类型对象的成员并查询其他有用信息。要将用户定义类型转换为 Struct,有几种选项可用。首先,您可以使用 BOOST_HANA_DEFINE_STRUCT 宏定义用户定义类型的成员。

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

此宏定义了具有给定类型的两个成员(nameage)。然后,它在 Person::hana 嵌套 struct 中定义了一些样板代码,这是使 Person 成为 Struct concept 的模型所必需的。未定义构造函数(因此保留了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 是一个由对组成的序列一样,其中一对的第一个元素是与成员关联的键,第二个元素是成员本身。当通过 BOOST_HANA_DEFINE_STRUCT 宏定义 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 a value at a given index in a sequence.
Definition: 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 上调用关联的函数。在继续使用这些自省功能的具体示例之前,还应提到 structs 可以在不使用宏的情况下进行适配。这种定义 Structs 的高级接口可用于指定非编译时字符串的键。高级接口在 Struct concept 的文档中有介绍。

示例:生成 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(我们假设如此),这会产生将序列中的所有字符串连接成一个大字符串的效果。现在,让我们定义如何打印 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
Returns a Sequence containing the name of the members of the data structure.
Definition: 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 = { /* huge vector of ints */ };
std::vector<std::string> strings = { /* huge vector of 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 循环生成。换句话说,以下内容没有意义:

for (??? i = 0_c; i < xs.size(); ++i) {
f(xs[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)>{};
});
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
false
>;

当然,上面的代码不能按原样工作,因为我们在调用 pred 的同时,它必须是常量表达式,但 pred 是一个 lambda(并且 lambda 不能在常量表达式中调用)。然而,这些对象中是否有任何一个的类型是整数类型,在编译时是清楚的,因此我们期望计算答案只涉及编译时计算。事实上,Hana 正是这样做的,并且上面的算法被展开为类似以下内容:

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<
decltype(pred(xs[0_c]))::value ? true
decltype(pred(xs[1_c]))::value ? true
decltype(pred(xs[2_c]))::value ? true
false
>;
注意
正如您将从下一节关于跨阶段计算的内容中推断出的那样,any_of 的实现实际上必须更通用。然而,这个“善意的谎言”对教学目的来说是完美的。

正如您所见,谓词甚至从未被执行;只使用了它对特定对象的结果类型。关于评估顺序,请考虑 transform 算法,它(对于元组)定义为:

hana::transform(hana::make_tuple(x1, ..., xn), f) == hana::make_tuple(f(x1), ..., f(xn))

由于 make_tuple 是一个函数,并且函数参数的求值顺序未指定,因此对元组的每个元素调用 f 的顺序也未指定。如果坚持使用纯函数,一切都会正常工作,结果代码通常更容易理解。但是,一些特殊的算法,如 for_each,确实期望非纯函数,并且它们保证了求值顺序。事实上,一个只接受纯函数的 for_each 算法几乎是无用的。当算法可以接受非纯函数或保证某种求值顺序时,该算法的文档会明确提及。但是,默认情况下,不得假定任何保证。

跨阶段算法

本节介绍跨阶段计算和算法的概念。事实上,我们在 快速入门 中已经使用过跨阶段算法,例如使用 filter,但当时我们没有精确地解释发生了什么。但在介绍跨阶段算法之前,让我们定义我们所说的跨阶段。我们这里所说的阶段是指程序的编译和执行。在 C++ 中,与大多数静态类型语言一样,编译时和运行时之间存在清晰的区别;这称为阶段区分。当我们谈论跨阶段计算时,我们指的是一种在这些阶段之间以某种方式执行的计算;即,一部分在编译时执行,一部分在运行时执行。

正如我们在之前的示例中看到的,一些函数即使在对运行时值调用时也能返回可用于编译时的内容。例如,让我们考虑应用于非 constexpr 容器的 length 函数。

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"});
// ^^^^^^^ 不是编译时值
BOOST_HANA_CONSTANT_CHECK(hana::length(animals) == hana::size_c<3>);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 编译时断言
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"}};
static_assert(std::tuple_size<decltype(xs)>::value == 3u, "");
std::元组的适配器。
定义: tuple.hpp:49

由于元组的大小编码在其类型中,因此它始终在编译时可用,而不管元组是否为 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>{};
});
注意
为了使其正常工作,必须包含 <boost/hana/ext/std/integral_constant.hpp> 中包含的 std::integral_constant 的外部适配器。

首先,由于谓词仅查询元组每个元素的类型信息,因此其结果显然可以在编译时得知。由于元组的元素数量在编译时也是已知的,因此算法的整体结果理论上可以在编译时得知。更精确地说,发生的情况是谓词返回一个已初始化的 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] 元组。但是,编译器无法知道这一点,因为谓词的结果是运行时计算的结果,而这发生在编译器完成工作之后。因此,编译器没有足够的信息来确定算法的返回类型。然而,我们可以用任何结果在编译时可用的谓词来过滤相同的序列:

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 issue;这可能是一个性能回归。
警告
截至撰写本文时,并非 Hana 的所有容器都已优化。实现 Hana 本身就是一个巨大的挑战,容器最初是 naively 编写的,现在正在进行严格优化。特别是,关联容器(hana::maphana::set)由于其 naively 的实现而具有相当糟糕的编译时行为,并且在某些情况下它们的运行时行为似乎也有问题。改进这种情况在 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 添加惰性视图。如果此功能对您很重要,请通过评论 此问题让我们知道。

第二类重要的算法是折叠(folds)。折叠可用于实现许多其他算法,如 count_ifminimum 等。因此,折叠算法良好的编译时性能可以确保派生算法的良好编译时性能,这就是为什么我们只在这里展示折叠的原因。另请注意,所有非单子折叠变体在编译时方面都有些等效,因此我们只展示左折叠。下面的图表展示了将 fold_left 应用于一个包含 n 个元素的序列的编译时性能。x 轴表示序列中的元素数量,y 轴表示编译时间(以秒为单位)。用于折叠的函数是一个什么都不做的虚拟函数。在实际代码中,您可能会使用非平凡的操作进行折叠,因此曲线会比这更差。但是,这些是微基准测试,因此它们只显示算法本身的性能。

我们在这里展示的第三个也是最后一个算法是 find_if 算法。这个算法很难高效地实现,因为它需要停止在第一个满足给定谓词的元素上。出于同样的原因,现代技术在这里并没有真正帮助我们,因此该算法构成了 Hana 实现质量的一个很好的测试,而不考虑 C++14 提供的免费午餐。

正如您所看到的,Hana 的性能优于 Fusion,并且与 MPL 相当,但 Hana 的 find_if 可以与值一起使用,而 MPL 的则不行。这结束了关于编译时性能的部分。如果您想查看我们尚未在此处介绍的算法的性能,Metabench 项目提供了 Hana 大部分算法的编译时基准测试。

运行时性能

Hana 的设计目标是实现非常高效的运行时性能。但在我们深入细节之前,让我们澄清一件事。Hana 是一个元编程库,它允许同时操作类型和值,因此讨论运行时性能有时甚至没有意义。事实上,对于类型级别的计算和 IntegralConstant 上的计算,运行时性能根本不是问题,因为计算的结果包含在一个类型中,而类型是纯粹的编译时实体。换句话说,这些计算只涉及编译时工作,甚至不会生成任何代码来在运行时执行这些计算。唯一有意义地讨论运行时性能的情况是操作异构容器和算法中的运行时值,因为这是编译器必须生成一些运行时代码的唯一情况。因此,在本节的其余部分,我们只研究此类计算。

就像我们为编译时基准测试所做的那样,用于衡量 Hana 运行时性能的方法是数据驱动的,而不是分析的。换句话说,我们不尝试通过计算算法执行的基本操作数量与输入大小的关系来确定其复杂性,而是直接对最有趣的情况进行测量并观察其行为。这样做有几个原因。首先,我们不期望 Hana 的算法在大型输入上被调用,因为这些算法处理的是异构序列,其长度必须在编译时已知。例如,如果您尝试在包含 100k 个元素的序列上调用 find_if 算法,您的编译器在尝试生成该算法的代码时会直接崩溃。因此,算法不能在非常大的输入上调用,分析方法就会失去很多吸引力。其次,处理器已经演变成相当复杂的“野兽”,您能够实际榨取的性能实际上是由比算法执行的步骤数更多的因素控制的。例如,糟糕的缓存行为或分支预测错误可能会将一个理论上高效的算法变成一个慢速程序,尤其是在输入较小的情况下。由于 Hana 会导致大量展开,因此必须更加仔细地考虑这些因素,任何分析方法可能只会让我们认为我们很高效。相反,我们需要硬数据和漂亮的图表来展示它!

注意
与编译时性能一样,我们正在强制评估一些通常是惰性的 Fusion 算法。同样,根据计算的复杂性,惰性算法可能会生成显着不同的代码或使用不同的设计,无论好坏。在查看这些运行时基准测试时,请牢记这一点。如果性能对您的应用程序至关重要,您应该在从 Fusion 切换到 Hana 之前之后进行性能剖析。如果 Hana 的性能变差,请告知我们;我们将修复它!

我们将要基准测试的方面有几个。首先,我们显然想基准测试算法的执行时间。其次,由于整个库都使用了按值语义,我们还想确保复制的数据量最少。最后,我们想确保使用 Hana 不会因为展开而导致过多的代码膨胀,正如在关于算法的部分中所解释的那样。

就像我们只研究了几个关键算法的编译时性能一样,我们将专注于几个算法的运行时性能。对于每个基准测试的方面,我们将比较不同库实现的算法。我们的目标是至少与 Boost.Fusion 一样高效,Boost.Fusion 在运行时性能方面接近最优。作为比较,我们还展示了在运行时序列上执行的相同算法,以及长度在编译时已知但其 transform 算法不使用显式循环展开的序列。这里呈现的所有基准测试都在Release CMake 配置中完成,该配置负责传递适当的优化标志(通常是 -O3)。让我们从下面的图表开始,该图表显示了转换不同类型序列所需的执行时间

注意
请记住,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 vector 与 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 来查询 vector 的大小,然后将其与某个值进行比较。我也可以使用 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 核心的高级概述。这个核心基于标签(tag)的概念,该概念借自 Boost.Fusion 和 Boost.MPL 库,但被 Hana 进一步发展。这些标签随后用于多种目的,例如算法自定义、文档分组、改进错误消息以及将容器转换为其他容器。由于其模块化设计,Hana 可以非常轻松地以一种临时的方式进行扩展。事实上,库的所有功能都通过一种临时自定义机制提供,该机制在此处进行了解释。

标签

异构编程本质上是使用具有不同对象的编程。然而,很明显,某些对象族,尽管具有不同的表示(C++ 类型),但彼此密切相关。例如,std::integral_constant<int, n> 类型对于每个不同的 n 都是不同的,但概念上它们都代表同一件事;一个编译时数字。std::integral_constant<int, 1>{}std::integral_constant<int, 2>{} 具有不同类型的事实仅仅是因为我们使用它们的类型来编码这些对象的。事实上,在操作一个 std::integral_constant<int, ...> 序列时,您很可能将其视为一个虚构的 integral_constant 类型的同质序列,忽略对象的实际类型,并假装它们都只是具有不同值的 integral_constant

为了反映这种现实,Hana 提供了代表其异构容器和其他编译时实体的标签。例如,Hana 的所有 integral_constant<int, ...> 都具有不同的类型,但它们都共享相同的标签 integral_constant_tag<int>。这使得程序员可以从该单个类型的角度进行思考,而不是尝试从对象的实际类型的角度进行思考。具体来说,标签被实现为空 struct。为了使它们脱颖而出,Hana 采用在这些标签名称后添加 _tag 后缀的约定。

注意
类型 T 的对象的标签可以通过 tag_of<T>::type 或等效地 tag_of_t<T> 来获取。

标签是普通 C++ 类型的扩展。事实上,默认情况下,类型 T 的标签是 T 本身,并且库的核心被设计为在这些情况下工作。例如,hana::make 期望一个标签或一个实际类型;如果您给它一个类型 T,它会做合乎逻辑的事情,并使用您传递给它的参数构造一个类型为 T 的对象。但是,如果您给它一个标签,您应该为该标签特化 make 并提供自己的实现,如下所述。由于标签是对常规类型的扩展,我们最终主要从标签的角度而不是常规类型的角度进行推理,并且文档有时会互换使用类型数据类型标签这些词。

标签分派

标签分派是一种泛型编程技术,用于根据传递给函数的参数的类型来选择函数的正确实现。覆盖函数行为的通常机制是重载。不幸的是,在处理具有不同基本模板的类型族,或者当模板参数的种类未知时(是类型还是非类型模板参数?),这种机制并不总是方便。例如,考虑尝试为所有 Boost.Fusion vector 重载一个函数

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

如果您了解 Boost.Fusion,那么您可能知道这行不通。这是因为 Boost.Fusion vector 不一定是 boost::fusion::vector 模板的特化。Fusion vector 也存在编号形式,它们都是不同类型的

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++ 模板目前缺少这种约束其模板参数的能力,但一项名为concepts的语言特性正在推出,目标是解决这个问题。

考虑到类似的事情,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++ concepts,然后可以与 hana::when 结合使用以实现极大的表达能力。

C++ Concepts 的模拟

Hana 中 Concepts 的实现非常简单。其核心是一个模板 struct,它继承自一个布尔 integral_constant,该常量表示给定类型是否为 Concept 的模型

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

然后,可以通过查看 Concept<T>::value 来测试一个类型 T 是否是 Concept 的模型。很简单,对吧?现在,虽然检查的方式不一定有什么特别之处,但本节的其余部分将解释 Hana 中通常是如何实现的,以及它如何与标签分派交互。然后,您应该能够根据需要定义自己的概念,或者至少更好地理解 Hana 内部的工作原理。

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

template <typename T>
struct Printable
: hana::integral_constant<bool, whether print_impl<tag of T> is defined>
{ };

为了知道 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> /* don't inherit from special_base_class */ {
// ... implementation ...
};
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 核心的内容到此结束。

头文件组织


该库的设计是模块化的,同时保持了获得基本功能所需的头文件数量合理地少。库的结构也故意保持简单,因为我们都喜欢简单。下面是对头文件组织的总体概述。如果需要更多细节,还可以从左侧面板(在 Headers 标签下)获取该库提供的所有头文件列表。

  • 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> // still required to create a 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 元编程入门(幻灯片/视频
  • 2014 年 C++Now 关于 MPL11 库的演讲。Hana 最初就是从这里开始的。(幻灯片/视频
  • 我的学士论文使用范畴论对 C++ 元编程进行了形式化。论文可在 此处 获取,相关的演示文稿幻灯片可在 此处 获取。不幸的是,两者都只有法语版本。

我发表的所有关于 Hana 和元编程的演讲的完整列表可在 此处 找到。还有一个 Hana 文档的非官方中文翻译版本,可在 此处 获取。

使用 Hana 的项目

使用 Hana 的项目越来越多。了解它们可以帮助您更好地使用该库。以下是其中一些项目(如果您希望您的项目在此列出,请打开一个 issue)。

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

至此,文档的教程部分结束。希望您喜欢使用该库,并请考虑贡献使其更上一层楼!

– Louis

使用参考文档


与大多数泛型库一样,Hana 中的算法是根据它们所属的概念(FoldableIterableSearchableSequence 等)进行文档化的。然后,不同的容器会单独进行文档化,并且它们所模拟的概念也会在对应的页面上进行文档化。某些容器所模拟的概念定义了可以与该容器一起使用的算法。

更具体地说,参考文档(可在左侧菜单中找到)的结构如下:

  • Core
    核心模块的文档,其中包含创建概念、数据类型和相关实用程序所需的一切。如果您需要扩展库,这会很有用,否则您可能可以忽略。
  • 概念
    库中所有概念的文档。每个概念:
    • 记录了必须绝对实现的函数才能成为该概念的模型。必须提供的函数集称为最小完整定义
    • 记录了任何该概念的模型必须满足的语义约束。这些约束通常称为定律,并以半形式化的数学语言表示。当然,这些定律无法自动检查,但您仍应确保您满足它们。
    • 记录了它所精炼(refines)的概念(如果有)。有时,一个概念足够强大,可以提供一个它所精炼的概念的模型,或者至少是其某些相关函数的实现。在这种情况下,该概念将记录它提供了哪些精炼概念的函数以及如何提供。此外,有时一个精炼概念的模型是唯一的,在这种情况下,它可以被自动提供。当这种情况发生时,它会被记录下来,但您无需做任何特殊操作即可获得该模型。
  • 数据类型
    库中所有数据结构的文档。每个数据结构都记录了它所模拟的概念以及如何模拟。它还记录了与其关联但与任何概念无关的方法,例如 optionalmaybe
  • Functional
    通用函数对象,在纯函数式环境中通常很有用。目前它们不与任何概念或容器绑定。
  • 外部适配器
    外部库适配器的文档。这些适配器被记录得就像它们是 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 项目。
  • 在 2015 年冬季,Boost Steering committee 为我解锁了一笔资金,用于在上一年的 GSoC 基础上继续进行 Hana 的工作。
  • 几位 C++Now 的与会者和 Boost 邮件列表的成员,就该项目进行了富有洞察力的对话、评论和提问。

术语表


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

  • forwarded(x)

    表示对象被优化转发。这意味着,如果 x 是一个参数,它将被 std::forward,如果它是一个捕获的变量,当包含它的 lambda 是一个右值时,它将被移动。

    另外请注意,当 x 可以被移动时,语句 return forwarded(x); 在返回类型为 decltype(auto) 的函数中,并不意味着会返回一个 x 的右值引用,这会创建悬空引用。而是意味着 x 按值返回,该值是通过 std::forwarded 的 x 构建的。

  • perfect-capture

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

  • tag-dispatched

    这意味着文档化的函数使用了标签分派,因此确切的实现取决于与函数关联的概念的模型。

  • implementation-defined

    这表示一个实体(通常是类型)的确切实现不应被用户依赖。特别是,这意味着除了文档中明确写出的内容之外,不能做任何假设。通常,实现定义实体满足的概念会被记录下来,因为否则您将无法使用它。具体来说,对实现定义实体的过多假设可能不会立即导致问题,但当您更新到新版本的 Hana 时,很可能会导致代码破坏。

设计理念/常见问题解答


本节记录了一些设计选择的理由。它也作为一些(不是那么)常见问题的解答。如果您认为应该向此列表添加内容,请提交一个 GitHub issue,我们将考虑改进文档或在此处添加问题。

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

这样做有几个原因。首先,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。然而,结果的 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) {
返回 ...;
});
// 比这个更好
algorithm([](auto x) {
返回 ...;
}, container);

为什么使用标签分派?

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

选择标签分派(tag-dispatching)而不是其他两层技术,有几个原因。首先,必须明确声明某个标签(tag)如何成为某个概念(concept)的模型,这使得用户有责任确保概念的语义要求得到满足。其次,在检查一个类型是否是某个概念的模型时,我们基本上是检查一些关键函数是否已实现。特别是,我们检查该概念的最小完整定义(minimal complete definition)中的函数是否已实现。例如,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 时用用户提供的对象填充最短的序列。由于没有要求所有要 zip 的序列都具有相似类型的元素,因此无法在所有情况下提供一个单一的、一致的填充对象。应该提供一个填充对象元组,但我认为目前这可能过于复杂,不值得。如果你需要此功能,请打开一个 GitHub issue。

为什么概念(concepts)不是 constexpr 函数?

由于 C++ 概念提案将概念映射到布尔 constexpr 函数,因此 Hana 将其概念定义为这样是有意义的,而不是作为带有嵌套 ::value 的结构体。事实上,这是最初的选择,但后来不得不修改,因为模板函数有一个使其不够灵活的限制。具体来说,模板函数无法传递给高阶元函数(higher-order metafunction)。换句话说,无法编写以下代码:

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 中引入了泛化的常量表达式之后,这一点更加明显。然而,能够操作异构对象(heterogeneous objects)的关键在于理解这个界限,并能够随心所欲地跨越它。本节的目标是理清 constexpr 的问题:理解它能解决什么问题,又不能解决什么问题。本节涵盖了关于常量表达式的高级概念;只有对 constexpr 有深入理解的读者才应尝试阅读。

Constexpr 的剥离

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

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

答案是“否”,Clang 给出如下错误:

error: static_assert expression is not an integral constant expression
static_assert(t == 1, "");
^~~~~~
constexpr auto integral
将一个元函数(Metafunction)转换为一个接受类型并返回默认构造对象的函数。
定义: type.hpp:513

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

// 在这里,编译器应该为 f<int> 生成代码,并将
// 该代码的地址存储到 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);

在这个假设的场景中,编译器将知道 tf 函数体内的常量表达式,并且 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);

答案是“”,但原因可能并不显而易见。这里发生的是,我们有一个非 constexprint n,以及一个接受其参数引用的 constexpr 函数 f。大多数人认为它不应该工作的原因是 n 不是 constexpr。然而,我们在 f 中并没有对 n 做任何事情,所以没有实际理由不让它工作!这有点像在 constexpr 函数中 throw

constexpr int sqrt(int i) {
if (i < 0) throw "i should be non-negative";
返回 ...;
}
constexpr int two = sqrt(4); // ok: 没有尝试抛出异常
constexpr int error = sqrt(-4); // error: 不能在常量表达式中抛出异常

只要不执行出现 throw 的代码路径,调用的结果就可以是常量表达式。同样,我们可以在 f 中做任何我们想做的事情,只要我们不执行需要访问其参数 n 的代码路径,而 n 不是常量表达式。

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

Clang 对第二个调用的错误是:

error: constexpr variable 'j' must be initialized by a constant expression
constexpr int j = f(n, true); // error
^ ~~~~~~~~~~
note: read of non-const variable 'n' is not allowed in a constant expression
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,所以它的值只能在运行时知道。编译器如何在编译时(compile-time)复制一个值仅在运行时才知道的变量?当然不能。事实上,Clang 给出的错误消息明确说明了正在发生的情况:

error: constexpr variable 'i' must be initialized by a constant expression
constexpr int i = f(n);
^ ~~~~
note: read of non-const variable 'n' is not allowed in a constant expression
constexpr int i = f(n);
^
待办事项
解释即使它们产生的值未被访问,副作用也可能不会出现在常量表达式内部。