C++ 类和对象入门(三):拷贝构造、赋值运算符重载和深浅拷贝

C++ 类和对象入门(三):拷贝构造、赋值运算符重载和深浅拷贝


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 《C语言基础》 《数据结构》 《机器学习导论》 《前端基础》 《python基础》 ✨ 数据即知识,压缩即智能


目录

  • [C++ 类和对象入门(三):拷贝构造、赋值运算符重载和深浅拷贝](#C++ 类和对象入门(三):拷贝构造、赋值运算符重载和深浅拷贝)
    • 前言
    • 一、拷贝构造函数是什么?
      • [1.1 拷贝构造的基本概念](#1.1 拷贝构造的基本概念)
      • [1.2 为什么参数必须用引用?](#1.2 为什么参数必须用引用?)
    • 二、什么时候会调用拷贝构造?
      • [2.1 用已有对象初始化新对象](#2.1 用已有对象初始化新对象)
      • [2.2 另一种初始化写法](#2.2 另一种初始化写法)
      • [2.3 传值传参](#2.3 传值传参)
      • [2.4 传值返回](#2.4 传值返回)
    • 三、默认拷贝构造会做什么?
      • [3.1 对 Date 这种类一般够用](#3.1 对 Date 这种类一般够用)
      • [3.2 对 Stack 这种类不够用](#3.2 对 Stack 这种类不够用)
    • 四、浅拷贝的问题
      • [4.1 两个对象共用一块资源](#4.1 两个对象共用一块资源)
      • [4.2 重复释放会导致程序崩溃](#4.2 重复释放会导致程序崩溃)
    • 五、深拷贝怎么写?
      • [5.1 Stack 的拷贝构造](#5.1 Stack 的拷贝构造)
    • 六、一个简单判断技巧
      • [6.1 什么时候需要自己写拷贝构造?](#6.1 什么时候需要自己写拷贝构造?)
    • 七、赋值运算符重载是什么?
      • [7.1 它和拷贝构造很像,但不是一回事](#7.1 它和拷贝构造很像,但不是一回事)
      • [7.2 拷贝构造和赋值的核心区别](#7.2 拷贝构造和赋值的核心区别)
    • 八、赋值运算符重载怎么写?
      • [8.1 Date 的赋值运算符重载](#8.1 Date 的赋值运算符重载)
      • [8.2 参数为什么用 const 引用?](#8.2 参数为什么用 const 引用?)
      • [8.3 为什么返回 Date&?](#8.3 为什么返回 Date&?)
      • [8.4 为什么检查自赋值?](#8.4 为什么检查自赋值?)
    • 九、赋值运算符也会遇到深浅拷贝问题
      • [9.1 Stack 的赋值不能只拷贝指针](#9.1 Stack 的赋值不能只拷贝指针)
    • 十、运算符重载的基本认识
      • [10.1 为什么需要运算符重载?](#10.1 为什么需要运算符重载?)
      • [10.2 运算符重载的基本形式](#10.2 运算符重载的基本形式)
    • 十一、运算符重载的几个规则
      • [11.1 不能创造新运算符](#11.1 不能创造新运算符)
      • [11.2 有些运算符不能重载](#11.2 有些运算符不能重载)
      • [11.3 至少有一个参数是类类型](#11.3 至少有一个参数是类类型)
      • [11.4 运算符重载要有意义](#11.4 运算符重载要有意义)
    • 十二、本文总结

前言

上一篇我们讲了两个非常重要的默认成员函数:

  • 构造函数
  • 析构函数

构造函数负责对象创建时初始化。

析构函数负责对象销毁前清理资源。

这一篇继续讲另外两个更容易出问题的默认成员函数:

  • 拷贝构造函数
  • 赋值运算符重载

这两个函数都和"对象拷贝"有关。

这一篇的目标是讲清楚三件事:

第一,什么时候调用拷贝构造?

第二,什么时候调用赋值运算符重载?

第三,为什么有资源管理的类不能依赖默认浅拷贝?


一、拷贝构造函数是什么?

1.1 拷贝构造的基本概念

拷贝构造函数本质上也是构造函数的一种。

它的作用是:

用一个已经存在的同类型对象,初始化一个新对象。

基本写法:

cpp 复制代码
class Date
{
public:
    Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

private:
    int _year;
    int _month;
    int _day;
};

这里:

cpp 复制代码
Date(const Date& d)

就是拷贝构造函数。

它的参数通常写成:

cpp 复制代码
const Date& d

也就是当前类类型的常引用。


1.2 为什么参数必须用引用?

错误写法:

cpp 复制代码
Date(Date d)
{
    _year = d._year;
    _month = d._month;
    _day = d._day;
}

这会有问题。

原因是:

传值传参本身就需要拷贝对象,而拷贝对象又要调用拷贝构造。

也就是说:

调用拷贝构造需要传参。

传参又要拷贝。

拷贝又要调用拷贝构造。

于是会形成无穷递归。

所以拷贝构造的参数必须使用引用。

通常还会加 const

cpp 复制代码
Date(const Date& d)

这样既避免拷贝,又保证不会修改被拷贝对象。


二、什么时候会调用拷贝构造?

2.1 用已有对象初始化新对象

最典型场景:

cpp 复制代码
Date d1(2024, 4, 14);
Date d2(d1);

这里 d2 是一个新对象。

它用 d1 来初始化。

所以调用拷贝构造。


2.2 另一种初始化写法

下面这种写法也是拷贝构造:

cpp 复制代码
Date d3 = d1;

虽然中间出现了 =,但这里不是赋值运算符重载。

因为 d3 是一个正在创建的新对象。

所以这句话的本质是:

用 d1 初始化 d3。

调用的是拷贝构造。


2.3 传值传参

如果函数参数按值接收对象,也会调用拷贝构造。

cpp 复制代码
void Func(Date d)
{
    d.Print();
}

Date d1(2024, 4, 14);
Func(d1);

调用 Func(d1) 时,需要把 d1 拷贝一份给形参 d

因此会调用拷贝构造。

如果对象比较大,或者拷贝成本比较高,通常更推荐传引用:

cpp 复制代码
void Func(const Date& d)
{
    d.Print();
}

这样不会产生对象拷贝。


2.4 传值返回

函数按值返回对象时,也可能涉及拷贝构造。

cpp 复制代码
Date Func()
{
    Date tmp(2024, 4, 14);
    return tmp;
}

现代编译器可能会做返回值优化,但从语法理解上,传值返回和对象拷贝关系很密切。


三、默认拷贝构造会做什么?

3.1 对 Date 这种类一般够用

对于:

cpp 复制代码
class Date
{
private:
    int _year;
    int _month;
    int _day;
};

成员都是内置类型,没有指向额外资源。

编译器自动生成的拷贝构造会按成员逐个拷贝。

例如:

cpp 复制代码
Date d2(d1);

会把:

cpp 复制代码
d1._year  -> d2._year
d1._month -> d2._month
d1._day   -> d2._day

这对 Date 来说通常是够用的。

所以 Date 类可以不自己写拷贝构造。


3.2 对 Stack 这种类不够用

再看 Stack

cpp 复制代码
class Stack
{
private:
    int* _a;
    size_t _capacity;
    size_t _top;
};

如果编译器默认拷贝:

cpp 复制代码
Stack st2 = st1;

它会把 _a 的值也复制过去。

注意,_a 是一个指针。

复制指针的值,复制的是地址。

结果就是:

cpp 复制代码
st1._a 和 st2._a 指向同一块空间

这就是浅拷贝。


四、浅拷贝的问题

4.1 两个对象共用一块资源

假设:

cpp 复制代码
Stack st1;
st1.Push(1);
st1.Push(2);

Stack st2 = st1;

如果使用默认拷贝构造,可能出现:

cpp 复制代码
st1._a == st2._a

也就是说,两个栈对象指向同一块数组空间。

这会导致两个问题。

第一,一个对象修改数据,另一个对象也受影响。

第二,析构时同一块空间会被释放两次。


4.2 重复释放会导致程序崩溃

当函数结束时,st1st2 都会调用析构函数。

如果它们的 _a 指向同一块空间:

cpp 复制代码
free(st1._a);
free(st2._a);

同一块空间被释放两次,程序很可能崩溃。

所以对于管理资源的类,默认浅拷贝通常是不够的。


五、深拷贝怎么写?

5.1 Stack 的拷贝构造

对于 Stack,正确思路是:

不仅要拷贝指针变量本身,更要重新申请一块空间,把原空间中的数据复制过去。

示例:

cpp 复制代码
Stack(const Stack& st)
{
    _a = (int*)malloc(sizeof(int) * st._capacity);
    if (_a == nullptr)
    {
        perror("malloc fail");
        return;
    }

    memcpy(_a, st._a, sizeof(int) * st._top);
    _top = st._top;
    _capacity = st._capacity;
}

这样 st1st2 各自拥有独立空间。

cpp 复制代码
st1._a != st2._a

但是它们保存的数据内容一样。

这就是深拷贝。


六、一个简单判断技巧

6.1 什么时候需要自己写拷贝构造?

可以先记一个经验规则:

如果一个类需要自己写析构函数释放资源,那么它通常也需要自己写拷贝构造。

原因很简单。

你需要自己写析构,说明这个类内部大概率管理了资源。

既然管理了资源,就要考虑对象拷贝时资源怎么处理。

比如 Stack

  • 构造函数申请资源;
  • 析构函数释放资源;
  • 拷贝构造必须深拷贝资源。

Date

  • 没有动态资源;
  • 默认拷贝即可;
  • 不需要自己写析构和拷贝构造。

七、赋值运算符重载是什么?

7.1 它和拷贝构造很像,但不是一回事

赋值运算符重载用于:

两个已经存在的对象之间进行赋值。

例如:

cpp 复制代码
Date d1(2024, 4, 14);
Date d2(2025, 1, 1);

d1 = d2;

这里 d1d2 都已经存在。

所以调用的是赋值运算符重载,而不是拷贝构造。


7.2 拷贝构造和赋值的核心区别

看两行代码:

cpp 复制代码
Date d2 = d1;
d2 = d1;

第一行:

cpp 复制代码
Date d2 = d1;

d2 正在创建。

所以是拷贝构造。

第二行:

cpp 复制代码
d2 = d1;

d2 已经存在。

所以是赋值运算符重载。


八、赋值运算符重载怎么写?

8.1 Date 的赋值运算符重载

对于 Date 类,可以这样写:

cpp 复制代码
Date& operator=(const Date& d)
{
    if (this != &d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

    return *this;
}

这里有几个细节。


8.2 参数为什么用 const 引用?

cpp 复制代码
const Date& d

原因有两个:

第一,避免传值导致拷贝。

第二,保证函数内部不会修改右侧对象。


8.3 为什么返回 Date&?

返回引用是为了支持连续赋值:

cpp 复制代码
d1 = d2 = d3;

这个表达式会从右往左执行。

如果 d2 = d3 返回的是 d2 本身,那么 d1 = d2 才能继续执行。

所以赋值运算符通常返回:

cpp 复制代码
return *this;

*this 表示当前对象本身。


8.4 为什么检查自赋值?

cpp 复制代码
if (this != &d)

这是为了处理这种情况:

cpp 复制代码
d1 = d1;

对于 Date 这种简单类,不检查也通常没问题。

但对于管理资源的类,比如 Stack,自赋值如果处理不当,可能先释放自己的资源,再从自己已经释放的资源里拷贝数据。

所以写赋值运算符时,养成自赋值检查习惯是好的。


九、赋值运算符也会遇到深浅拷贝问题

9.1 Stack 的赋值不能只拷贝指针

对于 Stack

cpp 复制代码
st1 = st2;

如果只是简单把 _a 的值复制过去,就又会出现两个栈共用一块空间的问题。

所以 Stack 的赋值运算符也应该做深拷贝。

这和拷贝构造的核心原因一样:

指针成员如果指向资源,不能只复制指针值。


十、运算符重载的基本认识

10.1 为什么需要运算符重载?

C++ 允许我们给自定义类型重新定义某些运算符的含义。

比如:

cpp 复制代码
Date d1(2024, 4, 14);
Date d2(2024, 4, 25);

我们希望可以直接写:

cpp 复制代码
d1 < d2
d1 == d2
d1 + 100
d2 - d1

这些写法对内置类型很自然。

对于自定义类型,编译器不知道该怎么比较、怎么加减。

所以我们需要通过运算符重载告诉编译器:

对 Date 类型来说,这个运算符应该怎么工作。


10.2 运算符重载的基本形式

运算符重载函数的名字由两部分组成:

cpp 复制代码
operator

加上要重载的运算符。

例如:

cpp 复制代码
operator==
operator+
operator-
operator=
operator++

示例:

cpp 复制代码
bool operator==(const Date& d)
{
    return _year == d._year
        && _month == d._month
        && _day == d._day;
}

当我们写:

cpp 复制代码
d1 == d2;

编译器会转换成类似:

cpp 复制代码
d1.operator==(d2);

十一、运算符重载的几个规则

11.1 不能创造新运算符

不能写:

cpp 复制代码
operator@

因为 C++ 语法中没有 @ 这个运算符。

运算符重载只能重载已有的运算符。


11.2 有些运算符不能重载

常见不能重载的运算符包括:

cpp 复制代码
.*
::
sizeof
?:
.

11.3 至少有一个参数是类类型

不能通过运算符重载改变内置类型的含义。

例如:

cpp 复制代码
int operator+(int x, int y)
{
    return x - y;
}

这种写法是不允许的。

因为它试图改变两个 int 相加的含义。

C++ 不允许这样做。


11.4 运算符重载要有意义

不是所有运算符都适合给所有类重载。

比如 Date 类重载这些就很有意义:

cpp 复制代码
<
==
+
-
++
--

但如果你给日期重载:

cpp 复制代码
operator*
operator/

就比较奇怪。

所以运算符重载的原则是:

让代码更自然,而不是让代码更魔幻。


十二、本文总结

这一篇主要讲了拷贝构造、赋值运算符重载和运算符重载基础。

拷贝构造:

  • 用已有对象初始化新对象;
  • 参数必须使用当前类类型的引用;
  • 常写成 const 类名&
  • 传值传参、传值返回可能触发拷贝构造。

赋值运算符重载:

  • 用于两个已经存在的对象之间赋值;
  • 通常返回当前类类型引用;
  • 参数通常用 const 类名&
  • 要考虑自赋值问题。

深浅拷贝:

  • 没有资源管理的类,默认拷贝通常够用;
  • 管理动态资源的类,默认浅拷贝容易出问题;
  • 如果写了析构释放资源,通常也要考虑拷贝构造和赋值重载。

运算符重载:

  • 用于让自定义类型支持自然的运算符写法;
  • 不能创造新运算符;
  • 有些运算符不能重载;
  • 至少要有一个类类型参数;
  • 重载应该符合类型本身的语义。

拷贝构造解决"新对象怎么从旧对象来",赋值运算符解决"已有对象之间怎么赋值",深拷贝解决"资源不能只复制地址"的问题。


相关推荐
Cx330❀1 小时前
【MySQL基础】库与表的全面操纵指南
linux·服务器·网络·数据库·c++·mysql
RickyWasYoung1 小时前
【Matlab】科研绘图配色-极简版
开发语言·matlab
凡人叶枫1 小时前
Effective C++ 条款03:尽可能使用 const
linux·开发语言·c++·嵌入式开发
tedcloud1231 小时前
Understand-Anything部署教程:打造AI代码理解平台
服务器·人工智能·学习·自动化·powerpoint
光影6271 小时前
Python接口自动化测试----Requests库基础入门
开发语言·python·测试工具·pycharm·自动化
程序媛_1 小时前
【Python】连接PostgreSQL获取手机验证码
开发语言·python·postgresql
ch.ju1 小时前
Java Programming Chapter 4——Inherited call
java·开发语言
信看1 小时前
Jetson Orin Quectel QMI 拨号上网
开发语言·python
小欣加油1 小时前
Leetcode31 下一个排列
数据结构·c++·算法·leetcode·职场和发展