本周小贴士#198:标签类型

作为TotW#198最初发表于2021年8月12日

由Alex Konradi创作

假设我们有一个类Foo:

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中的实例:

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

这很棒,但是如果std::optional被声明为const,那么我们无法调用emplace()怎么办? 幸运的是,std::optional有一个这样的构造函数:

// 传递 std::in_place作为第一个参数, 后面是构造函数的参数
const std::optional<Foo> maybe_foo(std::in_place, 5, 10);

等等,std::in_place是什么意思?如果我们查看std::optional的文档,可以看到其中一个重载函数接受std::in_place_t作为第一个参数,以及其他参数的列表。由于std::in_place是std::in_place_t的一个实例,编译器选择了emplace构造函数,它通过将其余的参数转发给Foo的构造函数,在std::optional中实例化了Foo。

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

std::in_place_t是一类被称为"标签类型"的松散类的成员。这些类的作用是通过给重载函数(通常是构造函数)提供一个适当的标签类的实例来向编译器传递信息,从而在重载集中"标记"特定的重载。通过将适当的标签类的实例提供给重载函数,我们可以使用编译器的普通解析规则来选择所需的重载。在我们的std::optional构造中,编译器看到第一个参数的类型是std::in_place_t,因此选择了匹配的emplace构造函数。
虽然std::in_place_t是在C++17中引入的,但在标准库中使用空类作为标记重载的方式自C++11中引入std::piecewise_construct_t以来一直很普遍,用于选择std::pair的emplace构造函数。C++17在标准库中显著扩展了标记类型的集合。

当然是模板

除了消除重载的歧义之外,标记类型的另一个常见用途是将类型信息传递给模板化的构造函数。考虑以下两个结构体:

struct A { A(); /* internal members */ };
struct B { B(); /* internal members */ };

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

// 如果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::in_place_type是类模板std::in_place_type_t的一个实例,而std::in_place_type_t在这一点上是空的(毫不意外)。通过将类型为std::in_place_type_t的值传递给std::variant的构造函数,编译器可以推断出构造函数模板参数是我们的类A。

用法

标签类型在与通用类模板交互时偶尔会出现,尤其是标准库中的类模板。标签类型的一个缺点是,其他技术(如工厂函数)通常会导致更易读的代码。看下面的例子:

// 这种标签写法要求读者了解std::optional与std::in_place的交互方式。
 std::optional<Foo> with_tag(std::in_place, 5, 10);

// 这种写法更清晰:通过提供这些参数来创建一个可选的Foo。 
std::optional<Foo> with_factory = std::make_optional<Foo>(5, 10); 这两个std::optional<Foo>对象由于C++17的强制复制省略而保证构造相同。

那么为什么要使用标签呢?因为工厂函数并不总是适用:

// 这段代码无法工作,因为Foo没有可移动构造函数。
 std::optional<std::optional<Foo>> foo(std::make_optional<Foo>(5, 10));

上述示例无法编译,因为Foo的移动构造函数被删除了。为了使其工作,我们使用std::in_place来选择std::optional构造函数,以转发剩余的参数。

// 这样就可以在原地构造所有对象,从而只调用一次Foo的构造函数。 
std::optional<std::optional<Foo>> foo(std::in_place, std::in_place, 5, 10); 

除了在工厂函数无法使用的地方可以使用标签类型外,标签类型还具有其他一些好处:

  • 它们是字面类型,这意味着我们可以声明constexpr实例,甚至可以在头文件中声明,就像std::in_place一样;
  • 因为它们是空的,编译器可以将它们优化掉,从而不会产生运行时开销。 虽然它们在标准库中被使用,但在实际应用中遇到空的标签类型相对较少。如果你发现自己在使用一个,考虑添加注释来帮助读者理解:
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++的其他部分相同的重载解析和模板类型推导规则。标准库使用类标签来消除构造函数调用的歧义,您也可以使用相同的机制为自己的需求定义标签。

你可能感兴趣的:(C++,Tips,of,the,Week,c++)