本章包含的配方如下:
- 尽可能使用自动
- 创建类型别名和别名模板
- 理解统一初始化
- 了解各种形式的非静态成员初始化
- 控制和查询对象对齐
- 使用限定范围的枚举
- 对虚拟方法使用重写和 final
- 使用基于范围的 for 循环在范围上迭代
- 为自定义类型的循环启用基于范围的
- 使用显式构造函数和转换运算符来避免隐式转换
- 使用未命名的命名空间代替静态全局
- 使用内联命名空间进行符号版本控制
- 使用结构化绑定处理多返回值
自动类型演绎是现代 C++ 中最重要、应用最广泛的特性之一。新的 C++ 标准使得在各种上下文中使用auto作为类型的占位符成为可能,并让编译器推导出实际的类型。在 C++ 11 中,auto可用于声明局部变量和带有尾随返回类型的函数的返回类型。在 C++ 14 中,auto可以用于函数的返回类型,而无需指定尾随类型,也可以用于 lambda 表达式中的参数声明。
未来的标准版本可能会将auto的使用范围扩大到更多的情况。在这些环境中使用auto有几个重要的好处。开发商应该意识到这一点,只要有可能就更喜欢auto。安德烈·亚历山德雷斯库为此创造了一个实际的术语,并由赫伯·萨特推广- 几乎总是自动 ( AAA )。
在以下情况下,考虑使用auto作为实际类型的占位符:
- 当您不想提交到特定类型时,要以
auto name = expression形式声明局部变量:
auto i = 42; // int
auto d = 42.5; // double
auto s = "text"; // char const *
auto v = { 1, 2, 3 }; // std::initializer_list<int> - 当需要提交到特定类型时,用
auto name = type-id { expression }形式声明局部变量:
auto b = new char[10]{ 0 }; // char*
auto s1 = std::string {"text"}; // std::string
auto v1 = std::vector<int> { 1, 2, 3 }; // std::vector<int>
auto p = std::make_shared<int>(42); // std::shared_ptr<int>- 以
auto name = lambda-expression形式声明命名的 lambda 函数,除非 lambda 需要被传递或返回到函数:
auto upper = [](char const c) {return toupper(c); };- 要声明 lambda 参数和返回值:
auto add = [](auto const a, auto const b) {return a + b;};- 当您不想提交到特定类型时,要声明函数返回类型:
template <typename F, typename T>
auto apply(F&& f, T value)
{
return f(value);
}auto说明符基本上是一个实际类型的占位符。使用auto时,编译器从以下实例推导出实际类型:
- 从用于初始化变量的表达式类型来看,当
auto用于声明变量时。 - 从尾随返回类型或函数返回表达式的类型,当
auto用作函数返回类型的占位符时。
在某些情况下,有必要提交到特定类型。例如,在前面的例子中,编译器将s的类型推导为char const *。如果目的是有一个std::string,那么类型必须明确指定。同样的,v的类型被演绎为std::initializer_list<int>。然而,其意图可能是拥有一台std::vector<int>。在这种情况下,必须在赋值的右侧显式指定类型。
使用自动说明符代替实际类型有一些重要的好处;以下可能是最重要的几个:
- 不能让变量保持未初始化状态。这是开发人员在声明指定实际类型的变量时经常犯的错误,但是
auto不可能需要初始化变量才能推导出类型。 - 使用
auto确保您始终使用正确的类型,并且不会发生隐式转换。考虑下面的例子,我们检索一个局部变量的向量大小。在第一种情况下,变量的类型是int,尽管size()方法返回size_t。这意味着从size_t到int的隐性转换将会发生。但是,对类型使用auto会推导出正确的类型,即size_t:
auto v = std::vector<int>{ 1, 2, 3 };
int size1 = v.size();
// implicit conversion, possible loss of data
auto size2 = v.size();
auto size3 = int{ v.size() }; // ill-formed (warning in gcc/clang, error in VC++)- 使用
auto可以促进良好的面向对象实践,比如更喜欢接口而不是实现。指定的类型数量越少,代码就越通用,对未来的变化也更开放,这是面向对象编程的一个基本原则。 - 这意味着少打字,少关心我们不关心的实际类型。很多时候,即使我们显式地指定了类型,我们实际上并不关心它。迭代器是一个非常常见的情况,但是我们可以想到更多。当您想要迭代一个范围时,您不关心迭代器的实际类型。你只对迭代器本身感兴趣;因此,使用
auto可以节省用于键入可能很长的名称的时间,并帮助您专注于实际代码,而不是键入名称。在下面的例子中,在第一个for循环中,我们显式地使用迭代器的类型。需要键入大量的文本,长语句实际上会降低代码的可读性,您还需要知道实际上并不关心的类型名称。带有auto说明符的第二个循环看起来更简单,让您不必键入和关心实际类型。
std::map<int, std::string> m;
for (std::map<int,std::string>::const_iterator it = m.cbegin();
it != m.cend(); ++ it)
{ /*...*/ }
for (auto it = m.cbegin(); it != m.cend(); ++ it)
{ /*...*/ }- 用
auto声明变量提供了一致的编码风格,类型总是在右边。如果动态分配对象,需要在赋值的左侧和右侧都写类型,例如int* p = new int(42)。使用auto,类型只在右侧指定一次。
但是,使用auto时有一些问题:
auto说明符只是类型的占位符,而不是const/volatile和引用说明符。如果需要const/volatile和/或引用类型,则需要明确指定它们。在以下示例中,foo.get()返回对int的引用;从返回值初始化变量x时,编译器推导出的类型是int,而不是int&。因此,对x的任何更改都不会传播到foo.x_。为此,应使用auto&:
class foo {
int x_;
public:
foo(int const x = 0) :x_{ x } {}
int& get() { return x_; }
};
foo f(42);
auto x = f.get();
x = 100;
std::cout << f.get() << std::endl; // prints 42- 不可移动的类型不能使用
auto:
auto ai = std::atomic<int>(42); // error- 多字类型不能使用自动,如
long long、long double或struct foo。但是,在第一种情况下,可能的解决方法是使用文字或类型别名;至于第二个,为了 C 兼容性,只有在 C++ 中支持以这种形式使用struct/class,无论如何都应该避免:
auto l1 = long long{ 42 }; // error
auto l2 = llong{ 42 }; // OK
auto l3 = 42LL; // OK- 如果您使用
auto说明符,但仍然需要知道类型,您可以在任何 IDE 中这样做,例如将光标放在变量上。然而,如果您离开 IDE,那就不可能了,知道实际类型的唯一方法是自己从初始化表达式中推导出来,这可能意味着在代码中搜索函数返回类型。
auto可用于指定函数的返回类型。在 C++ 11 中,这要求在函数声明中有一个尾随返回类型。在 C++ 14 中,这一点已经放宽,返回值的类型由编译器从return表达式中推导出来。如果有多个返回值,它们应该具有相同的类型:
// C++ 11
auto func1(int const i) -> int
{ return 2*i; }
// C++ 14
auto func2(int const i)
{ return 2*i; }如前所述,auto不保留const / volatile和参考限定词。这导致了auto作为函数返回类型的占位符的问题。为了解释这一点,让我们用foo.get()来考虑前面的例子。这次我们有一个叫做proxy_get()的包装函数,它引用一个foo,调用get(),并返回get()返回的值,这是一个int&。但是编译器会将proxy_get()的返回类型推导为int,而不是int&。尝试将该值赋给int&失败,出现错误:
class foo
{
int x_;
public:
foo(int const x = 0) :x_{ x } {}
int& get() { return x_; }
};
auto proxy_get(foo& f) { return f.get(); }
auto f = foo{ 42 };
auto& x = proxy_get(f); // cannot convert from 'int' to 'int &'要解决这个问题,我们需要真正返回auto&。然而,这是模板和完美转发返回类型的问题,而不知道那是值还是引用。C++ 14 中这个问题的解决方案是decltype(auto),它将正确推导出类型:
decltype(auto) proxy_get(foo& f) { return f.get(); }
auto f = foo{ 42 };
decltype(auto) x = proxy_get(f);最后一个可以使用auto的重要情况是使用 lambdas。从 C++ 14 开始,lambda 返回类型和 lambda 参数类型都可以是auto。这样的 lambda 被称为通用 lambda ,因为 lambda 定义的闭包类型有一个模板化的调用操作符。下面显示了一个通用的 lambda,它接受两个auto参数,并返回对实际类型应用operator+的结果:
auto ladd = [] (auto const a, auto const b) { return a + b; };
struct
{
template<typename T, typename U>
auto operator () (T const a, U const b) const { return a+b; }
} L;这个λ可以用来添加任何定义了operator+的东西。在下面的例子中,我们使用 lambda 来添加两个整数并连接到std::string对象(使用 C++ 14 用户定义的文字operator ""s):
auto i = ladd(40, 2); // 42
auto s = ladd("forty"s, "two"s); // "fortytwo"s- 创建类型别名和别名模板
- 了解统一初始化
在 C++ 中,可以创建同义词来代替类型名。这是通过创建一个typedef声明来实现的。这在一些情况下很有用,例如为类型创建更短或更有意义的名称,或者为函数指针创建名称。但是,typedef声明不能与模板一起使用来创建template type aliases。例如,std::vector<T>不是一种类型(std::vector<int>是一种类型),而是当类型占位符T被实际类型替换时可以创建的所有类型的一种族。
在 C++ 11 中,类型别名是另一个已声明类型的名称,别名模板是另一个已声明模板的名称。这两种类型的别名都引入了新的using语法。
- 使用形式
using identifier = type-id创建类型别名,如下例所示:
using byte = unsigned char;
using pbyte = unsigned char *;
using array_t = int[10];
using fn = void(byte, double);
void func(byte b, double d) { /*...*/ }
byte b {42};
pbyte pb = new byte[10] {0};
array_t a{0,1,2,3,4,5,6,7,8,9};
fn* f = func;- 使用表单
template<template-params-list> identifier = type-id创建别名模板,如下例所示:
template <class T>
class custom_allocator { /* ... */};
template <typename T>
using vec_t = std::vector<T, custom_allocator<T>>;
vec_t<int> vi;
vec_t<std::string> vs; 为了保持一致性和可读性,您应该执行以下操作:
- 不要混合使用
typedef和using声明来创建别名。 - 使用
using语法创建函数指针类型的名称。
typedef声明引入了一个类型的同义词(或者别名)。它没有引入另一种类型(如class、struct、union或enum声明)。通过typedef声明引入的类型名称遵循与标识符名称相同的隐藏规则。它们也可以重新声明,但只能引用同一类型(因此,您可以有多个有效的typedef声明,只要是同一类型的同义词,就可以在翻译单元中引入同一类型名称同义词)。以下是typedef声明的典型示例:
typedef unsigned char byte;
typedef unsigned char * pbyte;
typedef int array_t[10];
typedef void(*fn)(byte, double);
template<typename T>
class foo {
typedef T value_type;
};
typedef std::vector<int> vint_t;类型别名声明相当于typedef声明。它可以出现在块范围、类范围或命名空间范围中。根据 C++ 11 第 7.1.3.2 段:
A typedef-name can also be introduced by an alias-declaration. The identifier following the using keyword becomes a typedef-name and the optional attribute-specifier-seq following the identifier appertains to that typedef-name. It has the same semantics as if it were introduced by the typedef specifier. In particular, it does not define a new type and it shall not appear in the type-id.
然而,当涉及到为数组类型和函数指针类型创建别名时,别名声明对于别名的实际类型更加易读和清晰。在*的例子中如何做...*部分,很容易理解array_t是 10 个整数的类型数组的名称,fn是取类型byte和double两个参数并返回void的函数类型的名称。这也与声明std::function对象的语法一致(例如std::function<void(byte, double)> f)。
新语法的驱动目的是定义别名模板。这些模板在专门化时相当于用别名模板的模板参数替换type-id中模板参数的结果。
请务必注意以下事项:
- 别名模板不能部分或显式专门化。
- 推导模板参数时,别名模板从不通过模板参数推导来推导。
- 专门化别名模板时生成的类型不允许直接或间接使用自己的类型。
括号初始化是 C++ 11 中初始化数据的统一方法。为此也叫统一初始化。它可以说是 C++ 11 中开发人员应该理解和使用的最重要的特性之一。它消除了初始化基本类型、聚合和非聚合类型以及数组和标准容器之间的区别。
为了继续这个方法,您需要熟悉直接初始化和复制初始化,前者从一组显式构造函数参数初始化对象,后者从另一个对象初始化对象。以下是这两种初始化类型的简单示例,但要了解更多详细信息,您应该会看到其他资源:
std::string s1("test"); // direct initialization
std::string s2 = "test"; // copy initialization无论对象的类型如何,要统一初始化对象,请使用大括号初始化形式{},它既可用于直接初始化,也可用于复制初始化。当与括号初始化一起使用时,这些称为直接列表和复制列表初始化。
T object {other}; // direct list initialization
T object = {other}; // copy list initialization统一初始化的示例如下:
- 标准容器:
std::vector<int> v { 1, 2, 3 };
std::map<int, std::string> m { {1, "one"}, { 2, "two" }};- 动态分配的阵列:
int* arr2 = new int[3]{ 1, 2, 3 }; - 数组:
int arr1[3] { 1, 2, 3 }; - 内置类型:
int i { 42 };
double d { 1.2 }; - 用户定义的类型:
class foo
{
int a_;
double b_;
public:
foo():a_(0), b_(0) {}
foo(int a, double b = 0.0):a_(a), b_(b) {}
};
foo f1{};
foo f2{ 42, 1.2 };
foo f3{ 42 };- 用户定义的 POD 类型:
struct bar { int a_; double b_;};
bar b{ 42, 1.2 };在 C++ 11 之前,对象根据其类型需要不同类型的初始化:
- 基本类型可以使用赋值来初始化:
int a = 42;
double b = 1.2;- 如果类对象有一个转换构造函数(在 C++ 11 之前,具有单个参数的构造函数被称为转换构造函数),那么也可以使用从单个值赋值来初始化类对象:
class foo
{
int a_;
public:
foo(int a):a_(a) {}
};
foo f1 = 42;- 当提供参数时,非聚合类可以用圆括号(函数形式)初始化,而当执行默认初始化(调用默认构造函数)时,只能用圆括号初始化。在下一个例子中,
foo是在*中定义的结构如何做...*一节:
foo f1; // default initialization
foo f2(42, 1.2);
foo f3(42);
foo f4(); // function declaration- 聚合和 POD 类型可以用括号初始化来初始化。在下一个例子中,
bar是在*中定义的结构如何做...*一节:
bar b = {42, 1.2};
int a[] = {1, 2, 3, 4, 5};除了初始化数据的不同方法之外,还有一些限制。例如,初始化标准容器的唯一方法是首先声明一个对象,然后向其中插入元素;vector 是一个例外,因为它可以从一个数组中赋值,该数组可以使用聚合初始化预先初始化。但是,另一方面,动态分配的聚合不能直接初始化。
*中的所有例子如何做...*部分使用直接初始化,但是使用 brake-初始化也可以进行复制初始化。在大多数情况下,直接初始化和复制初始化这两种形式可能是等价的,但是复制初始化不太许可,因为它不考虑隐式转换序列中必须直接从初始值设定项生成对象的显式构造函数,而直接初始化需要从初始值设定项到构造函数参数的隐式转换。动态分配的数组只能使用直接初始化来初始化。
在前面的示例中显示的类中,foo是同时具有默认构造函数和带参数的构造函数的一个类。要使用默认构造函数执行默认初始化,我们需要使用空大括号,即{}。要使用带参数的构造函数,我们需要在大括号{}中提供所有参数的值。与默认初始化意味着调用默认构造函数的非聚合类型不同,对于聚合类型,默认初始化意味着用零初始化。
标准容器的初始化是可能的,例如上面显示的向量和映射,因为所有标准容器在 C++ 11 中都有一个附加的构造函数,该构造函数接受类型为std::initializer_list<T>的参数。这基本上是对类型为T const的元素数组的轻量级代理。这些构造函数然后从初始化列表中的值初始化内部数据。
使用std::initializer_list进行初始化的方式如下:
- 编译器解析初始化列表中元素的类型(所有元素必须具有相同的类型)。
- 编译器用初始值设定项列表中的元素创建一个数组。
- 编译器创建一个
std::initializer_list<T>对象来包装之前创建的数组。 std::initializer_list<T>对象作为参数传递给构造函数。
初始值设定项列表总是优先于使用括号初始化的其他构造函数。如果某个类存在这样的构造函数,则在执行大括号初始化时将调用它:
class foo
{
int a_;
int b_;
public:
foo() :a_(0), b_(0) {}
foo(int a, int b = 0) :a_(a), b_(b) {}
foo(std::initializer_list<int> l) {}
};
foo f{ 1, 2 }; // calls constructor with initializer_list<int>优先规则适用于任何函数,而不仅仅是构造函数。在下面的示例中,同一函数存在两个重载。使用初始值设定项列表调用函数会解析为使用std::initializer_list调用重载:
void func(int const a, int const b, int const c)
{
std::cout << a << b << c << std::endl;
}
void func(std::initializer_list<int> const l)
{
for (auto const & e : l)
std::cout << e << std::endl;
}
func({ 1,2,3 }); // calls second overload然而,这有可能导致错误。让我们以向量类型为例。在向量的构造函数中,有一个具有表示要分配的元素的初始数量的单个参数,另一个具有作为参数的std::initializer_list。如果目的是创建一个预分配大小的向量,使用括号初始化将不起作用,因为带有std::initializer_list的构造函数将是调用的最佳重载:
std::vector<int> v {5};前面的代码没有创建一个包含五个元素的向量,而是创建了一个包含一个元素且值为 5 的向量。为了能够实际创建具有五个元素的向量,必须使用括号形式的初始化:
std::vector<int> v (5);另一件需要注意的事情是,大括号初始化不允许缩小转换。根据 C++ 标准(参考标准的第 8.5.4 段),收缩转换是一种隐式转换:
- From a floating-point type to an integer type
- From long double to double or float, or from double to float, except where the source is a constant expression and the actual value after conversion is within the range of values that can be represented (even if it cannot be represented exactly)
- From an integer type or unscoped enumeration type to a floating-point type, except where the source is a constant expression and the actual value after conversion will fit into the target type and will produce the original value when converted to its original type
- From an integer type or unscoped enumeration type to an integer type that cannot represent all the values of the original type, except where the source is a constant expression and the actual value after conversion will fit into the target type and will produce the original value when converted to its original type.
下列声明会触发编译器错误,因为它们需要收缩转换:
int i{ 1.2 }; // error
double d = 47 / 13;
float f1{ d }; // error
float f2{47/13}; // OK要修复该错误,必须进行显式转换:
int i{ static_cast<int>(1.2) };
double d = 47 / 13;
float f1{ static_cast<float>(d) };A brace-initialization list is not an expression and does not have a type. Therefore, decltype cannot be used on a brace-init list, and template type deduction cannot deduce the type that matches a brace-init list.
以下示例显示了直接列表初始化和复制列表初始化的几个示例。在 C++ 11 中,所有这些表达式的推导类型都是std::initializer_list<int>。
auto a = {42}; // std::initializer_list<int>
auto b {42}; // std::initializer_list<int>
auto c = {4, 2}; // std::initializer_list<int>
auto d {4, 2}; // std::initializer_list<int>C++ 17 改变了列表初始化的规则,区分了直接和复制列表初始化。类型扣除的新规则如下:
- 对于复制列表初始化,如果列表中的所有元素具有相同的类型,或者格式不正确,自动推导将会推导出一个
std::initializer_list<T>。 - 对于直接列表初始化,如果列表有单个元素,自动演绎会演绎出
T,如果有多个元素,自动演绎会不规范。
基于新的规则,前面的例子会有如下变化:a``c推导为std::initializer_list<int>;b被演绎为一个int;d使用直接初始化,并且在 brake-init-list 中有多个值,会触发编译器错误。
auto a = {42}; // std::initializer_list<int>
auto b {42}; // int
auto c = {4, 2}; // std::initializer_list<int>
auto d {4, 2}; // error, too many- 尽可能使用自动
- 了解非静态成员初始化的各种形式
构造函数是完成非静态类成员初始化的地方。许多开发人员更喜欢构造函数体中的赋值。除了实际需要的几种例外情况之外,非静态成员的初始化应该在构造函数的初始化列表中完成,或者从 C++ 11 开始,当它们在类中声明时,使用默认成员初始化。在 C++ 11 之前,类的常量和非常量非静态数据成员必须在构造函数中初始化。只有静态常数才能在类的声明中初始化。正如我们将进一步看到的,C++ 11 中取消了这个限制,允许在类声明中初始化非静态。这个初始化被称为默认成员初始化,将在接下来的章节中解释。
这个方法将探索非静态成员初始化的方式。
要初始化类的非静态成员,您应该:
- 使用默认成员初始化为具有多个构造函数的类成员提供默认值,这些构造函数将为这些成员使用一个公共的初始化器(参见下面代码中的
[3]和[4])。 - 使用静态和非静态常量的默认成员初始化(参见下面代码中的
[1]和[2])。 - 使用构造函数初始化列表初始化没有默认值但依赖于构造函数参数的成员(参见下面代码中的
[5]和[6])。 - 当其他选项不可用时,在构造函数中使用赋值(示例包括用指针
this初始化数据成员,检查构造函数参数值,以及在用这些值初始化成员或两个非静态数据成员的自引用之前引发异常)。
以下示例显示了这些初始化形式:
struct Control
{
const int DefaultHeigh = 14; // [1]
const int DefaultWidth = 80; // [2]
TextVAligment valign = TextVAligment::Middle; // [3]
TextHAligment halign = TextHAligment::Left; // [4]
std::string text;
Control(std::string const & t) : text(t) // [5]
{}
Control(std::string const & t,
TextVerticalAligment const va,
TextHorizontalAligment const ha):
text(t), valign(va), halign(ha) // [6]
{}
};非静态数据成员应该在构造函数的初始化列表中初始化,如下例所示:
struct Point
{
double X, Y;
Point(double const x = 0.0, double const y = 0.0) : X(x), Y(y) {}
};然而,许多开发人员并不使用初始化列表,而是更喜欢在构造函数的主体中进行赋值,甚至混合赋值和初始化列表。这可能有几个原因——对于有许多成员的大型类,构造函数赋值可能看起来比长的初始化列表更容易阅读,可能会分成许多行,也可能是因为他们熟悉没有初始化列表的其他编程语言,或者不幸的是,由于各种原因,他们甚至不知道它。
It is important to note that the order in which non-static data members are initialized is the order in which they were declared in the class definition, and not the order of their initialization in a constructor initializer list. On the other hand, the order in which non-static data members are destroyed is the reversed order of construction.
在构造函数中使用赋值是没有效率的,因为这可能会创建临时对象,这些对象随后会被丢弃。如果未在初始值设定项列表中初始化,非静态成员将通过其默认构造函数初始化,然后,当在构造函数的主体中赋值时,调用赋值运算符。如果默认构造函数分配资源(如内存或文件),并且必须在赋值操作符中解除分配和重新分配,这可能会导致低效的工作:
struct foo
{
foo()
{ std::cout << "default constructor" << std::endl; }
foo(std::string const & text)
{ std::cout << "constructor '" << text << "'" << std::endl; }
foo(foo const & other)
{ std::cout << "copy constructor" << std::endl; }
foo(foo&& other)
{ std::cout << "move constructor" << std::endl; };
foo& operator=(foo const & other)
{ std::cout << "assignment" << std::endl; return *this; }
foo& operator=(foo&& other)
{ std::cout << "move assignment" << std::endl; return *this;}
~foo()
{ std::cout << "destructor" << std::endl; }
};
struct bar
{
foo f;
bar(foo const & value)
{
f = value;
}
};
foo f;
bar b(f);前面的代码产生了以下输出,显示了数据成员f是如何首先被默认初始化,然后被分配一个新值的:
default constructor
default constructor
assignment
destructor
destructor将初始化从构造函数体中的赋值更改为初始值设定项列表,将对默认构造函数和赋值运算符的调用替换为对复制构造函数的调用:
bar(foo const & value) : f(value) { }添加前一行代码会产生以下输出:
default constructor
copy constructor
destructor
destructor出于这些原因,至少对于内置类型以外的其他类型(如bool、char、int、float、double或指针),您应该更喜欢构造函数初始值设定项列表。然而,为了与您的初始化风格保持一致,在可能的情况下,您应该总是更喜欢构造函数初始值设定项列表。有几种情况下使用初始化列表是不可能的;这些案例包括以下案例(但列表可以扩展到其他案例):
- 如果必须用一个指针或对包含它的对象的引用来初始化一个成员,在初始化列表中使用
this指针可能会触发一些编译器的警告,即在构造对象之前使用它。 - 如果您有两个必须包含相互引用的数据成员。
- 如果要在用参数值初始化非静态数据成员之前测试输入参数并引发异常。
从 C++ 11 开始,非静态数据成员可以在类中声明时进行初始化。这被称为默认成员初始化,因为它应该用默认值来表示初始化。默认成员初始化适用于常量和未基于构造函数参数初始化的成员(换句话说,其值不依赖于对象构造方式的成员):
enum class TextFlow { LeftToRight, RightToLeft };
struct Control
{
const int DefaultHeight = 20;
const int DefaultWidth = 100;
TextFlow textFlow = TextFlow::LeftToRight;
std::string text;
Control(std::string t) : text(t)
{}
};在上例中,DefaultHeight和DefaultWidth都是常数;因此,这些值不依赖于对象的构造方式,因此它们在声明时被初始化。textFlow对象是一个非常数的非静态数据成员,它的值也不依赖于对象的初始化方式(它可以通过另一个成员函数来改变),因此,它在声明时也是使用默认的成员初始化来初始化的。另一方面,text也是非常数非静态数据成员,但其初始值取决于对象的构造方式,因此它在构造函数的初始值设定项列表中使用作为参数传递给构造函数的值进行初始化。
如果用默认成员初始化和构造函数初始值设定项列表初始化数据成员,则后者优先,默认值被丢弃。为了举例说明这一点,让我们再次考虑前面的foo类和下面使用它的bar类:
struct bar
{
foo f{"default value"};
bar() : f{"constructor initializer"}
{
}
};
bar b;在这种情况下,输出不同,如下所示,因为默认初始值设定项列表中的值被丢弃,并且对象没有初始化两次:
constructor
constructor initializer
destructorUsing the appropriate initialization method for each member leads not only to more efficient code but also to better organized and more readable code.
C++ 11 为指定和查询类型的对齐要求提供了标准化的方法(这在以前只有通过编译器特定的方法才能实现)。为了提高不同处理器的性能,并允许使用一些只处理特定对齐数据的指令,控制对齐非常重要。例如,英特尔 SSE 和英特尔 SSE2 需要 16 字节的数据对齐,而对于英特尔高级矢量扩展(或英特尔 AVX),强烈建议使用 32 字节对齐。该配方探索了用于控制对齐要求的alignas说明符和检索类型的对齐要求的alignof运算符。
您应该熟悉什么是数据对齐以及编译器执行默认数据对齐的方式。然而,关于后者的基本信息在*如何工作中提供...*段。
- 要控制类型(在类级别或数据成员级别)或对象的对齐,请使用
alignas说明符:
struct alignas(4) foo
{
char a;
char b;
};
struct bar
{
alignas(2) char a;
alignas(8) int b;
};
alignas(8) int a;
alignas(256) long b[4];- 要查询类型的对齐,请使用
alignof运算符:
auto align = alignof(foo);处理器不是一次访问一个字节的内存,而是以二进制幂(2、4、8、16、32 等)的更大块访问内存。因此,编译器在内存中对齐数据非常重要,以便处理器可以轻松访问。如果这些数据没有对齐,编译器必须为访问数据做额外的工作;它必须读取多个数据块,移位并丢弃不必要的字节,并将其余的字节组合在一起。
C++ 编译器根据数据类型的大小对齐变量:1 字节用于bool和char,2 字节用于short,4 字节用于int、long和float,8 字节用于double和long long等等。当涉及到结构或联合时,对齐必须与最大成员的大小相匹配,以避免性能问题。举例来说,让我们考虑以下数据结构:
struct foo1 // size = 1, alignment = 1
{
char a;
};
struct foo2 // size = 2, alignment = 1
{
char a;
char b;
};
struct foo3 // size = 8, alignment = 4
{
char a;
int b;
};foo1和foo2的大小不同,但是对齐方式是一样的——也就是 1——因为所有的数据成员都是char类型,大小为 1。结构foo3中,第二个成员为整数,大小为 4。因此,该结构的成员的对齐是在 4 的倍数的地址上完成的。为此,编译器引入了填充字节。foo3结构实际上转化为如下:
struct foo3_
{
char a; // 1 byte
char _pad0[3]; // 3 bytes padding to put b on a 4-byte boundary
int b; // 4 bytes
};类似地,以下结构的大小为 32 字节,对齐方式为 8;那是因为最大的成员是一个double,它的大小是 8。但是,这种结构需要在几个地方填充,以确保所有成员都可以在 8:
struct foo4
{
int a;
char b;
float c;
double d;
bool e;
};编译器创建的等效结构如下:
struct foo4_
{
int a; // 4 bytes
char b; // 1 byte
char _pad0[3]; // 3 bytes padding to put c on a 8-byte boundary
float c; // 4 bytes
char _pad1[4]; // 4 bytes padding to put d on a 8-byte boundary
double d; // 8 bytes
bool e; // 1 byte
char _pad2[7]; // 7 bytes padding to make sizeof struct multiple of 8
};在 C++ 11 中,使用alignas说明符来指定对象或类型的对齐方式。这可以采用表达式(计算结果为 0 的整数常量表达式或有效的对齐值)、类型 id 或参数包。alignas说明符可以应用于不表示位字段的变量或类数据成员的声明,或者应用于类、联合或枚举的声明。应用alignas规范的类型或对象的对齐要求等于声明中使用的所有alignas规范的最大值,大于零。
使用alignas说明符有几个限制:
- 唯一有效的对齐是二的幂(1,2,4,8,16,32,等等)。任何其他值都是非法的,程序被认为格式不正确;这不一定会产生错误,因为编译器可能会选择忽略规范。
- 0 的对齐总是被忽略。
- 如果声明中最大的
alignas小于没有任何alignas说明符的自然对齐,则该程序也被视为格式不正确。
在以下示例中,alignas说明符应用于类声明。没有alignas说明符的自然对齐应该是 1,但是有了alignas(4)就变成了 4:
struct alignas(4) foo
{
char a;
char b;
};换句话说,编译器将前面的类转换为下面的类:
struct foo
{
char a;
char b;
char _pad0[2];
};alignas说明符可以应用于类声明和成员数据声明。在这种情况下,最严格(即最大)的值获胜。在以下示例中,成员a的自然大小为 1,需要对齐 2;成员b自然大小为 4,要求对齐为 8,因此,最严格的对齐为 8。整个类的对齐要求为 4,比最严格的对齐要求弱(即更小),因此将被忽略,尽管编译器会产生警告:
struct alignas(4) foo
{
alignas(2) char a;
alignas(8) int b;
};结果是这样一个结构:
struct foo
{
char a;
char _pad0[7];
int b;
char _pad1[4];
};alignas说明符也可以应用于变量。在下一个例子中,变量a,也就是一个整数,需要以 8 的倍数放在内存中。下一个变量,4 a的数组,也就是一个整数,需要以 8 的倍数放在内存中。下一个变量,4 long的数组,需要以 256 的倍数放入内存。因此,编译器将在两个变量之间引入多达 244 字节的填充(取决于内存中的位置,在 8 的地址倍数处,变量a位于):
alignas(8) int a;
alignas(256) long b[4];
printf("%pn", &a); // eg. 0000006C0D9EF908
printf("%pn", &b); // eg. 0000006C0D9EFA00看地址可以看到a的地址确实是 8 的倍数,b的地址是 256(十六进制 100)的倍数。
要查询一个类型的对齐,我们使用alignof运算符。与sizeof不同,该运算符只能应用于类型 id,而不能应用于变量或类数据成员。可以应用它的类型可以是完整类型、数组类型或引用类型。对于数组,返回值是元素类型的对齐方式;对于引用,返回值是被引用类型的对齐方式。以下是几个例子:
| 表达式 | 评估 |
| alignof(char) | 1,因为char的自然对线是 1 |
| alignof(int) | 4,因为int的自然对线是 4 |
| alignof(int*) | 32 位 4,64 位 8,指针对齐 |
| alignof(int[4]) | 4,因为元素类型的自然对齐是 4 |
| alignof(foo&) | 8,因为作为引用类型的类foo的指定对齐方式(如最后一个示例所示)是 8 |
枚举是 C++ 中的一种基本类型,它定义了一个值的集合,并且总是一个完整的基础类型。它们的命名值是常量,称为枚举数。用关键字enum声明的枚举称为未计数枚举,用enum class或enum struct声明的枚举称为范围枚举。后者是在 C++ 11 中引入的,旨在解决未划分枚举的几个问题。
- 更喜欢使用限定范围的枚举,而不是未限定范围的枚举。
- 为了使用限定范围的枚举,您应该使用
enum class或enum struct声明枚举:
enum class Status { Unknown, Created, Connected };
Status s = Status::Created;The enum class and enum struct declarations are equivalent, and throughout this recipe and the rest of the book, we will use enum class.
未划分的枚举有几个问题会给开发人员带来问题:
- 它们将其枚举数导出到周围的范围(因此,它们被称为未划分的枚举),这有以下两个缺点:如果同一命名空间中的两个枚举具有同名的枚举数,并且不能使用完全限定名来使用枚举数,则可能导致名称冲突:
enum Status {Unknown, Created, Connected};
enum Codes {OK, Failure, Unknown}; // error
auto status = Status::Created; // error- 在 C++ 11 之前,他们不能指定需要是整型的基础类型。该类型不得大于
int,除非枚举值不能适合有符号或无符号整数。因此,不可能预先声明枚举。原因是枚举的大小是未知的,因为直到定义了枚举器的值,编译器才能选择合适的整数类型,才知道基础类型。这在 C++ 11 中已经修复了。 - 枚举数的值隐式转换为
int。这意味着您可以有意或无意地混合具有特定含义的枚举和整数(甚至可能与枚举的含义无关),编译器将无法警告您:
enum Codes { OK, Failure };
void include_offset(int pixels) {/*...*/}
include_offset(Failure);作用域枚举基本上是强类型枚举,其行为不同于非作用域枚举:
- 它们不会将其枚举器导出到周围的范围。前面显示的两个枚举将更改如下,不再产生名称冲突,并且可以完全限定枚举器的名称:
enum class Status { Unknown, Created, Connected };
enum class Codes { OK, Failure, Unknown }; // OK
Codes code = Codes::Unknown; // OK- 您可以指定基础类型。未划分枚举的基础类型的相同规则也适用于限定范围的枚举,只是用户可以显式指定基础类型。这也解决了前向声明的问题,因为在定义可用之前就可以知道基础类型:
enum class Codes : unsigned int;
void print_code(Codes const code) {}
enum class Codes : unsigned int
{
OK = 0,
Failure = 1,
Unknown = 0xFFFF0000U
};- 作用域枚举的值不再隐式转换为
int。将enum class的值赋给整数变量会触发编译器错误,除非指定了显式强制转换:
Codes c1 = Codes::OK; // OK
int c2 = Codes::Failure; // error
int c3 = static_cast<int>(Codes::Failure); // OK与其他类似的编程语言不同,C++ 没有特定的语法来声明接口(基本上是只包含纯虚拟方法的类),并且在如何声明虚拟方法方面也有一些不足。在 C++ 中,虚拟方法是用virtual关键字引入的。然而,关键字virtual对于在派生类中声明重写是可选的,这在处理大型类或层次结构时会导致混乱。您可能需要在整个层次结构中导航到底部,以确定某个函数是否是虚拟的。另一方面,有时,确保虚函数甚至派生类不再被重写或进一步派生是有用的。在这个食谱中,我们将看到如何使用 C++ 11 特殊标识符override和final来声明虚拟函数或类。
您应该熟悉 C++ 中的继承和多态性以及概念,例如抽象类、纯说明符、虚拟和重写方法。
要确保基类和派生类中虚拟方法的正确声明,同时提高可读性,请执行以下操作:
- 在派生类中声明虚函数时,总是使用
virtual关键字,这些派生类应该重写基类的虚函数,并且 - 在虚函数声明或定义的声明部分之后,始终使用
override特殊标识符。
class Base
{
virtual void foo() = 0;
virtual void bar() {}
virtual void foobar() = 0;
};
void Base::foobar() {}
class Derived1 : public Base
{
virtual void foo() override = 0;
virtual void bar() override {}
virtual void foobar() override {}
};
class Derived2 : public Derived1
{
virtual void foo() override {}
};The declarator is the part of the type of a function that excludes the return type.
为了确保函数不能被进一步覆盖或者类不能再被派生,使用final特殊标识符:
- 在虚拟函数声明或定义的声明部分之后,以防止派生类中的进一步重写:
class Derived2 : public Derived1
{
virtual void foo() final {}
};- 在类声明中的类名后面,以防止类的进一步派生:
class Derived4 final : public Derived1
{
virtual void foo() override {}
};override的工作方式很简单;在虚函数声明或定义中,它确保函数实际上是在重写基类函数,否则,编译器将触发错误。
需要注意的是override和final关键字都是特殊的标识符,只有在成员函数声明或定义中才有意义。它们不是保留关键字,仍然可以在程序的其他地方用作用户定义的标识符。
使用override特殊标识符有助于编译器检测虚拟方法不覆盖另一个方法的情况,如下例所示:
class Base
{
public:
virtual void foo() {}
virtual void bar() {}
};
class Derived1 : public Base
{
public:
void foo() override {}
// for readability use the virtual keyword
virtual void bar(char const c) override {}
// error, no Base::bar(char const)
};另一个特殊标识符final用在成员函数声明或定义中,表示该函数是虚拟的,不能在派生类中重写。如果派生类试图重写虚函数,编译器会触发一个错误:
class Derived2 : public Derived1
{
virtual void foo() final {}
};
class Derived3 : public Derived2
{
virtual void foo() override {} // error
};final说明符也可以用在类声明中,表示它不能被派生:
class Derived4 final : public Derived1
{
virtual void foo() override {}
};
class Derived5 : public Derived4 // error
{
};由于override和final在定义的上下文中使用时都有这种特殊的含义,并且实际上不是保留的关键字,所以您仍然可以在 C++ 代码的任何地方使用它们。这确保了在 C++ 11 之前编写的现有代码不会因为标识符使用这些名称而中断:
class foo
{
int final = 0;
void override() {}
};许多编程语言支持一种称为for each的for循环变体,即在集合元素上重复一组语句。直到 C++ 11,C++ 才对此有核心语言支持。最接近的功能是标准库中的通用算法std::for_each,它将一个函数应用于一个范围内的所有元素。C++ 11 为for each带来了语言支持,它实际上被称为基于范围的循环。新的 C++ 17 标准对原来的语言特性进行了一些改进。
在 C++ 11 中,基于范围的 for 循环具有以下一般语法:
for ( range_declaration : range_expression ) loop_statement为了举例说明使用基于范围的循环的各种方法,我们将使用以下返回元素序列的函数:
std::vector<int> getRates()
{
return std::vector<int> {1, 1, 2, 3, 5, 8, 13};
}
std::multimap<int, bool> getRates2()
{
return std::multimap<int, bool> {
{ 1, true },
{ 1, true },
{ 2, false },
{ 3, true },
{ 5, true },
{ 8, false },
{ 13, true }
};
}基于范围的 for 循环可以以多种方式使用:
- 通过为序列的元素提交特定类型:
auto rates = getRates();
for (int rate : rates)
std::cout << rate << std::endl;
for (int& rate : rates)
rate *= 2;- 通过不指定类型并让编译器推导它:
for (auto&& rate : getRates())
std::cout << rate << std::endl;
for (auto & rate : rates)
rate *= 2;
for (auto const & rate : rates)
std::cout << rate << std::endl;- 通过在 C++ 17 中使用结构化绑定和分解声明:
for (auto&& [rate, flag] : getRates2())
std::cout << rate << std::endl;先前在*中显示的基于范围的循环表达式如何操作...*部分基本上是语法糖,因为编译器将其转换为其他东西。在 C++ 17 之前,编译器生成的代码曾经如下:
{
auto && __range = range_expression;
for (auto __begin = begin_expr, __end = end_expr;
__begin != __end; ++ __begin) {
range_declaration = *__begin;
loop_statement
}
}该代码中的begin_expr和end_expr取决于范围的类型:
- 对于类 C 数组:
__range和__range + __bound(其中__bound是数组中的元素数) - 对于具有
begin和end成员的类类型(无论其类型和可访问性如何):__range.begin()和__range.end()。 - 对于其他人来说,是
begin(__range)和end(__range)通过依赖于自变量的查找来确定的。
需要注意的是,如果一个类包含任何名为begin或end的成员(函数、数据成员或枚举器),无论其类型和可访问性如何,它们都将被挑选为begin_expr和end_expr。因此,这样的类类型不能用于基于范围的循环。
在 C++ 17 中,编译器生成的代码略有不同:
{
auto && __range = range_expression;
auto __begin = begin_expr;
auto __end = end_expr;
for (; __begin != __end; ++ __begin) {
range_declaration = *__begin;
loop_statement
}
}新标准删除了开始表达式和结束表达式必须具有相同类型的约束。结束表达式不需要是实际的迭代器,但是它必须能够与迭代器进行不等式比较。这样做的一个好处是,范围可以由谓词来限定。
- 为自定义类型的循环启用基于范围的功能
正如我们在前面的方法中看到的,基于范围的 for 循环,在其他编程语言中被称为for each,允许您迭代一个范围的元素,为标准的for循环提供了简化的语法,并使代码在许多情况下更加可读。然而,基于范围的 for 循环不能使用任何表示范围的类型,而是需要有一个begin()和end()函数(对于非数组类型)作为成员或自由函数。在这个配方中,我们将看到如何在基于范围的 for 循环中启用自定义类型。
如果您需要了解基于范围的循环是如何工作的,以及编译器为这种循环生成的代码是什么,建议您在继续阅读本教程之前阅读使用基于范围的循环在范围上迭代的方法*。*
为了展示如何为表示序列的自定义类型启用基于范围的循环,我们将使用简单数组的以下实现:
template <typename T, size_t const Size>
class dummy_array
{
T data[Size] = {};
public:
T const & GetAt(size_t const index) const
{
if (index < Size) return data[index];
throw std::out_of_range("index out of range");
}
void SetAt(size_t const index, T const & value)
{
if (index < Size) data[index] = value;
else throw std::out_of_range("index out of range");
}
size_t GetSize() const { return Size; }
};该方法的目的是能够编写如下代码:
dummy_array<int, 3> arr;
arr.SetAt(0, 1);
arr.SetAt(1, 2);
arr.SetAt(2, 3);
for(auto&& e : arr)
{
std::cout << e << std::endl;
}要在基于范围的for循环中使用自定义类型,您需要执行以下操作:
-
为必须实现以下运算符的类型创建可变迭代器和常量迭代器:
operator++用于递增迭代器。operator*用于解引用迭代器和访问迭代器指向的实际元素。operator!=用于与另一个迭代器进行不等式比较。
-
为该类型提供免费的
begin()和end()功能。
给定一个简单范围的早期示例,我们需要提供以下内容:
- 迭代器类的以下最小实现:
template <typename T, typename C, size_t const Size>
class dummy_array_iterator_type
{
public:
dummy_array_iterator_type(C& collection,
size_t const index) :
index(index), collection(collection)
{ }
bool operator!= (dummy_array_iterator_type const & other) const
{
return index != other.index;
}
T const & operator* () const
{
return collection.GetAt(index);
}
dummy_array_iterator_type const & operator++ ()
{
++ index;
return *this;
}
private:
size_t index;
C& collection;
};- 可变迭代器和常数迭代器的别名模板:
template <typename T, size_t const Size>
using dummy_array_iterator =
dummy_array_iterator_type<
T, dummy_array<T, Size>, Size>;
template <typename T, size_t const Size>
using dummy_array_const_iterator =
dummy_array_iterator_type<
T, dummy_array<T, Size> const, Size>;- 释放返回相应开始和结束迭代器的
begin()和end()函数,两个别名模板都有重载:
template <typename T, size_t const Size>
inline dummy_array_iterator<T, Size> begin(
dummy_array<T, Size>& collection)
{
return dummy_array_iterator<T, Size>(collection, 0);
}
template <typename T, size_t const Size>
inline dummy_array_iterator<T, Size> end(
dummy_array<T, Size>& collection)
{
return dummy_array_iterator<T, Size>(
collection, collection.GetSize());
}
template <typename T, size_t const Size>
inline dummy_array_const_iterator<T, Size> begin(
dummy_array<T, Size> const & collection)
{
return dummy_array_const_iterator<T, Size>(
collection, 0);
}
template <typename T, size_t const Size>
inline dummy_array_const_iterator<T, Size> end(
dummy_array<T, Size> const & collection)
{
return dummy_array_const_iterator<T, Size>(
collection, collection.GetSize());
}有了这个实现,前面显示的基于范围的 for 循环将按预期编译和执行。当执行依赖于参数的查找时,编译器将识别我们编写的两个begin()和end()函数(它们引用了一个dummy_array,因此它生成的代码变得有效。
在前面的例子中,我们定义了一个迭代器类模板和两个别名模板,分别叫做dummy_array_iterator和dummy_array_const_iterator。对于这两种类型的迭代器,begin()和end()函数都有两个重载。这是必要的,这样我们所考虑的容器就可以在基于范围的循环中同时使用常量和非常量实例:
template <typename T, const size_t Size>
void print_dummy_array(dummy_array<T, Size> const & arr)
{
for (auto && e : arr)
{
std::cout << e << std::endl;
}
}对于我们在本食谱中考虑的简单范围类,启用基于范围的循环的一个可能的替代方法是提供成员begin()和end()函数。一般来说,只有当你拥有并能够修改源代码时,这才有意义。另一方面,该配方中显示的解决方案在所有情况下都有效,应该优先于其他替代方案。
- 创建类型别名和别名模板
在 C++ 11 之前,具有单个参数的构造函数被认为是转换构造函数。在 C++ 11 中,每个没有explicit说明符的构造函数都被认为是一个转换构造函数。这样的构造函数定义了从其参数类型到类类型的隐式转换。类还可以定义将类的类型转换为另一个指定类型的转换运算符。所有这些在某些情况下都很有用,但在其他情况下会产生问题。在这个食谱中,我们将看到如何使用显式构造函数和转换运算符。
对于这个方法,您需要熟悉转换构造函数和转换运算符。在本食谱中,您将学习如何编写显式构造函数和转换运算符,以避免类型之间的隐式转换。显式构造函数和转换运算符(称为用户定义的转换函数)的使用使编译器能够产生错误(在某些情况下是编码错误),并允许开发人员快速发现并修复这些错误。
要声明显式构造函数和转换运算符(无论它们是函数还是函数模板),请在声明中使用explicit说明符。
下面的示例显示了显式构造函数和转换运算符:
struct handle_t
{
explicit handle_t(int const h) : handle(h) {}
explicit operator bool() const { return handle != 0; };
private:
int handle;
};为了理解为什么显式构造函数是必要的以及它们是如何工作的,我们将首先研究转换构造函数。下面的类有三个构造函数:一个默认的构造函数(没有参数),一个接受int的构造函数,一个接受两个参数的构造函数,一个int和一个double。除了打印信息,他们什么也不做。从 C++ 11 开始,这些都被认为是转换构造函数。该类还有一个将类型转换为bool的转换运算符:
struct foo
{
foo()
{ std::cout << "foo" << std::endl; }
foo(int const a)
{ std::cout << "foo(a)" << std::endl; }
foo(int const a, double const b)
{ std::cout << "foo(a, b)" << std::endl; }
operator bool() const { return true; }
};基于此,以下对象定义是可能的(注意,注释代表控制台输出):
foo f1; // foo
foo f2 {}; // foo
foo f3(1); // foo(a)
foo f4 = 1; // foo(a)
foo f5 { 1 }; // foo(a)
foo f6 = { 1 }; // foo(a)
foo f7(1, 2.0); // foo(a, b)
foo f8 { 1, 2.0 }; // foo(a, b)
foo f9 = { 1, 2.0 }; // foo(a, b)f1和f2调用默认构造函数。f3、f4、f5和f6调用取int的构造函数。请注意,这些对象的所有定义都是等价的,即使它们看起来不同(f3使用函数形式初始化,f4和f6复制初始化,f5使用 brake-init-list 直接初始化)。类似地,f7、f8和f9用两个参数调用构造函数。
需要注意的是,如果foo定义了一个采用std::initializer_list的构造函数,那么所有使用{}的初始化都将解析为该构造函数:
foo(std::initializer_list<int> l)
{ std::cout << "foo(l)" << std::endl; }在这种情况下,f5和f6会print foo(l),而f8和f9会产生编译器错误,因为初始化列表的所有元素都应该是整数。
这些看起来都是正确的,但是隐式转换构造函数支持隐式转换可能不是我们想要的情况:
void bar(foo const f)
{
}
bar({}); // foo()
bar(1); // foo(a)
bar({ 1, 2.0 }); // foo(a, b)上例中bool的转换运算符也使我们能够在需要布尔值的地方使用foo对象:
bool flag = f1;
if(f2) {}
std::cout << f3 + f4 << std::endl;
if(f5 == f6) {}前两个例子中foo被期望用作布尔,但是最后两个带有加法和相等测试的例子可能是不正确的,因为我们最有可能期望添加foo对象并测试foo对象的相等性,而不是它们隐式转换成的布尔。
也许一个更现实的例子是考虑一个字符串缓冲区实现,来理解哪里会出现问题。这将是一个包含内部字符缓冲区的类。该类可以提供几个转换构造函数:一个默认构造函数,一个获取表示要预分配的缓冲区大小的size_t参数的构造函数,以及一个获取指向应该用于分配和初始化内部缓冲区的char的指针的构造函数。简而言之,这样的字符串缓冲区可能如下所示:
class string_buffer
{
public:
string_buffer() {}
string_buffer(size_t const size) {}
string_buffer(char const * const ptr) {}
size_t size() const { return ...; }
operator bool() const { return ...; }
operator char * const () const { return ...; }
};基于这个定义,我们可以构建以下对象:
std::shared_ptr<char> str;
string_buffer sb1; // empty buffer
string_buffer sb2(20); // buffer of 20 characters
string_buffer sb3(str.get());
// buffer initialized from input parametersb1是使用默认构造函数创建的,因此有一个空缓冲区;sb2使用带有单个参数的构造函数初始化,该参数的值代表内部缓冲区的字符大小;sb3用现有缓冲区初始化,用于定义内部缓冲区的大小,并将其值复制到内部缓冲区中。但是,相同的定义也支持以下对象定义:
enum ItemSizes {DefaultHeight, Large, MaxSize};
string_buffer b4 = 'a';
string_buffer b5 = MaxSize;在这种情况下,b4用char初始化。由于存在到size_t的隐式转换,将调用具有单个参数的构造函数。这里的意图不一定明确;也许应该是"a"而不是'a',在这种情况下,第三个构造函数就会被调用。但是b5很可能是一个错误,因为MaxSize是一个代表ItemSizes的枚举器,应该与字符串缓冲区大小无关。编译器不会以任何方式标记这些错误情况。
在构造函数的声明中使用explicit说明符,该构造函数成为显式构造函数,不再允许隐式构造类类型的对象。为了举例说明这一点,我们将稍早改变string_buffer类,以显式声明所有构造函数:
class string_buffer
{
public:
explicit string_buffer() {}
explicit string_buffer(size_t const size) {}
explicit string_buffer(char const * const ptr) {}
explicit operator bool() const { return ...; }
explicit operator char * const () const { return ...; }
};变化很小,但是前面例子中b4和b5的定义不再起作用,并且是不正确的,因为从char或int到size_t的隐式转换在重载解析期间不再可用,以确定应该调用什么构造函数。结果是b4和b5都出现编译器错误。注意b1、b2和b3即使构造函数是显式的,也仍然是有效的定义。
在这种情况下,解决问题的唯一方法是提供从char或int到string_buffer的显式强制转换:
string_buffer b4 = string_buffer('a');
string_buffer b5 = static_cast<string_buffer>(MaxSize);
string_buffer b6 = string_buffer{ "a" };使用显式构造函数,编译器能够立即标记错误的情况,开发人员可以做出相应的反应,或者用正确的值修复初始化,或者提供显式强制转换。
This is only the case when initialization is done with copy initialization and not when using the functional or universal initialization.
对于显式构造函数,以下定义仍然是可能的(并且是错误的):
string_buffer b7{ 'a' };
string_buffer b8('a');与构造函数类似,转换运算符可以声明为显式的(如前所示)。在这种情况下,从对象类型到转换运算符指定的类型的隐式转换不再可能,并且需要显式转换。考虑到前面定义的b1和b2``string_buffer对象,通过显式转换operator bool不再可能出现以下情况:
std::cout << b1 + b2 << std::endl;
if(b1 == b2) {}相反,它们需要显式转换为bool:
std::cout << static_cast<bool>(b1) + static_cast<bool>(b2);
if(static_cast<bool>(b1) == static_cast<bool>(b2)) {}- 了解统一初始化
一个程序越大,当你的程序被链接时,你就越有可能遇到与文件局部变量的名称冲突。在源文件中声明并被认为是翻译单元本地的函数或变量可能会与在另一个翻译单元中声明的其他类似函数或变量冲突。这是因为所有未声明为静态的符号都有外部链接,并且它们的名称在整个程序中必须是唯一的。这个问题的典型 C 解决方案是将这些符号声明为静态的,将它们的链接从外部更改为内部,从而使它们成为翻译单元的本地链接。在这个食谱中,我们将看看这个问题的 C++ 解决方案。
在这个食谱中,我们将讨论一些概念,如全局函数、静态函数、变量、名称空间和翻译单元。除此之外,还要求你明白内外联动的区别;这是这个食谱的关键。
当您需要将全局符号声明为静态以避免链接问题时,最好使用未命名的名称空间:
- 在源文件中声明一个没有名称的命名空间。
- 将全局函数或变量的定义放在未命名的名称空间中,不要使它们成为
static。
以下示例显示了两个不同翻译单元中的两个名为print()的函数;它们中的每一个都在一个未命名的名称空间中定义:
// file1.cpp
namespace
{
void print(std::string message)
{
std::cout << "[file1] " << message << std::endl;
}
}
void file1_run()
{
print("run");
}
// file2.cpp
namespace
{
void print(std::string message)
{
std::cout << "[file2] " << message << std::endl;
}
}
void file2_run()
{
print("run");
}当一个函数在翻译单元中声明时,它具有外部链接。这意味着来自两个不同翻译单元的两个同名函数会产生链接错误,因为不可能有两个同名的符号。这个问题在 C 中解决的方式,有些人在 C++ 中也是这样,就是将函数或变量声明为静态的,并将其链接从外部更改为内部。在这种情况下,其名称不再导出到翻译单元之外,避免了联动问题。
C++ 中正确的解决方案是使用未命名的名称空间。当您定义如上所示的命名空间时,编译器会将其转换为以下内容:
// file1.cpp
namespace _unique_name_ {}
using namespace _unique_name_;
namespace _unique_name_
{
void print(std::string message)
{
std::cout << "[file1] " << message << std::endl;
}
}
void file1_run()
{
print("run");
}首先,它声明了一个具有唯一名称的名称空间(名称是什么以及它如何生成该名称是编译器实现的细节,不应该成为关注点)。此时,命名空间为空,这一行的目的是基本建立命名空间。第二,一个 using 指令将来自_unique_name_命名空间的所有内容带入当前命名空间。第三,具有编译器生成的名称的命名空间被定义为原始源代码中的名称(当它没有名称时)。
通过在未命名的名称空间中定义翻译单元本地print()函数,它们仅具有本地可见性,但是它们的外部链接不再产生链接错误,因为它们现在具有外部唯一的名称。
未命名的名称空间也在一个可能更模糊的涉及模板的情况下工作。在 C++ 11 模板之前,非类型参数不能是具有内部链接的名称,因此使用静态变量是不可能的。另一方面,未命名命名空间中的符号具有外部链接,可以用作模板参数。尽管在 C++ 11 中取消了对模板非类型参数的这种链接限制,但它仍然存在于最新版本的 VC++ 编译器中。这个问题表现在下面的例子中,声明 t1 会产生编译器错误,因为非类型参数表达式有内部链接,但是t2是正确的,因为Size2有外部链接。(注意用 Clang 和 gcc 编译下面的代码不会产生任何错误。)
template <int const& Size>
class test {};
static int Size1 = 10;
namespace
{
int Size2 = 10;
}
test<Size1> t1;
test<Size2> t2;- 使用内联命名空间进行符号版本控制
C++ 11 标准引入了一种新类型的命名空间,称为内联命名空间,它基本上是一种机制,使嵌套命名空间的声明看起来和行为都像是周围命名空间的一部分。内联命名空间是使用命名空间声明中的inline关键字声明的(未命名的命名空间也可以内联)。这对于库版本控制是一个很有帮助的特性,在这个食谱中,我们将看到如何将内联命名空间用于版本控制符号。从这个食谱中,您将学习如何使用内联命名空间和条件编译来版本化您的源代码。
在本食谱中,我们将讨论命名空间和嵌套命名空间、模板和模板专门化,以及使用预处理器宏的条件编译。为了继续制作食谱,需要熟悉这些概念。
要提供库的多个版本并让用户决定使用哪个版本,请执行以下操作:
- 在命名空间中定义库的内容。
- 在内部内联命名空间中定义库的每个版本或部分版本。
- 使用预处理器宏和
#if指令来启用库的特定版本。
以下示例显示了一个库,该库有两个版本可供客户端使用:
namespace modernlib
{
#ifndef LIB_VERSION_2
inline namespace version_1
{
template<typename T>
int test(T value) { return 1; }
}
#endif
#ifdef LIB_VERSION_2
inline namespace version_2
{
template<typename T>
int test(T value) { return 2; }
}
#endif
}内联命名空间的成员被视为周围命名空间的成员。这样的成员可以是部分专门化的、显式实例化的或显式专门化的。这是一个可传递的属性,这意味着如果一个命名空间 A 包含一个内联的命名空间 B,该内联的命名空间 B 包含一个内联的命名空间 C,那么 C 的成员看起来就像是 B 和 A 的成员,而 B 的成员看起来就像是 A 的成员
为了更好地理解为什么内联命名空间是有帮助的,让我们考虑开发一个库的情况,该库随着时间从第一个版本发展到第二个版本(以及以后的版本)。这个库在一个名为modernlib的命名空间下定义了它的所有类型和函数。在第一个版本中,该库可能如下所示:
namespace modernlib
{
template<typename T>
int test(T value) { return 1; }
}库的客户端可以进行以下调用,并返回值 1:
auto x = modernlib::test(42);然而,客户端可能决定将模板功能test()专门化如下:
struct foo { int a; };
namespace modernlib
{
template<>
int test(foo value) { return value.a; }
}
auto y = modernlib::test(foo{ 42 });在这种情况下,y的值不再是 1,而是 42,因为用户专用函数被调用。
到目前为止,一切正常,但是作为一个库开发人员,您决定创建库的第二个版本,但是仍然提供第一个和第二个版本,并让用户控制对宏使用什么。在第二个版本中,您提供了test()函数的新实现,它不再返回 1,而是返回 2。为了能够提供第一个和第二个实现,您将它们放在名为version_1和version_2的嵌套名称空间中,并使用预处理器宏有条件地编译库:
namespace modernlib
{
namespace version_1
{
template<typename T>
int test(T value) { return 1; }
}
#ifndef LIB_VERSION_2
using namespace version_1;
#endif
namespace version_2
{
template<typename T>
int test(T value) { return 2; }
}
#ifdef LIB_VERSION_2
using namespace version_2;
#endif
}突然,客户端代码将会崩溃,不管它使用的是库的第一个版本还是第二个版本。这是因为测试函数现在在一个嵌套的名称空间中,并且foo的专门化是在modernlib名称空间中完成的,而实际上应该在modernlib::version_1或modernlib::version_2中完成。这是因为模板的专门化需要在声明模板的同一个命名空间中完成。在这种情况下,客户端需要像这样更改代码:
#define LIB_VERSION_2
#include "modernlib.h"
struct foo { int a; };
namespace modernlib
{
namespace version_2
{
template<>
int test(foo value) { return value.a; }
}
}这是一个问题,因为库泄露了实现细节,客户端需要知道这些细节,以便进行模板专门化。这些内部细节用内联名称空间隐藏,如*如何做到这一点所示...*本食谱的一节。有了modernlib库的定义,在modernlib命名空间中具有test()函数专门化的客户端代码不再被破坏,因为当模板专门化完成时,version_1::test()或version_2::test()(取决于客户端实际使用的版本)充当封闭的modernlib命名空间的一部分。实现的细节现在对只看到周围命名空间的客户端隐藏起来了modernlib。
但是,您应该记住:
- 命名空间
std是为标准保留的,永远不应该内联。 - 如果命名空间在其第一个定义中没有内联,则不应内联定义该命名空间。
- 使用未命名的名称空间代替静态全局变量
从一个函数中返回多个值是非常常见的事情,但是在 C++ 中没有第一流的解决方案来直接启用它。开发人员必须在通过函数的引用参数返回多个值、定义包含多个值的结构或返回std::pair或std::tuple之间做出选择。前两个使用命名变量的优点是它们清楚地表明了返回值的含义,但缺点是它们必须被显式定义。std::pair有名为first和second的成员,std::tuple有未命名的成员,只能通过函数调用来检索,但可以使用std::tie().复制到命名的变量中。这些解决方案都不理想。
C++ 17 将std::tie()的语义使用扩展到一级核心语言特性,该特性支持将元组的值解包为命名变量。这个特性叫做结构化绑定。
对于这个食谱,你应该熟悉标准实用类型std::pair和std::tuple以及实用功能std::tie()。
要使用支持 C++ 17 的编译器从函数中返回多个值,您应该执行以下操作:
- 返回类型使用
std::tuple。
std::tuple<int, std::string, double> find()
{
return std::make_tuple(1, "marius", 1234.5);
}- 使用结构化绑定将元组的值解包到命名对象中。
auto [id, name, score] = find();- 使用分解声明将返回值绑定到
if语句或switch语句中的变量。
if (auto [id, name, score] = find(); score > 1000)
{
std::cout << name << std::endl;
}结构化绑定是一种语言特性,其工作原理与std::tie()类似,只是我们不必为每个需要用std::tie()显式解包的值定义命名变量。使用结构化绑定,我们使用auto说明符在单个定义中定义所有命名变量,以便编译器可以为每个变量推断正确的类型。
为了举例说明,让我们考虑在std::map中插入项目的情况。insert 方法返回一个std::pair,它包含插入元素或阻止插入的元素的迭代器,以及一个指示插入是否成功的布尔值。下面的代码非常明确,使用second或first->second会使代码更难阅读,因为您需要不断弄清楚它们代表什么:
std::map<int, std::string> m;
auto result = m.insert({ 1, "one" });
std::cout << "inserted = " << result.second << std::endl
<< "value = " << result.first->second << std::endl;使用std::tie可以使前面的代码可读性更好,它将元组解包为单个对象(使用std::pair是因为std::tuple有一个来自std::pair的转换赋值):
std::map<int, std::string> m;
std::map<int, std::string>::iterator it;
bool inserted;
std::tie(it, inserted) = m.insert({ 1, "one" });
std::cout << "inserted = " << inserted << std::endl
<< "value = " << it->second << std::endl;
std::tie(it, inserted) = m.insert({ 1, "two" });
std::cout << "inserted = " << inserted << std::endl
<< "value = " << it->second << std::endl;代码不一定更简单,因为它需要预先定义要解包的对象对。同样,元组中的元素越多,需要定义的对象就越多,但是使用命名对象会使代码更容易阅读。
C++ 17 结构化绑定将元组元素到命名对象的解包提升到语言特性的级别;它不需要使用std::tie(),对象在声明时被初始化:
std::map<int, std::string> m;
{
auto[it, inserted] = m.insert({ 1, "one" });
std::cout << "inserted = " << inserted << std::endl
<< "value = " << it->second << std::endl;
}
{
auto[it, inserted] = m.insert({ 1, "two" });
std::cout << "inserted = " << inserted << std::endl
<< "value = " << it->second << std::endl;
}在上面的例子中使用多个块是必要的,因为变量不能在同一个块中重新声明,并且结构化绑定意味着使用auto说明符的声明。因此,如果您需要像上面的例子一样进行多次调用并使用结构化绑定,您必须使用不同的变量名或多个块,如上所示。另一种方法是避免结构化绑定,使用std::tie(),因为它可以用同一个变量调用多次,所以只需要声明一次。
在 C++ 17 中,也可以用if(init; condition)和switch(init; condition)的形式在if和switch语句中声明变量。这可以与结构化绑定相结合,以生成更简单的代码。在下面的示例中,我们尝试向地图中插入一个新值。调用的结果被解包成两个变量,it和inserted,在初始化部分的if语句的范围中定义。if语句的条件根据插入对象的值进行评估:
if(auto [it, inserted] = m.insert({ 1, "two" }); inserted)
{ std::cout << it->second << std::endl; }