这篇文章是我学习 C 类和对象的核心笔记。如果你觉得构造函数、拷贝构造、赋值重载这些概念很绕说明你没有从为什么需要它这个角度去理解。我们从头捋一遍。目录一、什么是默认成员函数二、构造函数2.1 为什么需要构造函数2.2 构造函数的规则2.3 一个容易踩的坑2.4 什么叫默认构造函数2.5 编译器自动生成的构造函数做了什么三、析构函数3.1 为什么需要析构函数3.2 析构函数的规则3.3 编译器自动生成的析构函数做了什么3.4 析构的调用顺序四、拷贝构造函数4.1 为什么需要拷贝构造4.2 拷贝构造的语法4.3 为什么参数必须是引用4.4 编译器生成的拷贝构造浅拷贝4.5 解决方案深拷贝4.6 传值传参和传值返回都会调用拷贝构造4.7 一个判断是否需要写拷贝构造的小技巧五、赋值运算符重载5.1 运算符重载是什么5.2 前置 和后置 怎么区分5.3 和 为什么要重载为全局函数5.4 赋值运算符重载5.5 编译器默认生成的赋值运算符六、日期类完整实现6.1 头文件接口声明6.2 实现文件函数定义6.3 日期类设计的几个思路七、const 成员函数7.1 为什么需要 const 成员函数7.2 const 成员函数的使用规则八、取地址运算符重载九、面试高频考点汇总Q1不能重载的运算符有哪些Q2构造函数能是虚函数吗析构函数呢Q3什么情况下必须自己写拷贝构造和赋值运算符Q4前置 和后置 哪个效率更高Q5赋值运算符为什么要返回引用为什么要检查自赋值Q6const 成员函数里能修改成员变量吗十、总结一、什么是默认成员函数先问自己一个问题你写了一个空类里面什么都没写这个类真的空吗class Empty {};不空。C 编译器会偷偷给你生成6 个默认成员函数构造函数析构函数拷贝构造函数赋值运算符重载取地址重载const 取地址重载后两个基本用不上最重要的是前四个。而且 C11 以后还多了移动构造和移动赋值这个后面再说。学习这些默认函数要从两个角度去想编译器默认生成的行为是什么能不能满足我的需求如果不满足我自己怎么写带着这两个问题我们逐一来看。二、构造函数2.1 为什么需要构造函数在 C 语言里你创建一个结构体之后必须手动调用Init函数初始化否则里面都是垃圾值。ST s; STInit(s); // 忘了这行后面就崩了这件事C觉得很烦因为忘记初始化是一个极其常见的 bug。C 想解决这个问题于是引入了构造函数对象创建的那一刻自动完成初始化想忘都忘不了。2.2 构造函数的规则构造函数有几个语法规定记住就行函数名必须和类名相同没有返回值连 void 都不写C 就是这么规定的对象实例化时系统自动调用可以重载可以写多个class Date { public: // 无参构造函数 Date() { _year 1; _month 1; _day 1; } // 带参构造函数 Date(int year, int month, int day) { _year year; _month month; _day day; } private: int _year; int _month; int _day; }; int main() { Date d1; // 调用无参构造 Date d2(2024, 7, 5); // 调用带参构造 return 0; }2.3 一个容易踩的坑Date d3(); // ⚠️ 这不是创建对象这是函数声明编译器看到这行认为你在声明一个叫d3、无参数、返回值是Date的函数。用无参构造创建对象时后面不能加括号。2.4 什么叫默认构造函数很多人以为默认构造函数就是编译器自动生成的那个这是错的。默认构造函数的定义是不传实参就能调用的构造函数。包含三种无参构造函数Date() {}全缺省构造函数Date(int year1, int month1, int day1) {}编译器自动生成的构造函数简单来说就是可以不传参数就可以调用但是不是一定不传这三种有且只有一个能存在。无参和全缺省虽然构成函数重载但调用时Date d1;编译器不知道该调用哪个产生歧义直接报错。2.5 编译器自动生成的构造函数做了什么这里是很多人搞不清楚的地方。编译器生成的默认构造函数对内置类型int、double、指针等不做任何处理值是随机垃圾值不是所有编译器都初始化内置类型对自定义类型class/struct 定义的类型调用它的默认构造函数class MyQueue { public: // 什么都不写 // 编译器自动生成的构造函数会去调用 Stack 的构造函数 // pushst 和 popst 都会被正确初始化 private: Stack pushst; Stack popst; }; int main() { MyQueue mq; // pushst 和 popst 都被自动初始化了 return 0; }所以规律就是如果类里面全是内置类型成员编译器生成的构造函数大概率不够用需要自己写。如果类里面包含自定义类型成员编译器会帮你调用那个成员的构造函数。三、析构函数3.1 为什么需要析构函数和构造函数对应析构函数解决的是另一个老问题用完之后忘记释放资源。在 C 语言里你必须手动调Destroy而且每一个提前return的地方都得写稍不注意就内存泄漏。C祖师爷依旧看不惯bool isValid(const char* s) { ST st; STInit(st); // ... if (某个条件) { STDestroy(st); // 每个 return 前都要写 return false; } // ... STDestroy(st); // 正常结束也要写 return true; }C 的析构函数对象生命周期结束时自动调用自动释放资源。3.2 析构函数的规则函数名是类名前加~无参数、无返回值一个类只能有一个析构函数不能重载对象生命周期结束时自动调用class Stack { public: Stack(int n 4) { _a (STDataType*)malloc(sizeof(STDataType) * n); _capacity n; _top 0; } ~Stack() { free(_a); // 释放堆上的资源 _a nullptr; _top _capacity 0; } private: STDataType* _a; size_t _capacity; size_t _top; };3.3 编译器自动生成的析构函数做了什么和构造函数的规律一样对内置类型成员不做处理对自定义类型成员调用它的析构函数对象销毁│├─ 调用析构函数│ ││ ├─ 调用成员对象析构│ └─ 内置类型 → 无操作│└─ 内存回收结论类里面没有申请堆上资源比如Date类不需要写析构编译器生成的够用类里面有自定义类型成员比如MyQueue里有两个Stack编译器生成的析构会自动调用Stack的析构也不需要自己写类里面有指针指向堆上资源比如Stack里的_a必须自己写析构否则内存泄漏3.4 可是为什么指向资源必须自己写编译器析构她的过程是怎样的指针本身不是资源指针只是“地址变量”。编译器不知道这个地址代表什么所以它不敢帮你释放资源。看一个最简单的例子class A { public: int* p; A() { p new int(10); } };对象A a;内存结构是这样的栈区┌─────────┐│ a ││p 只是一个地值 ┼────────┐└─────────┘ ││堆区 ▼┌───────┐│ 10 │└───────┘注意p 只是一个地址对象销毁时发生什么当a销毁编译器默认析构函数~A(){// 什么都不做}于是栈空间回收变成栈区(对象消失)堆区┌───────┐│ 10 │ ← 还在└───────┘这就叫内存泄漏memory leak因为没有指针再指向这块堆内存3.4 析构的调用顺序同一个局部域内后定义的对象先析构类似栈的 LIFO 顺序。int main() { Stack st1; // 先构造 Stack st2; // 后构造 return 0; // 先析构 st2再析构 st1 }四、拷贝构造函数4.1 为什么需要拷贝构造有时候我们想用一个已有的对象去初始化另一个新对象Date d1(2024, 7, 5); Date d2 d1; // 希望 d2 是 d1 的一份拷贝 Date d3(d1); // 同样是拷贝构造两种写法等价C 规定自定义类型对象进行拷贝行为必须调用拷贝构造函数。这不只是上面这种显式拷贝传值传参、传值返回都会触发拷贝构造。4.2 拷贝构造的语法拷贝构造是一种特殊的构造函数第一个参数必须是自身类类型的引用。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; int _month; int _day; };4.3 为什么参数必须是引用这是一个很有意思的推理过程面试也常考。假设拷贝构造写成值传递Date(Date d) // ❌ 编译直接报错调用拷贝构造时需要先把实参拷贝给形参d。但是把实参拷贝给形参这个操作本身就是一次拷贝行为又要调用拷贝构造……然后又要拷贝实参给形参……无穷递归直到栈溢出。形象一些就是函数形成了自依赖函数开始解决问题的前提是函数结果所以参数必须是引用引用不产生拷贝直接绑定到原对象。加const是为了保证传入的对象不被修改。4.4 编译器生成的拷贝构造浅拷贝如果你没有写拷贝构造编译器会自动生成一个行为是值拷贝浅拷贝把每个成员变量的值原样复制过去。内置类型和Date这种类浅拷贝完全够用。他是按字节来拷贝但对于Stack这种类浅拷贝会出大问题Stack st1; st1.Push(1); st1.Push(2); Stack st2 st1; // 浅拷贝浅拷贝之后st1._a和st2._a指向的是同一块堆内存st1._a ──────→ [ 1 | 2 | _ | _ ] ↑ st2._a ──────────────── 同一块内存程序结束st1析构free(_a)这块内存释放了。然后st2析构又free同一个地址double free程序崩溃。4.5 解决方案深拷贝Stack(const Stack st) { // 重新申请一块同样大的内存 _a (STDataType*)malloc(sizeof(STDataType) * st._capacity); if (nullptr _a) { perror(malloc 申请空间失败); return; } // 把数据完整复制过来 memcpy(_a, st._a, sizeof(STDataType) * st._top); _top st._top; _capacity st._capacity; }深拷贝之后st1._a ──────→ [ 1 | 2 | _ | _ ] ← st1 独享这块内存 st2._a ──────→ [ 1 | 2 | _ | _ ] ← st2 独享另一块内存两个析构函数各自释放各自的内存互不干扰。4.6 传值传参和传值返回都会调用拷贝构造void Func1(Date d) // 传值传参d1 传进来时调用一次拷贝构造 { d.Print(); } Date Func2() { Date tmp(2024, 7, 5); return tmp; // 传值返回产生一个临时对象调用一次拷贝构造 }所以能用引用传参就用引用传参能用引用返回就用引用返回避免不必要的拷贝开销。但引用返回有一个前提返回的对象在函数结束后还活着。如果返回的是局部变量的引用函数结束局部变量销毁引用就变成野引用了相当于野指针Date Func2() { Date tmp(2024, 7, 5); return tmp; // ⚠️ 危险tmp 函数结束就销毁了 // 返回的引用是野引用 }4.7 一个判断是否需要写拷贝构造的小技巧如果一个类显式写了析构函数并释放了资源那它几乎一定也需要写拷贝构造。反过来如果析构函数用编译器默认生成的就够了拷贝构造通常也不需要自己写。这两个经常成对出现。五、赋值运算符重载5.1 运算符重载是什么C 允许我们为类类型的对象重新定义运算符的含义。语法是用operator加上运算符名字作为函数名bool operator(const Date d1, const Date d2) { return d1._year d2._year d1._month d2._month d1._day d2._day; }然后d1 d2这个表达式编译器会自动转换成operator(d1, d2)来调用。有几个规则要记不能创造新运算符比如operator是非法的至少有一个参数是类类型不能用重载改变内置类型的行为..*::sizeof?:这五个运算符不能重载面试选择题常考说简单的为类类型定义这个运算符如何工作将运算符转换为函数调用使用户自定义类型能够像内置类型一样参与运算。operator(int a, int b) { return a - b; }两个都是内置类型编译器直接拒绝。当然也太反常识没有现实意义如果重载为成员函数this指针占据第一个参数位置所以参数比运算对象少一个C规定运算符重载函数至少有一个操作数必须是用户自定义类型从而防止程序员改变内置类型运算符的行为。class Date { public: // 成员函数版本d1 d2 → d1.operator(d2) bool operator(const Date d) { return _year d._year _month d._month _day d._day; } };5.2 前置 和后置 怎么区分两个都叫operator怎么区分C 规定后置 多一个 int 形参纯粹是为了和前置 构成重载这个 int 的值没有实际意义// 前置 d1 → d1.operator() Date operator() { *this 1; return *this; // 返回加完之后的自己 } // 后置 d1 → d1.operator(0) Date operator(int) { Date tmp(*this); // 先保存加之前的状态 *this 1; return tmp; // 返回加之前的状态 }注意返回值的区别前置 返回引用效率更高没有拷贝后置 返回值临时对象因为要保存修改前的状态不得不拷贝所以优先用前置 后置 有额外的拷贝开销。5.3 和 为什么要重载为全局函数如果重载为成员函数void operator(ostream out) { out _year - _month - _day; }调用时变成d1 cout因为this指针占了第一个参数d1就是左侧运算对象。这不符合使用习惯。如果左边不是你的类比如cout d左边是ostream所以必须写全局函数。所以要重载为全局函数把ostream放第一个参数ostream operator(ostream out, const Date d) { out d._year 年 d._month 月 d._day 日; return out; // 返回 out 是为了支持链式调用 cout d1 d2 }为什么返回ostream因为要支持连续输出但这样全局函数访问不了Date的私有成员解决方法是把这个函数声明为Date的友元函数class Date { friend ostream operator(ostream out, const Date d); // 友元声明 // ... };5.4 赋值运算符重载赋值运算符重载是一个默认成员函数必须重载为成员函数不能是全局函数。注意区分赋值运算符重载和拷贝构造Date d1(2024, 7, 5); Date d2(d1); // 拷贝构造d2 是新创建的对象 Date d3 d1; // 拷贝构造虽然用了 但 d3 是新创建的 // 不要被 迷惑 Date d4(2024, 8, 1); d4 d1; // 赋值运算符重载d4 已经存在是两个已存在对象之间的赋值记住赋值运算符是两个已经存在的对象之间的拷贝赋值。拷贝构造是用已有对象初始化一个新对象。赋值运算符重载的写法Date operator(const Date d) { if (this ! d) // 防止自己给自己赋值d1 d1 这种情况 { _year d._year; _month d._month; _day d._day; } return *this; // 返回 *this 是为了支持连续赋值 d1 d2 d3 }返回引用而不是值是为了减少一次拷贝同时支持d1 d2 d3这样的链式赋值。5.5 编译器默认生成的赋值运算符和拷贝构造一样默认生成的赋值运算符也是浅拷贝。所以Date类默认生成的够用不需要自己写Stack类需要自己写深拷贝版本MyQueue类编译器自动调用Stack的赋值运算符不需要自己写同样的小技巧如果显式写了析构函数并释放了资源赋值运算符重载也要自己写。六、日期类完整实现理论说了这么多来看一个完整的实战案例——日期类。它把上面所有的知识点都用上了。6.1 头文件接口声明#pragma once #include iostream #include assert.h using namespace std; class Date { // 友元函数声明让全局的 和 能访问私有成员 friend ostream operator(ostream out, const Date d); friend istream operator(istream in, Date d); public: Date(int year 1900, int month 1, int day 1); void Print() const; // 直接定义在类里面默认是 inline 内联函数 // 频繁调用的小函数适合放这里 int GetMonthDay(int year, int month) { assert(month 0 month 13); static int monthDayArray[13] { -1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; // 闰年 2 月有 29 天 if (month 2 ((year % 4 0 year % 100 ! 0) || (year % 400 0))) return 29; else return monthDayArray[month]; } bool CheckDate(); bool operator(const Date d) const; bool operator(const Date d) const; bool operator(const Date d) const; bool operator(const Date d) const; bool operator(const Date d) const; bool operator!(const Date d) const; Date operator(int day); Date operator(int day) const; Date operator-(int day); Date operator-(int day) const; int operator-(const Date d) const; // 两个日期相差天数 Date operator(); // 前置 Date operator(int); // 后置 Date operator--(); Date operator--(int); private: int _year; int _month; int _day; }; ostream operator(ostream out, const Date d); istream operator(istream in, Date d);6.2 实现文件函数定义#include Date.h // 检查日期是否合法 bool Date::CheckDate() { if (_month 1 || _month 12 || _day 1 || _day GetMonthDay(_year, _month)) return false; return true; } // 构造函数 Date::Date(int year, int month, int day) { _year year; _month month; _day day; if (!CheckDate()) cout 日期非法 endl; } void Date::Print() const { cout _year - _month - _day endl; } // d1 d2 bool Date::operator(const Date d) const { if (_year d._year) return true; else if (_year d._year) { if (_month d._month) return true; else if (_month d._month) return _day d._day; } return false; } // 其他比较运算符复用 和 来实现代码更简洁 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 _year d._year _month d._month _day d._day; } bool Date::operator!(const Date d) const { return !(*this d); } // d1 天数支持负数 Date Date::operator(int day) { if (day 0) return *this - -day; // 加负数转换成减正数 _day day; while (_day GetMonthDay(_year, _month)) { _day - GetMonthDay(_year, _month); _month; if (_month 13) { _year; _month 1; } } return *this; } // d1 天数用 来实现避免重复代码 Date Date::operator(int day) const { Date tmp *this; tmp day; return tmp; } // d1 - 天数 Date Date::operator-(int day) { if (day 0) return *this -day; _day - day; while (_day 0) { --_month; if (_month 0) { _month 12; _year--; } _day GetMonthDay(_year, _month); // 借上一个月的天数 } return *this; } Date Date::operator-(int day) const { Date tmp *this; tmp - day; return tmp; } // 前置 Date Date::operator() { *this 1; return *this; } // 后置 先保存再加返回加之前的 Date Date::operator(int) { Date tmp(*this); *this 1; return tmp; } Date Date::operator--() { *this - 1; return *this; } Date Date::operator--(int) { Date tmp *this; *this - 1; return tmp; } // 两个日期相差多少天计算两个日期之间的差值 int Date::operator-(const Date d) const { Date max *this; Date min d; int flag 1; if (*this d) { max d; min *this; flag -1; } int n 0; while (min ! max) { min; n; } return n * flag; } // 流插入重载全局函数 ostream operator(ostream out, const Date d) { out d._year 年 d._month 月 d._day 日 endl; return out; } // 流提取重载全局函数 istream operator(istream in, Date d) { cout 请依次输入年月日: ; in d._year d._month d._day; if (!d.CheckDate()) cout 日期非法 endl; return in; }6.3 日期类设计的几个思路思路一用实现而不是反过来很多人第一反应是先实现再用实现。但这样反了// 低效写法用 实现 Date operator(int day) { *this *this day; // 这里产生了一个临时对象多了一次拷贝 return *this; }正确思路是先实现直接修改自身没有拷贝再用实现需要产生新对象拷贝不可避免但不该在里产生。思路二比较运算符只实现和其他都复用bool operator(const Date d) const { return *this d || *this d; } bool operator(const Date d) const { return !(*this d); } bool operator(const Date d) const { return !(*this d); } bool operator!(const Date d) const { return !(*this d); }代码量减少一半逻辑更清晰修改也只需要改一处。思路三日期差值用暴力加一而不是数学计算int Date::operator-(const Date d) const { // 找出大的和小的然后一天一天往前走数步数 while (min ! max) { min; n; } return n * flag; }数学计算要考虑闰年、每月天数逻辑复杂容易出错。暴力加一虽然性能差一点但逻辑极其简单而且利用了已经写好的运算符没有重复代码。这种思路在面试手撕代码的时候很实用。七、const 成员函数7.1 为什么需要 const 成员函数考虑这个场景const Date d(2024, 7, 5); // 常量对象不允许修改 d.Print(); // ❓ 能调用吗Print函数的this指针类型是Date* const指针本身不变但可以通过指针修改对象。而d是const Date它的地址类型是const Date*。const Date*传给Date* const权限放大了本来只读现在可写编译器不允许报错。所以需要const成员函数把this指针变成const Date* constvoid Print() const // const 放在参数列表后面 { cout _year - _month - _day endl; // 在这个函数里不能修改任何成员变量 }7.2 const 成员函数的使用规则const对象只能调用const成员函数非const对象既能调用普通成员函数也能调用const成员函数权限缩小是允许的不修改成员变量的函数都应该加const这是一个好习惯祖师爷怕有咱们写成 thisnullptrconst Date d1(2024, 7, 5); d1.Print(); // ✅ const 对象调用 const 函数 // d1 100; // ❌ const 对象不能调用非 const 函数 Date d2(2024, 7, 5); d2.Print(); // ✅ 非 const 对象调用 const 函数权限缩小允许 d2 100; // ✅ 非 const 对象调用非 const 函数八、取地址运算符重载这个是六大默认成员函数里最不重要的简单了解就行。哈哈再说她可就要爆炸ing此时的“取地址运算符重载”默默的碎了/(ㄒoㄒ)/~~class Date { public: Date* operator() { return this; // 返回对象地址 } const Date* operator() const { return this; } };编译器自动生成的版本就是上面这样日常使用完全够了。有一个特殊的使用场景如果你不想让别人取到对象的地址可以自己实现这个函数并返回nullptr或者乱七八糟的地址把对方骗过去。但实际开发里几乎用不到。可以说是合法但是不道德呃♀️九、面试高频考点汇总Q1不能重载的运算符有哪些..*::sizeof?:这五个背下来。Q2构造函数能是虚函数吗析构函数呢构造函数不能是虚函数。析构函数可以是虚函数而且在继承场景下基类的析构函数通常都应该写成虚函数这个后面学继承和多态时会深入讲。Q3什么情况下必须自己写拷贝构造和赋值运算符类里面有指针成员指向堆上资源的时候。记住那个小技巧显式写了析构 → 必须写拷贝构造 → 必须写赋值运算符重载。Q4前置 和后置 哪个效率更高前置 。后置 需要保存一份加之前的临时对象多了一次构造和析构的开销。对于内置类型int i编译器优化后差别不大但对于自定义类型优先用前置 。Q5赋值运算符为什么要返回引用为什么要检查自赋值返回引用是为了① 减少一次拷贝的开销② 支持d1 d2 d3这样的链式赋值。检查自赋值是因为如果Stack类里先free(_a)再申请新内存自赋值时_a已经被释放读取d._top等数据会访问野指针程序崩溃。Q6const 成员函数里能修改成员变量吗不能直接报错。但有一个特殊关键字mutable加了mutable的成员变量在const函数里也可以修改用于统计函数调用次数等场景实际开发中很少用。十、总结把这篇文章的知识点用一张图梳理一下六大默认成员函数│├── 构造函数 → 初始化对象替代 Init│ └── 编译器默认内置类型不处理自定义类型调用其构造│├── 析构函数 → 释放资源替代 Destroy│ └── 编译器默认内置类型不处理自定义类型调用其析构│├── 拷贝构造 → 用已有对象初始化新对象│ └── 编译器默认浅拷贝有指针成员时危险需要深拷贝│├── 赋值运算符 → 两个已存在对象之间赋值│ └── 编译器默认浅拷贝同上│├── 取地址重载 → 一般用编译器默认的就行└── const 取地址重载 → 同上当然还有日期类这个经典类的实现下次我还会出专门一篇日期类将我的易错点供大家参考点赞收藏步迷了大家一起进步加油一句话记住核心规律如果类里有指针指向堆上的资源析构、拷贝构造、赋值运算符这三个必须自己写。其他情况交给编译器生成就好。下一篇初始化列表、static 成员变量、友元