首页 > 代码库 > 《C++ Primer》读书笔记—第十三章 控制拷贝

《C++ Primer》读书笔记—第十三章 控制拷贝

声明:

  • 文中内容收集整理自《C++ Primer 中文版 (第5版)》,版权归原书所有。
  • 学习一门程序设计语言最好的方法就是练习编程

第III部分,类设计者的工具

1、类是C++的核心概念。每个类都定义了一个新类型和在此类型对象上可执行的操作。

2、当定义一个类时,我们显式或隐式地指定在此类型的对象的拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数(copy construcor),拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。

一、拷贝、赋值与销毁

1、如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。拷贝构造函数第一个参数必须是一个引用类型。

2、如果我们没有给一个类定义拷贝构造函数,则编译器会定义一个合成拷贝构造函数,会将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。

  对于类类型,会使用其拷贝构造函数来拷贝,内置类型的成员则直接拷贝。如果数组成员是类类型,则使用元素的拷贝构造函数来进行拷贝。

  拷贝构造函数通常不应该是explicit的,因为在几种情况下都会被隐式地使用。

3、直接初始化与复制初始化的不同:直接初始化直接调用与实参匹配的构造函数,复制初始化总是调用复制构造函数。复制初始化首先使用指定构造函数创建一个临时对象,然后用复制构造函数将那个临时对象复制到正在创建的对象。所以当复制构造函数被声明为私有时,所有的复制初始化都不能使用。

1 string dots(10, .);   //直接初始化
2 string s(dots);         //直接初始化
3 string s2 = dots;       //拷贝初始化
4 string null_book = "9-999-99999-9"; //拷贝初始化
5 string nines = string(100, 9);    //拷贝初始化

 

4、如果一个类有移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。

5、直接初始化时,我们要求编译器使用普通的函数匹配,来选择参数最匹配的构造函数。使用构造初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中(还可能要类型转换)。

   拷贝初始化首先使用指定构造函数创建一个临时对象,然后后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。

   传递非引用实参,函数返回非引用类型,花括号列表初始化数组或聚合类时都用到拷贝构造函数。

  复制构造函数 不等于 operator=(), 后者是赋值运算符。

  拷贝初始化首先使用构造函数创建一个临时对象,然后用复制构造函数将那个临时对象复制到正在创建的对象。

  拷贝构造函数被用来初始化非引用类类型参数,这一特性揭示了为什么拷贝构造函数自己的参数必须是引用类型,如果其参数不是引用类型,则调用永远不会成功(参考不完全类型)。为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们有需要调用拷贝构造函数,如此无限循环(递归)。

1 vector<int> v1(10); //正确:直接初始化
2 vector<int> v2 = 10;//错误,接受大小参数的vector构造函数是explicit的
3 void f(vector<int> v);
4 f(10); //错误,原因同上
5 f(vector<int>(10)); //正确,拷贝构造函数

 

6、编译器可以略过拷贝构造函数。(不是必须)。在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。(比如说不能是private的)。

7、拷贝赋值运算符:operator。重载运算符本质是函数,参数表示运算符的运算对象。赋值运算符通常返回一个指向其左侧运算对象的引用。标准库要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。合成拷贝赋值运算符被用来禁止该类型对象的赋值。

  下列代码等价于Sales_data的合成拷贝赋值运算符:

1 Sales_data& Sale_sdata::operator=(const Sales_data &rhs)
2 {
3     bookNo = rhs.bookNo;//调用string::operator=();
4     units_sold = rhs.units_sold; //使用内置的int赋值
5     revenue = rhs.revenue;//同上
6     return *this;//返回引用是习惯做法
7     
8 }

 

8、析构函数:

  析构函数初始化对象的非static数据成员。析构函数释放对象使用的资源,并销毁对象的非static数据成员。名字由波浪号接类名构成。由于析构函数不接受参数,因此不能被重载。

  在构造函数中,成员初始化是在函数体执行之前完成的,并按照他们在类中出现的顺序进行初始化(使用初始化列表才是初始化,函数体中是给成员赋值)。

  而在析构函数中,首先执行函数体,然后销毁成员。成员按照初始化的顺序逆序销毁。

  通常,析构函数释放对象在生存期分配的所有资源。static不是在某个对象的生存期分配的,是贯穿程序的。

  内置类型没有析构函数,销毁内置类型什么都不用做,销毁类类型时,调用其析构函数。

  什么时候调用析构函数:

    1 变量离开其作用域是被销毁

    2 让一个对象被销毁时,其成员被销毁(比如类里面还有一个类,大类对象被销毁时,小类对象也被销毁)

    3 容器(无论是标准库容器还是数组被销毁时,其元素被销毁)

    4 动态分配内存的对象,当对指向它的指针应用delete运算符时被销毁

    5 对于临时对象,当创建它的完整表达式结束时被销毁。A a = 1; 这里面就有一个临时对象。

  当指向一个对象的引用或指针离开作用域时,析构函数不会执行。 

  析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段被销毁的,在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分进行的。

  (先执行函数体,然后再析构)

9、三/五法则: 

  有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符、析构函数。通常他们作为一个整体出现,只需要一个操作而不需要其他操作的情况是很少见的。

  要析构 =》 要delete =》要new =》不能直接copy或直接给指针赋值需要copy构造函数和operator=)。

  当决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本的原则是首先确定这个类是否需要一个析构函数。如果一个类需要一个析构函数,则一定需要拷贝构造函数和拷贝赋值运算符。

  如果对象里有指针,但使用了合成的copy构造函数和operator=(), 拷贝的时候两个对象里的指针都指向同一个内存,当其中一个对象被销毁的时候,指针会被delete,导致另一个对象的指针指向了一块被释放掉的内存。shared_pointer 可破之。

  

  有析构函数   ====几乎一定需要====》 有拷贝构造函数和拷贝赋值运算符    

  有拷贝构造函数====几乎一定需要====》拷贝赋值运算符      

  有拷贝构造函数和拷贝赋值运算符    =====不一定需要====》析构函数

10、使用=default:合成的函数将隐式地声明为内联的。如果不希望合成成员是内联函数,则只对成员的类外定义使用=default。

1 class Sales_data
2 {
3     Sales_data() = default;
4     Sales_data(const Sales_data&) = default;
5     Sales_data& operator=(const Sales_data&);
6     ~Saled_data() = default;
7 };
8 Sales_data: Sales_data::operator=(const Sales_data&) = default;

 

11、大多数类应该定义默认构造函数、拷贝构造函数、和拷贝赋值运算符,无论是隐式地还是显式地。

iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。

为了阻止拷贝,我们可以定义删除的函数(=delete)。

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

 

声明但并不实现:
我们也可以将这些拷贝控制成员声明为private的,但并不定义他们。声明但并不定义一个成员函数是合法的。如果单单声明为private,友元和其他成员函数还是可以用。

试图拷贝对象的用户代码将在编译阶段报错,成员函数或友元函数中的拷贝操作将在链接时报错。

二、拷贝控制和资源管理

1、当定义一个类的拷贝构造函数和赋值操作运算符时,我们要确定要让这个类的的行为向一个值还是像一个指针。

  行为像一个值:有自己的状态,拷贝时,副本和原对象是相互独立的,改变副本不会影响原对象。

  行为像一个指针:意味着状态是共享的,当我们拷贝这种类的对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象。反之亦然。

  标准库容器和string类的行为像一个值,而不出意外,share_ptr类提供 类似指针的行为。IO类型和unique_ptr不允许拷贝和赋值,因此他们的行为既不像值也不像指针。

  通常:类直接拷贝内置类型(不包括指针)成员:这些成员本身就是值,因此通常应该让他们的行为像值一样。我们如何拷贝指针成员决定了类具有类值行为还是类指针行为。

  类(似)值版本的HasPtr如下所示:

 1 class HasPtr
 2 {
 3 public:
 4     HasPtr(const std::string &s = std::string()):
 5         ps(new std::string(s)), i(0) { };
 6     HasPtr(const HasPtr &p):
 7         ps(new std::string(*p.ps)), i(p.i) { };   //注意new了一个新指针,并用p.ps指向的值初始化,然后两者再无关联了
 8     HasPtr& operator=(const HasPtr &);
 9     ~HasPtr() {delete ps;}
10 private:
11     std::string *ps;
12     int i;
13 };

 

这个构造函数动态分配他自己的string副本,并将指向string的指针保存在ps中。拷贝构造函数也分配他自己的string副本。

类值拷贝赋值运算符:

赋值运算符通常组合了析构函数和构造函数的操作。类似拷贝构造函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧对象拷贝数据。但是我们要注意自身赋值给自身的这种情况。

1 HasPtr& HasPtr::operator=(const HasPtr &rhs)
2 {
3     auto newp = new std::string(*rhs.ps);   //new并拷贝string
4     delete ps;
5     ps = newp;
6     i = rhs.i;
7     //delete newp;  //这里不能delete,否则ps指向的内存会被释放掉
8     return *this;
9 }

 

  赋值操作时,先用【右】操作数的指针指向的值初始化【左】操作数的string(这里要用一个临时变量newp),然后再释放掉【左】操作数原来指向的内存,最后【左】操作数的指针赋为newp的值。最后复制一些内置类型并返回*this。

  对一个赋值运算符来说,一个好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象。

2、对于行为类似像指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。我们的类仍然需要自己的析构函数来释放接受string参数的构造函数分配的内存。本例中,只有当最后一个指向string的HasPtr销毁时,它才可以释放string

3、引用计数:

  计算被某内存被指针引用的次数

    1. 构造函数要创建计数,用来记录有多少对象和正在创建的对象共享状态。创建对象时,只有一个对象共享状态,计数器初始化为1。
    2. copy构造函数不分配新的计数器,而是copy指定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
    3. 析构函数递减计数器,指出共享状态的用户又少了一个。如果计数器变为0,则析构函数delete掉内存。
    4. 拷贝赋值运算符递增右侧对象的计数器,递减左侧运算对象的计数器。如果(原)左侧对象的计数器变为0,参照3。
  我们可以通过将计数器保存在动态内存中。让多个对象共享计数器。
定义一个使用引用计数的类:
 1 class HasPtr
 2 {
 3 public:
 4     HasPtr(const std::string& s = std::string()):
 5         ps(new std::string(s)), i(0), use(new std::size_t(1)) {}; //计数器置为1
 6 
 7     HasPtr(const HasPtr &p):
 8         ps(p.ps), i(p.i), use(p.use) {++ *use;}  //递增计数器
 9     HasPtr& operator=(const HasPtr&);
10     ~HasPtr();
11 private:
12     std::string *ps;
13     int i;
14     std::size_t *use;  //记录有多少和对象共享*ps的成员。
15 };

 如果计数器变成0,则析构函数释放ps和use指向的内存。

1 HasPtr::~HasPtr()
2 {
3     if(--*use == 0)  //递减计数器
4     {
5         delete ps;  //释放string内存
6         delete use; //释放计数器内存
7     }
8 }

4、实现operator=时,应该先递增右操作数的引用次数,在递减左操作数的引用计数(否则应对自赋值这种情况会有麻烦)

 1 HasPtr& HasPtr::operator=(const HasPtr &rhs)
 2 {
 3     ++*rhs.use;    
 4     if(--*use == 0)//如果这步放在++*rhs.use;的右边的话,自赋值就会把自身(本不应被delete的内存delete掉了)
 5     {
 6         delete ps;
 7         delete use;
 8     }
 9     ps = rhs.ps;
10     use = rhs.use;
11     i = rhs.i;
12     return *this;
13 }

 

 

三、交换操作

1、为了交换两个对象我们需要进行一次拷贝和两次赋值

1 HasPtr temp = v1;
2 v1 = v2;
3 v2 = temp;

 

我们更希望交换指针,而不用分配string的新副本:

1 string *temp = v1.ps;
2 v1.ps = v2.ps;
3 v2.ps = temp;

 

2、编写自己的swap函数:

swap就是为了优化代码,所以声明为inline,在swap内部逐个交换内置类型,所以使用std::swap.

swap并不是必要的,但是对于分配了资源的类,定义swap可能是一种很重要的优化手段。

 1 class HasPtr
 2 {
 3     friend void swap(HasPtr&, HasPtr&);
 4 }
 5 inline void swap(HasPtr &lhs, HasPtr &rhs)
 6 {
 7     using std::swap;
 8     swap(lhs.ps, rhs.ps);
 9     swap(lhs.i, rhs.i);
10 }

 

3、在赋值运算符中使用swap,使用了一种名为【拷贝并交换】的技术,将左侧运算对象的一个对象和右侧运算对象的一个对象进行交换。

1 HasPtr& HasPtr::operator=(HasPtr rhs)
2 {
3     swap(*this, rhs);
4     
5     //rhs里的指针指向了*this里面指针原来指向的位置,函数结束之后自动销毁
6     return *this;
7 }

四、拷贝控制示例

1、定义两个类,用于邮件处理应用。Message和Folder,代表邮件消息和消息目录。

为了记录每个Message位于哪些Folder中,每个Message保存一个它所在的Folder的指针的set。同样,每个Folder也保存一个它包含的Message的指针的set。

Message提供save和remove操作,向folder添加和删除一个message。

Message类:

 1 class Message
 2 {
 3     friend class Folder;
 4 public:
 5     //folders被隐式初始化为空集合
 6     explicit Message(const std::string &str = "");
 7     Message(const Message&);
 8     ~Message();
 9     //从给定的Folder中添加/删除本Message
10     void save(Folder&);
11     void remove(Folder&);
12 private:
13     std::string contents; //包含消息文本
14     //folders被隐式初始化为空集合
15     std::set<Folder*> folders;   //包含本Message的Folder
16     //拷贝构造函数、拷贝赋值运算符和析构函数所使用的工具函数
17     //将本Message添加到指定参数的Folder中
18     void add_to_Folders(const Message&);
19     //从folders中的每个Folder中删除本Message
20     void remove_from_Folders();
21 
22 };

Save和Remove类:

 1 void Message::save(Folder &f)
 2 {
 3     //在Message中添加Folder的指针,然后在Folder中添加Message的指针
 4 
 5     folders.insert(&f);  //将给定Folder的指针添加到我们的Folder列表中
 6     f.addMsg(this);   
 7 }
 8 void Message::remove(Folder &f)
 9 {
10     folders.erase(&f);    //对应上面的insert
11     f.remMsg(this);       //对应上面的addMsg
12 }

Message类的析构函数:

1 void Message::remove_from_Folders()//析构和赋值都会用到这个工具函数
2 {    for(auto i : folders)
3         folders.remMsg(this);
4 }
5 
6 Message::~Message()
7 {
8     remove_from_Folders();
9 }

Message类的拷贝构造函数:

1 </pre><pre name="code" class="cpp">Message::Message(const Message &m):contest(m.contest),folders(m.folders)
2 {
3     /*
4     for(auto f : m.folders) //也可以,不过使用工具函数更方便
5         f->addMsg(this);
6     */
7     //使用上一个工具函数
8     add_to_Folders(m);
9 }

Message的拷贝赋值运算符:

1 Message& operator=(const Message& rhs)
2 {
3     remove_from_Folders();
4     folders = rhs.folders;
5     add_to_Folders(rhs);
6     contents = rhs.contents;
7     return *this;
8 }

Swap类:

 1 void swap(const Message& lhs, const Message& rhs)
 2 {
 3     //将每个Msg从原来的Folder中删除
 4     for(auto f : lhs.folder)
 5         f.remMsg(&lhs);
 6     for(auto f : rhs.folder)
 7         f.remMsg(&rhs);
 8     //交换Folders
 9     using std::swap;
10     swap(lhs.content, rhs.content); //使用swap(string&, string&);
11     swap(lhs.folders, rhs.folders); //使用swap(set&, set&);
12     //将每个Msg添加到新的Folder中。
13     for(auto f : lhs.folder)
14         f.addMsg(&lhs);
15     for(auto f : rhs.folder)
16         f.addMsg(&rhs);
17 }

 

五、动态内存管理类???

1、

2、

3、

六、对象移动???

1、

2、

3、

   4.1:第十三章第五、六章还需要回来再看。马上要小长假了,今天把第十三章笔记总结了下。假期估计要把“排行榜”的项目做完,收假了要开始看师兄给的论文了。C++学习不能落下,且随疾风前行。

《C++ Primer》读书笔记—第十三章 控制拷贝