C++从菜鸟到强手:2.类和对象(中)—— 拷贝、赋值与运算符重载

个人主页:
wengqidaifeng

✨ 永远在路上,永远向前走

个人专栏:
数据结构

文章目录

    • 前言
    • 一、拷贝构造函数
      • [1.1 什么是拷贝构造函数](#1.1 什么是拷贝构造函数)
      • [1.2 拷贝构造函数的格式](#1.2 拷贝构造函数的格式)
      • [1.3 拷贝构造函数的调用场景](#1.3 拷贝构造函数的调用场景)
      • [1.4 编译器自动生成的拷贝构造](#1.4 编译器自动生成的拷贝构造)
    • [二、浅拷贝 vs 深拷贝 ------ 拷贝构造的核心难点](#二、浅拷贝 vs 深拷贝 —— 拷贝构造的核心难点)
      • [2.1 浅拷贝的问题](#2.1 浅拷贝的问题)
      • [2.2 深拷贝的解决方案](#2.2 深拷贝的解决方案)
      • [2.3 判断是否需要深拷贝的黄金法则](#2.3 判断是否需要深拷贝的黄金法则)
      • [2.4 两个有趣的例子](#2.4 两个有趣的例子)
    • 三、赋值运算符重载
      • [3.1 拷贝构造 vs 赋值](#3.1 拷贝构造 vs 赋值)
      • [3.2 赋值运算符重载的格式](#3.2 赋值运算符重载的格式)
      • [3.3 编译器自动生成的赋值运算符](#3.3 编译器自动生成的赋值运算符)
    • 四、运算符重载深入
      • [4.1 operator== 的实现](#4.1 operator== 的实现)
      • [4.2 成员函数 vs 全局函数](#4.2 成员函数 vs 全局函数)
      • [4.3 日期类的 operator+ 和 operator+=](#4.3 日期类的 operator+ 和 operator+=)
      • [4.4 头文件声明](#4.4 头文件声明)
      • [4.5 一些复用的技巧](#4.5 一些复用的技巧)
    • [五、const 成员函数](#五、const 成员函数)
      • [5.1 为什么要 const 成员函数](#5.1 为什么要 const 成员函数)
      • [5.2 const 成员函数的语法](#5.2 const 成员函数的语法)
      • [5.3 const 对象 vs 非 const 对象](#5.3 const 对象 vs 非 const 对象)
      • [5.4 函数重载中的 const](#5.4 函数重载中的 const)
    • 六、取地址操作符重载(选读)
    • 七、成员函数指针(了解)
    • 总结

前言

在上一篇中,我们学习了类和对象的基础知识------类的定义、this 指针、构造函数和析构函数。本篇我们将深入几个 C++ 中最容易踩坑的核心概念:拷贝构造函数深拷贝与浅拷贝赋值运算符重载 ,以及运算符重载的详细实践。

这些内容直接关系到程序的正确性和内存安全,掌握它们才能真正写出健壮的 C++ 代码。


一、拷贝构造函数

1.1 什么是拷贝构造函数

当你用一个已存在的对象去初始化另一个同类对象时,会调用拷贝构造函数。

cpp 复制代码
Date d1(2024, 7, 12);
Date d2(d1);       // 拷贝构造:用 d1 初始化 d2
Date d3 = d1;      // 拷贝构造:注意,这是初始化,不是赋值!

1.2 拷贝构造函数的格式

cpp 复制代码
class Date {
public:
    Date(int year = 1, int month = 1, int day = 1) {
        _year = year;
        _month = month;
        _day = day;
    }

    // 拷贝构造函数
    Date(const Date& d) {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

private:
    int _year, _month, _day;
};

几个关键点:

  • 参数必须是 const T&:引用传递避免无限递归(如果传值,传值本身又会调用拷贝构造,形成死循环)
  • 建议加 const:保护源对象不被修改

如果写成 Date(Date d) 会发生什么?编译器直接报错------传值调用本身需要先拷贝参数,而拷贝参数又要调用拷贝构造函数......无限递归。

cpp 复制代码
// 错误示范
Date(const Date d)   // error C2652: "Date": 非法的复制构造函数:
{                    // 第一个参数不应是"Date"
    // ...
}

1.3 拷贝构造函数的调用场景

三种情况会调用拷贝构造:
场景3:函数返回对象(可能优化)
Date Func2() { Date d; return d; }
Date ret = Func2(); // 可能调用拷贝构造
场景2:函数传参(传值)
void Func1(Date d) { }
Func1(d1); // d1 → d,调用拷贝构造
场景1:用已有对象初始化新对象
Date d1(2024,7,12);
Date d2(d1); // 拷贝构造
Date d3 = d1; // 拷贝构造(不是赋值!)
📦 拷贝构造函数触发条件

C++ 规定传值 调用函数参数时,需要调用拷贝构造函数。因此实际编程中,建议用 const T& 引用传参来避免不必要的拷贝。

1.4 编译器自动生成的拷贝构造

如果你没有显式定义拷贝构造函数,编译器会自动生成一个。这个默认的拷贝构造函数执行的是浅拷贝 (shallow copy)------将源对象的所有成员变量按字节复制到新对象。


二、浅拷贝 vs 深拷贝 ------ 拷贝构造的核心难点

2.1 浅拷贝的问题

对于没有动态资源的类(如 Date),默认的浅拷贝完全没问题:

cpp 复制代码
// Date 只有 int 成员,浅拷贝 OK
Date d1(2024, 7, 12);
Date d2(d1);   // 默认拷贝构造,逐字节复制 int,一切正常

但对于持有动态内存的类(如 Stack),浅拷贝会导致严重问题:
浅拷贝 vs 深拷贝 ------ 本系列最重要的图解:
✅ 深拷贝 Deep Copy(手动实现)
st2 (拷贝构造)
st1
_a = 0x2000 ← 独立地址!
_a = 0x1000
堆内存 0x1000

1, 2, ?, ?

堆内存 0x2000

1, 2, ?, ?

✅ st2析构 → free(0x2000) ✓

✅ st1析构 → free(0x1000) ✓
_top = 2
_capacity = 4
_top = 2
_capacity = 4
❌ 浅拷贝 Shallow Copy(编译器默认)
st2 = st1
st1
_a = 0x1000 ← 同一个地址!
_a = 0x1000
堆内存 0x1000

1, 2, ?, ?

💥 st2析构 → free(0x1000)

💥 st1析构 → free(0x1000) ← 重复释放!
_top = 2
_capacity = 4
_top = 2
_capacity = 4

cpp 复制代码
typedef int STDataType;
class Stack {
public:
    Stack(int n = 4) {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        _capacity = n;
        _top = 0;
    }

    ~Stack() {
        cout << "~Stack()" << endl;
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }

private:
    STDataType* _a;        // 指向堆上的动态内存
    size_t _capacity;
    size_t _top;
};

int main() {
    Stack st1;
    st1.Push(1);
    st1.Push(2);

    Stack st2(st1);   // 使用默认拷贝构造------浅拷贝!
    // st1._a 和 st2._a 都指向同一块 malloc 出来的内存
    // 函数结束,st2 析构 → free(_a)
    // 函数结束,st1 析构 → free(_a)  ← 重复释放!崩溃!
}

图解:

复制代码
浅拷贝后:
st1._a ──→ [malloc堆内存: 1, 2, ?, ?]
st2._a ──→ [同一块堆内存       ↑]
                              重复释放 → 程序崩溃

2.2 深拷贝的解决方案

我们需要在拷贝构造函数中为副本也分配一块独立的内存,然后把数据复制过去。

cpp 复制代码
class Stack {
public:
    Stack(int n = 4) {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        _capacity = n;
        _top = 0;
    }

    // 深拷贝------手动实现
    Stack(const Stack& st) {
        cout << "Stack(const Stack& st)" << endl;
        // 1. 为新对象分配独立的内存
        _a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
        if (_a == nullptr) {
            perror("malloc fail");
            return;
        }
        // 2. 把数据从源对象复制过来
        memcpy(_a, st._a, sizeof(STDataType) * st._top);
        // 3. 复制其他成员
        _top = st._top;
        _capacity = st._capacity;
    }

    ~Stack() {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }

private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

图解:

复制代码
深拷贝后:
st1._a ──→ [malloc堆内存1: 1, 2, ?, ?]
st2._a ──→ [malloc堆内存2: 1, 2, ?, ?]  ← 独立的内存!

析构时各自释放各自的内存,互不影响

2.3 判断是否需要深拷贝的黄金法则

如果类中持有指向动态资源的指针(malloc/new 出来的),就需要自己实现深拷贝的拷贝构造函数和赋值运算符。

如果类的成员都是基本类型(int, double 等)或者有深拷贝能力的类,就可以用编译器自动生成的。

2.4 两个有趣的例子

例1:一个含成员有深拷贝能力的类

cpp 复制代码
class MyQueue {
public:
    // 不需要写拷贝构造函数!
    // 编译器自动生成的会对 pushst 和 popst 调用 Stack 的拷贝构造,
    // 而 Stack 的拷贝构造已经实现了深拷贝
private:
    Stack pushst;
    Stack popst;
};

例2:拷贝构造与取地址构造的区别

cpp 复制代码
class Date {
public:
    Date(const Date& d) {     // 拷贝构造:参数是引用
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

    Date(const Date* d) {     // 普通构造:参数是指针,不是拷贝构造!
        _year = d->_year;
        _month = d->_month;
        _day = d->_day;
    }

private:
    int _year, _month, _day;
};

三、赋值运算符重载

3.1 拷贝构造 vs 赋值

这两个经常被混淆,但它们有本质区别:
左值正在创建 →
左值已存在 →
赋值 ------ 已存在对象间
d1 = d2;
d1 已经存在

只是修改 d1 的值
拷贝构造 ------ 创建新对象
Date d3(d2);
d3 是全新对象
Date d4 = d2;
d4 是全新对象

(这是初始化,不是赋值!)
🔑 关键判断规则

cpp 复制代码
Date d1(2024, 7, 5);
Date d2(2024, 7, 6);

Date d3(d2);       // 拷贝构造------用 d2 初始化 d3(d3 是新对象)
Date d4 = d2;      // 拷贝构造------同样是在初始化新对象

d1 = d2;           // 赋值------d1 已经存在,把 d2 的值赋给 d1

关键判断:左值已经存在 → 赋值;左值正在创建 → 拷贝构造

3.2 赋值运算符重载的格式

cpp 复制代码
class Date {
public:
    Date(int year = 1, int month = 1, int day = 1) {
        _year = year;
        _month = month;
        _day = day;
    }

    // 赋值运算符重载
    Date& operator=(const Date& d) {
        _year = d._year;
        _month = d._month;
        _day = d._day;
        return *this;   // 返回 *this 是为了支持连续赋值
    }

private:
    int _year, _month, _day;
};

int main() {
    Date d1(2024, 7, 5);
    Date d2(2024, 7, 6);
    Date d3, d4;

    d3 = d1;          // 赋值
    d4 = d3 = d1;     // 连续赋值:需要 operator= 返回引用

    int i, j, k;
    i = j = k = 1;    // 内置类型的连续赋值也是如此
}

为什么返回 Date& 而不是 Date

  • 返回引用可以避免额外的拷贝,提高效率
  • 返回引用支持 d4 = d3 = d1 这样的连续赋值

为什么返回 *this

  • *this 就是当前对象自身,返回它让赋值表达式的结果仍然是这个对象

3.3 编译器自动生成的赋值运算符

和拷贝构造一样,编译器自动生成的赋值运算符也是浅拷贝

cpp 复制代码
// 对于 Stack 来说,默认的赋值运算符:
// _a = d._a;     ← 两个对象指向同一块内存!
// _top = d._top;
// _capacity = d._capacity;

如果类持有动态资源,必须自己实现深拷贝的赋值运算符。通常还需要考虑"自我赋值"和"释放旧资源"的问题(属于进阶内容,下篇会提到)。


四、运算符重载深入

在上一篇中我们简单介绍了运算符重载,现在来深入学习。

4.1 operator== 的实现

cpp 复制代码
class Date {
public:
    // 成员函数版本
    bool operator==(const Date& d) {
        return _year == d._year
            && _month == d._month
            && _day == d._day;
    }

private:
    int _year, _month, _day;
};

调用方式:

cpp 复制代码
Date d1(2024, 7, 5);
Date d2(2024, 7, 6);

// 三种写法等价:
d1.operator==(d2);    // 显式调用
d1 == d2;             // 语法糖,编译器转换为上面的形式
operator==(d1, d2);   // 如果是全局函数版本

4.2 成员函数 vs 全局函数

运算符可以在类内(成员函数)或类外(全局函数)重载:

运算符类型 推荐形式 原因
= () [] -> 必须成员函数 C++ 标准规定
比较运算符(< == 等) 建议成员函数 左操作数固定为 *this
算术运算符(+ - 等) 建议成员函数 + 复用 += 更方便
<< >>(流) 必须全局函数 左操作数是 cout/cin

+ 复用 += 的设计模式 --- 一图看懂:
渲染错误: Mermaid 渲染失败: Parse error on line 10: ...dd --> tmp --> call --> ret op_addeq... -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'CALLBACKNAME'

4.3 日期类的 operator+ 和 operator+=

以下是一个日期类的完整示例。思路:operator+ 不应改变左操作数,返回一个新的 Date 对象;operator+= 改变自身,返回引用。

cpp 复制代码
#include"Date.h"

// d1 + 100 ------ 不改变 d1,返回新对象
Date Date::operator+(int day) {
    Date tmp = *this;    // 拷贝一份
    tmp += day;          // 复用 += 的逻辑
    return tmp;
}

// d1 += 100 ------ 改变 d1 自身
Date& Date::operator+=(int day) {
    _day += day;
    while (_day > GetMonthDay(_year, _month)) {
        _day -= GetMonthDay(_year, _month);
        ++_month;
        if (_month == 13) {
            _year++;
            _month = 1;
        }
    }
    return *this;
}

设计技巧operator+ 调用 operator+=(复用逻辑,避免代码重复)。反过来(+= 调用 +)效率更低,因为多了一次拷贝。

4.4 头文件声明

cpp 复制代码
#pragma once
#include<iostream>
#include<assert.h>
using namespace std;

class Date {
public:
    Date(int year = 1900, int month = 1, int day = 1);
    void Print();

    // 获取某月的天数
    int GetMonthDay(int year, int month) {
        static int monthDayArray[13] =
            { -1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
        if (month == 2 && ((year % 4 == 0 && year % 100 != 0)
                            || (year % 400 == 0))) {
            return 29;
        }
        return monthDayArray[month];
    }

    // 比较运算符
    bool operator<(const Date& d);
    bool operator<=(const Date& d);
    bool operator>(const Date& d);
    bool operator>=(const Date& d);
    bool operator==(const Date& d);
    bool operator!=(const Date& d);

    // 加减运算
    Date operator+(int day);
    Date& operator+=(int day);
    Date operator-(int day);

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

4.5 一些复用的技巧

比较运算符之间有逻辑关系,可以互相复用:

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

bool Date::operator==(const Date& d) const {
    return _year == d._year && _month == d._month && _day == d._day;
}

// 其他只需复用一个已实现的:
bool Date::operator<=(const Date& d) const { return *this < d || *this == d; }
bool Date::operator>(const Date& d) const  { return !(*this <= d); }
bool Date::operator>=(const Date& d) const { return !(*this < d); }
bool Date::operator!=(const Date& d) const { return !(*this == d); }

五、const 成员函数

5.1 为什么要 const 成员函数

cpp 复制代码
const Date d1(2024, 7, 13);
// d1 是 const 对象,内容不可修改

// d1.Print();           // 错误!非 const 成员函数不能用于 const 对象
// 因为 Print 可能修改成员变量

d1.Print();              // 只有在 Print 后面加上 const 才能被 const 对象调用

5.2 const 成员函数的语法

cpp 复制代码
class Date {
public:
    // const 修饰的是 *this,即 this 指向的内容不可修改
    void Print() const {
        // _year++;      // 错误!const 成员函数不能修改成员变量
        cout << _year << "/" << _month << "/" << _day << endl;
    }
};

const 成员函数的本质
const 移到 this 上
调用规则
const 对象 → 只能调用 const 成员函数 ✅
非 const 对象 → const 和非 const 都能调用 ✅
const 成员函数内 → 不能修改成员变量 ❌
编译器眼中的等价代码
void Print(const Date* const this) { ... }
你写的代码
void Print() const { ... }

cpp 复制代码
// 你写的:
void Print() const { /* ... */ }

// 编译器眼中:
void Print(const Date* const this) { /* ... */ }
//          ↑ const 修饰 this 指向的内容,即 *this 不可改

5.3 const 对象 vs 非 const 对象

cpp 复制代码
int main() {
    const Date d1(2024, 7, 13);
    d1.Print();            // OK,Print() const

    Date d2(2024, 7, 13);
    d2.Print();            // OK,非 const 对象也可以调用 const 成员函数
}

const 成员函数可以被 const 对象和非 const 对象调用。非 const 成员函数只能被非 const 对象调用。

5.4 函数重载中的 const

const 可以作为函数重载的区分:

cpp 复制代码
class Date {
public:
    void Print()       { cout << "非 const" << endl; }
    void Print() const { cout << "const" << endl; }
};

int main() {
    Date d1;
    d1.Print();         // 输出:"非 const"(优先匹配非 const)

    const Date d2;
    d2.Print();         // 输出:"const"(只能匹配 const 版本)
}

六、取地址操作符重载(选读)

正常情况下你可能永远不需要重载取地址运算符,但它属于 6 个默认成员函数之一,了解一下即可。

cpp 复制代码
class Date {
public:
    Date* operator&() {
        return this;              // 正常返回地址
        // return nullptr;        // 也可以"骗"调用者
    }

    const Date* operator&() const {
        return this;
    }
};

七、成员函数指针(了解)

cpp 复制代码
class A {
public:
    void func() { cout << "A::func()" << endl; }
};

typedef void (A::*PF)();   // 成员函数指针类型

int main() {
    PF pf = nullptr;
    pf = &A::func;          // C++ 规定成员函数必须用 & 取地址
    A aa;
    (aa.*pf)();             // 通过对象和指针调用
}

成员函数指针在实际开发中用得不多,但理解它有助于理解 this 指针的工作原理。


总结

中篇知识体系总览:
C++类和对象 中篇
拷贝构造函数
const T& d
参数必须是引用否则无限递归
三种调用场景
初始化新对象
函数传值参数
函数返回值
编译器默认生成的是浅拷贝
浅拷贝 vs 深拷贝
浅拷贝
逐字节复制
指针成员指向同一地址
析构时重复释放 → 崩溃
深拷贝
手动为新对象分配内存
复制内容到新内存
各自独立释放
黄金法则
持有动态资源 → 必须深拷贝
无动态资源 → 默认即可
赋值运算符重载
const T& d
返回 *this 支持连续赋值
拷贝构造 vs 赋值
左值正在创建 → 拷贝构造
左值已存在 → 赋值
运算符重载深入
成员函数 vs 全局函数

  • 复用 += 的设计模式 比较运算符互相复用
    const 成员函数
    本质: const Date* const this
    const 对象只能调 const 函数
    const 成员不能修改成员变量
    取地址运算符重载
    了解即可

本篇重点内容:

  1. 拷贝构造函数T(const T& d),在初始化新对象、传值、返回时调用
  2. 浅拷贝 vs 深拷贝:持有动态资源的类必须实现深拷贝,否则重复释放导致崩溃
  3. 赋值运算符重载T& operator=(const T& d),返回 *this 支持连续赋值
  4. 运算符重载 :让自定义类型使用运算符如内置类型,+ 复用 +=> 复用 <
  5. const 成员函数void func() const,const 对象只能调用 const 成员函数
  6. 取地址运算符重载:了解即可,一般不需要重载

下一篇(下篇),将介绍初始化列表、explicit 关键字、static 成员、友元、内部类、匿名对象、流插入/流提取重载等进阶特性,并给出一个功能完备的日期类。

相关推荐
0x00078 小时前
Git Bash 中无法启动 Claude Code ?
开发语言·git·bash
彦为君8 小时前
Spring定时任务开发指南(动态实现)
java·开发语言·后端·python·spring·wpf
不绝1918 小时前
AB包相关知识
开发语言·lua
炘爚8 小时前
智能指针:共享型shared_ptr的底层逻辑
c++·智能指针
lly2024068 小时前
WebPages 发布
开发语言
冉卓电子8 小时前
MPC5604B/C MC_RGM 复位模块全解
c语言·开发语言·单片机·嵌入式硬件
Chris _data8 小时前
C# 与 PLC Modbus RTU 通信实践:从单例到线程安全的连接监控
开发语言·安全·c#
不负岁月无痕8 小时前
STL-- C++ vector类 模拟实现
开发语言·c++
晚烛8 小时前
CANN 分布式通信与 HCCL:多 NPU 协作的底层机制
开发语言·人工智能·分布式·python·深度学习