每周技巧 #198:标签类型

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #198: Tag Types

原文最初作为 TotW #198 发布于 2021 年 8 月 12 日。

作者:Alex Konradi

更新于 2022 年 1 月 24 日。

快捷链接:abseil.io/tips/198

假设我们有一个类 Foo

1
2
3
4
5
6
class Foo {
 public:
  explicit Foo(int x, int y);
  Foo& operator=(const Foo&) = delete;
  Foo(const Foo&) = delete;
};

Foo 既不可移动也不可拷贝,但它可以构造,例如 Foo f(1, 2);。由于它有公共构造函数,我们可以很容易地创建一个包在 std::optional 中的实例:

1
2
std::optional<Foo> maybe_foo;
maybe_foo.emplace(5, 10);

这很好,但如果 std::optional 被声明为 const,因此不能调用 emplace() 呢?幸运的是,std::optional 为此提供了一个构造函数:

1
2
// 把 std::in_place 作为第一个实参传入,后面跟 Foo 构造函数的实参。
const std::optional<Foo> maybe_foo(std::in_place, 5, 10);

等等,std::in_place 是怎么回事?如果查看 std::optional 构造的文档,可以看到其中一个重载把 std::in_place_t 作为第一个实参,并接受一串额外实参。由于 std::in_placestd::in_place_t 的一个实例,编译器会选择原位构造构造函数,它通过把剩余实参转发给 Foo 的构造函数,在 std::optional 内部实例化 Foo

如果更仔细地看 std::in_place_t 的文档,会发现它……是一个空结构体。没有特殊限定符,也没有魔法声明。唯一稍微特殊的是,标准库包含了它的一个具名实例 std::in_place

通过标签类型进行重载解析

std::in_place_t 属于一类松散的类,通常称为“标签类型”。这些类的作用是通过“标记”重载集中的特定重载,向编译器传递信息。向重载函数(通常是构造函数)提供合适标签类的实例后,我们就可以利用编译器普通的解析规则,让它选择期望的重载。在我们的 std::optional 构造例子中,编译器看到第一个实参类型是 std::in_place_t,于是选择匹配的原位构造构造函数。

虽然 std::in_place_t 在 C++17 中引入,但标准库中用空类标记重载的做法早在 C++11 引入 std::piecewise_construct_t 时就已经很常见了,它用于为 std::pair 选择原位构造构造函数。C++17 大幅扩展了标准库中的标签类型集合。

当然,还有模板

除了消除重载歧义,标签类型的另一个常见用途是向模板化构造函数传递类型信息。考虑这两个结构体:

1
2
struct A { A(); /* 内部成员 */ };
struct B { B(); /* 内部成员 */ };

让我们尝试用它们分别构造 std::variant<A, B> 的实例:

1
2
3
4
5
6
7
8
// 如果 A 和 B 可拷贝构造或可移动构造,这些代码可以工作,
// 但会产生一次额外拷贝或移动构造的性能成本。
std::variant<A, B> with_a{A()};
std::variant<A, B> with_b{B()};

// 这些不是合法 C++;语言不支持显式提供构造函数模板参数。
std::variant<A, B> try_templating_a<A>{};
std::variant<A, B><B> try_templating_b{};

我们需要一种方式告诉 std::variant 构造函数:想要实例化 AB,但不真的提供二者中任何一个实例。为此,可以使用 std::in_place_type

1
2
std::variant<A, B> with_a{std::in_place_type<A>};
std::variant<A, B> with_b{std::in_place_type<B>};

std::in_place_type<T> 是类模板 std::in_place_type_t<T> 的一个实例,而后者(到这里你大概不会惊讶)是空的。通过把类型为 std::in_place_type_t<A> 的值传给 std::variant 构造函数,编译器可以推导出构造函数模板参数就是我们的类 A

使用

与泛型类模板交互时,标签类型偶尔会出现,尤其是标准库中的模板。标签类型的一个缺点是,工厂函数等其他技术通常会产生更可读的代码。看下面这个例子:

1
2
3
4
5
// 这种标签写法要求读者知道 std::optional 如何与 std::in_place 交互。
std::optional<Foo> with_tag(std::in_place, 5, 10);

// 这里意图更清楚:用这些实参创建一个 optional Foo。
std::optional<Foo> with_factory = std::make_optional<Foo>(5, 10);

得益于 C++17 的强制拷贝消除,这两个 std::optional<Foo> 对象保证以相同方式构造。

那为什么还要使用标签?因为工厂函数不总是可行:

1
2
// 这不能工作,因为 Foo 不可移动构造。
std::optional<std::optional<Foo>> foo(std::make_optional<Foo>(5, 10));

上面的例子无法编译,因为 Foo 的移动构造函数已删除。为了让它工作,我们使用 std::in_place 选择会转发剩余实参的 std::optional 构造函数。

1
2
// 这会把所有东西原位构造,最终只调用一次 Foo 的构造函数。
std::optional<std::optional<Foo>> foo(std::in_place, std::in_place, 5, 10);

除了能在工厂函数不可用的地方工作,标签类型还有一些不错的性质:

  • 它们是字面类型,这意味着我们可以声明 constexpr 实例,甚至可以在头文件中声明,例如 std::in_place
  • 因为空类可以被编译器优化掉,所以它们没有运行时开销。

虽然标准库使用了标签类型,但在野外遇到空标签类型相对少见。如果你发现自己在使用它,考虑添加注释帮助读者:

1
2
3
4
5
6
std::unordered_map<int, Foo> int_to_foo;
// std::piecewise_construct 是用于解析 std::pair 构造函数重载的标签。
// 在 map 中原位构造 100 -> Foo(5, 10)。
int_to_foo.emplace(std::piecewise_construct,
                   std::forward_as_tuple(100),
                   std::forward_as_tuple(5, 10));

结论

标签类型是一种向编译器提供额外信息的强大方式。乍看之下它们可能像魔法,但它们使用的仍是 C++ 其他部分相同的重载解析和模板类型推导规则。标准库用类标签消除构造函数调用歧义,你也可以使用同样机制按自己的需要定义标签。

相关阅读

每周技巧 #197:读锁应当少见

上一节

每周技巧 #215:使用 `AbslStringify()` 将自定义类型字符串化

下一节