内省

优质
小牛编辑
128浏览
2023-12-01

我们将在这里讨论静态内省,静态内省是程序在编译时检查对象类型的能力。 换句话说,它是一个在编译时与类型交互的编程接口。 例如,你曾经想检查一些未知类型是否有一个名为foo的成员? 或者在某些时候你需要迭代结构的成员?

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

如果你在你的生活中写了一些模板,你遇到的第一个问题是检查一个成员的机会很高。 此外,任何人试图实现对象序列化,甚至只是格式化输出就会产生第二个问题。在大多数动态语言如PythonRubyJavaScript中,这些问题都是能完全解决的,程序员每天都使用内省来简化很多任务。 然而,作为一个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概念来解决,但是仍然存在这样的情况,其中快速、脏检查比创建完全概括的概念更方便。

我们如何以通用方式实现对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) {
  if (has_toString<T>::value)
    return obj.toString();
  else
    return "toString not defined";
}

注意: 当然,这个实现不会真正工作,因为if语句的两个分支都将被编译。 如果obj没有toString方法,if分支的编译将失败。 我们将在稍后解决这个问题。

代替上面的SFINAE技巧,Hana提供了一个is_valid函数,可以与C++14通用lambdas组合获得一个更干净的实现:

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

这里我们有一个函数对象has_toString返回给定的表达式是否对我们传递给它的参数有效。 结果作为IntegralConstant返回,因此constexpr-ness在这里不是一个问题,因为函数的结果表示为一个类型。 现在,除了代码更少(这是一个单行!),意图也更清晰外,还有其他好处是,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的参数,否则将需要x是复制构造的,这不是我们试图检查的。 这种方法很简单,当对象可用时最方便。 然而,当检查器旨在不使用对象时,以下替代实现可以更好地适合:

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

这个有效性检查器不同于我们之前看到的,因为通用的lambda不再期望一个通常的对象了; 它现在期待一个类型(它是一个对象,但仍然代表一个类型)。 然后,我们使用来自<boost/hana/traits.hpp>头文件的hana::traits::declval提升的元函数来创建由t表示的类型的右值,然后我们可以使用它来检查非静态成员。 最后,不是将实际对象传递给has_member(像Foo{}Bar{}),我们现在传递一个type_c<...>。 这个实现是没有对象时的理想选择。

静态成员

检查静态成员是很容易的,并且Hana提供完整性支持:

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

再次,我们期望一个类型被传递给检查器。 在通用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>));

SFINAE控制

只有表达式形式良好时才做某事是C++中非常常见的模式。 实际上,optionalToString函数只是以下模式的一个实例,这是一般化的形式:

template <typename T>
auto f(T x) {
  if (some expression involving x is well-formed)
    return something involving x;
  else
    return something else;
}

为了封装这个模式,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函数,它只是一个用Hanasfinae函数包装的通用lambdamaybe_add是一个函数,它接受两个输入,返回juse包装的普通lambda的结果,如果调用是良好的,just(...)返回一个类型的容器,称为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是一个函数,如果形式良好,则返回(x.toString()),否则不返回。 其次,我们使用.value_or()函数从容器中提取可选值。 如果可选值为空,.value_or()返回给定的默认值; 否则,返回just(x.toString())。 这种将SFINAE看作可能失败的计算的特殊情况的方式是非常干净和强大的,特别是因为sfinae'd函数可以通过hana::optional Monad组合,祥情参见参考文档。

内省用户定义类型

你曾经想要遍历用户定义类型的成员吗? 本节的目的是向您展示如何使用Hana轻松地做到这一点。 为了允许使用用户定义的类型,Hana定义了Struct概念。 一旦用户定义的类型是该概念的模型,可以遍历该类型的对象的成员并查询其他有用的信息。 要将用户定义的类型转换为Struct,可以使用几个选项。 首先,您可以使用BOOST_HANA_DEFINE_STRUCT宏定义用户定义类型的成员:

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

此宏使用给定的类型定义两个成员(名称和年龄)。 然后,它在Person::hana嵌套结构中定义了一些样板,这是使Person成为Struct概念的模型所必需的。 没有定义构造函数(因此保留POD属性),成员的定义顺序与它们在这里出现的顺序相同,宏可以与模板结构一起使用,也可以在任何范围使用。 另请注意,您可以在使用宏之前或之后向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

完成对结构体的迭代,好像结构体是一对对的序列,其中pair中的第一个元素是与成员相关的键,第二个元素是成员本身。 当通过BOOST_HANA_DEFINE_STRUCT宏定义一个Struct时,与任何成员关联的键是一个编译时hana::string,表示该成员的名称。 这就是为什么与for_each一起使用的函数使用单个参数pair,然后使用firstsecond函数来访问pair的子部分。 另外,注意如何对成员的名称使用<char const *>函数的? 这会将编译时字符串转换为constexpr char const *,所以它可以couted。 因为总是使用firstsecond来获取对的子部分可能很烦人,我们还可以使用fuse函数来包装我们的lambda,并使它成为二个参数的lambda

hana::for_each(john, hana::fuse([](auto name, auto member) {
  std::cout << hana::to<char const*>(name) << ": " << member << std::endl;
}));

现在,它看起来更简洁。 正如我们刚才提到的,结构体被看作是一种用于迭代目的的pair序列。 实际上,一个Struct甚至可以像关联数据结构一样被搜索,其键是成员的名字,其值是成员本身:

std::string name = hana::at_key(john, "name"_s);
BOOST_HANA_RUNTIME_CHECK(name == "John");
int age = hana::at_key(john, "age"_s);
BOOST_HANA_RUNTIME_CHECK(age == 30);

注意: _s用户定义的文本创建一个编译时hana::string。 它位于boost::hana::literals命名空间中。 请注意,它不是标准的一部分,但受ClangGCC支持。 如果要保持100%的标准,可以使用BOOST_HANA_STRING宏。

Structhana::map之间的主要区别在于hana::map可以修改映射(可以添加和删除键),而Struct是不可变的。 但是,您可以轻松地将一个Struct转换为与<map_tag>关联的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];
BOOST_HANA_RUNTIME_CHECK(age == 30);

使用BOOST_HANA_DEFINE_STRUCT宏来修改结构很方便,但有时候不能修改需要修改的类型。 在这些情况下,BOOST_HANA_ADAPT_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的高级接口可以用于例如指定不是编译时字符串的键。 高级接口在Struct概念的文档中有描述。

示例:生成JSON

现在让我们继续使用我们刚刚提供的用于以JSON格式打印自定义对象的内省功能的具体示例。 我们的最终目标是拥有像这样的东西:

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

格式化JSON输出,应该看起来像:

    1 [
    2   {
    3     "name": "John",
    4     "last_name": "Doe",
    5     "age": 30
    6   },
    7   {
    8     "brand": "Audi",
    9     "model": "A4"
   10   },
   11   {
   12     "brand": "BMW",
   13     "model": "Z3"
   14   }
   15 ]

首先,让我们定义一些效率函数,使字符串操作更容易:

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

quoteto_json重载是很自然的。 然而,join函数可能需要一点解释。 基本上,散布函数采用序列和分隔符,并且在原始序列的每对元素之间返回具有分隔符的新序列。 换句话说,我们采用形式[x1,...,xn]的序列,并将其转换为形式[x1,sep,x2,sep,...,sep,xn]的序列。 最后,我们使用_ + _函数对象折叠结果序列,这等价于std::plus<>{}。 因为我们的序列包含std::strings(我们假设它可行),这将具有将序列的所有字符串连接成一个大字符串的效果。 现在,让我们定义如何输出一个序列:

template <typename Xs>
  std::enable_if_t<hana::Sequence<Xs>::value,
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::enable_if_t<hana::Struct<T>::value,
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), ", ") + "}";
}

这里,我们使用keys方法来检索包含用户定义类型的成员的名称的元组。 然后,我们将该序列转换为“name”序列:成员字符串,然后我们join并用{}括起来,这用于表示JSON符号中的对象。 收工!