在上一篇博客中,我们从 C 语言结构体过渡到 C++ 类,掌握了类的定义、封装、实例化和对象大小计算,但留下了一个关键问题:多个对象调用同一个成员函数时,函数如何区分操作的是哪个对象的成员变量? 比如两个Date对象d1和d2都调用Print(),函数怎么知道该打印d1._year还是d2._year?
答案就是 C++ 的 “隐藏神器”——this 指针。在讲解默认成员函数前,我们先补上这个核心知识点,它是理解成员函数调用机制的基础。
目录
(4)this 指针与 const 成员函数:const 修饰的是 this
一、this 指针 —— 成员函数的 “隐藏导航”
1.1 this 指针的引出:为什么需要它?
先看一个示例:我们定义Date类,创建两个对象d1和d2,分别调用Print()函数:
class Date {
public:
Date(int y, int m, int d) {
_year = y;
_month = m;
_day = d;
}
void Print() {
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1(2024, 5, 20);
Date d2(2023, 12, 31);
d1.Print(); // 输出2024-5-20
d2.Print(); // 输出2023-12-31
return 0;
}
问题来了:d1.Print()和d2.Print()调用的是同一个Print函数(代码段中只有一份Print的指令),函数怎么知道要访问d1的成员还是d2的成员?
这背后就是this指针在工作 ——编译器会给每个非静态成员函数隐式添加一个this指针参数,该指针指向 “当前调用函数的对象”,函数内部访问的成员变量,实际都是通过this指针访问的。
1.2 this 指针的本质:编译器的 “隐式操作”
我们写的Print函数,看起来没有参数,但编译器会在编译阶段自动处理成这样
// 编译器未处理的Print函数
void Print() {
cout << _year << "-" <<_month << "-" << _day << endl;
}
// 编译器处理后的Print函数(我们看不到,但是实际执行的版本)
void Print(Date* this) { // 隐式添加this指针参数(第一个参数)
cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
相应地,我们调用d1.Print()时,编译器也会自动传递d1的地址作为this指针的实参:
d1.Print(&d1); //Print(&d1);
d2.Print(&d2); //Print(&d2);
// 编译器处理后的调用(我们写的d1.Print()会被转换为)
Print(&d1); // 自动传递d1的地址给this指针
// d2.Print()会被转换为
Print(&d2); // 自动传递d2的地址给this指针
这就是this指针的核心本质:它是成员函数的隐式参数,指向当前对象,用于区分不同对象的成员变量。
1.3 this 指针的特性:你需要知道的关键细节
(1)不能显式声明,但可以显式使用
我们不能在成员函数的参数列表中显式写this指针(比如void Print(Date* this)会编译错误),但可以在函数体内显式使用this指针访问成员:
void Print() {
// 显式使用this指针(和直接写_year等价,通常省略)
cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
显式使用this的场景:比如区分成员变量和函数参数(当参数名和成员变量名相同时):
void SetYear(int year) {
// this->_year指成员变量,year指函数参数
this->_year = year;
}
(2)this 指针的存储位置:优先用寄存器
this指针是成员函数的形参,但它的存储位置比较特殊:
- Visual Studio:通过
ecx寄存器传递this指针(不占栈空间,效率更高); - 其他编译器(如 GCC):可能将
this指针放在函数调用栈上。
无论存储在哪里,我们都不需要关心 —— 编译器会自动处理this的传递和访问。
(3)this 指针不能为空?空指针调用的坑
正常情况下,this指针指向当前对象,不会为空。但如果用空指针调用成员函数,会出现两种情况:
- 如果函数内部不访问成员变量(不通过
this指针解引用),不会崩溃; - 如果函数内部访问成员变量(通过
this指针解引用),会崩溃(空指针解引用错误)。
示例:空指针调用的两种情况
class Date {
private:
int _year;
public:
// 情况1:不访问成员变量(不使用this->_year)
void PrintHello() {
cout << "Hello Date!" << endl;
}
// 情况2:访问成员变量(使用this->_year)
void PrintYear() {
cout << _year << endl; // 等价于this->_year
}
};
int main() {
Date* p = nullptr; // 空指针
// 正确:PrintHello不访问成员,this指针虽为空,但未解引用
p->PrintHello(); // 输出:Hello Date!
// 错误:PrintYear访问成员,需要解引用this(nullptr->_year),崩溃
p->PrintYear(); // 程序崩溃(空指针访问错误)
return 0;
}
原因分析:成员函数的地址在代码段,空指针调用时,编译器能找到函数地址(所以PrintHello能执行);但访问成员变量时,需要通过this指针解引用(nullptr->_year),这是非法操作,导致崩溃。
(4)this 指针与 const 成员函数:const 修饰的是 this
在后面 “const 成员函数” 的章节中,我们会讲到 “const 成员函数不能修改成员变量”,其底层原理就是this指针:
- 普通成员函数的
this指针是Date* this(可修改指向的对象); - const 成员函数的
this指针是const Date* this(不可修改指向的对象)。
因此,const 成员函数中不能修改成员变量 —— 因为this指针被 const 修饰,无法通过this修改对象内容。
1.4 总结 this 指针:一句话讲清
this指针是编译器给非静态成员函数隐式添加的第一个参数,指向当前调用函数的对象,用于区分不同对象的成员变量,其存储和传递由编译器自动处理,我们只需知道它的存在和基本规则即可。
二、默认成员函数:编译器的 “隐形助手”
先明确一个概念:默认成员函数是指当我们不显式编写时,编译器会自动生成的成员函数。一个类哪怕是空类(没有任何成员),也会拥有这 6 个默认成员函数:
| 默认成员函数 | 核心作用 | 重点程度 |
|---|---|---|
| 构造函数 | 初始化对象(对象创建时自动调用) | ⭐⭐⭐⭐⭐ |
| 析构函数 | 清理对象资源(对象销毁时自动调用) | ⭐⭐⭐⭐⭐ |
| 拷贝构造函数 | 用已有对象初始化新对象 | ⭐⭐⭐⭐⭐ |
| 赋值运算符重载 | 已有对象间的赋值操作 | ⭐⭐⭐⭐⭐ |
| 取地址运算符重载 | 获取对象地址(普通对象) | ⭐ |
| const 取地址运算符重载 | 获取对象地址(const 对象) | ⭐ |
其中前 4 个是开发中必须掌握的核心,后 2 个几乎不用手动实现(依赖编译器默认即可),我们重点讲解前 6 个中的 “高频考点”。
三、构造函数:对象的 “出生证明”
你有没有想过:为什么创建对象时,成员变量可能是随机值?比如我们定义Date d;,d._year可能是一个乱码 —— 因为对象创建时没有被 “正确初始化”。而构造函数的作用,就是在对象创建时自动执行,完成初始化工作。
3.1 构造函数的核心特性
构造函数是 “特殊” 的成员函数,它有 3 个关键特征:
- 函数名与类名完全相同(比如
Date类的构造函数就叫Date); - 无返回值类型(连
void都不用写); - 对象创建时自动调用(不用手动调用,且只调用一次)。
示例:日期类的构造函数
class Date {
private:
int _year;
int _month;
int _day;
public:
// 构造函数:带参版本,初始化年、月、日
Date(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
// 还可以加合法性校验,比如月份不能超过12
if (month < 1 || month > 12) {
cout << "月份非法!" << endl;
}
}
void Print() {
cout << _year << "-" << _month << "-" << _day << endl;
}
};
// 使用:创建对象时自动调用构造函数
int main() {
Date d(2024, 5, 20); // 调用带参构造,d被正确初始化
d.Print(); // 输出:2024-5-20
return 0;
}
3.2 默认构造函数:三种 “不用传参” 的情况
如果我们没写构造函数,编译器会自动生成一个无参的默认构造函数。但默认构造函数有个关键规则:能不用传参直接调用的,都叫默认构造函数,具体分三种情况:
| 默认构造函数类型 | 特点 | 示例 |
|---|---|---|
| 编译器自动生成的无参构造 | 对内置类型(int/char 等)不初始化(随机值),对自定义类型调用其默认构造 | Date d; |
| 用户写的无参构造 | 自己控制初始化逻辑 | Date() { _year = 2000; } |
| 用户写的全缺省构造 | 最实用!支持传参 / 不传参两种场景 | Date(int y=2000, int m=1, int d=1) |
注意:默认构造函数 “互斥”
一个类只能有一个默认构造函数。比如同时写 “无参构造” 和 “全缺省构造” 会编译错误 —— 因为Date d;会歧义,不知道该调用哪个。
工程建议:优先写 “全缺省构造函数”,既支持Date d;(用缺省值 2000-1-1),也支持Date d(2024,5,20);(传具体值),灵活性最高。
2.3 编译器生成的默认构造:“双标” 行为
当我们没写构造函数时,编译器生成的默认构造有个 “奇怪” 的规则:
- 对内置类型(int、char、指针等):不做初始化,成员变量是随机值;
- 对自定义类型(比如
class Stack):会调用该自定义类型的默认构造函数。
示例:内置类型 vs 自定义类型:
class Stack {
public:
// Stack的全缺省构造
Stack(int capacity = 4) {
_array = (int*)malloc(sizeof(int)*capacity);
_size = 0;
_capacity = capacity;
cout << "Stack构造" << endl;
}
private:
int* _array;
int _size;
int _capacity;
};
class MyClass {
private:
// 内置类型:默认构造不初始化,是随机值
int _a;
// 自定义类型:默认构造会调用Stack的默认构造
Stack _st;
};
int main() {
MyClass mc; // 输出:Stack构造(_st被初始化),_a是随机值
return 0;
}
这就是为什么我们需要显式写构造函数—— 如果类里有内置类型(尤其是指针),编译器生成的默认构造无法保证初始化,可能导致野指针等问题。
四、析构函数:对象的 “临终遗言”
如果构造函数是 “出生初始化”,那析构函数就是 “临终清理”—— 当对象生命周期结束时(比如出作用域),析构函数会自动调用,释放对象占用的资源(比如 malloc 的内存、文件句柄等)。
4.1 析构函数的核心特性
析构函数也是 “特殊” 的成员函数,特征与构造函数对应:
- 函数名是 “~ 类名”(比如
Date类的析构函数是~Date); - 无返回值类型(连
void都不用写); - 无参数(因此不能重载,一个类只有一个析构函数);
- 对象销毁时自动调用(只调用一次)。
4.2 什么时候需要自己写析构函数?
编译器也会自动生成默认析构函数,但它的行为和默认构造类似:
- 对内置类型:不做任何清理(比如指针指向的内存不会释放);
- 对自定义类型:调用其析构函数。
因此,只有当类需要 “手动释放资源” 时(比如有动态内存、文件句柄),才需要自己写析构函数。最典型的例子就是Stack类:
示例:Stack 类的析构函数
class Stack {
public:
// 构造函数:动态申请内存
Stack(int capacity = 4) {
_array = (int*)malloc(sizeof(int)*capacity);
_size = 0;
_capacity = capacity;
cout << "Stack构造" << endl;
}
// 析构函数:释放动态内存(必须自己写!)
~Stack() {
free(_array); // 释放malloc的内存
_array = nullptr; // 避免野指针
_size = _capacity = 0;
cout << "Stack析构" << endl;
}
private:
int* _array; // 动态内存,需要手动释放
int _size;
int _capacity;
};
int main() {
Stack st; // 调用构造:Stack构造
// st出作用域,自动调用析构:Stack析构
return 0;
}
如果不写析构函数,_array指向的内存会泄漏 —— 编译器生成的默认析构不会帮我们free。
4.3 析构顺序:“先构造的后析构”
析构函数的调用顺序和构造顺序相反,类似 “栈” 的后进先出(LIFO):
int main() {
Stack st1; // 先构造st1
Stack st2; // 后构造st2
// 析构顺序:先st2,后st1
return 0;
}
// 输出:
// Stack构造(st1)
// Stack构造(st2)
// Stack析构(st2)
// Stack析构(st1)
五、拷贝构造函数:对象的 “克隆术”
当我们用一个已存在的对象初始化新对象时(比如Date d2 = d1;),就会调用拷贝构造函数。它的作用是 “克隆” 一个和原对象完全相同的新对象。
5.1 拷贝构造函数的核心特性
- 函数名与类名相同(和构造函数一样);
- 参数必须是 “同类对象的 const 引用”(这是重点,后面讲为什么);
- 如果不写,编译器会生成默认拷贝构造函数(浅拷贝)。
示例:日期类的拷贝构造
class Date {
public:
// 全缺省构造
Date(int y=2000, int m=1, int d=1)
: _year(y)
, _month(m)
, _day(d) {}
// 拷贝构造函数:参数是const Date&
Date(const Date& d) {
_year = d._year; // 拷贝原对象的年
_month = d._month; // 拷贝月
_day = d._day; // 拷贝日
cout << "Date拷贝构造" << endl;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1(2024, 5, 20);
Date d2 = d1; // 调用拷贝构造,d2是d1的克隆
d2.Print(); // 输出:2024-5-20
return 0;
}
5.2 为什么参数必须是 “引用”?
如果参数是 “传值” 而不是 “引用”,会导致无限递归调用—— 因为传值时,编译器需要用原对象拷贝构造一个 “形参对象”,而拷贝形参又需要调用拷贝构造,循环往复直到栈溢出。
错误示例:传值参数导致递归
// 错误!参数是传值,会无限递归
Date(Date d) {
_year = d._year;
// ...
}
// 调用Date d2 = d1时:
// 1. 需要将d1传值给形参d,这需要调用拷贝构造生成d
// 2. 生成d又需要传值,再调用拷贝构造... 无限循环
因此,拷贝构造函数的参数必须是引用(加const是为了防止修改原对象,且能接收 const 对象)。
5.3 默认拷贝构造:浅拷贝的 “坑”
如果我们没写拷贝构造,编译器会生成默认拷贝构造 —— 它做的是浅拷贝(按字节拷贝,逐个复制成员变量的值)。
浅拷贝在大多数情况下没问题(比如Date类,成员都是 int),但如果类里有 “动态内存”(比如Stack类的_array指针),浅拷贝会导致两个对象指向同一块内存,析构时重复释放,程序崩溃。
示例:Stack 类的浅拷贝问题
lass Stack {
public:
Stack(int capacity = 4) {
_array = (int*)malloc(sizeof(int)*capacity);
_size = 0;
_capacity = capacity;
}
~Stack() {
free(_array); // 释放内存
_array = nullptr;
}
private:
int* _array;
int _size;
int _capacity;
};
int main() {
Stack st1;
Stack st2 = st1; // 调用默认拷贝构造(浅拷贝)
// st2._array = st1._array(指向同一块内存)
return 0;
// 析构时:先析构st2,free(_array);再析构st1,再次free同一块内存 → 崩溃!
}
解决方案:自己写拷贝构造函数,实现深拷贝—— 为新对象单独申请一块内存,再复制原对象的数据:
// Stack类的深拷贝构造
Stack(const Stack& st) {
// 为st2单独申请内存
_array = (int*)malloc(sizeof(int)*st._capacity);
memcpy(_array, st._array, sizeof(int)*st._size); // 复制数据
_size = st._size;
_capacity = st._capaci
}
六、赋值运算符重载:对象的 “更新术”
拷贝构造是 “用旧对象初始化新对象”(新对象不存在),而赋值运算符重载是 “给已存在的对象赋值”(两个对象都已存在)。比如d1 = d2;,其中d1和d2都已经创建好了。
6.1 运算符重载的基本规则
C++ 允许我们重载大部分运算符(比如+、==、=),让自定义类型像内置类型一样使用运算符。重载的语法是:返回值类型 operator运算符(参数列表)。
赋值运算符重载有几个关键要求:
- 参数是 const 引用(避免拷贝,防止修改原对象);
- 返回 * this 的引用(支持链式赋值,比如
d1 = d2 = d3;); - 处理自赋值(比如
d1 = d1;,避免自己赋值给自己导致错误); - 如果不写,编译器生成默认赋值重载(浅拷贝,同样有 Stack 类的问题)。
6.2 示例:Date 类的赋值运算符重载
class Date {
public:
Date(int y=2000, int m=1, int d=1) : _year(y), _month(m), _day(d) {}
// 赋值运算符重载
Date& operator=(const Date& d) {
// 1. 处理自赋值(如果d就是this,直接返回)
if (this != &d) {
// 2. 赋值成员变量
_year = d._year;
_month = d._month;
_day = d._day;
}
// 3. 返回*this,支持链式赋值
return *this;
}
void Print() {
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1(2024, 5, 20);
Date d2(2023, 12, 31);
Date d3;
d3 = d2 = d1; // 链式赋值:d2 = d1,返回d2;再d3 = d2
d3.Print(); // 输出:2024-5-20
return 0;
}
6.3 Stack 类的赋值重载:深拷贝 again
和拷贝构造类似,Stack 类的默认赋值重载是浅拷贝,会导致重复释放。因此需要自己实现深拷贝的赋值重载:
Stack& operator=(const Stack& st) {
// 1. 处理自赋值
if (this == &st) {
return *this;
}
// 2. 释放当前对象的旧内存
free(_array);
// 3. 深拷贝:申请新内存,复制数据
_array = (int*)malloc(sizeof(int)*st._capacity);
memcpy(_array, st._array, sizeof(int)*st._size);
_size = st._size;
_capacity = st._capacity;
// 4. 返回*this
return *this;
}
七、const 成员函数:对象的 “只读保护”
有时候我们需要创建 “只读对象”(比如const Date d(2024,5,20);),这类对象不能被修改。但如果调用非 const 成员函数(比如d.Print(),如果Print没加 const),编译器会报错 —— 因为非 const 成员函数可能修改对象。
const 成员函数的作用就是:修饰成员函数,保证该函数不会修改成员变量,从而允许 const 对象调用。
7.1 const 成员函数的语法
在成员函数的参数列表后加const,就是 const 成员函数。本质上,它修饰的是隐含的this指针(this是const Date*类型,而非Date*)。
示例:const 成员函数的正确写法
class Date {
public:
Date(int y=2000, int m=1, int d=1) : _year(y), _month(m), _day(d) {}
// const成员函数:不会修改成员变量
void Print() const {
// _year = 2025; // 错误!const成员函数不能修改成员变量
cout << _year << "-" << _month << "-" << _day << endl;
}
// 非const成员函数:可能修改成员变量
void SetYear(int year) {
_year = year;
}
private:
int _year;
int _month;
int _day;
};
int main() {
const Date d(2024, 5, 20); // const对象
d.Print(); // 正确:const对象可以调用const成员函数
// d.SetYear(2025); // 错误:const对象不能调用非const成员函数
Date d2(2023, 12, 31); // 非const对象
d2.Print(); // 正确:非const对象可以调用const成员函数
d2.SetYear(2024); // 正确:非const对象可以调用非const成员函数
return 0;
}
7.2 const 成员函数的调用规则
总结一下关键规则:
- const 对象:只能调用 const 成员函数(不能调用非 const 成员函数);
- 非 const 对象:可以调用 const 成员函数和非 const 成员函数;
- const 成员函数:不能调用非 const 成员函数(因为非 const 成员函数可能修改对象);
- 非 const 成员函数:可以调用 const 成员函数。
工程建议:只要成员函数不修改成员变量,就加上const—— 这样既能支持 const 对象,也不影响非 const 对象,兼容性更好。
八、取地址及 const 取地址运算符重载:几乎不用管
这两个默认成员函数的作用是 “获取对象的地址”,编译器会自动生成默认版本,行为很简单:
- 普通取地址重载:
Date* operator&() { return this; }; - const 取地址重载:
const Date* operator&() const { return this; }。
只有一种情况需要自己写:禁止外部获取对象地址(比如单例模式),此时可以返回nullptr:
// 禁止获取地址
Date* operator&() {
return nullptr;
}
const Date* operator&() const {
return nullptr;
}
除此之外,完全依赖编译器默认实现即可。
九、小结与预告
中篇我们深入了类的 “核心自动行为”:
- 构造函数负责初始化,析构函数负责清理,二者是 “生命周期搭档”;
- 拷贝构造(初始化新对象)和赋值重载(更新旧对象),都要注意浅拷贝的坑;
- const 成员函数是 “只读保护”,让 const 对象也能安全调用函数。
这些内容是 C++ 面向对象的 “基石”,接下来的下篇,我们会讲更进阶的特性:构造函数的初始化列表、static 成员、友元、匿名对象、编译器优化,以及如何从工程角度 “再理解封装”。
如果这篇文章对你有帮助,欢迎点赞收藏,我们下篇见!






