C++ 类和对象(中):六大默认成员函数与深浅拷贝详解
个人主页道尔柯南❄专栏传送门《C语言》《C》《Linux操作系统》昙花一现却等待了整个白昼蝉鸣一夏却蛰伏了好几个四季。文章目录一、前言为什么学会了类还要学习默认成员函数二、什么是类的默认成员函数2.1 默认成员函数的概念2.2 编译器自动生成不代表一定符合需求三、构造函数让对象出生时就拥有初始状态3.1 为什么需要构造函数3.2 构造函数的基本写法3.3 构造函数有哪些特点3.4 什么是默认构造函数3.5 编译器生成的构造函数会初始化什么四、析构函数对象结束时负责清理资源4.1 为什么需要析构函数4.2 析构函数的特点4.3 析构函数与构造函数的配合五、拷贝构造函数如何复制一个对象5.1 什么是拷贝构造函数5.2 为什么拷贝构造必须使用引用5.3 拷贝构造的几种常见调用方式六、浅拷贝与深拷贝指针复制为什么会出问题6.1 什么是浅拷贝浅拷贝的内存示意图0x10000x1000同一块动态内存6.2 什么是深拷贝6.3 什么情况下需要深拷贝七、运算符重载让自定义对象也能使用运算符7.1 为什么需要运算符重载7.2 运算符重载的基本语法7.3 运算符重载有哪些规则7.4 前置 和后置 7.5 为什么 和 通常重载为全局函数八、赋值运算符重载两个已有对象之间的数据复制8.1 赋值与拷贝构造有什么区别8.2 赋值运算符重载的实现8.3 默认赋值运算符重载有什么问题九、综合实战一步步理解日期类的设计9.1 日期类需要实现哪些功能9.2 判断闰年与获取每月天数9.3 判断日期是否合法9.4 日期比较运算符9.5 日期加减天数十、const 成员函数为什么函数末尾还有一个 const10.1 先看一个常见问题10.2 const 成员函数的本质10.3 为什么推荐给只读函数加 const十一、取地址运算符重载了解即可十二、本章知识点总结零基础必须掌握的内容十三、结语结尾一、前言为什么学会了类还要学习默认成员函数在上一篇 C 学习中我们已经知道类可以把数据和操作数据的函数组织在一起。例如我们可以定义一个日期类class Date { public: int _year; int _month; int _day; void Print() { cout _year - _month - _day endl; } };有了这个类就可以创建日期对象Date d1; d1._year 2026; d1._month 10; d1._day 10; d1.Print();但是我们很快就会遇到一些问题每次创建对象都要手动初始化成员变量吗对象使用完毕后申请的内存应该如何释放如果想把一个对象复制给另一个对象应该怎么办两个对象能不能直接使用、、等运算符C 为我们提供了一套机制来解决这些问题这就是类的默认成员函数和运算符重载。本篇博客将围绕以下内容展开默认成员函数的基本概念构造函数与析构函数拷贝构造函数与深浅拷贝运算符重载与赋值运算符重载日期类的综合实现思路const成员函数与取地址运算符重载二、什么是类的默认成员函数2.1 默认成员函数的概念默认成员函数就是用户没有显式定义时编译器可以自动生成的特定成员函数。传统 C 类的默认成员函数主要有六个默认成员函数主要作用构造函数初始化对象析构函数清理对象持有的资源拷贝构造函数使用已有对象初始化新对象赋值运算符重载将一个已有对象的状态赋给另一个已有对象普通取地址运算符重载获取非 const 对象的地址const 取地址运算符重载获取 const 对象的地址其中前四个是学习重点。C11 后增加了移动构造函数和移动赋值运算符重载后续可以进一步学习。2.2 编译器自动生成不代表一定符合需求这是本章最重要的思想之一。编译器能够自动生成函数但自动生成的函数不一定适合所有类。例如日期类的成员变量只是三个整数自动生成的拷贝构造通常就能满足需求。但是如果一个栈类内部通过指针申请了动态内存编译器默认生成的拷贝构造就可能导致两个对象指向同一块内存引发严重问题。因此学习默认成员函数时要思考两个问题编译器自动生成的函数具体做了什么如果自动生成的行为不符合需求我们应该如何自己实现带着这两个问题我们开始逐个学习。三、构造函数让对象出生时就拥有初始状态3.1 为什么需要构造函数先看下面的代码class Date { public: void Init(int year, int month, int day) { _year year; _month month; _day day; } void Print() { cout _year - _month - _day endl; } private: int _year; int _month; int _day; };使用这个类时我们需要先创建对象再手动调用Init()Date d1; d1.Init(2026, 10, 10); d1.Print();问题在于如果忘记调用Init()就可能使用尚未正确初始化的成员变量。有没有办法让对象创建时自动完成初始化呢答案就是构造函数。3.2 构造函数的基本写法class Date { public: Date(int year 1, int month 1, int day 1) { _year year; _month month; _day day; } void Print() const { cout _year - _month - _day endl; } private: int _year; int _month; int _day; };使用时Date d1; // 使用默认参数初始化 Date d2(2026, 10, 10); // 使用指定参数初始化 d1.Print(); d2.Print();运行结果1-1-1 2026-10-10这里的构造函数相当于给对象安排了一位“出生登记员”对象创建时它自动负责初始化。3.3 构造函数有哪些特点需要重点记住以下几点函数名与类名相同。没有返回值也不需要写void。创建对象时会自动调用。可以进行函数重载。如果没有显式定义构造函数编译器通常会尝试生成默认构造函数如果显式定义了构造函数编译器就不会再额外生成无参默认构造函数。例如class Date { public: Date() { _year 2026; _month 1; _day 1; } Date(int year, int month, int day) { _year year; _month month; _day day; } private: int _year; int _month; int _day; };这两个构造函数构成重载可以根据传入的参数选择合适的版本。3.4 什么是默认构造函数默认构造函数是指不传实参就可以调用的构造函数。强调以下三种形式都可以属于默认构造函数无参构造函数全缺省构造函数编译器自动生成的默认构造函数。但一个类不能同时拥有多个在无参调用时产生歧义的默认构造函数。例如class Date { public: Date() { _year 1; } // 如果同时取消下面的注释会产生无参调用歧义 // Date(int year 1) // { // _year year; // } private: int _year; };另外下面这行代码很容易让初学者出错Date d1(); // 不是创建一个 Date 对象它会被解释为函数声明而不是对象定义。正确的写法是Date d1;初学者记忆口诀创建无参对象类名后面不要加括号。3.5 编译器生成的构造函数会初始化什么这一点非常重要。假设有如下代码class A { public: int _x; }; class B { public: A _a; int _y; };对于编译器自动生成的默认构造函数需要区分两种成员内置类型成员如int、char、指针等在传统默认构造场景下不能指望编译器自动将它们初始化为零。自定义类型成员如A _a编译器会调用其默认构造函数进行初始化如果该成员没有可用的默认构造函数外层类的默认构造也可能无法生成或调用。因此编写类时应该明确对象的初始状态不要依赖未初始化的内置类型值。四、析构函数对象结束时负责清理资源4.1 为什么需要析构函数构造函数解决了对象初始化的问题那么对象使用完毕之后呢对于普通整数我们通常不需要手动释放。但如果对象使用malloc()申请了动态内存就必须在适当的时候释放否则会发生内存泄漏。例如栈类会申请一块动态内存来保存数据。#include iostream #include cstdlib using namespace std; class Stack { public: Stack(int n 4) { _a (int*)malloc(sizeof(int) * n); if (_a nullptr) { perror(malloc failed); return; } _capacity n; _top 0; } ~Stack() { free(_a); _a nullptr; _capacity 0; _top 0; } private: int* _a; size_t _capacity; size_t _top; };这里的~Stack()就是析构函数。当对象生命周期结束时析构函数会自动调用负责释放对象管理的资源。4.2 析构函数的特点函数名是在类名前添加~。没有参数也没有返回值。一个类只能有一个析构函数。对象生命周期结束时析构函数自动调用。如果没有显式定义编译器可以生成默认析构函数。默认析构函数不会替你释放普通指针指向的动态内存但会自动调用自定义类型成员的析构函数。最后一点尤其容易出错。假如栈类没有自己实现析构函数那么编译器生成的析构函数不会自动执行free(_a)。指针变量本身虽然会随对象结束而消失但它指向的动态内存可能仍然占用着这就是内存泄漏。4.3 析构函数与构造函数的配合可以把两者理解为一对负责对象生命周期管理的函数构造函数Stack()申请空间、设置容量、初始化栈顶位置。析构函数~Stack()释放动态内存清理对象管理的资源。需要注意析构函数负责清理资源并不是由它直接决定对象本身何时销毁。五、拷贝构造函数如何复制一个对象5.1 什么是拷贝构造函数假设我们已经创建了一个日期对象Date d1(2026, 10, 10);现在想创建另一个对象d2并让它与d1拥有相同的初始数据。可以这样写Date d2(d1);这就是拷贝构造的典型使用场景使用一个已经存在的对象初始化一个新对象。拷贝构造函数是构造函数的一种特殊重载常见写法如下Date(const Date d) { _year d._year; _month d._month; _day d._day; }下面逐步理解这段代码Date表示当前类的名称。const Date d接收一个已有的Date对象的引用。const表示函数不应该通过这个引用修改原对象。引用可以避免为了传参而再次复制整个对象。5.2 为什么拷贝构造必须使用引用新手可能会写出这样的代码Date(Date d) { _year d._year; _month d._month; _day d._day; }这不是合法的拷贝构造函数写法。原因是如果拷贝构造的参数采用传值方式那么调用拷贝构造时为了把实参复制到形参d中又需要调用拷贝构造最终可能导致无限递归。所以拷贝构造的第一个参数必须是本类对象的引用通常写成Date(const Date d);5.3 拷贝构造的几种常见调用方式Date d1(2026, 10, 10); // 写法一 Date d2(d1); // 写法二 Date d3 d1;注意Date d3 d1;在这里是初始化一个新对象因此调用的是拷贝构造而不是赋值运算符重载。如果类没有显式定义拷贝构造函数编译器可以自动生成一个。对于普通成员变量这种自动生成的拷贝通常就是逐成员复制。但如果类管理着动态内存仅仅复制指针值就可能产生问题。接下来我们学习本章最容易出错的知识浅拷贝与深拷贝。六、浅拷贝与深拷贝指针复制为什么会出问题6.1 什么是浅拷贝假设我们有一个栈对象它内部通过指针管理一块动态内存。浅拷贝的内存示意图栈对象 A指针_a0x1000栈对象 B指针_a0x1000同一块动态内存两个指针指向相同地址浅拷贝只复制指针的值没有为新对象创建独立的内存资源。例如Stack st1; Stack st2 st1;如果Stack使用编译器自动生成的拷贝构造函数那么st1._a和st2._a可能指向同一块动态内存。当两个对象分别执行析构函数时就可能对同一块内存重复释放造成未定义行为程序甚至会崩溃。6.2 什么是深拷贝深拷贝的核心思想是不仅复制数据还要为新对象创建属于它自己的资源。例如#include iostream #include cstdlib #include cstring using namespace std; class Stack { public: Stack(int n 4) : _a(nullptr), _capacity(n), _top(0) { _a (int*)malloc(sizeof(int) * n); if (_a nullptr) { _capacity 0; } } Stack(const Stack st) : _a(nullptr), _capacity(st._capacity), _top(st._top) { if (_capacity 0) return; _a (int*)malloc(sizeof(int) * _capacity); if (_a ! nullptr) { memcpy(_a, st._a, sizeof(int) * _top); } else { _capacity 0; _top 0; } } ~Stack() { free(_a); } private: int* _a; size_t _capacity; size_t _top; };这段代码演示了深拷贝的基本思路为新对象申请一块独立内存。将原对象中的有效数据复制过去。让两个对象分别管理自己的内存。这样两个栈对象就不会因为共同管理同一块动态内存而发生重复释放。注意上面的代码主要用于解释深拷贝的思路。完整的资源管理类还需要妥善处理内存申请失败以及赋值运算时原有资源的释放等问题。6.3 什么情况下需要深拷贝可以这样判断类的情况通常如何处理Date只保存年、月、日默认拷贝构造通常够用Stack使用指针管理动态内存需要考虑自行实现深拷贝MyQueue内部由两个Stack对象组成如果Stack的拷贝行为正确队列通常可以使用编译器生成的拷贝构造这里有一个实用的判断技巧如果一个类显式实现了析构函数并且析构函数负责释放资源就应该重点检查它的拷贝构造和赋值运算符重载是否也需要自定义实现。这并不是说所有自定义析构函数都必然要求手写拷贝函数而是说资源所有权需要保持一致。七、运算符重载让自定义对象也能使用运算符7.1 为什么需要运算符重载对于内置类型下面的写法非常自然int a 10; int b 20; cout (a b) endl;但如果我们定义了一个Date类下面的表达式是什么意思呢Date d1(2026, 10, 10); Date d2(2026, 10, 11); // d1 d2编译器并不知道如何直接比较两个Date对象的年月日。这时就可以通过运算符重载为类类型定义合理的运算行为。7.2 运算符重载的基本语法运算符重载本质上是一个特殊命名的函数其名称由operator和对应的运算符组成。例如定义日期相等比较bool operator(const Date d) { return _year d._year _month d._month _day d._day; }这是成员函数形式的运算符重载。使用时Date d1(2026, 10, 10); Date d2(2026, 10, 10); cout (d1 d2) endl;如果对象日期相同表达式的结果就是true。对于成员函数形式的运算符重载d1 d2;可以理解为d1.operator(d2);其中左侧对象d1通过隐含的this指针传入右侧对象d2则作为显式参数。7.3 运算符重载有哪些规则初学者需要掌握以下规则运算符重载不能凭空创造新的运算符例如operator。至少有一个操作数必须是类类型或枚举类型不能改变两个内置类型之间的运算含义。运算符重载不会改变原运算符的优先级和结合性。赋值运算符operator必须重载为非静态成员函数。.*、::、sizeof、?:、.等运算符不能重载。7.4 前置和后置这是 C 面试和基础考试中比较常见的知识点。前置递增d1;后置递增d1;为了区分这两种操作C 规定后置重载时需要额外增加一个int形参。// 前置 Date operator() { *this 1; return *this; } // 后置 Date operator(int) { Date tmp(*this); *this 1; return tmp; }两者的区别是前置先修改对象再返回修改后的对象引用。后置先保存修改前的状态再修改对象最后返回旧状态的副本。例如假设d1最初是2026-10-10Date d2 d1;那么d1和d2都表示递增后的日期。而Date d3 d1;则d3保存递增前的日期d1自身已经递增。7.5 为什么和通常重载为全局函数我们经常使用cout 100; cin value;如果想让日期类支持类似的写法Date d(2026, 10, 10); cout d;通常会把流插入运算符重载为非成员函数ostream operator(ostream out, const Date d) { out d._year - d._month - d._day; return out; }不过上述示例假设该函数可以访问日期类的私有成员。实际实现时可以将它声明为友元函数或者通过公有接口获取日期数据。为什么不直接把它写成成员函数因为成员函数隐含的this会占据第一个操作数的位置。如果写成成员函数使用方式会变成d.operator(cout);而不是我们熟悉的cout d;所以为了保持流操作的使用习惯通常选择全局函数并在需要时通过friend授予访问权限。八、赋值运算符重载两个已有对象之间的数据复制8.1 赋值与拷贝构造有什么区别这是本章必须掌握的知识点。先看两段代码Date d1(2026, 10, 10); // 拷贝构造 Date d2 d1;这里的d2是一个新创建的对象因此调用拷贝构造函数。再看Date d1(2026, 10, 10); Date d2(2026, 10, 11); // 赋值 d1 d2;这里d1和d2都已经存在d1 d2是把d2的状态赋给d1通常调用赋值运算符重载。拷贝构造创建新对象时使用已有对象初始化。Date d2 d1;赋值运算符重载两个已有对象之间进行状态赋值。d1 d2;8.2 赋值运算符重载的实现以日期类为例Date operator(const Date d) { if (this ! d) { _year d._year; _month d._month; _day d._day; } return *this; }逐句理解const Date d通过引用接收右侧对象避免不必要的拷贝。this ! d检查是否发生自己给自己赋值。return *this返回当前对象的引用。为什么要返回引用因为这样可以支持连续赋值d1 d2 d3;赋值运算符返回当前对象的引用后整个表达式就可以继续完成下一次赋值。8.3 默认赋值运算符重载有什么问题如果一个类只包含整数、字符等普通成员变量编译器自动生成的赋值运算符通常就够用了。但如果类中含有指针并管理动态内存默认赋值可能只复制指针值导致两个对象共享同一块内存。因此对于前面介绍的Stack我们不仅要考虑深拷贝构造还要考虑深拷贝赋值。需要注意赋值运算符重载不能只申请新内存还必须妥善处理当前对象原来管理的资源并保证异常或申请失败时不会破坏对象状态。这也是资源管理类比普通数据类复杂得多的原因。九、综合实战一步步理解日期类的设计前面学习的构造函数、拷贝构造、运算符重载等知识并不是彼此孤立的。它们最终被应用到了一个完整的Date日期类中。我们以日期类为例看看如何把这些知识联系起来。9.1 日期类需要实现哪些功能一个实用的日期类至少需要考虑以下问题如何创建一个日期对象如何判断输入的日期是否合法如何比较两个日期的先后如何计算日期加减若干天如何实现前置和后置如何计算两个日期之间相差多少天如何支持cout d和cin d9.2 判断闰年与获取每月天数首先需要解决一个基础问题每个月有多少天2 月比较特殊闰年有 29 天平年有 28 天。判断闰年的规则是能被 4 整除但不能被 100 整除或者能被 400 整除。代码如下bool IsLeapYear(int year) { return (year % 4 0 year % 100 ! 0) || year % 400 0; } int GetMonthDay(int year, int month) { int days[13] { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; if (month 2 IsLeapYear(year)) { return 29; } return days[month]; }这里使用了一个包含 13 个元素的数组将下标 1 到 12 分别对应 1 月到 12 月。这种写法可以让我们直接通过days[month]获取某个月的天数。9.3 判断日期是否合法有了每月天数的计算方法就可以检查日期是否合法。bool CheckDate(int year, int month, int day) { if (month 1 || month 12) return false; if (day 1 || day GetMonthDay(year, month)) return false; return true; }例如CheckDate(2024, 2, 29); // true CheckDate(2025, 2, 29); // false CheckDate(2026, 13, 1); // false注意这个简单示例主要检查月份和日期范围。如果项目还要求限制年份范围可以继续增加年份合法性判断。9.4 日期比较运算符假设需要判断d1是否早于d2。比较顺序应该是先比较年份年份相同再比较月份年月相同最后比较日期。这就是日期类operator的基本逻辑。bool operator(const Date d) const { if (_year ! d._year) return _year d._year; if (_month ! d._month) return _month d._month; return _day d._day; }这里的const有两个用途const Date d不允许通过参数修改右侧对象。函数末尾的const表示这个成员函数不会修改当前对象的成员状态。如果已经实现了和其他比较运算符就可以复用已有逻辑。例如bool operator(const Date d) const { return *this d || *this d; } bool operator!(const Date d) const { return !(*this d); }这种写法能够减少重复代码让类更容易维护。9.5 日期加减天数例如Date d1(2026, 10, 10); Date d2 d1 5;我们希望d2表示 2026 年 10 月 15 日同时d1保持不变。一种常见的设计是operator直接修改当前对象operator复制当前对象再在副本上执行。示意代码Date operator(int day) { // 这里实现日期向后增加 day 天的逻辑 // 需要考虑跨月、跨年以及闰年 return *this; } Date operator(int day) const { Date tmp *this; tmp day; return tmp; }为什么operator返回对象而operator返回引用因为两者的语义不同d1 5修改d1本身。d1 5计算一个新结果不应该改变d1。这也体现了运算符重载的一个基本原则尽量保持与内置类型操作相似的使用习惯。上面的代码只是接口和设计思路的演示并不是完整的日期加减实现。课件中的完整实现还需要通过循环处理跨月、跨年以及负数天数等情况。十、const成员函数为什么函数末尾还有一个 const10.1 先看一个常见问题class Date { public: void Print() { cout _year - _month - _day endl; } private: int _year; int _month; int _day; };创建一个 const 对象const Date d1; d1.Print();这时可能出现编译错误。原因是Print()没有声明为 const 成员函数不能直接通过 const 对象调用。如果函数的职责只是读取数据并打印就应该将它声明为 const 成员函数。void Print() const { cout _year - _month - _day endl; }注意const是写在参数列表后面的而不是函数名前面。10.2 const 成员函数的本质可以把它理解为向编译器作出承诺这个成员函数不会通过当前对象修改普通成员变量。从this指针的角度理解普通成员函数中的隐含this可以理解为指向当前对象的常量指针而 const 成员函数中的this则指向 const 对象。简化表示如下// 普通成员函数中的 this Date* const this; // const 成员函数中的 this const Date* const this;这里需要区分两个 constDate* const指针本身不能改为指向其他地址。const Date*不能通过该指针修改所指对象的普通成员。10.3 为什么推荐给只读函数加 const因为这样可以同时满足两种调用需求Date d1; const Date d2; // const 成员函数可以由普通对象调用 d1.Print(); // const 成员函数也可以由 const 对象调用 d2.Print();因此对于不需要修改对象状态的成员函数应该优先考虑添加const。十一、取地址运算符重载了解即可C 还允许我们对取地址运算符进行重载。普通对象可以通过Date d1; Date* p d1;获取地址。对于 const 对象const Date d2; const Date* p d2;也可以正常获取地址。编译器能够自动生成普通取地址运算符重载和 const 取地址运算符重载通常不需要自己编写。手动重载的形式Date* operator() { return this; } const Date* operator() const { return this; }一般情况下我们不建议随意改变取地址运算符的正常行为。对于初学者先理解编译器可以自动生成这两个函数即可。十二、本章知识点总结零基础必须掌握的内容学完这一章不要只记住代码更要理解每个函数解决了什么问题。知识点核心作用最容易犯的错误构造函数初始化对象误以为负责分配对象本身的空间析构函数清理对象管理的资源认为默认析构会自动释放所有动态内存拷贝构造初始化新对象把Date d2 d1误认为赋值深浅拷贝管理指针与资源忘记默认拷贝可能只复制指针运算符重载定义对象的运算行为忘记重载规则和成员函数的this赋值运算符重载处理已有对象之间的赋值没有处理自赋值或资源所有权const 成员函数保护只读操作把函数末尾的const忘掉取地址运算符重载自定义取地址行为没有必要时也进行重载十三、结语C 的类和对象是从基础语法走向真正面向对象编程的重要一步。刚开始学习时你可能觉得构造函数、析构函数、拷贝构造、赋值运算符重载这些概念比较多而且容易混淆。这很正常。建议你先抓住三条主线对象的生命周期 构造函数负责初始化析构函数负责清理资源。对象的复制 拷贝构造用于创建新对象赋值运算符用于处理已经存在的对象。对象的行为 通过运算符重载和 const 成员函数让类的使用更加自然、安全。特别是深浅拷贝一定要亲自动手写一个管理动态内存的类观察指针地址、资源释放和拷贝结果。只有真正理解了这些问题后续学习智能指针、移动语义和 STL 容器时才会更加轻松。学习 C 不需要一口气记住所有知识。每理解一个概念就用一段小代码验证它逐步积累你会发现原本复杂的面向对象编程也能变得清晰起来。结尾uu们本文的内容到这里就全部结束了道尔在这里再次感谢您的阅读道尔柯南C/C Linux 底层探索者 | 一个正在努力学习的技术博主【关注】跟随我一起深耕技术领域见证每一次成长。❤️【点赞】让优质内容被更多人看见让知识传递更有力量。⭐【收藏】把核心知识点存好在需要时随时查、随时用。【评论】分享你的经验或疑问评论区一起交流避坑不要忘记给博主“一键四连”哦“今日目标达成”结语希望对学习C相关内容的uu有所帮助不要忘记给博主“一键四连”哦