C++ 类和对象中篇:默认成员函数、拷贝控制与运算符重载
写 C++ 类时,最容易被忽略的一类函数,往往不是普通成员函数,而是编译器会在背后帮我们生成的那些函数。
一个 Date 类只有 int 成员,看起来怎么拷贝都没问题;一个 Stack 类也只是几个成员变量,但只要里面有一块动态申请的空间,默认拷贝就可能引发二次释放。类和对象真正开始变复杂,通常就是从这些"默认成员函数"开始的。
这篇文章围绕 C++ 类的默认成员函数展开,重点讲清楚构造函数、析构函数、拷贝构造函数、赋值运算符重载、普通运算符重载、const 成员函数以及取地址运算符重载。
一、什么是类的默认成员函数
默认成员函数指的是:当我们没有显式实现时,编译器可能自动生成的特殊成员函数。
一个类通常会涉及 6 个默认成员函数:
#mermaid-svg-hqIuKj0w8nEgaD0Y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hqIuKj0w8nEgaD0Y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hqIuKj0w8nEgaD0Y .error-icon{fill:#552222;}#mermaid-svg-hqIuKj0w8nEgaD0Y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hqIuKj0w8nEgaD0Y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hqIuKj0w8nEgaD0Y .marker.cross{stroke:#333333;}#mermaid-svg-hqIuKj0w8nEgaD0Y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hqIuKj0w8nEgaD0Y p{margin:0;}#mermaid-svg-hqIuKj0w8nEgaD0Y .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hqIuKj0w8nEgaD0Y .cluster-label text{fill:#333;}#mermaid-svg-hqIuKj0w8nEgaD0Y .cluster-label span{color:#333;}#mermaid-svg-hqIuKj0w8nEgaD0Y .cluster-label span p{background-color:transparent;}#mermaid-svg-hqIuKj0w8nEgaD0Y .label text,#mermaid-svg-hqIuKj0w8nEgaD0Y span{fill:#333;color:#333;}#mermaid-svg-hqIuKj0w8nEgaD0Y .node rect,#mermaid-svg-hqIuKj0w8nEgaD0Y .node circle,#mermaid-svg-hqIuKj0w8nEgaD0Y .node ellipse,#mermaid-svg-hqIuKj0w8nEgaD0Y .node polygon,#mermaid-svg-hqIuKj0w8nEgaD0Y .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hqIuKj0w8nEgaD0Y .rough-node .label text,#mermaid-svg-hqIuKj0w8nEgaD0Y .node .label text,#mermaid-svg-hqIuKj0w8nEgaD0Y .image-shape .label,#mermaid-svg-hqIuKj0w8nEgaD0Y .icon-shape .label{text-anchor:middle;}#mermaid-svg-hqIuKj0w8nEgaD0Y .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hqIuKj0w8nEgaD0Y .rough-node .label,#mermaid-svg-hqIuKj0w8nEgaD0Y .node .label,#mermaid-svg-hqIuKj0w8nEgaD0Y .image-shape .label,#mermaid-svg-hqIuKj0w8nEgaD0Y .icon-shape .label{text-align:center;}#mermaid-svg-hqIuKj0w8nEgaD0Y .node.clickable{cursor:pointer;}#mermaid-svg-hqIuKj0w8nEgaD0Y .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hqIuKj0w8nEgaD0Y .arrowheadPath{fill:#333333;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hqIuKj0w8nEgaD0Y .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hqIuKj0w8nEgaD0Y .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hqIuKj0w8nEgaD0Y .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hqIuKj0w8nEgaD0Y .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hqIuKj0w8nEgaD0Y .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hqIuKj0w8nEgaD0Y .cluster text{fill:#333;}#mermaid-svg-hqIuKj0w8nEgaD0Y .cluster span{color:#333;}#mermaid-svg-hqIuKj0w8nEgaD0Y div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hqIuKj0w8nEgaD0Y .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hqIuKj0w8nEgaD0Y rect.text{fill:none;stroke-width:0;}#mermaid-svg-hqIuKj0w8nEgaD0Y .icon-shape,#mermaid-svg-hqIuKj0w8nEgaD0Y .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hqIuKj0w8nEgaD0Y .icon-shape p,#mermaid-svg-hqIuKj0w8nEgaD0Y .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hqIuKj0w8nEgaD0Y .icon-shape .label rect,#mermaid-svg-hqIuKj0w8nEgaD0Y .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hqIuKj0w8nEgaD0Y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hqIuKj0w8nEgaD0Y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hqIuKj0w8nEgaD0Y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 默认成员函数
初始化与清理
拷贝控制
取地址重载
构造函数
析构函数
拷贝构造函数
赋值运算符重载
普通对象取地址
const 对象取地址
这 6 个函数并不是都需要我们亲自写。判断的关键不在于"能不能自动生成",而在于自动生成的行为是否符合类的语义。
| 默认成员函数 | 主要作用 | 什么时候需要自己写 |
|---|---|---|
| 构造函数 | 对象创建时初始化成员 | 需要明确初始化规则时 |
| 析构函数 | 对象销毁时清理资源 | 类中申请了堆空间、文件句柄等资源时 |
| 拷贝构造函数 | 用已有对象初始化新对象 | 默认浅拷贝不够用时 |
| 赋值运算符重载 | 两个已存在对象之间赋值 | 默认浅拷贝不够用时 |
| 普通取地址重载 | 对普通对象取地址 | 极少需要手写 |
| const 取地址重载 | 对 const 对象取地址 | 极少需要手写 |
从经验上看,如果一个类显式写了析构函数,并且析构函数里释放了资源,那么拷贝构造函数和赋值运算符重载也要重点检查。资源类最怕默认浅拷贝,表面上能编译,运行时才出问题。
二、构造函数:对象创建时自动初始化
构造函数是特殊的成员函数。它的名字和类名相同,没有返回值,对象实例化时会被自动调用。
构造函数容易被误解成"创建对象的函数"。更准确地说,对象空间的开辟通常已经由运行环境完成,构造函数负责的是初始化对象内部状态 。以前写 C 风格结构体时,我们经常手动调用 Init 函数;到了 C++,这件事应该交给构造函数。
cpp
#include <iostream>
using namespace std;
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;
};
int main() {
Date d1;
Date d2(2026, 7, 9);
d1.Print();
d2.Print();
return 0;
}
编译运行:
bash
g++ -std=c++11 date_demo.cpp -o date_demo
./date_demo
运行结果:
text
1-1-1
2026-7-9
这里的 Date(int year = 1, int month = 1, int day = 1) 是一个全缺省构造函数,不传参也能调用,所以它也是默认构造函数。
默认构造函数并不只指"编译器自动生成的无参构造"。只要创建对象时不需要传实参,就可以称为默认构造函数。常见形式有三种:
| 形式 | 示例 | 特点 |
|---|---|---|
| 无参构造函数 | Date() |
调用时不传参 |
| 全缺省构造函数 | Date(int year = 1, int month = 1, int day = 1) |
所有参数都有默认值 |
| 编译器生成的构造函数 | 类中没有显式构造函数 | 对自定义类型成员会调用其默认构造 |
这里有两个坑很常见。
一个类中不能同时保留无参构造函数和全缺省构造函数,因为 Date d; 到底调哪个会产生歧义。
另一个是 Date d3(); 并不是创建对象,而是声明了一个返回 Date、参数为空的函数。创建无参对象应写成:
cpp
Date d3;
编译器自动生成的构造函数还有一个细节:对 int、char、指针这类内置类型成员,不保证完成有意义的初始化;对自定义类型成员,会尝试调用这个成员自己的默认构造函数。如果成员类型没有默认构造函数,就需要使用初始化列表来解决,这属于后面更细的对象初始化问题。
三、析构函数:对象销毁时自动清理
析构函数和构造函数刚好相反。构造函数负责初始化,析构函数负责对象生命周期结束时的资源清理。
析构函数的写法是在类名前加 ~:
cpp
~Stack()
{
// 清理资源
}
它没有参数,没有返回值,一个类只能有一个析构函数。对象生命周期结束时,析构函数会自动调用。
对于 Date 这种只包含普通整型成员的类,不写析构函数通常没有问题。对于 Stack 这种内部申请堆空间的类,不写析构函数就会泄漏内存。
cpp
#include <cstdlib>
#include <iostream>
using namespace std;
typedef int STDataType;
class Stack {
public:
Stack(size_t capacity = 4) {
_array = static_cast<STDataType*>(malloc(sizeof(STDataType) * capacity));
if (_array == nullptr) {
perror("malloc failed");
_capacity = 0;
_top = 0;
return;
}
_capacity = capacity;
_top = 0;
}
~Stack() {
free(_array);
_array = nullptr;
_capacity = 0;
_top = 0;
}
private:
STDataType* _array;
size_t _capacity;
size_t _top;
};
析构函数不负责销毁对象本身。比如局部对象在栈帧上,函数结束后栈帧释放,对象空间自然消失。析构函数要做的是把对象内部管理的额外资源释放掉。
如果一个类里有自定义类型成员,即使我们显式写了析构函数,这些成员对象的析构函数仍然会自动调用。多个局部对象在同一个作用域内创建时,析构顺序与定义顺序相反,后定义的先析构。
四、构造和析构带来的代码变化
构造函数和析构函数的一个直接收益是:少写手动初始化和手动销毁代码。以前写 C 风格栈时,常见写法是:
cpp
ST st;
STInit(&st);
// 使用 st
STDestroy(&st);
一旦中途有多个 return 分支,就很容易忘记释放资源。C++ 写法可以把这件事交给对象生命周期管理。
下面用栈判断括号匹配。为了突出构造和析构,这里只看 IsValid 的核心逻辑,假设 Stack 已经提供 Push、Pop、Top、Empty 这几个接口。Stack 会在进入函数时构造,在函数退出时自动析构。
cpp
bool IsValid(const char* str) {
Stack st;
while (*str != '\0') {
if (*str == '(' || *str == '[' || *str == '{') {
st.Push(*str);
} else {
if (st.Empty()) {
return false;
}
char top = static_cast<char>(st.Top());
st.Pop();
if ((*str == ')' && top != '(')
|| (*str == ']' && top != '[')
|| (*str == '}' && top != '{')) {
return false;
}
}
++str;
}
return st.Empty();
}
这段代码的关键不是括号匹配算法本身,而是资源管理方式。函数里不再出现 Init 和 Destroy,提前返回也不会跳过析构函数。对象生命周期结束时,清理动作自动发生。
五、拷贝构造函数:用已有对象初始化新对象
拷贝构造函数也是构造函数的一种重载。它的典型写法如下:
cpp
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
参数必须使用当前类类型的引用,通常还会加上 const。如果写成传值:
cpp
Date(Date d)
逻辑上会出问题。因为传值本身就要拷贝对象,拷贝对象又要调用拷贝构造函数,于是会形成递归调用,编译器会直接报错。
拷贝构造发生在"用已有对象创建新对象"时:
cpp
Date d1(2026, 7, 9);
Date d2(d1); // 拷贝构造
Date d3 = d1; // 也是拷贝构造
很多初学者容易把下面两句混在一起:
cpp
Date d4 = d1; // 创建 d4,并用 d1 初始化,调用拷贝构造
d4 = d1; // d4 已经存在,调用赋值运算符重载
默认拷贝构造对内置类型成员做值拷贝。对于 Date 这种类,默认行为够用。但对于 Stack 这种管理堆空间的类,默认浅拷贝会出大问题。
假设 st1 中的 _array 指向一块堆空间,默认拷贝后:
cpp
Stack st2 = st1;
st1._array 和 st2._array 会指向同一块空间。两个对象析构时都会 free 这块空间,轻则崩溃,重则出现更隐蔽的内存错误。
资源类应该实现深拷贝:
cpp
#include <cassert>
#include <cstring>
#include <cstdlib>
#include <iostream>
using namespace std;
typedef int STDataType;
class Stack {
public:
Stack(size_t capacity = 4) {
_array = static_cast<STDataType*>(malloc(sizeof(STDataType) * capacity));
if (_array == nullptr) {
perror("malloc failed");
_capacity = 0;
_top = 0;
return;
}
_capacity = capacity;
_top = 0;
}
Stack(const Stack& st) {
_array = static_cast<STDataType*>(malloc(sizeof(STDataType) * st._capacity));
if (_array == nullptr) {
perror("malloc failed");
_capacity = 0;
_top = 0;
return;
}
memcpy(_array, st._array, sizeof(STDataType) * st._top);
_capacity = st._capacity;
_top = st._top;
}
~Stack() {
free(_array);
_array = nullptr;
_capacity = 0;
_top = 0;
}
void Push(STDataType x) {
if (_top == _capacity) {
size_t newCapacity = _capacity == 0 ? 4 : _capacity * 2;
STDataType* tmp = static_cast<STDataType*>(
realloc(_array, sizeof(STDataType) * newCapacity)
);
if (tmp == nullptr) {
perror("realloc failed");
return;
}
_array = tmp;
_capacity = newCapacity;
}
_array[_top++] = x;
}
void Pop() {
assert(_top > 0);
--_top;
}
STDataType Top() const {
assert(_top > 0);
return _array[_top - 1];
}
bool Empty() const {
return _top == 0;
}
private:
STDataType* _array;
size_t _capacity;
size_t _top;
};
这段代码的核心在 Stack(const Stack& st):新对象重新申请一块空间,再把旧对象中的有效元素拷贝过去。这样两个栈对象各自管理自己的空间,析构时互不影响。
还有一个容易踩坑的点:传值返回会产生拷贝,而引用返回不会产生拷贝。但引用返回有前提,返回的对象在函数结束后必须仍然存在。
cpp
Date& BadFunc() {
Date tmp(2026, 7, 9);
return tmp; // 错误:tmp 是局部对象,函数结束后就销毁
}
这种代码返回的是一个失效引用,后续使用结果不可预期。
六、运算符重载:让类对象也能使用运算符
当运算符作用于类类型对象时,C++ 允许我们通过 operator 函数重新定义它的含义。
比如日期对象可以比较大小:
cpp
bool operator==(const Date& d) const {
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
使用时可以写成:
cpp
if (d1 == d2) {
cout << "same date" << endl;
}
编译器会把它转换成类似下面的函数调用:
cpp
d1.operator==(d2);
运算符重载有几条硬规则:
| 规则 | 说明 |
|---|---|
| 不能创造新运算符 | 不能写 operator@ |
| 不能改变操作数个数 | 二元运算符仍然需要两个操作数 |
| 不能改变优先级和结合性 | 重载后的 + 优先级仍然和内置 + 一样 |
| 至少有一个类类型参数 | 不能重载 int + int 的含义 |
| 部分运算符不能重载 | .*、::、sizeof、?:、. 不能重载 |
++ 比较特殊,因为它有前置和后置两种形式。C++ 用一个额外的 int 形参区分后置版本:
cpp
Date& operator++(); // ++d
Date operator++(int); // d++
前置 ++ 修改对象后返回自身引用;后置 ++ 需要保留修改前的临时对象,所以通常返回值。
<< 和 >> 通常重载为全局函数,而不是成员函数。原因很简单:cout << d 的左操作数是 cout,如果写成 Date 的成员函数,调用形式会变成 d << cout,不符合使用习惯。
cpp
ostream& operator<<(ostream& out, const Date& d) {
out << d._year << "年" << d._month << "月" << d._day << "日";
return out;
}
如果这个函数要访问 Date 的私有成员,可以把它声明为友元函数。
七、赋值运算符重载:两个已有对象之间的拷贝
赋值运算符重载用于处理这种场景:
cpp
Date d1(2026, 7, 9);
Date d2(2026, 8, 1);
d1 = d2; // d1 和 d2 都已经存在
它和拷贝构造的区别一定要分清:
| 写法 | 含义 | 调用函数 |
|---|---|---|
Date d2(d1); |
创建新对象并初始化 | 拷贝构造函数 |
Date d3 = d1; |
创建新对象并初始化 | 拷贝构造函数 |
d1 = d2; |
已有对象之间赋值 | 赋值运算符重载 |
赋值运算符重载必须写成成员函数,推荐形式如下:
cpp
Date& operator=(const Date& d) {
if (this != &d) {
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
这里有三个关键点。
参数使用 const Date&,避免传值导致不必要的拷贝;返回值使用 Date&,是为了支持连续赋值:
cpp
d1 = d2 = d3;
返回 *this,表示赋值表达式的结果是左操作数本身。
对资源类来说,赋值运算符同样要做深拷贝。下面是在 Stack 中补上的版本:
cpp
Stack& operator=(const Stack& st) {
if (this == &st) {
return *this;
}
STDataType* tmp = static_cast<STDataType*>(
malloc(sizeof(STDataType) * st._capacity)
);
if (tmp == nullptr) {
perror("malloc failed");
return *this;
}
memcpy(tmp, st._array, sizeof(STDataType) * st._top);
free(_array);
_array = tmp;
_capacity = st._capacity;
_top = st._top;
return *this;
}
这个实现先申请新空间,成功后再释放旧空间,可以避免申请失败时把原对象破坏掉。this == &st 用来处理自己给自己赋值的情况。
八、日期类中的运算符设计思路
日期类很适合练习运算符重载,因为它天然支持比较、加减天数、前后置自增、自减、日期相减等操作。
一个简化版接口可以这样设计:
cpp
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;
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 GetMonthDay(int year, int month) const;
bool CheckDate() const;
private:
int _year;
int _month;
int _day;
};
比较运算符可以复用,没必要每个都从头写一遍:
cpp
bool Date::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;
}
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);
}
+= 负责真正修改对象,+ 只需要创建临时对象并复用 +=:
cpp
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;
}
Date Date::operator+(int day) const {
Date tmp = *this;
tmp += day;
return tmp;
}
前置 ++ 和后置 ++ 也可以复用已有逻辑:
cpp
Date& Date::operator++() {
*this += 1;
return *this;
}
Date Date::operator++(int) {
Date tmp = *this;
*this += 1;
return tmp;
}
这种写法的好处是修改日期的核心逻辑集中在 +=、-= 中,其他运算符只做薄封装。后续如果日期进位逻辑要调整,维护成本会低很多。
九、const 成员函数:限制 this 指针的权限
成员函数后面加 const,称为 const 成员函数:
cpp
void Print() const {
cout << _year << "-" << _month << "-" << _day << endl;
}
它修饰的不是返回值,而是隐藏的 this 指针。
普通成员函数中的 this 可以理解为:
cpp
Date* const this
const 成员函数中的 this 可以理解为:
cpp
const Date* const this
这意味着:在 const 成员函数内部,不能修改成员变量。
cpp
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;
};
int main() {
Date d1(2026, 7, 9);
d1.Print();
const Date d2(2026, 8, 1);
d2.Print();
return 0;
}
非 const 对象可以调用 const 成员函数,因为这是权限缩小;const 对象不能调用普通成员函数,因为那可能修改对象。
所以像 Print、比较运算符、operator+ 这类不修改对象的函数,建议都加上 const。
十、取地址运算符重载:了解即可
取地址运算符重载分为普通版本和 const 版本:
cpp
class Date {
public:
Date* operator&() {
return this;
}
const Date* operator&() const {
return this;
}
private:
int _year;
int _month;
int _day;
};
大多数情况下,编译器自动生成的版本完全够用,不需要手写。只有在很少见的场景下,比如不想让外部拿到对象真实地址,才可能自己实现。不过日常写业务类、数据结构类时,很少会动这两个函数。
十一、常见问题与易错点
Date d(); 为什么不是对象?
这是函数声明。创建无参对象应写 Date d;。
什么时候需要自己写析构函数?
类中管理了需要手动释放的资源时需要写。比如 malloc 申请的空间、打开的文件、系统句柄等。只包含普通内置类型成员时,默认析构通常就够了。
为什么拷贝构造参数要写成引用?
传值会触发拷贝,拷贝又需要调用拷贝构造,逻辑上会递归。写成 const T& 可以避免这个问题。
浅拷贝为什么危险?
浅拷贝只复制指针值,不复制指针指向的资源。两个对象指向同一块空间后,析构时可能二次释放,修改其中一个对象也可能影响另一个对象。
赋值运算符重载为什么返回引用?
为了减少拷贝,并支持连续赋值。d1 = d2 = d3; 中,d2 = d3 的结果应该还能继续赋给 d1。
为什么 << 和 >> 常写成全局函数?
因为左操作数分别是 ostream 和 istream。写成全局函数后,调用形式才能保持 cout << d、cin >> d 这样的习惯写法。
哪些运算符不能重载?
.*、::、sizeof、?:、. 不能重载。也不能创造 C++ 中不存在的运算符。
const 对象为什么只能调用 const 成员函数?
const 对象不允许被修改,普通成员函数没有承诺"不修改对象"。只有函数后面带 const,编译器才能确认调用是安全的。
十二、总结
类的默认成员函数是 C++ 对象模型里绕不开的一块。构造函数和析构函数解决对象初始化、清理问题;拷贝构造和赋值运算符重载决定对象如何复制;运算符重载让类对象用起来更接近内置类型;const 成员函数则把"能不能修改对象"这件事交给编译器检查。
写普通数据类时,很多默认行为已经够用。写资源管理类时,默认浅拷贝往往不够安全,需要自己实现深拷贝。判断一个类是否要手写这些函数,可以抓住一个问题:这个对象是否独占或管理某种资源?如果答案是肯定的,构造、析构、拷贝构造和赋值运算符重载就要放在一起考虑。