持续创作,加速生长!这是我参加「日新方案 10 月更文挑战」的第9天,点击检查活动概况

class目标的初始化

咱们有一个class Data, 里边有一个int m_d 变量,存储一个整数。

class Data
{
    int m_i;
    public:
    void print()
    {
        std::cout << m_i << std::endl;
    }
};

咱们假如需求一个Data类的目标的话,能够这样写:

void test()
{
    Data d;
    d.print(); // 打印内部的变量 m_i
}

看到这儿,应该能发现问题,虽然 d 变量现已实例化了,可是,咱们好像没有在初始化的时分指定内部m_i究竟是什么值。

有没有一种或许性,咱们并没有将 d 所引证的内存变成一个能够运用的状况。

比如说,这儿提一个事务需求,内部的m_i只能是奇数。

而上述代码中的变量d所引证的内存中的m_i究竟是什么数,是不知道的,有或许你的编译器将m_i的初始值设置成了0,但这是于事无补的,因为咱们的事务需求是:

  • m_i 有必要是奇数

所有用到d的当地,都会有这个假定,所以假如在初始化d的时分,没有保证这个m_i是奇数的话,那么后续的所有事务逻辑全部都会崩溃。

说了这么多,实际上便是想道明一句话:

  • 想要运用一个类目标,先进行初始化,这个目标的内存变成一个合法的状况

合法的状况大部分跟事务逻辑相关,比如上面的m_i有必要是奇数

constructor 结构器

目标在实例化的时分,大抵有这么两步:

  • 分配内存:这儿分栈和堆,又名主动分配内存(函数栈主动张开)和手动(运用new操作符在堆上请求)
  • 填充内存

分配好的内存,简直都是混沌的,彻底不知道里边存的数据是什么,所以需求第二步填充内存,使得这块内存变成合法的

而 constructor 的最大责任便是这个。(翻开文件,翻开数据库,或者网络连接也能在这儿边干)

这意思便是,constructor 履行的时机一定是在内存现已预备好了的时分。

拿上面的比如,咱们这样来保证一个合法的m_i:

class Data
{
    int m_i;
    public:
    Data(int i): m_i{i} // 变量m_i初始化
    {}
};
void test()
{
    Data d{3};// 这儿保证了变量 m_i 为 3
}

或许不想在初始化的非要想一个合法值传给m_i,咱们能够搞一个默许constructor:

class Data
{
    int m_i;
    public:
    Data():m_i{1}
    {}
};
void test()
{
    Data d{}; // 这儿不用填参数
}

constructor overload 结构器重载

constructor的方式有许多,可是它本质上便是一个函数,在初始化的时分会调用罢了。

只要是函数,那么就能够依照一般的函数的重载规矩进行重载。

上面的比如现已说明了这个用法

    Data() : m_i{1}        // 不带参数
    Data(int i) : m_i{i}   // 带了一个int参数 i

所以一个类该有什么样的constructor,由事务逻辑自己决定。

copy constructor 复制结构器

仍是上面的Data的比如:

void test
{
    Data d1{5};   调用 Data(int i) 进行初始化
    Data d2{d1}; // 这个是啥?????
}

从写法上来看,咱们能够猜测到,d2.m_i 应该复制自 d1.m_i, 所以最后的结果是 5。

这没问题的,可是咱们前面说了,初始化一定是调用了某个constructor,那么这儿是调用的哪个constructor呢?

答案是:

Data(const Data& other);

形如这样的参数是这样的constructor,还特意起了个姓名:copy constructor, 也便是复制结构器

这个函数承受一个参数,咱们起了个名叫other,所以一看就理解了,这个other便是咱们想要复制的目标。

这个constructor,咱们并没有手动供给,所以这是编译器主动给咱们加上去的。

你或许会问,编译器怎么知道这个函数内部应该怎样实现?

对啊,编译器不知道,他对咱们的事务逻辑以及合法性一无所知,所以,编译器只能供给一个比较根底的功能:

Data类里只要一个m_i, 所以这儿编译器供给的这个constructor,便是做了大概这样的工作:

class Data
{
    int m_i;
    public:
    Data(const Data& other):m_i{other.m_i}
    {}
};

像m_i这种根底类型,便是直接复制了。那假如Data类内部有class类型的变量呢:

class Foo
{
    int m_i;
};
class Data
{
    Foo m_f;
};

从方式上看,编译器给咱们供给的默许的复制结构器,应该是这样的:

class Data
{
    Foo m_f;
    public:
    Data(const Data& other):m_f{other.m_f}
    {}
};

虽然m_f不是根本类型的变量,可是方式上来看,和根本变量是共同的。

有必要提一下:

m_f{other.m_f}

这句,实际上继续调用了Foo类的复制结构,所以到这儿,那便是Foo类的工作了,与Data类无关了。

总之:

  • 复制结构器,便是一个一般的结构器,接收一个参数const T &
  • 复制结构器,能够让咱们新产生的目标去复制一个已有的老目标,进行初始化
  • 假如咱们不供给一个复制结构器,那么编译器会给咱们搞一个默许的,逐一成员复制的,复制结构器

复制结构器的调用时机

上面现已说过一种:

Data d1{};
Data d2{d1} // 这儿会调用复制结构器

事实上,还有其他时分,复制结构器会被调用,那便是函数的传参,和返回值。


class Data{}; // 内部省掉
void foo(Data d)
{
    // 一些逻辑
}
void test()
{
    Data d1{};
    foo(d1); // 这一句调用了复制结构器
}

函数传参的时分,假如是值类型参数,那么会调用复制结构器。

再来看看函数返回值:

class Data{}; // 内部省掉
Data getData()
{
    Data d1{};
    return d1; // 这儿也是调用复制结构器
}
void test()
{
    Data d{getData()}; // 这儿依然调用了复制结构器
}

从理论上来看,上面的 Data d{getData()} 这一句应该调用两次复制结构

  • 榜首次是函数getData内部的一个部分d1,复制给了一个暂时匿名变量
  • 第2次是这个暂时匿名变量复制给了变量d

可是假如你在复制结构器里加上打印,你会发现,没有任何东西会打印出来,也便是说,压根就没有调用到复制结构器。

这不代表上面关于函数的说法是错的,这仅仅编译器的优化罢了,因为来来回回的复制,实在是没有必要,所以在某些编译器以为能够的情况下,编译器就直接省了。这个不重要,就不具体往里边细说规矩了。

自定义复制结构器

大部分时分,编译器生成的这个复制结构器就满足需求了。

可是,假如咱们的class包含了动态资源,比如说一个堆上动态的int数组, 默许的复制结构器就没那么好用了:

class Data
{
    int m_size; // 数组的元素个数
    int* m_ptr; // 指向数组首元素的指针
    public:
    Data(int size):m_size{size}
    {
        if (size > 0)
        {
            m_ptr = new int[size]{};
        }
    }
    ~Data()
    {
        delete[] m_ptr;
    }
};

由于这个Data类,拥有一个动态的数组,所以咱们供给了一个析构函数,省的这块内存不会被收回。

然后,咱们没有供给一个复制结构器,所以编译器就给咱们增加了一个:

class Data
{
    // 疏忽其他代码,现在只关注复制结构器
    Data(const Data& other):m_size{other.m_size}, m_ptr{other.m_ptr}
    {}
};
void test()
{
    Data d1{10}; // 榜首句
    Data d2{d1}; // 第二句
}

没什么悬念,便是依照成员,逐一复制,留意,连指针也是直接复制。

所以上述test函数中,第二句履行了之后,整个内存应该是这样的:

掌握C++ copy-and-swap 语义

这有问题吗?

有很大的问题,考虑一下test函数履行完毕前,是不是需求对这两个变量 d1,d2d1, d2 进行析构。

你会发现,两次析构,delete 的资源是一份!!!

一份资源,被delete两次,这便是所谓double free问题。

还有其他问题吗?

有。考虑下面的代码:

void foo(Data d)
{
    // 一些逻辑
}
void test()
{
    Data d1{10};
    foo(d1);
    //
}

上面代码里,foo履行完之前,会析构这个部分变量d!导致资源现已被delete!

而外面d1和里边的d,指向的是同一份资源,也便是说,foo履行完之后,d1.m_ptr 成为了一个悬挂指针!

没办法了,只能靠自己定义复制结构器,来处理上面的问题了:

class Data
{
    int m_size; // 动态数组的元素个数
    int* m_ptr; // 指向数据的指针
    public:
    Data(const Data& other){
        if(other.m_ptr)
        {
            auto temp_ptr { new int[other.m_size]};
            std::copy(other.m_ptr, other.m_ptr + other.m_size, temp_ptr);
            m_ptr = temp_ptr;
            m_size = other.m_size;
        }
        else
        {
            m_ptr = nullptr;
        }
    }
};

上面的复制结构器,才是真实的复制,这种复制一般称之为深复制

进行深复制之后,新目标和老目标,各自都有一份资源,不会再有任何粘连了。

复制赋值,copy assignment

想要完成深复制,到现在只进行了一半。

剩下的一般便是重载一个操作符,operator=,这是用来处理如下方式的复制:

Data d1{10};
Data d2{2};
///
d2 = d1;

这儿,两个变量 d1,d2d1, d2 都自己进行了初始化,在经过一堆代码逻辑之后,此刻咱们的需求是:

  • 铲除 d2 的数据
  • 将 d1 完好的复制给 d2

两个类目标之间用赋值操作符,其实是调用了一个成员函数:operator=

对,这玩意虽然是操作符,可是操作符本质上也仍是函数,这个函数的姓名便是operator=

仍是相同的,假如咱们不供给一个自定义的operator=, 那么编译器会给咱们增加一个如下的:

class Data
{
    int m_size;
    int* m_ptr;
    public:
    Data(int size):m_size{size} // 一般结构器
    {
        if (size > 0)
        {
            m_ptr = new int[size]{};
        }
    }
    Data(const Data& other) // 复制结构器
    {
        if(other.m_ptr)
        {
            auto temp_ptr { new int[other.m_size]};
            std::copy(other.m_ptr, other.m_ptr + other.m_size, temp_ptr);
            m_ptr = temp_ptr;
            m_size = other.m_size;
        }
        else
        {
            m_ptr = nullptr;
        }
    }
    ~Data()               // 析构
    {
        delete[] m_ptr;
    }
    ///////// 编译器主动增加的 operator=
    Data& operator=(const Data& other)
    {
        m_size = other.m_size;
        m_ptr = other.m_ptr;
        return *this;
    }
};

看这个编译器主动增加的operator=, 是清楚明了能发现问题的:

  • 本身的m_ptr指向的内存永远无法收回了

自定义 operator=

仍是得靠自己来编写 operator=

前方警告,总算要点题了,copy and swap 即将呈现。

先依照咱们的思路来写一个:

Data& operator=(const Data& other)
{
    // 1. 首要铲除本身的资源
    delete[] m_ptr;
    // 2. 复制other的资源
    m_size = other.m_size;
    if (other.m_ptr)
    {
        m_ptr = new int[m_size];
        std::copy(other.m_ptr, other.m_ptr+m_size, m_ptr);
    }
    return *this;
}

假如依照上面的代码,来看下面的test函数,会产生什么问题:

void test()
{
    Data d1{10};
    d1 = d1; // 自己赋值给自己
}

咱们在operator=里边看见,上来直接把整个资源删除了,GG!

咱们要加一个判别:

Data& operator=(const Data& other)
{
    if (this == &other) // 加了一个判别
    {
        return *this;
    }
    // 1. 首要铲除本身的资源
    delete[] m_ptr;
    // 2. 复制other的资源
    m_size = other.m_size;
    if (other.m_ptr)
    {
        m_ptr = new int[m_size]; // 这句有或许反常
        std::copy(other.m_ptr, other.m_ptr+m_size, m_ptr);
    }
    return *this;
}

关于这儿加不加判别,许多大师级人物也以为不该加:

  • 谁会写出这种 d1 = d1; 这种代码???加了判别,徒增烦恼罢了。

再来看上面注释那个, new 在请求新的内存的时分,或许会产生反常,此刻呈现了一个问题,在文章开头提及的:

  • 内存合法性

m_size 现已复制过来了
而真实的数据没有复制过来,导致这两个变量,不满足咱们的事务合法性。

所以再改改:

Data& operator=(const Data& other)
{
    // 1. 首要铲除本身的资源
    delete[] m_ptr;
    m_ptr = nullptr;
    // 2. 复制other的资源
    auto temp_size {other.m_size};
    if (other.m_ptr)
    {
        m_ptr = new int[temp_size];
        std::copy(other.m_ptr, other.m_ptr+temp_size, m_ptr);
        m_size = temp_size;
    }
    return *this;
}

此刻此刻,这个代码现已没啥大问题了,除了相同:

  • 代码重复了,咱们发现在复制other的数据的时分,逻辑是和复制结构器是一模相同的

c++里有一个准则:DRY: DonotRepeatYourself

别写重复的代码!

所以接着往下,copy-and-swap正式出场:

copy-and-swap 语义

  • 首要copy便是指复制结构器

咱们先来讲讲swap是个啥。

便是说,咱们需求写一个函数swap,如下:

class Data
{
    // 其余部分省掉,将重点放在swap函数
    friend void swap(Data &left, Data& right)
    {
        std::swap(left.m_size, right.m_size);
        std::swap(left.m_ptr, right.m_ptr);
    }
};

这个swap函数很简单,便是交流两个已有的Data目标的内部数据,仅此罢了。

现在,

  • copy有了
  • swap有了

让咱们写出最终极的operator=:

Data& operator=(Data other)
{
    swap(*this, other);
    return *this;
}

是不是惊呆了,就这么两句,就行了!

细心领略一下这个写法的高深之处:

  • 函数传参,用的值传参,而非引证,所以此刻会调用复制结构器(copy)
  • 函数内部,交流了当时目标,和部分暂时变量other的数据(swap)

你或许会问,没有铲除本身的资源啊???

留意,other 是一个部分暂时变量,这个函数完毕之前,会进行析构,而析构的时分,other身上现已是被交流过的了,所以other被析构的时分,便是本身资源铲除的时分。

妙,妙,妙!!

用如此短的代码实现了operator=, 实在是妙~