第一部分

基本语言特性

这一部分会引入新的C++17核心语言特性,它们都与泛型编程(即模板)无关。它们对于应用开发者的日常编程工作有很大的帮助。因此每个使用C++17的C++程序员都应该知道它们。

关于新的模板的核心语言特性会在第二部分提到。

第一章 结构化绑定

结构化绑定允许你使用对象的成员或者说元素来初始化多个变量。

举个例子,假如你定义了一个包含两个不同成员的结构:

struct MyStruct {
  int i = 0;
  std::string s;
};

MyStruct ms;

只需使用下面的声明,你就可以将这个结构体的成员直接绑定到新名字上

auto [u,v] = ms;

在这里,名字u和v就被称为结构化绑定(structured bindings)。在某种程度上,它们分解了对象并用来初始化自己(在有些地方它们也被称为分解声明(decompose declarations))。

结构化绑定对于那些返回结构体或者数组的函数来说尤其有用。举个例子,假设你有一个返回结构体的函数:

MyStruct getStruct() {
  return MyStruct{42, "hello"};
}

你可以直接为函数返回的数据成员赋予两个局部名字:

auto[id,val] = getStruct(); // id and val name i and s of returned struct

在这里,id和val分别表示返回的数据成员i和s。它们的类型分别是int和std::string ,可以当新变量使用。

if (id > 30) {
  std::cout << val;
}

使用结构化绑定的好处是可以直接通过名字访问值,并且由于名字可以传递语义信息,使得代码可读性也大大提高。

下面的示例展示了结构化绑定如何改善代码可读性。在没有结构化绑定的时候,要想迭代处理std::map<>的所有元素,需要这么写:

for (const auto& elem : mymap) {
  std::cout << elem.first << ": " << elem.second << '\n'; 
}

代码中的elem是表示键和值的std::pair,它们在std::pair中分别用first和second表示,你可以使用这两个名字去访问键和值。使用结构化绑定后,代码可读性大大提高:

for (const auto& [key,val] : mymap) {
  std::cout << key << ": " << val << '\n'; 
}

我们可以直接使用每个元素的键和值,key和value清晰的表示了它们的语义。

1.1 结构化绑定的细节

为了理解结构化绑定,了解其中设计的一个匿名变量是很重要的。结构化绑定引入的新名字都是指代的这个匿名变量的成员/元素的。

绑定到匿名变量

初始化代码的最精确的行为:

auto [u,v] = ms;

可以看成我们初始化一个匿名变量e,然后让结构化绑定u和v成为这个新对象的别名,类似下面:

auto e = ms;
aliasname u = e.i;
aliasname v = e.s;

注意u和v不是e.ie.s的引用。它们只是这两个成员的别名。因此,decltype(u)的类型与成员i的类型一致,decltype(v)的类型与成员s的类型一致。因为匿名变量e没有名字,所以我们不能直接访问这个已经初始化的变量。所以

std::cout << u << ' ' << v << ✬\n✬;

输出e.ie.s的值,它们是ms.ims.s的一份拷贝。

e和结构化绑定的存活时间一样长,当结构化绑定离开作用域时,e也会析构。

这样做的后果,除非使用引用,否则修改通过结构化绑定的值不会影响到初始化它的对象(反之亦然):

MyStruct ms{42,"hello"};
auto [u,v] = ms;
ms.i = 77;
std::cout << u;    // prints 42
u = 99;
std::cout << ms.i; // prints 77

u和ms.i地址是不一样的。

当对返回值使用结构化绑定的时候,上面的规则一样成立。下面代码的初始化:

auto [u,v] = getStruct();

和我们使用getStruct()的返回值初始化匿名变量e,然后用u和v作为e的成员别名效果一样,类似下面:

auto e = getStruct();
aliasname u = e.i;
aliasname v = e.s;

换句话说,结构化绑定将绑定到一个新的对象,它由返回值初始化,而不是直接绑定到返回值本身。

对于匿名变量e,内存地址和对齐也是存在的,以至于如果成员有对齐,结构化绑定也会有对齐。比如:

auto [u,v] = ms;
assert(&((MyStruct*)&u)->s == &v); // OK

((MyStruct*)&u)会产生一个指向匿名变量的指针。

使用修饰符

我们在结构化绑定过程中使用一些修饰符,如const和引用。再次强调,这些修饰符修饰的是匿名变量e。虽说是对匿名变量使用修饰符,但是通常也可以看作对结构化绑定使用修饰符,尽管存在一些额例外。

下面的例子中,我们对结构化绑定使用const引用:

const auto& [u,v] = ms; // a reference, so that u/v refer to ms.i/ms.s

这里,匿名变量被声明为const引用,这意味着对ms使用const引用修饰,然后再将u和v作为i和s的别名。后续对ms成员的修改会直接影响到u和v:

ms.i = 77;      // affects the value of u
std::cout << u; // prints 77

如果使用非const引用,你甚至可以通过对结构化绑定的修改,影响到初始化它的对象:

MyStruct ms{42,"hello"};
auto& [u,v] = ms;       // the initialized entity is a reference to ms
ms.i = 77;              // affects the value of u
std::cout << u;         // prints 77
u = 99;                 // modifies ms.i
std::cout << ms.i;      // prints 99

如果初始化对象是临时变量,对它使用结构化绑定,此时临时值的生命周期会扩展:

MyStruct getStruct();
...
const auto& [a,b] = getStruct();
std::cout << "a: " << a << '\n'; // OK

修饰符并非修饰结构化绑定

如题,修饰符修饰的是匿名变量。它们没必要修饰结构化绑定。事实上:

const auto& [u,v] = ms;  // a reference, so that u/v refer to ms.i/ms.s

u和v都没有声明为引用。上面只是对匿名变量e的引用。u和v的类型需要ms的成员一致。根据我们最开始的定义可以知道,decltype(u)是int,decltype(v)std::string

当指定对齐宽度的时候也有一些不同。

alignas(16) auto [u,v] = ms;

在这里,我们将初始化后的匿名对象对齐而不是结构化绑定u和v。这意味着u作为第一个成员,被强制对齐到16位,而v不是。

同样的原因,尽管使用了auto,结构化绑定的类型也不会类型退化(术语退化(decay)描述的是当参数值传递的时候发生的类型转换,这意味着数组会转换为指针,最外面的修饰符如const和引用会被忽略)。例如,如果我们有一个包含多个原生数组的结构体:

struct S{
    const char x[6];
    const char y[3];
};

然后

S s1{};
auto [a, b] = s1; // a and b get the exact member types

a的类型仍然是const char[6]。原因仍然是修饰符并非修饰结构化绑定而是修饰初始化结构化绑定的对象。这一点和使用auto初始化新对象很不一样,它会发生类型退化:

auto a2 = a;    // a2 gets decayed type of a

移动语义

即将介绍到,结构化绑定也支持移动语义。在下面的声明中:

MyStruct ms = { 42, "Jim" };
auto&& [v,n] = std::move(ms);  // entity is rvalue reference to ms

结构化绑定v和n指向匿名变量中的成员,该匿名变量是ms的右值引用。ms仍然持有它的值:

std::cout << "ms.s: " << ms.s << '\n'; // prints "Jim"

但是你可以移动赋值n,它与ms.s关联:

std::string s = std::move(n); // moves ms.s to s
std::cout << "ms.s: " << ms.s << '\n'; // prints unspecified value
std::cout << "n: " << n << '\n'; // prints unspecified value
std::cout << "s: " << s << '\n'; // prints "Jim"

通常,移动后的对象的状态是有效的,只是包含了未指定的值(unspecified value)。因此,输出它的值是没有问题的,但是不能断言输出的东西一定是什么。

这一点和直接移动ms的值给匿名变量稍有不同:

MyStruct ms = { 42, "Jim" };
auto [v,n] = std::move(ms); // new entity with moved-from values from ms

此时匿名对象是一个新对象,它用移动后的ms的值来初始化。所以ms失去了他们的值:

std::cout << "ms.s: " << ms.s << '\n'; // prints unspecified value
std::cout << "n: " << n << '\n'; // prints "Jim"

你仍然可以移动n并赋值,或者用它赋予一个新的值,但是不会影响ms.s

std::string s = std::move(n); // moves n to s
n = "Lara";
std::cout << "ms.s: " << ms.s << '\n'; // prints unspecified value
std::cout << "n: " << n << '\n'; // prints "Lara"
std::cout << "s: " << s << '\n'; // prints "Jim"

1.2 结构化绑定可以在哪使用

原则上,结构化绑定可以用于公有成员,原始C-style数组,以及“似若tuple”的对象:

  • 如果结构体或者类中,所有非静态数据成员都是public,那么你可以使用结构化绑定来绑定非静态数据成员
  • 对于原生数组,你可以使用结构化绑定来绑定每个元素
  • 对于任何类型,你都可以使用似若tuple的API来进行绑定。对于类型type,API可以粗糙的概括为下列内容:
    • std::tuple_size<type>::value返回元素数量
    • std::tupel_element<idx,type>::type返回第idx个元素的类型
    • 一个全局的或者成员函数get<idx>()返回第idx个元素的值

如果结构体或者累提供这些似若tuple的API,那么就可以使用它们。

任何情况下都要求元素或者数据成员的数量必须匹配结构化绑定的名字的个数。你不能跳过任何一个元素,也不能使用同一个名字两次。但是你可以看使用非常段的名字如"_"(很多程序员倾向于用下划线,但是也有些人讨厌它,不允许它出现在全局命名空间中),但是在一个作用域它也只能出现一次:

auto [_,val1] = getStruct(); // OK
auto [_,val2] = getStruct(); // ERROR: name _ already used

嵌套或者非平坦的对象分解是不支持的。(译注:指的是形如OCaml等语言的这种let a,(b,c) = (3,(4,2));;模式匹配能力)

接下来的章节讨论本节列表提到的各种情况。

1.2.1 结构体和类

到目前为止,已经演示了很多关于结构体和类的简单示例了。

如果类和结构体用到了继承,那么结构化绑定的使用就很受限了。所有非静态数据成员必须出现在同一个类。(换句话说,这些数据成员要么全是该类的,要么全是基类的)。

struct B {
  int a = 1;
  int b = 2;
};

struct D1 : B {
};
auto [x, y] = D1{}; // OK

struct D2 : B {
  int c = 3;
};

auto [i, j, k] = D2{}; // Compile-Time ERROR

1.2.1 原生数组

下面的代码使用有两个元素的C-style数组初始化x和y:

int arr[] = { 47, 11 };
auto [x, y] = arr; // x and y are ints initialized by elems of arr
auto [z] = arr; // ERROR: number of elements doesn’t fit

这种方式只能出现在数组长度已知的情况下。如果将数组作为参数传递,这样写就行不通,因为数组作为参数传递会发生类型退化,变成指针类型。

C++允许我们返回带长度的数组引用,如果有函数返回这种带长度的数组引用,那么也可以使用结构化绑定:

auto getArr() -> int(&)[2]; // getArr() returns reference to raw int array
...
auto [x, y] = getArr(); // x and y are ints initialized by elems of returned array

你也可以对std::array使用结构化绑定,但是这需要使用似若tuple的API,这也是下一节的内容。

1.2.3 std::pair,std::tuplestd::array

结构化绑定是可扩展的,你可以为任何类型添加结构化绑定机制。标准库为std::pair,std::tuplestd::array都添加了该机制。

std::array

举个例子,下面的getArray()将返回四个元素的std::array<>,并用它初始化i,j,k和l。

std::array<int,4> getArray();
...
auto [i,j,k,l] = getArray(); // i,j,k,l name the 4 elements of the copied return value

i,j,k和l分别绑定到getArray()返回的四个元素上。

写操作也是支持的,但这要求用来初始化结构化绑定的值不是一个临时的返回值:

std::array<int,4> stdarr { 1, 2, 3, 4 };
...
auto& [i,j,k,l] = stdarr;
i += 10; // modifies std::array[0]

std::tuple

下面的代码使用getTuple()返回有三个元素的std::tuple<>来初始化a,b和c:

std::tuple<char,float,std::string> getTuple();
...
auto [a,b,c] = getTuple();    // a,b,c have types and values of returned tuple

std::pair

另一个例子是处理关联型/无序型容器的insert()调用的返回值,使用结构化绑定使代码可读性更强,可以清晰的表达自己的意图,而不是依赖于std::tuple通用的first和second:

std::map<std::string, int> coll;
...
auto [pos,ok] = coll.insert({"new",42});
if (!ok) {
  // if insert failed, handle error using iterator pos:
  ...
}

在C++17之前,必须使用下面的代码检查返回数据:

auto ret = coll.insert({"new",42});
if (!ret.second){
  // if insert failed, handle error using iterator ret.first
  ...
}

注意,在这个例子中,C++17甚至还提供一种表达力更强的带初始化的if:

为pair和tuple的结构化绑定赋值

在声明了结构化绑定之后,通常你不能一次性修改全部结构化绑定,因为结构化绑定是一次性声明所有而不是一次性使用所有。然而,如果重新赋的值是std::pair<>或者std::tuple<>那么你可以使用std::tie()

也就是说,你可以写出下面的代码:

std::tuple<char,float,std::string> getTuple();
...
auto [a,b,c] = getTuple(); // a,b,c have types and values of returned tuple
...
std::tie(a,b,c) = getTuple(); // a,b,c get values of next returned tuple

这种方式在实现循环调用且每次循环赋予一对返回值的过程中尤其有用,比如下面子啊循环中使用searcher的代码:

std::boyer_moore_searcher bm{sub.begin(), sub.end()};
for (auto [beg, end] = bm(text.begin(), text.end());
  beg != text.end();
  std::tie(beg,end) = bm(end, text.end())) {
  ...
}

1.3 为结构化绑定提供似若tuple的API

前面提到过,只要你的类型实现了似若tuple的API,那么就可以针对该类型使用结构化绑定,就和标准库的std::pair<>,std::tuple<>std::array<>意义。

只读结构化绑定

下面的代码展示了如何为类型Customer添加结构化绑定功能,Customer的定义如下

// lang/customer1.hpp
#include <string>
#include <utility> // for std::move()
class Customer {
private:
  std::string first;
  std::string last;
  long val;
public:
  Customer (std::string f, std::string l, long v)
      : first(std::move(f)), last(std::move(l)), val(v) {
  }
  std::string getFirst() const {
    return first;
  }
  std::string getLast() const {
    return last;
  }
  long getValue() const {
    return val;
  }
};

我们可以提供似若tuple的API:

// lang/structbind1.hpp
#include "customer1.hpp" #include <utility> // for tuple-like API
// provide a tuple-like API for class Customer for structured bindings:
template<>
struct std::tuple_size<Customer> {
  static constexpr int value = 3; // we have 3 attributes
};
template<>
struct std::tuple_element<2, Customer> {
  using type = long; // last attribute is a long
};
template<std::size_t Idx>
struct std::tuple_element<Idx, Customer> {
  using type = std::string; // the other attributes are strings
};
// define specific getters:
template<std::size_t> auto get(const Customer& c);
template<> auto get<0>(const Customer& c) { return c.getFirst(); }
template<> auto get<1>(const Customer& c) { return c.getLast(); }
template<> auto get<2>(const Customer& c) { return c.getValue(); }

代码Customer有三个成员,还有为三个成员准备的getter:

  • 表示first name的成员,std::string类型
  • 表示last nane的成员,std::string类型
  • 表示value的成员,long类型

获取Customer成员个数的函数是std::tuple_size的特化:

template<>
struct std::tuple_size<Customer> {
  static constexpr int value = 3; // we have 3 attributes
};

获取成员类型的函数是std::tuple_element的特化:

template<>
struct std::tuple_element<2, Customer> {
  using type = long; // last attribute is a long
};
template<std::size_t Idx>
struct std::tuple_element<Idx, Customer> {
  using type = std::string; // the other attributes are strings
};

第三个成员类型是long,需要为它(index 2)编写全特化代码。其它成员是std::stinrg类型,部分特化(比全特化优先级低)即可。这里指定的类型与decltype产生的类型一致。

最终,我们在同一个命名空间为Customer类型定义相应的get<>()函数重载:

template<std::size_t> auto get(const Customer& c);
template<> auto get<0>(const Customer& c) { return c.getFirst(); }
template<> auto get<1>(const Customer& c) { return c.getLast(); }
template<> auto get<2>(const Customer& c) { return c.getValue(); }

在这里,我们声明了模板函数,然后为所有情况都写出来对应的全特化形式。

注意,模板函数的全特化必须与模板函数的签名一致(也包括一致的返回类型)。原因是我们只提供了特定的“实现”,而不是声明新的函数。下面的代码不能通过编译:

template<std::size_t> auto get(const Customer& c);
template<> std::string get<0>(const Customer& c) { return c.getFirst(); }
template<> std::string get<1>(const Customer& c) { return c.getLast(); }
template<> long get<2>(const Customer& c) { return c.getValue(); }

通过使用新的编译时if特性,我们可以所有特化形式的get<>()组合到一个函数里面:

template<std::size_t I> auto get(const Customer& c) {
  static_assert(I < 3);
  if constexpr (I == 0) {
    return c.getFirst();
  }
  else if constexpr (I == 1) {
    return c.getLast();
  }
  else { // I == 2
    return c.getValue();
  } 
}

有了这些API,就能对Customer的对象使用结构化绑定了:

#include <iostream>
int main()
{
  Customer c("Tim", "Starr", 42);
  auto [f, l, v] = c;
  std::cout << "f/l/v: " << f << ' ' << l << ' ' << v << '\n';
// modify structured bindings:
  std::string s = std::move(f);
  l = "Waters";
  v += 10;
  std::cout << "f/l/v: " << f << ' ' << l << ' ' << v <<'\n';
  std::cout << "c: " << c.getFirst() << ' '
  << c.getLast() << ' ' << c.getValue() << '\n';
  std::cout << "s: " << s << '\n';
}

和往常一样,结构化绑定f,l和v是新的匿名变量的成员的别名,新的匿名变量经由c初始化。初始化为每个成员调用相应的getter函数。因此,在初始化后,修改c不会影响到结构化绑定(反之亦然)。所以,程序的输出如下:

f/l/v: Tim Starr 42
f/l/v:  Waters 52
c: Tim Starr 42
s: Tim

你也可以在迭代一个由Customer元素构成的vector的过程中使用结构化绑定:

std::vector<Customer> coll;
...
for (const auto& [first, last, val] : coll) {
  std::cout << first << ' ' << last << ": " << val << '\n'; 
}

对结构化绑定使用decltype仍然回产出它的类型,而不是匿名变量的类型。这意味着decltype(first)const std::string

允许针对结构化绑定的写操作

似若tuple的API可以可以使用产生引用的函数。这使得我们可以允许针对结构化绑定的写操作发生。考虑下面的代码,它为Customer提供了读取和修改成员的API:

// lang/customer2.hpp
#include <string>
#include <utility> // for std::move()
class Customer {
private:
  std::string first;
  std::string last;
  long val;
public:
  Customer (std::string f, std::string l, long v)
      : first(std::move(f)), last(std::move(l)), val(v) {
  }
  const std::string& firstname() const {
    return first;
  }
  std::string& firstname() {
    return first;
  }
  const std::string& lastname() const {
    return last;
  }
  std::string& lastname() {
    return last;
  }
  long value() const {
    return val;
  }
  long& value() {
    return val;
  }
};

要支持读写操作,我们还得为常量引用和非常量引用准备getter重载:

// lang/structbind2.hpp
#include "customer2.hpp" 
#include < utility> // for tuple-like API
// provide a tuple-like API for class Customer for structured bindings:
template <> struct std::tuple_size<Customer> {
  static constexpr int value = 3; // we have 3 attributes
};
template <> struct std::tuple_element<2, Customer> {
  using type = long; // last attribute is a long
};
template <std::size_t Idx> struct std::tuple_element<Idx, Customer> {
  using type = std::string; // the other attributes are strings
};
// define specific getters:
template <std::size_t I> decltype(auto) get(Customer &c) {
  static_assert(I < 3);
  if constexpr (I == 0) {
    return c.firstname();
  } else if constexpr (I == 1) {
    return c.lastname();
  } else { // I == 2
    return c.value();
  }
}
template <std::size_t I> decltype(auto) get(const Customer &c) {
  static_assert(I < 3);
  if constexpr (I == 0) {
    return c.firstname();
  } else if constexpr (I == 1) {
    return c.lastname();
  } else { // I == 2
    return c.value();
  }
}
template <std::size_t I> decltype(auto) get(Customer &&c) {
  static_assert(I < 3);
  if constexpr (I == 0) {
    return std::move(c.firstname());
  } else if constexpr (I == 1) {
    return std::move(c.lastname());
  } else { // I == 2
    return c.value();
  }
}

你应该写出这三个重载,来处理常量对象,非常量对象,以及可移动对象。为了返回引用,你应该使用decltype(auto)

还是之前那样,我们可以使用新的编译时if特性,来简化我们的实现,尤其是getter的返回类型不一样时,它更有用。没有编译时if特性,我们只能写出所有的全特化:

template<std::size_t> decltype(auto) get(Customer& c);
template<> decltype(auto) get<0>(Customer& c) { return c.firstname(); }
template<> decltype(auto) get<1>(Customer& c) { return c.lastname(); }
template<> decltype(auto) get<2>(Customer& c) { return c.value(); }

模板函数声明的签名必须与全特化的一致(包括返回类型)。下面的代码不能编译:

template<std::size_t> decltype(auto) get(Customer& c);
template<> std::string& get<0>(Customer& c) { return c.firstname(); }
template<> std::string& get<1>(Customer& c) { return c.lastname(); }
template<> long& get<2>(Customer& c) { return c.value(); }

做完这些后,你就能使用结构化绑定读取或者修改Customer的成员了:

#include "structbind2.hpp" 
#include <iostream>
int main() {
  Customer c("Tim", "Starr", 42);
  auto [f, l, v] = c;
  std::cout << "f/l/v: " << f << ' ' << l << ' ' << v << '\n';
  // modify structured bindings via references:
  auto &&[f2, l2, v2] = c;
  std::string s = std::move(f2);
  f2 = "Ringo";
  v2 += 10;
  std::cout << "f2/l2/v2: " << f2 << ' ' << l2 << ' ' << v2 << '\n';
  std::cout << "c: " << c.firstname() << ' ' << c.lastname() << ✬ ✬ << c.value() << '\n';
  std::cout << "s: " << s << '\n'; 
}

它会输出:

f/l/v: Tim Starr 42
f2/l2/v2: Ringo Starr 52
c: Ringo Starr 52
s: Tim

1.4 后记

结构化绑定最初由Herb Sutter,Bjarne Stroustrup和Gabriel Dos Reis在https://wg21.link/p0144r0中提出,当时使用花括号而不是方括号。最后这个特性的公认措辞是由Jens Maurer在https://wg21.link/p0217r3中给出。

第二章 带初始化的if和switch

现在ifswitch控制结构允许我们在普通的条件语句或者选择语句之外再指定一个初始化语句。

比如,你可以这样写:

if (status s = check(); s != status::success) {
    return s;
}

其中初始化语句是:

status s = check();

它初始化s,然后用if判断s是否是有效状态。

2.1 带初始化的if

任何在if语句内初始化的值的生命周期都持续到then代码块或者else代码块(如果有的话)的最后。比如:

if (std::ofstream strm = getLogStrm(); coll.empty()) {
    strm << "<no data>\n"; 
}
else {
    for (const auto& elem : coll) {
        strm << elem << '\n'; 
    } 
}
// strm no longer declared

strm的析构函数回在then代码块或者else代码块的最后调用。

另一个例子是执行一些依赖某些条件的任务的时候使用锁:

if (std::lock_guard<std::mutex> lg{collMutex}; !coll.empty()) {
    std::cout << coll.front() << '\n'; 
}

因为有类模板参数推导,也可以这样写:

if (std::lock_guard lg{collMutex}; !coll.empty()) {
    std::cout << coll.front() << '\n'; 
}

任何情况下,上面的代码都等价于:

{
    std::lock_guard<std::mutex> lg{collMutex};
    if (!coll.empty()) {
        std::cout << coll.front() << '\n'; 
    } 
}

区别在于lg是在if语句的作用域中定义的,因此与条件在相同的作用域(声明性区域)中,就像for循环中初始化的情况一样。

任何被初始化的对象都必须有一个名字。否则,初始化语句会长久一个立即销毁大的临时值。举个例子,初始化一个没有名字的lock guard,其后的条件检查不是在加锁环境下进行的:

if (std::lock_guard<std::mutex>{collMutex}; // run-time ERROR:
        !coll.empty()) { // - no longer locked
    std::cout << coll.front() << '\n'; // - no longer locked
} 

一般来说,一个_作为名字也是可以的(一些程序员喜欢它,另一些讨厌它因为它污染全局命名空间):

if (std::lock_guard<std::mutex> _{collMutex}; // OK, but...
    !coll.empty()) {
  std::cout << coll.front() << '\n';
}

接下来是第三个例子,考虑一段代码,插入新元素到map或者unordered map。你可以检查操作是否成功,就像下面一样:

std::map<std::string, int> coll;
... 
if (auto [pos, ok] = coll.insert({"new", 42}); !ok) {
  // if insert failed, handle error using iterator pos:
  const auto &[key, val] = *pos;
  std::cout << "already there: " << key << '\n';
}

这段代码还是用了结构化绑定,给返回值和元素插入的位置pos分别赋予了名字,而不是first和second。在C++17前,上面相应的检查必须像下面一样规范:

auto ret = coll.insert({"new", 42});
if (!ret.second) {
  // if insert failed, handle error using iterator ret.first
  const auto &elem = *(ret.first);
  std::cout << "already there: " << elem.first << '\n';
}

注意这种带if的初始化也能用于编译时if特性。

2.2 带初始化的switch

使用带初始化的switch语句允许我们在检查条件并决定控制流跳转到哪个case执行之前初始化一个对象。

比如,我们可以先初始化一个文件系统路径,再根据路径的类型选择对应的处理方式:

using namespace std::filesystem;
... 
switch (path p(name); status(p).type()) {
case file_type::not_found:
  std::cout << p << " not found\n";
  break;
case file_type::directory:
  std::cout << p << ":\n";
  for (auto &e : std::filesystem::directory_iterator(p)) {
    std::cout << "- " << e.path() << '\n';
  }
  break;
default:
  std::cout << p << " exists\n";
  break;
}

初始化的p能在整个switch语句中使用。

2.3 后记

带初始化的if和switch最初由Thomas Koppe在https://wg21.link/p0305r0中提出,当时只有带初始化的if没有带初始化的switch。最后这个特性的公认措辞是由Thomas Koppe在https://wg21.link/p0305r1中给出。

第三章 内联变量

C++的一个优点是它支持header-only(译注:即只有头文件)的库。然而,截止C++17,header-only的库也不能有全局变量或者对象出现。

C++17后,你可以在头文件中使用inline定义变量,如果这个变量被多个翻译单元(translation unit)使用,它们都会指向相同对象:

class MyClass {
  static inline std::string name = ""; // OK since C++17
  ...
};
inline MyClass myGlobalObj; // OK even if included/defined by multiple CPP files

3.1 内联变量的动机

C++不允许在class内部初始化非const静态成员:

class MyClass {
  static std::string name = "";  // Compile-Time ERROR
  ...
};

在class外面定义这个变量定义这个变量,且变量定义是在头文件中,多个CPP文件包含它,仍然会引发错误:

class MyClass {
  static std::string name; // OK
  ...
};
MyClass::name = ""; // Link ERROR if included by multiple CPP files

根据一处定义规则(one definition 入了,ODR),每个翻译单元只能定义变量最多一次。

即便有预处理保护(译注:也叫头文件保护,header guard)也没有用:

#ifndef MYHEADER_HPP
#define MYHEADER_HPP
class MyClass {
  static std::string name; // OK
  ...
};
MyClass.name = ""; // Link ERROR if included by multiple CPP files
#endif

不是因为头文件可能被包含多次,问题是两个不同的CPP如果都包含这个头文件,那么MyClass.name可能定义两次。

同样的原因,如果你在头文件中定义一个变量,你会得到一个链接时错误:

class MyClass {
  ...
};
MyClass myGlobalObject; // Link ERROR if included by multiple CPP files

临时解决方案

这里有一些临时的应对措施:

  • 你可以在class/struct内初始化一个static const整型数据成员:
class MyClass {
  static const bool trace = false;
  ...
};
  • 你可以定义一个返回局部static对象的内联函数:
inline std::string getName() {
  static std::string name = "initial value";
  return name;
}
  • 你可以定义一个static成员函数返回它的值:
std::string getMyGlobalObject() {
  static std::string myGlobalObject = "initial value";
  return myGlobalObject;
}
  • 你可以使用变量模板(C++14及以后):
template<typename T = std::string>
T myGlobalObject = "initial value";
  • 你可以继承一个包含static成员的类模板:
template<typename Dummy>
class MyClassStatics
{
  static std::string name;
};
template<typename Dummy>
std::string MyClassStatics<Dummy>::name = "initial value";
class MyClass : public MyClassStatics<void> {
  ...
};

但是这些方法都有不小的负载,可读性也比较差,想要使用全局变量也比较困难。除此之外,全局变量的初始化可能会推迟到它第一次使用的时候,这使得应用程序不能在启动的时候把对象初始化好。(比如用一个对象监控进程)。

3.2 使用内联变量

现在,有了inline,你可以在头文件中定义一个全局可用的变量,它可以被多个CPP文件包含:

class MyClass {
  static inline std::string name = ""; // OK since C++17
  ...
};
inline MyClass myGlobalObj; // OK even if included/defined by multiple CPP files

初始化发生在第一个包含该头文件的翻译单元。

形式化来说,在变量前使用inline和将函数声明为inline有相同的语义:

  • 如果每个定义都是相同的,那么它可以在多个翻译单元定义
  • 它必须在使用它的每个翻译单元中定义

两者都是通过包含来自同一头文件的定义来实现的。最终程序的行为就像是只有一个变量。

你甚至可以在头文件中定义原子类型的变量:

inline std::atomic<bool> ready{false};

注意,对于std::atomic,通常在定义它的时候你还得初始化它。

这意味着,你仍然必须保证在你初始化它之前类型是完全的(complete)。比如,如果一个struct或者class有一个static成员,类型是自身,那么该成员只能在该类型被声明后才能使用。

struct MyType {
  int value;
  MyType(int i) : value{i} {
  }
// one static object to hold the maximum value of this type:
  static MyType max; // can only be declared here
  ...
};
inline MyType MyType::max{0};

参见另一个使用内联变量的例子,它会使用头文件跟踪所有new调用

3.3 constexpr隐式包含inline

对于static数据成员,constexpr现在隐式包含inline的语义,所以下面的声明在C++17后会定义static数据成员n:

struct D {
  static constexpr int n = 5; // C++11/C++14: declaration
                              // since C++17: definition
};

换句话说,它与下面的代码一样:

struct D {
  inline static constexpr int n = 5;
};

在C++17之前,有时候你也可以只声明不定义。考虑下面的声明:

struct D {
  static constexpr int n = 5;
};

如果不需要D::n的定义,这就足够了,例如,D::n只通过值传递的话:

std::cout << D::n; // OK (ostream::operator<<(int) gets D::n by value)

如果D::n是传引用到非内联函数,并且/或者函数调用没有优化,那么就是无效的。比如:

int inc(const int& i);
std::cout << inc(D::n); // usually an ERROR

这段代码违背了一处定义规则(ODR)。当使用带优化的编译器构建时,它可能正常工作,或者抛出链接时错误指出缺少定义。当使用不带优化的编译器时,几乎可以确定这段代码会由于缺少D::n的定义而拒绝编译:

因此,在C++17前,你不得不在相同的翻译单元定义D::n

constexpr int D::n; // C++11/C++14: definition
                    // since C++17: redundant declaration (deprecated)

当使用C++17构建,在class中的声明本身就是一个定义,所以这段代码就算没有前面的定义也是有效的。前面的定义也是可以的,但是已经废弃。

3.4 内联变量和thread_local

使用thread_local你可以让每个线程拥有一个内联变量:

struct ThreadData {
  inline static thread_local std::string name; // unique name per thread
...
};

inline thread_local std::vector<std::string> cache; // one cache per thread

为了演示一个完整的例子,考虑下面的头文件:

// lang/inlinethreadlocal.hpp
#include <string>
#include <iostream>

struct MyData {
  inline static std::string gName = "global"; // unique in program
  inline static thread_local std::string tName = "tls"; // unique per thread
  std::string lName = "local"; // for each object
  ...
  void print(const std::string& msg) const {
    std::cout << msg << '\n';
    std::cout << "- gName: " << gName << '\n';
    std::cout << "- tName: " << tName << '\n';
    std::cout << "- lName: " << lName << '\n'; }
};

inline thread_local MyData myThreadData; // one object per thread

你可以在有main()的翻译单元使用它:

// lang/inlinethreadlocal1.cpp
#include "inlinethreadlocal.hpp" 
#include <thread>
void foo();

int main()
{
  myThreadData.print("main() begin:");
  myThreadData.gName = "thread1 name";
  myThreadData.tName = "thread1 name";
  myThreadData.lName = "thread1 name";
  myThreadData.print("main() later:");
  std::thread t(foo);
  t.join();
  myThreadData.print("main() end:");
}

你可以在另一个定义foo()的翻译单元使用头文件,其中foo()被不同的线程调用:

// lang/inlinethreadlocal2.cpp
#include "inlinethreadlocal.hpp"

void foo()
{
  myThreadData.print("foo() begin:");
  myThreadData.gName = "thread2 name";
  myThreadData.tName = "thread2 name";
  myThreadData.lName = "thread2 name";
  myThreadData.print("foo() end:");
}

程序的输出如下:

main() begin:
- gName: global
- tName: tls
- lName: local
main() later:
- gName: thread1 name
- tName: thread1 name
- lName: thread1 name
foo() begin:
- gName: thread1 name
- tName: tls
- lName: local
foo() end:
- gName: thread2 name
- tName: thread2 name
- lName: thread2 name
main() end:
- gName: thread2 name
- tName: thread1 name
- lName: thread1 name

3.5 后记

David Krauss的文档https://wg21.link/n4147是内联变量产生的动机。内联变量最初由Hal Finkel和Richard Smith在https://wg21.link/n4424中提出。最后这个特性的公认措辞是由Hal Finkel和Richard Smith在 https://wg21.link/p0386r2中给出。

第四章 聚合扩展

C++中有一种初始化对象的方式叫做聚合初始化(aggregate initialization),它允许用花括号聚集多个值来初始化。

struct Data {
  std::string name;
  double value;
};

Data x{"test1", 6.778};

从C++17开始,聚合还支持带基类的数据结构,所以下面这种数据结构用列表初始化也是允许的:

struct MoreData : Data {
  bool done;
};
MoreData y{{"test1", 6.778}, false};

正如你看到的,聚合初始化现在支持嵌套的花括号传给基类的成员来初始化。

对于带有成员的子对象的初始化,如果基类或子对象只有一个值,则可以跳过嵌套的大括号:

MoreData y{"test1", 6.778, false};

4.1 扩展聚合初始化的动机

如果没有这项特性的话,继承一个类之后就不能使用聚合初始化了,需要你为新类定义一个构造函数:

struct Cpp14Data : Data {
  bool done;
  Cpp14Data (const std::string& s, double d, bool b)
      : Data{s,d}, done{b} {
  }
};
Cpp14Data y{"test1", 6.778, false};

现在,有了这个特性我们可以自由的使用嵌套的花括号,如果只传递一个值还可以省略它:

MoreData x{{"test1", 6.778}, false}; // OK since C++17
MoreData y{"test1", 6.778, false}; // OK

注意,因为它现在是聚合体,其它初始化方式也是可以的:

MoreData u; // OOPS: value/done are uninitialized
MoreData z{}; // OK: value/done have values 0/false

如果这个看起来太危险了,你还是最好提供一个构造函数。

4.2 使用扩展的聚合初始化

关于这个特性的常见用法是列表初始化一个C风格的数据结构,该数据结构继承自一个类,然后添加了一些数据成员或者操作。比如:

struct Data {
  const char* name;
  double value;
};
struct PData : Data {
  bool critical;
  void print() const {
    std::cout << ✬[✬ << name << ✬,✬ << value << "]\n"; }
};

PData y{{"test1", 6.778}, false};
y.print();

这里里面的花括号会传递给基类Data的数据成员。

你可以跳过一些初始值。这种情况下这些元素是零值初始化(zero initalized)(调用默认构造函数或者将基本数据类型初始化为0,false或者nullptr)。比如:

PData a{};          // zero-initialize all elements
PData b{{"msg"}};   // same as {{"msg",0.0},false}
PData c{{}, true};  // same as {{nullptr,0.0},true}
PData d;            // values of fundamental types are unspecified

注意使用空的花括号和不使用花括号的区别。

  • a零值初始化所有成员,所以name被默认构造,double value被初始化为0.0,bool flag被初始化为false。
  • d只调用name的默认构造函数。所有其它的成员都没用被初始化,所以值是未指定的(unspecified)。

你也可以继承非聚合体来创建一个聚合体。比如:

struct MyString : std::string {
  void print() const {
    if (empty()) {
      std::cout << "<undefined>\n"; }
    else {
      std::cout << c_str() << '\n'; } }
};

MyString x{{"hello"}};
MyString y{"world"};

甚至还可以继承多个非聚合体:

template<typename T>
struct D : std::string, std::complex<T>
{
  std::string data;
};

然后使用下面的代码初始化它们:

D<float> s{{"hello"}, {4.5,6.7}, "world"};        // OK since C++17
D<float> t{"hello", {4.5, 6.7}, "world"};         // OK since C++17
std::cout << s.data;                              // outputs: ”world”
std::cout << static_cast<std::string>(s);         // outputs: ”hello”
std::cout << static_cast<std::complex<float>>(s); // outputs: (4.5,6.7)

内部花括号的值(initializer_lists)会传递给基类,其传递顺序遵循基类声明的顺序。

这项新特性还有助于用很少的代码定义lambdas重载

4.3 聚合体定义

总结一下,C++17的聚合体(aggregate)定义如下:

  • 是个数组
  • 或者是个类类型(class,struct,union),其中
    • 没有用户声明的构造函数或者explicit构造函数
    • 没有使用using声明继承的构造函数
    • 没有private或者protected的非static数据成员
    • 没有virtual函数
    • 没有virtual,private或者protected基类

为了让聚合体可以使用,还要求聚合体没有private或者protected基类成员或者构造函数在初始化的时候使用。

C++17还引入了一种新的type trait即is_aggregate<>来检查一个类型是否是聚合体:

template<typename T>
struct D : std::string, std::complex<T> {
  std::string data;
};
D<float> s{{"hello"}, {4.5,6.7}, "world"}; // OK since C++17
std::cout << std::is_aggregate<decltype(s)>::value; // outputs: 1 (true)

4.4 向后不兼容

注意,下面示例中的代码将不再能通过编译:

// lang/aggr14.cpp
struct Derived;
struct Base {
  friend struct Derived;
private:
  Base() {
  }
};
struct Derived : Base {
};
int main()
{
  Derived d1{}; // ERROR since C++17
  Derived d2; // still OK (but might not initialize)
}

C++17之前,Derived不是一个聚合体,所以:

Derived d1{};

调用Derived隐式定义的默认构造函数,它默认调用基类Base的默认构造函数。虽然基类的默认构造函数是private,但是通过子类的默认构造函数调用它是有效的,因为子类被声明为一个friend类。

C++17开始,Derived是一个聚合体,没有隐式的默认构造函数。所以这个初始化被认为是聚合初始化,聚合初始化不允许调用基类的默认构造函数。不管基类是不是friend都不行。

4.5 后记

内联变量最初由Oleg Smolsky在https://wg21.link/n4404中提出。最后这个特性的公认措辞是由Oleg Smolsky在 https://wg21.link/p0017r1中给出。

新的type trait即std::is_aggregate<>最初作为美国国家机构对C++ 17标准化的评论而引入。(参见https://wg21.link/lwg2911

第五章 强制拷贝消除或者传递unmaterialized对象

本章的主题可以从两个角度来看:

  • C++17引入了新的规则,在确定条件下可以强制消除拷贝:以前临时对象传值或者返回临时对象期间发生的拷贝操作的消除是可选的,现在是强制的。
  • 因此,我们处理传递未具体化对象的值以进行初始化 我将从技术上介绍这个特性,然后讨论具体化(materialization)的效果和相关术语。

5.1 临时量强制拷贝消除的动机

标准伊始,C++就明确允许一些拷贝操作可以被省略(消除),不调用拷贝构造函数会失去可能存在的副作用,从而可能影响程序的行为,即便这样也在所不惜。强制拷贝消除的场景之一是使用临时对象初始化新对象。这个情况经常发生,尤其是以值传递方式将临时对象传递给一个函数,或者函数返回临时对象。举个例子:

class MyClass
{
  ...
};
void foo(MyClass param) { // param is initialized by passed argument
  ...
}
MyClass bar() {
  return MyClass(); // returns temporary
}

int main()
{
  foo(MyClass());       // pass temporary to initialize param
  MyClass x = bar();    // use returned temporary to initialize x
  foo(bar());           // use returned temporary to initialize param
}

但是,由于这些拷贝消除优化不是强制的,要拷贝的对象必须提供隐式或显式的拷贝或移动构造函数。也就是说,尽管拷贝/移动构造函数一般不会调用,但是也必须存在。如果没有定义拷贝/移动构造函数,那么代码不能通过编译。

因此,下面MyClass的定义的代码编译不了:

class MyClass
{
public:
  ...
  // no copy/move constructor defined:
  MyClass(const MyClass&) = delete;
  MyClass(MyClass&&) = delete;
  ...
};

这里没有拷贝构造函数就足够了,因为仅当没有用户声明的拷贝构造(或者拷贝赋值运算符)时移动构造函数才隐式可用。

C++17后,临时变量初始化新对象期间发生的拷贝是强制消除的。事实上,在后面我们会看到,我们简单的传值作为实参初始化或者返回一个值,该值会接下来用于具体化(materalize)一个新对象。

这意味着就算MyClass类完全没有表示启用拷贝操作,上面的例子也能通过编译。

然而,请注意其他可选的拷贝消除仍然是可选的,仍然要求一个可调用的拷贝或者移动构造函数,比如:

MyClass foo()
{
  MyClass obj;
  ...
  return obj;    // still requires copy/move support
}

在这里,foo()里面的obj是一个带名字的变量(即左值(lvalue))。所以会发生命名的返回值优化(named return value optimization,NRVO),它要求类型支持拷贝或者移动操作。即便obj是一个参数也仍然如此:

MyClass bar(MyClass obj) // copy elision for passed temporaries
{
  ...
  return obj; // still requires copy/move support
}

传递一个临时量(即纯右值(prvalue))到函数作为实参,不会发生拷贝/移动操作,但是返回这个参数仍然需要拷贝/移动操作,因为返回的对象有名字。

作为这一改变的部分,值范畴(value categories)修改和新增了很多术语。

5.2 临时量强制拷贝消除的好处

强制拷贝消除的一个好处是,很明显,如果拷贝操作开心较大时会得到更好的性能。虽然移动语言显著减少了拷贝开销,但是完全不执行拷贝能极大的提示性能。这可能会减少使用出参(译注:所谓出参即可out parameter,是指使用参数来传递返回信息,通常是一个指针或者引用)代替返回一个值(假设这个值是由返回语句创建的)的需求。

另一个好处是现在只要写一个工厂函数它总是能工作,因为现在的工厂函数可以返回对象,即便对象不允许拷贝/移动。比如,考虑下面的泛型工厂函数:

// lang/factory.hpp
#include <utility>
template <typename T, typename... Args>
T create(Args&&... args)
{
  ...
  return T{std::forward<Args>(args)...};
}

这个函数现在甚至可以用于std::atomic<>这种类型,该类型既没有定义拷贝构造函数也没有定义移动构造函数:

// lang/factory.cpp
#include "factory.hpp" 
#include <memory>
#include <atomic>

int main() {
    int i = create<int>(42);
    std::unique_ptr<int> up = create<std::unique_ptr<int>>(new int{42});
    std::atomic<int> ai = create<std::atomic<int>>(42);
}

这个特性带来的另一个效果是,如果类有显式delete的移动构造函数,你现在可以返回临时值,然后用它初始化对象:

class CopyOnly {
public:
    CopyOnly() {
    }
    CopyOnly(int) {
    }
    CopyOnly(const CopyOnly&) = default;
    CopyOnly(CopyOnly&&) = delete; // explicitly deleted
};

CopyOnly ret() {
    return CopyOnly{}; // OK since C++17
}

CopyOnly x = 42; // OK since C++17

x的初始化代码在C++17之前是无效的,因为拷贝初始化需要将42转换为一个临时对象,然后临时对象原则上需要提供一个移动构造函数,尽管不会用到它。()

5.3 值范畴的解释

强制拷贝消除带来的额外工作是值范畴(value categories)的一些修改。

5.3.1 值范畴

在C++中的每个表达式都有一个值范畴。这个值范畴描述了表达式可以做什么。

值范畴的历史

从C语言历史的角度来看,在赋值语句中只有lvalue(左值)和rvalue(右值):

  x = 42;

表达式x是lvalue,因为它可以出现在赋值语句的左边,表达式42是rvalue,因为它只能出现在赋值语句的右边。但是因为ANSI-C,事情变得更复杂一些,因为x如果声明为const int就不能在赋值语句的左边了,但是它仍然是个(不具可修改性的)lvalue。

C++11我们有了可移动的对象,这些对象在语义上是只能出现在赋值语句右边,但是可以被修改,因为赋值语句可以盗取它们的值。基于这个原因,新的值范畴xvalue被引入,并且之前的值范畴rvalue有了新名字即prvalue。

C++11的值范畴

C++11后,值范畴如图5.1描述的那样:我们的核心值范畴是lvalue,prvalue(pure rvalue,纯右值),xvalue(eXpiring value,将亡值)。组合得到的值范畴有:glvalue(generalized lvalue,泛化左值,是lvalue和xvalue的结合)以及rvalue(是xvalue和prvalue的结合)。

图5.1 C++11后的值范畴

lvalue的例子有:

  • 一个表达式只包含变量,函数或者成员的名字
  • 一个表达式是字符串字面值
  • 内置一元操作符*的结果(即对原生指针解引用)
  • 返回左值引用(type&)的函数的返回值

prvalue的例子有:

  • 除字符串字面值外的其他字面值(或者用户定义的字面值,其中与之关联的字面值操作符的返回类型标示值的范畴)
  • 内置一元操作符&的结果(即获取表达式地址)
  • 内置算术运算符的结果
  • 返回值的函数的返回值

xvalue的例子有:

  • 返回右值引用(type&&,尤其是返回std::move())的函数的返回值
  • 右值引用到对象类型的转换

大概来说:

  • 所有使用名字的表达式是lvalue
  • 所有字符串字面值表达式是lvalue
  • 所有其他字面值(4.2,true,nullptr)是prvalue
  • 所有临时变量(尤其是返回值的函数返回的对象)是prvalue
  • std::move()的结果是xvalue

举个例子:

class X {
};

X v;
const X c;

void f(const X&);   // accepts an expression of any value category
void f(X&&);        // accepts prvalues and xvalues only, but is a better match

f(v);               // passes a modifiable lvalue to the first f()
f(c);               // passes a non-modifiable lvalue to the first f()
f(X());             // passes a prvalue to the second f()
f(std::move(v));    // passes an xvalue to the second f()

值得强调的是,严格来说,glvalue,prvalue和xvalue是针对表达式的, 不是针对值的(这意味着这些值用词不当)。举个例子,一个变量本身不是一个lvalue,只有一个变量放到表达式里才标示这个变量是lvalue:

int x = 3; // x here is a variable, not an lvalue
int y = x; // x here is an lvalue

第一个语句中3是prvalue,它用来初始化变量x(不是lvalue)。第二个语句中x是lvalue(对它求值会会发现它包含值3)。然后作为lvallue的x转换为prvalue,用来初始化变量y。

5.3.2 C++17的值范畴

C++17没有改变既有的值范畴,但是阐述了它们的语义(如图5.2所示)

图5.1 C++17后的值范畴

现在解释值范畴的主要方式是认为我们有两类表达式:

  • glvalue:对象/函数位置的表达式
  • prvalue:初始化表达式 xvalue被认为是一个特殊的位置,表示有一个变量它的资源可以重用(通常因为它接近它的生命周期结尾)。

C++17引入了一个新术语,具体化(materialization),表示在某个时刻一个prvalue成为临时对象。因此,临时变量具体化转换(temporary materialization conversion)是指prvalue到xvalue的转换。

任何时刻,期待出现glvalue(lvalue或xvalue)的地方出现prvalue都是有效的,创建一个临时变量并通过prvalue初始化,然后prvallue被替换为xvalue。因此在上面的例子中,严格来说:

void f(const X& p); // accepts an expression of any value category,
                    // but expects a glvalue
f(X());             // passes a prvalue materialized as xvalue

因为例子中的f()有一个引用参数,它期待一个glvalue实参。然而,表达式X()是一个prvalue。临时具体化规则因此生效,表达式X()转换为一个xvalue并使用默认构造函数初始化临时变量。

注意具体化不意味着我们创建了一个新的/不同的对象。lvalue引用仍然绑定xvalue和prvalue,虽然后者总是转换到xvalue。

在这些改变后,拷贝消除意义非凡,因为prvalue不再要求可移动,我们只传递一个初始值,这个值迟早会具体化然后初始化一个对象。

5.4 未具体化返回值传递

未具体化返回值传递是指所有形式的返回临时对象(prvalue)的值:

  • 当返回一个不是字符串字面值的字面值:
int f1() {    // return int by value
  return 42;
}
  • 当返回类型为临时变量的值或者使用auto:
auto f2() {   // return deduced type by value
  ...
  return MyType{...};
}
  • 当返回临时对象,并且类型用decltype(auto)推导:
decltype(auto) f3() {   // return temporary from return statement by value
  ...
  return MyType{...};
}

记住如果用于初始化的表达式(这里是返回语句)会创建一个临时变量(prvalue),那么用decltype(auto)声明的类型是值。

上述所有形式我们都返回一个prvalue的值,我们不需要任何拷贝/移动的支持。

5.5 后记

强制拷贝消除最初由Richard Smith在https://wg21.link/p0135r0中提出。最后这个特性的公认措辞是由Richard Smith在https://wg21.link/p0135r1中给出。

这一章翻译的不好,后面我会修订

第六章 Lambda扩展

C++11引入了lambda,C++14引入了泛型lambda,这是一个成功的故事。lambda允许我们将功能指定为参数,这让定制函数的行为变得更加容易。

C++ 17进一步改进,允许lambda用在更多的地方。

6.1 constexpr lambda

自C++17后,只要可能,lambda就隐式地用constexpr修饰。也就是说,任何lambda都可以用于编译时上下文,前提是它使用的特性对编译时上下文有效(例如,仅字符串字面值,无静态变量,无virutal变量,无try/catch,无new/delete)。

举个例子,你可以传一个值给lambda,然后用计算的结果作为编译时的std::array<>大小:

auto squared = [](auto val) { // implicitly constexpr since C++17
  return val*val;
};
std::array<int,squared(5)> a; // OK since C++17 => std::array<int,25>

如果在不允许constexpr的上下文使用这个特性就不行,但是你仍然可以在运行时傻姑娘上下文使用lambda:

he lambda in run-time contexts:
auto squared2 = [](auto val) {      // implicitly constexpr since C++17
  static int calls = 0;             // OK, but disables lambda for constexpr contexts
  ...
  return val*val;
};
std::array<int,squared2(5)> a;      // ERROR: static variable in compile-time context
std::cout << squared2(5) << '\n';   // OK

要知道是否一个lambda在一个编译时上下文有效,你可以将它声明为constexpr:

auto squared3 = [](auto val) constexpr {    // OK since C++17
  return val*val;
};

还可以指定返回类型,语法如下:

auto squared3i = [](int val) constexpr -> int { // OK since C++17
  return val*val;
};

constexpr对于函数的一般规则仍然有效:如果lambda在运行时上下文中使用,相应的功能在运行时执行。

然而,在不允许编译时上下文的地方使用constexpr lambda会得到一个编译时错误:

auto squared4 = [](auto val) constexpr {
  static int calls=0; // ERROR: static variable in compile-time context
  ...
  return val*val;
};

如果lambda式显式或隐式的constexpr,那么函数调用操作符也会是constexpr。换句话说,下面的定义:

auto squared = [](auto val) { // implicitly constexpr since C++17
  return val*val;
};

会转换为闭包类型:

class CompilerSpecificName {
  public:
    ...
    template<typename T>
    constexpr auto operator() (T val) const {
      return val*val;
    }
};

生成的闭包类型的函数调用操作符是自动附加constexpr的。在C++17中,如果lambda显式定义为constexpr或者隐式定义为constexpr(就像这个例子),那么生成的函数调用运算符也会是constexpr。

6.2 传递this的拷贝到lambda

当在成员函数中使用lambda时,你不能隐式的访问调用这个成员函数的对象的成员。也就是说,在lambda内部,如果不捕获this,那么你不能使用这个对象的成员:

class C {
private:
    std::string name;
public:
    ...
    void foo() {
        auto l1 = [] { std::cout << name << '\n'; }; // ERROR
        auto l2 = [] { std::cout << this->name << '\n'; }; // ERROR
        ...
    }
};

C++11和C++14中可以传this引用或者传this值:

class C {
private:
    std::string name;
public:
    ...
    void foo() {
        auto l1 = [this] { std::cout << name << '\n'; }; // OK
        auto l2 = [=] { std::cout << name << '\n'; }; // OK
        auto l3 = [&] { std::cout << name << '\n'; }; // OK
        ...
    }
};

然而,问题是即使是传递this的值,其底层捕获的仍然是引自对象(即只有指针被拷贝)。如果lambda的生命周期超过了对象的生命周期,这就会出现问题。一个重要的例子是当用lambda为新线程定义task,它应该使用对象的拷贝来避免任何并发或者生命周期问题。另一个原因可能只是传递一个对象的副本当前状态。

C++14有一个临时的解决方案,但是它读起来不好,工作起来也不好:

class C {
private:
    std::string name;
public:
    ...
    void foo() {
        auto l1 = [thisCopy=*this] { std::cout << thisCopy.name << '\n'; };
        ...
    }
};

举个例子,就算使用=&捕获了对象,开发者仍然可能不小心用到this

auto l1 = [&, thisCopy=*this] {
            thisCopy.name = "new name";
            std::cout << name << '\n'; // OOPS: still the old name
};

C++17开始,你可以显式地通过*this说明你想捕获当前对象的复制:

class C {
private:
    std::string name;
public:
    ...
    void foo() {
        auto l1 = [*this] { std::cout << name << '\n'; };
        ...
    }
};

捕获*this意味着当前对象的复制传递到了lambda。

在捕获了*this的情况下你仍然可以捕获其他this,只要没有与其他的发生冲突:

auto l2 = [&, *this] { ... };     // OK
auto l3 = [this, *this] { ... };  // ERROR

这里一个完整的例子:

// lang/lambdathis.cpp
#include <iostream>
#include <string>
#include <thread>

class Data {
private:
    std::string name;
public:
    Data(const std::string& s) : name(s) {
    }
    auto startThreadWithCopyOfThis() const {
        // start and return new thread using this after 3 seconds:
        using namespace std::literals;
        std::thread t([*this] {
            std::this_thread::sleep_for(3s);
            std::cout << name << '\n';
        });
        return t;
    }
};

int main()
{
    std::thread t;
    {
        Data d{"c1"};
        t = d.startThreadWithCopyOfThis();
    } // d is no longer valid
    t.join();
}

lambda用*this获取对象拷贝,即d。因此,即便是d的析构函数被调用后线程再使用传递的对象也没有问题。

如果我们使用[this],[=][&]捕获this,线程会产生未定义行为,因为在lambda打印name时,lambda使用的是已经析构后的对象的成员。

6.3 捕获引用

通过使用新的utility库函数,你现在可以捕获const对象引用

6.4 后记

constexpr最初由 Faisal Vali, Ville Voutilainen和Gabriel Dos Reis在https://wg21.link/n4487中提出。最后这个特性的公认措辞是由Faisal Vali, Jens Maurer和Richard Smith在https://wg21.link/p0170r1中给出。

捕获*this最初由H. Carter Edwards, Christian Trott, Hal Finkel, Jim Reus, Robin Maffeo和Ben Sander在https://wg21.link/p0018r0中提出。最后这个特性的公认措辞是由 H. Carter Edwards, Daveed Vandevoorde, Christian Trott, Hal Finkel, Jim Reus, Robin Maffeo和Ben Sander在https://wg21.link/p0180r3中给出。

第七章 新属性和属性相关特性

C++11开始,你可以指定属性(attribute,一种规范的注解,可以启用或者禁用一些warning)。C++17还引入了新的属性。此外,属性现在可以在更多的地方使用,并且有一些额外的便利。

7.1 [[nodiscard]]属性

新属性[[nodiscard]]用于鼓励编译器,当发现函数返回值没有被使用的时候,产生一个warning。

通常,这个属性可以用于通知一些返回值没有使用的错误行为。错误行为可能是:

  • 内存泄漏,比如没有使用已经分配并返回的内存
  • 不符合期望,或者非直观行为,比如没有使用返回值时候可能产生的一些不同寻常/不符合期望的行为
  • 不必要的负载,比如如果没有使用返回值,这个调用过程相当于无操作。

这是一些例子,它们展示了这个属性的是有用的:

  • 分配资源必须由另一个函数释放的函数应标记为 [[nodiscard]]。 一个典型的例子是分配内存的函数,例如malloc()或分配器的成员函数allocate()。 但是请注意,某些函数可能会返回一个值,后续无需再针对这个值做其他调用。 例如,程序员调用大小为零字节的C函数realloc(0以释放内存,这个函数的返回值就不必保存以后再调用free()
  • 一个关于不使用返回值那么函数的行为将会改变的例子是std::async(由C++11引入)。它的目的是异步启动任务,并返回一个句柄以等待其结束(并使用结果)。当返回值没使用时,这个调用会成为同步调用,因为未使用的返回值的析构函数会立即调用,即立刻开始等待任务结束。 因此,不使用返回值会与std::async()的设计目的相矛盾。 这种情况下用[[nodiscard]]让编译器对此发出警告。
  • 另一个例子是成员函数empty(),它检查对象是否没有元素。程序员有时候可能错误的调用这个函数来清空容器(译注:即误以为empty做动词)
cont.empty();

这种对empty()的误用可以被检查出来,因为它的返回值没有被使用。将成员函数标注这个属性即可:

class MyContainer {
  ...
public:
  [[nodiscard]] bool empty() const noexcept;
  ...
};

尽管这个是C++17引入的,但是标准库至今都没有使用它。对于C++17来说,应用此功能的建议来得太晚了。因此关于这个特性的关键动机,即为std::async()的声明添加现在都没有完成。对于上述所有示例,下一个C++标准将附带相应的修复程序(具体参见已经接受的提案https://wg21.link/p0600r1)。为了使代码更具可移植性,你应该使用它,而不是使用不可移植的方式(比如gcc或者clang的[[gnu:warn_unused_result]])来标注函数。当定义operator new()时你应该为函数标记[[nodiscard]]

7.2 [[maybe_unused]]属性

新属性[[maybe_unused]]可以用来避免编译器为未被使用的名字或者对象发出警告。

这个属性可以用在类声明上、类型定义typedef或者using上、变量、非静态数据成员、函数、枚举类型或者枚举值。

这个属性的一个应用是标记那些不是必要的参数:

void foo(int val, [[maybe_unused]] std::string msg)
{
#ifdef DEBUG
  log(msg);
#endif
  ...
}

另一个例子是标记可能不会使用的成员

class MyStruct {
  char c;
  int i;
  [[maybe_unused]] char makeLargerSize[100];
  ...
};

注意,你不能为一个语句标注[[maybe_unused]]。基于这个原因,你不能使用让[[maybe_unused]][[nodiscard]]相见:

int main()
{
  foo(); // WARNING: return value not used
  [[maybe_unused]] foo(); // ERROR: attribute not allowed here
  [[maybe_unused]] auto x = foo(); // OK
}

7.3 [[fallthrough]]属性

新属性[[fallthrough]]可以让编译器不警告那些switch中的某个case没有break,导致其他case被相继执行的情况。

比如:

void commentPlace(int place)
{
  switch (place) {
    case 1:
      std::cout << "very ";
      [[fallthrough]];
    case 2:
      std::cout << "well\n";
      break;
    default:
      std::cout << "OK\n";
      break; 
  } 
}

传递1会输出

very well

同时执行了case 1和case 2。

注意这个属性必须被用在空语句中。因此,你需要在它尾巴上加个分号。

在switch的最后一条语句使用这个属性是不允许的。

7.4 通用属性扩展

下面的特性在C++17zhong被启用:

  1. 现在允许为namespace标记属性。比如,你可以像下面代码一样弃用一个命名空间:
namespace [[deprecated]] DraftAPI {
  ...
}

也可以用于inline namespace和匿名namespace。 2. 枚举值现在也可以标注属性。

比如,你可以引入新的枚举值代替原有的枚举值,然后弃用原有枚举值:

enum class City { Berlin = 0,
                  NewYork = 1,
                  Mumbai = 2, Bombay [[deprecated]] = Mumbai,
                  ... };

Mumbai和Bombay都表示相同的city数值,但是Bombay已经弃用。注意标记枚举值时,语法上需要将属性放到枚举值名字的后面。

  1. 用户定义的属性它们通常在自己的namespace定义,你现在可以使用using来避免重复书写namespace。换句话说,以前写法是:
[[MyLib::WebService, MyLib::RestService, MyLib::doc("html")]] void foo();

现在你可以这么写:

[[using MyLib: WebService, RestService, doc("html")]] void foo();

注意用了using之后再书写namespace前缀会出错的:

[[using MyLib: MyLib::doc("html")]] void foo(); // ERROR

7.5 后记

这三个属性最初由Andrew Tomazos在https://wg21.link/p0068r0中提出。最后[[nodiscard]]的公认措辞是由Andrew Tomazos在https://wg21.link/p0189r1中给出。[[maybe_unused]]的公认措辞是由Andrew Tomazos在https://wg21.link/p0212r1中给出。[[fallthrough]]的公认措辞是由Andrew Tomazos在https://wg21.link/p0188r1中给出。

允许namespace和枚举值标注属性这个特性最初由 Richard Smith在https://wg21.link/n4196中提出。最后的公认措辞是由 Richard Smith在https://wg21.link/n4266中给出。

属性允许使用using这个特性最初由J. Daniel Garcia, Luis M. Sanchez, Massimo Torquati, Marco Danelutto和Peter Sommerlad在https://wg21.link/p0028r0中提出。最后的公认措辞是由J. Daniel Garcia and Daveed Vandevoorde在https://wg21.link/P0028R4中给出。

第八章 其他语言特性

有一些小的C++核心语言特性改动,它们会在本章描述。

8.1 嵌套命名空间

最早这个提案是在2003年提出的,C++标准委员会现在终于最终接受了它:

namespace A::B::C {
  ...
}

它等价于:

namespace A {
  namespace B {
    namespace C {
      ...
    }
  }
}

嵌套的inline命名空间还不支持。这是因为如果用了inline就不知道到底inline是针对最后一个还是对所有命名空间使用。

8.2 定于表达式求值顺序

很多代码库和C++书籍包含的代码首先给出符合直觉的假设,然后代码上看起来是有效的,但是严格来讲,这些代码可能产生未定义行为。一个例子是使用寻找并替换子字符串:

std::string s = "I heard it even works if you don't believe";
s.replace(0,8,"").replace(s.find("even"),4,"sometimes")
                 .replace(s.find("you don✬t"),9,"I");

直觉上看起来这段代码是有效的,它将前8个字符替换为空,“even”替换为“sometimes”,将“you don't”替换为“I”:

it sometimes works if I believe

然而,在C++17之前,结果是不保证的,因为,虽然find()调用返回从何处开始替换,但是当整个语句执行并且在结果被需要之前,这个调用可能在任何时候执行。实际上,所有find(),即计算待替换的起始索引,都可能在任何替换发生前被执行,因此结果是:

it sometimes works if I believe

其他结果也是可能的:

it sometimes workIdon’t believe
it even worsometiIdon’t believe
it even worsometimesf youIlieve

另一个例子是使用输出运算符来打印计算后的表达式的值:

std::cout << f() << g() << h();

通常的假设是f()g()之前被调用,两者又都在h()之前被调用。然而,这个假设是错误的。f()g()h()可以按任意顺序调用,这可能导致一些奇怪的,甚至是糟糕的结果,尤其是当这些调用互相依赖时

具体来说,考虑下面的例子,在C++17之前,这段代码会产生未定义行为:

i = 0;
std::cout << ++i << ' ' << --i << '\n';

在C++17之前,他可能输出1 0,也可能输出0 -1,甚至是0 0。不管i是int还是用户定义的类型,都可能这样。(对于基本类型,一些编译器至少会warning这个问题)。

要修复这个未定义行为,一些运算符/操作符的求值被挑战,因此现在它们有确定的求值顺序:

  • 对于
    • e1 [ e2 ]
    • e1 . e2
    • e1 .* e2
    • e1 ->* e2
    • e1 << e2
    • e1 >> e2 e1保证在e2之前求值,它们的求值顺序是从左至右。

然而,相同函数的不同实参的求值顺序仍然是未定义的。即:

e1.f(a1,a2,a3)

e1保证在a1 a2 a3之前求值。但是a1 a2 a3的求职顺序仍然是未定义的。

  • 所有赋值运算符
    • e2 = e1
    • e2 += e1
    • e2 *= e1
    • ... 右手边的e1会先于左手变的e2被求值。
  • 最后,new表达式中
    • new Type(e) 分配行为保证在e之前求值,初始化新的值保证在任何使用初始化的值之前被求值。

上述所有保证对基本类型和用户定义类型都有效。

这样做的效果是,C++17后:

std::string s = "I heard it even works if you don't believe";
s.replace(0,8,"").replace(s.find("even"),4,"sometimes")
                 .replace(s.find("you don✬t"),9,"I");

保证会改变s的值,变成:

it always works if you use C++17

因此,每个find()之前的替换都会在find()之前被求值。

另一个结果是,下面的语句

i = 0;
std::cout << ++i << ' ' << --i << '\n';

其输出保证是1 0

然而,对于其他大多数运算符而言,求值顺序仍然未定义。举个例子:

i = i++ + i; // still undefined behavior

这里右手变的i可能在递增之前或者递增之后传递给左手变。

另一个使用new表达式求值顺序的例子是在传值之前插入空格的函数

向后兼容

新的求值顺序的保证可能影响既有程序的输出。这不是理论上可能,是真的。考虑下面的代码:

#include <iostream>
#include <vector>

void print10elems(const std::vector<int>& v) 
{
    for (int i=0; i<10; ++i) {
        std::cout << "value: " << v.at(i) << '\n';
    }
}

int main()
{
    try {
        std::vector<int> vec{7, 14, 21, 28};
        print10elems(vec);
    }
    catch (const std::exception& e) { // handle standard exception
        std::cerr << "EXCEPTION: " << e.what() << '\n'; }
    catch (...) { // handle any other exception
        std::cerr << "EXCEPTION of unknown type\n"; 
    } 
}

因为这里的vector<>只有4个元素,程序会在print10elems()的循环中,调用at()时遇到无效索引抛出异常:

std::cout << "value: " << v.at(i) << "\n";

在C++17之前,可能输出:

value: 7
value: 14
value: 21
value: 28
EXCEPTION: ...

因为at()可以在"value "输出之前求值,所以对于错误的索引可能直接跳过不输出"value "。

自C++17之后,保证输出:

value: 7
value: 14
value: 21
value: 28
value: EXCEPTION: ...

因为"value "一定在at()调用之前执行。

8.3 宽松的基于整数的枚举初始化

对于有固定基本类型的枚举,C++17允许你使用带数值的列表初始化。

// unscoped enum with underlying type:
enum MyInt : char { };
MyInt i1{42};     // C++17 OK (C++17之前错误)
MyInt i2 = 42;    // 仍然错误
MyInt i3(42);     // 仍然错误
MyInt i4 = {42};  // 仍然错误

enum class Weekday { mon, tue, wed, thu, fri, sat, sun };
Weekday s1{0};    // C++17 OK (C++17之前错误)
Weekday s2 = 0;   // 仍然错误
Weekday s3(0);    // 仍然错误
Weekday s4 = {0}; // 仍然错误

类似的,如果Weekday有基本类型:

// scoped enum with specified underlying type:
enum class Weekday : char { mon, tue, wed, thu, fri, sat, sun };
Weekday s1{0};    // C++17 OK (C++17之前错误)
Weekday s2 = 0;   // 仍然错误
Weekday s3(0);    // 仍然错误
Weekday s4 = {0}; // 仍然错误

对于没有指定基本类型的未限域枚举(不带class的enum),你仍然不能使用带数值的列表初始化:

enum Flag { bit1=1, bit2=2, bit3=4 };
Flag f1{0}; // 仍然错误

注意,列表初始化还是不允许变窄(narrowing),因此你不能传递浮点值:

enum MyInt : char { };
MyInt i5{42.2}; // 仍然错误

之所以提出这个特性,是想实现一种技巧,即基于原有的整数类型定义另一种新的枚举类型,就像上面MyInt一样。

实际上,C++17的标准库中的std::byte也提供这个功能,它直接使用了这个特性。

8.4 修复带auto和直接列表初始化一起使用产生的矛盾行为

C++11引入了统一初始化后,结果证明它和auto搭配会不幸地产生反直觉的矛盾行为:

int x{42};      // initializes an int
int y{1,2,3};   // ERROR
auto a{42};     // initializes a std::initializer_list<int>
auto b{1,2,3};  // OK: initializes a std::initializer_list<int>

这些使用直接列表初始化(direct list initialization,不带=的花括号)造成的前后不一致行为已经得到修复,现在程序行为如下:

int x{42};      // initializes an int
int y{1,2,3};   // ERROR
auto a{42};     // initializes an int now
auto b{1,2,3};  // ERROR now

注意这是一个非常大的改变,甚至可能悄悄的改变程序的行为。出于这个原因,编译器接受这个改变,但是通常也提供C++11版本的模式。对于主流编译器,比如Visual Studio 2015,g++5和clang3.8同时接受两种模式。

还请注意拷贝列表初始化(copy list initialization,带=的花括号)的行为是不变的,当使用auto时初始化一个std::initializer_list<>

auto c = {42}; // still initializes a std::initializer_list<int>
auto d = {1,2,3}; // still OK: initializes a std::initializer_list<int>

因此,现在的直接列表初始化(不带=)和拷贝列表初始化(带=)有另一个显著区别:

auto a{42}; // initializes an int now
auto c = {42}; // still initializes a std::initializer_list<int>

推荐的方式是总是使用直接列表初始化(不带=的花括号)来初始化变量和对象。

8.5 十六进制浮点字面值

C++17标准化了十六进制的浮点值字面值(有些编译器早已在C++17之前就支持了)。这种方式尤其适用于要求精确的浮点表示(对于双精度浮点值,没法保证精确值的存在)。

举个例子:

// lang/hexfloat.cpp
#include <iostream>
#include <iomanip>

int main() {
  // init list of floating-point values:
  std::initializer_list<double> values{
      0x1p4, // 16
      0xA, // 10
      0xAp2, // 40
      5e0, // 5
      0x1.4p+2, // 5
      1e5, // 100000
      0x1.86Ap+16, // 100000
      0xC.68p+2, // 49.625
  };
  
  // print all values both as decimal and hexadecimal value:
  for (double d : values) {
    std::cout << "dec: " << std::setw(6) << std::defaultfloat << d
              << " hex: " << std::hexfloat << d << '\n';
  }
}

这个程序使用不同的方式定义了不同的浮点值,其中包括使用十六进制浮点记法。新的记法是base为2的科学表示法:

  • significant/mantissa写作十六进制方式
  • exponent写作数值方式,解释为base为2

比如说,0xAp2是指定数值40(10乘以2的次方)。这个值也可以表示为0x1.4p+5,表示1.25乘以32(0.4是十六进制的四分之一,2的5次方是32)。

程序输出如下:

dec: 16     hex: 0x1p+4
dec: 10     hex: 0x1.4p+3
dec: 40     hex: 0x1.4p+5
dec: 5      hex: 0x1.4p+2
dec: 5      hex: 0x1.4p+2
dec: 100000 hex: 0x1.86ap+16
dec: 100000 hex: 0x1.86ap+16
dec: 49.625 hex: 0x1.8dp+5

如你说见,这个例子的浮点记法早已在C++11的std::hexfloat操作符上就已经支持了。

8.6 UTF-8字符串字面值

C++11支持以u8前缀表示的UTF-8字符串字面值。然而,这个前缀对于字符是不支持的。C++17修复了这个问题,你现在可以这样写:

char c = u8'6'; // character 6 with UTF-8 encoding value

样可以保证字符值是UTF-8中字符‘6’的值。你可以使用所有的7bits US-ASCII字符,对于这些字符,UTF-8代码具有相同的值。换句话说,用这个指定的值和US-ASCII、ISO Latin-1、ISO-8859-15和基本Windows字符集的值都是一样的。通常,你的源代码的字符都会被解释为US-ASCII/UTF-8,所以前缀不是很重要。变量c的值几乎总是54(十六进制的36)。

对于源码中的字符和字符串字面值,C++标准化了你可以使用哪些字符,但是没有标准化这些字符对应的值。这些值取决于源代码字符集。当编译器生成可执行程序时,它会使用运行时字符集。源代码字符集集合总是7bits的US-ASCII,并且运行时字符集通常和源代码字符集一样。对于任何C++程序,有没有u8前缀这些字符和字符串字面值都是一样的。但是在很少见的情况下,可能不是这样。比如老式的IBM主机,仍然使用EBCDIC字符集,在这个字符集中字符‘6’的值是246(十六进制F6)。如果程序使用EBCDIC字符集,那么c的值将会是246而不是54,并且在UTF-8编码的平台上运行该程序时可能输出"¨o",因为它对应ASCII值的246.在这种情况下前缀可能是必要的。

注意u8只能用于单个字符和UTF-8单字节字符。下面的初始化: 是不被允许的,因为这个德语字符在UTF-8是双字节,即195和182(十六进制C3 B6)。

总结来熟哦,所有允许的字符和字符串字面值如下:

  • 单字节US-ASCII和UTF-8可以使用u8
  • 双字节的UTF-16可以使用u
  • 四字节的UTF-32可以使用U
  • 没有指定编码的宽字符可以使用l,它可能是两字节也可能是四字节

8.7 异常声明成为类型的一部分

C++17开始异常处理声明成为一个函数的类型的一部分。也就是说,下面的两个函数现在有不同的类型:

void f1();
void f2() noexcept; // different type

在C++17之前,这两个函数的类型是相同的。

这样的后果是,现在的编译器会检查是否你将不抛异常的函数传递给抛异常的函数指针:

void (*fp)() noexcept;  // pointer to function that doesn’t throw
fp = f2;                // OK
fp = f1;                // ERROR since C++17

给抛异常的函数指针传递不抛异常的函数仍然有效:

void (*fp2)();  // pointer to function that might throw
fp2 = f2;       // OK
fp2 = f1;       // OK

所以,这个新的特性不会破坏哪些没有使用noexcept作为函数指针的一部分的那些程序。

异常声明有无不能作为重载函数的依据:

void f3();
void f3() noexcept; // ERROR

注意,其他规则是不受影响的。举个例子,下面的代码中你还是不能忽略基类noexcept声明:

class Base {
public:
    virtual void foo() noexcept;
    ...
};

class Derived : public Base {
public:
    void foo() override; // ERROR: does not override
    ...
};

子类的foo()的类型与基类的foo()类型不一致,所以不允许重载,这个代码不能通过编译。即便没有指定override修饰符,还是不能编译,因为我们不能用更宽松的抛异常的版本来重载不抛异常的严格版本。

使用条件异常声明

当使用条件异常声明时,函数的类型取决于条件为true还是false:

void f1();
void f2() noexcept;
void f3() noexcept(sizeof(int)<4); // same type as either f1() or f2()
void f4() noexcept(sizeof(int)>=4); // different type than f3()

在这里,当代码编译时f3()的类型取决于条件:

  • 如果sizeof(int)为4(或者更多),最终的签名是
void f3() noexcept(false);    // same type as f1()
  • 如果sizeof(int)小于4,最终签名是:
void f3() noexcept(true);     // same type as f2()

因为f4()的异常条件与f3()相反,所以f4()的类型总是与f3()不一样(即保证f3()抛异常它就不抛,f3()不抛它就抛)。

老式的空异常声明仍然可以使用,但是C++17已经标为废弃:

void f5() throw(); // same as void f5() noexcept but deprecated

动态的异常声明已经不再支持(它们在C++11时已经标为废弃):

void f6() throw(std::bad_alloc); // ERROR: invalid since C++17

对泛型库的影响

让noexcept成为类型的一部分可能对一些泛型库造成影响。

比如,下面的程序截止C++14是有效的,但是在C++17中无法编译:

// lang/noexceptcalls.cpp
#include <iostream>

template<typename T>
void call(T op1, T op2)
{
    op1();
    op2();
}

void f1() {
    std::cout << "f1()\n"; 
}

void f2() noexcept {
    std::cout << "f2()\n"; 
}

int main()
{
    call(f1, f2); // ERROR since C++17
}

原因是C++17中f1()f2()的类型不一样,编译器在实例化模板调用call()的时候不能为两个类型找到相同的类型T。

在C++17下,你不得不用两个类型:

template<typename T1, typename T2>
void call(T1 op1, T2 op2)
{
  op1();
  op2();
}

如果你想,或者不得不重载所有可能的函数类型,你需要付出双倍。来看std::is_function<>,主要的函数模板定义如下,通常T不是函数:

// primary template (in general type T is no function):
template<typename T> struct is_function : std::false_type { };

这个模板继承自std::false_type,所以is_function<T>::value通常产生false。

对于那些的确是函数的类型,需要偏特化,它继承自std::true_type,所以成员value的值是true:

// partial specializations for all function types:
template<typename Ret, typename... Params>
struct is_function<Ret (Params...)> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) const> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) &> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) const &> : std::true_type { };

C++17之前,它已经有24个偏特化来,因为函数可能有const和volatile修饰符,也可能有lvalue和rvalue引用修饰符,你重载的函数需要可变参数模板类型。

C++17后,偏特化的数量将会翻倍,因为有了新的noexcept修饰符,所以现在有48个:

...
// partial specializations for all function types with noexcept:
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) noexcept> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) const noexcept> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) & noexcept> : std::true_type { };
template<typename Ret, typename... Params>
struct is_function<Ret (Params...) const& noexcept> : std::true_type { };

没有实现noexcept重载的库可能编译不了一些代码,因为它们可能用了noexcept。

8.8 单参数的static_assert

C++17开始,之前static_assert()必须传的错误消息参数现在变成可选了。这意味着最后的诊断性消息完全平台特定。比如:

#include <type_traits>

template<typename T>
class C {
  // OK since C++11:
  static_assert(std::is_default_constructible<T>::value,
                "class C: elements must be default-constructible");
                // OK since C++17:
  static_assert(std::is_default_constructible_v<T>);
  ...
};

没有传消息的断言使用了新的type trait后缀_v

8.9 预处理条件__has_include

C++17扩展了预处理起,可以检查一个特定的头文件是否被include。比如:

#if __has_include(<filesystem>)
# include <filesystem>
# define HAS_FILESYSTEM 1
#elif __has_include(<experimental/filesystem>)
# include <experimental/filesystem>
# define HAS_FILESYSTEM 1
# define FILESYSTEM_IS_EXPERIMENTAL 1
#elif __has_include("filesystem.hpp") # include "filesystem.hpp" # define HAS_FILESYSTEM 1
# define FILESYSTEM_IS_EXPERIMENTAL 1
#else
# define HAS_FILESYSTEM #if __has_include(<filesystem>)
# include <filesystem>
# define HAS_FILESYSTEM 1
#elif __has_include(<experimental/filesystem>)
# include <experimental/filesystem>
# define HAS_FILESYSTEM 1
# define FILESYSTEM_IS_EXPERIMENTAL 1
#elif __has_include("filesystem.hpp") # include "filesystem.hpp" # define HAS_FILESYSTEM 1
# define FILESYSTEM_IS_EXPERIMENTAL 1
#else
# define HAS_FILESYSTEM 0
#endif0
#endif

如果#include成功则__has_include(...)会求值为1(true)。如果不成功则没有什么影响。

8.10 后记

嵌套namespace定义最初由Jon Jagger在2003年于https://wg21.link/n1524提出。Robert Kawulak在2014年于https://wg21.link/n4026提出了新的提案。最后这个特性的公认措辞是由Robert Kawulak 和 Andrew Tomazos在https://wg21.link/n4230中给出。

重新定义后的求值顺序最初由Gabriel Dos Reis, Herb Sutter和Jonathan Caves在https://wg21.link/n4228中提出。最后这个特性的公认措辞是由Gabriel Dos Reis, Herb Sutter和Jonathan Caves在https://wg21.link/p0145r3中给出。

更宽松的枚举初始化最初由Gabriel Dos Reis在https://wg21.link/p0138r0中提出。最后这个特性的公认措辞是由Gabriel Dos Reis在https://wg21.link/p0138r2中给出。

修复带auto和直接列表初始化一起使用产生的矛盾行为最初由Ville Voutilainen在 https://wg21.link/n3681https://wg21.link/3912中提出。最后这个特性的公认措辞是由 James Dennett在https://wg21.link/n3681中给出。

十六进制浮点值最初由Thomas Koppe在https://wg21.link/p0245r0中提出。最后这个特性的公认措辞是由Thomas Koppe在https://wg21.link/p0245r1中给出。

UTF-8字符串字面值最初由 Richard Smith在https://wg21.link/n4197中提出。最后这个特性的公认措辞是由 Richard Smith在https://wg21.link/n4267中给出。

异常声明成为类型的一部分最初由Jens Maurer在https://wg21.link/n4320中提出。最后这个特性的公认措辞是由Jens Maurer在https://wg21.link/p0012r1中给出。

单参数的static_assert的公认措辞是由Walter E. Brown在https://wg21.link/n3928中给出。

预处理条件__has_include最初由Clark Nelson和RichardSmith在https://wg21.link/p0061r0中作为其中一部分提出。最后这个特性的公认措辞是由Clark Nelson和RichardSmith在https://wg21.link/p0061r1中给出。

第二部分

模板特性

这一部分介绍C++17泛型编程相关特性。

我们从类型模板参数推导开始,它影响了模板的使用,后面的篇章介绍了范型代码相关特性

第九章 类模板参数推导

C++17之前,你必须显式指定类模板的所有模板参数类型。比如,你不能忽略这里的double:

std::complex<double> c{5.1,3.3};

也不能忽略第二次的std::mutex

std::mutex mx;
std::lock_guard<std::mutex> lg(mx);

C++17开始,必须显式指定类模板的所有模板参数类型这个限制变得宽松了。有了类模板参数推导(class template argument deduction,CTAD)技术,如果构造函数可以推导出所有模板参数,那么你可以跳过显式指定模板实参。

比如:

  • 你可以这样声明:
std::complex c{5.1,3.3}; // OK: std::complex<double> deduced
  • 你可以这样实现:
std::mutex mx;
std::lock_guard lg{mx}; // OK: std::lock_guard<std_mutex> deduced
  • 你甚至可以让容器推导其元素的类型:
std::vector v1 {1, 2, 3} // OK: std::vector<int> deduced
std::vector v2 {"hello", "world"}; // OK: std::vector<const char*> deduced

9.1 使用类模板参数推导

只要传给构造函数的实参可以用来推导类型模板参数,那么就可以使用类模板参数推导技术。该技术支持所有初始化方式:

std::complex c1{1.1, 2.2}; // deduces std::complex<double>
std::complex c2(2.2, 3.3); // deduces std::complex<double>
std::complex c3 = 3.3; // deduces std::complex<double>
std::complex c4 = {4.4}; // deduces std::complex<double>

c3和c4的初始化方式是可行的,因为你可以传递一个值来初始化std::complex<>,这对于推导出模板参数T来说足够了,它会被用于实数和虚数部分:

namespace std {
  template<typename T>
  class complex {
    constexpr complex(const T& re = T(), const T& im = T());
  ...
  }
};

假设有如下声明

std::complex c1{1.1, 2.2};

编译器会在调用的地方找到构造函数

constexpr complex(const T& re = T(), const T& im = T());

因为两个参数T都是double,所以编译器推导出T是double,然后编译下面的代码:

complex<double>::complex(const double& re = double(),
                         const double& im = double());

注意模板参数必须是无歧义、可推导的。因此,下面的初始化是有问题的:

std::complex c5{5,3.3}; // ERROR: attempts to int and double as T

对于模板来说,不会在推导模板参数的时候做类型转换。

对于可变参数模板的类模板参数推导也是支持的。比如,std::tuple<>定义如下:

namespace std {
  template<typename... Types>
  class tuple;
    public:
    constexpr tuple(const Types&...);
    ...
  };
};

这个声明:

std::tuple t{42, 'x', nullptr};

推导出的类型是std::tuple<int, char, std::nullptr_t>

你也可以推导出非类型模板参数。举个例子,像下面例子中传递一个数组,在推导模板参数的时候可以同时推导出元素类型和数组大小:

template<typename T, int SZ>
class MyClass {
public:
  MyClass (T(&)[SZ]) {
    ...
  }
};
MyClass mc("hello"); // deduces T as const char and SZ as 6

SZ推导为6,因为模板参数类型传递了一个六个字符的字符串字面值。

你甚至可以推导出用作基类的lambda的类型,或者推导出auto模板参数类型。

9.1.1 默认拷贝

如果类模板参数推导发现一个行为更像是拷贝初始化,它就倾向于这么认为。比如,在用一个元素初始化std::vector后:

std::vector v1{42}; // vector<int> with one element

用这个vector去初始化另一个vector:

std::vector v2{v1}; // v2 also is vector<int>

v2会被解释为vector<int>而不是vector<vector<int>>

又比如,这个规则适用于下面所有初始化形式:

std::vector v3(v1); // v3 also is vector<int>
std::vector v4 = {v1}; // v4 also is vector<int>
auto v5 = std::vector{v1}; // v5 also is vector<int>

如果传递多个元素时,就不能被解释为拷贝初始化,此时initializer list的类型会成为新vector的元素类型:

std::vector vv{v, v}; // vv is vector<vector<int>>

那么问题来了,如果传递可变参数模板,那么类模板参数推导会发生什么:

template<typename... Args>
auto make_vector(const Args&... elems) {
  return std::vector{elems...};
}

std::vector<int> v{1, 2, 3};
auto x1 = make_vector(v, v); // vector<vector<int>>
auto x2 = make_vector(v); // vector<int> or vector<vector<int>> ?

当前,不同的编译器有不同的处理方式,这个问题还在讨论中。

9.1.2 推导lambda的类型

有了类模板参数推导,我们现在终于可以用lambda的类型实例化类模板类。举个例子,我们可以提供一个泛型类,然后包装一下callback,并统计调用了多少次callback:

// tmpl/classarglambda.hpp
#include <utility> // for std::forward()

template<typename CB>
class CountCalls
{
private:
  CB callback; // callback to call
  long calls = 0; // counter for calls
public:
  CountCalls(CB cb) : callback(cb) {
  }
  template<typename... Args>
  auto operator() (Args&&... args) {
    ++calls;
    return callback(std::forward<Args>(args)...);
  }
  long count() const {
    return calls;
  }
};

这里,构造函数接受一个callback,然后包装一下,用它的类型来推导出模板参数CB。比如,我们可以传一个lambda:

CountCalls sc([](auto x, auto y) {
                   return x > y;
             });

这意味着sc的类型被推导为CountCalls<TypeOfTheLambda>

通过这种方式,我们可以计算传递给排序函数的sc的调用次数:

std::sort(v.begin(), v.end(),
          td::ref(sc));
std::cout << "sorted with " << sc.count() << " calls\n";

包装后的lambda通过引用的方式传递给排序函数,因为如若不然std::sort()只会计算传递给他的lambda的拷贝的调用,毕竟是传值的方式。

然而,我没可以传递包装后的lambda给std::for_each,因为这个算法可以返回传递给他的callback的拷贝:

auto fo = std::for_each(v.begin(), v.end(),
                        CountCalls([](auto i) {
                                      std::cout << "elem: " << i << '\n';
                        }));
std::cout << "output with " << fo.count() << " calls\n";

9.1.3 非部分类模板参数推导

不像函数模板那样,类模板参数不能部分推导(显示模板参数的一部分)。比如:

template<typename T1, typename T2, typename T3 = T2>
class C {
public:
  C (T1 x = T1{}, T2 y = T2{}, T3 z = T3{}) {
    ...
  }
  ...
};
// all deduced:
C c1(22, 44.3, "hi"); // OK: T1 is int, T2 is double, T3 is const char*
C c2(22, 44.3); // OK: T1 is int, T2 and T3 are double
C c3("hi", "guy"); // OK: T1, T2, and T3 are const char*
// only some deduced:
C<string> c4("hi", "my"); // ERROR: only T1 explicitly defined
C<> c5(22, 44.3); // ERROR: neither T1 not T2 explicitly defined
C<> c6(22, 44.3, 42); // ERROR: neither T1 nor T2 explicitly defined
// all specified:
C<string,string,int> c7; // OK: T1,T2 are string, T3 is int
C<int,string> c8(52, "my"); // OK: T1 is int,T2 and T3 are strings
C<string,string> c9("a", "b", "c"); // OK: T1,T2,T3 are strings

因为第三个模板参数类型有默认值,所以如果已经指定了第二个就可以省略第三个。

如果i想知道为什么不支持偏特化,下面是造成这个抉择的原因:

std::tuple<int> t(42, 43); // still ERROR

std::tuple是一个可变参数模板,所以你可以指定任意数量的参数。在这种情况下,到底是认为这是只指定了一个类型的而导致的错误还是有意为之很难说清。看起来是有问题的。后期有更多考量后,偏特化也有可能加入C++标准。尽管目前没有。

不幸的是,缺少部分特化就不能解决一个常见代码需求。对于关联容器的排序规则,或者无序容器的hash函数,我们仍然不能简单的传一个lambda:

std::set<Cust> coll([](const Cust& x, const Cust& y) { // still ERROR
  return x.name() > y.name();
});

我们还是得指定lambda的类型,因此需要像下面这样写:

auto sortcrit = [](const Cust& x, const Cust& y) {
  return x.name() > y.name();
};
std::set<Cust, decltype(sortcrit)> coll(sortcrit); // OK

9.1.4 类模板参数推导代替便捷的工具函数。

有了类模板参数推导,我们可以不再使用那些目的仅是推导传的参数的类型的便捷工具函数。

最明显的是make_pair,他允许我们不指定传的参数的类型。比如,对于v:

std::vector<int> v;

我们可以使用

auto p = std::make_pair(v.begin(), v.end());

来代替

std::pair<typename std::vector<int>::iterator,typename std::vector<int>::iterator> p(v.begin(), v.end());

现在,make_pair()不再需要了,可以直接这么写:

std::pair p(v.begin(), v.end());

然而,std::make_pair()也是一个很好的例子,它说明了有时候工具函数不只是做模板参数推导一件事情。事实上,std::make_pair()也会类型退化,这意味着传入的string字面值会转换为const char*

auto q = std::make_pair("hi", "world"); // pair of pointers

在这个例子中,q的类型是std::pair<const char*, const char*>

使用类模板参数推导,情况变得更复杂。让我们看看一个简单的类声明,它有点像std::pair

template<typename T1, typename T2>
struct Pair1 {
  T1 first;
  T2 second;
  Pair1(const T1& x, const T2& y) : first{x}, second{y} {
  }
};

重点是元素通过引用传递。根据语言规则,当使用引用传递一个模板类型的实参时,形参不会_类型退化_,所谓类型退化是指将原生数组类型转换为原生指针类型这样一种机制。所以,当这样调用的时候:

Pair1p1{"hi","world"};//deduces pair of arrays of different size, but...

T1被推导为char[3],T2被推导为char[6]。基本上,这个推导是有效地。然而,当我们使用T1和T2类型去声明成员firstsecond时,结果是,它们被声明为:

char first[3];
char second[6];

并且从一个左值数组初始化出新数组是不被允许的。编译过程就像这样:

const char x[3] = "hi";
const char y[6] = "world";
char first[3] {x}; // ERROR
char second[6] {y}; // ERROR

注意如果直接使用值传递,然后用它的类型来声明成员就不会有这个问题:

template<typename T1, typename T2>
struct Pair2 {
  T1 first;
  T2 second;
  Pair2(T1 x, T2 y) : first{x}, second{y} {
  }
};

如果我们这样调用:

Pair2 p2{"hi", "world"}; // deduces pair of pointers

T1和T2将会被推导为const char*。 因为类std::pair<>被声明,所以构造函数使用引用传参,你可能期望看到下面的初始化代码不会被编译:

std::pair p{"hi", "world"}; // seems to deduce pair of arrays of different size, but...

但是它可以编。原因是我们用到了推导规则

9.2 推导规则

你可以定义特定的推导规则来提供额外的类模板实参推导能力,或者修复已经存在的、由构造函数定义的推导。举个例子,你可以定义一个规则,无论何时Pair3被推导,类型推导看起来就像在操作值传递的参数一样:

template<typename T1, typename T2>
struct Pair3 {
  T1 first;
  T2 second;
  Pair3(const T1& x, const T2& y) : first{x}, second{y} {
  }
};
// deduction guide for the constructor:
template<typename T1, typename T2>
Pair3(T1, T2) -> Pair3<T1, T2>;

这里->左边是什么我们想推导什么。在这里例子中,我们想推导一个构造函数,其参数是值传递,类型是任意T1和T2。在->右边是我们想定义的推导结果。本例中,Pair3进行实例化。

你可能争辩说,这不就是构造函数做的事情吗。然而,构造函数的参数是引用传递,这里是值传递,不一样。通常来说,即使在模板外面,值传递的实参也会_类型退化_,而引用传递的实参不会退化。_类型退化_意味着原始数组转换为指针,顶级修饰符,比如const和引用符号,会被忽略。

没有类型推导规则,下面的声明:

Pair3 p3{"hi", "world"};

x的类型,即T1是const char[3],y的类型,即T2是const char[6]

因为有类型推导,模板参数会类型华为,意味着传递的数组或者字符串字面值会退化为对应的指针类型。现在当我们声明:

Pair3 p3{"hi", "world"};

推导规则被应用,两个参数类型都是const char*。推导后的类型就像我们直接这样写:

Pair3<const char*, const char*> p3{"hi", "world"};

注意,此时构造函数仍然是引用传参。推导规则只影响模板类型的推导,不影响T1、T2被推导后的构造函数调用。

9.2.1 使用推导规则强制类型退化

正如上面例子演示的那样,通常,这些重载规则的一个常见用途是确保一个模板参数T在推导过程中类型进行退化。考虑一个传统的类模板:

template<typename T>
struct C {
  C(const T&) {
  }
  ...
};

如果我们这里传一个字符串字面值"hello",T被推导为字符串字面值的类型,即const char[6]

C x{"hello"}; // T deduced as const char[6]

原因是当引用传值时,模板类型推导不会将它退化成对应的指针类型。 带上一个简单的推导规则:

template<typename T> C(T) -> C<T>;

我们就修复了这个问题:

C x{"hello"}; // T deduced as const char*

现在,因为推导规则是值传递,它的类型发生退化,所以"hello"的类型T最终是const char*。 出于这个原因,对于任意类模板,其构造函数带引用传递的参数,都给出这样一个推导规则是很合理的。C++标准库为pair和tuple提供了对应的推导规则(参见9.2.6)。

9.2.2 非模板推导规则

to translate

第十章 编译期if

通过语法if constexpr(...),编译器使用编译时表达式在编译时决定是否使用then部分或者else部分。如果任一部分被抛弃,那部分代码就不会生成。但是,这不意味着被抛弃的部分完全被忽略了。它将像未使用模板的代码一样进行检查。 比如:

#include <string>
template <typename T>
std::string asString(T x)
{
    if constexpr(std::is_same_v<T, std::string>) {
        return x; // statement invalid, if no conversion to string
    }
    else if constexpr(std::is_arithmetic_v<T>) {
        return std::to_string(x); // statement invalid, if x is not numeric
    }
    else {
        return std::string(x); // statement invalid, if no conversion to string
    }
}

这里我们用到了编译器if的特性。它在编译时决定我们是只返回一个字符串,还是说要调std::to_string()把数组转称字符串,又或者把传进来的参数转成std::string。因为无效的调用会被抛弃,下面的代码都可以编译:

#include "ifcomptime.hpp"
#include <iostream>

int main()
{
    std::cout << asString(42) << '\n';
    std::cout << asString(std::string("hello")) << '\n';
    std::cout << asString("hello") << '\n';
}

10.1 存在编译期if的动机

如果刚刚的例子中,我们用运行时if:

 #include <string>
template <typename T>
std::string asString(T x)
{
    if (std::is_same_v<T, std::string>) {
        return x; // ERROR, if no conversion to string
    }
    else if (std::is_numeric_v<T>) {
        return std::to_string(x); // ERROR, if x is not numeric
    }
    else {
        return std::string(x); // ERROR, if no conversion to string
    }
}

对应的调用代码肯定不能编译。这是因为函数模板有个规则,要么全编,要么完全不编。if的条件检查是一个运行时行为。即使编译期就知道条件一定是个false,then也必须编。所以,当传递一个std::string或者字符串字面值的时候,编译就出错了,因为对于传入的参数调用std::string是无效的。当传递数值时,编译也出错,因为第一个和第三个返回语句是无效的。

注意,仅当使用编译期if的时候,then或者else没有被用到的才会被丢弃:

  • 当传std::string时,第一个if的else部分被丢弃
  • 当传一个数值时,第一个if的then部分和最后的else被丢弃
  • 当传递一个字符串字面值时(即类型const char*),then和第二个if被丢弃

注意,被丢弃的语句不是说被忽略了。只是说当依赖模板参数时,它不会被实例化。语法必须正确,不依赖模板参数的调用也必须正确。事实上,在第一个翻译阶段(definition time)执行时,编译器会检查语法正确与否,使用的所有名字是否都不依赖模板参数。所有static_assert也必须正确,即使该分支不会被编译。比如

template<typename T>
void foo(T t)
{
    if constexpr(std::is_integral_v<T>) {
        if (t > 0) {
            foo(t-1); // OK
        }
    }
    else {
        undeclared(t); // error if not declared and not discarded (i.e., T is not integral)
        undeclared(); // error if not declared (even if discarded)
        static_assert(false, "no integral"); // always asserts (even if discarded)
    }
}

只要是符合标准的编译器,这个例子都不会被编译,原因有两个:

  • 即使T是整数类型,这个
undeclared(); // error if not declared (even if discarded)

没有声明的调用也是错的,因为它依赖模板参数

  • 这个静态断言
static_assert(false, "no integral");

总是失败,即使它所在的分支会被丢弃,原因还是因为它依赖一个模板参数。重复编译期条件的静态断言是可以的:

static_assert(!std::is_integral_v<T>, "no integral");

注意,一些编译器(比如Visual C++2013和1025)没有正确的实现两阶段模板翻译,它们把绝大多第一阶段(definition time)该做的事情推迟到第二阶段(instantiation time),所以无效的函数调用,甚至一些语法错误也可能通过编译。

10.2 使用编译期if

原则上,你可以使用编译器if做一些事情,看起来就像是运行时的if,只是条件是编译期的表达式。你也可以混用编译期间if和运行时if:

if constexpr (std::is_integral_v<std::remove_reference_t<T>>) {
    if (val > 10) {
        if constexpr (std::numeric_limits<char>::is_signed) {
            ...
        }
        else {
            ...
        }
    }
    else {
        ...
    }
}
else {
    ...
}

注意你不能在函数体外面使用if constexpr。因此,你不能在用它代替条件预处理器。

10.2.1 编译期if注意事项

即使有时看起来可以使用编译期if,有一些不明显的结果会出现,本小节会一一称述。

编译期if影响返回类型

编译期if可能影响函数的返回类型。比如,下面的代码总是可以编译,但是返回类型可能是不同的:

auto foo()
{
    if constexpr (sizeof(int) > 4) {
        return 42;
    }
    else {
        return 42u;
    }
}

因为我们用了auto,返回类型取决于返回语句,返回语句又取决于int的大小:

  • 如果size大于4,只有一个有效的返回语句返回42,所以返回类型是_int_
  • 否则,只有一个有效的返回语句返回42u,所以返回类型是_unsigned int_

事情还可能更魔幻。比如下面的例子,如果我们跳过else部分,返回类型可能是int或者void:

auto foo() // return type might be int or void
{
    if constexpr (sizeof(int) > 4) {
        return 42;
    }
}

如果这里的if运行时if那代码就不能编译,因为两个返回语句都会被编译器考虑,最后得出的结论是返回类型存在二义性。

即便then返回了,else也可能造成问题

对于运行时if语句,有一\种模式不适用于编译期if:如果then和else都有返回语句,而且能通过编译,你总是可以跳过运行时if的else部分。换句话说,下面这种代码:

if (...) {
    return a;
}
else {
    return b;
}

你总是可以改写为:

if (...) {
    return a;
}
return b;

这种模式不适用于编译期if,因为第二种形式的返回值取决于两个返回语句,而不是一个,可能会造成歧义。比如,改一下上面的例子,代码可能能编,也可能不能编:

auto foo()
{
    if constexpr (sizeof(int) > 4) {
        return 42;
    }
    return 42u;
}

如果条件是true,那么编译器推导出两个不同的返回类型,编不了。否则,只有一个返回语句,不会造成问题,所以可以编译。

编译期if的短路运算

考虑下面的代码:

template<typename T>
constexpr auto foo(const T& val)
{
    if constexpr (std::is_integral<T>::value) {
        if constexpr (T{} < 10) {
            return val * 2;
        }
    }
    return val;
}

我们有两个编译期条件,来决定是否直接返回val,或者翻倍再返回。 下面代码都可以编译:

constexpr auto x1 = foo(42);   //产生84
constexpr auto x2 = foo("hi"); //可以的,产生"hi"

在运行时if中的条件可以进行短路运算。你可能期望编译其if也有这种能力:

template<typename T>
constexpr auto bar(const T& val)
{
    if constexpr (std::is_integral<T>::value && T{} < 10) {
        return val * 2;
    }
    return val;
}

然而,编译期if的条件总是被实例化,需要作为整体来确定是否有效,所以传入一个不支持<10判断的类型编不了:

constexpr auto x2 = bar("hi"); // compile-time ERROR

所以,编译期if是不会短路实例化的。如果编译期条件的有效性取决于更早期的编译期条件,你不得不嵌套一下,也就是说,你不得不这样写:

if constexpr (std::is_same_v<MyType, T>) {
    if constexpr (T::i == 42) {
        ...
    }
}

而不是:

if constexpr (std::is_same_v<MyType, T> && T::i == 42) {
    ...
}

10.2.2 其他编译期if的例子

返回完美转发

一个编译期if的应用是返回值的完美转发。因为decltype(auto)不能被推导为void(因为void是不完全类型(incomplete type)),你必须这样写:

#include <functional> // for std::forward()
#include <type_traits> // for std::is_same<> and std::invoke_result<>

template<typename Callable, typename... Args>
decltype(auto) call(Callable op, Args&&... args)
{

    if constexpr(std::is_void_v<std::invoke_result_t<Callable, Args...>>) {
        // return type is void:
        op(std::forward<Args>(args)...);
        ... // do something before we return
        return;
    }
    else {
        // return type is not void:
        decltype(auto) ret{op(std::forward<Args>(args)...)};
        ... // do something (with ret) before we return
        return ret;
    }
}

编译期的tag派发

编译期if的一个传统应用是tag派发。在C++17之前,你必为你希望处理的类型提供完整的函数重载集合。现在有了编译期if,你可以把所有逻辑放到一个函数里面。举个例子,你可以不用像下面这样写一堆重载函数来实现std::advance()算法:

template<typename Iterator, typename Distance>
void advance(Iterator& pos, Distance n) {
    using cat = std::iterator_traits<Iterator>::iterator_category;
    advanceImpl(pos, n, cat); // tag dispatch over iterator category
}
template<typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n,
    std::random_access_iterator_tag) {
    pos += n;
}
template<typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n,
                 std::bidirectional_iterator_tag) {
    if (n >= 0) {
        while (n--) {
            ++pos;
        }
    }
    else {
        while (n++) {
            --pos;
        }
    }
}
template<typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n,                 std::input_iterator_tag) {
    while (n--) {
        ++pos;
    }
}

而是将所有行为在一个函数里面实现:

template<typename Iterator, typename Distance>
void advance(Iterator& pos, Distance n) {
    using cat = std::iterator_traits<Iterator>::iterator_category;
    
    if constexpr (std::is_same_v<cat,                       std::random_access_iterator_tag>) {
        pos += n;
    }
    else if constexpr (std::is_same_v<cat,
                       std::bidirectional_access_iterator_tag>) {
        if (n >= 0) {
            while (n--) {
                ++pos;
            }
        }
        else {
            while (n++) {
                --pos;
            }
        }
    }
    else { // input_iterator_tag
        while (n--) {
            ++pos;
        }
    }
}

在某种程度上,我们现在有一个编译期switch,虽然不同case是通过if constexpr来表达。注意,这里有一个区别:

  • 重载函数集合给你最佳匹配(best match)语意
  • 编译期if给你第一匹配(fisrt match)语意

另一个tag派发的例子是第一章里面使用编译期if的get<>()重载。 第三个例子是第十六章里面处理不同类型的std::variant<>()访问器。

10.3 编译期if初始化

注意编译期if也可以用于新的带初始化的if语法中。比如,如果有个constexpr函数foo(),你可以使用:

template<typename T>
void bar(const T x)
{
    if constexpr (auto obj = foo(x); std::is_same_v<decltype(obj), T>) {
        std::cout << "foo(x) yields same type\n";
        ...
    }
    else {
        std::cout << "foo(x) yields different type\n";
        ...
    }
}

你可以像上面一样让bar根据foo产生结果的值的类型是否与T相同,来产生不同的行为。 要根据foo(x)返回的值本身来决定不同行为,你可以这样:

constexpr auto c = ...;
if constexpr (constexpr auto obj = foo(c); obj == 0) {
    std::cout << "foo() == 0\n";
    ...
}

obj必须声明为constexpr,因为要在条件中使用它的值。

10.4 在模板外面使用编译期if

if constexpr可以被用于任何函数,不仅仅局限于模板。我们只需要编译期表达式产生的结果可以转换为bool值。然而,在那种情况下then和else的中的所有语句都必须是有效地,即便它们可能被抛弃。

比如,下面的代码总是不能编译,因为即便char是signed、else被抛弃,undeclared()这个调用也必须有效才行:

#include <limits>

template<typename T>
void foo(T t);

int main()
{
    if constexpr(std::numeric_limits<char>::is_signed) {
        foo(42); // OK
    }
    else {
        undeclared(42); // ALWAYS ERROR if not declared (even if discarded)
    }
}

下面的代码也不能编译,因为其中一个静态断言总是会失败:

if constexpr(std::numeric_limits<char>::is_signed) {
    static_assert(std::numeric_limits<char>::is_signed);
}
else {
    static_assert(!std::numeric_limits<char>::is_signed);
}

在模板代码外面使用编译期if唯一的好处是那些被抛弃的语句(必须有效)不需要编到最后的二进制代码中,减小了可执行程序的体积。比如下面的程序:

#include <limits>
#include <string>
#include <array>
int main()
{
    if (!std::numeric_limits<char>::is_signed) {
        static std::array<std::string,1000> arr1;
        ...
    }
    else {
        static std::array<std::string,1000> arr2;
        ...
    }
}

arr1或者arr2是最终可执行程序的一部分,但不会都是。

10.5 后记

编译期if最初由Walter Bright,Herb Sutter和Andrei Alexandrescu在https://wg21.link/n3329中提出。Ville Voutilainen在https://wg21.link/n4461提出了static if语言特性。在https://wg21.link/p0128r0中Ville Voutilainen第一次提出了constexpr_if(这个feature名字的起源)。最后的公认措辞是由Jens Maurer在https://wg21.link/p0292r2中给出。

第十一章 折叠表达式

自C++17起, 其特性有支持带一个(可带有初始值的)参数包(parameter pack)的所有实参能使用二元操作符并计算结果.

例如, 下列的函数能返回所有传入实参的和:

template <typename ...T>
auto foldSum(T... args) {
    return (... + args); // ((arg1 + arg2) + arg3)...
}

注意, return 表达式里的括号是折叠表达式的一部分并且不能省略.

函数调用 foldSum(47, 11, val, -1); 使模版实例化并执行: return 47 + 11 + val + -1;.

函数调用 foldSum(std::string("hello"), "world", "!"); 使模版实例化为: return std::string("hello") + "world" + "!";

还要注意, 折叠表达式实参的次序可以不同并且效果也不一样 (可能看起有点反直觉): 例如写成 (... + args) 的结果则是 ((arg1 + arg2) + arg3)..., 该含义是重复地“往后添加”(post-adds)东西. 你也可以写成 (args + ...), 该含义是重复地“往前添加”(pre-adds)东西, 因此其结果为: (arg1 + (arg2 + arg3))....

11.1 折叠表达式的目的

折叠表达式避免了需要递归地去实例化模版并作用于一个参数包的所有形参. 在 C++17 之前, 你必须这样实现:

template <typename T>
auto foldSumRec(T arg) {
    return arg;
}
template <typename T1, typename ...Ts>
auto foldSumRec(T1 arg1, Ts... otherArgs) {
    return arg1 + foldSumRec(otherArgs...);
}

这样的一种实现不仅写起来繁琐, 并且它也给 C++ 编译器造成负担. 使用

template <typename ...T>
auto foldSum(T... args) {
    return (... + args); // ((arg1 + arg2) + arg3)...
}

对于程序员和编译器双方的工作明显有所减少.

11.2 折叠表达式的使用

给定形参 args 和一个操作符 op, C++17 允许我们写成

  • 要么是一元左折叠(unary left fold) ( ... op args), 它将展开为: (...(arg1 op arg2) op ... argN-1) op argN)
  • 要么是一元右折叠(unary right fold) (args op ...), 它将展开为: (arg1 op (arg2 op ... (argN-1 op argN)...)

其中括号是必需的. 但是, 括号和省略号 (...) 不必用空格隔开.

比起知道左和右折叠表达式的预期结果, 理解两者的差别更重要. 例如, 甚至在使用 + 操作符时就有可能出现不同的效果. 在使用左折叠表达式时:

template <typename ...T>
auto foldSumL(T... args) {
    return (... + args); // ((arg1 + arg2) + arg3)...
}

调用 foldSumL(1, 2, 3) 则计算出 ((1 + 2) + 3). 这也意味着下列示例代码是能被编译的:

std::cout << foldSumL(std::string("hello"), "world", "!") << "\n"; // 编译通过.

记住操作符 + 用于标准字符串类型则至少有一个操作数是 std::string 类型. 因为使用了左折叠表达式, 则函数第一次调用将计算 std::string("hello") + "world", 其返回结果为一个 std::string 类型的字符串, 因此再加上字面形式的字符串 "!" 也是有效的.

然而, 以下的函数调用:

std::cout << foldSumL("hello", "world", std::string("!")) << "\n"; // 编译报错.

将不能被编译, 因为其计算得到 (("hello" + "world") + std::string("!")), 而两个字面形式的字符串是不允许用操作符 + 进行拼接的.

然而, 我们可以将实现改成:

template <typename ...T>
auto foldSumL(T... args) {
    return (args + ...); // (arg1 + (arg2 + arg3))...
}

调用 foldSumL(1, 2, 3) 则计算出 (1 + (2 + 3)). 这意味着下列示例代码就不再能被编译:

std::cout << foldSumL(std::string("hello"), "world", "!") << "\n"; // 编译报错.

而以下的函数调用现在能被编译:

std::cout << foldSumL("hello", "world", std::string("!")) << "\n"; // 编译通过.

因为几乎在所有情况下, 计算的次序都是从左至右, 通常, 参数包的左折叠语法(参数在末尾)应该更受青睐(除非它没有作用):

(... + args); // 更受青睐的折叠表达式语法

11.2.1 空参数包的处理

如果一个折叠表达式使用了空参数包, 则应用以下规则:

  • 如果使用了操作符 &&, 则其值为 true.
  • 如果使用了操作符 ||, 则其值为 false.
  • 如果使用了操作符 ,, 则其值是 void().
  • 其他操作符的调用则是不良形式 (ill-formed).

对于所有其他情况 (一般而言) 你可以添加一个初始值: 给定一个参数包 args, 一个初始值 value 和一个操作符 op, C++17 也允许我们写成:

  • 要么一个二元左折叠(binary left fold) (value op ... op args), 它将展开为: ((...((value op arg1) op arg2) op ... op argN-1) op argN) — 要么一个二元右折叠(binary right fold) (args op ... op value), 它将展开为: (arg1 op (arg2 op ... op (argN-1 op (argN op value))...))

在省略号两边的操作符 op 必须相同.

例如, 下列定义允许传递一个空参数包

template <typename ...T>
auto foldSum(T... s) {
    return (0 + ... + s); // sizeof...(s) == 0 的情况也可行
}

在概念上, 不论我们添加 0 作为首个操作数或最后一个操作数应该都无所谓.

template <typename ...T>
auto foldSum(T... s) {
    return (s + ... + 0); // sizeof...(s) == 0 的情况也可行
}

但对于一元折叠表达式其不同的计算次序则比预期结果更重要, 而二元左折叠表达式则更受青睐:

(value + ... + args); // 更受青睐的二元折叠表达式语法

还有, 首个操作数可能是特别的, 比如这个例子:

template <typename ...T>
void print(const T&... args)
{
    (std::cout << ... << args) << "\n";
}

这里, 重要的是首次调用是传递给 print() 的第一个实参的输出, 其返回的输出流作用于其它输出的调用. 其它实现可能无法编译甚至得到发生无法预料的事情. 例如, 使用

std::cout << (args << ... << "\n");

调用print(1) 将编译通过但打印出的值 1 会向左移10位 ('\n' 的值通常为 10), 因此输出的结果为 1024.

注意, 在这个例子 print() 中没有空格分隔参数包的各个元素. 这样的调用 print("hello", 42, "world") 将会打印 hello42world.

为了用空格将传入的元素分隔开, 你需要一个helper函数以确保除了第一个实参之外在打印前加上空格. 例如, 用以下 helper 函数模版 spaceBefore() 可以办到:

// tmpl/addspace.hpp
template <typename T>
const T& spaceBefore(const T& arg) {
    std::cout << ' ';
    return arg;
}

template <typename First, typename... Args>
void print(const First& firstarg, const Args&... args) {
    std::cout << firstarg;
    (std::cout << ... << spaceBefore(args)) << '\n';
}

这里, (std::cout << ... << spaceBefore(args)) 这个折叠表达式展开成: (std::cout << spaceBefore(arg1) << spaceBefore(arg2) << ...)

因此, 在参数包 args 中每个元素都调用一个helper函数, 在返回被传递的实参之前打印出一个空格字符, 写入输出流 std::cout 里. 为了确保这不会应用到第一个实参, 我们添加了额外的首个形参并且不对其使用 spaceBefore().

注意, 参数包的输出的计算需要所有输出在左边.

我们也能在print()里面使用lambda来定义spaceBefore():

template <typename First, typename ...Args>
void print(const First& firstarg, const Args&... args) {
    std::cout << firstarg;
    auto spaceBefore = [](const auto& arg) {
        std::cout << '';
        return arg;
    };
    (std::cout << ... << spaceBefore(args)) << '\n';
}

然而, 注意 lambda 通过值返回对象, 这意味着将创建传入实参的没必要的拷贝. 避免不必要拷贝的方式是通过显式声明lambda的返回类型要为const auto&decltype(auto):

template <typename First, typename ...Args>
void print(const First& firstarg, const Args&... args) {
    std::cout << firstarg;
    auto spaceBefore = [](const auto& arg) -> const auto& {
        std::cout << '';
        return arg;
    };
    (std::cout << ... << spaceBefore(args)) << '\n';
}

如果你不能够将这些语句组合成这样一条语句, 那你用的C++就不能称为真正的C++:

template <typename First, typename ...Args>
void print(const First& firstarg, const Args& ...args) {
    std::cout << firstarg;
    (std::cout << ... << [](const auto& arg) -> decltype(auto) {
                             std::cout << ' ';
                             return arg;
                          }(args)) << '\n';
}

不过, 一种更简单实现print()的方式是使用一个lambda打印空格和实参并将其传递给一个一元折叠表达式(脚注: 感谢 Barry Revzin 提出来):

template <typename First, typename ...Args>
void print(First first, const Args& ...args) {
    std::cout << first;
    auto outWithSpace = [](const auto& arg) {
                            std::cout << ' ' << arg;
                        };
    (..., outWithSpace(args));
    std::cout << '\n';
}

通过使用一个额外的用**auto声明的模版参数**, 我们可以使print()更灵活地将字符类型的分隔符, 字符串或任意其它可打印的类型参数化.

11.2.2 已支持的操作符

除了., ->, 和 [] 这些操作符之外, 你可以使用所有二元操作符作用于折叠表达式.

折叠的函数调用

折叠表达式

// tmpl/foldcalls.cpp
#include <iostream>

// 可变数目的基类模版
template <typename ...Bases>
class MultiBase : private Bases...
{
public:
    void print() {
        // 调用所有基类的 print()
        (..., Bases::print());
    }
};

struct A {
    void print() { std::cout << "A::print()\n"; }  
};

struct B {
    void print() { std::cout << "B::print()\n"; }
};

struct C {
    void print() { std::cout << "C::print()\n"; }
};

int main()
{
    MultiBase<A, B, C> mb;
    mb.print();
}

这里,

template <typename ...Bases>
class MultiBase : private Bases...
{
    ...
};

允许我们用可变数目的基类初始化对象:

MultiBase<A, B, C> mb;

并且使用

(..., Base::print());

这个折叠表达式被展开为调用每一个基类的print. 这个折叠表达式展开后如下所示:

(A::print(), B::print(), C::print());

然而, 注意到,操作符的性质与我们使用左折叠表达式或右折叠表达式没什么关系. 这些函数总是从左往右被调用. 使用

(Base::print(), ...);

这个括号只是将调用组合起来, 因此第一个print()和其它两个print()的结果组合了一起如下所示:

A::print(), (B::print(), C::print());

但因为,操作符的计算次序总是从左向右, 仍然是在括号里面两个为一组的函数调用之前先调用第一个函数, 并且仍然是中间的函数在右边函数之前调用.

尽管如此, 这就像左表达式的结果并且能跟其计算次序匹配上, 还是建议在折叠多个函数调用时使用左折叠表达式.

组合Hash函数

一个使用,操作符组合Hash值的例子. 这个例子如下:

template <typename T>
void hashCombine(std::size_t& seed, const T& val)
{
    seed ^= std::hash<T>()(val) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}

template <typename ...Type>
std::size_t combineHashValue(const Type& ...args)
{
    std::size_t seed = 0;           // 初始种子
    (..., hashCombine(seed, args)); // hashCombine() 调用链
    return seed;
}

通过调用

std::size_t combinedHashValue("Hello", "World", 42);

中间的这条语句展开成:

(hashCombine(seed, "Hello"), hashCombine(seed, "World")), hashCombine(seed, 42));

使用这个定义, 我们可以容易地为一个某个类型的对象定义一个新的Hash函数, 例如 Customer:

struct CustomerHash
{
    std::size_t operator()(const Customer& c) const {
        return combineHashValue(c.getFirstname(), c.getLastname(), c.getValue());
    }
};

这样我们就可以将 Customers 放入一个 std::unordered_set 的容器:

std::unordered_set<Customer, CustomerHash> coll;

折叠的路径遍历

你也可以使用折叠表达式去遍历一个二叉树的路径通过操作符->*:

// tmpl/foldtraverse.cpp
// 定义二叉树结构和用于遍历的helper函数.
struct Node {
    int value;
    Node* left;
    Node* right;
    Node(int i = 0) : value(i), left(nullptr), right(nullptr) {}
    ...
};
auto left = &Node::left;
auto right = &Node::right;

// 使用折叠表达式遍历树:
template <typename T, typename ...TP>
Node* traverse(T np, TP... paths) {
    return (np ->* ... ->* paths); // np ->* path1 ->* path2 ...
}

int main()
{
    // 初始二叉树的结构:
    Node* root = new Node{0};
    root->left = new Node{1};
    root->left->right = new Node{2};
    ...
    // 遍历二叉树:
    Node* node = traverse(root, left, right);
    ...
}

这里,

(np ->* ... ->* paths)

使用一个折叠表达式从np开始去遍历可变数目的paths的元素. 当调用:

traverse(root, left, right);

这个折叠表达式的调用展开成:

root->left->right

11.2.3 使用折叠表达式作用于类型

// tmpl/ishomogeneous.hpp
#include <type_traits>

// 检查传递的类型是否为同一类:
template <typename T1, typename ...TN>
struct IsHomogeneous {
    static constexpr bool value = (std::is_same<T1, TN>::value && ...);
};

// 检查传递的实参是否有相同类型:
template <typename T1, typename ...TN>
constexpr bool isHomogeneous(T1, TN...)
{
    return (std::is_same<T1, TN>::value && ...);
}

这个类型 trait IsHomogeneous<> 可被使用如下:

IsHomogeneous<int, Size, decltype(42)>::value

此情况下, 这个初始化成员变量value的折叠表达式展开成:

std::is_same<int, MyType>::value && std::is_same<int, decltype(42)>::value

这个函数模版isHomogeneous<>() 可被使用如下:

isHomogeneous(43, -1, "hello", nullptr)

此情况下, 这个初始化成员变量value的折叠表达式展开成:

std::is_same<int, int>::value && std::is_same<int, const char*>::value && std::is_same<int, std::nullptr_t>::value

通常, 操作符&&是短路的(第一false则终止计算).

在标准库里的**std::arary<>的推导规则**使用这种特性.

11.3 后记

折叠表达式最初由Andrew Sutton和Richard Smith在https://wg21.link/n4191中提出. 最后这个特性的公认措辞由Andrew Sutton和Richard Smith在https://wg21.link/n4295中制定的. Thibaut Le Jehan 在 https://wg21.link/n0036 中提出了删除对操作符*, +, &|支持空参数包的情况.

第十二章 String作为模板参数

一直以来,C++对于哪些类型可以用于模板参数的规则在逐渐放松,C++17更是如此。即使在当前作用域外定义的模板,也可以使用它作为模板参数。

12.1 在模板中使用string

非类型模板参数只能是整数类型(包括枚举),指向对象、函数、成员的指针,对象或者函数的左值引用,以及std::nullptr_t(nullptr的类型)。

对于指针,链接是必须的,这意味着你不能直接传递字符串字面值。然而,从C++11(译注:这里原文是C++17,可能是笔误)开始,你可以传一个内部链接(internal linkage)的指针。比如

template<const char* str>
class Message {
    ...
};
extern const char hello[] = "Hello World!"; // external linkage
const char hello11[] = "Hello World!"; // internal linkage

void foo()
{
    Message<hello> msg; // OK (all C++ versions)
    Message<hello11> msg11; // OK since C++11
    static const char hello17[] = "Hello World!"; // no linkage
    Message<hello17> msg17; // OK since C++17
}

也就是说,从C++17开始,你仍然需要写两行代码来传字符串字面值给模板,但是现在第一行可以放到和类实例化相同的作用域。 这种能力解决了一个很不幸的约束:从C++11开始你可以传指针给类模板:

template<int* p> struct A {
};

int num;
A<&num> a; // OK since C++11

但你不能使用返回一个地址的编译时函数作为模板参数,但是C++17开始允许这么做了:

int num;
...

constexpr int* pNum() {
    return &num;
}
A<pNum()> b; // ERROR before C++17, now OK

12.2 后记

对于所有非类型模板参数允许常量求值这种能力首先由Richard Smith在https://wg21.link/n4198中提出。最后的公认措辞是由Richard Smith在https://wg21.link/n4268中给出。

第十三章 auto作为模板参数占位符

从C++17开始,你可以使用占位符类型(autodecltype(auto)作为非类型模板参数。这意味着我们可以针对不同类型的非模板参数写泛型代码。

13.1 使用auto作为模板参数

从C++17开始,你可以使用auto来声明一个非类型模板参数。比如:

template<auto N> class S {
    ...
};

这允许我们针对不同类型都可以实例化非类型模板参数N:

S<42> s1; // OK: type of N in S is int
S<'a'> s2; // OK: type of N in S is char

然而,对于那些规则不允许的类型作为模板类型,这个特性仍然是没用的,即不会实例化成功:

S<2.5> s3; // ERROR: template parameter type still cannot be double

我们甚至在偏特化中可以写具体类型:

template<int N> class S<N> {
    ...
};

甚至支持类模板参数推导。比如:

template<typename T, auto N>
class A {
public:
    A(const std::array<T,N>&) {
    }
    A(T(&)[N]) {
    }
    ...
};

可以推导T的类型,N的类型,N的值。:

A a2{"hello"}; // OK, deduces A<const char, 6> with N being int

std::array<double,10> sa1;
A a1{sa1}; // OK, deduces A<double, 10> with N being std::size_t

你也可以修饰auto,比如,要求模板参数的类型是一个指针:

template<const auto* P> struct S;

使用可变参数模板,你可以参数化模板,使用一堆同构模板参数:

template<auto... VS> class HeteroValueList {
};

或者一堆异构模板参数:

template<auto V1, decltype(V1)... VS> class HomoValueList {
};

比如:

HeteroValueList<1, 2, 3> vals1; // OK
HeteroValueList<1, 'a', true> vals2; // OK
HomoValueList<1, 2, 3> vals3; // OK
HomoValueList<1, 'a', true> vals4; // ERROR

13.1.1 参数化模板以适用字符和字符串

使用该特性的一种应用是允许同时传入字符和字符串作为模板参数。比如我们可以使用折叠表达式输出任意数量的参数的个数:

#include <iostream>

template<auto Sep = ' ', typename First, typename... Args>
void print(const First& first, const Args&... args) {
    std::cout << first;
    auto outWithSep = [](const auto& arg) {
                        std::cout << Sep << arg;
                      };
    (... , outWithSep(args));
    std::cout << '\n';
}

使用空格作为模板参数的一个默认参数,我们可以输出带空格分隔的参数:

template<auto Sep = ' ', typename First, typename... Args>
void print (const First& firstarg, const Args&... args) {
    ...
}

我们仍然可以调用:

std::string s{"world"};
print(7.5, "hello", s); // prints: 7.5 hello world

但是有了参数化的print(),而且带分隔符Sep,我们现在可以显式的传递一个不同的字符作为第一个模板参数:

print<' '>(7.5, "hello", s); // prints: 7.5-hello-world

因为用了auto,我们甚至可以传一个字符串字面值,但这样的话就不得不声明一个没有链接(linkage)的对象:

static const char sep[] = ", ";
print<sep>(7.5, "hello", s); // prints: 7.5, hello, world

或者我们可以传一个分隔符,只要类型是允许作为模板参数的:

print<-11>(7.5, "hello", s); // prints: 7.5-11hello-11world

13.1.2 定义元编程常量

另一个auto这个特性的使用是更容易的定义编译时常量。你不必这样定义:

template<typename T, T v>
struct constant
{
    static constexpr T value = v;
};

using i = constant<int, 42>;
using c = constant<char, 'x'>;
using b = constant<bool, true>;

现在你可以这样子做:

template<auto v>
struct constant
{
    static constexpr auto value = v;
};

using i = constant<42>;
using c = constant<'x'>;
using b = constant<true>;

也不必:

template<typename T, T... Elements>
struct sequence {
};

using indexes = sequence<int, 0, 3, 4>;

可以这样:

template<auto... Elements>
struct sequence {
};

using indexes = sequence<0, 3, 4>;

现在你甚至可以定义编译时对象来代表一系列异构类型的值:(有点像condensed tuple(译注:这里没理解condensed tuple啥意思,所以保留原文))

using tuple = sequence<0, 'h', true>;

13.2 使用auto作为变量模板参数

你也可以使用auto作为带变量模板(不要被变量模板(variable template)所困扰,它指的是模板化的变量,并且是可变参数模板,即带有任意数量的参数)的模板参数。比如。下面的声明,可能在一个头文件里,它定义一个变量模板,这个模板是元素类型和元素个数已经被参数化后的:

template<typename T, auto N> std::array<T,N> arr;

在每个翻译单元中,所有使用arr<int,10>的地方都共享一个全局对象,而arr<long,10>arr<int,10u>将会是不同的全局对象。

下面的头文件将展示一个完整的例子:

#ifndef VARTMPLAUTO_HPP
#define VARTMPLAUTO_HPP

#include <array>

template<typename T, auto N> std::array<T,N> arr{};

void printArr();

#endif // VARTMPLAUTO_HPP

一个翻译单元可以修改这个变量模板的两个不同实例的值:

#include "vartmplauto.hpp"

int main()
{
    arr<int,5>[0] = 17;
    arr<int,5>[3] = 42;
    arr<int,5u>[1] = 11;
    arr<int,5u>[3] = 33;
    printArr();
}

另一个翻译单元可以打印这两个变量:

#include "vartmplauto.hpp"
#include <iostream>

void printArr()
{
    std::cout << "arr<int,5>: ";
    for (const auto& elem : arr<int,5>) {
        std::cout << elem << ' ';
    }
    std::cout << "\narr<int,5u>: ";
    for (const auto& elem : arr<int,5u>) {
        std::cout << elem << ' ';
    }
    std::cout << '\n';
}

输入结果如下:

arr<int,5>: 17 0 0 42 0
arr<int,5u>: 0 11 0 33 0

与之相同的方式,你可以声明一个常量,其类型是从初始值推导出来的:

template<auto N> constexpr auto val = N; // OK since C++17

然后后面使用它,比如,像下面一样:

auto v1 = val<5>; // v1 == 5, v1 is int
auto v2 = val<true>; // v2 == true, v2 is bool
auto v3 = val<'a'>; // v3 == ’a’, v3 is char

为了说明发生了什么,可以看看下面的例子:

std::is_same_v<decltype(val<5>), int> // yields false
std::is_same_v<decltype(val<5>), const int> // yields true
std::is_same_v<decltype(v1), int>; // yields true (because auto decays)

13.3 使用decltype(auto)作为模板参数

你可以使用另一种占位符类型,由C++14引入的decltype(auto)。注意,这个东西对于类型是如何推导的有非常特殊的规则。根据decltype的规则,如果传了个表达式而不是名字,它会根据表达式的值范畴(参见5.3)来推导类型:

  • prvalue的类型是type(比如临时变量)
  • lvalue的类型是type&(比如对象名字)
  • xvalue的类型是type&&(比如通过std::move()将对象转换为右值引用)

这意味着,你可以很容易的把参数模板推导成引用,其结果可能出乎意料:

#include <iostream>
template<decltype(auto) N>

struct S {
    void printN() const {
        std::cout << "N: " << N << '\n';
    }
};

static const int c = 42;
static int v = 42;

int main()
{
    S<c> s1; // deduces N as const int 42
    S<(c)> s2; // deduces N as const int& referring to c
    s1.printN();
    s2.printN();

    S<(v)> s3; // deduces N as int& referring to v
    v = 77;
    s3.printN(); // prints: N: 77
}

13.4 后记

对于费类型模板参数可以使用占位符类型首先由James Touton和Michael Spertus在https://wg21.link/n4469中作为其一部分提出。最后的公认措辞是由James Touton和Michael Spertus在https://wg21.link/p0127r2中给出。

第十四章 扩展Using声明

扩展的using声明可以使用逗号分隔一系列声明,允许它们打包写到一起,后面会自动展开。 比如,你可以这样编程:

class Base {
    public:
        void a();
        void b();
        void c();
};

class Derived : private Base {
    public:
        using Base::a, Base::b, Base::c;
};

C++17之前,你得写三行using声明代替。

14.1 使用可变参数using声明

逗号分隔的using声明提供一种能力

第十五章 std::optional<>

编程的时候我们经常遇到的一个情况是可能需要返回/传递/使用某个对象。也就是说,我们可能要获取某个类型的值,也可能完全不获取。因此,我们需要一种方式来模拟这种类似指针的语义,当不需要值的时候给它传nullptr。处理这种情况的通常方式是定义一个类型,里面包含了值本身,同时还带一个布尔值的成员(或者说flag)来标示值是否存在。

**可空对象(Optional objects)**就是这样的,它的内部内存主要包含一个对象加上一个布尔类型的flag。因此,它的大小通常是比包含的对象要大一个字节。对于某些包含的对象,鉴于额外的信息可以放到包含的对象里面,可空对象本身甚至可以做到零额外开销。不会分配内存。可空对象和被包含的对象完全一致。

然而,可空对象不仅仅是在结构布局上比普通对象多了个布尔值的flag。举个例子,如果没有值,那么被包含对象的构造函数也不会被调用(因此,这种情况下你可以给这些对象填充一个默认的状态)。

如同std::variant<>std::any对象一样,可控对象也有值语义。也即是说,拷贝操作的底层实现是深拷贝,会创建一个完全不同的被包含的对象的副本以及布尔flag。拷贝不带包含值的std::optional<>可选对象开销非常小。拷贝一个带包含值的std::optional<>开销大不大完全取决于拷贝这个包含值。另外可空对象也支持移动语义。

15.1 使用std::optional<>

std::optional<>塑造了一个可容纳类型的、可空的对象。这个对象可以是成员,也可以是实参,又或者返回值。你也可以说std::optional<>是一个容器,容纳最多一个元素(译注:最少零个)。

15.1.1 可空的返回值

下面的程序演示了std::optional<>用来作为返回值的能力:

#include <optional>
#include <string>
#include <iostream>

// convert string to int if possible:
std::optional<int> asInt(const std::string& s)
{
    try {
        return std::stoi(s);
    }
    catch (...) {
        return std::nullopt;
    }
}
int main()
{
    for (auto s : {"42", " 077", "hello", "0x33"} ) {
        // try to convert s to int and print the result if possible:
        std::optional<int> oi = asInt(s);
        if (oi) {
            std::cout << "convert '" << s << "' to int: " << *oi << "\n";
        }
        else {
            std::cout << "can't convert '" << s << "' to int\n";
        }
    }
}

程序里面asInt()这个函数将传过来的字符串转换为一个int值。然而,这可能会失败。出于这个原因,我们用std::optional<>,这样我们就可以返回“无int”并且避免了用一个特殊int值表示失败或者给调用者抛一个异常。

因此,我们既可以调用stoi()来初始化可空对象,也可以返回std::nullopt,告诉调用者我们没有int要返回给你。我们可以用下面的代码实现相同的行为:

std::optional<int> asInt(const std::string& s)
{
    std::optional<int> ret; // initially no value
    try {
        ret = std::stoi(s);
    }
    catch (...) {
    }
    return ret;
}

main()中我们调用这个函数,然后传给它不同的字符串:

for (auto s : {"42", " 077", "hello", "0x33"} ) {
    // convert s to int and use the result if possible:
    std::optional<int> oi = asInt(s);
    ...
}

对于每个返回的std::optional<int> oi,我们都要检查一下是否有值(通过查布尔flag得知)然后通过对可空对象“解引用”来访问被包含的对象:

if (oi) {
    std::cout << "convert '" << s << "' to int: " << *oi << "\n";
}

注意字符串“0x33”调用asInt()拿到了0,因为stoi()不对十六进制字符串做解析。 还有一只可选的方式来实现返回值处理:

if (oi.has_value()) {
    std::cout << "convert '" << s << "' to int: " << oi.value() << "\n";
}

这里,has_value()用于检查可空对象是否包含值,然后用value()访问值。value()operator *更安全:如果没有值,它抛出异常(译注:而不是对空对象解引用产生段错误)。operator *应该只被用于那种你非常确信可空对象不是空的的场景。否则你的程序将产生未定义行为(undefined behavior)。 注意我们也可以使用新标准库类型std::string_view来优化asInt()

15.1.2 可空对象作为实参和对象成员

另一个使用std::optional<>的例子是传实参并且/或者将它作为对象的数据成员:

#include <string>
#include <optional>
#include <iostream>

class Name
{
    private:
        std::string first;
        std::optional<std::string> middle;
        std::string last;
    public:
        Name (std::string f,
              std::optional<std::string> m,
              std::string l)
          : first{std::move(f)}, middle{std::move(m)}, last{std::move(l)} {
        }
        friend std::ostream& operator << (std::ostream& strm, const Name& n) {
            strm << n.first << ' ';
            if (n.middle) {
                strm << *n.middle << ' ';
            }
            return strm << n.last;
        }
};

int main()
{
    Name n{"Jim", std::nullopt, "Knopf"};
    std::cout << n << '\n';
    Name m{"Donald", "Ervin", "Knuth"};
    std::cout << m << '\n';
}

Name表示名字,由first namemiddle namelast name三个成员组成。由于middle name有可能不存在,因此它被定义为可空对象,这样构造函数就可以传一个std::nullopt来表示没有middle name。这和middle name是空字符串是不一样的。

注意通常类型都带值语义,定义一个初始化对应成员的构造函数的最好方式是实参值传递,然后构造函数里面移动形参到成员。

还有一点也要注意,std::optional<>改变了middle name的访问方式。将middle作为布尔表达式可以知道是否存在middle name,不过要想访问它的值还是得*middle(如果有的话)。

另一种访问可空对象包含的值的方式是使用成员函数value_or(),它可以指定一个备选值,当可空对象真的空的时候这个备选值将作为结果。举个例子,我们可以这样做:

std::cout << middle.value_or(""); // print middle name or nothing

15.2 std::optional<>类型和操作

这一小节描述了std::optional<>的类型和操作细节。

15.2.1 std::optional<>类型

在C++标准库头文件<optional>使用下面的形式定义std::optional<>

namespace std{
    template<typename T> class optional;
}

另外,还定义了下面的对象和对象:

  • 定义了nullopt对象,它的类型是std::nullopt_t,它表示可空对象没有值
  • 定义了异常类std::bad_optional_access,继承自std::exception,如果访问空的可空对象将引发此异常。

15.2.2 std::optional<>操作

下表列出了针对std::optional<>的所有操作。

构造

特殊的构造函数可以直接传递包含的值作为构造函数参数:

  • 你可以创建一个空的可空对象。
std::optional<int> o1;
std::optional<int> o2(std::nullopt);
  • 你可以传值来初始化可空对象里面包含的对象。因为类模板参数推导规则,你不用非得指定包含对象的类型:
std::optional o3{42}; // deduces optional<int>
std::optional<std::string> o4{"hello"};
std::optional o5{"hello"}; // deduces optional<const char*>
操作效果
构造创建一个可空对象(可能会调用包含的对象的构造函数)
make_optional<>()创建一个可空对象(传值初始化它
析构函数销毁可空对象
=赋新值
emplace()给包含的对象赋新值
reset()销毁值(让可空对象变空)
has_value()可空对象是否为空
强制类型转换为bool可空对象是否为空
*访问被包含的值(如果可空对象为空的时候执行该操作会产生未定义行为)
->访问被包含的值(如果可空对象为空的时候执行该操作会产生未定义行为)
value()访问被包含的值(如果可空对象为空的时候执行该操作会引发异常)
value_or()访问被包含的值(如果可空对象为空的时候执行该操作会返回备选值)
swap()交换两个可空对象
==,!=,<,<=,>,>=比较两个可空对象
hash<>计算两个可空对象的哈希值
  • 要用多个值初始化可空对象,你必须直接创建该对象,或者std::in_place作为第一个参数然后传递剩下的值(因为被包含的类型不能推导):
std::optional o6{std::complex{3.0, 4.0}};
std::optional<std::complex<double>> o7{std::in_place, 3.0, 4.0};

注意第二种形式避免了临时对象的创建。使用这种形式你甚至可以传递一个initializer list再加上额外的参数:

// initialize set with lambda as sorting criterion:
auto sc = [] (int x, int y) {
    return std::abs(x) < std::abs(y);
};
std::optional<std::set<int,decltype(sc)>> o8    {std::in_place,
                                                {4, 8, -7, -2, 0, 5},
                                                sc};
  • 你可以拷贝可空对象
std::optional o5{"hello"}; // deduces optional<const char*>
std::optional<std::string> o9{o5}; // OK

注意还有一个便捷函数make_optional<>(),它允许你用一个或者多个参数初始化可空对象(不需要in_place作为第一个参数)。通常用make...系列函数都会导致类型退化(译注:decay):

auto o10 = std::make_optional(3.0); // optional<double>
auto o11 = std::make_optional("hello"); // optional<const char*>
auto o12 = std::make_optional<std::complex<double>>(3.0, 4.0);

但是请注意没有构造函数可以根据一个参数来推导他的类型,不管可空对象初始化带不带值。因此,必须使用operator ?。举个例子:

std::multimap<std::string, std::string> englishToGerman;
...
auto pos = englishToGerman.find("wisdom");
auto o13 = pos != englishToGerman.end()
            ? std::optional{pos->second}
            : std::nullopt;

因为类模板参数推导规则std::optional{pos->second}o13被初始化为std::optional<std::string>类型。对于std::nullopt,模板类型推导无法正常工作,但是在推导表达式最终类型的时候operator ?可以将它转换为这个类型。

访问值

要检查可空对象是不是空的,你可以将它作为布尔表达式或者调用has_value()函数:

std::optional o{42};

if (o) ... // true
if (!o) ... // false
if (o.has_value()) ... // true

接下来要访问值,可以用类似指针的语法。你可以直接用operator *访问包含的对象,用operator->访问包含的对象的成员:

std::optional o{std::pair{42, "hello"}};

auto p = *o; // initializes p as pair<int,string>
std::cout << o->first; // prints 42

注意这些操作都要求可空对象本身不为空。如果可空对象为空又执行这些操作将会产生未定义行为:

std::optional<std::string> o{"hello"};
std::cout << *o; // OK: prints "hello"
o = std::nullopt;
std::cout << *o; // undefined behavior

虽然第二个是未定义行为,但是实践中它很可能会通过编译并且执行结果和第一个一样,都输出"hello",因为可空对象管理的内存没有被修改。 然而,你不能,也不应该依赖这个。如果你不知道可空对象是不是空的,那么请事先检查:

if (o) std::cout << *o; // OK (might output nothing)

或者你可以用value()检查,它会跑抛出std::bad_optional_access_exception

std::cout << o.value(); // OK (throws if no value)

std::bad_optional_access_exception直接继承自std::exception。 最后,你可以在检查是否为空的时候传一个备选值,如果可空对象真的是空的那么将返回这个备选值:

std::cout << o.value_or("fallback"); // OK (outputs fallback if no value)

备选值是通过右值引用的方式传递的,所以如果备选值没有被使用,整个传递过程零开销,如果被使用,走的是移动语义。

请注意operator *value()一样都是返回的被包含的对象的引用。因此,你必须小心操作这些临时返回值。比如:

std::optional<std::string> getString();
...
auto a = getString().value(); // OK: copy of contained object
auto b = *getString(); // ERROR: undefined behavior if std::nullopt
const auto& r1 = getString().value(); // ERROR: reference to deleted contained object
auto&& r2 = getString().value(); // ERROR: reference to deleted contained object

有时候你可能会像下面一样把它用于range-based循环中:

std::optional<std::vector<int>> getVector();
...
for (int i : getVector().value()) { // ERROR: iterate over deleted vector
    std::cout << i << '\n';
}

返回int的vector,然后迭代它是可以的。所以不要轻易的将foo()返回值类型改变成对应的可空类型,而应该调用foo().value()

比较

你可以使用普通的比较运算符。操作数可以是一个可空对象、被包含的对象、std::nullopt

  • 如果操作数都是不为空的可空对象,将会比较被包含的值
  • 如果操作数都是空的可空对象,那么比较运算会认为它们相等(==产生true值,其他比较运算符产生false值)
  • 如果一个操作数为空,一个不为空,为空的那个操作数将会被认为是小于不为空的那个操作数

比如:

std::optional<int> o0;
std::optional<int> o1{42};

    o0 == std::nullopt // yields true
    o0 == 42 // yields false
    o0 < 42 // yields true
    o0 > 42 // yields false
    o1 == 42 // yields true
    o0 < o1 // yields true

这意味着对于包含unsigned int的可空对象,它可以小于零,对于包含bool的可空对象,它也可以小于零:

std::optional<unsigned> uo;
    uo < 0 // yields true
std::optional<bool> bo;
    bo < false // yields true

再次强调,包含类型的隐式类型转换是支持的:

std::optional<int> o1{42};
std::optional<double> o2{42.0};

o2 == 42 // yields true
o1 == o2 // yields true

另外包含bool或者原生指针的可空对象在这里会产生一些令人意外的结果。

修改值

赋值操作和emplace()操作与初始化对应:

std::optional<std::complex<double>> o; // has no value
std::optional ox{77}; // optional<int> with value 77

o = 42; // value becomes complex(42.0, 0.0)
o = {9.9, 4.4}; // value becomes complex(9.9, 4.4)
o = ox; // OK, because int converts to complex<double>
o = std::nullopt; // o no longer has a value
o.emplace(5.5, 7.7); // value becomes complex(5.5, 7.7)

给可空对象赋std::nullopt会移除原来的包含值,即调用原包含值的析构函数。你可以用reset()实现一样的效果:

o.reset();  // o no longer has a value

或者赋给它一个{}

o = {}; // o no longer has a value

最后,我们也可以用operator *修改值,因为它产生包含值的引用。然而注意前提是可空对象得有值存在:

std::optional<std::complex<double>> o;
*o = 42; // undefined behavior
...
if (o) {
    *o = 88; // OK: value becomes complex(88.0, 0.0)
    *o = {1.2, 3.4}; // OK: value becomes complex(1.2, 3.4)
}

移动语义

std::optional<>也支持移动语义。如果你将整个对象移动,状态会随之被复制,被包含的值(如果有的话)也会被移动。 结果就是,移动后的对象状态仍然还保留,但是被包含的值已经不在了。 但是你可以将一个值移动到被包含对象里面,或者从被包含对象里面移出去。比如:

std::optional<std::string> os;
std::string s = "a very very very long string";
os = std::move(s); // OK, moves
std::string s2 = *os; // OK copies
std::string s3 = std::move(*os); // OK, moves

执行完最后一行,os仍然还有字符串的值,但是通常来说移动后的对象的值都是不存在的(译注:原文是unspecified)。因此, 你仍然可以使用它,前提是你不要对它是什么做任何假设。你甚至可以给它赋一个新字符串。

哈希

可空对象的哈希值是被包含对象的哈希值(如果存在的话)。

15.3 特殊情况

可空对象包含特定的类型可能产生令人意想不到的结果甚至未定义行为。

15.3.1 包含布尔值或原生指针

对可空对象使用比较运算符和将它视作布尔值有不同的雨衣。如果可空对象包含布尔值或者原生指针可能会产生一些困扰:比如:

std::optional<bool> ob{false}; // has value, which is false
if (!ob) ... // yields false
if (ob == false) ... // yields true

std::optional<int*> op{nullptr};
if (!op) ... // yields false
if (op == nullptr) ... // yields true

15.3.2 可空对象里面包含可空对象

原则上,你可以定义包含可空对象的可空对象:

std::optional<std::optional<std::string>> oos1;
std::optional<std::optional<std::string>> oos2 = "hello";
std::optional<std::optional<std::string>>
    oos3{std::in_place, std::in_place, "hello"};

std::optional<std::optional<std::complex<double>>>
    ooc{std::in_place, std::in_place, 4.2, 5.3};

你也可以借助隐式转换给它赋一个新值:

oos1 = "hello"; // OK: assign new value
ooc.emplace(std::in_place, 7.2, 8.3);

两层可空对象都没有值,但是最外层可空对象和最内层可空对象有没有值有待商榷:

*oos1 = std::nullopt; // inner optional has no value
oos1 = std::nullopt; // outer optional has no value

你必须小心谨慎处理这些特殊例子:

if (!oos1) std::cout << "no value\n";
if (oos1 && !*oos1) std::cout << "no inner value\n";
if (oos1 && *oos1) std::cout << "value: " << **oos1 << '\n';

因为这个语义上不仅仅是说一个有两种状态的值表示没有值,使用std::variant<>包裹两个bool或者用std::monostate可能是更合适的选择。

15.4 后记

可空对象首先由Fernando Cacciola在2005的https://wg21.link/n1878中提出。Fernando Cacciola和Andrzej Krzemienski提出新提案https://wg21.link/n3793被Library Fundamentals TS接受

Beman Dawes和Alisdair Meredith的新提案https://wg21.link/p0220r1被其他C++17组件接受。

Tony van Eerd极大的改进了比较操作的语义,提案参见[https:// wg21.link/n3765](https:// wg21.link/n3765)和https://wg21.link/p0307r2。 Vicente J. Botet Escriba 优化了std::optional<> 、std::variant<>和std::anyAPI,提案参见https://wg21.link/p0032r3。Jonathan Wakely修复了in_place的行为,提案参见https://wg21.link/p0504r0。