C++11——拷贝控制

对拷贝控制成员使用= default

我们可以通过将拷贝控制成员定义为= default,显示地要求编译器生成它们的合成版本:

Code:
    class Sales_data {
    public:
        // copy control; use defaults
        Sales_data() = default;
        Sales_data(const Sales_data&) = default;
        Sales_data& operator=(const Sales_data &);
        ~Sales_data() = default;
        // other members as before
    };
    Sales_data& Sales_data::operator=(const Sales_data&) = default;

当我们在类体内的成员声明中指定= default时,编译器生成的合成函数是隐式内联的(就像在类体中定义的任何其他成员函数一样)。如果我们不希望合成函数是内联函数,我们可以在该函数的定义上指定= default,就像在重载=运算符的定义中那样。
注:我们只能对具有合成版本的成员函数使用= default(即默认构造函数或拷贝控制成员)。

使用= delete来阻止拷贝类对象

在新标准下,我们可以通过将拷贝构造函数和赋值运算符定义为已删除函数来阻止复制。已删除的函数是已声明的函数,但不能以任何其他方式使用。我们使用= delete跟随想要删除的函数的参数列表来将函数定义为已删除:

Code:
    struct NoCopy {
        NoCopy() = default;      // use the synthesized default constructor
        NoCopy(const NoCopy&) = delete;            // no copy
        NoCopy &operator=(const NoCopy&) = delete; // no assignment
        ~NoCopy() = default;    // use the synthesized destructor
        // other members
    }

= delete关键字即告诉编译器又告诉代码阅读者,我们故意没有定义这些成员。
= default不同,= delete必须出现在已删除函数的第一个声明中。从逻辑上讲,这种差异源自这些声明的含义。默认成员只影响编译器生成的代码;因此在编译器生成代码之前不需要= default。另一方面,编译器需要知道一个函数被删除,以禁止试图使用它的操作。
= default不同,我们可以在任何函数上指定= delete(我们可以在默认构造函数或编译器可以合成的拷贝控件成员上使用= default)。虽然删除函数的主要用途是抑制拷贝控制成员,但是当我们想要引导函数匹配过程时,删除函数有时也很有用。

移动而不是复制类对象

我们可以使用新标准库引入的两个工具来避免复制字符串。首先,包括字符串在内的几个库类定义了所谓的“移动构造函数”。字符串移动构造函数如何工作的详细信息与其他任何有关实现的详细信息都没有公开。但是,我们知道移动构造函数通常通过将资源从给定对象“移动”到正在构造的对象来操作。我们也知道库保证“移动”字符串保持有效,可破坏的状态。对于字符串,我们可以想象每个字符串都有一个指向char数组的指针。据推测,字符串移动构造函数复制指针而不是为字符本身分配空间和复制字符。
我们将使用的第二个工具是名为move的库函数,它在头文件<utility>中定义(关于utility更多的信息可参考header <utility>)。目前,关于move有两点需要了解。首先,当reallocate在新内存中构造字符串时,它必须调用move来表示它想要使用字符串移动构造函数。如果省略了调用move来移动字符串,则将使用拷贝构造函数。其次,我们通常不提供move的使用说明。当我们使用move时,我们调用std::move,而不是move。关于move的更多的信息可参考std::move

右值引用

为了支持move操作,新标准引入了一种新的引用,即右值应用。右值引用是必须绑定到右值的引用。通过使用&&而不是&获得右值引用。正如我们将看到的,右值引用具有重要的属性,它们可能只绑定到即将被销毁的对象上。因此,我们可以自由地将资源从一个右值引用“移动”到另一个对象。
左值和右值是表达式的属性。有些表达式产生或需要左值;另一些表达式产生或需要右值。一般来说,左值表达式指的是对象的标识,而右值表达式指的是对象的值。
与任何引用一样,右值引用只是对象的另一个名称。我们知道,当我们需要将它们与右值引用区分开时,我们将其称为左值引用,我们不能将常规引用绑定到需要转换的表达式,文本或返回右值的表达式。右值引用具有相反的绑定属性:我们可以将右值引用绑定到这些类型的表达式,但是我们不能直接将右值引用绑定到左值:

Code:
    int i = 42;
    int &r = i;      // ok: r refers to i
    int &&rr = i;    // error: cannot bind an rvalue reference to an lvalue
    int &r2 = i * 42;    // error: i * 42 is an rvalue
    const int &r3 = i * 42; // ok: we can bind a reference to const to an rvalue
    int &&rr2 = i * 42;  // ok: bind rr2 to the result of the multiplication

返回左值引用的函数以及赋值,下标,解引用和前缀增量/减量运算符都是返回左值的表达式的示例。我们可以将左值引用绑定到任何这些表达式的结果。
返回非引用类型的函数,以及算术,关系,按位和后缀增量/减量运算符,都产生右值。我们不能将左值引用绑定到这些表达式,但我们可以绑定对const的左值引用或对这些表达式的右值引用。

库函数move

虽然我们不能直接将右值引用绑定到左值,但我们可以显式地将左值转换为其对应的右值引用类型。我们还可以通过调用名为move的新标准库函数来获取绑定到左值的右值引用,该函数在utility头文件中定义。move函数的定义如下:

Code:
    template <typename T>
    typename remove_reference<T>::type&& move(T&& t)
    {
        return static_cast<typename remove_reference<T>::type&&>(t);
    }

我们可以以如下方式使用move函数;

Code:
    int &&rr3 = std::move(rr1);  // ok

调用move告诉编译器我们有一个左值,我们要将它视为右值。必须认识到move承诺,我们不打算再次使用rr1,除非对它赋值或销毁它。在调用move之后,我们无法对被操作对象的值做出任何假设。
注:我们可以销毁一个被move操作的对象,也可以为它分配一个新值,但是我们不能使用它的值。
注:使用move的代码应该使用std::move,而不是move。这样做可以避免潜在的名称冲突。

移动构造和移动赋值

我们可以在自己的类中定义移动构造和移动赋值函数,这种函数与拷贝构造函数类型,但它们从给定对象中“窃取资源”而不是复制资源。
与拷贝构造函数一样,移动构造函数具有一个初始参数,该参数是对类类型的引用。与拷贝构造函数不同,移动构造函数中的引用参数是右值引用。与拷贝构造函数一样,任何其他参数都必须具有默认参数。
除了移动资源之外,移动构造函数还必须确保移动的对象处于一种合法状态,以便无影响地销毁该对象。特别是,一旦移动其资源,原始对象就不能再指向那些被移动的资源,而新创建的对象负责这些被移动的资源。
作为一个例子,我们将定义StrVec移动构造函数来移动而不是将元素从一个StrVec复制到另一个:

Code:
/***********************
    class StrVec{
    private:
        std::string *elements;  // pointer to the first element in the array
        std::string *first_free; // pointer to the first free element in the array
        std::string *cap;  // pointer to one past the end of the array
    };
***********************/
    StrVec::StrVec(StrVec &&s) noexcept  // move won't throw any exceptions
         // member initializers take over the resources in s
      : elements(s.elements), first_free(s.first_free), cap(s.cap)
    {
        // leave s in a state in which it is safe to run the destructor
        s.elements = s.first_free = s.cap = nullptr;
    }

与复制构造函数不同,移动构造函数不分配任何新内存;它接管给定StrVec中的内存。从其参数接管内存后,构造函数体将给定对象中的指针设置为nullptr。 移动对象后,该对象继续存在。最终,移动的对象将被销毁,这意味该对象将运行析构函数。StrVec析构函数在first_free上调用deallocate。如果我们忽略了改变s.first_free,那么销毁给定对象将删除我们刚刚移动的资源。

移动构造函数通常应该是noexcept

因为移动操作通过“窃取”资源来执行,所以它通常不会自己分配任何资源。 因此,移动操作通常不会引发任何异常。当我们编写一个不能抛出异常的移动操作时,我们应该告知库这个事实。正如我们所看到的,除非库知道移动构造函数不会抛出异常,否则它将做额外的工作,以满足移动类类型的对象可能抛出异常的可能性。
通知库的一种方法是在构造函数上指定noexceptnoexcept是我们承诺函数不会抛出任何异常的一种方式。我们在函数的参数列表之后指定noexcept。在构造函数中,noexcept出现在参数列表和以开始的成员初始化列表之间:

Code:
    class StrVec {
    public:
        StrVec(StrVec&&) noexcept;  // move constructor
          // other members as before
    };
    StrVec::StrVec(StrVec &&s) noexcept : // member initializers
    { /* constructor body  */ }

如果函数的定义出现在类之外,我们必须在类头中的声明和定义上都指定noexcept
理解为什么需要noexcept可以帮助加深我们对库如何与我们编写的类型的对象交互的理解。我们需要指出一个移动操作不会抛出异常,因为有两个相互关联的事实:第一,尽管移动操作通常不抛出异常,但允许它们这样做。第二,库容器提供了在发生异常时所做操作的保证。例如,vector保证如果在我们调用push_back时发生异常,那么向量本身将保持不变。
现在让我们考虑一下push_back内部会发生什么。与相应的StrVec操作一样,vector上的push_back可能需要重新分配向量。当重新分配向量时,它会将元素从其旧空间移动到新内存。
正如我们之前所见,移动一个对象通常会更改被移动的对象的值。如果重新分配使用移动构造函数,并且该构造函数在移动一些但不是所有元素后抛出异常,则会出现问题。旧空间中的已经移动的元素将被更改,新空间中未构造的元素将不存在。在这种情况下,vector将无法满足其保持向量不变的要求。
另一方面,如果vector使用复制构造函数并发生异常,则可以轻松满足此要求。在这种情况下,当元素在新内存中构造时,旧元素保持不变。如果发生异常,vector可以释放它已经分配的但无法成功构造的空间并返回。原始的vector元素仍然存在。
为了避免这个潜在的问题,vector在重新分配期间必须使用拷贝构造函数而不是移动构造函数,除非它知道元素类型的移动构造函数不能抛出异常。如果我们希望移动我们类型的对象而不是在vector重新分配等情况下复制,我们必须明确告诉库我们的移动构造函数是安全的。我们通过标记移动构造函数(和移动赋值运算符)noexcept来实现。

移动迭代器

我们在重新分配成员时可以使用for循环来调用构造函数以将元素从旧内存复制到新内存。作为编写该循环的替代方法,如果我们可以调用uninitialized_copy来构造新分配的空间,那将会更容易。但是uninitialized_copy只做复制工作。没有类似的库函数可以将对象“移动”到未构造的内存中。
相反,新标准库定义了一个移动迭代器适配器。移动迭代器通过更改迭代器的解引用运算符的行为来调整其给定的迭代器。通常,迭代器解引用运算符返回对该元素的左值引用。与其他迭代器不同,移动迭代器的取消引用运算符产生右值引用。
我们通过调用库make_move_iterator函数将普通迭代器转换为移动迭代器。此函数接收迭代器并返回移动迭代器。
返回的移动迭代器的所有操作都和原始迭代器一样。因为这些迭代器支持常规迭代器操作,所以我们可以将一对移动迭代器传递给算法。特别是,我们可以将移动迭代器传递给uninitialized_copy

Code:
    void StrVec::reallocate()
    {
        // allocate space for twice as many elements as the current size
        auto newcapacity = size() ? 2 * size() : 1;
        auto first = alloc.allocate(newcapacity);
        // move the elements
        auto last = uninitialized_copy(make_move_iterator(begin()),
                                  make_move_iterator(end()),
                                  first);
        free();            // free the old space
        elements = first;  // update the pointers
        first_free = last;
        cap = elements + newcapacity;
    }

uninitialized_copy调用输入序列中每个元素的构造函数,以将该元素“复制”到目标中。该算法使用迭代器解引用运算符从输入序列中获取元素。因为我们传递了移动迭代器,所以解引用运算符产生一个右值引用,这意味着构造将使用移动构造函数来构造元素。
值得注意的是,标准库不保证哪些算法可以与移动迭代器一起使用,哪些算法不能。因为移动对象可以消除源对象,所以只有当我们确信算法在分配给该元素或将该元素传递给用户定义的函数后才访问该元素时,才应将移动迭代器传递给算法。
因为移动的对象具有不确定的状态,所以在对象上调用std::move是一种危险的操作。当我们调用move时,我们必须绝对确定不能有其他用户移动对象。
在类代码中明智地使用move可以提供显着的性能优势。随便在普通用户代码中使用(与类实现代码相对),移动对象更有可能导致神秘且难以发现的错误,而不是应用程序性能的任何改进。
在类实现代码(例如移动构造函数或移动赋值运算符)之外,只有当我们确定需要执行移动并且保证移动是安全的时,才使用std::move

引用限定成员函数

我们以与定义const成员函数相同的方式指示this的左右值属性。我们在参数列表后面放置一个引用限定符:

Code:
    class Foo {
    public:
        Foo &operator=(const Foo&) &; // may assign only to modifiable lvalues
            // other members of Foo
        };
        Foo &Foo::operator=(const Foo &rhs) &
        {
            // do whatever is needed to assign rhs to this object
            return *this;
        }

引用限定符可以是&&&,表示这可以分别指向右值或左值。与const限定符一样,引用限定符只能出现在(非static)成员函数上,并且必须出现在函数的声明和定义中。
我们可以只在左值上运行由&限定的函数,也可以只在右值上运行由&&限定的函数:

Code:
    Foo &retFoo(); // returns a reference; a call to retFoo is an lvalue
    Foo retVal();  // returns by value; a call to retVal is an rvalue
    Foo i, j;      // i and j are lvalues
    i = j;         // ok: i is an lvalue
    retFoo() = j;  // ok: retFoo() returns an lvalue
    retVal() = j;  // error: retVal() returns an rvalue
    i = retVal();  // ok: we can pass an rvalue as the right-hand operand to assignment

函数可以是const和引用限定的。在这种情况下,引用限定符必须遵循const限定符:

Code:
    class Foo {
    public:
        Foo someMem() & const;    // error: const qualifier must come first
        Foo anotherMem() const &; // ok: const qualifier comes first
    };

参考文献

[1] Lippman S B , Josée Lajoie, Moo B E . C++ Primer (5th Edition)[J]. 2013.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,254评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,875评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,682评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,896评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,015评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,152评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,208评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,962评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,388评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,700评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,867评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,551评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,186评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,901评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,689评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,757评论 2 351

推荐阅读更多精彩内容