C++_Primer13.copy_control

拷贝控制


第三部分:类设计者工具

  • 13.拷贝控制
    • 拷贝、赋值、移动和销毁
  • 14.操作重载与类型转换
    • 运算符重载
    • 函数调用运算符
    • 转换运算符(类类型对象的隐式转换机制)
  • 15.面向对象程序设计
    • 继承和动态绑定(可以编写类型无关的代码)
  • 16.模板与泛型编程
    • 函数模板和类模板
    • 可变参数模板、模板类型别名和控制实例化

5个特殊成员函数:(拷贝控制操作)

  • 拷贝构造函数(copy constructor)
  • 拷贝赋值运算符(copy-assignment operator)
  • 移动构造函数 (move constructor)
  • 移动赋值运算符 (move-assignment operator)
  • 析构函数 (destructor)

拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。
拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。
析构函数定义了对象销毁时做什么。

拷贝、赋值与销毁

拷贝构造函数

class Foo {
public:
    Foo();              // 默认构造函数
    Foo(const Foo&);    // 拷贝构造函数
    ...
};

拷贝构造函数的第一个参数必须是引用类型,参数也可以不是 const,但几乎总是一个 const 的引用。
如果不是引用类型,则拷贝时会调用拷贝构造函数,而调用拷贝构造函数会传递参数,而参数传递会调用拷贝构造函数,如此则陷入无限循环。
拷贝构造函数经常被隐式地使用(隐式转换),因此它不应该是 explicit 的。(explicit 会阻止隐式转换)

如果未定义拷贝构造函数,编译器会默认定义一个。
合成的拷贝构造函数会将给定对象的每个非 static 成员依次拷贝到正在创建的对象中。
对于类类型成员,会使用其拷贝构造函数来拷贝;
对于内置类型成员,直接拷贝;
对于数组成员,虽然不能直接拷贝一个数组,但会逐元素地拷贝一个数组类型的成员;如果数组元素是类类型,则使用其拷贝构造函数来拷贝。

拷贝初始化:

string dots(10, '.');           // 直接初始化
string s(dots);                 // 直接初始化
string s2 = dots;               // 拷贝初始化
string s3 = "abc";              // 拷贝初始化
string s4 = string(10, '.');    // 拷贝初始化

在拷贝时,可能会进行类型转换。

拷贝初始化的时机:

  • 使用 =
  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
  • 某些类型会对他们所分配的对象使用拷贝初始化
    • 比如标准库容器调用其 insert 或 push 成员时,容器会对元素进行拷贝初始化
    • 而 emplace 成员创建的元素都进行直接初始化

explicit 限制

vector 的拷贝构造函数是用 explicit 修饰的(实际上 vector 所有接受单一参数的构造器都是 explicit 的),所以传递一个实参或从函数返回一个值时,我们不能隐式使用一个 explicit 构造函数。如果想使用,就必须显式地使用:

vector<int> v1(10);         // 正确,直接初始化,创建一个10个元素的vector
vector<int> v2 = 10;        // 错误,explicit 的构造函数不能进行隐式转换

void f(vector<int>);
f(10);                      // 错误,不能将10隐式地转换为 vector<int> 类型
f(vector<int>(10));         // 正确,显式地转换

拷贝赋值运算符

对赋值运算符 = 进行重载:

class Foo {
public:
    Foo& operator=(const Foo&);     // 赋值运算符
    ...
};
Foo& Foo::operator(const Foo& rhs) {
    bookNo = rhs.bookNo;
    // ...
    return * this;
}

为了与内置类型的赋值保持一致,通常返回一个指向其左侧运算对象的引用。

析构函数

class Foo {
public:
    ~Foo();     // 析构函数
    ...
};

调用析构函数时,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
析构部分是隐式的,不需要显式调用,通常无需担心何时释放资源。
内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
成员销毁时发生什么完全依赖于成员的类型。

隐式销毁内置指针类型的成员不会 delete 它所指向的对象。
智能指针是类类型,具有析构函数,所以智能指针成员会在析构阶段被自动销毁。

调用析构函数的时机:

  • 变量离开作用域
  • 当一个对象被销毁,其成员也被销毁
  • 容器(标准库容器和数组)被销毁时,元素被销毁
  • 动态分配的对象,对其指针使用 delete 时
  • 对于临时对象,当创建它的完整表达式结束时被销毁

当类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。

三/五法则

三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数
新标准还可以定义一个移动构造函数和一个移动赋值运算符
C++语言不要求定义所有的操作,可以只定义其中一两个。但是这些操作通常应该看作一个整体。

需要析构函数的类也需要拷贝和赋值操作

class HasPtr {
public:
    HasPtr(const std::string& s = std::string()): ps(new std::string(s)), i(0) {}
    ~HasPtr() { delete ps; }
    // 错误,HasPtr 需要一个拷贝构造函数和一个拷贝赋值运算符
private:
    ...
}

HasPtr f(HasPtr hp) {   // hp 是值传递,所以会被拷贝
    HasPtr ret = hp;
    return ret;         // ret 和 hp 被销毁
}

HasPtr p("abc");
f(p);                   // f 结束时,p.ps 指向的内存被释放
HasPtr q(p);            // 此时 p 和 q 指向的内存都已无效

当 f 返回时,hp 和 ret 都被销毁,都会调用 HasPtr 的析构函数,所以 delete ret 会被执行两次。
所以如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。

需要拷贝的类也需要赋值操作,反之亦然

很多类需要定义所有拷贝控制成员,但某些类只需要拷贝或赋值操作,不需要析构函数。
如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然。

使用 =default

显式地要求编译器生成合成的版本

class Sales_data {
public:
    Sales_data() = default;
    Sales_data(const Sales_data&) = default;
    ~Sales_data() = default;
    ...
}
Sales_data& Sales_data::operator=(const Sales_data&) = default;

阻止拷贝

struct NoCopy {
    NoCopy() = default;     // 使用默认构造函数
    NoCopy(const NoCopy&) = delete;             // 阻止拷贝
    NoCopy& operator=(const NoCopy&) = delete;  // 阻止赋值运算
    ~NoCopy() = default;    // 使用默认合成的析构函数
    ...
}

析构函数不能是删除的成员

对于删除了析构函数的类型,编译器不允许定义该类型的变量或创建该类的临时对象,但可以动态分配这种类型的对象,只是不能释放这些对象:

struct NoDtor {
    NoDtor() = default;
    ~NoDtor() = delete;
};
NoDtor nd;                  // 错误
NoDtor* p = new NoDtor();   // 正确,但不能 delete p
delete p;                   // 错误

合成的构造函数可能是删除的

如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的:

  • 如果类的某个成员的析构函数是删除或不可访问的(private),则类的合成析构函数被定义为删除的。
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。
    • 如果类的某个成员的析构函数是删除或不可访问的,则类的合成拷贝构造函数也被定义为删除的。
  • 如果类的某个成员的拷贝构造函数是删除或不可访问的,或是类有一个 const 的或引用的成员,则类的合成拷贝赋值运算符被定义为删除的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,或类有一个引用成员,它没有类内初始化器,或类有一个 const 成员,它没有类内初始化器且其类型未显式定义默认的构造函数,则该类的默认构造函数被定义为删除的。

private 拷贝控制

在新标准发布前,类是通过 private 来阻止拷贝的。
如果拷贝构造函数和拷贝赋值运算符是 private 的,用户代码将不能拷贝这个类型的对象。但是友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,可以将这些拷贝控制成员声明为 private 的,但并不定义他们。
试图访问一个未定义的成员将导致一个链接时错误。

声明为 private 可以组织拷贝该类型对象的企图:

  • 试图拷贝对象的用户代码将在编译阶段被标记为错误
  • 成员函数或友元函数中的拷贝操作将导致链接时错误

新标准下,希望阻止拷贝的类应使用 =delete 来定义他们的拷贝构造函数和拷贝赋值运算符,而不是将他们声明为 private 的。

拷贝控制和资源管理

类的行为像值

每个成员都要拷贝,指针成员要拷贝底层数据

赋值操作会销毁左侧对象的资源,然后从右侧对象拷贝数据。这些操作必须以正确的顺序执行的,即将一个对象赋予它自身也保证正确。先创建一个临时对象,保存右侧数据,然后删除左侧数据:

HasPtr& HasPtr::operator=(const HasPtr& rhs) {
    auto newp = new string(*rhs.ps);
    delete ps;
    ps = newp;
    i = rhs.i;
    return *this;
}
  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作

类的行为像指针

令一个类展现类似指针的行为最好方法是使用 shared_ptr 来管理类中的资源,它会自己计数和释放资源。
如果想要自己直接管理资源,需要使用引用计数:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数。创建对象时,计数器初始化为1.
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器。
  • 析构函数递减计数器,如果计数器变为0,则析构函数释放资源。
  • 拷贝赋值运算符递增右侧对象计数器,递减左侧对象的计数器。如果左侧对象计数器变为0,则销毁资源。

交换操作

无需定义中间变量,直接交换成员指针:

class HasPtr {
    friend void swap(HasPtr&, HasPtr&);
    ...
};
inline void swap(HasPtr& lhs, HasPtr& rhs) {
    using std::swap;
    swap(lhs.ps, rhs.ps);   // 不要写成 std::swap
    swap(lhs.i, rhs.i);
}

如果 ps 对象的类型定义了 swap,则调用该 swap,否则调用 std::swap。
但如果上例中写成 std::swap(lhs.ps, rhs.ps) 则只能调用 std::swap。

动态内存管理类

移动构造函数

对于 vector<string>,为保证内存连续性,在 vector 初始化时会分配一定大小的空间,往其添加元素时判断空间是否够用,不够用则会重新分配大小,然后将其内容移动到新空间。
在移动过程中,会将元素从就内存逐个移动到新内存。
由于 string 的行为类似值,拷贝一个 string 就必须真的拷贝数据。所以在 vector 重新分配空间时会产生额外的开销来拷贝这些数据。
通过新标准库引入的两种机制,可以避免 string 的拷贝:

  • 定义所谓的”移动构造函数”
  • 使用一个名为 move 的标准库函数(utility 头文件)

关于 string 的移动构造函数如何工作的细节,目前都尚未公开。但我们知道,移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。并且移动后的 string 仍然保持一个有效、可析构的状态。可以假定 string 的移动构造函数进行了指针的拷贝,而不是重新分配内存空间然后拷贝字符串。

假如要定义一个用于处理 string 的类似 vector 的类:

class StrVec {
public:
    StrVec(): elements(nullptr), first_free(nullptr), cap(nullptr) {}
    StrVec(const StrVec&);
    StrVec& operator=(const StrVec&);
    ~StrVec();
    void push_back(const std::string&);
    size_t size() const { return first_free - elements; }
    size_t capacity() const { return cap - elements; }
    std::string* begin() const { return elements; }
    std::string* end() const { return first_free; }
private:
    Static std::allocator<std::string> alloc;
    // 被添加元素的函数使用,判断空间是否已满
    void chk_n_alloc()
        { if (size() == capacity()) reallocate(); }
    // 工具函数,被拷贝构造函数、赋值运算符和析构函数使用
    std::pair<std::string*, std::string*> alloc_n_copy
        (const std::string*, const std::string*);
    void free();                // 销毁元素并释放内存
    void reallocate();          // 内存需要扩充时重新分配内存并拷贝/移动元素
    std::string* elements;      // 指向数组首元素的指针
    std::string* first_free;    // 指向数组第一个空闲元素的指针
    std::string* cap;           // 指向数组尾后的指针
};

void StrVec::push_back(const std::string& s) {
    chk_n_alloc();      // 确保空间足够
    alloc.construct(first_free++, s);
}
std::pair<std::string*, std::string*>
StrVec::alloc_n_copy (const std::string* b, const std::string* e) {
    // 分配空间保存给定范围中的元素
    auto data = alloc.allocate(e - b);
    return {data, uninitialized_copy(b, e, data)};
}
void StrVec::free() {
    // 不能释放空指针
    if (elements) {
        // 逆序销毁
        for (auto p = first_free; p != elements;) {
            alloc.destroy(--p);
        }
        // for_each+lambda 写法
        for_each(elements, first_free, [this](string& p){alloc.destroy(&p)});

        alloc.deallocate(elements, cap-elements);
    }
}
void StrVec::reallocate() {
    auto newcapicity = size() ? 2*size() : 1;
    auto newdata = alloc.allocate(newcapicity);
    auto dest = newdata;
    auto elem = elements;
    for (size_t i = 0; i != size(); ++i) {
        alloc.construct(dest++, std::move(*elem++));
    }
    free();
    elements = newdata;
    first_free = dest;
    cap = elements + newcapicity;
}

StrVec::StrVec(const StrVec& sv) {
    auto newdata = alloc_n_copy(s.begin(), s.end());
    elements = newdata.first;
    first_free = cap = newdata.second;
}
StrVec::~StrVec() { free(); }
StrVec& StrVec::operator=(const StrVec& rhs) {
    auto data = alloc_n_copy(rhs.begin(), rhs.end());
    free();
    elements = data.first;
    first_free = cap = data.second;
    return * this;
}

reallocate 函数中使用了 move,construct 的第二个参数是 move 的返回值。这样 string 管理的内存将不会被拷贝,相反,我们构造的每个 string 都会从 elem 指向的 string 那里接管内存的所有权。

uninitialized_copy

(来自STL源码剖析:)
uninitialized_copy() 使 我 们 能 够 将 记 忆 体 的 配 置 与 物 件 的 建 构 行 为 分 离 开 来。如果做为输出目的地的 [result, result+(last-first)) 范围内的每一个迭 代 器 都 指 向 未 初 始 化 区 域 , 则 uninitialized_copy() 会 使 用 copy
constructor,为身为输入来源之 [first,last) 范围内的每一个对象产生一份复 制品,放进输出范围中。换句话说,针对输入范围内的每一个迭代器 i,此函式会呼叫 construct(&*(result+(i-first)),*i) ,产 生 *i 的 复制 品, 放置 于 输出范围的相对位置上。
C++ 标准规格书要求 uninitialized_copy() 具有 "commit or rollback" 语意, 意思是要不就「建构出所有必要元素」,要不就(当有任何㆒个 copy constructor 失败时)「不建构任何东西」。

习题 13.39, 13.40

编写你自己版本的StrVec,包括自己版本的reserve、capacity和resize。
为你的StrVec类添加一个构造函数,它接受一个initializer_list参数。

class StrVec {
public:
    ...
    StrVec(const initializer_list<std::string>&);
    size_t capacity() const { return cap - elements; };
    void reserve(size_t n);
    void resize(size_t n, const string& s = "");
private:
    void alloc_n_move(size_t n);
}
StrVec::StrVec(const initializer_list<std::string>& il) {
    auto data = alloc_n_copy(il.begin(), il.end());
    elements = data.first;
    first_free = cap = data.second;
}

void StrVec::alloc_n_move(size_t n) {
    auto data = alloc.allocate(n);
    auto dest = data;
    auto elem = elements;
    for (size_t i = 0; i != size(); ++i) {
        alloc.construct(dest++, std::move(*elem++));
    }
    free();
    elements = data;
    first_free = dest;
    cap = elements + n;
}
void StrVec::reserve(size_t n) {
    if (n > capacity()) {
        alloc_n_move(n);
    }
}
void StrVec::resize(size_t n, const string& s = "") {
    if (n > size()) {
        if (n > capacity()) {
            reserve(n);
        }
        for (size_t i = size(); i != n; ++i) {
            alloc.construct(first_free++, s);
        }
    } else {
        for (size_t i = size(); i != n; --i) {
            alloc.destroy(--first_free);
        }
    }
}

习题13.44, 13.48

编写标准库string类的简化版本,命名为String。你的类应该至少有一个默认构造函数和一个接受C风格字符串指针参数的构造函数。使用allocator为你的String类分配所需内存。

定义一个vector并在其上多次调用push_back。运行你的程序,并观察String被拷贝了多少次。

String.hpp:

#ifndef _STRING_HPP_
#define _STRING_HPP_

#include <iostream>
#include <memory>
#include <algorithm>

class String {
public:
    String(): begin(nullptr), end(nullptr) {}
    String(const char*);
    String(const String&);
    String& operator=(const String&);
    ~String();

    static size_t c_str_len(const char*);

    size_t size() const { return end - begin; }
    bool empty() const { return size() == 0; }

    void print();
private:
    void free();

    std::allocator<char> alloc;
    char* begin;
    char* end;
};

size_t String::c_str_len(const char* pc) {
    size_t i = 0;
    while (pc[i] != 0) {
        ++i;
    }
    return i;
}
String::String(const char* pc) {
#ifdef DEBUG
    std::cout << "String(const char* pc) constructor:\t" << pc << std::endl;
#endif
    size_t len = c_str_len(pc);
    begin = alloc.allocate(len+1);
    end = begin + len;
    std::uninitialized_copy(pc, pc + len + 1, begin);
}
String::String(const String& s) {
#ifdef DEBUG
    std::cout << "String(const String& s) constructor:\t" << s.begin << std::endl;
#endif
    begin = alloc.allocate(s.size() + 1);
    end = begin + s.size();
    std::uninitialized_copy(s.begin, s.end+1, begin);
}
String&
String::operator=(const String& rhs) {
#ifdef DEBUG
    std::cout << "operator=(const String& rhs):\t" << rhs.begin << std::endl;
#endif
    auto data = alloc.allocate(rhs.size() + 1);
    std::uninitialized_copy(rhs.begin, rhs.end + 1, data);
    free();
    begin = data;
    end = begin + rhs.size();
    return *this;
}
void
String::free() {
#ifdef DEBUG
    std::cout << "free():\t" << begin << std::endl;
#endif
    if (empty()) {
        return;
    }
    size_t len = size();
    alloc.destroy(end);
    while (begin != end) {
        alloc.destroy(--end);
    }
    // std::for_each(begin, end+1, [this](char& p) { alloc.destroy(&p); });
    alloc.deallocate(begin, len + 1);
}
String::~String() { free(); }

void
String::print() {
    if (!empty()) {
        std::cout << "print:\t" << begin << std::endl;
    } else {
        std::cout << "String is empty." << std::endl;
    }
}

#endif

Stringtest.cpp:

#include <iostream>
#include <vector>
#include "String.hpp"

int main(int argc, char** argv) {
    std::cout << "begin:" << std::endl;
    String s;
    const char* cs = "abc";
    String s1(cs);

    std::cout << "s:" << std::endl;
    s.print();
    std::cout << "s1:" << std::endl;
    s1.print();

    std::cout << "-----------------" << std::endl;

    std::vector<String> vs;
    vs.push_back("abcde");
    vs.push_back("Hello World");
    vs.push_back("Tom");

    return 0;
}

编译和运行:

$ g++ --std=c++11 -DDEBUG -o Stringtest Stringtest.cpp String.hpp
$ ./Stringtest
begin:
String(const char* pc) constructor:	abc
s:
String is empty.
s1:
print:	abc
-----------------
String(const char* pc) constructor:	abcde
String(const String& s) constructor:	abcde
free():	abcde
String(const char* pc) constructor:	Hello World
String(const String& s) constructor:	Hello World
String(const String& s) constructor:	abcde
free():	abcde
free():	Hello World
String(const char* pc) constructor:	Tom
String(const String& s) constructor:	Tom
String(const String& s) constructor:	abcde
String(const String& s) constructor:	Hello World
free():	abcde
free():	Hello World
free():	Tom
free():	abcde
free():	Hello World
free():	Tom
free():	abc
free():

在添加 "Hello World" 和 "Tom" 的时候,vector 进行了扩充,发生了元素的拷贝,调用了拷贝构造器。

对象移动

为了减少诸如 vector 等容器重新分配内存时的拷贝开销,可以移动对象,而不必拷贝对象。
新标准中,容器可以保存不可拷贝的类型,只要他们能被移动即可。
标准库容器、string 和 shared_ptr 类既支持移动也支持拷贝。IO 类和 unique_ptr 类可以移动但不能拷贝。

右值引用

rvalue reference

通过 && 而不是 & 获得右值引用。右值引用只能绑定到一个将要销毁的对象(字面量或临时对象)。
所以,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

int i = 42;
int& r = i;             // 左值引用
int&& rr = i;           // 错误,不能将右值引用绑定到一个左值上
int& r2 = i*42;         // 错误,不能将常规引用绑定到右值上
const int& r3 = i*42;   // 正确,可以将一个 const 的引用绑定到一个右值上
int&& rr2 = i*42;       // 正确
int&& rr21 = rr2;       // 错误,表达式 rr2是左值
    // 右值代表的是临时对象和字面量,而右值本身不是临时对象
    // 右值本身是个变量,变量是左值

std::move 函数

想要获得绑定到左值上的右值引用,可以调用新标准库函数 move 来获得。(utility 头文件中)

int&& r2 = std::move(rr2);

我们可以销毁一个移后源对象(move-from, 移动完成后移动来源的对象),也可以赋予它新值,但不能使用一个移后源对象的值。
使用 move 的代码应该使用 std::move 而不是 move,避免潜在的名字冲突。

调用 move 意味着承诺:除了对 rr2 赋值或销毁它外,我们将不再使用它。

移动构造函数和移动赋值运算符

类似于在拷贝时进行托梁换柱的操作。

在诸如 vector 自动扩充空间的操作中,可以对元素使用移动操作,代替拷贝操作,省去了拷贝后销毁的开销。

类似拷贝构造函数,移动构造函数第一个参数是该类的引用,只不过是一个右值引用。
除了完成资源移动,移动构造函数还必须确保销毁移后源对象是无害的。一旦完成移动,源对象必须不再指向被移动的资源,这些资源的所有权已经归属新创建的对象。

StrVec::StrVec(StrVec&& s) noexcept     // 移动操作不应抛出任何异常
        : elements(s.elements), first_free(s.first_free), cap(s.cap) {
    s.elements = s.first_free = s.cap = nullptr;
}

移动构造函数不分配任何新内存,它接管给定的 StrVec 中的内存,该对象将继续存在。而移后源对象将被销毁,即运行其析构函数。如果以上代码中忘记了改变 s.first_free,则销毁移后源对象会释放掉我们刚刚移动的内存。
移动操作通常不分配任何资源,所以它通常不会抛出任何异常。
使用 noexcept 明确告诉编译器不会抛出异常会省去编译器一些额外的工作。(指出现异常失败时类似回滚的操作)

移动赋值运算符与移动构造函数类似,除此之外,还需要处理自赋值问题:

StrVec& StrVec::operator=(StrVec&& rhs) noexcept {
    if (this != &rhs) {
        free();
        elements = rhs.elements;
        first_free = rhs.first_free;
        cap = rhs.cap;
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}

移后源对象必须可析构,通过对成员指针赋 nullptr 来实现。
移后操作必须保证源对象仍然是有效的,即可以被赋予新值或安全地使用而不依赖于当前值。用户不能对移后源的值进行任何假设。

合成的移动操作

如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 static 数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。

编译器可以移动内置类型的成员。如果类类型的成员有对应的移动操作,编译器也能移动这个成员:

struct X {
    int i;
    std::string s;      // string 定义了自己的移动操作
};
struct hasX {
    X mem;              // X 有合成的移动操作
};
X x, x2 = std::move(x);         // 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx);   // 使用合成的移动构造函数

C++ 在声明 struct 的变量时可以省略 struct,而 C语言不能省略

将移动操作定义为删除的情况:

  • 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。
  • 有类成员的移动构造函数或移动赋值运算符被定义为删除或不可访问。
  • 类的析构函数被定义为删除或不可访问
  • 有类成员是 const 的或是引用

即使显式要求编译器生成一个默认移动构造函数,比如 hasX(hasX&&) = default;,以上情况下,编译器会将移动构造函数定义为删除的。

如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。

定义了移动构造函数或移动赋值运算符的类也必须定义自己的拷贝操作。否则,这些成员默认是删除的。

// Y 是一个类,定义了自己的拷贝构造函数,但未定义移动构造函数
struct hasY {
    hasY() = default;
    hasY(hasY&&) = default;
    Y mem;      // hasY 将有一个删除的移动构造函数
};
hasY hy, hy2 = std::move(hy);   // 错误:移动构造函数是删除的

如果没有移动构造函数,右值也被拷贝

class Foo {
public:
    Foo() = default;
    Foo(const Foo&);    // 拷贝构造函数
    ...                 // 其他成员,但未定义移动构造函数
};
Foo x;
Foo y(x);               // 使用拷贝构造函数;x 是一个左值
Foo z(std::move(x));    // 使用拷贝构造函数;因为未定义移动构造函数

std::move(x) 返回 Foo&&,调用拷贝构造函数时转换为了 const Foo&

一般情况下, 拷贝构造函数满足对应的移动构造函数的要求:它会拷贝给定对象,并将原对象置于有效状态。实际上,拷贝构造函数甚至都不会改变原对象的值。

拷贝并交换赋值运算符:移动操作

class HasPtr {
    HasPtr(HasPtr&& p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }
    HasPtr& operator=(HasPtr rhs) {
        swap(*this, rhs);
        return *this;
    }
    ...
};

在赋值运算符基础上添加一个移动构造函数实际上同时也获得了一个移动赋值运算符。

建议:更新三/五法则

所有5个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有5个操作。
某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作。
拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。

移动迭代器

使用 uninitialized_copy 函数对新对象分配内存时,会对元素进行拷贝操作,而对于重新分配内存后旧内存不再使用的情况,新标准库定义了一种移动迭代器适配器,通过改变给定迭代器的解引用运算符的行为来适配此迭代器。
一般来说,一个迭代器的解引用运算符返回一个指向元素的左值,而移动迭代器的解引用运算符生成一个右值引用。
移动迭代器支持正常的迭代器操作,可以将其传递给 uninitialized_copy.

// 模仿 string 中的重分配内存
void StrVec::reallocate() {
    auto newcapacity = size() ? 2 * size() : 1;
    auto first = alloc.allocate(newcapacity);
    auto last = uninitialized_copy(make_move_iterator(begin()),
            make_move_iterator(end()), first);
    free();
    elements = first;
    first_free = last;
    cap = elements + newcapacity;
}

不要随便使用移动操作
由于一个移后源对象具有不确定的状态,对其调用 std::move 是危险的。当我们调用 move 时,必须绝对确认移后源对象没有其他用户,源对象在移动后不会再访问它。
在类代码中小心地使用 move,可以大幅度提升性能,而如果随意在普通用户代码中使用移动操作,很可能导致难以查找的错误。

右值引用和成员函数

拷贝构造函数和移动构造函数具有相同的参数模式:指向 const 的左值引用和指向非 const 的右值引用。
push_back 为例:

void push_back(const X&);
void push_back(X&&);

通常来说,不需要定义一个接受一个 const X&& 或 X& 参数的版本。

push_back的实现:

class StrVec {
public:
    void push_back(const std::string&);
    void push_back(std::string&&);
    // ...
};
void StrVec::push_back(const string& s) {
    chk_n_alloc();  // 确保空间足够
    // 在 first_free 指向的元素中构造一个 s 的副本
    alloc.construct(first_free++, s);
}
void StrVec::push_back(string&& s) {
    chk_n_alloc();
    alloc.construct(first_free++, std::move(s));
}

右值和左值引用成员函数

在一个对象上调用成员函数,不管其是左值还是右值:

string s1 = "a value", s2 = "another";
auto n = (s1+s2).find('a');

有时,右值的使用方式令人惊讶:

s1 + s2 =  "wow!";

旧标准中,我们没有办法阻止这种使用方式。为了维护向后兼容性,新标准库类仍然允许向右值赋值。但我们可能希望在自己的类中阻止这种用法。此时,我们希望强制左侧运算对象是一个左值,即在参数列表后放置一个引用限定符:

class Foo {
public:
    Foo& operator=(const Foo&) &;   // 只能向可修改的左值赋值
    ...
};
Foo& Foo::operator=(const Foo&) & {
    ...
    return * this;
}

使用:

Foo& retFoo();  // 返回一个引用,左值
Foo retVal();   // 返回一个值,临时值,右值
Foo i, j;       // i,j 都是左值
i = j;          // 正确
retFoo() = j;   // 正确
retVal() = j;   // 错误:retVal() 返回一个右值
i = retVal();   // 正确

一个成员函数可以同时用 const 和引用限定,引用限定必须在 const 之后:

class Foo {
public:
    Foo someMem() & const;      // 错误
    Foo anotherMem() const &;   // 正确
};

重载和引用函数

class Foo {
public:
    Foo sorted() &&;        // 可用于可改变的右值
    Foo sorted() const &;   // 可用于任何类型的Foo
    // ...
private:
    vector<int> data;
};
Foo Foo::sorted() && {
    sort(data.begin(), data.end());
    return * this;
}
Foo Foo::sorted() const & {
    // 由于该成员函数有const 修饰,不能对其中的成员进行修改
    // 所以创建一个副本,对其排序后返回
    Foo ret(* this);
    sort(ret.begin(), ret.end());
    return ret;
}

调用:

retVal().sorted();  // retVal() 是一个右值,调用 Foo::sorted() &&
retFoo().sorted();  // retFoo() 是一个左值,调用 Foo::sorted() const &

当我们定义 const 成员函数时,可以定义两个版本,唯一的差别是一个版本有 const 限定,而另一个没有。引用限定的函数则不一样。要么对所有函数都加上引用限定符,要么都不加:

class Foo {
public:
    Foo sorted() &&;
    //Foo sorted() const;         // 错误,必须加上引用限定符
    Foo sorted() const &;       // 正确
    using Comp = bool(const int&, const int&);
    Foo sorted(Comp*);          // 正确,参数列表不同
    Foo sorted(Comp*) const;    // 正确,两个版本都没有引用限定符
}

习题13.55

为StrBlob添加一个右值引用版本的push_back:

void push_back(std::string&& s) {
    data->push_back(std::move(s));
}

习题13.56

以下sorted函数会发生什么:

Foo Foo::sorted() const & {
    Foo ret(* this);
    return ret.sorted();
}

A: ret是个左值,调用 ret.sorted() 时会发生递归调用,直到内存崩溃

习题13.57

如果sorted改为以下版本会发生什么:

Foo Foo::sorted() const & {
    return Foo(* this).sorted();
}

A: Foo(* this) 是一个无主的右值,会调用右值版本的sorted

习题13.58

编写程序,包含左值和右值引用版本的sorted函数,并测试

#include <vector>
#include <algorithm>
#include <stdio.h>

class Foo {
public:
    Foo() {}
    Foo(const Foo& f) { data = f.data; }
    Foo(std::vector<int> data1);
    std::vector<int> data;
    Foo sorted() const &;
    Foo sorted() &&;
    void print();
};

Foo::Foo(std::vector<int> data1) {
    data = data1;
}
Foo Foo::sorted() const & {
    printf("left value sorted.\n");
    return Foo(*this).sorted();
}

Foo Foo::sorted() && {
    printf("right value sorted.\n");
    sort(data.begin(), data.end());
    return * this;
}

void Foo::print() {
    printf("Foo print:\n");
    for (auto it = data.cbegin(); it != data.cend(); ++it) {
        printf("%d\t", * it);
    }
    printf("\n");
}
int main(int argc, char** argv) {
    std::vector<int> data{4,3,9,10,2,8,1,7,5,6};
    Foo f(data);
    f.print();
    Foo f1 = f.sorted();
    f1.print();

    return 0;
}

测试:

$ g++ --std=c++11 -o sort sort.cpp
$ ./sort
Foo print:
4	3	9	10	2	8	1	7	5	6
left value sorted.
right value sorted.
Foo print:
1	2	3	4	5	6	7	8	9	10

小结

每个类都会控制该类型对象的拷贝、移动、赋值和销毁时做什么。特殊成员函数:拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符和析构函数定义了这些操作。
移动构造函数和移动赋值运算符接受一个(通常是非const的)右值引用;而拷贝版本则接受一个(一般是const的)普通左值引用。

如果类未声明这些操作,编译器会自动为其生成。如果这些操作未定义成删除的,他们会逐成员初始化、移动、赋值或销毁对象:合成的操作会依次处理每个非static数据成员,根据成员类型确定如何移动、拷贝、赋值或销毁它。

分配了内存或其他资源的类几乎总是需要定义拷贝控制成员来管理分配的资源。如果一个类需要析构函数,则它几乎肯定也需要定义移动和拷贝构造函数以及移动和拷贝赋值运算符。

附:直接初始化和拷贝初始化机制

https://blog.csdn.net/sksukai/article/details/104741675/

常见错误认识:

  • 使用 () 和使用=定义对象没什么区别(直接初始化、拷贝初始化)
  • 直接初始化使用构造函数(错,也可能使用拷贝构造函数)
  • 拷贝初始化使用拷贝构造函数(错,也可能使用构造函数)

直接初始化

直接初始化时,实际上是要求编译器使用普通的函数匹配,来提供参数最匹配的构造函数。因此直接初始化可能使用构造函数,也可能使用拷贝构造函数

拷贝初始化

按字面意思理解即可,将一个对象给另一个对象初始化。
简单理解,用=定义对象的就为拷贝初始化。

注意:
拷贝赋值运算符也用在=的情况下,但是在对象已经创建并存在的情况下,只是修改对象的值而已。
而用=拷贝初始化是发生在定义一个对象的情况下,即对象此前尚未存在。

例子

类定义如下:

#include <string>
#include <iostream>

class Book
{
public:
    Book() = default;
    Book(std::string s): name(s){std::cout << name << ": 1 para construstor" << std::endl;}
    Book(std::string s, int n): name(s), sum(n){std::cout << name << ": 2 paras construstor" << std::endl;}
    Book(const Book &b): name(b.name), sum(b.sum){std::cout << name << ": copy constructor" << std::endl;}
    Book & operator = (const Book &b) {
        name = b.name;
        sum = b.sum;
        std::cout << name << ": copy-assignment operator" << std::endl;
        return * this;
    }

private:
    std::string name;
    int sum;
};

如果有以下代码,判断,b1~b5初始化时分别使用哪个函数?

Book b1;
Book b2("b2");
Book b3 = string ("b3");
cout << endl;

Book b4(b3);
Book b5 = b3;
cout << endl;

b1 = string("b1");

1、b1:直接初始化。由于未能提供初始值,使用默认的构造函数。(想定义使用默认构造函数的对象,对象名之后不能有括号,即不能写Book b1()。这种写法意思是定义了一个叫b1的函数,返回值为Book对象。)

2、b2:直接初始化。使用了具有一个string参数的构造参数。

3、b3:拷贝初始化。使用了具有一个string参数的构造函数。

在b3初始化时,分为如下两步:
第一步:使用一个具有string参数的构造函数将string构造为临时Book类对象。
第二部:使用拷贝构造函数将上一步中的Book对象拷贝给b3。
但是实际上,编译器会进行优化,可以跳过拷贝、移动构造函数,直接使用构造函数来构造对象,即优化掉了第一步。

即使编译器掠过了拷贝/移动构造函数,但拷贝/移动构造函数必须是存在且可访问的(不能是 private 的)

4、b4:直接初始化。使用了拷贝构造函数。因为括号中为Book类对象,最匹配的是拷贝构造函数。

5、b5:拷贝初始化。使用拷贝构造函数。

6、b1: 先使用构造函数将string转为Book类对象,然后将该对象通过赋值运算符来赋值给b1。

posted @ 2023-05-06 10:35  keep-minding  阅读(17)  评论(0)    收藏  举报