C++ 类与对象(中):六大默认成员函数
上一篇 C++ 类与对象(上) 讲了类的定义、实例化和 this 指针。这一篇深入类的内部机制——那些你不写编译器也会帮你生成的函数。
为什么需要默认成员函数
上篇我们手动写了 Init 和 Destroy 来初始化和清理对象:
Stack st;
st.Init(); // 每次创建对象都要手动调用——忘了就出事
st.Push(1);
st.Destroy(); // 每次销毁对象都要手动调用——忘了就泄漏
如果一个类的使用者有 100 个人,每个人都要记住"创建后调 Init、销毁前调 Destroy"——封装的秩序感荡然无存。C++ 用默认成员函数解决这个问题:编译器在特定时机自动调用它们,把初始化和清理写进对象的生命周期里,而不是靠使用者的自觉。
一个类在不写任何代码的情况下,编译器会默认生成 6 个成员函数:
| 序号 | 函数 | 重要性 |
|---|---|---|
| 1 | 构造函数(Constructor) | ★★★ |
| 2 | 析构函数(Destructor) | ★★★ |
| 3 | 拷贝构造函数(Copy Constructor) | ★★★ |
| 4 | 赋值运算符重载(Copy Assignment Operator) | ★★★ |
| 5 | 普通取地址运算符重载 | ★ |
| 6 | const 取地址运算符重载 | ★ |
C++11 之后还新增了两个:移动构造和移动赋值,后面再讲。学习每个默认成员函数,都从两个维度入手:
- 我们不写时,编译器默认生成的版本做了什么?是否满足需求?
- 不满足需求时,我们如何自己实现?
构造函数
基本规则
构造函数(Constructor)的名称与类名相同,没有返回值(连 void 也不写——这是 C++ 的规定)。对象实例化时系统自动调用对应的构造函数。
它的主要任务不是"开空间创建对象"(局部对象的空间在栈帧创建时就已经分配好了),而是初始化对象。本质上,构造函数就是要替代上篇中手动写的 Init 函数。
#include <iostream>
class Date {
public:
// 1. 无参构造函数
Date() {
_year = 1;
_month = 1;
_day = 1;
}
// 2. 带参构造函数
Date(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
// 3. 全缺省构造函数(和无参构造只能二选一,原因见下文)
// Date(int year = 1, int month = 1, int day = 1) { ... }
void Print() {
std::cout << _year << "/" << _month << "/" << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1; // 调用无参构造函数
Date d2(2025, 1, 1); // 调用带参构造函数
// 注意:无参构造的对象后面不要跟括号!
// Date d3(); // 编译器会把它当成函数声明,而非对象定义
return 0;
}
关键规则总结:
- 无参构造、全缺省构造、编译器默认生成的构造,三者统称为默认构造函数——即不需要传实参就能调用的构造函数。三者只能存在一个,无参构造和全缺省构造同时存在时,
Date d1;会让编译器不知道调用谁。 - 构造函数支持重载,可以根据参数类型和数量定义多个版本。
- 一旦你显式定义了任意构造函数,编译器就不再自动生成无参默认构造函数。如果你写了一个带参构造但没写无参构造,
Date d1;就会报错——找不到默认构造函数。 - 无参创建对象时,对象名后面不能加括号:
Date d3();会被解析为"声明一个返回 Date 的函数",而非定义对象。
💡 背景补充:这条语法歧义继承自 C——C 语言中
int f();是函数声明,C++ 不得不兼容它。在 C++ 中这被称为"最烦人的解析(Most Vexing Parse)"。
编译器默认生成的构造函数
不写构造函数时,编译器生成的默认构造:
- 对内置类型(int、char、指针等)成员变量:不保证初始化——值取决于编译器,可能是随机值。
- 对自定义类型成员变量:调用该成员自己的默认构造函数。
class Stack {
public:
Stack(int n = 4) { // 自定义构造函数,带缺省参数
_a = (int*)malloc(sizeof(int) * n);
_capacity = n;
_top = 0;
}
private:
int* _a;
size_t _capacity;
size_t _top;
};
// MyQueue 没有写构造函数,但它的两个 Stack 成员会调用 Stack 的默认构造
class MyQueue {
private:
Stack pushst; // 自动调用 Stack(4) 初始化
Stack popst; // 自动调用 Stack(4) 初始化
};
int main() {
MyQueue mq; // pushst 和 popst 都自动初始化好了
return 0;
}
📖 参考:《C++ Primer Plus》第10章:构造函数的自动调用机制;《高质量C++/C编程指南》第9章:编译器自动生成函数的规则。
析构函数
析构函数(Destructor)与构造函数功能相反。它的名字是 ~类名,无参数无返回值。对象生命周期结束时,系统自动调用析构函数。
注意:析构函数不是销毁对象本身(局部对象的空间由栈帧自动回收),而是释放对象持有的资源——比如 malloc 申请的内存、打开的文件句柄等。
#include <iostream>
typedef int STDataType;
class Stack {
public:
Stack(int n = 4) {
_a = (STDataType*)malloc(sizeof(STDataType) * n);
_capacity = n;
_top = 0;
}
~Stack() {
std::cout << "~Stack()" << std::endl;
free(_a); // 释放 malloc 的资源——不能漏
_a = nullptr;
_top = _capacity = 0;
}
// ... Push, Pop, Top 等方法
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
几条重要规则:
- 一个类只能有一个析构函数,不可重载。
- 编译器默认生成的析构函数,对内置类型成员不做处理,对自定义类型成员会调用它的析构函数。
- 如果类没有申请资源(如
Date只有三个int),可以不写析构函数,用编译器生成的就够了。 - 如果类申请了资源(如
Stack里malloc了_a),必须自己写析构函数释放——否则就是资源泄漏。
📖 参考:《Effective C++》条款13:以对象管理资源——构造函数获取资源,析构函数释放资源,这就是 RAII 的基本实践。
析构顺序
同一个局部域内的多个对象,C++ 规定后定义的先析构:
int main() {
Stack st; // 先构造
MyQueue mq; // 后构造
return 0;
// 析构顺序:先 ~MyQueue(),再 ~Stack()
}
C vs C++:构造函数 + 析构函数的实际价值
用括号匹配 isValid 为例对比。C 版本必须手动调用 STInit + STDestroy,每个 return 分支都要记得销毁——漏一处就是内存泄漏;C++ 版本创建即初始化,离开作用域自动清理,所有分支都不需要操心。
// C++ 版本:构造函数和析构函数自动管理,代码干净
bool isValid(const char* s) {
Stack st; // 自动调用构造
while (*s) {
if (*s == '[' || *s == '(' || *s == '{') {
st.Push(*s);
} else {
if (st.Empty()) return false; // 自动析构——不用手动写 Destroy
char top = st.Top();
st.Pop();
if ((*s == ']' && top != '[')
|| (*s == '}' && top != '{')
|| (*s == ')' && top != '('))
return false; // 自动析构——不用手动写 Destroy
}
++s;
}
return st.Empty(); // 自动析构——不用手动写 Destroy
}
拷贝构造函数
什么是拷贝构造
如果一个构造函数的第一个参数是自身类类型的引用,且其他参数都有默认值,那它就是拷贝构造函数(Copy Constructor)。本质上是构造函数的一个重载。
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;
};
⚠️ 拷贝构造的第一个参数必须是引用。如果写成传值
Date(const Date d),编译器直接报错——因为传值本身就要调用拷贝构造,触发无限递归。
拷贝构造的调用时机:
Date d1(2024, 7, 5);
Date d2(d1); // 场景一:用同类型对象初始化新对象——调用拷贝构造
Date d3 = d1; // 场景二:写法不同,本质也是拷贝构造(不是赋值!)
void Func(Date d) { /* ... */ }
Func(d1); // 场景三:传值传参——拷贝构造
Date Func2() {
Date tmp;
return tmp; // 场景四:传值返回——拷贝构造(产生临时对象)
}
浅拷贝 vs 深拷贝
编译器默认生成的拷贝构造做的是浅拷贝(Shallow Copy)——把每个成员变量逐字节复制过去。对于 Date 这样所有成员都是 int 的类,浅拷贝完全够用。
但对于 Stack 这样的类,_a 是指向 malloc 出来的堆内存的指针。浅拷贝只会复制指针的值,结果 st1 和 st2 的 _a 指向同一块内存:
浅拷贝前: st1._a → [1, 2, 3, ...]
浅拷贝后: st1._a → [1, 2, 3, ...] ← st2._a (两个指针指向同一块!)
析构时,两个对象的析构函数都会 free(_a)——同一块内存被释放两次,程序崩溃。
必须自己实现深拷贝(Deep Copy):
class Stack {
public:
Stack(int n = 4) {
_a = (STDataType*)malloc(sizeof(STDataType) * n);
_capacity = n;
_top = 0;
}
// 深拷贝:重新分配内存,复制数据内容
Stack(const Stack& st) {
_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
memcpy(_a, st._a, sizeof(STDataType) * st._top);
_top = st._top;
_capacity = st._capacity;
}
~Stack() {
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
一个实用判断法则:如果类显式实现了析构函数并释放了资源,那么几乎一定需要显式写拷贝构造函数。
📖 参考:《高质量C++/C编程指南》第9章:编译器默认生成的拷贝构造是位拷贝(浅拷贝),含指针成员的类必定出错。《C++ Primer Plus》第12章:需要深拷贝的三种信号——自定义析构、复制构造、赋值运算符。
赋值运算符重载
运算符重载基础
C++ 允许给自定义类型定义运算符行为,叫做运算符重载(Operator Overloading)。格式为 operator运算符:
class Date {
public:
// 重载 == 为成员函数:左操作数通过 this 传入,右操作数是参数 d
bool operator==(const Date& d) const {
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
// 前置++:返回自增后的引用
Date& operator++() {
*this += 1;
return *this;
}
// 后置++:int 参数仅为区分前置,不实际使用
Date operator++(int) {
Date tmp(*this);
*this += 1;
return tmp;
}
private:
int _year, _month, _day;
};
int main() {
Date d1(2024, 7, 5), d2(2024, 7, 6);
d1 == d2; // 编译器转换为 d1.operator==(d2)
++d1; // 转换为 d1.operator++()
d1++; // 转换为 d1.operator++(0)
}
运算符重载的关键约束:
- 不能重载的五个运算符:
.*::sizeof?:. - 不能创建新的运算符(如
operator@) - 必须至少有一个操作数是类类型——不能通过重载改变
int + int的含义 - 重载为成员函数时,第一个操作数通过
this传入,参数比操作数少一个
<< 和 >> 的重载必须写成全局函数(搭配友元),因为如果写成成员函数,this 会占据左侧操作数——结果你得写 d << cout 而不是 cout << d,不符合使用习惯。
赋值运算符重载
赋值运算符重载也是默认成员函数,用于在两个已存在的对象之间赋值。注意和拷贝构造的区分:
| 场景 | 调用的是 |
|---|---|
Date d2 = d1; |
拷贝构造(用 d1 初始化正在创建的 d2) |
d2 = d1; |
赋值重载(两个对象都已经存在) |
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;
}
Date& operator=(const Date& d) { // 赋值重载
if (this != &d) { // 防止自我赋值
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this; // 返回引用以支持连续赋值
}
private:
int _year, _month, _day;
};
int main() {
Date d1(2024, 7, 5);
Date d2(d1); // 拷贝构造——d2 正在被创建
Date d3(2024, 7, 6);
d1 = d3; // 赋值重载——d1 早已存在
Date d4 = d1; // 这仍然是拷贝构造!不是赋值!
// d4 是刚创建的对象,= 被解释为初始化而非赋值
}
和拷贝构造类似的规律:
- 编译器默认生成的赋值重载也是浅拷贝,逐字节复制。
Date这种纯内置成员的类,默认赋值就够了。Stack这种有资源指针的类,必须自己实现深拷贝赋值(释放旧资源 → 分配新资源 → 复制 → 返回*this)。- 一旦显式写了析构函数释放资源,就需要同时显式写拷贝构造和赋值重载——这就是"Big Three"原则。
📖 参考:《高质量C++/C编程指南》第9章:赋值函数的标准四步——检查自赋值 → 释放旧资源 → 分配新资源并复制 → 返回
*this引用。
日期类实战
以下是一个完整的 Date 类实现,综合运用了构造函数、拷贝构造、赋值重载、运算符重载、<</>> 流插入、const 成员函数。这里只展示接口和关键实现:
class Date {
friend std::ostream& operator<<(std::ostream& out, const Date& d);
friend std::istream& operator>>(std::istream& in, Date& d);
public:
Date(int year = 1900, int month = 1, int day = 1);
bool operator<(const Date& d) const;
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;
bool operator!=(const Date& d) const { return !(*this == d); }
Date& operator+=(int day); // 返回引用,支持链式,高效
Date operator+(int day) const; // 返回新对象,不改变自身
Date& operator-=(int day);
Date operator-(int day) const;
int operator-(const Date& d) const; // d1 - d2 相差天数
Date& operator++(); // 前置++
Date operator++(int); // 后置++(int 仅为区分)
Date& operator--();
Date operator--(int);
private:
int _year, _month, _day;
};
设计原则在代码中体现得很清楚:
operator+复用operator+=(反之则不行——+如果复用+=,每次+都要多构造一次临时对象)。- 关系运算符复用核心判断逻辑——六个比较符只需写好
<和==,其余四个都通过组合实现,保证一致性。 const成员函数中不能调用非const版本的操作——这是const正确性的基本要求。
const 成员函数
将 const 写在成员函数的参数列表后面,修饰的是隐式 this 指针:
class Date {
public:
void Print() const {
// 编译器实际将 this 的类型从 Date* const
// 转为 const Date* const
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
};
int main() {
Date d1(2024, 7, 5);
d1.Print(); // 非 const 对象可以调用 const 函数(权限缩小)
const Date d2(2024, 8, 5);
d2.Print(); // const 对象只能调用 const 成员函数
// d2 += 100; // 编译报错:非 const 函数修改成员,不允许
}
const 对象只能调用 const 成员函数,非 const 对象两者都能调。这是权限逻辑:const 承诺不修改,传给更宽松的非 const 函数自然不行——你不能把一个"只读"标签贴给一个"可写"的操作。
取地址运算符重载
一般不需要自己写——编译器生成的默认版本返回 this 就够用了。只有极端场景(比如不想让别人获取对象地址)才需要重载,返回 nullptr 或其他地址。
常见误区
误区一:Date d1(); 是想创建对象
编译器把 Date d1(); 解析为函数声明而非对象定义。无参构造调用不要加括号——Date d1; 即可。
误区二:分不清拷贝构造和赋值
Date d2 = d1; // 拷贝构造——d2 刚出生,= 是初始化语法糖
d2 = d3; // 赋值重载——d2 早已存在
误区三:有指针成员的类不写深拷贝
浅拷贝让两个对象的指针指向同一块堆内存,析构时重复释放导致崩溃。
本节要点
- 构造函数 = 自动调用的 Init。名同类名、无返回、可重载。默认构造函数是不传实参就能调用的。
- 析构函数 = 自动调用的 Destroy。
~类名(),无参无返回,不可重载。有资源申请的类必须自己写。 - 拷贝构造第一个参数必须是自身类类型的引用。传值会触发无限递归。
- 浅拷贝只复制指针,深拷贝复制指针指向的数据。类中有
malloc/new出来的资源,必须实现深拷贝。 - 赋值 ≠ 拷贝构造。前者用于已存在的对象,后者用于正在创建的对象。两者都需要深拷贝时一并实现——Big Three 缺一不可。
📖 参考:《C++ Primer Plus》第10-12章 —— 构造函数、析构函数、运算符重载、动态内存与深拷贝;《高质量C++/C编程指南》第9章 —— 构造函数、析构函数与赋值函数的 Big Three 规范;《Effective C++》条款13、22 —— RAII 与封装原则。
迪亚波罗学编程
更多推荐



所有评论(0)