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

本文详细探讨了C++中的拷贝构造函数、复制初始化、析构函数及其重要性。拷贝构造函数用于初始化类类型对象,而拷贝赋值运算符处理对象间的值传递。析构函数负责释放对象资源。文章强调了正确实现拷贝控制的重要性,特别是在涉及资源管理时,如避免浅拷贝问题。C++11引入了移动构造函数和移动赋值运算符以优化性能,尤其在对象短暂存在或资源不可共享的场景下。
摘要由CSDN通过智能技术生成

一个类有5种特殊的成员函数:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数。如果没有定义这些拷贝控制成员,编译器会自动为它定义缺失的操作。

A a;
A  b = a;//报错


13.1  拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,而且任何额外参数都有默认值(也就是说最少只需一个实参),则此构造函数是拷贝构造函数。

class Foo
{
public:
    Foo();
    Foo(const Foo&);
    //...
};
第一个参数必须是引用类型。此参数几乎总是一个const的引用。

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

如果拷贝构造函数是explicit的,那么

A a;
A b = a;//报错

就会报错

即使我们定义了其他构造函数(不是拷贝构造函数),编译器也会为我们定义一个合成的拷贝构造函数。

合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。(除了static成员)

如果类成员是A类类型,则会调用A类的拷贝构造函数来拷贝成员类。内置成员直接拷贝。数组逐个拷贝。


 

C++的一大误区——深入解释直接初始化与复制初始化的别

http://blog.csdn.net/ljianhui/article/details/9245661

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

class Foo
{
public:
     int x;
/*1*/
    Foo(){cout << 1 << endl;};
/*2*/
    Foo(const int xx){x = xx;cout << 2 << endl;}
/*3*/
    Foo(const Foo&){cout << 3 << endl;};
/*4*/
    Foo& operator=(const Foo&){cout << 4 << endl;}
};
int main()
{
    Foo f1(0);      /*2*/
    Foo f2 = 0;     /*2和3,但是编译器把3优化掉了所以看不到,假如把2设为explicit,或者把3设为private:,都会报错,说明有一个先用2后3的带类型转化的过程*/ 
    Foo f3 = f1;    /*3*/
    Foo f4(f1);     /*3*/
    Foo f5 = Foo(); /*1和3,但是编译器把3优化掉了所以看不到*/
    Foo f6;         /*1*/
    f6 = f1;        /*4*/
    return 0;
}


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


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


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


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


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


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


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

编译器可以略过拷贝构造函数。(不是必须)。


string null_book = "9-999-99999-9";//拷贝初始化

编译器允许将上面的代码,改成下面的

string null_book("9-999-99999-9"); //编译器掠过了拷贝构造函数
虽然编译器掠过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。(比如说不能是private的)。


拷贝赋值运算符

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

析构函数:

构造函数初始化对象的非static数据成员,以及其他一些工作。析构函数释放对象使用的资源,并销毁对象的非static数据成员

析构函数不能被重载,一个类只能有一个析构函数。

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

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

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

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


什么时候调用析构函数:

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

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

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

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

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


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


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

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


13.1.4   三/五法则

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

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


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


如果一个类需要自定义析构函数,几乎可以可定它也需要自定义拷贝复制运算符和拷贝构造函数。


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

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

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


C++11还定义了移动构造函数和移动赋值函数。



13.1.5 使用=default

class Sales_data
{
    Sales_data() = default;
    Sales_data(const Sales_data&) = default;
    Sales_data& operator=(const Sales_data&);
    ~Saled_data() = default;
};
Sales_data: Sales_data::operator=(const Sales_data&) = default;
我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员),也就是说普通的成员函数不能用=default。


13.1.6 阻止拷贝

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

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

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


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


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

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


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

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

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

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

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

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

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

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

类值拷贝赋值运算符:

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


HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
    auto newp = new std::string(*rhs.ps);   //new并拷贝string
    delete ps;
    ps = newp;
    i = rhs.i;
    //delete newp;  //这里不能delete,否则ps指向的内存会被释放掉
    return *this;
}
赋值操作时,先用【右】操作数的指针指向的值初始化【左】操作数的string(这里要用一个临时变量newp),然后再释放掉【左】操作数原来指向的内存,最后【左】操作数的指针赋为newp的值。最后复制一些内置类型并返回*this。


13.2.2 定义行为像指针的类(例如:shared_ptr)

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


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

  1. 构造函数要创建计数,用来记录有多少对象和正在创建的对象共享状态。创建对象时,只有一个对象共享状态,计数器初始化为1。
  2. copy构造函数不分配新的计数器,而是copy指定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
  3. 析构函数递减计数器,指出共享状态的用户又少了一个。如果计数器变为0,则析构函数delete掉内存。
  4. 拷贝赋值运算符递增右侧对象的计数器,递减左侧运算对象的计数器。如果(原)左侧对象的计数器变为0,参照3。
我们可以通过将计数器保存在动态内存中。让多个对象共享计数器。

class HasPtr
{
public:
    HasPtr(const std::string& s = std::string()):
        ps(new std::string(s)), i(0), use(new std::size_t(1)) {}; //计数器置为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的成员。
};

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

HasPtr::~HasPtr()
{
    if(--*use == 0)  //递减计数器
    {
        delete ps;  //释放string内存
        delete use; //释放计数器内存
    }
}

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

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
    ++*rhs.use;    
    if(--*use == 0)//如果这步放在++*rhs.use;的右边的话,自赋值就会把自身(本不应被delete的内存delete掉了)
    {
        delete ps;
        delete use;
    }
    ps = rhs.ps;
    use = rhs.use;
    i = rhs.i;
    return *this;
}

13.3 交换操作

除了定义拷贝控制函数,管理资源的类通常还定义一个名为swap的函数。对于重排元素的算法,这个很有用。

交换两个HasPtr对象代码可能像下面这样:

HasPtr temp = v1;
v1 = v2;
v2 = temp;
这段代码将v1中原来的string拷贝了两次,理论上,这些拷贝都是不必要的。只需swap交换指针即可。即:

string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

编写自己的swap函数:

class HasPtr
{
    friend void swap(HasPtr&, HasPtr&);
}
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap(lhs.ps, rhs.ps);
    swap(lhs.i, rhs.i);
}
swap就是为了优化代码,所以声明为inline,在swap内部逐个交换内置类型,所以使用std::swap.

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


void swap(Foo &lhs, Foo &rhs)
{
    //假设Foo有类型为HasPtr的类成员
    //错误,使用了标准库的swap
    std::swap(lhs.h, rhs.h);
}

void swap(Foo &lhs, Foo &rhs)
{
    using std::swap;
    swap(lhs.h, rhs.h); //使用HasPtr版本的swap
}
先用using std;,再用swap的话,会优先使用HasPtr版本的swap,并不会被隐藏这与模板的匹配优先级有关,详见第16章(p616)以及p706.


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

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

rhs里的指针指向了*this里面指针原来指向的位置,函数结束之后自动销毁


使用拷贝和交换的赋值运算符自动就是【异常安全】的,且正确处理自赋值。

13.4 拷贝控制示例

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

};
save和remove成员:

void Message::save(Folder &f)
{
    //在Message中添加Folder的指针,然后在Folder中添加Message的指针

    folders.insert(&f);  //将给定Folder的指针添加到我们的Folder列表中
    f.addMsg(this);   
}
void Message::remove(Folder &f)
{
    folders.erase(&f);    //对应上面的insert
    f.remMsg(this);       //对应上面的addMsg
}



以下的函数是Message类的拷贝构造函数可能会用到的:将一Msg对象添加到另一个Msg对象的Folders中
void Message::add_to_Folders(const Message &m) //将*this对象添加到参数Msg所在的所有Folder中。
{
    for(auto f : m.folders)
        f->addMag(this);
}


Msg类的析构函数:

void Message::remove_from_Folders()//析构和赋值都会用到这个工具函数
{    for(auto i : folders)
        folders.remMsg(this);
}

Message::~Message()
{
    remove_from_Folders();
}
Msg类的拷贝构造函数:

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

Msg类的赋值运算符

Message& operator=(const Message& rhs)
{
    remove_from_Folders();
    folders = rhs.folders;
    add_to_Folders(rhs);
    contents = rhs.contents;
    return *this;
}

总结:

  1. 拷贝构造函数和赋值运算符都需要用到add_to_Folders。(在赋值运算符中对左侧运算符先remove_from_Folders(),删除左操作数原来的Folders,然后再add_to_Foldersa(右操作数),将右操作数的Folders添加到左操作数中)。
  2. 拷贝构造函数和析构函数都需要用到remove_from_Folders。(在赋值运算符中对左侧运算符先remove_from_Folders(),删除左操作数原来的Folders,然后再add_to_Foldersa(右操作数),将右操作数的Folders添加到左操作数中)。
swap():
void swap(const Message& lhs, const Message& rhs)
{
    //将每个Msg从原来的Folder中删除
    for(auto f : lhs.folder)
        f.remMsg(&lhs);
    for(auto f : rhs.folder)
        f.remMsg(&rhs);
    //交换Folders
    using std::swap;
    swap(lhs.content, rhs.content); //使用swap(string&, string&);
    swap(lhs.folders, rhs.folders); //使用swap(set&, set&);
    //将每个Msg添加到新的Folder中。
    for(auto f : lhs.folder)
        f.addMsg(&lhs);
    for(auto f : rhs.folder)
        f.addMsg(&rhs);
}

13.5 动态内存管理类——以简化的vector类为例。
StrVec类的设计:
//****************************************

****************************************//

13.6 对象移动

在某些情况下,对象拷贝后马上就被销毁了,所以移动而非拷贝对象会大幅度提高性能。

使用移动而不是拷贝的另一个原因是源于IO类或unique_ptr这样的类。他们包含不能被共享的资源(指针或IO缓冲)。因此,这些类型不能被拷贝但能被移动。

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


13.6.1  右值移动

为了支持移动操作,新标准引入了一种新的引用类型——右值引用(rvalue referrece),就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。

右值引用只能绑定到一个将要销毁的对象。

可以绑定到临时对象的引用:左值const引用、右值引用

左值持久,右值短暂。

左值有持久的状态,右值要么是字面常量,要么是表达式求值过程中创建的临时对象。

由于右值引用只能绑定到临时对象,我们得知:

  • 所引用的对象将要被销毁
  • 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自身自由的接管所引用的对象的资源。
变量可以看作只有一个运算对象而没有运算符的表达式。


一个右值引用一旦绑定之后,就成了一个左值。如:

int && rr1 = 666; //正确

int &&rr2 = rr1; //错误,变量rr1是左值。

变量是左值!!!!!不能把右值引用绑定到变量上,即使这个是右值引用类型的变量。


我们可以用标准库中的std::move()来获取【某个左值】的【右值引用】。

int && rr1 = 666; //正确

int &&rr2 = rr1; //错误,变量rr1是左值。

int &&rr3 = std::move(rr2); //

调用move之后,除了对rr1赋值和销毁它之外,将不能再使用它。不能对他的值做任何假设。

我们可以销毁一个移后源对象,也可以赋予他新值,但不能使用一个移后源对象的值。就是说给他赋新值之前不能使用它


对move我们不提供using 声明。我们直接调用std::move() 而不是move,这样做可以避免潜在的名字冲突。原因在18.2.3节 p707中解释。


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

移动构造函数和移动赋值运算符类似相应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。


移动构造函数的第一个参数是该类类型的一个引用(拷贝构造函数也是一样),而且其他参数都必须有默认值(拷贝构造函数也是一样)。

不同于拷贝构造函数的是:这个引用参数在移动构造函数中是一个右值引用。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值