返回顶部
首页 > 资讯 > 后端开发 > 其他教程 >C++语义copyandswap示例详解
  • 667
分享到

C++语义copyandswap示例详解

C++语义copyandswapC++语义 2022-11-13 19:11:37 667人浏览 泡泡鱼
摘要

目录class对象的初始化constructor 构造器constructor overload 构造器重载copy constructor 拷贝构造器拷贝构造器的调用时机自定义拷贝

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,拷贝给了一个临时匿名变量
  • 第二次是这个临时匿名变量拷贝给了变量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函数中,第二句执行了之后,整个内存应该是这样的:

image.png

这有问题吗?

有很大的问题,考虑一下test函数执行完毕前,是不是需要对这两个变量 d1,d2d1, 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, 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: Do not Repeat Yourself。

别写重复的代码!

所以接着往下,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=, 实在是妙~

以上就是C++语义copy and swap示例详解的详细内容,更多关于C++语义copy and swap的资料请关注编程网其它相关文章!

--结束END--

本文标题: C++语义copyandswap示例详解

本文链接: https://lsjlt.com/news/170589.html(转载时请注明来源链接)

有问题或投稿请发送至: 邮箱/279061341@qq.com    QQ/279061341

猜你喜欢
  • C++语义copyandswap示例详解
    目录class对象的初始化constructor 构造器constructor overload 构造器重载copy constructor 拷贝构造器拷贝构造器的调用时机自定义拷贝...
    99+
    2022-11-13
    C++语义copy and swap C++语义
  • go语言的变量定义示例详解
    目录前言定义单个变量定义多个变量定义相同类型的多个变量变量的初始化变量类型的省略var关键字的省略(简短声明)全局变量与局部变量特别的变量名未使用变量的限制常量前言 特别说明: 本文...
    99+
    2022-12-20
    go语言变量定义 go 变量
  • C语言指针教程示例详解
    目录指针内存指针类型指针运算二级指针指针数组指针 指针提供了对地址操作的一种方法,因此,使用指针可使得 C 语言能够更高效地实现对计算机底层硬件的操作。另外,通过指针可以更便捷地操作...
    99+
    2024-04-02
  • C语言实现栈的示例详解
    目录前言一. 什么是栈二. 使用什么来实现栈三. 栈的实现3.1 头文件3.2 函数实现3.3 完整代码四. 栈的用处前言 前一段时间,我们试着用C语言实现了数据结构中的顺序表,单链...
    99+
    2024-04-02
  • C语言实现阶乘的示例详解
    目录前言1.阶乘实现1.1理论步骤1.2实践结果2.连续乘层相加实现2.1理论步骤2.2实践结果前言 在现实中,我们做数学题总会遇到阶乘问题,这在计算机中也不例外。 那我们应该怎么实...
    99+
    2024-04-02
  • C语言实现队列的示例详解
    目录前言一. 什么是队列二. 使用什么来实现栈三. 队列的实现3.1头文件3.2 函数的实现四.完整代码前言 前一段时间,我们试着用C语言实现了数据结构中的顺序表,单链表,双向循环链...
    99+
    2024-04-02
  • C++预定义的流对象基本示例详解
    目录C++预定义的流对象示例说明总结:C++预定义的流对象 C++预定义的流对象是可用于输入和输出的数据流向对象。它们是在C++语言中内置的,可以使用标准库的iostream头文件来...
    99+
    2023-05-16
    C++预定义流对象 C++ 流对象
  • C#实现自定义ListBox背景的示例详解
    目录实践过程效果代码实践过程 效果 代码 public partial class DrawListBox : ListBox { public Draw...
    99+
    2022-12-16
    C#自定义ListBox背景 C# ListBox背景 C# ListBox
  • MySQL教程数据定义语言DDL示例详解
    目录1.SQL语言的基本功能介绍2.数据定义语言的用途3.数据库的创建和销毁4.数据库表的操作(所有演示都以student表为例)1)创建表2)修改表3)销毁表如果你是刚刚学习MyS...
    99+
    2024-04-02
  • C语言转义字符详解
    ####1.认识转义字符 所有的ASCII码都可以用“\”加数字(一般是8进制数字)来表示。而C中定义了一些字母前加""来表示常见的那些不能显示的ASCII字符,如\0,\t,\n...
    99+
    2024-04-02
  • C语言进阶栈帧示例详解教程
    目录正片开始栈有什么用?寄存器main函数创建局部变量创建函数部分形参与实参正片开始 今天来讲讲我对栈帧创建与销毁的拙见。理解什么是栈帧首先知道什么是栈: 在数据结构中, 栈是限定仅...
    99+
    2024-04-02
  • +=在C语言中的作用及示例详解
    +=运算符在c语言中是一个复合赋值运算符,它将变量的值与其自身加上一个给定值相加,从而修改变量的值。使用方法:将变量 += 常量/变量/表达式;,其中变量是可以修改的值,常量是不可修改的...
    99+
    2024-04-03
    c语言 += 编译错误
  • Android 自定义ListView示例详解
    本文讲实现一个自定义列表的Android程序,程序将实现一个使用自定义的适配器(Adapter)绑定 数据,通过contextView.setTag绑定数据有按钮的ListVi...
    99+
    2022-06-06
    listview Android
  • C#实现自定义动画鼠标的示例详解
    目录实践过程效果代码实践过程 效果 代码 public partial class Form1 : Form { public Form1() { ...
    99+
    2022-12-20
    C#自定义动画鼠标 C# 动画鼠标 C# 鼠标
  • C++ LeetCode542矩阵示例详解
    目录LeetCode  542.01 矩阵方法一:广度优先搜索AC代码C++LeetCode  542.01 矩阵 力扣题目链接:leetcode.cn/pro...
    99+
    2022-12-16
    C++ LeetCode矩阵 C++ LeetCode题解
  • C++多态的示例详解
    目录案例一:计算器案例要求代码实现运行效果案例二:制作饮品案例要求代码实现运行效果案例三:电脑组装案例要求代码实现运行效果今天就以三个案例来把C++多态的内容结束。第一个案例就是用多...
    99+
    2024-04-02
  • C++BoostBimap示例详细讲解
    目录一、提要二、示例练习一、提要 库 Boost.Bimap 基于 Boost.MultiIndex 并提供了一个无需先定义即可立即使用的容器。该容器类似于 std::map,但支持...
    99+
    2022-11-13
    C++ Boost Bimap C++ Bimap库
  • 示例详解C++语言中的命名空间 (namespace)
    目录前言1. 命名空间 2. using 指令 3. 不连续的命名空间 4. 嵌套的命名空间 5. 命名空间内变量、函数、全局变量的作用域5.1 using namespace fi...
    99+
    2024-04-02
  • C语言学习之关键字的示例详解
    目录1. 前言2. 什么是关键字3. extern-声明外部符号4. auto-自动5. typedef-类型重定义(类型重命名)6. register-寄存器6.1 存储器6.2 ...
    99+
    2022-11-13
    C语言 关键字
  • C语言实现单元测试的示例详解
    目录前沿使用前提测试框架如下测试方法编写文件验证前沿 单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际...
    99+
    2024-04-02
软考高级职称资格查询
编程网,编程工程师的家园,是目前国内优秀的开源技术社区之一,形成了由开源软件库、代码分享、资讯、协作翻译、讨论区和博客等几大频道内容,为IT开发者提供了一个发现、使用、并交流开源技术的平台。
  • 官方手机版

  • 微信公众号

  • 商务合作