Skip to content

Latest commit

 

History

History
1682 lines (1222 loc) · 76.5 KB

File metadata and controls

1682 lines (1222 loc) · 76.5 KB

二、基本的 C++ 技术

在这一章中,我们将深入研究一些基本的 C++ 技术,例如移动语义、错误处理和 lambda 表达式,这些将在本书中用到。这些概念中的一些仍然让有经验的 C++ 程序员感到困惑,因此我们将研究他们的用例以及他们是如何工作的。

本章将涵盖以下主题:

  • 自动类型推演以及声明函数和变量时如何使用auto关键字。
  • 移动语义和五的规则和零规则。
  • 错误处理和合同。虽然这些主题没有提出任何可以被认为是现代 C++ 的东西,但是异常和契约都是当今 C++ 中备受争议的领域。
  • 使用 lambda 表达式创建函数对象,这是 C++ 11 最重要的特性之一。

我们先来看看自动类型演绎。

带有 auto 关键字的自动类型扣除

自从 C++ 11 中auto关键字的引入之后,C++ 社区中就出现了很多关于如何使用auto不同口味的const``auto&``auto&``auto&&decltype(auto)的困惑。

在函数签名中使用自动

虽然有些 C++ 程序员不鼓励使用,但根据我的经验,在函数签名中使用auto可以增加浏览和查看头文件时的可读性。

与带有显式类型的传统语法相比,auto语法是这样的:

| 显式类型的传统语法: | 自动的新语法: | |
struct Foo {
  int val() const {    return m_;   }  const int& cref() const {    return m_;   }  int& mref() {    return m_;   }  int m_{};}; 

|

struct Foo {
  auto val() const {    return m_;   }  auto& cref() const {    return m_;   }  auto& mref() {    return m_;   }  int m_{};}; 

|

auto语法可以在有或没有尾随返回类型的情况下使用。在某些上下文中,尾随返回是必要的。例如,如果我们正在编写一个虚拟函数,或者函数声明放在头文件中,函数定义放在.cpp文件中。

注意auto语法也可以用于自由函数:

| 返回类型 | 句法变体(a、b 和 c 对应于相同的结果): | | 价值 |
auto val() const                // a) auto, deduced type
auto val() const -> int         // b) auto, trailing type
int val() const                 // c) explicit type 

| | 常量引用 |

auto& cref() const              // a) auto, deduced type
auto cref() const -> const int& // b) auto, trailing type
const int& cref() const         // c) explicit type 

| | 可变引用 |

auto& mref()                    // a) auto, deduced type
auto mref() -> int&             // b) auto, trailing type
int& mref()                     // c) explicit type 

|

使用 decltype(自动)转发返回类型

有一个有点罕见的版本自动类型推演叫做decltype(auto)。它最常见的用途是从函数中转发精确的类型。假设我们正在为上一个表中声明的val()mref()编写包装函数,如下所示:

int val_wrapper() { return val(); }    // Returns int
int& mref_wrapper() { return mref(); } // Returns int& 

现在,如果我们想对包装函数使用返回类型推断,那么auto关键字会在两种情况下将返回类型推断为int:

auto val_wrapper() { return val(); }   // Returns int
auto mref_wrapper() { return mref(); } // Also returns int 

如果我们想让我们的mref_wrapper()返回一个int&,我们需要写auto&。在这个例子中,这很好,因为我们知道mref()的返回类型。然而,情况并非总是如此。因此,如果我们希望编译器选择完全相同的类型,而不明确地说int&auto&代表mref_wrapper(),我们可以使用decltype(auto):

decltype(auto) val_wrapper() { return val(); }   // Returns int
decltype(auto) mref_wrapper() { return mref(); } // Returns int& 

这样,当我们不知道val()mref()返回什么函数时,我们可以避免在写入autoauto&之间进行显式选择。这种情况通常发生在泛型代码中,被包装的函数类型是一个模板参数。

对变量使用自动

C++ 11 中auto关键字的引入在 C++ 程序员中引发了相当大的争论。许多人认为它降低了可读性,甚至认为它使 C++ 类似于动态类型语言。我倾向于不参与这些辩论,但我个人的观点是,你应该(几乎)总是使用auto,因为根据我的经验,它让代码更安全,也不那么杂乱无章。

过度使用auto会使代码更难理解。在阅读代码时,我们通常想知道某些对象支持哪些操作。一个好的 IDE 可以为我们提供这些信息,但是在源代码中并没有明确地提供。C++ 20 概念通过关注对象的行为来解决这个问题。有关 C++ 概念的更多信息,请参见第 8 章编译时编程

我更喜欢用auto来表示局部变量,使用从左到右的初始化风格。这意味着保持变量在左边,后面跟一个等号,然后类型在右边,如下所示:

auto i = 0;
auto x = Foo{};
auto y = create_object();
auto z = std::mutex{};     // OK since C++ 17 

C++ 17 引入保证复制省略,语句auto x = Foo{}Foo x{}相同;也就是说,在这种情况下,语言保证没有需要移动或复制的临时对象。这意味着我们现在可以使用从左到右的初始化风格,而不用担心性能,我们也可以将其用于不可移动/不可复制的类型,如std::atomicstd::mutex

对变量使用auto的一个很大的好处是,由于auto x;不编译,所以永远不会让变量保持未初始化状态。未初始化的变量是未定义行为的一个特别常见的来源,您可以按照这里建议的样式完全消除它。

使用auto将帮助您为变量使用正确的类型。但是,您仍然需要做的是,通过指定您是需要引用还是副本,以及您是想要修改变量还是只是从中读取,来表达您打算如何使用变量。

常量引用

const auto&表示的const引用具有绑定任何东西的能力。原始对象永远不能通过这样的引用进行变异。我认为const参考应该是复制潜在昂贵对象的默认选择。

如果const引用绑定到临时对象,则临时对象的生存期将延长到引用的生存期。这在下面的示例中得到了演示:

void some_func(const std::string& a, const std::string& b) {
  const auto& str = a + b;  // a + b returns a temporary
  // ...
} // str goes out of scope, temporary will be destroyed 

也有可能通过使用auto&const引用结束。这可以从以下示例中看出:

 auto foo = Foo{};
 auto& cref = foo.cref(); // cref is a const reference
 auto& mref = foo.mref(); // mref is a mutable reference 

即使这是完全有效的,最好总是明确表示我们正在使用const auto&处理const引用,更重要的是,我们应该使用auto&表示可变引用。

可变引用

const引用相反,可变的引用不能绑定到临时引用。如上所述,我们使用auto&来表示可变引用。仅当您打算更改可变引用所引用的对象时,才使用可变引用。

转发参考

auto&&称为转发参考(也称为通用参考)。它可以绑定到任何东西,这使得它在某些情况下很有用。转发引用将像const引用一样,延长临时引用的生命周期。但是与const引用相反,auto&&允许我们变异它引用的对象,包括临时对象。

对于只转发给其他代码的变量,使用auto&&。在那些转发的情况下,你很少关心变量是const还是可变的;你只是想把它传递给一些实际上要使用这个变量的代码。

需要注意的是,auto&&T&&只有在函数模板中使用时才是转发引用,其中T是该函数模板的模板参数。使用带有显式类型的&&语法,例如std::string&&,表示右值引用,不具有转发引用的属性(右值和移动语义将在本章后面讨论)。

易于使用的实践

虽然这是我的个人观点,但是我推荐基本型(intfloat等)和像std::pairstd::complex这样的小非基本型使用const auto。对于可能复制成本较高的较大型号,请使用const auto&。这应该涵盖了 C++ 代码库中的大多数变量声明。

auto&auto应该只在需要可变引用或显式副本的行为时使用;这告诉代码的读者,这些变量很重要,因为它们要么复制一个对象,要么变异一个被引用的对象。最后,仅使用auto&&转发代码。

遵循这些规则使您的代码库更容易阅读、调试和推理。

虽然我建议对大多数变量声明使用const autoconst auto&,但我倾向于在本书的某些地方使用简单的auto,这可能看起来很奇怪。使用普通auto的原因是一本书的格式提供的空间有限。

在继续之前,我们将花一点时间讨论const以及使用指针时如何传播const

指针的常量传播

通过使用关键字const,我们可以通知编译器哪些对象是不可变的。然后,编译器可以检查我们是否试图变异不打算改变的对象。换句话说,编译器检查我们代码的const-正确性。用 C++ 编写const-正确代码时的一个常见错误是,const初始化的对象仍然可以操作成员指针指向的值。下面的例子说明了这个问题:

class Foo {
public:
  Foo(int* ptr) : ptr_{ptr} {} 
  auto set_ptr_val(int v) const { 
    *ptr_ = v; // Compiles despite function being declared const!
  }
private:
  int* ptr_{};
};
int main() {
  auto i = 0;
  const auto foo = Foo{&i};
  foo.set_ptr_val(42);
} 

虽然函数set_ptr_val()正在变异int的值,但是声明它const是有效的,因为指针ptr_本身没有变异,只是指针指向的int对象。

为了以可读的方式防止这种情况发生,标准库扩展中添加了一个名为std::experimental::propagate_const的包装器(在编写本文时,包含在最新版本的 Clang 和 GCC 中)。使用propagate_const,函数set_ptr_val()不会编译。注意propagate_const只适用于指针,类似指针的类如std::shared_ptrstd::unique_ptr,不适用std::function

下面的例子演示了当试图在const函数中变异一个对象时如何使用propagate_const产生编译错误:

#include <experimental/propagate_const>
class Foo { 
public: 
  Foo(int* ptr) : ptr_{ptr} {}
  auto set_ptr(int* p) const { 
    ptr_ = p;  // Will not compile, as expected
  }
  auto set_val(int v) const { 
    val_ = v;  // Will not compile, as expected
  }
  auto set_ptr_val(int v) const { 
    *ptr_ = v; // Will not compile, const is propagated
  }
private:
  std::experimental::propagate_const<int*> ptr_ = nullptr; 
  int val_{}; 
}; 

大型代码库中正确使用const的重要性怎么强调都不为过,propagate_const的引入使得const-正确性更加有效。

接下来,我们将看看 move 语义和一些处理类内资源的重要规则。

移动语义解释

移动语义是 C++ 11 中引入的一个概念,根据我的经验,这个概念很难掌握,即使是有经验的程序员也很难掌握。因此,我将尝试向您深入解释它是如何工作的,编译器何时使用它,以及最重要的是,为什么需要它。

本质上,C++ 之所以甚至有移动语义的概念,而大多数其他语言没有,是因为它是一种基于值的语言,正如第 1 章c++ 简介中所讨论的。如果 C++ 没有内置移动语义,基于值的语义的优势将在许多情况下丧失,程序员将不得不进行以下权衡之一:

  • 以高性能成本执行冗余深度克隆操作
  • 像 Java 那样对对象使用指针,失去了值语义的健壮性
  • 以牺牲可读性为代价执行容易出错的交换操作

我们不想要这些,所以让我们看看移动语义如何帮助我们。

复制-构建、交换和移动

在我们进入移动的细节之前,我将首先解释和说明复制构造一个对象、交换两个对象和移动构造一个对象之间的区别。

复制-构建对象

当复制处理资源的对象时,需要分配一个新的资源,并且需要复制来自源对象的资源,以便两个对象完全分离。假设我们有一个类Widget,它引用了某种需要在构建时分配的资源。以下代码默认构造一个Widget对象,然后复制构造一个新实例:

auto a = Widget{}; 
auto b = a;        // Copy-construction 

下图说明了所进行的资源分配:

![](img/B15619_02_01.png)

图 2.1:复制带有资源的对象

分配和复制是缓慢的过程,在许多情况下,源对象不再需要。有了移动语义,编译器就可以检测到类似这样的情况,即旧对象没有绑定到变量,而是执行移动操作。

交换两个对象

在 C++ 11 中添加移动语义之前,交换两个对象的内容是一种常见的数据传输方式,无需分配和复制。如下所示,对象只是相互交换内容:

auto a = Widget{};
auto b = Widget{};
std::swap(a, b); 

下图说明了该过程:

![](img/B15619_02_02.png)

图 2.2:在两个对象之间交换资源

std::swap()函数是一个简单但有用的工具,在本章后面的复制和交换习惯用法中使用。

移动-构建对象

移动对象时,目标对象直接从源对象窃取资源,源对象被重置。

如您所见,这与交换非常相似,除了移动的对象不必从移动到的对象接收资源:

auto a = Widget{}; 
auto b = std::move(a); // Tell the compiler to move the resource into b 

下图说明了该过程:

![](img/B15619_02_03.png)

图 2.3:将资源从一个对象移动到另一个对象

虽然源对象被重置,但它仍然处于有效状态。编译器不会自动为我们重置源对象。相反,我们需要在移动构造函数中实现重置,以确保对象处于可以销毁或分配的有效状态。我们将在本章后面讨论有效状态。

只有当对象类型拥有某种资源(最常见的情况是堆分配内存)时,移动对象才有意义。如果所有数据都包含在对象中,移动对象最有效的方法就是复制它。

现在您已经基本掌握了移动语义,让我们来详细了解一下。

资源获取与五大法则

为了完全理解移动语义,我们需要回到 C++ 中类和资源获取的基础。C++ 中的一个基本概念就是一个类应该完全处理自己的资源。这意味着当一个类被复制、移动、拷贝分配、移动分配或析构时,该类应该确保其资源得到相应的处理。实现这五个功能的必要性通常被称为为五大法则

让我们看看如何在处理分配资源的类中实现五的规则。在下面的代码片段中定义的Buffer类中,分配的资源是由原始指针ptr_指向的float的数组:

class Buffer { 
public: 
  // Constructor 
  Buffer(const std::initializer_list<float>& values)       : size_{values.size()} { 
    ptr_ = new float[values.size()]; 
    std::copy(values.begin(), values.end(), ptr_); 
  }
  auto begin() const { return ptr_; } 
  auto end() const { return ptr_ + size_; } 
  /* The 5 special functions are defined below */
private: 
  size_t size_{0}; 
  float* ptr_{nullptr};
}; 

在这种情况下,处理的资源是在Buffer类的构造函数中分配的一块内存。内存可能是类要处理的最常见的资源,但是资源可以更多:互斥体、显卡上纹理的句柄、线程句柄等等。

五项规则中提到的五项职能被省略了,接下来将继续。我们将从复制构造函数、复制赋值和析构函数开始,它们都需要参与资源处理:

// 1\. Copy constructor 
Buffer::Buffer(const Buffer& other) : size_{other.size_} { 
  ptr_ = new float[size_]; 
  std::copy(other.ptr_, other.ptr_ + size_, ptr_); 
} 
// 2\. Copy assignment 
auto& Buffer::operator=(const Buffer& other) {
  delete [] ptr_;
  ptr_ = new float[other.size_];
  size_ = other.size_;
  std::copy(other.ptr_, other.ptr_ + size_, ptr_);
  return *this;
} 
// 3\. Destructor 
Buffer::~Buffer() { 
  delete [] ptr_; // OK, it is valid to delete a nullptr
  ptr_ = nullptr;  
} 

在 C++ 11 中引入移动语义之前,这三个函数通常被称为三个的规则。在以下情况下调用复制构造函数、复制赋值和析构函数:

auto func() { 
  // Construct 
  auto b0 = Buffer({0.0f, 0.5f, 1.0f, 1.5f}); 
  // 1\. Copy-construct 
  auto b1 = b0; 
  // 2\. Copy-assignment as b0 is already initialized 
  b0 = b1; 
} // 3\. End of scope, the destructors are automatically invoked 

虽然正确实现这三个功能是一个类处理其内部资源所需要的,但是会出现两个问题:

  • 不可复制的资源:在Buffer类的例子中,我们的资源是可以复制的,但是在其他类型的资源中,复制没有意义。例如,类中包含的资源可能是std::thread、网络连接或其他无法复制的东西。在这些情况下,我们不能绕过物体。
  • 不必要的复制:如果我们从一个函数返回我们的Buffer类,整个数组都需要复制。(不过,在某些情况下,编译器会优化掉副本,但我们暂时忽略这一点。)

这些问题的解决方案是移动语义。除了复制构造函数和复制赋值,我们还可以在类中添加一个移动构造函数和一个移动赋值操作符。移动版本接受一个Buffer&&对象,而不是一个const引用(const Buffer&)作为参数。

&&修饰符表示参数是我们打算从中移动而不是复制的对象。用 C++ 术语来说,这被称为右值,我们将在后面详细讨论。

copy()函数复制一个对象,而移动等效函数用于将资源从一个对象移动到另一个对象,从资源中释放被移动的对象。

这就是我们如何用移动构造函数和移动赋值扩展我们的Buffer类。如您所见,这些函数不会抛出任何异常,因此可以标记为noexcept。这是因为,与复制构造函数/复制赋值相反,它们不分配内存或做一些可能引发异常的事情:

// 4\. Move constructor
Buffer::Buffer(Buffer&& other) noexcept     : size_{other.size_}, ptr_{other.ptr_} {
  other.ptr_ = nullptr;
  other.size_ = 0;
}
// 5\. Move assignment
auto& Buffer::operator=(Buffer&& other) noexcept {
  ptr_ = other.ptr_;
  size_ = other.size_;
  other.ptr_ = nullptr;
  other.size_ = 0;
  return *this;
} 

现在,当编译器检测到我们执行了看似复制的操作,比如从一个函数中返回一个Buffer,但是复制自的值不再使用时,它将使用不抛出的移动构造函数/移动赋值,而不是复制。

这是相当甜蜜的;界面仍然像复制时一样清晰,但是在幕后,编译器执行了一个简单的移动。因此,程序员不需要使用任何深奥的指针或输出参数来避免复制;当类实现了移动语义时,编译器会自动处理。

不要忘记将你的移动构造函数和移动赋值操作符标记为noexcept(当然,除非它们可能抛出异常)。不标记它们noexcept会阻止标准库容器和算法使用它们,而是在特定条件下使用常规拷贝/分配。

为了能够知道何时允许编译器移动对象而不是复制,理解右值是必要的。

命名变量和值

那么,什么时候才允许编译器移动对象而不是复制呢?简而言之,当对象可以归类为右值时,编译器会移动该对象。术语右值听起来可能很复杂,但本质上它只是一个没有绑定到命名变量的对象,原因如下:

  • 它直接来自一个函数
  • 我们使用std::move()使一个变量成为一个右值

以下示例演示了这两种情况:

// The object returned by make_buffer is not tied to a variable
x = make_buffer();  // move-assigned
// The variable "x" is passed into std::move()
y = std::move(x);   // move-assigned 

我也将在本书中交替使用术语左值命名变量。左值对应于我们可以在代码中通过名称引用的对象。

现在,我们将通过在类中使用类型为std::string的成员变量来使这更高级一点。以下Button课将作为示例:

class Button { 
public: 
  Button() {} 
  auto set_title(const std::string& s) { 
    title_ = s; 
  } 
  auto set_title(std::string&& s) { 
    title_ = std::move(s); 
  } 
  std::string title_; 
}; 

我们还需要一个返回标题和Button变量的自由函数:

auto get_ok() {
  return std::string("OK");
}
auto button = Button{}; 

考虑到这些先决条件,让我们详细看看几个复制和移动的例子:

  • 案例 1 : Button::title_是副本分配的,因为string对象绑定到变量str :

    auto str = std::string{"OK"};
    button.set_title(str);              // copy-assigned 
  • 情况 2 : Button::title_被移动分配,因为str经过std::move() :

    auto str = std::string{"OK"};
    button.set_title(std::move(str));   // move-assigned 
  • 情况 3 : Button::title_是移动指定的,因为新的std::string对象直接从功能中出来:

    button.set_title(get_ok());        // move-assigned 
  • 情况 4 : Button::title_被拷贝分配,因为string对象被绑定到s (这与情况 1 相同):

    auto str = get_ok();
    button.set_title(str);             // copy-assigned 
  • 案例 5 : Button::title_被拷贝分配,因为str被声明为const,因此不允许变异:

    const auto str = get_ok();
    button.set_title(std::move(str));  // copy-assigned 

正如你所看到的,确定一个物体是被移动还是被复制是很简单的。如果有变量名,则复制;否则,它会被移动。如果使用std::move()移动一个命名对象,该对象不能被声明为const

默认移动语义和零规则

本节讨论自动生成的复制分配运算符。重要的是要知道生成的函数没有强异常保证。因此,如果在复制分配期间引发异常,对象可能会处于只被部分复制的状态。

与复制构造函数和复制赋值一样,移动构造函数和移动赋值可以由编译器生成。虽然有些编译器允许自己在特定条件下自动生成这些函数(后面会详细介绍),但我们可以简单地使用default关键字强制编译器生成它们。

对于不手动处理任何资源的Button类,我们可以简单地这样扩展它:

class Button {
public: 
  Button() {} // Same as before

  // Copy-constructor/copy-assignment 
  Button(const Button&) = default; 
  auto operator=(const Button&) -> Button& = default;
  // Move-constructor/move-assignment 
  Button(Button&&) noexcept = default; 
  auto operator=(Button&&) noexcept -> Button& = default; 
  // Destructor
  ~Button() = default; 
  // ...
}; 

更简单地说,如果我们不声明任何自定义复制构造函数/复制赋值或析构函数,那么移动构造函数/移动赋值是隐式声明的,这意味着第一个Button类实际上处理一切:

class Button {
public: 
  Button() {} // Same as before

  // Nothing here, the compiler generates everything automatically! 
  // ...
}; 

很容易忘记,只添加五个函数中的一个会阻止编译器生成其他函数。以下版本的Button类有一个自定义析构函数。因此,不会生成移动运算符,该类将始终被复制:

class Button {
public: 
  Button() {} 
  ~Button() 
    std::cout << "destructed\n"
  }
  // ...
}; 

让我们看看在实现应用类时,如何将这种洞察力用于生成的函数。

真实代码库中的零规则

在实践中,必须自己编写复制/移动构造函数、复制/移动赋值和构造函数的情况应该很少。编写您的类,使它们不需要显式编写任何这些特殊的成员函数(或default -声明),通常被称为零规则。这意味着,如果应用代码库中的一个类需要显式编写这些函数中的任何一个,那么这段代码最好放在代码库的库部分。

在本书的后面,我们将讨论std::optional,这是一个在应用零规则时处理可选成员的便利实用类。

关于空析构函数的一点注记

编写空析构函数可以阻止编译器实现某些优化。正如您在下面的片段中所看到的,用空析构函数复制一个普通类的数组会产生与用手工for循环复制相同的(非优化的)汇编代码。第一个版本使用空析构函数std::copy():

struct Point {
 int x_, y_;
 ~Point() {}     // Empty destructor, don't use!
};
auto copy(Point* src, Point* dst) {
  std::copy(src, src+64, dst);
} 

第二个版本使用了一个没有析构函数的Point类,但是有一个手工的for循环:

struct Point {
  int x_, y_;
};
auto copy(Point* src, Point* dst) {
  const auto end = src + 64;
  for (; src != end; ++ src, ++ dst) {
    *dst = *src;
  }
} 

两个版本都生成了以下 x86 汇编程序,对应于一个简单的循环:

 xor eax, eax
.L2:
 mov rdx, QWORD PTR [rdi+rax]
 mov QWORD PTR [rsi+rax], rdx
 add rax, 8
 cmp rax, 512
 jne .L2
 rep ret 

然而,如果我们移除析构函数或声明析构函数default,编译器优化std::copy()以利用memmove()而不是循环:

struct Point { 
  int x_, y_; 
  ~Point() = default; // OK: Use default or no constructor at all
};
auto copy(Point* src, Point* dst) {
  std::copy(src, src+64, dst);
} 

前面的代码通过memmove()优化生成了下面的 x86 汇编程序:

 mov rax, rdi
 mov edx, 512
 mov rdi, rsi
 mov rsi, rax
 jmp memmove 

汇编程序是在编译器浏览器中使用 GCC 7.1 生成的,可在https://godbolt.org/上获得。

总而言之,使用default析构函数或无析构函数来支持空析构函数,以从应用中挤出更多的性能。

一个常见的陷阱——转移非资源

使用默认创建的移动赋值时,有一个常见的陷阱:将基本类型与更高级的复合类型混合在一起的类。与复合类型相反,基本类型(如intfloatbool)在移动时只是被复制,因为它们不处理任何资源。

当一个简单的类型与一个拥有资源的类型混合时,移动分配就变成了移动和复制的混合。

下面是一个会失败的类的例子:

class Menu {
public:
  Menu(const std::initializer_list<std::string>& items)       : items_{items} {}
  auto select(int i) {
    index_ = i;
  }
  auto selected_item() const {
     return index_ != -1 ? items_[index_] : "";
  }
  // ...
private:
  int index_{-1}; // Currently selected item
  std::vector<std::string> items_; 
}; 

如果像这样使用Menu类,它将具有未定义的行为:

auto a = Menu{"New", "Open", "Close", "Save"};
a.select(2);
auto b = std::move(a);
auto selected = a.selected_item(); // crash 

未定义的行为随着items_向量的移动而发生,因此是空的。另一方面,index_是复制的,因此在移动对象a中仍然具有值2。当调用selected_item()时,函数将尝试在索引2处访问items_,程序将崩溃。

在这些情况下,移动构造函数/赋值可以通过简单地交换成员来实现,如下所示:

Menu(Menu&& other) noexcept { 
  std::swap(items_, other.items_); 
  std::swap(index_, other.index_); 
} 
auto& operator=(Menu&& other) noexcept { 
  std::swap(items_, other.items_); 
  std::swap(index_, other.index_); 
  return *this; 
} 

这样,Menu级可以安全移动,同时还保留了不丢球的保证。在第 8 章**编译时编程中,您将学习如何利用 C++ 中的反射技术来自动创建交换元素的移动构造函数/赋值函数。

将&&修饰符应用于类成员函数

除了将应用于对象之外,您还可以将&&修改器添加到类的成员函数,就像您可以将const修改器应用到成员函数一样。与const修改器一样,只有当对象是右值时,才会通过重载解析来考虑具有&&修改器的成员函数:

struct Foo { 
  auto func() && {} 
}; 
auto a = Foo{}; 
a.func();            // Doesn't compile, 'a' is not an rvalue 
std::move(a).func(); // Compiles 
Foo{}.func();        // Compiles 

似乎很奇怪会有人想要这种行为,但是有用例。我们将在第 10 章 代理对象和懒评中调查其中一个。

无论如何,当副本被删除时不要移动

从函数中返回值时,使用std::move()可能很有诱惑力,如下所示:

auto func() {
  auto x = X{};
  // ...
  return std::move(x);  // Don't, RVO is prevented
} 

然而,除非x是只动类型,否则你不应该这样做。std::move()的这种用法阻止了编译器使用返回值优化 ( RVO )从而完全省略了x的复制,这比移动它更有效率。所以,按值返回新创建的对象时,不要使用std::move();相反,只需返回对象:

auto func() {
  auto x = X{};
  // ...
  return x;  // OK
} 

这个名为的对象被省略的特殊例子通常被称为 NRVO ,或者名为-RVO 的**。RVO 和 NRVO 目前由所有主要的 C++ 编译器实现。如果你想阅读更多关于 RVO 的内容,复制省略,可以在https://en.cppreference.com/w/cpp/language/copy_elision找到详细的总结。**

适用时按值传递

考虑一个函数将一个std::string转换成小写。为了在适用的情况下使用移动构造函数,否则使用复制构造函数,似乎需要两个函数:

// Argument s is a const reference
auto str_to_lower(const std::string& s) -> std::string {
  auto clone = s;
  for (auto& c: clone) c = std::tolower(c);
  return clone;
}
// Argument s is an rvalue reference
auto str_to_lower(std ::string&& s) -> std::string {
  for (auto& c: s) c = std::tolower(c);
  return s;
} 

然而,通过取值std::string,我们可以编写一个函数来涵盖这两种情况:

auto str_to_lower(std::string s) -> std::string {
  for (auto& c: s) c = std::tolower(c);
  return s;
} 

让我们看看为什么str_to_lower()的这个实现在可能的情况下避免了不必要的复制。当传递一个正则变量时,如下所示,str的内容在函数调用之前被复制构造到s中,然后在函数返回时被移动分配回str:

auto str = std::string{"ABC"};
str = str_to_lower(str); 

当传递一个右值时,如下所示,str的内容在函数调用之前被移动构造到s中,然后在函数返回时被移动分配回str。因此,不会通过函数调用进行复制:

auto str = std::string{"ABC"};
str = str_to_lower(std::move(str)); 

乍一看,这项技术似乎适用于所有参数。然而,这种模式并不总是最佳的,正如您接下来将看到的。

传递值不适用的情况

有时这种先接受价值再转移的模式实际上是一种模仿。例如,考虑下面的类,其中函数set_data()将保存传递给它的参数的副本:

class Widget {
  std::vector<int> data_{};
  // ...
public:
  void set_data(std::vector<int> x) { 
    data_ = std::move(x);               
  }
}; 

假设我们调用set_data()并传递一个左值,如下所示:

auto v = std::vector<int>{1, 2, 3, 4};
widget.set_data(v);                  // Pass an lvalue 

由于我们正在传递一个命名对象v,代码将复制-构造一个新的std::vector对象x,然后将该对象移动-分配到data_成员中。除非我们将一个空向量对象传递给set_data(),否则std::vector复制构造函数将为其内部缓冲区执行堆分配。

现在将此与针对左值优化的以下版本的set_data()进行比较:

void set_data(const std::vector<int>& x) { 
    data_ = x;  // Reuse internal buffer in data_ if possible
} 

这里,只有当当前向量data_的容量小于源对象x的大小时,赋值运算符内部才会有堆分配。换句话说,data_的内部预分配缓冲区在许多情况下可以在赋值操作符中重用,并使我们免于额外的堆分配。

如果我们发现有必要为左值和右值优化set_data(),在这种情况下,最好提供两个重载:

void set_data(const std::vector<int>& x) {
  data_ = x;
}
void set_data(std::vector<int>&& x) noexcept { 
  data_ = std::move(x);
} 

第一个版本最适合左值,第二个版本最适合右值。

最后,我们现在来看一个场景,在这个场景中,我们可以安全地传递值,而不用担心刚刚演示的模拟。

移动构造函数参数

在构造函数中初始化类成员时,我们可以安全地使用按值传递然后移动的模式。在构建新对象的过程中,不可能有预先分配的缓冲区可以用来避免堆分配。下面是一个带有一个std::vector成员和一个构造函数的类的例子来演示这个模式:

class Widget {
  std::vector<int> data_;
public:
  Widget(std::vector<int> x)       // By value
      : data_{std::move(x)} {}     // Move-construct
  // ...
}; 

我们现在将把焦点转移到一个不能被认为是现代 C++ 但即使在今天也经常被讨论的话题上。

设计带有错误处理的接口

错误处理是重要的,也是函数和类的接口中经常被忽略的部分。在 C++ 中,错误处理是一个备受争议的话题,但是讨论往往集中在异常和其他错误机制上。虽然这是一个有趣的领域,但是在关注错误处理的实际实现之前,还有其他方面的错误处理更需要理解。显然,异常和错误代码已经在许多成功的软件项目中使用过,偶然发现将两者结合在一起的项目并不少见。

无论使用何种编程语言,错误处理的一个基本方面是区分编程错误(也称为 bug)和运行时错误。运行时错误可进一步分为可恢复运行时错误不可恢复运行时错误。不可恢复的运行时错误的一个例子是堆栈溢出(参见第 7 章内存管理)。当不可恢复的错误发生时,程序通常会立即终止,因此发出这类错误的信号是没有意义的。然而,一些错误可能在一种类型的应用中被认为是可恢复的,但在其他应用中是不可恢复的。

在讨论可恢复和不可恢复的错误时,经常出现的一个边缘情况是 C++ 标准库在内存不足时的一些不幸行为。当您的程序内存不足时,这通常是不可恢复的,然而当这种情况发生时,标准库(尝试)会抛出一个std::bad_alloc异常。我们在这里不会花时间讨论不可恢复的错误,但是如果你想更深入地研究这个话题,强烈推荐赫伯·萨特(https://sched.co/SiVW)的演讲去碎片化 C++ :让异常和 RTTI 变得更加经济和可用。

当设计和实现一个应用编程接口时,您应该始终思考您正在处理什么类型的错误,因为不同类别的错误应该以完全不同的方式处理。确定错误是编程错误还是运行时错误可以通过使用名为合同设计的方法来完成;这是一个值得单独成书的话题。然而,我将在这里介绍基本原理,这些对于我们的目的来说已经足够了。

有人提议在 C++ 中增加对契约的语言支持,但目前契约还没有达到标准。然而,许多 c++ API 和指南都假设您了解契约的基础知识,因为契约使用的术语使讨论和记录类和函数的接口变得更加容易。

契约

一个契约是在某个函数的调用者和函数本身(被调用者)之间的一组规则。C++ 允许我们使用 C++ 类型系统显式指定一些规则。例如,考虑以下函数签名:

int func(float x, float y) 

它指定func()正在返回一个整数(除非它抛出一个异常),并且调用方必须传递两个浮点值。但是,它没有说明允许什么样的浮点值。例如,我们可以传递值 0.0 或负值吗?此外,xy之间可能存在一些不容易用 C++ 类型系统表达的必需关系。当我们在 C++ 中谈论契约时,我们通常指的是调用者和被调用者之间存在的规则,这些规则不能用类型系统轻松表达。

在不太正式的情况下,这里将介绍几个与契约设计相关的概念,以便为您提供一些术语,您可以用来推理接口和错误处理:

  • 先决条件规定了函数调用方的职责。传递给函数的参数可能有限制。或者,如果它是成员函数,对象在调用函数之前可能必须处于特定状态。例如,在std::vector上调用pop_back()的前提条件是向量不为空。pop_back()调用者有责任确保向量不为空。
  • 一个后置条件规定了功能返回后的职责。如果是成员函数,函数在什么状态下离开对象?例如std::list::sort()的后置条件是列表中的元素按升序排序。
  • 不变量是一个应该永远成立的条件。不变量可以在许多环境中使用。一个循环不变量是在每个循环迭代开始时必须为真的条件。此外,类不变量定义了对象的有效状态。例如std::vector的一个不变量就是size() <= capacity()。明确陈述一些代码周围的不变量可以让我们更好地理解代码。不变量也是一个工具,可以用来证明某些算法做了它应该做的事情。

类不变量非常重要;因此,我们将花更多的时间来讨论它们是什么,以及它们如何影响类的设计。

类不变量

如上所述,类不变量定义了对象的有效状态。它指定类内部数据成员之间的关系。在成员函数执行期间,对象可能暂时处于无效状态。重要的是,每当函数将控制传递给其他可以观察对象状态的代码时,不变量都会得到维护。当函数:

  • 返回
  • 引发异常
  • 调用回调函数
  • 调用其他一些可能观察当前调用对象状态的函数;一个常见的场景是将对this的引用传递给其他函数

重要的是要认识到类不变量是类的每个成员函数的前置条件和后置条件的隐含部分。如果成员函数使对象处于无效状态,则后条件尚未满足。类似地,成员函数可以始终假设对象在调用该函数时处于有效状态。这个规则的例外是类的构造函数和析构函数。如果我们想插入代码来检查类不变量是否成立,我们可以从以下几点着手:

struct Widget {
  Widget() {
    // Initialize object…
    // Check class invariant
  }
  ~Widget() {
    // Check class invariant
    // Destroy object…
   }
   auto some_func() {
     // Check precondition (including class invariant)
     // Do the actual work…
     // Check postcondition (including class invariant)
   }
}; 

这里省略了复制/移动构造函数和复制/移动赋值操作符,但是它们分别遵循与构造函数和some_func()相同的模式。

当一个对象被移出时,该对象可能处于某个空状态或重置状态。这也是对象的有效状态,因此是类不变量的一部分。但是,当对象处于这种状态时,通常只能调用少数成员函数。例如,您不能在已经移动的std::vector上调用push_back()empty()size(),但您可以调用clear(),这将使向量处于可以再次使用的状态。

但是,您应该知道,这个额外的重置状态会使类不变量变得越来越弱,越来越没用。为了完全避免这种状态,您应该以这样一种方式实现您的类,以便从对象被重置为对象在默认构造后的状态。我的建议是始终这样做,除非在极少数情况下,将移动状态重置为默认状态会带来不可接受的性能损失。通过这种方式,您可以更好地推理移动自状态,并且使用该类更安全,因为在该对象上调用成员函数是可以的。

如果你能确保一个对象总是处于有效状态(类不变量成立),你很可能有一个很难误用的类,如果你在实现中有 bug,它们通常很容易被发现。你最不想看到的就是在你的代码库中找到一个类,然后想知道这个类的某些行为是一个 bug 还是一个特性。违反合同总是一个严重的错误。

为了能够写出有意义的类不变量,要求我们写出内聚性高、可能状态少的类。如果您曾经为自己编写的类编写过单元测试,那么您可能已经注意到,在编写单元测试时,很明显 API 可以从初始版本进行改进。单元测试迫使您使用和思考类的接口,而不是实现细节。同样,类不变量让你思考对象可能处于的所有有效状态。如果你发现很难定义一个类不变量,那通常是因为你的类有太多的责任和处理太多的状态。因此,定义类不变量通常意味着最终得到设计良好的类。

维护合同

契约是你设计并实现的应用编程接口的一部分。但是,如何使用您的应用编程接口维护合同并将其传达给客户呢?C++ 还没有内置的合同支持,但是正在努力将其添加到 C++ 的未来版本中。不过,有一些选择:

  • 使用诸如 Boost.Contract 之类的库
  • 记录合同。这样做的缺点是运行程序时不检查合同。此外,当代码改变时,文档往往会过时。
  • 使用static_assert()<cassert>中定义的assert()宏。断言是可移植的,标准的 C++。
  • 构建一个自定义库,其中包含类似于断言的自定义宏,但是可以更好地控制失败契约的行为。

在本书中,我们将使用断言,这是检查违反合同的最原始的方法之一。尽管如此,断言可能非常有效,并对代码质量产生巨大影响。

启用和禁用资产

从技术上讲,我们有两种标准的方式来断言 C++ 中的事情:使用static_assert()或者来自<cassert>头的assert()宏。static_assert()是在代码编译期间验证的,因此需要一个可以在编译时而不是运行时检查的表达式。失败的static_assert()导致编译错误。

对于只能在运行时评估的断言,需要使用assert()宏来代替。assert()宏是一个运行时检查,通常在调试和测试期间处于活动状态,当程序在发布模式下构建时,它将被完全禁用。assert()宏观通常是这样定义的:

#ifdef NDEBUG
#define assert(condition) ((void)0)
#else
#define assert(condition) /* implementation defined */
#endif 

这意味着您可以通过定义NDEBUG完全删除所有断言和用于检查条件的代码。

现在,用一些术语从你的腰带下的契约设计开始,让我们关注契约违反(错误)以及如何在你的代码中处理它们。

错误处理

在设计具有适当错误处理的 API 时,首先要做的是区分编程错误和运行时错误。因此,在我们深入研究错误处理策略之前,我们将使用契约式设计来定义我们正在处理的错误类型。

编程错误还是运行时错误?

如果我们发现违反了合同,我们也发现了程序中的错误。例如,如果我们可以检测到有人在空向量上调用pop_back(),我们就知道我们的源代码中至少有一个 bug 需要修复。只要不满足前提条件,我们就知道我们正在处理一个编程错误

另一方面,如果我们有一个函数从磁盘加载一些记录,并且由于磁盘上的读取错误而无法返回记录,那么我们检测到了一个运行时错误:

auto load_record(std::uint32_t id) {
  assert(id != 0);           // Precondition
  auto record = read(id);    // Read from disk, may throw
  assert(record.is_valid()); // Postcondition
  return record;
} 

前提条件满足,但是后条件因为我们程序外的原因无法满足。源代码中没有错误,但是由于一些与磁盘相关的错误,该函数无法返回在磁盘上找到的记录。由于无法满足后置条件,必须向调用者报告运行时错误,除非调用者可以通过重试等方式从错误中恢复过来。

编程错误(错误)

总的来说,编写代码来表示和处理代码中的 bug 是没有意义的。相反,使用断言(或者前面提到的其他替代方法)让开发人员意识到代码中的问题。对于可恢复的运行时错误,您应该只使用异常或错误代码。

通过假设缩小问题空间

断言指定了作为某些代码的作者,您所做的假设。只有当代码中的所有断言都成立时,才能保证代码按预期工作。这使得编码变得更加容易,因为您可以有效地限制需要处理的案例数量。当使用、读取和修改您编写的代码时,断言对您的团队也是一个巨大的帮助。所有的假设都以断言的形式清晰地记录下来。

发现资产中的 bug

失败的断言总是一个严重的错误。当你发现一个断言在测试中失败时,基本上有三个选项:

  • 断言是正确的,但是代码是错误的(要么是因为函数实现中的错误,要么是因为调用站点上的错误)。以我的经验,这是最常见的情况。获得正确的断言通常比获得正确的代码更容易。修复代码并再次测试。
  • 代码是正确的,但是断言是错误的。有时会发生这种情况,如果您正在查看旧代码,通常会非常不舒服。更改或删除失败的断言可能会很耗时,因为您需要 100%确定代码确实有效,并理解为什么旧的断言突然开始失败。通常,这是因为原始作者没有想到的新用例。
  • 断言和代码都是错误的。这通常需要重新设计类或函数。可能需求变了,程序员做的假设不再成立。但不要绝望;相反,您应该很高兴这些假设是使用断言显式编写的;现在你知道为什么代码不再工作了。

运行时断言需要测试,否则断言不会被执行。新编写的带有许多断言的代码在测试时通常会中断。这并不意味着你是一个糟糕的程序员;这意味着您添加了有意义的断言来捕获一些错误,否则这些错误可能会进入生产。同样,导致程序测试版本终止的错误也可能被修复。

性能影响

在你的代码中有许多运行时断言很可能会降低你的测试构建的性能。然而,断言从来不意味着在优化程序的最终版本中使用。如果您的断言使您的测试构建太慢而无法使用,那么在剖析器中找到减慢代码的断言集通常很容易跟踪(有关剖析的更多信息,请参见第 3 章分析和测量性能)。

通过让您的程序的发布版本完全忽略各种编程错误,您的程序将不会花时间检查由错误引起的错误状态。相反,您的代码将运行得更快,并且只花时间解决它应该解决的实际问题。它将只检查需要恢复的运行时错误。

总而言之,在测试程序时应该检测到编程错误。不需要使用异常或其他错误处理机制来处理编程错误。相反,编程错误最好记录一些有意义的东西,并终止程序,以通知程序员需要修复错误。遵循这一准则可以大大减少代码中需要处理异常的地方。我们将在优化的构建中有更好的性能,并且希望更少的错误,因为它们已经被失败的断言检测到了。但是,也有可能在运行时出现错误的情况,这些错误需要我们实现的代码来处理和恢复。

可恢复的运行时错误

如果一个函数不能维护它在契约中的部分(即后置条件),那么运行时错误已经发生,需要在代码中的某个地方发出信号来处理它并恢复有效状态。处理可恢复错误的目的是将错误从错误发生的地方传递到可以恢复有效状态的地方。有很多方法可以实现这一点。这枚硬币有两面:

  • 对于信号部分,我们可以在 C++ 异常、错误代码、返回一个std::optionalstd::pair,或使用boost::outcomestd::experimental::expected之间进行选择。
  • 保持程序的有效状态而不泄漏任何资源。确定性析构函数和自动存储持续时间是在 C++ 中实现这一点的工具。

实用程序类std::optionalstd::pair将包含在第 9 章基本实用程序中。我们现在将关注 C++ 异常,以及如何在从错误中恢复时避免资源泄漏。

例外

例外是 C++ 提供的标准错误处理机制。这种语言被设计成在有例外的情况下使用。这方面的一个例子是构造函数失败;从构造函数发出错误信号的唯一方法是使用异常。

根据我的经验,异常有许多不同的用法。其中一个原因是,不同的应用在处理运行时错误时会有截然不同的需求。对于一些应用,如起搏器或电厂控制系统,如果崩溃可能会产生严重影响,我们可能必须处理每一种可能的异常情况,如内存不足,并保持应用处于运行状态。有些应用甚至完全不使用堆内存,要么是因为平台根本没有可用的堆,要么是因为堆引入了不可控制的不确定性,因为分配新内存的机制超出了应用的控制范围。

我假设您已经知道抛出和捕获异常的语法,这里就不赘述了。保证不抛出异常的函数可以标记为noexcept。重要的是要理解编译器不验证这一点;相反,应该由代码的作者来判断他们的函数是否会抛出异常。

标有noexcept的函数使得编译器在某些情况下可以生成更快的代码。如果标记有noexcept的函数抛出异常,程序将调用std::terminate()而不是展开堆栈。下面的代码演示了如何将函数标记为不抛出:

auto add(int a, int b) noexcept {
  return a + b;
} 

您可能会注意到,本书中的许多代码示例没有使用noexcept(或const),即使它在生产代码中是合适的。这只是因为一本书的格式;我通常会在所有地方添加noexceptconst,这将使代码难以阅读。

保持有效状态

异常处理需要我们程序员思考异常安全保障;也就是说,异常发生前后的程序状态是怎样的?强异常安全可以看作一个事务。函数要么提交所有状态更改,要么在出现异常时执行完全回滚。

为了更具体一点,让我们来看看下面这个简单的函数:

void func(std::string& str) {
  str += f1();  // Could throw
  str += f2();  // Could throw
} 

该函数将f1()f2()的结果追加到字符串str中。现在考虑一下如果调用函数f2()时抛出异常会发生什么;只有来自f1()的结果会被附加到str上。相反,如果出现异常,我们希望str保持不变。这可以通过使用一个叫做复制并交换的习惯用法来解决。这意味着在我们让应用的状态被非抛出swap()函数修改之前,我们执行可能在临时副本上抛出异常的操作:

void func(std::string& str) {
  auto tmp = std::string{str};  // Copy
  tmp += f1();                  // Mutate copy, may throw
  tmp += f2();                  // Mutate copy, may throw
  std::swap(tmp, str);          // Swap, never throws
} 

在成员函数中可以使用相同的模式来保持对象的有效状态。假设我们有一个包含两个数据成员的类,一个类不变量表示数据成员不能相等比较,如下所示:

class Number { /* ... */ };
class Widget {
public:
  Widget(const Number& x, const Number& y) : x_{x}, y_{y} {
    assert(is_valid());           // Check class invariant
  }
private:
  Number x_{};
  Number y_{};
  bool is_valid() const {         // Class invariant
   return x_ != y_;               // x_ and y_ must not be equal
  }
}; 

接下来,假设我们添加了一个更新两个数据成员的成员函数,如下所示:

void Widget::update(const Number& x, const Number& y) {
  assert(x != y && is_valid());   // Precondition
  x_ = x;
  y_ = y;          
  assert(is_valid());             // Postcondition
} 

前提条件是xy不能相等比较。如果x_y_的赋值可以抛出,x_可能会更新,但y_不会。这可能会导致类不变量被破坏;即处于无效状态的对象。如果发生错误,我们希望函数保留赋值操作之前对象的有效状态。同样,一个可能的解决方案是使用复制和交换习惯用法:

void Widget::update(const Number& x, const Number& y) {
    assert(x != y && is_valid());     // Precondition
    auto x_tmp = x;  
    auto y_tmp = y;  
    std::swap(x_tmp, x_); 
    std::swap(y_tmp, y_); 
    assert(is_valid());               // Postcondition
  } 

首先,在不修改对象状态的情况下创建本地副本。然后,如果没有抛出异常,可以使用非抛出swap()来改变对象的状态。复制和交换习惯用法也可以在实现赋值操作符时使用,以实现强大的异常安全保证。

错误处理的另一个重要方面是避免在错误发生时泄漏资源。

资源获取

C++ 对象的破坏是可预测的,这意味着我们可以完全控制何时以及以何种顺序释放我们已经获得的资源。下面的例子进一步说明了这一点,互斥变量m在退出函数时总是解锁的,因为当我们退出作用域时,作用域锁会释放它,而不管我们如何以及在哪里退出:

auto func(std::mutex& m, bool x, bool y) {
  auto guard = std::scoped_lock{m}; // Lock mutex 
  if (x) { 
    // The guard automatically releases the mutex at early exit
    return; 
  }
  if (y) {
    // The guard automatically releases if an exception is thrown
    throw std::exception{};
  }
  // The guard automatically releases the mutex at function exit
} 

所有权、对象的生存期和资源获取是 C++ 中的基本概念,我们将在第 7 章内存管理中进行介绍。

表演

不幸的是,在性能方面,例外的名声很差。有些担忧是合理的,而有些则是基于编译器没有有效实现异常时的历史观察。然而,今天人们放弃例外有两个主要原因:

  • 即使没有抛出异常,二进制程序的大小也会增加。尽管这通常不是一个问题,但它并不遵循零开销原则,因为我们在为我们不使用的东西付费。
  • 抛出和捕捉异常相对昂贵。抛出和捕获异常的运行时成本是不确定的。这使得异常不适用于有严格实时要求的环境。在这种情况下,其他替代方法可能更好,例如返回带有返回值和错误代码的std::pair

另一方面,当没有抛出异常时,异常表现得非常出色;也就是说,当程序遵循成功路径时。其他错误报告机制,如错误代码,要求检查if-else语句中的返回代码,即使程序运行时没有任何错误。

异常应该很少发生,通常当异常发生时,异常处理增加的额外性能损失在这些情况下通常不是问题。通常有可能在一些性能关键的代码运行之前或之后执行可能引发的计算。这样,我们就可以避免在程序中我们负担不起异常的地方抛出和捕获异常。

为了公平地比较异常和其他错误报告机制,指定要比较的内容非常重要。有时将异常与完全没有错误处理进行比较,这是不公平的;当然,异常需要与提供相同功能的机制进行比较。在衡量异常可能产生的影响之前,不要因为性能原因而放弃它们。您可以在下一章阅读更多关于分析和测量性能的内容。

现在我们将远离错误处理,探索如何使用 lambda 表达式创建函数对象。

函数对象和 lambda 表达式

在 C++ 11 中引入的 Lambda 表达式是现代 C++ 中最有用的特性之一,此后的每个 C++ 版本都进一步增强了它。它们的多功能性不仅来自于容易地将函数传递给算法,还来自于它们在许多需要传递代码的情况下的使用,尤其是当你可以在std::function中存储一个 lambda 时。

虽然 lambdas 使这些编程技术的使用变得非常简单,但是在这个部分中提到的所有东西都可以在没有它们的情况下执行。lambda——或者更正式地说,lambda 表达式——是构造函数对象的一种便捷方式。但是我们可以用重载的operator()来实现类,然后实例化这些类来创建函数对象,而不是使用 lambda 表达式。

稍后我们将探讨 lambda 与这些类的相似之处,但首先我将在一个简单的用例中介绍 lambda 表达式。

C++ lambda 的基本语法

简而言之,lambdas 使程序员能够将函数传递给其他函数,就像传递变量一样容易。

让我们将传递 lambda 给算法与传递变量进行比较:

// Prerequisite 
auto v = std::vector{1, 3, 2, 5, 4}; 

// Look for number three 
auto three = 3; 
auto num_threes = std::count(v.begin(), v.end(), three); 
// num_threes is 1 

// Look for numbers which is larger than three 
auto is_above_3 = [](int v) { return v > 3; }; 
auto num_above_3 = std::count_if(v.begin(), v.end(), is_above_3);
// num_above_3 is 2 

在第一种情况下,我们传递一个变量给std::count(),在后一种情况下,我们传递一个函数对象给std::count_if()。这是 lambdas 的典型用例;我们将一个函数传递给另一个函数(在本例中为std::count_if())进行多次求值。

此外,lambda 不需要绑定到变量;就像我们可以将变量放入表达式一样,我们也可以用 lambda:

auto num_3 = std::count(v.begin(), v.end(), 3); 
auto num_above_3 = std::count_if(v.begin(), v.end(), [](int i) { 
  return i > 3; 
}); 

到目前为止你所看到的 lambdas 被称为无状态 lambdas;它们不复制或引用 lambda 外部的任何变量,因此不需要任何内部状态。让我们通过使用捕获块引入有状态 lambdas 来使这个更高级一点。

俘获条款

在前面的例子中,我们对 lambda 中的值3进行了硬编码,以便我们总是计算大于 3 的数字。如果我们想在 lambda 内部使用外部变量呢?我们所做的是通过将外部变量放入捕获子句来捕获它们;也就是λ的[]部分:

auto count_value_above(const std::vector<int>& v, int x) { 
  auto is_above = [x](int i) { return i > x; }; 
  return std::count_if(v.begin(), v.end(), is_above); 
} 

在这个例子中,我们通过将变量x复制到 lambda 中来捕获它。如果我们要声明x作为参考,我们在开头放一个&,像这样:

auto is_above = [&x](int i) { return i > x; }; 

该变量现在只是对外部x变量的引用,就像 C++ 中的常规引用变量一样。当然,我们需要非常谨慎地对待通过引用传递到 lambda 中的对象的生命周期,因为 lambda 可能在被引用对象已经不存在的上下文中执行。因此,通过价值获取更安全。

按引用捕获与按值捕获

使用 capture 子句进行引用和复制变量就像常规变量一样工作。看看这两个例子,看看你是否能发现其中的区别:

| 按价值获取 | 引用捕获 | |
auto func() {
  auto vals = {1,2,3,4,5,6};
  auto x = 3;
  auto is_above = [x](int v) {
    return v > x;
  };
  x = 4;
  auto count_b = std::count_if(
    vals.begin(),
    vals.end(),
    is_above
   );  // count_b equals 3 } 

|

auto func() {
  auto vals = {1,2,3,4,5,6};
  auto x = 3;
  auto is_above = [&x](int v) {
    return v > x;
  };
  x = 4;
  auto count_b = std::count_if(
    vals.begin(),
    vals.end(),
    is_above
   );  // count_b equals 2 } 

|

在第一个例子中,x复制到了λ中,因此当x被 突变时不受影响;因此std::count_if()计算的数值大于 3。

在第二个例子中,x被参考捕获到*,因此std::count_if()计算出的值的数大于 4。*

λ和类之间的相似性

我前面提到 lambda 表达式生成函数对象。函数对象是定义了调用运算符operator()()的类的实例。

为了理解 lambda 表达式由什么组成,您可以将其视为一个有限制的常规类:

  • 该类只包含一个成员函数
  • capture 子句是类成员变量及其构造函数的组合

下表显示了 lambda 表达式和相应的类。左栏使用按值捕捉,右栏使用 c 按引用捕捉:

| 按值捕获的λ… | 通过引用捕获的λ... | |
auto x = 3;auto is_above = [x](int y) { return y > x;};auto test = is_above(5); 

|

auto x = 3;auto is_above = [&x](int y) { return y > x;};auto test = is_above(5); 

| | ...对应于此类: | ...对应于此类: | |

auto x = 3;class IsAbove {
public: IsAbove(int x) : x{x} {} auto operator()(int y) const {   return y > x; }private: int x{}; // Value };auto is_above = IsAbove{x};
auto test = is_above(5); 

|

auto x = 3;class IsAbove {
public: IsAbove(int& x) : x{x} {} auto operator()(int y) const {   return y > x; }private: int& x; // Reference };
auto is_above = IsAbove{x};
auto test = is_above(5); 

|

得益于 lambda 表达式,我们不必手动将这些函数对象类型实现为类。

在捕获中初始化变量

如前面的示例所示,capture 子句初始化相应类中的成员变量。这意味着我们也可以初始化 lambda 中的成员变量。这些变量只能从 lambda 内部看到。下面是一个初始化名为numbers的捕获变量的 lambda 示例:

auto some_func = [numbers = std::list<int>{4,2}]() {
  for (auto i : numbers)
    std::cout << i;
};
some_func();  // Output: 42 

对应的类看起来像这样:

class SomeFunc {
public:
 SomeFunc() : numbers{4, 2} {}
 void operator()() const {
  for (auto i : numbers)
    std::cout << i;
 }
private:
 std::list<int> numbers;
};
auto some_func = SomeFunc{};
some_func(); // Output: 42 

初始化捕获内部的变量时,可以想象在变量名前面有一个隐藏的auto关键字。在这种情况下,你可以认为numbers被定义为像auto numbers = std::list<int>{4, 2}一样。如果你想初始化一个引用,你可以在名字前面用一个&符号,对应auto&。这里有一个例子:

auto x = 1;
auto some_func = [&y = x]() {
  // y is a reference to x
}; 

同样,当引用(而不是复制)lambda 之外的对象时,您必须非常谨慎地对待生存期。

也可以在 lambda 内移动对象,这在使用仅移动类型(如std::unique_ptr)时是必要的。这是如何做到的:

auto x = std::make_unique<int>(); 
auto some_func = [x = std::move(x)]() {
  // Use x here..
}; 

这也证明了变量可以使用相同的名称(x)。这是没有必要的。相反,我们可以在 lambda 中使用一些其他名称,例如[y = std::move(x)]

突变 lambda 成员变量

由于 lambda 的工作原理就像一个带有成员变量的类,它也可以变异它们。但是一个 lambda 的函数调用运算符默认为const,所以我们明确需要指定 lambda 可以通过使用mutable关键字对其成员进行变异。在下面的例子中,lambda 每次被调用时都会变异counter变量:

auto counter_func = [counter = 1]() mutable {
  std::cout << counter++ ;
};
counter_func(); // Output: 1
counter_func(); // Output: 2
counter_func(); // Output: 3 

如果一个 lambda 只通过引用捕获变量,我们不需要在声明中添加mutable修饰符,因为 lambda 本身不会变异。可变和不可变 lambdas 之间的区别在下面的代码片段中演示:

| 按价值获取 | 引用捕获 | |
auto some_func() {
  auto v = 7;
  auto lambda = [v]() mutable {
    std::cout << v << " ";
    ++ v;
  };
  assert(v == 7);
  lambda();  lambda();
  assert(v == 7);
  std::cout << v;
} 

|

auto some_func() {
  auto v = 7;
  auto lambda = [&v]() {
    std::cout << v << " ";
    ++ v;
  };
  assert(v == 7);
  lambda();
  lambda();
  assert(v == 9);
  std::cout << v;
} 

| | 输出:7 8 7 | 输出:7 8 9 |

在右边的例子中v被引用捕获,lambda 将变异变量v,它属于some_func()的范围。左栏的突变 lambda 只会突变v的一个副本,属于 lambda 本身。这就是为什么我们最终会在两个版本中得到不同的输出。

从编译器的角度改变成员变量

为了理解在前面的例子中发生了什么,看看编译器是如何看到前面的 lambda 对象的:

| 按价值获取 | 引用捕获 | |
class Lambda {
 public:
 Lambda(int m) : v{m} {}
 auto operator()() {
   std::cout<< v << " ";
   ++ v;
 }
private:
  int v{};
}; 

|

class Lambda {
 public:
 Lambda(int& m) : v{m} {}
 auto operator()() const {
   std::cout<< v << " ";
   ++ v;
 }
private:
 int& v;
}; 

|

如您所见,第一种情况对应于具有常规成员的类,而通过引用捕获的情况只是对应于成员变量是引用的类。

您可能已经注意到,我们在按引用捕获类的operator()成员函数上添加了修饰符const,并且我们也没有在相应的 lambda 上指定mutable。这个类仍然被认为是const的原因是我们没有在实际的类/lambda 里面变异任何东西;实际突变适用于参考值,因此该函数仍被视为const

捕获全部

除了逐个捕获变量外,范围内的所有变量都可以通过简单写[=][&]来捕获。

使用[=]意味着每个变量都将被值捕获,而[&]通过引用捕获所有变量。

如果我们在成员函数中使用 lambdas,也可以通过使用[this]引用或通过编写[*this]复制来捕获整个对象:

class Foo { 
public: 
 auto member_function() { 
   auto a = 0; 
   auto b = 1.0f;
   // Capture all variables by copy 
   auto lambda_0 = [=]() { std::cout << a << b; }; 
   // Capture all variables by reference 
   auto lambda_1 = [&]() { std::cout << a << b; }; 
   // Capture object by reference 
   auto lambda_2 = [this]() { std::cout << m_; }; 
   // Capture object by copy 
   auto lambda_3 = [*this]() { std::cout << m_; }; 
 }
private: 
 int m_{}; 
}; 

注意,使用[=]并不意味着范围内的所有变量都被复制到 lambda 中;仅复制 lambda 内部实际使用的变量。

通过值捕获所有变量时,可以通过引用指定要捕获的变量(反之亦然)。下表显示了捕获块中不同组合的结果:

| 捕获块 | 结果捕获类型 | |
int a, b, c;auto func = [=] { /*...*/ }; 

| 通过数值捕捉abc。 | |

int a, b, c;auto func = [&] { /*...*/ }; 

| 参照捕捉abc。 | |

int a, b, c;auto func = [=, &c] { /*...*/ }; 

| 通过数值捕捉ab。参照捕捉c。 | |

int a, b, c;auto func = [&, c] { /*...*/ }; 

| 参照捕捉ab。通过数值捕捉c。 |

虽然用[&][=]捕获所有变量很方便,但我建议逐个捕获变量,因为它通过明确 lambda 范围内使用了哪些变量来提高代码的可读性。

将 C 函数指针分配给 lambdas

没有捕获的 Lambdas】可以隐式转换为函数指针。假设您使用的是一个 C 库,或者旧的 C++ 库,使用回调函数作为参数,如下所示:

extern void download_webpage(const char* url,
                              void (*callback)(int, const char*)); 

回调用返回代码和一些下载的内容调用。调用download_webpage()时可以传递一个λ作为参数。由于回调是一个常规函数指针,lambda 不能有任何捕获,您必须在 lambda 前面使用一个加号(+):

auto lambda = +[](int result, const char* str) {
  // Process result and str
};
download_webpage("http://www.packt.com", lambda); 

这样,λ就被转换成了一个规则的函数指针。请注意,为了使用这个功能,lambda 根本不能有任何捕获。

Lambda 类型

从 C++ 20 开始,没有捕获的 lambdas 是默认可构造和可赋值的。通过使用decltype,现在很容易构造具有相同类型的不同 lambda 对象:

auto x = [] {};   // A lambda without captures
auto y = x;       // Assignable
decltype(y) z;    // Default-constructible
static_assert(std::is_same_v<decltype(x), decltype(y)>); // passes
static_assert(std::is_same_v<decltype(x), decltype(z)>); // passes 

然而,这仅适用于没有捕获的 lambdas。Lambdas 抓拍都有自己独特的类型。即使两个带有捕获的 lambda 函数是彼此的简单克隆,它们仍然有自己独特的类型。因此,不可能将一个带有捕获的λ分配给另一个λ。

Lambdas 和 std::函数

如前一节所述,带有捕获的 lambda(有状态 lambda)不能分配给彼此,因为它们有唯一的类型,即使它们看起来完全相同。为了能够用捕获来存储和传递 lambda,我们可以使用std::function来保存由 lambda 表达式构造的函数对象。

std::function的签名定义如下:

std::function< return_type ( parameter0, parameter1...) > 

因此,一个不返回任何东西并且没有参数的std::function是这样定义的:

auto func = std::function<void(void)>{}; 

以一个int和一个std::string作为参数返回一个boolstd::function定义如下:

auto func = std::function<bool(int, std::string)>{}; 

共享相同签名(相同参数和相同返回类型)的 Lambda 函数可以由相同类型的std::function对象持有。A std::function也可以在运行时重新分配。

这里重要的是,lambda 捕获的内容不会影响其签名,因此无论有无捕获,lambda 都可以分配给同一个std::function变量。下面的代码显示了如何将不同的 lambdas 分配给同一个名为funcstd::function对象:

// Create an unassigned std::function object 
auto func = std::function<void(int)>{}; 
// Assign a lambda without capture to the std::function object 
func = [](int v) { std::cout << v; }; 
func(12); // Prints 12 
// Assign a lambda with capture to the same std::function object 
auto forty_two = 42; 
func = [forty_two](int v) { std::cout << (v + forty_two); }; 
func(12); // Prints 54 

接下来,让我们将std::function用于类似于真实世界示例的东西中。

用标准::函数实现一个简单的按钮类

假设我们设置来实现一个Button类。然后我们可以使用std::function来存储点击按钮对应的动作,这样当我们调用on_click()成员函数时,相应的代码就会被执行。

我们可以这样声明Button类:

class Button {
public: 
  Button(std::function<void(void)> click) : handler_{click} {} 
  auto on_click() const { handler_(); } 
private: 
  std::function<void(void)> handler_{};
}; 

然后,我们可以使用它来创建大量具有不同动作的按钮。按钮可以方便地存放在容器中,因为它们都具有相同的类型:

auto create_buttons () { 
  auto beep = Button([counter = 0]() mutable {  
    std::cout << "Beep:" << counter << "! "; 
    ++ counter; 
  }); 
  auto bop = Button([] { std::cout << "Bop. "; }); 
  auto silent = Button([] {});
  return std::vector<Button>{beep, bop, silent}; 
} 

迭代列表并在每个按钮上调用on_click()将执行相应的功能:

const auto& buttons = create_buttons();
for (const auto& b: buttons) {
  b.on_click();
}
buttons.front().on_click(); // counter has been incremented
// Output: "Beep:0! Bop. Beep:1!" 

前面带有按钮和点击处理程序的示例演示了将std::function与 lambdas 结合使用的一些好处;即使每个有状态 lambda 都有自己唯一的类型,单个std::function类型也可以包装共享相同签名(返回类型和参数)的 lambda。

顺便说一下,你可能已经注意到on_click()成员函数被声明为const。然而,它通过增加一个点击处理程序中的counter变量来改变成员变量handler_。这似乎违反了常量正确性规则,因为Button的常量成员函数被允许在它的一个类成员上调用变异函数。允许的原因与允许成员指针在常量上下文中改变其指向值的原因相同。在本章的前面,我们讨论了如何传播指针数据成员的常量。

标准::功能的性能考虑

与直接由λ表达式构造的函数对象相比,αstd::function有一些性能损失。本节将讨论使用std::function时需要考虑的一些与性能相关的问题。

阻止内联优化

谈到 lambdas,编译器有能力内联函数调用;也就是说,函数调用的开销被消除了。std::function的灵活设计使得编译器几乎不可能内联一个包装在std::function中的函数。如果频繁调用包装在std::function中的小函数,防止内联优化会对性能产生负面影响。

为捕获的变量动态分配内存

如果一个std::function被分配给一个带有捕获变量/引用的λ,std::function在大多数情况下将使用堆分配的内存来存储捕获的变量。如果捕获变量的大小低于某个阈值,std::function的一些实现不分配额外的内存。

这意味着,由于额外的动态内存分配,不仅存在性能损失,而且速度更慢,因为堆分配的内存会增加缓存未命中的数量(请阅读第 4 章数据结构中关于缓存未命中的更多内容)。

额外的运行时计算

调用一个std::function一般来说比执行一个 lambda 要慢一点,因为会涉及到更多的代码。对于小型且经常被调用的std::function来说,这种开销可能会变得很大。假设我们有一个非常小的λ,定义如下:

auto lambda = [](int v) { return v * 3; }; 

接下来的基准测试展示了为显式 lambda 类型的std::vector和相应的std::functionstd::vector执行 1000 万个函数调用之间的区别。我们将从使用显式 lambda 的版本开始:

auto use_lambda() { 
  using T = decltype(lambda);
  auto fs = std::vector<T>(10'000'000, lambda);
  auto res = 1;
  // Start clock
  for (const auto& f: fs)
    res = f(res);
  // Stop clock here
  return res;
} 

我们只测量在函数内部执行循环所需的时间。下一个版本将我们的λ包装在一个std::function中,看起来像这样:

auto use_std_function() { 
  using T = std::function<int(int)>;
  auto fs = std::vector<T>(10'000'000, T{lambda});
  auto res = 1;
  // Start clock
  for (const auto& f: fs)
    res = f(res);
  // Stop clock here
  return res;
} 

我正在我 2018 年的 MacBook Pro 上编译这段代码,使用的是启用了优化的 Clang(-O3)。第一个版本use_lambda()以大约 2 毫秒的速度执行循环,而第二个版本use_std_function()几乎需要 36 毫秒来执行循环。

通用 lambdas

通用的 lambda 是一个 lambda 接受auto参数,可以用任何类型调用它。它就像一个普通的 lambda 一样工作,但是operator()已经被定义为一个成员函数模板。

只有参数是模板变量,而不是捕获的值。换句话说,无论v0v1的类型如何,以下示例中的捕获值v都将是类型int:

auto v = 3; // int
auto lambda = [v](auto v0, auto v1) {
  return v + v0*v1;
}; 

如果我们将上面的 lambda 翻译成一个类,它将对应于如下内容:

class Lambda {
public:
  Lambda(int v) : v_{v} {}
  template <typename T0, typename T1>
  auto operator()(T0 v0, T1 v1) const { 
    return v_ + v0*v1; 
  }
private:
  int v_{};
};
auto v = 3;
auto lambda = Lambda{v}; 

就像模板化版本一样,编译器在调用 lambda 之前不会生成实际的函数。所以,如果我们像这样调用前面的 lambda:

auto res_int = lambda(1, 2);
auto res_float = lambda(1.0f, 2.0f); 

编译器将生成类似于以下 lambdas 的东西:

auto lambda_int = [v](int v0, const int v1) { return v + v0*v1; };
auto lambda_float = [v](float v0, float v1) { return v + v0*v1; };
auto res_int = lambda_int(1, 2);
auto res_float = lambda_float(1.0f, 2.0f); 

正如你可能已经发现的,这些版本就像普通的 lambdas 一样被进一步处理。

C++ 20 的一个新特性是,我们可以使用typename而不仅仅是auto来表示泛型 lambda 的参数类型。以下通用 lambdas 是相同的:

// Using auto
auto x = [](auto v) { return v + 1; };
// Using typename
auto y = []<typename Val>(Val v) { return v + 1; }; 

这使得命名类型或引用 lambda 主体内部的类型成为可能。

摘要

在本章中,您已经学习了如何使用将在本书中使用的现代 C++ 特性。自动类型推导、移动语义和 lambda 表达式是当今每个 C++ 程序员都需要适应的基本技术。

我们还花了一些时间研究错误处理和如何考虑 bug,以及有效状态和如何从运行时错误中恢复。错误处理是编程中非常重要的一部分,很容易被忽略。考虑调用者和被调用者之间的契约是一种方法,可以使您的代码正确,并避免在程序的发布版本中进行不必要的防御检查。

在下一章中,我们将研究分析和测量 C++ 性能的策略。