C++ Primer 5th学习笔记12 拷贝控制

拷贝控制

1 拷贝、赋值与销毁

  拷贝控制操作:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。

1.1 拷贝构造函数

  若一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。示例如下:

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

拷贝初始化

string dots(10, '.');    //直接初始化
string s(dots);    //直接初始化
string s2 = dots;    //拷贝初始化
string nines = string(100, '9');    //拷贝初始化

当使用直接初始化时,实际上是要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。
当使用拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中。

拷贝初始化不仅在用=定义变量时发生,在以下情况也会发生:

  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

例如:初始化标准库容器或调用其insertpush成员时,容器会对其元素进行拷贝初始化,而用emplace成员创建的元素都进行直接初始化。

1.3 析构函数

析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,而析构函数则是释放对象使用的资源,并销毁对象的非static数据成员。其示例如下:

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

析构函数完成的工作: 成员的初始化是在函数体执行之前完成的,按照在类中出现的顺序进行初始化,而在析构函数中,首先执行函数体,然后销毁成员,==成员按初始化顺序的逆序销毁。==通常析构函数释放对象在生存期分配分配的所有资源。
无论何时一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用于时被销毁
  • 当一个对象被销毁时,其成员被销毁
  • 容器(无论是标准库还是数组)被销毁时,其元素被销毁
  • 对于动态分配的对象,当对指向其指针应用delete运算符时被销毁
  • 对于临时对象,当创建它的完整表达式结束时被销毁

1.4 三/五法则

三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。
  需要析构函数的类也需要拷贝和赋值操作
当决定一个类是否要定义其拷贝控制成员时,首先应该确定这个类是否需要一个析构函数,若这个类需要一个析构函数,则其也需要一个拷贝构造函数和一个拷贝赋值运算符。
合成析构函数不会delete一个指针数据成员,此时则需要定义一个析构函数来释放构造函数分配的内存。
  需要拷贝操作的类也需要赋值操作,反之亦然
第二个基本原则:如果一个类需要一个拷贝构造函数,可以肯定也需要一个拷贝赋值运算符;反之亦然,如果一个类需要一个拷贝赋值运算符,可以肯定也需要一个拷贝构造函数。

1.5 使用=default

通过将拷贝控制成员定义为=default来显式要求编译器生成合成的版本,示例如下:

class Sales_data{
    public:
    //拷贝控制成员;使用default
    Sales_data() = default;
    Sales_data(const Sales_data&) = default;
    Sales_data& operator=(const Sales_data &);
    ~Sales_data() = default;
};

当在类内用=default修饰成员的声明时,合成的函数将隐式第声明为内联的,若不希望合成的成员是内联函数,则只对成员的类外定义使用=default
只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)

1.6 阻止拷贝

  定义删除的函数:声明了该函数,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出定义其为删除的,示例如下:

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

与default不同,=delete必须出现在函数第一次声明的时候;可以对任何函数指定=delete注意:析构函数不能是删除的成员

如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。但是有一个规则:若一个类有const成员,则它不能使用合成的拷贝赋值

2 拷贝控制和资源管理

2.1 行为像值的类

  类值拷贝赋值运算符
如下例子,通过先拷贝右侧运算符对象,在拷贝完成后,释放左侧运算对象的资源,并更新指针指向新分配的string,代码示例如下:

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
    auto newp = new string(*rhs.ps);    //拷贝底层string
    delete ps;    //释放旧内存
    ps = newp;    //从右侧运算对象拷贝数据到本对象
    i = rhs.i;
    return *this;    //返回本对象
}

要点:

  • 若将一个对象赋予其自身,赋值运算符必须能正确工作
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作

2.2 定义行为指针的类

令一个类展现类似指针的行为的最后办法是:使用shared_ptr来管理类中的资源。其特点:拷贝(或赋值)一个shared_ptr会拷贝(赋值)shared_ptr所指向的指针;shared_ptr类自己记录有多少用户共享其所指向的对象;当没有用户使用对象时,shared_ptr类负责释放资源。

  引用计数
引用计数的工作方式如下:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来距离有多少对象与正在创建的对象共享状态。当创建对象时,计数器初始化为1.
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。
  • 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器,若左侧运算对象的计数器变为0,则拷贝赋值运算符就必须销毁状态。

解决确定在哪存放引用计数的方法是:将计数器保存在动态内存中
定义一个使用引用计数的类,其示例如下:

class HasPtr{
public:
    //构造函数分配新的string
    HasPtr(const std::string &s = std::string()):
        ps(new std::string(s)), i(0), use(new std::size_t(1)){}
    //拷贝构造函数拷贝所有三个数据成员,并递增计数器
    HasPtr(const HasPtr &p):
        ps(p.ps), i(p.i), use(p.use) { ++*use; }
    HasPtr& operator=(const HasPtr);
    ~HasPtr();
private:
    std::string *ps;
    int i;
    std::size_t *use;    //用来记录有多少个对象共享*ps的成员
}

  类指针的拷贝成员“篡改”引用计数
析构函数释放ps和use指向的内存,示例如下:

HasPtr::~HasPtr()
{
    if(--*use == 0)    //如果引用计数变为0
    {
        delete ps;    //释放string内存
        delete use;    //释放计数器内存
    }
}

当拷贝赋值运算符执行类似拷贝构造函数和析构函数的工作时,他必须递增右侧运算对象的引用计数(即拷贝构造函数的工作);并递减左侧运算对象的引用计数,在必要时释放使用的内存(即析构函数的工作。)

3 交换操作

定义自己的swap函数,利用用户定义的swap来重载swap的默认行为,其示例如下:

class HasPtr{
    friend void swap(HasPtr&, HasPtr&);
};
inline
void swap(HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap(lhs.ps, rhs.ps);    //交换指针,而不是是string数据
    swap(lhs.i, rhs.i);    //交换int成员
}

将swap定义为friend,以便能访问HasPtr的(private的)数据成员,并将其声明为inline函数。

  在赋值运算符中使用swap
定义swap的类通常用swap来定义其赋值运算符,这些运算符使用了名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换,示例如下:

//注意rhs是按值传递的,意味着HasPtr的拷贝构造函数
//将右侧运算符对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
    //交换左侧运算对象和局部变量rhs的内容
    swap(*this, rhs);    //rhs现指向本对象曾经使用的内存
    return *this;    //rhs被销毁,从而delete了rhs中的指针
    
}

4 拷贝控制示例

作为类需要拷贝控制来进行簿记操作的例子,下面将概述两个类的设计,这两个类能用于邮件处理应用中。两个类的名字为Message和Folder,分别表示电子邮件消息和消息目录。每个Message对象可以出现在多个Folder中,但是任意给定的Message的内容只有一个副本。这样,如果一个Message的内容被改变,则从它所在的任何Folder来浏览此Message时,都会看到改变后的内容。
  为每个Message设置一个保存其所在Folder的指针的set;同样,为每个Folder设置一个保存其包含的Message的指针的set。
Message类提供save和move操作,来向一个指定Folder添加一条Message或是从中删除一条Message。
Message类

class Message{
    friend class Folder;
public:
    //folders被隐式初始化为空集合
    explicit Messsage(const std::string &str = ""):
        contents(str) {}
    //拷贝控制成员,用来管理指向本Message的指针
    Message(const Message&);    //拷贝构造函数
    Message& operator=(const Message&);    //拷贝赋值运算符
    ~Message();    //析构函数
    //从给定Folder集合中添加、删除本Message
    void save(Folder&);
    void remove(Folder&);
private:
    std::string contents;    //实际消息文本
    std::set<Folder*> folders;    //包含本Message的Folder
    //拷贝构造函数、拷贝赋值运算符和析构汉所使用的工具函数
    //将本Message添加到指向参数的Folder中
    void add_to_Folders(const Message&);
    //从folders中的每个Folder中删除本Message
    void remove_from_Folders();
}

save和remove成员

//save成员
void Message::save(Folder &f)
{
    folders.insert(&f);    //将给定Folder添加到Folder中
    f.addMsg(this);    //将本Message添加到f的Message集合中
}
//remove成员
void Message::remove(Folder &f)
{
    folders.erase(&f);    //将给定Folder从Folder列表中删除
    f.remMsg(this);    //将本Message从f的Message集合中删除
}

Message类的拷贝控制成员

void Message::add_to_Folders(const Message &m)
{
    for(auto f : m.folders)    //对每个包含m的Folder
    f->addMsg(this);           //向该Folder添加一个指向本Message的指针
}
//Message的拷贝构造函数拷贝给定对象的数据成员
Message::Message(const Message &m):
    contents(m.contents), folders(m.folders)
{
    add_to_Folders(m);    //将本消息添加到指向m的Folder中
}

Message的析构函数

//从对应的Folder中删除本Message
void Message::remove_from_Folders()
{
    for(auto f : folders)    //对folder中每个指针
        f->remMsg(this);    //从该Folder中删除本Message
}

有了上面的remove_from_Folders函数,就可以编写析构函数,如下:

Message::~Message()
{
    remove_from_Folders();
}

Message的拷贝赋值运算符

Message& Message::operator=(const Message &rhs)
{
    //通过先删除指针在插入它们来处理自赋值情况
    remove_from_Folders();    //更新已有Folder
    contents = rhs.contents;    //从rhs拷贝消息内容
    folders = rhs.folders;    //从rhs拷贝Folder指针
    add_to_Folders(rhs);    //将本Message添加到那些Folder中
    return *this;
}

Message的swap函数

void(Messags &lhs, Messags &rhs)
{
    using std::swap;
    //将每个消息的指针从原来所在Folder中删除
    for(auto f: lhs.folders)
        f->remMsg(&lhs);
    for(auto f: rhs.folders)
        f->remMsg(&rhs);
    //交换contents和Folder指针set
    swap(lhs.folders, rhs.folders);    //使用swap(set&, set&)
    swap(lhs.contents, rhs.contents);    //swap(string&, string&)
    //将每个Message的指针添加到新的Folder中
    for(auto f: lhs.folders)
        f->addMsg(&lhs);
    for(auto f: rhs.folders)
        f->addMsg(&rhs);
}

5 动态内存管理类

  StrVec类定义
该类需要实现的功能:

  • elements,指向分配的内存中的首元素
  • first_free,指向最后一个实际元素之后的位置
  • cap,指向分配的内存末尾之后的位置。
  • alloc_n_copy会分配内存,并拷贝一个给定范围中的元素
  • free会销毁构造的元素,并释放内存
  • chk_n_alloc保证StrVec至少有容纳一个新元素的空间,若没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存
  • reallocate在内存用完时为StrVec分配新内存

其实现如下:

//类vector类内存分配策略的简化实现
class StrVec{
public:
    StrVec():    //allocator成员进行默认初始化
    elemets(nullptr), first_free(nullptr), cap(nullptr) {}
    StrVec(const StrVec&);    //拷贝构造函数
    StrVec &operator=(const StrVec&);    //拷贝赋值运算符
    ~StrVec();    //析构函数
    void push_bcak(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 racallocate();    //获得更多内存并拷贝已有元素
    std::string *elements;    //指向数组首元素的指针
    std::string *first_free;    //指向数组第一个空闲元素的指针
    std::string *cap;    //指向数组尾后位置的指针
}

  使用construct
函数push_back调用chk_n_alloc确保有空间容纳新元素。函数push_back示例如下:

void StrVec::push_back(const string& s)
{
    chk_n_alloc();    //确保有空间容纳新元素
    //在first_free指向的元素中构造s的副本
    alloc.construct(first_free++, s);
}

当用allocator分配内存时,必须记住内存是未构造的。这里first_free前置版本,是因为这个调用会在first_free当前值指定的地址构造一个对象,并递增first_free指向下一个为构造的元素。

  alloc_n_copy成员
alloc_n_copy成员会分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中,此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝的尾后的位置,示例如下:

pair<string*, string*>
StrVec::alloc_n_copy(const string *b, const string *e)
{
    //分配空间保存给定范围中的元素
    auto data = alloc.allocate(e - b);
    //初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成
    return {data, uninitialized_copy(b, e, data)};
}

  free成员
free成员的两个功能:首先destroy元素,然后释放StrVec自己分配的内存空间。for循环调用allocator的destroy成员,从构造的尾元素开始,到首元素为止,逆序销毁所有元素:

void StrVec::free()
{
    //不能传递给deallocate一个空指针,只有elements不为0,才销毁旧元素
    if(elements)
    {
        //逆序销毁旧元素
        for( auto p = first_free; p != elements;)
            alloc.destroy(--p);
        alloc.deallocate(elements, cap - elements);
    }    
}

  拷贝控制成员
alloc_n_copy的返回值是一个指针的pair。其first成员执行第一个构造的元素,second成员指向最后一个构造的元素之后的位置,由于alloc_n_copy分配的空间恰好容纳给定的元素,cap也指向最后一个构造的元素之后的位置。

StrVec::StrVec(const StrVec &s)
{
    //调用alloc_n_copy分配空间以容纳与s中一样多的元素
    auto newdata = alloc_n_copy(s.begin(), s.end());
    elements = newdata.first;
    first_free = cap = newdata.second;
}

析构函数调用free:

StrVec::~StrVec() { free(); }

拷贝赋值运算符在释放已有元素之前调用alloc_n_copy,这样就可以正确处理自赋值的情况:

StrVec &StrVec::operator = (const StrVec &rhs)
{
    //调用alloc_n_copy分配内存,大小与rhs中元素占用空间一样多
    auto data = alloc_n_copy(rhs.begin(), rhs.end());
    free();
    elements = data.first;
    first_free = cap = data.second;
    return *this;
}

  在重新分配内的过程中移动而不是拷贝元素
  reallocate成员
该成员首先调用allocate分配新内存空间,每次重新分配内存时会将StrVec的容量翻倍,若StrVec为空,将分配容纳一个元素的空间:

void StrVec::reallocate()
{
    //将分配当前大小两倍的内存空间
    auto newcapacity = size() ? 2 * size() : 1;
    //分配内存
    auto newdata = alloc.allocate(newcapacity);
    //将数据从旧内存移动到新内存
    auto dest = newdata;    //指向新数组中的下一个空闲位置
    auto elem = elements;    //指向旧数组中下一个元素
    for (size_t i = 0; i != size(); i++)
        //注意这里elem是后置递增运算
        alloc.construct(dest++, std::move(*elem++));    
    free();    //一旦移动完元素就释放旧内存空间
    //更新数据结构,执行新元素
    elements = newdata;
    first_free = dest;
    cap = elements + newcapacity;
}

这里使用dest指向构造新string的内存,使用elem指向原数组中的元素;每次用后置递增运算符将dest(和elem)推进到各自数组中的下一个元素。

6 对象移动

  标准库容器、string、和shared_ptr类既支持移动也支持拷贝;IO类和unique_ptr类可以移动但不能拷贝。

6.1 右值引用

右值引用:必须绑定到右值的引用,通过&&而不是&来获得右值引用,其重要性质:只能绑定到一个将要销毁的对象。绑定特性:可以将一个右值引用绑定到要求转换的表达式、字面常量或是返回右值的表达式上,但不能将一个右值引用绑定到一个左值上,示例如下:

int i = 42;
int &r = i;    //正确:r引用i
int &&rr = i;    //错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42;    //错误:i*42是一个右值
const int &r3 = i * 42;    //正确:可以将一个const的引用绑定到一个左值上
int &&rr2 = i * 42;    //正确:将rr2绑定到乘法结果上

  左值持久;右值短暂
左值和右值表达式的区别:左值有持久的状态,右值要么是字面常量,要么是在表达式求值过程中创建的临时对象,其特性决定:使用右值引用的代码可以自由地接管所引用的对象的资源
注意:不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行

  标准库move函数
可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,该函数定义在utility头文件中,使用示例如下:

int &&rr3 = std::move(rr1);    //正确

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

  移动操作、标准库容器和异常
在构造函数中指明noexcept,是承诺一个函数不抛出异常的一种方法,示例如下:

class StrVec{
public:
    StrVec(StrVec&&) noexcept;    //移动构造函数
};
StrVec::StrVec(StrVec &&s) noexcept:    //成员初始化器
{  //构造函数体  }

注意:不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept

  移动赋值运算符
类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值:

StrVec &StrVec:;operator=(StrVec &&rhs) noexcept
{
    //直接检测自赋值
    if(this != &rhs)
    {
        free();    //释放已有元素
        elements = rhs.elements;    //从rhs接管资源
        first_free = rhs.first_free;
        cap = rhs.cap;
        //将rhs置于可析构状态
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}

  移后源对象必须可析构
当编写一个移动操作时,必须确保移动后对象进入一个可析构的状态。当从一个标准库string或容器对象移动数据时,移动后源对象仍然保持有效,因此,可以对其执行empty或size操作

  合成的移动操作
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
与拷贝操作不同,移动操作永远不会隐式定义为删除的函数,若要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除函数

6.2 右值引用和成员函数

区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受T&&
StrVec类定义另外一个版本的push_back,具体示例如下:

class StrVec{
public:
    viod push_back(const std::string&);    //拷贝元素
    viod push_back(std::string&&);    //移动元素
};
viod push_back(const std::string&)    //拷贝元素
{
    chk_n_alloc9);    //确保有空间容纳新元素
    //在first_free指向的元素中构造s的一个副本
    alloc.construct(first_free++, s);
}

viod push_back(std::string&&)    //移动元素
{
    chk_n_alloc9);    //确保有空间容纳新元素
    //在first_free指向的元素中构造s的一个副本
    alloc.construct(first_free++, std::move(s));
}

调用上述两个push_back,示例如下:

StrVec vec;    //空StrVec
string s = "some string or another";
vec.push_back(s);    //调用push_back(const std::string&)
vec.push_back("done");    //调用push_back(std::string&&)

猜你喜欢

转载自blog.csdn.net/qq_18150255/article/details/89300867