Skip to content

Latest commit

 

History

History
1061 lines (731 loc) · 38.8 KB

File metadata and controls

1061 lines (731 loc) · 38.8 KB

三、实现移动语义

在本章中,我们将学习一些高级的 C++ 移动语义。我们将首先讨论五大,这是一个习惯用法,只是鼓励程序员明确定义类的销毁和移动/复制语义。接下来,我们将学习如何定义移动构造函数和移动赋值运算符;移动语义的不同组合(包括仅移动和不可复制);不可移动类;以及如何实现这些类以及它们为什么重要。

本章还将讨论一些常见的陷阱,例如为什么const &&移动没有意义,以及如何克服 l 值和 r 值引用类型。本章中的方法很重要,因为一旦启用 C++ 11 或更高版本,就会启用移动语义,这将从根本上改变 C++ 在许多情况下处理类的方式。本章中的方法提供了用 C++ 编写高效代码的基础,这些代码的行为符合预期。

本章中的配方如下:

  • 使用编译器生成的特殊类成员函数和五大
  • 让你的班级可移动
  • 仅移动类型
  • 实现noexcept移动构造器
  • 学会警惕const &&
  • 引用限定成员函数
  • 探索无法移动或复制的对象

技术要求

要编译和运行本章中的示例,您必须拥有运行 Ubuntu 18.04 的计算机的管理权限,并且具有功能性互联网连接。在运行这些示例之前,您必须安装以下内容:

> sudo apt-get install build-essential git cmake 

如果这安装在 Ubuntu 18.04 以外的任何操作系统上,则需要 GCC 7.4 或更高版本以及 CMake 3.6 或更高版本。

使用编译器生成的特殊类成员函数和五大

当使用 C++ 11 或更高版本时,如果您没有在类定义中显式提供某些函数,编译器将自动为您的 C++ 类生成这些函数。在本食谱中,我们将探索这是如何工作的,编译器将为您创建哪些函数,以及这如何影响您的程序的性能和有效性。总的来说,这个方法的目标是说明每个类至少应该定义五大类,以确保您的类明确您希望如何管理资源。

准备好

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:

> sudo apt-get install build-essential git

这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

怎么做...

您需要执行以下步骤来尝试此食谱:

  1. 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe01_examples
  1. 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe01_example01
The answer is: 42

> ./recipe01_example02
The answer is: 42

> ./recipe01_example03
The answer is: 42

> ./recipe01_example04
The answer is: 42

在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。

它是如何工作的...

在这个食谱中,我们将探索移动和复制之间的区别,以及这与五大函数的关系,五大函数是指所有类都应该明确定义的五个函数。首先,让我们看一个简单的例子,这个类在其构造函数中输出一个整数值:

class the_answer
{
    int m_answer{42};

public:

    ~the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }
};

在上例中,当类被析构时,类将输出到stdout。该类还有一个在构造时初始化的整数成员变量。前面例子的问题是隐式复制和移动语义被抑制了,因为我们定义了类的析构函数。

五大函数是以下函数,如果至少定义了其中一个函数,每个类都应该定义这些函数(也就是说,如果定义了一个函数,就必须定义所有函数):

~the_answer() = default;

the_answer(the_answer &&) noexcept = default;
the_answer &operator=(the_answer &&) noexcept = default;

the_answer(const the_answer &) = default;
the_answer &operator=(const the_answer &) = default;

如图所示,五大类包括析构函数、移动构造函数、移动赋值运算符、复制构造函数和复制赋值运算符。这些类的作者不需要实现这些功能,而是应该——至少——定义功能,明确说明删除、复制和移动应该如何进行(如果有的话)。这确保了如果定义了其中一个函数,类的其余移动、复制和销毁语义都是正确的,如本例所示:

class the_answer
{
    int m_answer{42};

public:

    the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }

public:

    virtual ~the_answer() = default;

    the_answer(the_answer &&) noexcept = default;
    the_answer &operator=(the_answer &&) noexcept = default;

    the_answer(const the_answer &) = default;
    the_answer &operator=(const the_answer &) = default;
};

在前面的例子中,通过定义一个虚拟析构函数,这个类被标记为virtual(意味着这个类能够参与运行时多态)。不需要实现(通过将析构函数设置为default,但是定义本身是显式的,这告诉编译器我们希望类支持虚函数。这告诉该类的用户,指向该类的指针可用于删除从该类派生的任何类的实例。它还告诉用户继承将利用运行时多态性,而不是合成。这个类还声明复制和移动都是允许的。

让我们看另一个例子:

class the_answer
{
    int m_answer{42};

public:

    the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }

public:

    ~the_answer() = default;

    the_answer(the_answer &&) noexcept = default;
    the_answer &operator=(the_answer &&) noexcept = default;

    the_answer(const the_answer &) = delete;
    the_answer &operator=(const the_answer &) = delete;
};

在前面的示例中,拷贝被显式删除(这与定义移动构造函数而不定义拷贝语义相同)。这定义了一个只能移动的类,这意味着该类只能被移动;它不能被复制。标准库中这样的类的一个例子是std::unique_ptr

下一个类实现相反的情况:

class the_answer
{
    int m_answer{42};

public:

    the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }

public:

    ~the_answer() = default;

    the_answer(the_answer &&) noexcept = delete;
    the_answer &operator=(the_answer &&) noexcept = delete;

    the_answer(const the_answer &) = default;
    the_answer &operator=(const the_answer &) = default;
};

在前面的例子中,我们已经明确定义了一个只复制类。

五大有很多不同的组合。这个方法的要点是表明,显式定义这五个函数可以确保类的作者明确了解类本身的意图。这与它应该如何操作以及用户应该如何使用类有关。显式确保类的作者不打算使用一种行为,而是获得另一种行为,因为编译器将如何基于编译器的实现以及 C++ 规范是如何定义的来隐式构造类。

让你的班级可移动

在 C++ 11 或更高版本中,可以复制或移动对象,这可以用来指示如何管理对象的资源。拷贝和移动的最大区别很简单:拷贝创建一个对象管理的资源的拷贝,而移动将资源从一个对象转移到另一个对象。

在这个食谱中,我们将解释如何使一个类可移动,包括如何正确添加移动构造函数和移动赋值操作符。我们还将解释可移动类的一些微妙细节,以及如何在代码中使用它们。这个方法很重要,因为在很多情况下,移动一个对象而不是复制一个对象可以提高性能并减少程序的内存消耗。然而,如果使用不当,可移动物体的使用可能会带来一些不稳定性。

准备好

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:

> sudo apt-get install build-essential git

这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

怎么做...

您需要执行以下步骤来尝试此食谱:

  1. 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe02_examples
  1. 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe02_example01
The answer is: 42
> ./recipe02_example02
The answer is: 42
The answer is: 42

The answer is: 42

在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。

它是如何工作的...

在这个食谱中,我们将学习如何使一个类可移动。首先,让我们检查一个基本的类定义:

#include <iostream>

class the_answer
{
    int m_answer{42};

public:

    the_answer() = default;

public:

    ~the_answer()
    {
        std::cout << "The answer is: " << m_answer << '\n';
    }
};

int main(void)
{
    the_answer is;
    return 0;
}

在前面的例子中,我们创建了一个简单的类,它有一个初始化的私有整数成员。然后,我们定义一个默认构造函数和一个析构函数,当类的一个实例被销毁时,它们输出到stdout。默认情况下,这个类是可移动的,但是移动操作模仿拷贝(换句话说,在这个简单的例子中,移动和拷贝没有区别)。

为了真正使这个类可移动,我们需要添加一个移动构造函数和一个移动赋值操作符,如下所示:

the_answer(the_answer &&other) noexcept;
the_answer &operator=(the_answer &&other) noexcept;

一旦我们添加了这两个函数,我们将能够使用以下内容将我们的类从一个实例移动到另一个实例:

instance2 = std::move(instance1);

为了支持这一点,在前面的类中,我们不仅将添加移动构造函数和赋值操作符,还将实现一个默认构造函数,为我们的示例类提供一个有效的移动状态,如下所示:

#include <iostream>

class the_answer
{
    int m_answer{};

public:

    the_answer() = default;

    explicit the_answer(int answer) :
        m_answer{answer}
    { }

如图所示,该类现在有一个默认构造函数和一个接受整数参数的显式构造函数。默认构造函数初始化整数内存变量,该变量表示我们的移出或无效状态:

public:

    ~the_answer()
    {
        if (m_answer != 0) {
            std::cout << "The answer is: " << m_answer << '\n';
        }
    }

如上例所示,当类被破坏时,我们输出整数成员变量的值,但是在这种情况下,我们首先检查以确保整数变量有效:

    the_answer(the_answer &&other) noexcept
    {
        *this = std::move(other);
    }

    the_answer &operator=(the_answer &&other) noexcept
    {
        if (&other == this) {
            return *this;
        }

        m_answer = std::exchange(other.m_answer, 0);        
        return *this;
    }

    the_answer(const the_answer &) = default;
    the_answer &operator=(const the_answer &) = default;
};

最后,我们实现了移动构造函数和赋值操作符。移动构造函数只是调用移动赋值操作符,以避免重复(因为它们执行相同的操作)。移动分配操作符首先检查以确保我们没有移动到自己身上。这是因为这样做会导致损坏,因为用户会期望类仍然包含有效的整数,但实际上,内部整数会无意中被设置为0

然后我们交换整数值,并将原始值设置为0。这是因为,再一次,移动不是复制。移动会将值从一个实例转移到另一个实例。在这种情况下,被移动到的实例从0开始,并被赋予一个有效的整数,而被移动到的实例从一个有效的整数开始,并在移动后被设置为0,导致只有1实例包含一个有效的整数。

It should also be noted that we have to define the copy constructor and assignment operator. This is because, by default, if you provide a move constructor and assignment operator, C++ will automatically delete the copy constructor and assignment operator if they are not explicitly defined.

在本例中,我们将比较移动和复制,因此我们定义了复制构造函数和赋值操作符,以确保它们不会被隐式删除。一般来说,最好的做法是为您定义的每个类定义析构函数、移动构造函数和赋值操作符,以及复制构造函数和赋值操作符。这确保了您编写的每个类的复制/移动语义都是显式的和有意的:

int main(void)
{
    {
        the_answer is;
        the_answer is_42{42};
        is = is_42;
    }

    std::cout << '\n';

    {
        the_answer is{23};
        the_answer is_42{42};
        is = std::move(is_42);
    }

    return 0;
}

当执行前面的代码时,我们会得到以下结果:

在我们的主要功能中,我们运行两个不同的测试:

  • 第一个测试创建了我们类的两个实例,并将一个实例的内容复制到另一个实例中。
  • 第二个测试创建了我们类的两个实例,然后将一个实例的内容移动到另一个实例。

当这个例子被执行时,我们看到第一个测试的输出被写入了两次。这是因为我们的类的第一个实例被赋予了我们的类的第二个实例的副本,该副本具有有效的整数值。第二个测试的输出只被写入一次,因为我们正在将一个实例的有效状态转移到另一个实例,导致在任何给定时刻只有一个实例具有有效状态。

这里有一些值得一提的显著例子:

  • 移动构造函数和赋值操作符永远不应该抛出异常。具体来说,移动操作将一个类型实例的有效状态转移到该类型的另一个实例。这个操作在任何时候都不会失败,因为没有创建或销毁任何状态。它只是被转移了。此外,在移动过程中,有时很难撤销部分移动操作。由于这些原因,这些功能应始终标记为noexcept(参考https://github . com/isocpp/cppcoregories/blob/master/cppcoregories . MD # Rc-move-no except)。
  • 移动构造函数和赋值操作符的函数签名中不包含const类型,因为被移动的实例不能是const,因为它的内部状态正在被转移,这隐含地假设正在发生写操作。更重要的是,如果您将移动构造函数或赋值操作符标记为const,则可能会出现副本。
  • 除非您打算创建副本,否则应改用移动,尤其是对于大型对象。就像传递const T&作为函数参数来防止复制发生一样,当调用函数时,当资源被移动到另一个变量而不是被复制时,应该使用移动来代替复制。
  • 编译器会在可能的情况下自动生成移动操作,而不是复制操作。例如,如果您在函数中创建一个对象,配置该对象,然后返回该对象,编译器将自动执行移动。

现在,您已经知道了如何使您的类可移动,在下一个食谱中,我们将学习什么是只移动类型,以及为什么您可能想要在您的应用中使用它们。

仅移动类型

在这个食谱中,我们将学习如何使一个类只移动。复制和移动之间区别的一个很好的例子是std::unique_ptrstd::shared_ptr之间的区别。

std::unique_ptr的要点是对动态分配的类型强制一个所有者,而std::shared_ptr则允许动态分配类型的多个所有者。两者都允许用户将指针类型的内容从一个实例化移动到另一个实例化,但是只有std::shared_ptr允许用户复制指针(因为复制指针会创建多个所有者)。

在这个食谱中,我们将使用这两个类来展示如何创建一个只移动的类,并展示为什么这种类型的类在 C++ 中被如此频繁地使用(因为大多数时候我们希望移动而不是复制)。

准备好

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:

> sudo apt-get install build-essential git

这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

怎么做...

您需要执行以下步骤来尝试此食谱:

  1. 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe03_examples
  1. 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe03_example01
The answer is: 42

> ./recipe03_example03
count: 2
The answer is: 42
The answer is: 42

count: 1
The answer is: 42

在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。

它是如何工作的...

仅移动类是可以移动但不能复制的类。为了探索这种类型的类,让我们在下面的例子中包装std::unique_ptr,它本身是一个只移动的类:

class the_answer
{
    std::unique_ptr<int> m_answer;

public:

    explicit the_answer(int answer) :
        m_answer{std::make_unique<int>(answer)}
    { }

    ~the_answer()
    {
        if (m_answer) {
            std::cout << "The answer is: " << *m_answer << '\n';
        }
    }

public:

    the_answer(the_answer &&other) noexcept
    {
        *this = std::move(other);
    }

    the_answer &operator=(the_answer &&other) noexcept
    {
        m_answer = std::move(other.m_answer);
        return *this;
    }
};

前面的类将std::unique_ptr存储为成员变量,并在构造时用整数值实例化内存变量。销毁时,该类检查以确保std::unique_ptr有效,如果有效,则将该值输出到stdout

乍一看,我们可能想知道为什么我们必须检查有效性,因为std::unique_ptr总是被构造的。std::unique_ptr无效的原因是在移动过程中。因为我们正在创建一个只移动的类(而不是一个不可复制、不可移动的类),我们实现了移动构造函数和移动赋值操作符,这将移动std::unique_ptrstd::unique_ptr在移动时,会将其内部指针的内容从一个类转移到另一个类,导致该类因存储无效指针(即nullptr)而被移动。换句话说,即使这个类不能被空构造,如果它被移动,它仍然可以存储nullptr,如下例所示:

int main(void)
{
    the_answer is_42{42};
    the_answer is = std::move(is_42);

    return 0;
}

如前例所示,只有一个类输出到stdout,因为只有一个实例有效。像std::unique_ptr一样,一个只移动的类确保你在被创建的资源总数和实际发生的实例总数之间总是有 1:1 的关系。

需要注意的是,由于我们使用的是std::unique_ptr,所以不管我们喜不喜欢,我们的类都变成了只动类。例如,试图添加复制构造函数或复制赋值运算符来启用复制功能将导致编译错误:

the_answer(const the_answer &) = default;
the_answer &operator=(const the_answer &) = default;

换句话说,每个包含只移动类作为成员的类本身也变成了只移动类。虽然这看起来不可取,但你必须首先问自己:你真的需要一个类来复制吗?可能的答案是否定的。事实上,在大多数情况下,甚至在 C++ 11 之前,我们使用的大多数(如果不是全部)类都应该是只移动的。当一个类应该被移动时,它被复制的能力会导致资源浪费、损坏等等,这也是移动语义被添加到规范中的原因之一。移动语义允许我们定义我们希望如何处理我们分配的资源,并且它为我们提供了一种在编译时实施所需语义的方法。

您可能想知道如何将前面的示例转换为允许复制。以下示例利用共享指针来实现这一点:

#include <memory>
#include <iostream>

class the_answer
{
    std::shared_ptr<int> m_answer;

public:

    the_answer() = default;

    explicit the_answer(int answer) :
        m_answer{std::make_shared<int>(answer)}
    { }

    ~the_answer()
    {
        if (m_answer) {
            std::cout << "The answer is: " << *m_answer << '\n';
        }
    }

    auto use_count()
    { return m_answer.use_count(); }

前面的类用std::shared_ptr代替std::unique_ptr。在引擎盖下,std::shared_ptr会记录副本的数量,只有当副本总数为0时,才会删除它存储的指针。事实上,您可以使用use_count()功能查询总份数。

接下来,我们定义移动构造函数、移动赋值运算符、复制构造函数和复制赋值运算符,如下所示:

public:

    the_answer(the_answer &&other) noexcept
    {
        *this = std::move(other);
    }

    the_answer &operator=(the_answer &&other) noexcept
    {
        m_answer = std::move(other.m_answer);
        return *this;
    }

    the_answer(const the_answer &other)
    {
        *this = other;
    }

    the_answer &operator=(const the_answer &other)
    {
        m_answer = other.m_answer;
        return *this;
    }
};

这些定义也可以使用=默认语法编写,因为这些实现是相同的。最后,我们使用以下内容测试这个类:

int main(void)
{
    {
        the_answer is_42{42};
        the_answer is = is_42;
        std::cout << "count: " << is.use_count() << '\n';
    }

    std::cout << '\n';

    {
        the_answer is_42{42};
        the_answer is = std::move(is_42);
        std::cout << "count: " << is.use_count() << '\n';
    }

    return 0;
}

如果我们执行前面的代码,我们会得到以下结果:

在前面的测试中,我们首先创建一个类的副本,并输出副本总数,以查看实际上创建了两个副本。第二个测试执行std::move()而不是拷贝,这导致只按照预期创建了一个拷贝。

实现 noexcept 移动构造函数

在本食谱中,我们将学习如何确保移动构造函数和移动赋值运算符永远不会抛出异常。C++ 规范并不阻止移动构造函数抛出(因为已经确定这样的要求很难执行,因为即使在标准库中也存在太多合法的例子)。然而,在大多数情况下,确保不抛出异常应该是可能的。具体来说,移动通常不会创建资源,而是转移资源,因此,强异常保证应该是可能的。创建资源的移动的一个很好的例子是std::list,它必须提供一个有效的end()迭代器,即使是在移动中。

准备好

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:

> sudo apt-get install build-essential git

这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

怎么做...

您需要执行以下步骤来尝试此食谱:

  1. 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe04_examples
  1. 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe04_example01
failed to move

The answer is: 42

在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。

它是如何工作的...

如前所述,移动不应该抛出异常,以确保强异常保证(也就是说,移动对象的行为不会损坏对象),在大多数情况下,这是可能的,因为移动(不像复制)不会创建资源,而是转移资源。确保移动构造函数和移动赋值操作符不抛出的最佳方法是仅使用std::move()转移成员变量,如下例所示:

m_answer = std::move(other.m_answer);

假设您正在移动的成员变量没有抛出,那么您的类也不会抛出。使用这个简单的技术将确保您的移动构造函数和操作符永远不会抛出。但是如果这个操作不能用呢?让我们用下面的例子来探讨这个问题:

#include <vector>
#include <iostream>

class the_answer
{
    std::vector<int> m_answer;

public:

    the_answer() = default;

    explicit the_answer(int answer) :
        m_answer{{answer}}
    { }

    ~the_answer()
    {
        if (!m_answer.empty()) {
            std::cout << "The answer is: " << m_answer.at(0) << '\n';
        }
    }

在前面的例子中,我们创建了一个以向量为成员变量的类。默认情况下,向量可以初始化为空,也可以用单个元素初始化。销毁时,如果向量有值,我们将值输出到stdout。我们实现move构造函数和运算符如下:

public:

    the_answer(the_answer &&other) noexcept
    {
        *this = std::move(other);
    }

    the_answer &operator=(the_answer &&other) noexcept
    {
        if (&other == this) {
            return *this;
        }

        try {
            m_answer.emplace(m_answer.begin(), other.m_answer.at(0));
            other.m_answer.erase(other.m_answer.begin());
        }
        catch(...) {
            std::cout << "failed to move\n";
        }

        return *this;
    }
};

如图所示,move 操作符将单个元素从一个实例转移到另一个实例(这不是实现移动的最佳方式,但是这个实现可以演示这一点,而不会过于复杂)。如果向量为空,此操作将抛出,如下例所示:

int main(void)
{
    {
        the_answer is_42{};
        the_answer is_what{};

        is_what = std::move(is_42);
    }

    std::cout << '\n';

    {
        the_answer is_42{42};
        the_answer is_what{};

        is_what = std::move(is_42);
    }

    return 0;
}

最后,我们尝试在两个不同的测试中移动这个类的一个实例。在第一个测试中,两个实例都是默认构造的,这导致空类,而第二个测试用单个元素构造向量,这导致有效的移动。在这种情况下,我们能够防止移动被抛出,但是应该注意的是,结果类实际上并没有执行移动,导致两个对象都不包含所需的状态。这就是为什么移动构造函数永远不应该抛出。即使我们没有捕捉到异常,在抛出发生后断言程序的状态也是极其困难的。搬家发生了吗?每个实例处于什么状态?在大多数情况下,这种类型的错误会导致在程序进入损坏状态时调用std::terminate()

副本是不同的,因为原始类保持不变。复制是无效的,程序员可以很好地处理这种情况,因为被复制的实例的原始状态不受影响(因此我们将其标记为const)。

但是,由于被移动的实例是可写的,两个实例都处于损坏状态,并且没有好的方法知道如何处理向前移动的程序,因为我们不知道原始实例是否处于可以正确处理的状态。

学会警惕 const&&

在本食谱中,我们将学习为什么移动构造函数或运算符永远不应该标记为const(为什么复制构造函数/运算符总是标记为const)。这一点很重要,因为它触及到了移动和复制之间区别的核心。C++ 中的 Move 语义是它最强大的特性之一,理解它为什么如此重要以及它实际在做什么对于编写好的 C++ 代码至关重要。

准备好

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:

> sudo apt-get install build-essential git

这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

怎么做...

您需要执行以下步骤来尝试此食谱:

  1. 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe05_examples
  1. 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe05_example01
copy

在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。

它是如何工作的...

在本食谱中,我们将了解为什么const&&构造函数或运算符没有意义,并将导致意想不到的行为。移动会转移资源,这就是为什么它被标记为非const。这是因为传输假设两个实例都被写入(一个实例接收资源,而另一个实例取走资源)。副本创建资源,这就是为什么它们不总是被标记为noexcept(创建资源绝对可以抛出)并且它们被标记为const(因为原始实例是被复制的,而不是被修改的)。一个const&&构造函数声称是一个不转移的移动,它必须是一个副本(如果你没有写入原始实例,你没有移动——你在复制),如本例所示:

#include <iostream>

class copy_or_move
{
public:

    copy_or_move() = default;

public:

    copy_or_move(copy_or_move &&other) noexcept
    {
        *this = std::move(other);
    }

    copy_or_move &operator=(copy_or_move &&other) noexcept
    {
        std::cout << "move\n";
        return *this;
    }

    copy_or_move(const copy_or_move &other)
    {
        *this = other;
    }

    copy_or_move &operator=(const copy_or_move &other)
    {
        std::cout << "copy\n";
        return *this;
    }
};

int main(void)
{
    const copy_or_move test1;
    copy_or_move test2;

    test2 = std::move(test1);
    return 0;
}

在前面的示例中,我们创建了一个实现默认移动和复制构造函数/运算符的类。唯一不同的是,我们将输出添加到stdout来告诉我们是正在执行拷贝还是正在执行移动。

然后,我们创建了两个类实例,实例被从标记为const的位置移走。然后我们执行移动,输出的是一个副本。这是因为即使我们要求移动,编译器也使用了副本。我们可以实现一个const &&移动构造函数/操作符,但是没有办法将移动写成移动,因为我们将被移动的对象标记为const,所以我们不能获取它的资源。事实上,这样的移动将被实现为一个副本,与编译器自动为我们做的一样。

在下一个配方中,我们将学习如何向成员函数添加限定符。

引用限定成员函数

在这个食谱中,我们将了解什么是引用限定成员函数。虽然 C++ 语言的这一方面较少被使用和理解,但它很重要,因为它为程序员提供了处理资源如何操作的能力,这取决于调用函数时类是处于 l 值还是 r 值状态。

准备好

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:

> sudo apt-get install build-essential git

这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

怎么做...

您需要执行以下步骤来尝试此食谱:

  1. 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe06_examples
  1. 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe06_example01
the answer is: 42
the answer is not: 0
the answer is not: 0

在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。

它是如何工作的...

在本例中,我们将了解什么是引用限定成员函数。为了解释什么是引用限定成员函数,让我们看下面的例子:

#include <iostream>

class the_answer
{
public:

 ~the_answer() = default;

 void foo() &
 {
 std::cout << "the answer is: 42\n";
 }

 void foo() &&
 {
 std::cout << "the answer is not: 0\n";
 }

public:

 the_answer(the_answer &&other) noexcept = default;
 the_answer &operator=(the_answer &&other) noexcept = default;

 the_answer(const the_answer &other) = default;
 the_answer &operator=(const the_answer &other) = default;
};

在这个例子中,我们实现了一个foo()函数,但是我们有两个不同的版本。第一个版本结尾有&,第二个版本结尾有&&。执行哪个foo()函数取决于实例是 l 值还是 r 值,如下例所示:

int main(void)
{
    the_answer is;

    is.foo();
    std::move(is).foo();
    the_answer{}.foo();
}

执行时会产生以下结果:

如前例所示,foo()的第一次执行是一个 l 值,因为执行的是foo()的 l 值版本(也就是最后有&的函数)。foo()的最后两次执行是 r 值,因为执行的是foo()的 r 值版本。

引用限定的成员函数可用于确保该函数仅在正确的上下文中调用。使用这些类型的函数的另一个原因是确保只有当 l 值或 r 值引用存在时才调用该函数。

例如,您可能不希望将foo()作为 r 值调用,因为这种类型的调用不能确保类的实例在调用本身之外有一个生存期,如前面的示例所示。

在下一个食谱中,我们将学习如何制作一个既不能移动也不能复制的类,并解释为什么你可能会做这样的事情。

探索无法移动或复制的对象

在本食谱中,我们将学习如何创建一个我们不能移动或复制的对象,以及为什么您可能想要创建这样一个类。复制类需要复制类的内容,这在某些情况下是不可能的(例如,复制内存池并不简单)。移动一个类假设该类被允许以一种潜在的无效状态存在(例如,std::unique_ptr在移动时采用一个nullptr值,该值是无效的)。这种情况也可能是不可取的(你现在必须检查有效性)。我们无法复制的不可移动类可以克服这些类型的问题。

准备好

开始之前,请确保满足所有技术要求,包括安装 Ubuntu 18.04 或更高版本,并在终端窗口中运行以下内容:

> sudo apt-get install build-essential git

这将确保您的操作系统拥有适当的工具来编译和执行本食谱中的示例。完成后,打开一个新的终端。我们将使用这个终端来下载、编译和运行我们的示例。

怎么做...

您需要执行以下步骤来尝试此食谱:

  1. 从新的终端,运行以下命令下载源代码:
> cd ~/
> git clone https://github.com/PacktPublishing/Advanced-CPP-CookBook.git
> cd Advanced-CPP-CookBook/chapter03
  1. 要编译源代码,请运行以下命令:
> cmake .
> make recipe07_examples
  1. 编译源代码后,您可以通过运行以下命令来执行该配方中的每个示例:
> ./recipe07_example01
The answer is: 42
Segmentation fault (core dumped)

在下一节中,我们将逐一介绍这些示例,并解释每个示例程序的功能以及它与本食谱中所教授的课程之间的关系。

它是如何工作的...

仅移动类防止类被复制,这在某些情况下可以提高性能。仅移动类还确保了创建的资源与分配的资源之间的 1:1 关系,因为副本不存在。但是,移动类可能会导致类无效,如本例所示:

#include <iostream>

class the_answer
{
    std::unique_ptr<int> m_answer;

public:

    explicit the_answer(int answer) :
        m_answer{std::make_unique<int>(answer)}
    { }

    ~the_answer()
    {
        std::cout << "The answer is: " << *m_answer << '\n';
    }

public:

    the_answer(the_answer &&other) noexcept = default;
    the_answer &operator=(the_answer &&other) noexcept = default;
};

int main(void)
{
    the_answer is_42{42};
    the_answer is_what{42};

    is_what = std::move(is_42);
    return 0;
}

如果我们运行前面的代码,我们会得到以下结果:

在前面的例子中,我们创建了一个可以移动的类,它存储std::unique_ptr。在类的析构函数中,我们取消引用该类并输出它的值。我们不检查std::unique_ptr的有效性,因为我们编写了一个强制有效std::unique_ptr的构造函数,却忘记了一个动作可以撤销这个显式的有效性。结果是,当执行一个移动时,我们得到一个分割错误。

为了克服这一点,我们需要提醒一下,我们做出了如下假设:

class the_answer
{
 std::unique_ptr<int> m_answer;

public:

 explicit the_answer(int answer) :
 m_answer{std::make_unique<int>(answer)}
 { }

 ~the_answer()
 {
 std::cout << "The answer is: " << *m_answer << '\n';
 }

public:

 the_answer(the_answer &&other) noexcept = delete;
 the_answer &operator=(the_answer &&other) noexcept = delete;

 the_answer(const the_answer &other) = delete;
 the_answer &operator=(const the_answer &other) = delete;
};

前面的类显式删除了复制和移动操作,这是我们想要的意图。现在,如果我们不小心移动了这个类,我们会得到以下结果:

/home/user/book/chapter03/recipe07.cpp: In function ‘int main()’:
/home/user/book/chapter03/recipe07.cpp:106:30: error: use of deleted function ‘the_answer& the_answer::operator=(the_answer&&)’
is_what = std::move(is_42);
^
/home/user/book/chapter03/recipe07.cpp:95:17: note: declared here
the_answer &operator=(the_answer &&other) noexcept = delete;
^~~~~~~~

这个错误告诉我们,假设类是有效的,因此不支持移动。我们要么需要适当的支持移动(这意味着我们必须保持对无效std::unique_ptr的支持),要么我们需要移除move操作。如图所示,一个不能被移动或复制的类可以确保我们的代码按预期工作,为编译器提供了一种机制,当我们用我们的类做一些我们不打算做的事情时,它会警告我们。