C++ 入门篇类和对象·上篇:从本质深剖类与对象与C++基本用法
前言
从本篇开始,我们正式踏入 C++ 面向对象编程的核心世界。
C 语言是面向过程的语言,关注的是"过程"------一步一步地描述问题怎么解决。而 C++ 引入了面向对象的思想,关注的是"对象"------把现实世界中的事物抽象为对象,让对象自己管理自己的状态和行为。
类(Class)是 C++ 实现面向对象的基础。类可以看作是创建对象的"蓝图",定义了对象长什么样(属性),能做什么(行为)。
本文将深入骨髓、图文并茂地讲解以下内容:
- 类的定义与成员
- 访问限定符与封装
struct与class的区别- 类域与作用域操作符
:: - 类的实例化
- 对象大小的计算(内存对齐)
this指针的本质- 封装规范管理的意义
一、引用介绍
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。比如:水壶传中李逵,宋江叫"铁牛",江湖上人称"黑旋风";林冲,外号豹子头;
C++中为了避免引入太多的运算符,会复用C语言的一些符号,比如前面的<<和>>,这里引用也和取地址使用了同一个符号&,大家注意使用方法角度区分就可以。(吐槽一下,这个问题其实挺坑的,个人觉得用更多符号反而更好,不容易混淆)


1.1引用的使用方法
这里我们和C语言共用了一个符号。
| C | C++ | 解释 |
|---|---|---|
| int*a=&b; | int&a=b; | C语言这里是取地址,C++这里是引用 |
下面我给大家再举一个例子好理解。
cpp
#include<iostream>
using namespace std;
int main()
{
int a = 0;
int& b = a;
int& c = a;
int& d = a;
d++;
return 0;
}

此时a变量所指向的整型空间有三个别名。由于别名都指向同一块空间,改变别名就可以改变原值,对d++,同时会让a,b,c,d同时改变。
1.2引用的特性
-引用在定义时必须初始化
-一个变量可以有多个引用
-引用一旦引用一个实体,再不能引用其他实体
cpp
#include<iostream>
using namespace std;
int main()
{
int a = 10;
// 编译报错:"ra": 必须初始化引用
//int& ra;
int& b = a;
int c = 20;
// 这里并非让b引用c,因为C++引用不能改变指向, // 这里是一个赋值
b = c;
cout << &a << endl;
cout << &b << endl;
cout << &c << endl;
return 0;
}
1.3引用的使用场景
1 引用在实践中主要是于引用传参和引用做返回值中减少拷贝提高效率和改变引用对象时同时改变被引用对象。
2 引用传参跟指针传参功能是类似的,引用传参相对更方便一些。
3 引用和指针在实践中相辅相成,功能有重叠性,但是各有特点,互相不可替代。C++的引用跟其他语言的引用(如Java)是有很大的区别的,除了用法,最大的点,C++引用定义后不能改变指向,Java的引用可以改变指向。
4 一些主要用C代码实现版本数据结构教材中,使用C++引用替代指针传参,目的是简化程序,避开复杂的指针,但是很多同学没学过引用,导致一头雾水。
这里我先用最简单的例子给大家讲解一下为什么C++的引用好用,这里我拿C语言做对比。
这里是最简单的交换。
cpp
#include<iostream>
using namespace std;
void Swap(int& a, int& b)
{
int c = a;
a = b;
b = c;
}
int main()
{
int a = 1;
int b = 2;
cout << "Swap前" << a <<" " << b << endl;
Swap(a, b);
cout << "Swap后" << a << " " << b << endl;
return 0;
}
C语言传递指针的方法弊端:
1、取地址和解引用的繁琐步骤。
2、使用不透明性------感受上并非操作变量本身。
C++引用传参方法的优势:
1、传递即取别名,直接操作别名不需要解引用
2、使用透明性------感受直接操作变量本
我相信大家看了上面交换的例子不觉得有啥,这里我用一个数据结构的例子再为大家讲下。
链表C语言实现
c
//链表结构体
typedef struct ListNode
{
int val;
struct ListNode* next;
} LTNode, * PNode;
c
//链表尾插
void ListPushBack(LTNode** pphead, int x)//最初的写法
{
assert(pphead);
ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
if (!newNode)
{
perror("malloc:fail");
exit(1);
}
newNode->val = x;
newNode->next = NULL;
if (*ppHead == NULL)
{
*ppHead = newNode;
}
else
{
ListNode* tail = *ppHead;
while (tail->next)
{
tail = tail->next;
}
tail->next = newNode;
}
}
这里我们不仅需要对传什么指针进行理解,还要想会不会为空指针,造成操作访问错误。
所以我们C++进行了改良。
C++优化方法
直接对链表的指针引用取别名:修改链表指针的别名就是修改链表指针本身。
cpp
// 指针变量也可以取别名,这里LTNode*& phead就是给指针变量取别名 // 这样就不需要用二级指针了,相对而言简化了程序
//void ListPushBack(LTNode** phead, int x)
//void ListPushBack(LTNode*& phead, int x)
void ListPushBack(PNode& phead, int x)
{
PNode newnode = (PNode)malloc(sizeof(LTNode)); newnode->val = x;
newnode->next = NULL;
if (phead == NULL)
{
phead = newnode;
}
else
{
//...
}
主文件
cpp
int main()
{
PNode plist = NULL;
ListPushBack(plist, 1);
return 0;
}
可以看见C++引用多么方便。
其实还有一种介于两者之间的方法:
二级指针法是指针的指针;
纯引用的法是指针的引用;
该方法时引用的指针;
既没有完全脱离指针使用的复杂,又不能更好的发挥引用的优势,不建议使用。
cpp
void ListPushBack(LTNode*& phead, int x)//指针-引用混合
1.4const引用
1.4.1权限问题
cpp
int main()
{
const int a = 10;
// 编译报错:error C2440: "初始化": 无法从"const int"转换为"int &" // 这里的引用是对a访问权限的放大
int& ra = a;//
// 这样才可以
const int& ra = a;
// 编译报错:error C3892: "ra": 不能给常量赋值 //ra++;
// 这里的引用是对b访问权限的缩小
int b = 20;
const int& rb = b;
// 编译报错:error C3892: "rb": 不能给常量赋值 //rb++;
return 0;
}
总结:
引用,我们只能把权限缩小,不能放大,我们引用的对象是什么类型,他就是什么类型,不能因为原来他是int类型,我们把他引用为const后,还对他进行操作。
1.4.2常量引用问题
cpp
#include<iostream>
using namespace std;
int main()
{
int a = 10;
const int& ra = 30;
// 编译报错: "初始化": 无法从"int"转换为"int &"
// int& rb = a * 3;
const int& rb = a * 3;
double d = 12.34;
// 编译报错:"初始化": 无法从"double"转换为"int &" 16 // int& rd = d;
const int& rd = d;
return 0;
}
需要注意的是类似 int& rb = a*3; double d = 12.34; int& rd = d;,这样一些场景下a*3 的和结果保存在一个临时对象中,int& rd = d也是类似,在类型转换中会产生临时对象存储中间值,也就是时,rb和rd引用的都是临时对象,而C++规定临时对象具有常性,所以这里就触发了权限放大,必须要用常引用才可以。
说白就是像30,20这样的数字存在引用变量里面,他会先建议一个临时变量,注意这个临时变量具有常性,所以我们引用他的时候必须要加const,就相当我们给这个临时变量起了一个别名,他的生命周期会随着这个变量的周期改变。
对了还有类型转换,他是先把他的消除部分小数部分,然后他会把整数存到临时变量里面,我们知道临时变量具有常性,所以我们必须用const int& 来接受。
有的同学问,临时变量直接复制给一个新变量不就好了吗,这个变量在销毁后保留了他的数据,不也只有一个变量了吗?这里我们就要讲一下他的实际应用了。
cpp
#include <iostream>
using namespace std;
// 大结构体:内部存放大量数组,模拟大数据对象
struct BigData
{
// 2000 个 int,单个 int 占4字节
int num[2000];
// 构造函数:简单初始化
BigData()
{
for (int i = 0; i < 2000; i++)
num[i] = i;
}
};
cpp
BigData getObj() {
BigData t;
return t; // 返回临时对象
}
// 普通拷贝:临时 → 拷贝给 obj,临时销毁,obj 正常用
BigData obj = getObj();
这个代码他创建了一个很大了临时变量,并通过函数返回,如果我们用一个新的变量接受他的话,这样我们峰值占用就有2个这样的变量的大小,一次大拷贝,速度慢。
这时候我们就要用到了临时变量具有常性,我们可以直接引用他,把他的生命周期扩大。
cpp
const BigData& r = getObj();
1.4.3 C++ 指针和引用的关系
指针和引用就像两个性格迥异的亲兄弟,在实践中相辅相成,既有功能重叠,又各自不可替代。
一、语法概念上的本质区别
这是指针和引用最根本的区别:
- 引用(Reference) :是个变量的别名,编译器不会为其单独开辟内存空间。从语法层面看,引永就是原变量的"另外个名字"。
- 指针(Pointer) :是个个独立的变量,存储的是另一个变量的内存地址,需要单独开辟内存空间。
cpp int a = 10; int& ref = a; // ref 是 a 的别名,不开辟新空间 int* ptr = &a; // ptr 是一个指针变量,存储 a 的地址,开辟新空间
二、初始化要求的差异
- 引用在定义时必须初始化,不存在"空引用":
cpp int& ref; // ? 编译错误!引用必须在定义时初始化 int& ref = a; // ? 正确
- 指针建议初始化,但语法上不是强制要求。如果不初始化,指针将持有垃圾值,极其增加野指针风险:
cpp int* ptr; // ?? 语法合法,但危险!ptr 是野指针 int* ptr = nullptr; // ? 推荐做法
三、能否重新绑定
- 引用一旦初始化绑定某个对象后,就无法再引用其他对象:
cpp
int a = 10, b = 20;
int& ref = a;
ref = b; // 这?是把 b 的值赋给 a,ref 依然引用 a
// ref 永远指向 a,不会变成 b 的引用
- 指针可以随时改变指向,时常灵活:
cpp int a = 10, b = 20; int* ptr = &a; ptr = &b; // ptr 现在指向 b
四、访问对象的方式
- 引用可以直接访问,使用起来和原变量完全一致,语法简洁:
cpp
int a = 10;
int& ref = a;
ref = 20; // 直接赋值,a 变为 20
cout << ref; // 直接读取,输出 20
`
- **指针需要解引用**才能访问指向的对象:
int a = 10;
int* ptr = &a;
*ptr = 20; // 解引?后赋值
cout << *ptr; // 解引?后读取
五、sizeof 的含义不同
这是一个容易踩坑的点:
| 操作 | 含义 | 结果 |
|---|---|---|
| sizeof(ref) | 返回引永类型的 大小 | 等于原变量类型的大小 |
| sizeof(ptr) | 返回指针本身的大小 | 32位平台:4字节;64位平台:8字节 |
cpp
double d = 3.14;
double& ref = d;
double* ptr = &d;
cout << sizeof(ref) << endl; // 8(double 的??)
cout << sizeof(ptr) << endl; // 4 或 8(取决于平台位数)
六、安全性对比
- 指针容易出现空指针和野指针问题,使用不当可能导致程序崩溃或未定义行为:
cpp
int* ptr = nullptr;
*ptr = 10; // ? 空指针解引用,程序崩溃!
int* ptr2; // 野指针
*ptr2 = 10; // ? 未定义行为,极其危险!
- 引用很少出现这类问题 ,因为:
- 引用必须初始化,不存在空引用
- 引用不能重新绑定,避免了意外修改
- 引用使用起来相对更安全
七、应用场景建议
| 场景 | 推荐 | 原因 |
|---|---|---|
| 函数参数传递(只读) | const T& | 避免拷贝开销,语义清晰,安全 |
| 函数参数传递(修改) | T& | 简洁直观,无需判空 |
| 需要"不指向任何对象"的语义 | T* | 指针可以为 |
| ullptr | ||
| 需要动态改变指向 | T* | 指针可以随时重新绑定 |
| 返回值(可选语义) | T* | 可以返回 |
| ullptr 表示"无结果" | ||
| 多态 / 动态绑定 | T& 或 T* | 两者均可,引用语法更简洁 |
总结
指针是哥哥------灵活、强,但需要更多责任;引用是弟弟------乖巧、安全,但灵活性有限。在实际编码中:
- 优先使用引用,当引用能满足需求时就不使用指针
- 指针只在必要时才上阵:需要空值语义、动态改变指向、或操作底层内存时
掌握两者的区别和适合场景,是写好 C++ 代码的重要的一环。
C++ 内联函数 vs 普通函数:编译、链接、运行全程区别
结合 C/C++ 讲,分编译、链接、运行三个阶段,直白对比。
8.1 核心前提
- 内联函数(inline):编译期把函数代码「原地展开」,不生成独立函数调用。
- 普通函数:正常生成独立函数体,走函数调用流程。
先给结论:链接不是没做事,而是专门解决「call 指令里的地址找不到」的问题。下面一步步拆开讲。
8.2 先回顾单文件场景(只有一个 .cpp)
假设代码都写在同一个文件里:
cpp
void func() { ... }
int main() { func(); }
编译阶段
编译器知道 func 的位置,直接把 call 指令填成最终绝对地址。
链接阶段
几乎无事可做,只是打包段、合成可执行文件。
运行阶段
call 直接跳转到目标地址执行。
因此,单文件场景下你会感觉"链接没干活"。
三、重点:多文件场景(工程常态,链接核心作用)
拆成两个文件:
a.cpp:实现void func(){}main.cpp:调用func()
1. 单独编译每个文件 → 生成目标文件 .obj / .o
编译器是单文件编译,只处理当前文件:
编译 main.cpp:
- 看到
func()调用,但看不到func的实现,不知道它在哪。 - 于是先生成一条临时的 call 指令:地址先空着 / 填一个占位标记。
- 同时在目标文件里记录一条:外部符号引用:func(我要找这个函数)。
编译 a.cpp:
- 正常生成
func的机器码,记录:本文件定义符号:func + 它的内存地址。
此时两个 .o 是孤立的,main.o 里的 call 地址是未回填的。
链接阶段(关键一步)
链接器做这几件事:
- 收集所有
.o的符号表; - 匹配「外部引用」和「符号定义」:找到
main.o引用的func,对应到a.o里func的真实地址; - 重定位(Relocation) :把
main.o里那条占位的call指令,修改、回填成func真正的内存地址; - 合并代码段、数据段,解决所有跨文件符号,最终生成可执行文件。
链接的核心工作 = 符号解析 + 地址重定位
没有链接,
call永远是无效地址,运行直接报错。
运行阶段
操作系统加载程序到内存,call 里已经是合法地址,直接跳转执行。
8.4 结合内联函数再对比
普通函数(多文件)
- 编译 :调用处生成
call,地址占位; - 链接 :回填
call的真实地址,解决跨文件引用; - 运行 :按
call地址跳转,走栈帧调用流程。
内联函数(展开成功)
- 编译:代码原地拷贝,根本不生成 call 指令;
- 链接:没有未解析的符号、没有需要重定位的地址,链接器完全不用处理它;
- 运行:顺序执行代码,无跳转。
五、编译阶段(最关键差异)
普通函数
- 编译器为函数生成独立的机器码,存为一段独立函数实体。
- 调用处只生成一条函数调用指令(
call),不拷贝函数代码。 - 函数有独立地址,符号表正常记录函数名、地址。
内联函数(inline)
- 不生成独立函数实体(绝大多数情况)。
- 遇到函数调用时,直接把函数体代码拷贝到调用位置(代码展开)。
- 没有单独的
call调用指令。 - 多文件共用内联函数:头文件必须放完整实现,否则编译报错。
原因:每个包含头文件的.cpp都要就地展开代码。
补充:inline 只是「建议」
编译器可拒绝内联(递归函数、循环多、函数体过大等),此时它退化成普通函数。
8.6 链接阶段
普通函数
- 多个文件调用同一个普通函数:只保留一份函数实体。
- 链接器合并符号,解决跨文件函数调用,无重复定义问题。
内联函数
- 正常内联成功:没有独立函数符号,链接阶段无动作、无开销。
- 内联失败(编译器不展开) :会生成弱符号(weak),允许多文件存在重复实体,链接器自动只留一份,不会报「重定义错误」。这也是为什么内联函数实现必须写在头文件的原因。
七、运行阶段(性能 & 行为差异)
普通函数
执行流程:
- 保存现场(栈帧、返回地址、寄存器)
- 跳转到函数地址执行(
call) - 函数执行完,恢复现场、返回(
ret)
- 开销:函数调用/返回的栈开销、跳转开销。
- 优点:代码体积小(一份函数多处复用)。
内联函数(展开成功)
- 无调用、无跳转、无栈帧切换,代码直接顺次执行。
- 运行更快,消除了函数调用开销。
- 缺点:代码膨胀(每一处调用都拷贝一份代码,可执行文件变大)。
8.8 一张极简总结表
| 阶段 | 普通函数 | 内联函数(成功展开) |
|---|---|---|
| 编译 | 生成独立函数体,调用处生成 call |
代码原地展开,无独立函数、无 call |
| 链接 | 合并函数符号,正常链接 | 无独立符号,链接无开销 |
| 运行 | 有调用/跳转/栈开销,速度略慢 | 顺序执行,无调用开销,速度更快 |
| 代码体积 | 小(一份代码复用) | 变大(多处拷贝) |
| 书写位置 | 声明放头,实现放源文件 | 声明+实现必须都放头文件 |
九、额外关键细节(面试常考)
-
递归函数不能内联
递归无法无限展开,
inline对递归无效,退化为普通函数。 -
虚函数不能内联
虚函数靠运行时动态绑定,编译期无法确定调用地址,
inline失效。 -
inline只是编译器建议不是强制,最终是否内联由编译器决定。
-
类内定义的成员函数默认隐式 inline
cpp
class A {
void f() {} // 自动 inline
};
8.10 使用场景建议
- 用普通函数:函数体大、逻辑复杂、调用次数少。
- 用内联函数:函数体短小(几行代码)、高频调用(如 getter/setter、简单工具函数)。
8.11 一句话总结
- 单文件 :
call编译时就填好地址,链接存在感低; - 多文件:编译只留占位 call,链接负责把地址补全,这就是链接最核心的工作之一。
NULL 与 nullptr:空指针的前世今生
C++ 中
NULL是历史遗留的坑,C++11 的nullptr彻底解决了它。
2.1 NULL 的标准定义
NULL 实际上是一个宏,在传统的 C 头文件 stddef.h 中:
cpp
#ifndef NULL
#ifdef __cplusplus
#define NULL 0 // C++ 中:NULL 是整数 0
#else
#define NULL ((void*)0) // C 中:NULL 是 void* 空指针
#endif
#endif
核心区别:
- C 语言 :
NULL→void*类型的空指针,是一个真正的空指针 - C++ 语言 :
NULL→ 整数0,根本不是指针
2.2 C++ 中 NULL 的经典坑
不论 NULL 被定义成 0 还是 (void*)0,使用空值指针时都会遇到麻烦。
看这段代码:
cpp
void f(int x) {
cout << "f(int x)" << endl;
}
void f(int* ptr) {
cout << "f(int* ptr)" << endl;
}
int main() {
f(0);
// 本想通过 f(NULL) 调用指针版本的 f(int*),
// 但由于 NULL 被定义成 0,调用了 f(int x),
// 因此与程序的初衷相悖。
f(NULL);
f((int*)NULL);
return 0;
}
运行结果:
f(int x)
f(int x)
f(int* ptr)
本想通过 f(NULL) 调用指针版本,结果调用了 f(int),与初衷相悖。
2.3 为什么 C++ 中 NULL 被定义成 0
如果把 NULL 定义成 (void*)0:
cpp
f((void*)NULL); // ? 编译报错:error C2665: "f": 2 个重载中没有一个
// 可以转换所有参数类型
C++ 有严格的类型检查,void* 不能直接匹配 int,也不能直接匹配 int*,编译器不知道转成什么,所以直接报错。
所以 C++ 退而求其次,把 NULL 定义成 0------但这也带来了函数重载匹配 int 的问题。两头堵死,怎么定义都有坑。
2.4 C 语言为什么可以
C 语言的类型检查更宽松,允许 void* 隐式转换为其他指针类型:
cpp
void* p1 = NULL; // OK
int* p2 = p1; // OK,C 语言不需要强转
C++ 不允许这种隐式转换,所以 C++ 的 NULL 只能用 0。
2.5 NULL vs nullptr 对比
C 语言 NULL |
C++ NULL |
C++11 nullptr |
|
|---|---|---|---|
| 定义 | (void*)0 |
0 |
特殊字面量 |
| 本质 | void* 空指针 | 整数 | 指针类型 |
f(int) 匹配 |
? | ? | ? |
f(int*) 匹配 |
? | ? | ? |
f((void*)NULL) |
? | ? 报错 | ? |
| 类型安全 | 一般 | ? 有二义性 | ? 无二义性 |
2.6 nullptr 登场
C++11 引入了 nullptr,彻底解决所有问题:
cpp
void f(int x) {
cout << "f(int x)" << endl;
}
void f(int* ptr) {
cout << "f(int* ptr)" << endl;
}
int main() {
f(0);
f(NULL);
f((int*)NULL);
f(nullptr); // ? 精确匹配指针版本
return 0;
}
运行结果:
f(int x)
f(int x)
f(int* ptr)
f(int* ptr)
2.7 为什么 nullptr 更好
nullptr 的本质:一种特殊类型的字面量,只能隐式转换为指针类型,不能被转换为整数类型。
cpp
int* p1 = NULL; // ? C++ 中实际是 int* p1 = 0;
int* p2 = nullptr; // ? 明确是指针
2.8 使用建议
C++ 以后一律用 nullptr 表示空指针,永远不要用 NULL 或 0 表示空指针。
cpp
// 推荐写法
int* p = nullptr;
if (p == nullptr) { ... }
// 不好的写法
int* p = NULL; // 历史遗留问题,不推荐
int* p = 0; // 语义不明确
C 语言仍然用原来的
NULL,不需要改。C++11 以后nullptr是标准做法。
2.9 一句话总结
NULL 在 C++ 中本质是整数 0,会导致函数重载二义性,f((void*)NULL) 直接编译报错。C++11 引入的 nullptr 是专用空指针关键字,只能匹配指针类型,不能匹配整数,彻底消除了所有隐患。以后写 C++,见到 NULL 就换成 nullptr。
三、类的定义
3.1 什么是类
在 C++ 中,class 是定义类的关键字,后面的 {} 是类的主体,定义结束时分号不能省略。
cpp
class 类名 {
// 成员变量(属性)
// 成员函数(方法)
};
类体中的内容称为类的成员:
- 成员变量(属性/数据成员):描述对象的特征,如名字、年龄
- 成员函数(方法/成员方法):描述对象的行为,如吃饭、奔跑
注意:类定义后面的分号千万不能省略!这一点 C++ 初学者经常犯错。
1.2 举例:定义"狗"这个类
我们用生活中的例子来理解类。狗是一个对象,它有:
- 属性:名字、年龄
- 行为:吠叫、奔跑
用 C++ 的类来描述:
cpp
class Dog {
// ============ 属性(成员变量)============
private:
// private 表示私有,类外部不能直接访问
char name[5]; // 狗的名字(最多4个字符+结束符)
int age; // 狗的年龄
// ============ 行为(成员函数)============
public:
// public 表示公有,类外部可以调用
int Age() {
return age; // 获取狗的年龄
}
void Bark() {
cout << "汪汪汪!" << endl; // 狗叫
}
}; // 这里一定要加分号!
类的实例化就是用这个类去创建一个具体的狗,比如创建一只叫"旺财"、3岁的狗。
四、访问限定符
4.1 三种访问限定符
C++ 通过访问限定符实现封装------将对象的属性和方法结合在一起,通过权限控制,选择性地将接口暴露给外部使用。
| 限定符 | 含义 | 类外能否直接访问 |
|---|---|---|
public |
公有 | ? 可以 |
protected |
受保护 | ? 暂不可以(涉及继承) |
private |
私有 | ? 不可以 |
现阶段建议 :成员变量全部用
private,成员函数按需设置为public。
4.2 访问权限作用域的规则
访问权限的作用域,从该限定符出现的位置开始,直到下一个限定符出现为止 ;如果后面没有其他限定符,作用域就到 }(类结束)为止。
cpp
class Date {
public: // public 作用域开始
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
void Print() {
cout << _year << "/" << _month << "/" << _day << endl;
}
private: // private 作用域开始,public 结束
int _year; // _year、_month、_day 都是私有
int _month;
int _day;
}; // 类结束
4.3 为什么要访问限定符
封装的意义:用户不能直接修改对象的内部数据,必须通过类提供的接口(成员函数)来操作。这样做的好处是:
- 数据保护:防止用户随意修改内部状态导致对象进入非法状态
- 接口统一:所有对数据的操作都经过成员函数,行为可控
- 实现灵活:内部实现可以随时改变,而不影响使用者的代码
有一句话非常经典:"我不期望用户随便修改我的数据,但是我的方法你可以随便用。"
五、定义的习惯
5.1 相对位置
业界公司中有一个约定俗成的习惯:成员函数定义在上面,成员变量定义在下面。
cpp
class Date {
// 习惯:成员函数在上面
public:
void Init(int year, int month, int day);
void Print();
// 习惯:成员变量在下面
private:
int _year;
int _month;
int _day;
};
这样的布局让人一目了然------上面是接口(使用方法),下面是内部实现(数据结构)。
5.2 变量命名
如果成员变量名和函数参数名相同,直接赋值会出问题:
cpp
// 错误示范
class Date {
public:
void Init(int year, int month, int day) {
year = year; // ? 编译能通过,但这里 year = year,没有任何意义!
month = month; // ? 参数给自己赋值,等于没赋值
day = day;
}
private:
int year;
int month;
int day;
};
C++ 没有强制规定命名规则,但业界有一些常见的命名习惯:
| 命名方式 | 示例 | 说明 |
|---|---|---|
_name |
_year、_month |
前下划线(最常见) |
name_ |
year_、month_ |
后下划线(较常见) |
m_name |
m_year |
m 开头(member) |
mName |
mYear |
驼峰法 |
推荐 :使用
_name这种前下划线的命名方式,这是最被广泛接受的风格。
六、struct 与 class 的区别
6.1 C++ 兼容 C 的 struct
C++ 中的 struct 不仅仅是一个结构体,它升级成了类------可以定义函数了:
cpp
// C 语言写法
typedef struct ListNode_C {
int val;
struct ListNode_C* next;
} LTNode;
// C++ 写法:不需要 typedef,也不需要 struct 关键字命名指针
struct ListNode_CPP {
int val;
ListNode_CPP* next; // 直接用类名命名指针
};
6.2 struct 和 class 的唯一区别
默认访问权限不同:
| 定义方式 | 默认访问权限 |
|---|---|
class 类名 { ... }; |
private(私有) |
struct 类名 { ... }; |
public(公有) |
cpp
struct Stack {
void Push(int x) { } // struct 默认 public
// ...
};
class Stack2 {
void Push(int x) { } // class 默认 private,这里是私有的!
// ...
};
七、类域
7.1 成员函数的声明定义分离
定义在类里面的成员函数,默认是 inline 函数 。inline 的含义是:在调用点直接展开代码,省去 call 跳转开销。
有些场景不希望 inline(函数体太长、或需要声明放头文件而定义放源文件),就需要声明和定义分离:
cpp
class Date {
public:
void Init(int year, int month, int day); // 只有声明,不是 inline
private:
int _year;
int _month;
int _day;
};
// 定义放类外,必须加 Date::
void Date::Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
如果不加 Date::,编译器会认为 Init 是全局函数,_year 等成员变量找不到,直接报错。
7.2 三种写法对照
| 写法 | 是否 inline | 说明 |
|---|---|---|
函数体写在类 {} 里面 |
是(隐式 inline) | 编译器自动处理,不用手动加 |
| 类内声明 + 类外定义 | 否(普通函数) | 走标准 call 调用 |
手动加 inline 关键字 |
是(显式 inline) | 声明和定义必须同文件 |
例1:类内实现 → 隐式 inline
cpp
class Test {
public:
void show() { // 写在类里面 → 自动 inline
cout << "隐式 inline" << endl;
}
};
例2:声明定义分离 → 普通函数
cpp
class Test {
public:
void show(); // 只声明
};
void Test::show() { // 类外定义 → 普通函数
cout << "普通函数" << endl;
}
例3:手动 inline → 声明定义必须同文件
cpp
class Test {
public:
inline void show(); // 声明加 inline
};
inline void Test::show() { // 定义紧跟,也必须加 inline
cout << "显式 inline" << endl;
}
7.3 补充
inline只是给编译器的建议,函数太复杂时编译器会忽略,按普通函数处理。- 多文件开发:内联函数放头文件;普通函数声明放头文件、实现放
.cpp。
---### 5.2 什么是类域
类定义了一个新的作用域 ,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 :: 作用域操作符指明成员属于哪个类域。
cpp
class Date {
public:
void Init(int year, int month, int day);
private:
int _year;
int _month;
int _day;
};
void Date::Init(int year, int month, int day) {
// 这里的 :: 说明 Init 属于 Date 类域
// _year、_month、_day 都属于 Date 类域
_year = year;
_month = month;
_day = day;
}
5.3 类域的意义:名称隔离
类域提供了天然的命名隔离。不同的类可以有同名成员,互不冲突:
cpp
class Queue {
void Push(int x); // Queue 的 Push
};
class Stack {
void Push(int x); // Stack 的 Push,与 Queue 的 Push 不冲突
};
如果没有类域的隔离,这两个 Push 函数就会冲突。类域和命名空间域一样,只是名称隔离,不影响生命周期。
九、类的实例化
9.1 什么是实例化
类实例化出对象:用类类型在物理内存中创建对象的过程。
cpp
class Date {
public:
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1; // d1 是 Date 类实例化的对象
Date d2; // d2 是另一个对象
return 0;
}
9.2 类与对象的关系
| 类 | 对象 | |
|---|---|---|
| 是什么 | 抽象描述,蓝图 | 具体的实例 |
| 成员变量 | 只是声明 | 分配空间,实际存储数据 |
| 比喻 | 建筑设计图 | 建好的房子 |
类实例化出对象,就像用建筑设计图建造房子:设计图规划了有多少个房间、房间大小、功能,但设计图本身不是实体建筑,不能住人。用设计图建造出房子,房子才能住人。同样,类只是声明了成员变量,不分配空间;实例化出对象,才会分配物理内存存储数据。
重要警告:
cpp
Date::_year = 2024; // ? 错误!_year 只是声明,没有分配空间,不能访问
十、对象大小的计算
10.1 对象中包含什么
每个对象都有独立的数据空间,所以对象中肯定包含成员变量。那么成员函数需不需要在对象中存储呢?
答案:不需要。
原因:
- 成员函数被编译后是一段指令 ,存储在代码段中
- 如果在对象中存储函数指针,实例化 100 个对象就要重复存储 100 个指针,浪费
- 编译器在编译链接时就能找到函数地址,不需要运行时查找(静态多态除外)
补充:函数指针严格来说不是第一句指令的地址。在 VS 下,函数
call指向的是一句中间指令jmp,然后才是第一句指令。
10.2 内存对齐的原理
C++ 对象大小的计算,与 C 语言结构体内存对齐的规则完全一致。
为什么要内存对齐?
- 平台原因:不是所有硬件平台都能访问任意地址上的任意数据,某些平台只能在特定地址访问特定类型的数据
- 性能原因:对齐的内存访问只需一次,未对齐需要两次访问
10.3 结构体的内存对齐规则
- 第一个成员放在偏移量为 0 的地址处
- 其他成员 对齐到
min(编译器默认对齐数, 成员自身大小)的整数倍地址处(VS 默认对齐数 = 8) - 结构体总大小 调整为所有成员对齐数中最大值的整数倍
- 嵌套结构体:内嵌结构体对齐到其自身最大对齐数的整数倍;整个结构体大小调整为所有最大对齐数的整数倍
10.4 内存对齐案例
cpp
class S2 {
private:
char c1; // 1字节,对齐数1,起始偏移0,占用偏移[0]
char c2; // 1字节,对齐数1,下一个可用偏移1(1的倍数),占用偏移[1]
int i; // 4字节,对齐数4,下一个可用偏移4(4的倍数),占用偏移[4~7]
};
// 总大小:c1(1) + c2(1) + 补3字节(到偏移4) + i(4) = 8字节
// 最大对齐数 = 4,8 是 4 的倍数 ?
10.5 两个特殊案例
cpp
class B {
public:
void Print() { }
};
class C {
};
sizeof(B); // = 1
sizeof(C); // = 1
为什么空类大小是 1?
如果一个字节都不给,怎么表示这个对象存在过?所以编译器给空类或只有成员函数(无成员变量)的类分配 1 字节 ,纯粹是为了占位标识对象存在。
十一、this 指针与空指针调用
this 指针是 C++ 成员函数的核心机制,配合空指针调用成员函数的编译、链接、运行三阶段分析,彻底理解底层原理。
11.1 this 指针的注意事项
形参和实参位置不能显示写 this
编译器会自动处理 this 指针,程序员不需要(也不能)手动在形参和实参位置写 this:
cpp
// ? 错误,不能显示写 this
void Init(this, int year, int month, int day);
// ? 正确,由编译器自动处理
void Init(int year, int month, int day);
可以在函数体内显示使用 this
极少数场景需要在函数体内显式使用 this,增强代码可读性:
cpp
void Init(int year, int month, int day) {
this->_year = year; // 显示使用 this
this->_month = month;
this->_day = day;
}
11.2 this 指针的特点
cpp
void Init(Date* const this, int year, int month, int day)
// ^^^^^^^^^^^^^^^
// const 修饰的是 this 指针本身,表示 this 的值不能改变
// 但 this 指向的内容(对象的数据)是可以修改的
- this 指针的值不可以修改(指向不能改变)
- this 指向的内容可以修改
11.3 this 指针的存储位置
- 传统意义上 :this 存储在栈中
- 部分编译器优化 :存储在寄存器中(因为 this 使用频率高)
asm
; 汇编代码(VS 编译器)
; d2.Init(&d2, 2024, 7, 5);
push 5
push 7
push 7E8h ; 2024 的十六进制
lea ecx, [d2] ; 把 d2 的地址加载到 ecx 寄存器
call Date::Init ; 调用 Init,ecx 中就是 this 指针
四、空指针调用成员函数:核心结论
nullptr 调用成员函数,编译、链接全都不报错。是否会崩溃,取决于函数内部是否访问成员变量:访问则崩溃,不访问则正常运行。
11.5 两种场景
场景1:不访问成员变量 → 不崩溃
cpp
class A {
public:
void Print() {
cout << "A::Print()" << endl; // 只打印,不访问 _a
}
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // ? 正常运行,不崩溃
return 0;
}
为什么?
p->Print() 只是把 nullptr 作为 this 传给了成员函数,但函数内部没有通过 this 访问任何成员变量,没有触碰非法内存,自然不会崩溃。
场景2:访问成员变量 → 崩溃
cpp
class A {
public:
void Print() {
cout << _a << endl; // 等价于 cout << this->_a << endl
}
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // ? 运行崩溃
return 0;
}
为什么?
_a 实际上是 *(this + 偏移),而 this 是 nullptr,访问 nullptr + 偏移 就是访问地址 0,操作系统判定为非法内存访问,程序崩溃。
11.6 三阶段详解
以这段代码为例:
cpp
A* p = nullptr;
p->Print();
编译阶段:只翻译语法,不执行代码
编译器只做 3 件事:
- 检查语法、类型:
A*调用Print()语法合法 - 翻译成汇编指令(
mov传 this、call函数) - 完全不关心指针是不是空
?? 编译:0 报错
编译后目标文件里生成:
asm
mov rax, [rbp-8] ; 取栈上的 p(nullptr)
mov rdi, rax ; 把 this 送入参数寄存器 rdi
call A::Print ; 目标地址先记为符号名,不是真实内存地址
链接阶段:只补全函数地址
链接器把 call A::Print 里的符号,替换成真实内存地址。
?? 链接:0 报错
链接后变成:
asm
call 0x401120 ; 填好了真实函数地址
运行阶段:CPU 真执行指令,才会出问题
情况1:Print 不访问任何成员变量
asm
; 继续上面的指令
call 0x401120 ; 跳转到 Print 函数
; Print 函数内部只打印,不访问 this
; 没有触碰非法内存 → 正常运行
情况2:Print 访问了成员变量
asm
; Print 函数内部:
mov rax, rdi ; rax = this = nullptr
mov eax, [rax + 0] ; 访问地址 0 → 非法内存访问!
; 操作系统触发段错误 → 程序崩溃
?? 是否崩溃,只在【运行阶段】决定
11.7 为什么编译器不检查空指针?
C/C++ 是静态编译、不做运行期内存检查的语言。
指针的值是运行时才确定的(运行时才知道 p 是不是 nullptr),编译器猜不到,所以编译器只管翻译代码,不管代码对不对。
11.8 极简总结
- 编译阶段:翻译代码,不检查空指针 → 0 报错
- 链接阶段:补全函数地址,不检查空指针 → 0 报错
- 运行阶段 :CPU 执行指令,才真正访问内存
- 只传 this、不解引用 → 正常运行
- 通过 this 访问成员 → 访问 0 地址 → 崩溃
11.9 面试常考题
cpp
class A {
public:
void Print() {
cout << "A::Print()" << endl;
}
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // 正常运行还是崩溃?
return 0;
}
答案:正常运行。
Print() 不访问成员变量,p 虽然是空指针,但 p->Print() 只起指定类域 的作用,函数地址在编译时就确定了,没有解引用 p。
十二、封装规范管理
12.1 C 和 C++ 实现栈的对比
C 语言实现(简化):
c
typedef struct Stack {
int* a;
int top;
int capacity;
} Stack;
void StackInit(Stack* ps) { ps->a = NULL; ps->top = ps->capacity = 0; }
void StackPush(Stack* ps, int x) { /*...*/ }
C++ 实现(简化):
cpp
class Stack {
public:
void Init() { a = NULL; top = capacity = 0; }
void Push(int x) { /*...*/ }
private:
int* a;
int top;
int capacity;
};
Stack s;
s.Init(); // 不需要传指针,this 隐式传递
s.Push(1);
12.2 C++ 封装的三个优势
- this 指针隐式传递 :表面上不传指针,实际上
this指针在底层自动传递,使用更方便 - 数据和函数封装在一起:通过访问限定符限制,不能随意直接修改数据,实现更严格规范的管理
- 语法更便捷 :不需要
typedef,类名直接可用;成员函数不需要每次传对象地址
12.3 C 语言的不规范性
在 C 语言中,可以直接访问结构体内部:
c
s.a[s.top - 1]; // 获取栈顶元素,但如果 top == 0,这里就越界访问了!
C++ 建议把成员变量设为 private,用户无法直接访问 top,必须通过接口访问,从而避免越界访问。这就是**"封装规范管理"**的意义------用语法强制规范,减少 bug。
总结
本文详细讲解了 C++ 类的以下核心内容:
| 知识点 | 核心要点 |
|---|---|
| 类的定义 | class 关键字,成员变量 + 成员函数,分号不能省略 |
| 访问限定符 | public/protected/private,控制成员的可访问性 |
| struct vs class | 唯一区别:默认访问权限不同(class 私有,struct 公有) |
| 类域 | :: 作用域操作符,声明定义分离,名称隔离 |
| 类实例化 | 类是蓝图,对象占用物理空间;成员变量只是声明,不分配空间 |
| 内存对齐 | 与 C 语言结构体对齐规则完全一致,了解即可 |
| this 指针 | 隐含参数,指向调用对象,存储在栈或寄存器中 |
| 封装的意义 | 规范管理,避免乱访问乱修改,保证对象状态合法 |
下一期我们将讲解类的默认成员函数------构造函数、析构函数、拷贝构造、赋值运算符重载等,深入面向对象的核心。敬请期待!