欢迎阅读本篇学习笔记。
本篇作为个人计算机专业的学习记录,这里将系统梳理关于C++类定义与对象实例的部分知识点,从基础概念到代码实现逐步展开,已便于后续的复习巩固。如有不足,欢迎大家在评论区交流指正,感谢大家的阅读与支持!
如果说 C 语言是面向过程的思路,讲究步骤清晰、步步推进;那么 C++ 的面向对象,就是把数据与操作数据的方法打包成一个个独立单元,让代码组织方式更贴近真实世界的认知。而封装,正是我们踏入面向对象世界的第一道门槛。
本篇作为类与对象系列的上篇,我们从最基础的类定义讲起,逐步拆解访问限定符、类作用域与对象实例化,帮你筑牢面向对象的根基。
一、类的定义
1. 基本定义格式
在 C++ 中,定义类需要使用 class 关键字,后面紧跟类名,再用一对花括号包裹类的内容,花括号内就是这个类的成员。类中既可以定义变量(称为成员变量 / 属性),也可以定义函数(称为成员函数 / 方法)。
有一个很实用的编码习惯:为了区分成员变量和普通形参,通常会给成员变量加上特殊标记,比如前缀下划线 _ 或者前缀 m_。这不是语法强制要求,但能大幅提升代码可读性,也能避免形参与成员变量重名的问题。
#include <iostream>
using namespace std;
// 定义一个日期类
class Date
{
public:
// 成员函数:初始化日期
void Init(int year, int month, int day)
{
// _year 是成员变量,year 是形参,下划线区分避免重名
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
// 成员变量(属性)
int _year;
int _month;
int _day;
};
2. class 与 struct 的联系与区别
很多同学会疑惑:C 语言已经有 struct 了,C++ 为什么还要加 class? 其实在 C++ 中,struct 已经被升级强化了 ------ 它不再只是存放数据的结构体,也可以在里面定义函数,本质上已经具备了类的能力,同时完全兼容 C 语言的用法。
二者最核心的区别在于默认访问权限:
class定义的类,默认访问权限是私有(private)struct定义的类,默认访问权限是公有(public)
日常开发中,如果是纯数据结构、或者需要兼容 C 的场景,我们习惯用 struct;如果是完整的面向对象类,优先使用 class。
// C++ 中 struct 也可以定义成员函数
struct ListNode
{
// 成员函数:初始化节点
void Init(int x)
{
val = x;
next = nullptr;
}
int val;
ListNode* next;
};
二、访问限定符:封装的语法基石
面向对象的三大特性是封装、继承、多态,而封装是入门的第一步。所谓封装,就是把数据和操作数据的方法绑定在一起,隐藏内部实现细节,只对外暴露安全可控的访问接口。 C++ 正是通过访问限定符,来实现这种 "内外有别" 的封装效果。
三种访问限定符
- public(公有):被修饰的成员在类外可以直接访问,通常用来放对外提供的成员函数接口。
- protected(保护):和继承机制相关,后续讲到继承时再深入展开。
- private(私有):被修饰的成员在类外不能直接访问,通常用来放成员变量,把数据保护起来。
使用规则
- 访问限定符的作用范围:从当前限定符出现的位置开始,一直到下一个访问限定符出现为止;如果后面没有其他限定符,就一直持续到类的末尾。
- class 默认权限是 private,struct 默认权限是 public。
- 工程里的通用写法:成员变量统一设为 private 隐藏起来,需要对外提供的操作函数设为 public。这样外部不能随意修改内部数据,只能通过我们提供的接口访问,安全性和可控性更强。
三、类作用域
类本身就定义了一个独立的作用域,叫做类域。类里的所有成员,都被限制在这个作用域当中。
1. 类外定义成员函数
当函数声明写在类内、定义写在类外时,必须在函数名前加上 类名::,用作用域解析符指明这个函数属于哪个类,否则编译器会把它当成全局函数。
class Stack
{
public:
// 类内只写声明
void Init(int n = 4);
private:
int* _array;
size_t _capacity;
size_t _top;
};
// 类外定义:必须加 Stack:: 指明所属类域
void Stack::Init(int n)
{
_array = (int*)malloc(sizeof(int) * n);
_capacity = n;
_top = 0;
}
2. 名字查找规则
当我们调用对象的成员函数时,编译器查找名字有固定顺序: 先在函数局部域查找,找不到就去类域里找,类域里还找不到,最后才去全局域查找。 也正因为有类域做隔离,不同的类里可以定义同名的成员变量和成员函数,不会发生命名冲突。
四、对象的实例化
1. 类与对象的关系
类是什么?类只是一张设计蓝图,它描述了一种类型具备哪些属性、哪些方法。类本身不占用实际的内存空间,就像建筑图纸只画了房子的布局,但图纸本身不能住人。
对象是什么?用类创建出具体实体的过程,就叫做实例化,实例化产生的实体就是对象。对象才会真正占用内存空间,用来存储具体的数据。就像按照图纸盖出来的房子,才有真实的空间可以存放家具。
- 一个类可以实例化出任意多个对象
- 每个对象都拥有自己独立的成员变量
- 同一个类的所有对象,共享同一份成员函数代码(函数代码存在公共代码段,不用每个对象都存一份)
2. 实例化代码示例
int main()
{
// 用 Date 类实例化出两个对象 d1、d2
Date d1;
Date d2;
d1.Init(2024, 3, 31);
d2.Init(2024, 7, 5);
d1.Print(); // 2024/3/31
d2.Print(); // 2024/7/5
return 0;
}
五、对象的内存布局与大小计算
1. 对象里到底存了什么
很多同学刚开始会有疑问:一个类里同时包含成员变量和成员函数,实例化出的对象会把这些内容都存一份吗?
答案是否定的,二者的存储方式完全不同:
- 成员变量:每个对象独立存储 每个对象都拥有独立的成员变量空间,存放各自的数据。就像同一张图纸盖出的房子,每户的家具、物品都是相互独立的。
- 成员函数:所有对象共享同一份 成员函数编译后是一段可执行指令,存放在公共代码段。如果每个对象都复制一份函数代码,会造成极大的空间浪费。因此同一个类的所有对象,共享同一份成员函数代码。
这种设计既保证了对象数据独立,又最大程度节省了内存,是非常经典的空间优化思路。
2. 空类的特殊大小:1 字节占位
如果一个类没有任何成员变量,它实例化出的对象大小是多少?答案是 1 字节。
class Empty {};
int main()
{
Empty e;
cout << sizeof(e) << endl; // 输出结果为 1
return 0;
}
为什么不是 0 字节? 因为每个对象在内存中都必须拥有一个独一无二的地址,用来标识它的存在。如果大小为 0,就无法在内存中区分不同的对象。这 1 个字节不存储任何有效数据,纯粹是为了占位、标识对象存在。
3. 内存对齐规则
和 C 语言的结构体一样,C++ 类的对象在内存中存储时,也需要遵循内存对齐规则。对齐的本质是用少量空间换取读取效率,让 CPU 可以更高效地访问内存。
核心对齐规则:
- 第一个成员变量,存储在对象偏移量为 0 的地址处。
- 其余成员变量,要对齐到对齐数的整数倍地址处。
- 对齐数 = 编译器默认对齐数 与 该成员变量大小的较小值
- VS 系列编译器的默认对齐数为 8
- 对象的总大小,必须是所有成员中最大对齐数的整数倍。
- 如果类中嵌套了结构体类型,嵌套的结构体对齐到自身最大对齐数的整数倍处,整体大小也要是所有最大对齐数的整数倍。
示例:计算类对象大小
class A
{
public:
void Print(); // 成员函数不占用对象内存空间
private:
char _ch;
int _i;
};
class B
{
public:
void Print();
};
class C
{};
int main()
{
cout << sizeof(A) << endl; // 8 字节:char占1 + 填充3 + int占4
cout << sizeof(B) << endl; // 1 字节:无成员变量,占位1字节
cout << sizeof(C) << endl; // 1 字节:空类,占位1字节
return 0;
}
重点提醒:计算类对象大小时,只统计成员变量,成员函数不占用对象的内存空间。
六、this 指针:隐藏的对象身份牌
1. 一个核心问题
我们知道,多个对象共享同一份成员函数代码。那当**d1.Init()** 和 d2.Init() 调用同一个 Init 函数时,函数怎么知道自己该操作 d1 的数据,还是 d2 的数据?
解开这个问题的钥匙,就是 this 指针。
2. 底层原理
C++ 编译器会给每一个非静态成员函数,都隐式增加一个指针参数,这个指针就叫做 this 指针。它指向当前调用函数的对象,函数体内所有对成员变量的访问,本质都是通过 this 指针去间接访问的。
比如我们书写的代码是:
void Date::Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
经过编译器处理后,真实的函数原型等价于:
void Date::Init(Date* const this, int year, int month, int day)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
3. 核心特性
- this 指针是编译器自动添加的,我们不能在形参列表里显式书写 this 指针,但可以在函数体内显式使用。
- this 指针本身是常量指针**(
Type* const)**,它的指向不能被修改,但指向的对象内容可以修改。 - this 指针作为函数形参存储在栈上,并不存放在对象内部,不占用对象的内存空间。
4. 经典面试题:空指针调用成员函数
这是面试中的高频考点,我们分两种情况来看运行结果:
情况 1:成员函数中没有访问成员变量
class A
{
public:
void Print()
{
cout << "A::Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print(); // 程序正常运行
return 0;
}
✅ 结果:正常运行,不会崩溃。
很多同学第一反应是空指针解引用一定会崩溃,但这道题的核心考点,正是区分「成员函数」和「成员变量」在内存中的存储差异,以及 this 指针的底层传递逻辑。我们逐层拆解:
1. 成员函数不存放在对象内存中
C++ 类的成员函数编译后,是一段可执行的指令代码,存放在公共代码段。同一个类的所有对象共享同一份成员函数代码,并不会在每个对象里都复制一份。 因此,调用成员函数时,根本不需要去对象的内存空间里查找函数地址 ------函数的地址在编译阶段就已经确定了。
2. p->Print() 的底层到底做了什么
当编译器看到 p->Print() 时,会将其转换为类似这样的调用形式:
A::Print(p);
也就是:
- 直接调用编译期就确定好地址的
A::Print函数 - 将指针
p作为隐藏的 this 指针参数,传递给成员函数
整个过程并没有对 p 做解引用去读取对象内存,仅仅是把 p 的值(也就是 nullptr)当成了函数的一个参数传进去。
3. 为什么传空指针也不会崩溃
程序崩溃的前提,是对空指针进行解引用访问内存 。 而这个 Print 函数内部只打印了字符串,完全没有访问任何成员变量,也就全程没有用到 this 指针。虽然传进来的 this 是 nullptr,但函数自始至终都没有去触碰它,自然不会触发空指针解引用错误。
情况 2:成员函数中访问成员变量
拓展对比:什么情况会崩溃
如果我们把 Print 函数修改一下,让它访问成员变量:
void Print()
{
cout << _a << endl;
// 编译器处理后等价于:cout << this->_a << endl;
}
此时程序就会运行崩溃。 因为访问成员变量 _a 的本质,是通过 this 指针去间接访问对象内存。当 this 为 nullptr 时,this->_a 就构成了空指针解引用,进而触发程序崩溃。
📌重点标记:成员函数本身不依赖对象内存,只传递空指针不会崩溃;只有当函数内部通过 this 访问成员变量时,才会真正触发空指针解引用。
七、从栈的实现看 C 与 C++ 的差异
我们以栈这个经典数据结构为例,直观感受两种语言在设计思路上的区别。
C 语言实现数据结构,结构体和操作函数是分离的;结构体只负责存储数据,操作函数以全局函数形式存在。C 语言没有语法层面的访问权限控制,结构体成员对外完全可见,外部可直接读写结构体内部数据,需要依靠程序员编码规范来避免误用。
而在 C++ 中,我们把数据和操作数据的方法封装到同一个类里:
- 成员变量设为私有,外部无法直接修改,从语法层面保证了数据安全
- 对外只暴露公有的成员函数接口,使用方式统一规范
- 数据与方法绑定在一起,语义更清晰,代码组织更合理
1. 结构定义:数据可见性天差地别
C 语言的结构体,所有成员默认完全公开,外部可以随意读写修改;而 C++ 通过访问限定符,把数据保护了起来。
C 语言写法:结构体成员全部暴露
typedef struct Stack
{
int* _a; // 动态数组
int _capacity; // 容量
int _top; // 栈顶
} Stack;
外部代码可以直接修改 _top、_capacity,甚至把数组指针改乱,没有任何语法约束,全靠使用者自觉。
C++ 写法:成员变量私有隐藏
class Stack
{
public:
// 对外只暴露操作接口
void Push(int x);
void Pop();
int Top();
private:
// 成员变量私有,类外不能直接访问
int* _a;
int _capacity;
int _top;
};
成员变量被设为私有,外部无法直接触碰内部数据,只能通过公有成员函数间接操作。这就是封装最直观的体现:隐藏内部细节,只对外暴露可控的接口。
2. 函数调用:从手动传参到自动传递
C 语言操作数据结构时,必须手动把结构体地址传进函数;C++ 则由编译器自动完成这件事。
C 语言写法:每次调用都要手动传地址
// 函数定义:第一个参数永远是结构体指针
void StackPush(Stack* ps, int x);
// 调用方式:手动取地址传入
Stack st;
StackInit(&st);
StackPush(&st, 10);
每一个操作函数都要接收结构体指针,写法繁琐,也容易漏传、传错。
C++ 写法:对象直接调用成员函数
// 调用方式:通过对象直接调用
Stack st;
st.Init();
st.Push(10);
看起来没有传指针,实际上编译器会隐式把 &st 作为 this 指针传给成员函数。使用者不用关心底层传递,代码更简洁,语义也更自然 ------「这个栈自己执行入栈操作」。
3. 命名管理:从全局污染到域隔离
C 语言所有函数都在全局域,为了避免重名,必须给函数加上模块前缀;C++ 有类域做天然隔离,命名可以更清爽。
C 语言:必须加前缀区分模块
// 为了不和其他数据结构冲突,函数名要加 Stack 前缀
void StackInit();
void StackPush();
void StackPop();
项目越大,函数名越长,命名冲突的风险也越高。
C++:类域天然隔离重名
class Stack {
public:
void Init();
void Push();
void Pop();
};
class Queue {
public:
void Init();
void Push();
void Pop();
};
不同的类里可以有完全同名的函数,编译器通过类域区分,不会冲突。命名不用再堆冗长的前缀,代码更干净。
4. 设计思想:从面向过程到面向对象
- C 语言:数据与操作分离。结构体仅保存数据,操作数据的函数是独立的全局函数,数据和操作在语法上相互独立,需要人为约定来保护数据。
- C++:数据与操作封装成一个整体。将数据和操作该数据的方法放在同一个类中,借助访问限定符,在语法层面实现数据保护。
🔆封装的意义,不止是写法变好看了。它通过权限控制强制规范了访问方式,降低了误用概率,也让代码的可维护性和扩展性大幅提升。这也是我们从 C 语言过渡到 C++ 面向对象,最先感受到的变化。
好啦,那这篇内容就先整理到这里,这次把类和对象的部分相关知识整理了一下,为了方便自己日后复习回顾,也希望能给正在学习的小伙伴一些参考。如果文中有理解不到位或是书写有误的地方,欢迎大家在评论区指出,一起交流共同进步。谢谢大家!