在C++中,类和对象是面向对象编程(OOP)的基石。本文将从面向对象思想引入、类的定义与封装,一直深入到构造函数、析构函数、拷贝构造以及运算符重载等核心机制。
一、 面向过程 vs 面向对象
C语言(面向过程):关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。(例如:洗衣服 -> 拿盆子 -> 放水 -> 放衣服 -> 放洗衣粉 -> 揉搓 -> 拧干 -> 晾晒)
C++(面向对象):关注的是对象,将一件事拆分成不同的对象,靠对象之间的交互完成。(例如:人、衣服、洗衣机、洗衣粉四个对象,人只需将衣服放进洗衣机,启动按钮即可,不需关注洗衣机内部如何运转)。
二、 类的引入与定义
C语言中,结构体只能定义变量;而在C++中,结构体(struct)和类(class)不仅可以定义变量,还可以定义函数。
1. 类的两种定义方式
方式一:声明和定义全部放在类体中
这种方式比较直接,适合代码量较少的情况。需要注意的是,如果成员函数在类体内部定义,编译器可能会将其视为内联函数(inline)来处理。
// 全部写在类里面 class Person { public: // 成员函数在类内部定义 void showInfo() { cout << _name << "-" << _sex << "-" << _age << endl; } private: char* _name; // 姓名 char* _sex; // 性别 int _age; // 年龄 };方式二:声明放在 .h 文件,定义放在 .cpp 文件
在实际工程开发中,更推荐使用这种方式。这能让代码结构更清晰,也利于分离编译。
注意:在.cpp文件中定义成员函数时,函数名前必须加上类名::,以指明该函数属于哪个类域。
---------- person.h (头文件:类声明) ----------
class Person { public: // 成员函数声明 void showInfo(); private: char* _name; // 姓名 char* _sex; // 性别 int _age; // 年龄 };---------- person.cpp(源文件:类定义)----------
#include "person.h" // 成员函数定义,必须加 Person:: void Person::showInfo() { cout << _name << "-" << _sex << "-" << _age << endl; }💡建议:日常练习时为了方便,可以使用方式一。但在实际工作项目中,要使用方式二,做到声明与实现分离。
2.struct和class的区别
在C++中,struct和class都可以用来定义类,区别在于默认的访问权限:
struct的默认访问权限是public(公有)。class的默认访问权限是private(私有)。
三、 类的访问限定符及封装
1. 访问限定符
| 限定符 | 含义 | 特点 |
|---|---|---|
public(公有) | 类外可以直接访问 | 暴露给用户的接口 |
protected(保护) | 类外不能直接访问 | 继承体系中会用到 |
private(私有) | 类外不能直接访问 | 隐藏内部实现细节 |
2. 封装
封装的核心目的:隐藏实现细节,只暴露操作接口。
将数据和操作数据的方法进行有机结合,通过访问权限来隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。就像电脑一样,用户只需要知道如何开机、使用键盘鼠标,而不需要关心CPU和显卡的内部工作原理。
四、 类的作用域、实例化与对象大小
1. 类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用::作用域操作符指明成员属于哪个类域。
2. 类的实例化
类:是设计图,只定义有哪些东西,不占用实际物理内存。
对象:是房子,根据设计图建造出来的实体,占用物理内存。
注意:不能直接访问类中的成员变量,必须通过实例化后的对象来访问。
3. 对象大小的计算
核心规则:对象中只存储成员变量,不存储成员函数。成员函数存放在代码段,属于同类所有对象共享。
内存对齐规则(同结构体):
第一个成员在与结构体偏移量为0的地址处。
其他成员变量要对齐到某个数字(对齐数)的整数倍地址处。对齐数 = 编译器默认的一个对齐数与该成员大小的较小值(VS中默认8,Linux中默认4)。
结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
如果嵌套了结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
特殊情况:空类
class A {}; // 空类 int main() { cout << sizeof(A) << endl; // 输出 1 }为什么空类是1?因为编译器需要一个独一无二的地址来标识这个类的不同对象,所以会分配1个字节作为占位符。
五、 类成员函数的 this 指针
1. this 指针的本质
this指针是隐藏的成员函数形参,指向当前调用该函数的对象。在 x86 平台上,this通常通过ecx寄存器传递;在 x64 平台上,则作为第一个参数通过rcx寄存器传递。在类中定义成员函数时,编译器会自动加上
this指针。例如void Print()实际上是void Print(Date* const this)。
2. 经典面试题:空指针调用成员函数
class A { public: void Print() { cout << "Print()" << endl; } void PrintA() { cout << _a << endl; } private: int _a; }; int main() { A* p = nullptr; p->Print(); // 正常运行!因为Print()函数没有访问成员变量,不需要解引用this p->PrintA(); // 崩溃!因为PrintA()访问了_a,本质是 this->_a,对空指针解引用导致崩溃 return 0; }3. const 成员函数
将const修饰的成员函数称之为const成员函数。const修饰类成员函数,实际修饰的是该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
权限问题:
const对象只能调用const成员函数;非const对象既可以调用const成员函数,也可以调用非const成员函数。权限放大:权限可以缩小(非const对象调用const函数),但不能放大(const对象调用非const函数)。
六、 类和对象(中):六大默认成员函数
如果一个类中什么成员都没有,简称为空类。空类中什么都没有吗?并不是的,任何一个类在我们不写的情况下,都会自动生成下面6个默认成员函数。
1. 构造函数 (Constructor)
主要任务:初始化对象,而不是开空间创建对象。
| 特性 | 说明 |
|---|---|
| 函数名 | 与类名相同 |
| 返回值 | 无返回值(不需要写void) |
| 调用时机 | 对象实例化时由编译器自动调用 |
| 重载 | 可以重载 |
| 默认生成 | 如果类中没有显式定义构造函数,编译器会自动生成一个无参的默认构造函数。一旦用户显式定义,编译器将不再生成。 |
C++11 补丁:针对编译器默认生成的构造函数对内置类型不做处理(是随机值)的问题,C++11允许在成员声明时给缺省值。
class Date { private: int _year = 1; // 声明时给缺省值 int _month = 1; int _day = 1; };2. 析构函数 (Destructor)
主要任务:完成对象中资源的清理工作,而不是销毁对象本身(对象销毁由编译器完成)。
| 特性 | 说明 |
|---|---|
| 函数名 | 类名前加~ |
| 参数与返回值 | 无参数,无返回值 |
| 重载 | 一个类只能有一个析构函数,不能重载 |
| 调用时机 | 对象生命周期结束时,C++编译系统自动调用 |
| 默认生成 | 若未显式定义,系统会自动生成默认的析构函数 |
什么时候需要自己写析构函数?
有动态申请资源(
malloc/new),需要显式释放资源。没有动态申请资源,不需要写析构。
需要释放资源的成员都是自定义类型,不需要写析构(会自动调用成员对象的析构)。
3. 拷贝构造函数 (Copy Constructor)
主要任务:用已存在的对象创建新对象。
| 特性 | 说明 |
|---|---|
| 函数名 | 与类名相同 |
| 参数 | 只有一个参数,且必须是类类型对象的引用(一般用const修饰)。使用传值方式会引发无穷递归调用。 |
| 调用场景 | 使用已存在对象创建新对象;函数参数类型为类类型对象;函数返回值类型为类类型对象。 |
浅拷贝 vs 深拷贝:
浅拷贝(默认生成):内置类型成员完成值拷贝,自定义类型成员会调用他的拷贝构造。对于指针成员,只拷贝指针地址,会导致两个对象指向同一块内存(二次析构,互相影响)。
深拷贝(必须自己实现):对于
Stack这种有动态资源的类,必须自己实现拷贝构造,重新分配内存并拷贝数据。
// 深拷贝示例 Stack(const Stack& st) { _a = (int*)malloc(sizeof(int) * st._capacity); if (nullptr == _a) return; memcpy(_a, st._a, sizeof(int) * st._top); _top = st._top; _capacity = st._capacity; }4. 赋值运算符重载 (operator=)
主要任务:两个已经存在的对象之间进行复制拷贝。
Date d4 = d2;是拷贝构造(初始化)。d4 = d1;是赋值运算符重载(已经存在了)。
格式与要点:
Date& operator=(const Date& d) { if (this != &d) { // 1. 检测是否自己给自己赋值 _year = d._year; _month = d._month; _day = d._day; } return *this; // 2. 返回*this,支持连续赋值 (d5 = d4 = d1) }参数:
const T&,传递引用提高传参效率。返回值:
T&,返回引用提高返回效率,有返回值目的是为了支持连续赋值。注意:赋值运算符只能重载成类的成员函数,不能重载成全局函数。
默认生成的赋值重载行为跟拷贝构造一样(浅拷贝),有动态资源的类需要自己实现深拷贝。
5. 取地址及 const 取地址操作符重载
这两个默认成员函数一般不用重新定义,编译器默认会生成。
Date* operator&() { return this; } const Date* operator&() const { return this; }七、 运算符重载进阶
1. 运算符重载基本规则
不能通过连接其他符号来创建新的操作符(如
operator@)。重载操作符必须有一个类类型参数。
用于内置类型的运算符,其含义不能改变。
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的
this。不能重载的运算符(5个):
.*、::、sizeof、?:、.。补充:
operator=、operator()、operator[]、operator->只能重载成类的成员函数,不能重载成全局函数。
2. 前置++ 与 后置++ 的重载
为了区分前置和后置,后置++重载时增加了一个int参数,但这个参数并不接收具体的值,仅仅是占位,跟前置++构成重载。
// 前置++ Date& operator++() { *this += 1; return *this; } // 后置++ Date operator++(int) { Date tmp = *this; *this += 1; return tmp; }前置++:返回++之后的对象(引用)。
后置++:返回++之前的对象(值)。
3. 流插入<<与流提取>>的重载
cout << d1;会转换为operator<<(cout, d1);
不能写成成员函数!因为成员函数的第一个参数永远是this指针,即d1.operator<<(cout),这不符合我们的使用习惯。
必须写成全局函数,并在类中声明为友元(friend)。
class Date { // 友元函数声明 friend ostream& operator<<(ostream& out, const Date& d); friend istream& operator>>(istream& in, Date& d); }; // 全局定义 ostream& operator<<(ostream& out, const Date& d) { out << d._year << "年" << d._month << "月" << d._day << "日"; return out; // 支持连续输出 }八、 总结
学习类和对象,核心在于理解封装、内存分布、以及编译器默认生成的六大函数的行为。
基础:区分面向过程和面向对象,掌握类的作用域、实例化与大小计算(内存对齐)。
核心工具:熟练使用
this指针,理解const成员函数的权限缩小原则。生命周期管理:深刻理解构造函数(初始化)、析构函数(清理)、拷贝构造(深/浅拷贝)的调用时机和底层行为。
运算符重载:掌握赋值运算符、前后置++、流插入提取的重载规则。