这篇文章是我学习 C++ 类和对象的核心笔记。如果你觉得构造函数、拷贝构造、赋值重载这些概念很绕,说明你没有从"为什么需要它"这个角度去理解。我们从头捋一遍。
目录
[2.1 为什么需要构造函数?](#2.1 为什么需要构造函数?)
[2.2 构造函数的规则](#2.2 构造函数的规则)
[2.3 一个容易踩的坑](#2.3 一个容易踩的坑)
[2.4 什么叫"默认构造函数"?](#2.4 什么叫"默认构造函数"?)
[2.5 编译器自动生成的构造函数做了什么?](#2.5 编译器自动生成的构造函数做了什么?)
[3.1 为什么需要析构函数?](#3.1 为什么需要析构函数?)
[3.2 析构函数的规则](#3.2 析构函数的规则)
[3.3 编译器自动生成的析构函数做了什么?](#3.3 编译器自动生成的析构函数做了什么?)
[3.4 析构的调用顺序](#3.4 析构的调用顺序)
[4.1 为什么需要拷贝构造?](#4.1 为什么需要拷贝构造?)
[4.2 拷贝构造的语法](#4.2 拷贝构造的语法)
[4.3 为什么参数必须是引用?](#4.3 为什么参数必须是引用?)
[4.4 编译器生成的拷贝构造:浅拷贝](#4.4 编译器生成的拷贝构造:浅拷贝)
[4.5 解决方案:深拷贝](#4.5 解决方案:深拷贝)
[4.6 传值传参和传值返回都会调用拷贝构造](#4.6 传值传参和传值返回都会调用拷贝构造)
[4.7 一个判断是否需要写拷贝构造的小技巧](#4.7 一个判断是否需要写拷贝构造的小技巧)
[5.1 运算符重载是什么?](#5.1 运算符重载是什么?)
[5.2 前置 ++ 和后置 ++ 怎么区分?](#5.2 前置 ++ 和后置 ++ 怎么区分?)
[5.3 << 和 >> 为什么要重载为全局函数?](#5.3 << 和 >> 为什么要重载为全局函数?)
[5.4 赋值运算符重载](#5.4 赋值运算符重载)
[5.5 编译器默认生成的赋值运算符](#5.5 编译器默认生成的赋值运算符)
[6.1 头文件(接口声明)](#6.1 头文件(接口声明))
[6.2 实现文件(函数定义)](#6.2 实现文件(函数定义))
[6.3 日期类设计的几个思路](#6.3 日期类设计的几个思路)
[七、const 成员函数](#七、const 成员函数)
[7.1 为什么需要 const 成员函数?](#7.1 为什么需要 const 成员函数?)
[7.2 const 成员函数的使用规则](#7.2 const 成员函数的使用规则)
[Q4:前置 ++ 和后置 ++ 哪个效率更高?](#Q4:前置 ++ 和后置 ++ 哪个效率更高?)
[Q6:const 成员函数里能修改成员变量吗?](#Q6:const 成员函数里能修改成员变量吗?)
一、什么是默认成员函数?
先问自己一个问题:你写了一个空类,里面什么都没写,这个类真的"空"吗?
class Empty {};
不空。C++ 编译器会偷偷给你生成 6 个默认成员函数:
- 构造函数
- 析构函数
- 拷贝构造函数
- 赋值运算符重载
- 取地址重载
- const 取地址重载
后两个基本用不上,最重要的是前四个。而且 C++11 以后还多了移动构造和移动赋值,这个后面再说。
学习这些默认函数,要从两个角度去想:
- 编译器默认生成的行为是什么?能不能满足我的需求?
- 如果不满足,我自己怎么写?
带着这两个问题,我们逐一来看。
二、构造函数
2.1 为什么需要构造函数?
在 C 语言里,你创建一个结构体之后,必须手动调用 Init 函数初始化,否则里面都是垃圾值。
ST s;
STInit(&s); // 忘了这行,后面就崩了
这件事C++觉得很烦,因为"忘记初始化"是一个极其常见的 bug。C++ 想解决这个问题,于是引入了构造函数:对象创建的那一刻,自动完成初始化,想忘都忘不了。
2.2 构造函数的规则
构造函数有几个语法规定,记住就行:
- 函数名必须和类名相同
- 没有返回值(连 void 都不写,C++ 就是这么规定的)
- 对象实例化时系统自动调用
- 可以重载(可以写多个)
cpp
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 year=1, int month=1, int day=1) {} - 编译器自动生成的构造函数
简单来说就是可以不传参数就可以调用(但是不是一定不传);
这三种有且只有一个能存在。无参和全缺省虽然构成函数重载,但调用时 Date d1; 编译器不知道该调用哪个,产生歧义,直接报错。
2.5 编译器自动生成的构造函数做了什么?
这里是很多人搞不清楚的地方。编译器生成的默认构造函数:
- 对内置类型 (int、double、指针等):不做任何处理,值是随机垃圾值(不是所有编译器都初始化内置类型)
- 对自定义类型 (class/struct 定义的类型):调用它的默认构造函数
cpp
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++祖师爷依旧看不惯:)
cpp
bool isValid(const char* s)
{
ST st;
STInit(&st);
// ...
if (某个条件)
{
STDestroy(&st); // 每个 return 前都要写
return false;
}
// ...
STDestroy(&st); // 正常结束也要写
return true;
}
C++ 的析构函数:对象生命周期结束时,自动调用,自动释放资源。
3.2 析构函数的规则
- 函数名是类名前加
~ - 无参数、无返回值
- 一个类只能有一个析构函数(不能重载)
- 对象生命周期结束时自动调用
cpp
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 可是为什么指向资源必须自己写,编译器析构她的过程是怎样的?
指针本身不是资源,指针只是"地址变量"。
编译器不知道这个地址代表什么,所以 它不敢帮你释放资源。
看一个最简单的例子
cpp
class A
{
public:
int* p;
A()
{
p = new int(10);
}
};
对象A a;
内存结构是这样的:
栈区
┌─────────┐
│ a │
│p 只是一个地值 ┼────────┐
└─────────┘ │
│
堆区 ▼
┌───────┐
│ 10 │
└───────┘
注意:p 只是一个地址
对象销毁时发生什么
当 a 销毁:
编译器默认析构函数
~A()
{
// 什么都不做
}
于是栈空间回收
变成:栈区(对象消失)
堆区
┌───────┐
│ 10 │ ← 还在
└───────┘
这就叫:
内存泄漏(memory leak)
因为:没有指针再指向这块堆内存
3.4 析构的调用顺序
同一个局部域内,后定义的对象先析构 ,类似栈的 LIFO 顺序。
cpp
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 拷贝构造的语法
拷贝构造是一种特殊的构造函数,第一个参数必须是自身类类型的引用。
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;
int _month;
int _day;
};
4.3 为什么参数必须是引用?
这是一个很有意思的推理过程,面试也常考。
假设拷贝构造写成值传递:
Date(Date d) // ❌ 编译直接报错
调用拷贝构造时,需要先把实参拷贝给形参 d。但是"把实参拷贝给形参"这个操作本身,就是一次拷贝行为,又要调用拷贝构造......然后又要拷贝实参给形参......无穷递归,直到栈溢出。
形象一些就是函数形成了自依赖,函数开始解决问题的前提是函数结果
所以参数必须是引用,引用不产生拷贝,直接绑定到原对象。
加const是为了保证传入的对象不被修改。
4.4 编译器生成的拷贝构造:浅拷贝
如果你没有写拷贝构造,编译器会自动生成一个,行为是值拷贝(浅拷贝):把每个成员变量的值原样复制过去。
内置类型,和Date这种类,浅拷贝完全够用。他是按字节来拷贝;
但对于 Stack 这种类,浅拷贝会出大问题:
cpp
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 解决方案:深拷贝
cpp
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 传值传参和传值返回都会调用拷贝构造
cpp
void Func1(Date d) // 传值传参:d1 传进来时调用一次拷贝构造
{
d.Print();
}
Date Func2()
{
Date tmp(2024, 7, 5);
return tmp; // 传值返回:产生一个临时对象,调用一次拷贝构造
}
所以能用引用传参就用引用传参,能用引用返回就用引用返回,避免不必要的拷贝开销。
但引用返回有一个前提:返回的对象在函数结束后还活着 。如果返回的是局部变量的引用,函数结束局部变量销毁,引用就变成野引用了,相当于野指针:
cpp
Date& Func2()
{
Date tmp(2024, 7, 5);
return tmp; // ⚠️ 危险!tmp 函数结束就销毁了
// 返回的引用是野引用
}
4.7 一个判断是否需要写拷贝构造的小技巧
如果一个类显式写了析构函数并释放了资源,那它几乎一定也需要写拷贝构造。
反过来,如果析构函数用编译器默认生成的就够了,拷贝构造通常也不需要自己写。这两个经常成对出现。
五、赋值运算符重载
5.1 运算符重载是什么?
C++ 允许我们为类类型的对象重新定义运算符的含义。语法是用 operator 加上运算符名字作为函数名:
cpp
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?:这五个运算符不能重载(面试选择题常考)
说简单的:
- 为类类型定义这个运算符如何工作
- 将运算符转换为函数调用,使用户自定义类型能够像内置类型一样参与运算。
cpp
operator+(int a, int b)
{
return a - b;
}
两个都是 **内置类型,**编译器直接拒绝。当然也太反常识,没有现实意义;
如果重载为成员函数,this 指针占据第一个参数位置,所以参数比运算对象少一个:
C++规定运算符重载函数至少有一个操作数必须是用户自定义类型,从而防止程序员改变内置类型运算符的行为。
cpp
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 的值没有实际意义:
cpp
// 前置 ++:++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 << 和 >> 为什么要重载为全局函数?
如果重载为成员函数:
cpp
void operator<<(ostream& out)
{
out << _year << "-" << _month << "-" << _day;
}
调用时变成 d1 << cout,因为 this 指针占了第一个参数,d1 就是左侧运算对象。这不符合使用习惯。
如果左边不是你的类,比如:cout << d
左边是 ostream,所以必须写 全局函数。
所以要重载为全局函数,把 ostream 放第一个参数:
cpp
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日";
return out; // 返回 out 是为了支持链式调用 cout << d1 << d2
}
为什么返回 ostream&:因为要支持 连续输出:
但这样全局函数访问不了 Date 的私有成员,解决方法是把这个函数声明为 Date 的友元函数:
cpp
class Date
{
friend ostream& operator<<(ostream& out, const Date& d); // 友元声明
// ...
};
5.4 赋值运算符重载
赋值运算符重载是一个默认成员函数,必须重载为成员函数(不能是全局函数)。
注意区分赋值运算符重载和拷贝构造:
cpp
Date d1(2024, 7, 5);
Date d2(d1); // 拷贝构造:d2 是新创建的对象
Date d3 = d1; // 拷贝构造!虽然用了 =,但 d3 是新创建的
// 不要被 = 迷惑
Date d4(2024, 8, 1);
d4 = d1; // 赋值运算符重载:d4 已经存在,是两个已存在对象之间的赋值
记住:赋值运算符是两个已经存在的对象之间的拷贝赋值。拷贝构造是用已有对象初始化一个新对象。
赋值运算符重载的写法:
cpp
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 头文件(接口声明)
cpp
#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 实现文件(函数定义)
cpp
#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 日期类设计的几个思路
思路一:用 += 实现 +,而不是反过来
很多人第一反应是先实现 +,再用 + 实现 +=。但这样反了:
cpp
// 低效写法:用 + 实现 +=
Date& operator+=(int day)
{
*this = *this + day; // 这里产生了一个临时对象,多了一次拷贝
return *this;
}
正确思路是:先实现 +=(直接修改自身,没有拷贝),再用 += 实现 +(+ 需要产生新对象,拷贝不可避免,但不该在 += 里产生)。
思路二:比较运算符只实现 < 和 ==,其他都复用
cpp
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); }
代码量减少一半,逻辑更清晰,修改也只需要改一处。
思路三:日期差值用"暴力加一"而不是数学计算
cpp
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* const:
cpp
void Print() const // const 放在参数列表后面
{
cout << _year << "-" << _month << "-" << _day << endl;
// 在这个函数里,不能修改任何成员变量
}
7.2 const 成员函数的使用规则
const对象只能调用const成员函数- 非
const对象既能调用普通成员函数,也能调用const成员函数(权限缩小是允许的) - 不修改成员变量的函数,都应该加
const,这是一个好习惯;祖师爷怕有咱们写成 this=nullptr;
cpp
const 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ㄒ)/~~)
cpp
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 等数据会访问野指针,程序崩溃。
Q6:const 成员函数里能修改成员变量吗?
不能,直接报错。但有一个特殊关键字 mutable,加了 mutable 的成员变量在 const 函数里也可以修改(用于统计函数调用次数等场景,实际开发中很少用)。
十、总结
把这篇文章的知识点用一张图梳理一下:
六大默认成员函数
│
├── 构造函数 → 初始化对象,替代 Init
│ └── 编译器默认:内置类型不处理,自定义类型调用其构造
│
├── 析构函数 → 释放资源,替代 Destroy
│ └── 编译器默认:内置类型不处理,自定义类型调用其析构
│
├── 拷贝构造 → 用已有对象初始化新对象
│ └── 编译器默认:浅拷贝(有指针成员时危险,需要深拷贝)
│
├── 赋值运算符 → 两个已存在对象之间赋值
│ └── 编译器默认:浅拷贝(同上)
│
├── 取地址重载 → 一般用编译器默认的就行
└── const 取地址重载 → 同上
当然还有日期类这个经典类的实现,下次我还会出专门一篇日期类,将我的易错点供大家参考
点赞收藏步迷了,大家一起进步,加油;
一句话记住核心规律:
如果类里有指针指向堆上的资源,析构、拷贝构造、赋值运算符这三个必须自己写。其他情况,交给编译器生成就好。
下一篇:初始化列表、static 成员变量、友元