文章目录
- 前言
- 第一章:编程思想的演变——从面向过程到面向对象
- 第二章:类的引入 —— 从 Struct 讲起
- 第三章:类的定义与核心机制
- 第四章:OOP 的灵魂——封装与访问限定符
- 第五章:类的作用域
- 第六章:类的实例化
- 第七章:类的对象大小的计算
- 第八章:类成员函数的 `this` 指针
前言
由于c++的类与对象较为繁琐复杂,本文介绍类与对象部分的相关内容。
(【由浅入深】是一个系列文章,它记录了我个人作为一个小白,在学习c++技术开发方向计相关知识过程中的笔记,欢迎各位彭于晏刘亦菲从中指出我的错误并且与我共同学习进步,作为该系列的第一部曲-c语言,大部分知识会根据本人所学和我的助手——通义,gimini等以及合并网络上所找到的相关资料进行核实誊抄,每一篇文章都可能会因为一些错误在后续时间增删改查,因为该系列按照我的网络课程学习笔记形式编写,我会使用绝大多数人使用的讲解顺序编写,所以基础框架和大部分内容案例会与他人一样,基础知识不会过于详细讲述)
第一章:编程思想的演变——从面向过程到面向对象
在深入学习 C++ 的语法(怎么写类、怎么写对象)之前,我们需要先调整大脑的思维方式。C++ 之所以强大,是因为它引入了面向对象编程 (OOP) 的思想,这是它与 C 语言最大的分水岭。
1.1 什么是编程范式?
简单来说,面向过程 和 面向对象 是两种不同的 编程范式。
- 它们不是具体的语法,而是看待和解决问题的逻辑方式。
- C 语言是典型的面向过程语言。
- C++ 是多范式语言,但其核心优势在于面向对象。
1.2 面向过程
核心思想:关注“怎么做” (How)
面向过程像是一个独裁的指挥官。它分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用。
- 思维逻辑:步骤化、线性化。
- 基本单位:函数 (Function)。
- 数据处理:数据(变量)和操作数据的方法(函数)是分离的。
生活案例:手洗衣服
如果你用面向过程的思维去写一个“洗衣服”的程序,你的代码逻辑是这样的:
- 执行
拿桶()函数 - 执行
放水()函数 - 执行
放衣服()函数 - 执行
放洗衣液()函数 - 执行
手搓()函数 - 执行
拧干()函数
优缺点
- 优点:流程清晰,效率高(贴近机器执行逻辑),适合编写底层代码(如驱动程序、嵌入式)。
- 缺点:当程序变大时,代码会变得像“面条”一样乱。如果你要修改“洗衣服”变成“洗鞋子”,可能需要修改大部分步骤,复用性差,难以维护。
1.3 面向对象
核心思想:关注“谁来做” (Who)
面向对象像是一个管理者。它把问题分解成各个独立的实体(对象),每个对象都有自己的职责。解决问题就是让这些对象之间进行协作。
- 思维逻辑:模块化、拟人化。
- 基本单位:类 (Class) 和 对象 (Object)。
- 数据处理:数据和操作数据的方法被捆绑在一起(封装)。
生活案例:机洗衣服
如果你用面向对象的思维去解决“洗衣服”的问题,你的逻辑是这样的:
第一步:定义对象
我们需要两个主要对象:人 和 洗衣机。
- 洗衣机 这个对象内部包含了:转速、水量(数据/属性)以及 洗涤、甩干(功能/方法)。
第二步:对象交互
程序的主流程变成了对象之间的通信:
- 人 打开 洗衣机。
- 人 把衣服放入 洗衣机。
- 人 按下 洗衣机 的“启动”按钮。
- (此时,洗衣机 内部自己完成加水、洗涤、甩干,人 不需要关心具体步骤)。
优缺点
- 优点:
- 易维护:对象坏了换对象,不用改整个流水线。
- 易复用:设计好的“洗衣机类”可以直接拿去别的房子(程序)用。
- 易扩展:如果需要“洗鞋子”,只需要给洗衣机增加一个“洗鞋模式”,或者继承造一个“洗鞋机”。
- 缺点:前期设计比较复杂,代码量可能比面向过程多一点,运行效率在某些极端情况下略低于面向过程(但在现代硬件上通常忽略不计)。
1.4 总结与对比
为了方便记忆,我们可以通过这张表来对比两者的区别:
| 维度 | 面向过程 (C 语言风格) | 面向对象 (C++ 风格) |
|---|---|---|
| 侧重点 | 过程、步骤、算法 | 对象、数据、交互 |
| 核心单位 | 函数 (Function) | 类 (Class) |
| 程序结构 | 自顶向下,逐步细化 | 自底向上,抽象出类,再组合 |
| 数据安全 | 数据往往暴露在全局,容易被误改 | 数据被封装在对象内部,更安全 |
| 比喻 | 蛋炒饭 (饭菜混在一起,很难把鸡蛋挑出来) | 盖浇饭 (饭是饭,菜是菜,可以随意组合) |
1.5 为什么我们要学 C++ 的 OOP?
在很多大型软件开发(如游戏引擎、操作系统UI、大型服务器)中,逻辑极其复杂。如果用面向过程的步骤式写法,代码行数一旦超过几万行,维护起来就是一场灾难。
面向对象的三大特性(后续章节重点):
- 封装:保护数据,不仅让代码安全,而且让代码用起来更简单。
- 继承:代码复用的神器,老爸有的功能,儿子天生就有。
- 多态:一个接口,多种实现,让程序极具灵活性。
第二章:类的引入 —— 从 Struct 讲起
1. 回顾 C 语言中的 Struct(传统的“数据包”)
在 C 语言中,结构体(struct)仅仅是数据的集合。它把一堆变量捆绑在一起,但它不管“动作”(函数)。
- 痛点:数据和操作是分离的。
- 代码示例:
// C语言写法 struct Student { char name[20]; int age; }; // 操作函数必须写在外面,而且必须传指针进去 void InitStudent(struct Student* s, const char* n, int a) { strcpy(s->name, n); s->age = a; } int main() { struct Student s1; // 必须带 struct 关键字 InitStudent(&s1, "张三", 18); // 数据和操作是分开的 }
2. C++ 对 Struct 的“升级”
C++ 为了兼容 C 语言,保留了 struct,但赋予了它全新的能力。在 C++ 中,struct 不仅仅可以放变量,还可以放函数!
这是一个巨大的飞跃,标志着 “面向过程”向“面向对象”的转变。
“C++ 对 struct 进行了史诗级增强。它不再只是 C 语言里那个只能装数据的包裹,它现在拥有了和 class 一样的能力——可以包含函数,并且函数可以直接访问内部数据,无需传参。唯一的区别只是默认访问权限不同。”
- 变化 1:定义变量时不再需要写
struct关键字(直接Student s1;)。 - 变化 2:函数可以定义在结构体内部(成员函数)。
- 代码示例:
// C++中的 Struct struct Student { // 1. 成员变量 char name[20]; int age; // 2. 成员函数(直接写在里面!) void Init(const char* n, int a) { strcpy(name, n); age = a; } void Print() { cout << name << " " << age << endl; } }; int main() { Student s1; // 不需要写 struct Student s1 s1.Init("李四", 20); // 这种写法叫“调用对象的方法” s1.Print(); }
3. 既然 Struct 这么强了,为什么还需要 Class?
这时候就可以抛出本章的核心概念:封装与安全。
虽然 C++ 的 struct 可以包含函数,但在 C++ 的设计哲学中,struct 主要还是为了兼容 C 语言的数据结构,它有一个默认特性:
- Struct 的默认访问权限是
public(公有的)。
这意味着谁都可以随意修改里面的数据,很不安全。
为了强调 “封装” (Encapsulation) —— 即“把数据藏起来,把接口露出来”,C++ 引入了 class 关键字。
- Class 的默认访问权限是
private(私有的)。
这强迫程序员显式地划分权限,这才是面向对象编程(OOP)的正统思想。
4. 对比总结(本章的核心知识点)
| 特性 | C 语言 struct | C++ struct | C++ class |
|---|---|---|---|
| 能否包含函数 | ❌ 不能 | ✅ 能 | ✅ 能 |
| 定义变量 | struct Tag v; | Tag v; | Tag v; |
| 默认访问权限 | (无此概念) | Public (公有) | Private (私有) |
| 主要用途 | 纯数据聚合 | 数据结构兼容/轻量级对象 | 完整的面向对象封装 |
=
第三章:类的定义与核心机制
在上一章中,我们通过 struct 窥探了“将数据和函数打包”的雏形。到了这一章,我们要正式进入 C++ 面向对象的核心领地——类 (Class)。
在动手写代码之前,我们需要先统一几个关键的 “行话”(术语)和常识,这将决定你对 C++ 的理解深度。
3.1 预备知识:类的三大常识
很多初学者会混淆“写了一个类”和“用了一个类”,我们需要从三个维度来厘清:
1. “蓝图”与“实体”的关系(实例化)
这是面向对象最底层的逻辑:
- 类 (Class):它是图纸(蓝图)。
- 比如:“手机设计图”。
- 它规定了手机有屏幕、电池(属性),能打电话、拍照(行为)。
- 重要常识:类本身不占内存空间(因为它只是一个类型定义,就像
int这个词不占内存一样)。
- 对象 (Object):它是盖好的房子(实体)。
- 比如:“你手里拿的那台 iPhone 16”。
- 它是根据图纸制造出来的实物。
- 重要常识:对象是实实在在占用内存的。
术语:实例化 (Instantiation)
由“类”创建“对象”的过程,叫做实例化。
Class->Instantiate->Object
2. 成员的划分:属性与行为
在 C++ 类中,万物皆成员。但为了逻辑清晰,我们把它们分为两类:
- 成员变量:描述属性。
- 对应 struct 中的数据。例如:身高、体重、学号。
- 成员函数 :描述行为。
- 对应 struct 中的函数。例如:吃饭、睡觉、打印成绩。
成员变量:在类中是布局的描述。只有实例化对象(造房子)时,才会真正分配内存。每个对象各有一份。
成员函数:在类中是行为的定义。编译后放在公共代码区。无论你创建 1 个还是 100 个对象,函数代码只有一份,不占对象的空间。
3. 命名规范(约定俗成)
虽然 C++ 语法不强求,但专业的 C++ 程序员通常遵守以下规范,以便一眼看出“这是个类”还是“这是个变量”:
- 类名:通常使用大驼峰命名法 (PascalCase),首字母大写。
- 例如:
Student,Car,TcpSocket。
- 例如:
- 成员变量:为了防止和函数参数混淆,通常加前缀
m_或前缀_。- 例如:
m_Age(m 代表 member) 或_age。
- 例如:
3.2 类的标准定义语法
有了上面的常识铺垫,我们现在来看 C++ 中定义类的标准“骨架”。
class 类名 {
public:
// 【公共区域】
// 通常放置:成员函数(对外的接口)
// 允许类外访问
private:
// 【私有区域】
// 通常放置:成员变量(核心数据)
// 只允许类内访问,类外看不见
protected:
// 【保护区域】
// (留待继承章节讲解,目前暂时将其视为 private)
}; // <--- ⚠️ 这里的封号千万不能漏!
与 Struct 的核心区别:
我们在第二章讲过,Struct 默认是 public 的,而 Class 默认是 private 的。如果不写 public:,那么类里的东西外界全都不许碰。
3.3 成员函数的两种实现方式
定义类时,关于“行为”(函数)怎么写,C++ 提供了极大的灵活性,这也是新手容易晕的地方。
方式一:类内实现(声明与实现合一)
直接在 class 的大括号里把代码写完。
- 适用场景:代码极少、逻辑简单的函数(通常是 Get/Set 方法)。
- 底层细节:编译器通常会将其视为
inline(内联)函数处理。
class Student {
public:
void sleep() {
cout << "学生在睡觉..." << endl;
}
};
方式二:类外实现(声明与实现分离) —— 更推荐
在类里面只写函数名字(声明),把具体的代码写在外面。
- 适用场景:逻辑复杂、代码较长的函数。这是大型项目开发的标准规范(通常分
.h和.cpp文件)。 - 关键语法:作用域解析运算符
::。
class Student {
public:
void study(); // 只写声明,加分号
};
// 在外面写实现
// 含义:我定义的这个 study 不是普通的全局函数,而是属于 Student 类的!
void Student::study() {
cout << "学生在努力学习 C++" << endl;
}
3.4 完整代码演示
让我们用一个标准的 Circle(圆)类,把本章所有概念串起来:
#include <iostream>
using namespace std;
const double PI = 3.14;
// 1. 定义类 (蓝图)
class Circle {
public:
// --- 行为 (Public) ---
// 给半径赋值
void setR(double r) {
if (r < 0) {
cout << "错误:半径不能为负数" << endl;
return;
}
m_R = r; // 成员函数可以直接访问私有变量
}
// 获取半径
double getR() {
return m_R;
}
// 计算周长 (类内只声明)
double calculatePerimeter();
private:
// --- 属性 (Private) ---
double m_R; // 成员变量习惯加 m_ 前缀
};
// 2. 类外实现复杂函数
// 必须加上 Circle:: 告诉编译器这是谁的成员函数
double Circle::calculatePerimeter() {
return 2 * PI * m_R;
}
int main() {
// 3. 实例化对象 (盖房子)
// 此时才真正分配内存
Circle c1;
// c1.m_R = 10; // ❌ 报错!私有成员不可访问
// ✅ 通过公共接口操作对象
c1.setR(10);
cout << "圆的半径: " << c1.getR() << endl;
cout << "圆的周长: " << c1.calculatePerimeter() << endl;
return 0;
}
3.5 成员函数的参数
是否需要参数,完全取决于这个函数“要完成的任务”所需的数据来源。
我们可以把成员函数看作是这个对象的“动作”。判断需不需要传参,只需要问自己一个问题:
“完成这个动作,我自己肚子里的数据(成员变量)够不够用?”
- 够用 → \rightarrow → 不需要参数。
- 不够用(需要外界告诉我) → \rightarrow → 需要参数。
下面我们分三种情况详细说明:
1. 不需要传参:自给自足型
如果这个函数只需要读取或操作类内部已经存在的变量,那就不需要别人给它传参。它直接用 this 指针(隐式)就能找到自己的数据。
- 场景:获取当前状态、重置状态、基于内部数据计算。
- 例子:
getName():名字已经在对象里存着了,直接拿就行。clear():把内部计数器清零,不需要外界告诉怎么清。print():打印自己的信息。
class Hero {
int hp = 100; // 内部数据:血量
public:
// 不需要参数
// 因为通过成员变量 hp,我已经知道自己有多少血了
int getHp() {
return hp;
}
// 不需要参数
// 因为“回满血”这个动作隐含的意思就是变成 100,不需要外界告诉
void recoverFull() {
hp = 100;
}
};
2. 需要传参:外部输入型
如果这个函数需要的数据,不在类内部,或者需要外界指定一个新值,那就必须传参。
- 场景:设置新值、与外部数据交互、计算依赖外部变量。
- 例子:
setName(string newName):你得告诉它新名字叫什么。attack(Monster m):你得告诉它打哪一只怪。add(int x):你得告诉它加上多少。
class Hero {
int hp = 100;
public:
// 【需要参数】
// 因为“受伤”这个动作,必须知道受了“多少”伤
void takeDamage(int damage) {
hp -= damage;
}
// 【需要参数】
// 因为要改名,必须知道“新名字”是什么
void rename(string newName) {
// ... 修改名字的代码
}
};
3. 最核心的区别:C 语言 vs C++
你可能会觉得:“在 C 语言里,函数不是都要传结构体指针进去吗?为什么 C++ 不需要?”
这是 C++ 也就是 面向对象 的魔法所在:隐式传参。
-
C 语言的做法(所有数据都要显式传):
// 吃苹果,必须把“人”传进去,否则不知道谁在吃 void eat_apple(Person* p, int count) { p->stomach += count; } -
C++ 的做法(只有外部数据要传):
class Person { int stomach; public: // “人”不需要传,因为函数就在“人”里面 // 只有“吃了几个”需要外界告诉 void eatApple(int count) { stomach += count; // 自动识别成 this->stomach } };
总结图表
当你写一个成员函数时,按这个逻辑判断:
| 函数目的 | 数据来源 | 是否传参 | 例子 |
|---|---|---|---|
| 只是为了输出/查看 | 数据全在类内部 (this->xxx) | ❌ 不需要 | print(), getAge() |
| 固定逻辑的操作 | 逻辑固定,不依赖外部数值 | ❌ 不需要 | reset(), turnOn() |
| 修改数据 | 需要外界提供新数值 | ✅ 需要 | setPrice(int p), resize(int w, int h) |
| 复杂交互 | 需要用到另一个对象的数据 | ✅ 需要 | compare(OtherObj b) |
一句话口诀:
用自己的不用传,用别人的就要传。
3.6 本章小结
到这里,我们已经完成了从“思想”到“语法”的落地:
- 概念:类是图纸(不占地),对象是房子(占地)。
- 封装:用
private保护数据,用public开放接口。 - 语法:类外写函数要加
类名::。
第四章:OOP 的灵魂——封装与访问限定符
在学会了怎么定义类之后,新手最容易犯的错误就是:把所有东西都设为 public。
这样做虽然代码能跑,但完全违背了面向对象编程(OOP)的初衷。这一章我们将深入探讨为什么要封装,以及如何使用访问限定符来保护你的对象。
4.1 什么是封装 (Encapsulation)?
封装不仅仅是“把变量和函数扔进一个花括号里”。
它的核心含义是:隐藏细节,暴露接口。
- 隐藏 (Hiding):把核心数据和复杂的内部逻辑藏起来,不让外界随意触碰。
- 暴露 (Exposing):只提供一套安全、简单的操作入口(函数)给外界使用。
生活中的例子:
原子弹就是封装的极致。
- Private (私有):内部的核反应原料、复杂的引爆电路。你绝对不希望普通人能随便打开盖子去摸里面的线路(不安全)。
- Public (公有):只有一个红色的按钮。
- 封装的结果:使用者不需要懂核物理,只需要按按钮(调用接口),就能实现功能。
4.2 C++ 的三大访问限定符
C++ 提供了三个关键字来划分“地盘”。这些限定符的作用域从冒号 : 开始,直到下一个限定符出现或者类的大括号结束。
1. public (公共权限) —— “客厅”
- 谁能访问:
- ✅ 类自己(成员函数)。
- ✅ 子类(派生类)。
- ✅ 外部(main 函数、其他类)。
- 通常放什么:
- 对外提供的接口函数(如
setAge(),printInfo())。 - 构造函数、析构函数。
- 对外提供的接口函数(如
2. private (私有权限) —— “卧室/保险箱”
- 谁能访问:
- ✅ 只有类自己(成员函数)。
- ❌ 子类不可访问。
- ❌ 外部不可访问。
- 通常放什么:
- 成员变量(属性)。
- 内部辅助函数(只给类自己用的逻辑,不想对外暴露)。
3. protected (保护权限) —— “传家宝”
- 谁能访问:
- ✅ 类自己。
- ✅ 子类(这是它和 private 的唯一区别)。
- ❌ 外部不可访问。
- 通常放什么:
- 希望留给子类继承使用,但不想公开给外界的数据。
- (注:在没有继承的情况下,它的表现和 private 完全一样)。
4.3 为什么要这么麻烦?(封装的好处)
为什么不直接把所有变量都 public,想怎么改就怎么改?
我们来看一个反面教材与正面教材的对比。
❌ 反面教材:没有任何封装
class Person {
public: // 全部公开
string name;
int age;
};
int main() {
Person p;
// 危险操作 1:输入非法数据
p.age = -1000; // 人的年龄怎么可能是负数?逻辑崩坏!
// 危险操作 2:数据被意外篡改
p.name = "不知名"; // 谁都可以随时改名,无法追踪
return 0;
}
✅ 正面教材:标准的封装
class Person {
private: // 数据私有化
string m_Name;
int m_Age;
public: // 接口公开化
// 1. 写权限 (Setter):可以加逻辑控制!
void setAge(int age) {
if (age < 0 || age > 150) {
cout << "错误:年龄不合法!" << endl;
// 可以选择报错、抛出异常或设为默认值
m_Age = 0;
return;
}
m_Age = age; // 只有合法时才赋值
}
// 2. 读权限 (Getter)
int getAge() {
return m_Age;
}
// 3. 只读属性 (只给 Getter 不给 Setter)
// 比如:名字一旦设定就不许改
void setName(string name) {
m_Name = name;
}
string getName() {
return m_Name;
}
};
int main() {
Person p;
// p.m_Age = -1000; // ❌ 编译报错!无法访问私有成员
p.setAge(-1000); // ✅ 运行被拦截,输出“错误:年龄不合法”
p.setAge(18); // ✅ 正常赋值
cout << p.getAge() << endl;
return 0;
}
总结封装的意义:
- 安全性:防止外部赋予不合法的值(如
age = -100)。 - 可维护性:如果未来内部逻辑变了(比如
age不存int了,改存string),只要setAge的接口不变,外部调用的代码就不需要修改。这叫实现细节对用户透明。 - 控制权:你可以轻松实现“只读”属性(只写 get 不写 set)或“只写”属性。
4.4 两个重要的补充知识点
1. class vs struct 的默认权限
这是面试必考题,再次强调:
class:如果不写 public/private,默认是private。struct:如果不写 public/private,默认是public。
class A {
int x; // 默认 private
};
struct B {
int y; // 默认 public
};
2. 成员变量命名规范
为了在代码中一眼区分“参数”和“成员变量”,C++ 社区有几种常见的命名习惯,建议选一种坚持使用:
- 前缀
m_(Google 风格/Qt 风格):m_Age - 后缀 或者前缀
_(Google 风格/LLVM 风格):age_/_age - 小驼峰:
age(容易和参数int age混淆,不推荐新手使用)
4.5 C++的struct和class区别
1. 核心区别:唯一的不同是“门没锁”
在 C++ 面试中,如果你能答出下面这点,基本就及格了:
struct和class在 C++ 中唯一的实质区别是:默认的访问权限 (Default Access Specifier) 不同。
class:默认是 Private(私有)。如果你不写public:,谁也别想碰里面的数据。struct:默认是 Public(公有)。如果你不写private:,谁都可以随意访问。
代码对比
class A {
int x; // 默认为 private
};
struct B {
int x; // 默认为 public
};
int main() {
A a;
// a.x = 10; // ❌ 报错!不可访问
B b;
b.x = 10; // ✅ 成功!可以直接访问
return 0;
}
补充知识(进阶):在继承时,默认权限也不同。
class Derived : Base默认是 private 继承。struct Derived : Base默认是 public 继承。
2. 能力演示:Struct 也能“起舞”
很多从 C 语言转过来的同学以为 struct 只能放变量。错!
在 C++ 中,struct 拥有 class 的所有功能。它可以有:
- 构造函数
- 成员函数
- 继承
- 多态(虚函数)
看下面这个代码,完全合法的 C++ struct:
#include <iostream>
using namespace std;
struct Point {
// 成员变量
int x;
int y;
// 1. 它可以有构造函数!
Point(int a, int b) {
x = a;
y = b;
}
// 2. 它可以有成员函数!
void move(int dx, int dy) {
x += dx;
y += dy;
}
// 3. 它甚至可以有 private 区域!
private:
int id; // 这个变量外面看不见
};
int main() {
Point p(1, 2); // 调用构造函数
p.move(10, 10); // 调用成员函数
cout << p.x << ", " << p.y << endl; // 输出 11, 12
return 0;
}
3. C 语言 struct vs C++ struct
为了避免混淆,我们需要理清历史包袱:
| 特性 | C 语言的 struct | C++ 的 struct |
|---|---|---|
| 内部内容 | 只能包含数据变量,不能有函数。 | 可以包含数据和函数。 |
| 定义变量 | 必须写 struct Point p; (除非用了 typedef)。 | 直接写 Point p; 即可。 |
| 内存模型 | 纯粹的数据块(POD)。 | 可能是复杂的对象(含虚函数表等)。 |
| 初始化 | 只能用 {} 初始化。 | 可以用构造函数初始化。 |
4. 行业潜规则:什么时候用 struct,什么时候用 class?
既然两者功能几乎一样,为什么我们还要保留两个关键字?为什么不把 struct 删了?
这是为了代码的可读性和语义表达。在 C++ 工程师之间,有一套约定俗成的“潜规则”:
场景 A:使用 struct —— “数据包”
当我们定义一个主要用来存数据,不需要复杂的封装,也不需要太多逻辑控制的类型时,用 struct。这通常被称为 POD (Plain Old Data) 类型。
- 例子:坐标点、颜色值、配置参数包、网络通信的数据包头。
- 心态:“你看吧,数据都在这,随便拿,没什么秘密。”
struct Color {
unsigned char r, g, b; // 简单的数据集合
};
场景 B:使用 class —— “管理者/对象”
当我们定义一个需要维护内部状态,有复杂的行为逻辑,且数据不允许被随意修改时,用 class。
- 例子:数据库连接池、学生管理系统、窗口控制器、游戏角色。
- 心态:“我是个复杂的对象,请通过我的函数接口来办事,不要直接动我的数据。”
class BankAccount {
private:
double balance; // 余额绝对不能随便改!
public:
void deposit(double amount); // 必须通过存款函数来改
};
第五章:类的作用域
我们之前学过全局作用域、局部作用域(函数内)。类定义了一个新的作用域,就像一个围墙,把成员围了起来。
5.1 什么是类作用域?
在类 (class) 的大括号 {} 也就是类作用域。
在这个范围内:
- 类的成员变量和成员函数可以直接互相访问,不需要加任何前缀。
- 但在类外面,这些名字是“看不见”的。
5.2 作用域解析运算符 ::
如果你想在类外面访问里面的东西(或者在类外定义成员函数),你需要告诉编译器:“我要找的是属于 Student 家里的那个 study 函数”。
这就是 :: 的作用。
class Person {
public:
void func(); // 声明
};
// ❌ 错误:编译器以为这是个全局函数
void func() {
...
}
// ✅ 正确:使用 :: 指明作用域
void Person::func() {
...
}
补充:如果在类成员函数中,参数名和成员变量名冲突了,也可以用
类名::变量名来强制指定(不过更推荐下一章讲的this指针)。
第六章:类的实例化
这一章我们从内存的角度重新审视“创建对象”。
6.1 再次强调:声明 vs 实例化
- 声明:
class Person { ... };- 这只是告诉编译器
Person长什么样。 - 不分配内存。就像画了一张图纸,还没有买砖头。
- 这只是告诉编译器
- 实例化 (Instantiation):
Person p1;- 这是根据图纸造房子。
- 分配内存。系统会在栈(Stack)或堆(Heap)上划出一块空间给
p1。
6.2 实例化的本质
当你写下 Person p1; 时,操作系统做了两件事:
- 分配空间:根据类中成员变量的大小,分配内存块。
- 初始化:调用构造函数填充这块内存。
第七章:类的对象大小的计算
这是一个经典的面试题,也是理解 C++ 内存模型的关键。
核心问题:一个对象 Person p 到底占多少字节?
答案公式:对象大小 ≈ 所有非静态成员变量大小之和 (+ 内存对齐 padding)。
7.1 惊人的事实:成员函数不占对象空间
初学者常以为:对象里面装着变量和函数代码。
错!
- 成员变量:每个对象独有一份。你叫张三,我叫李四,我们的
m_Name数据不同,必须各自存一份。 - 成员函数:所有对象共享一份。
eat()吃饭的动作(代码逻辑)是一样的,没必要每个对象都拷贝一份代码。函数代码存放在公共的代码区(Code Segment)。
7.2 案例验证
class A {
// 空类
};
class B {
int m_a; // 4字节
};
class C {
int m_a; // 4字节
void func() {} // 函数不占空间
};
int main() {
cout << "sizeof(A) = " << sizeof(A) << endl; // 结果:1
cout << "sizeof(B) = " << sizeof(B) << endl; // 结果:4
cout << "sizeof(C) = " << sizeof(C) << endl; // 结果:4 (不是8!)
}
7.3 两个特殊规则
- 空类的大小是 1 字节:
- 为什么
sizeof(A)不是 0? - 如果是 0,那么
A obj1; A obj2;这两个对象就没有区别了(地址可能重叠)。为了保证每个对象在内存中都有独一无二的地址,编译器会给空对象分配 1 个字节的“占位符”。
- 为什么
- 内存对齐 (Memory Alignment):
- 类的内存布局遵循结构体的对齐规则(通常以最大成员的大小为单位对齐),这是为了 CPU 读取效率。
- 例如:
char(1) +int(4) 可能会变成 8 字节(中间补 3 字节 padding)。
1.sizeof(类名):你是在问编译器,“如果我要根据这张图纸造房子,需要多大的地皮?”编译器根据类定义里的成员变量算了一下,回答你:“16平米”。
2.sizeof(对象):你是在问编译器,“这个已经造好的房子占了多大地皮?”编译器看了一眼这个对象的类型(是 Student),回答你:“哦,这是按 Student 图纸造的,所以是 16平米”。
3.本质上:sizeof 是一个编译时运算符(Compile-time Operator)。 也就是说,在程序还没运行的时候,编译器就已经把 sizeof(…) 替换成具体的数字了。它看的是类型,而不是里面的值。
第八章:类成员函数的 this 指针
接上文,既然成员函数只有一份,存在公共代码区,那么问题来了:
当
p1调用eat()和p2调用eat()时,函数怎么知道是 p1 在吃,还是 p2 在吃? 怎么保证修改的是p1.stomach而不是p2.stomach?
答案就是:隐藏的 this 指针。
8.1 编译器背后的“手脚”
当我们编写如下代码时:
// 程序员写的
p1.setAge(18);
编译器在编译阶段,会将其翻译成类似 C 语言的全局函数调用,并多传一个参数:
// 编译器实际执行的(伪代码)
Student_setAge(&p1, 18); // 把 p1 的地址传进去了!
而在类的内部,函数定义也被“篡改”了:
// 程序员写的
void setAge(int age) {
m_age = age;
}
// 编译器看到的
void setAge(Student * const this, int age) {
this->m_age = age; // 这里的 this 就是刚才传进来的 &p1
}
结论:this 指针是 C++ 编译器给每个非静态成员函数增加的一个隐藏的指针参数。它指向当前调用该函数的对象。
(this 就是调用该函数的“那个对象”的内存地址。)
注意:
1.实参和形参不能显示写this指针,由编译器自己加
2.在类里面可以显示用,this->age ✅ 代表“当前对象”的地址
8.2 this 指针的用途
虽然 this 是隐式的,但我们在写代码时也可以显式使用它,主要有两个场景:
场景 1:解决名称冲突
当参数名和成员变量名一样时:
class Student {
public:
int age;
void setAge(int age) {
// age = age; // ❌ 此时两个都是参数 age,赋值无效
this->age = age; // ✅ 明确:左边是属性,右边是参数
}
};
场景 2:支持链式编程 (Returning *this)
如果你想实现像 cout << a << b 或者 obj.add().sub().mul() 这样连续调用的效果,函数需要返回对象本身。
class Calculator {
int m_Num;
public:
Calculator(int n) { m_Num = n; }
// 返回引用,代表返回对象本身,而不是拷贝
Calculator& add(int num) {
m_Num += num;
return *this; // <--- 返回当前对象的本体
}
void print() { cout << m_Num << endl; }
};
int main() {
Calculator calc(10);
// 链式调用:先加5,再加5,最后加5
calc.add(5).add(5).add(5);
calc.print(); // 输出 25
}
8.3 容易忽略的细节
this指针的类型:对于Student类,this的类型是Student * const。这意味着你不能修改this指针的指向(不能让它指向别的对象),但可以修改它指向的内容。this只能在成员函数内部使用:全局函数、静态成员函数(后面会讲)里没有this。
8.4 this指针存在哪里的
结论:this 指针主要存在“寄存器”或“函数栈帧(栈)”中。
它就像一个普通的函数参数,生命周期仅限于函数执行期间。它绝对不存在于对象的内存空间里(即不占 sizeof 的空间)。
具体存在哪里,取决于操作系统、编译器和架构(x86 vs x64):
1. 最经典的情况:寄存器(ECX)
场景:Windows 下 Visual Studio 编译器,x86(32位)程序。
约定:__thiscall 调用约定。
这是 C++ 针对类成员函数特有的优化。编译器认为 this 指针太重要、用得太频繁了,所以不把它放在慢速的内存(栈)里,而是直接放在 CPU 的通用寄存器 ECX 中传递。
汇编视角:
; C++ 代码: p.move(10);
; 假设 p 的地址是 0x00400000
lea ecx, [p] ; 1. 把对象 p 的地址加载到 ECX 寄存器
push 10 ; 2. 把参数 10 压入栈
call Student::move ; 3. 调用函数
在 move 函数内部,CPU 只要想找“当前对象”,直接读 ECX 寄存器就行了,速度飞快。
2. 通用的情况:栈(Stack)
场景:GCC 编译器、某些复杂的调用约定,或者参数过多的情况。
在这种情况下,编译器会把 this 当作函数的第一个隐式参数,通过 push 指令压入栈中。
汇编视角:
; C++ 代码: p.move(10);
push 10 ; 1. 压入参数 10
lea eax, [p] ; 2. 获取对象地址
push eax ; 3. 【关键】把对象地址(this)压入栈,作为第一个参数
call Student::move
在函数内部,程序通过读取栈上的数据(比如 [ebp+8])来获取 this 指针。
3. x64 现代架构(你现在电脑大概率是这个)
场景:64位程序(无论是 Windows 还是 Linux)。
64位系统通用寄存器很多,参数基本都是靠寄存器传的。
- Windows (x64):
this通常放在 RCX 寄存器。 - Linux (x64):
this通常放在 RDI 寄存器。
4. 这里的【特大误区】
很多初学者会误以为 this 指针存放在对象里,这是完全错误的。
请记住:
- 对象(Object):是房子。里面装着
int age,double score等家具。 - this 指针:是手里拿着的一张写着房子地址的纸条。
这张纸条(this)是在你进入房子(调用函数)的那一刻,由系统临时发给你的。当你离开房子(函数结束),纸条就被扔掉了。纸条绝对不贴在墙上,也不占房子的面积。
这也是为什么:
class A { void func() {} };
sizeof(A) == 1; // 空类是1,完全没有 4字节/8字节 的 this 指针空间
8.5 this 指针的特性
this 指针的特性是 C++ 面向对象机制的灵魂。理解这些特性,能帮你避开很多底层的坑,也能让你写出更高级的代码(比如链式调用)。
我把它的特性总结为 5 个核心点,按重要程度排序:
1. 类型特性:它是“指针常量”
这是 this 指针最本质的属性。
- 本质类型:对于
Student类,this的类型是Student * const。 - 含义:
- 指向不可变:你不能修改
this指针本身的指向(不能让它指向别的对象)。 - 内容可变:但是你可以通过
this修改它所指向的对象里的数据。
- 指向不可变:你不能修改
void Student::func() {
this->age = 18; // ✅ 可以:修改成员变量
Student s2;
// this = &s2; // ❌ 报错:this 是 const 的,一旦绑定当前对象,就不能改嫁!
}
2. 作用域特性:只能在“非静态成员函数”中使用
这是最容易混淆的一点。
- 能用:普通的成员函数(如
setAge,print)。 - 不能用:
- 全局函数:显然没有对象。
- 静态成员函数 (
static):这一点非常重要!
为什么静态函数没有 this?
因为静态成员函数是属于类的,不属于某个对象。调用它的时候(Student::func()),可能根本就没有实例化任何对象,所以系统无法传递 this 指针。
class A {
public:
int x;
// 普通函数:有 this
void func1() {
this->x = 100; // ✅
}
// 静态函数:无 this
static void func2() {
// this->x = 100; // ❌ 报错:静态函数里没有 this 指针
}
};
3. 常函数特性:const 修饰后的变化
当你定义一个常成员函数时(在函数括号后面加 const),this 指针的权限会被降级。
- 普通函数:
Student * const this(只读指针,可写数据) - 常函数:
const Student * const this(双重只读:指针不可变,数据也不可变)
class A {
int x;
public:
// 这是一个 const 函数
void show() const {
// this->x = 100; // ❌ 报错:const 函数里,this 指向的数据变成只读了
cout << x << endl; // ✅ 只能读
}
};
4. 空指针特性:this 可以是 NULL 吗?(高频面试题)
答案:可以,但是很危险。
在 C++ 中,如果一个指针是空指针 (nullptr),你依然可以用它调用成员函数!只要这个函数不访问任何成员变量,代码就能正常运行。
原理:
因为成员函数存在公共代码区,调用函数本身不需要解引用。只有当你尝试访问 this->age 时,才会发生解引用,从而导致崩溃。
class A {
public:
void sayHello() {
cout << "Hello" << endl;
}
void showAge() {
cout << m_Age << endl; // 等价于 this->m_Age
}
int m_Age;
};
int main() {
A* p = nullptr; // 创建一个空指针
p->sayHello(); // ✅ 正常运行!输出 Hello。
因为没有用到 this 指针里的内容,只是单纯跳转到代码区执行。
普通成员函数的地址是编译期确定的,存在代码区,不在对象里。
调用函数这个动作本身,只是跳转指令,不需要读取对象的内存。
只有当函数内部真正用到对象里的数据(成员变量)时,才会去“摸”那个空指针,这时候才会崩。
p->showAge(); // ❌ 崩溃!(Segmentation Fault)
// 因为试图访问 this->m_Age,也就是 0x0->m_Age,读取非法内存。
}
5. 自杀特性:delete this
这是一个非常极端但合法的操作。在成员函数内部,你可以执行 delete this;。
- 后果:对象把自己销毁了,释放了内存。
- 前提:
- 该对象必须是
new出来的(在堆上)。 - 执行完
delete this后,绝对不能再访问任何成员变量或调用虚函数,函数必须立即返回,否则未定义行为。
- 该对象必须是
- 用途:通常用于引用计数系统(如 COM 组件)中,当引用计数归零时对象自动销毁。
void Object::destroy() {
delete this; // ✅ 自杀
}
总结表
| 特性 | 描述 |
|---|---|
| 类型 | 类名 * const (指针本身不可变) |
| 存储位置 | 寄存器 (ECX) 或 栈 (Stack) |
| 存在范围 | 仅限非静态成员函数内部 |
| Const 修饰 | 在 const 函数中变为 const 类名 * const (数据也变只读) |
| 空指针调用 | 指针为 nullptr 时也可调用函数,但访问数据会崩 |
理解了这几点,你对 C++ 类机制的掌控力就超过大部分初学者了。特别是空指针调用和静态函数无 this,是实际开发和面试中遇到最多的坑。
1387

被折叠的 条评论
为什么被折叠?



