xml
hello,这里是AuroraWanderll。
兴趣方向:C++,算法,Linux系统,游戏客户端开发
欢迎关注,我将更新更多相关内容!
这是类和对象系列的第二篇文章,上篇指引:类和对象(一)
之前由于第一次发布时篇幅过长,可能导致阅读体验很差,现在我把他按要点进行了适当拆分,希望能帮助读者更好理解,也方便自己复习。
C++类和对象--C++重要基础入门知识(二)
简易目录
- 访问限定符与封装------面向对象的大门守卫
- 类的实例化与对象模型------从蓝图到实体
- this指针------对象的自我认知
第四个重要问题:访问限定符与封装------面向对象的大门守卫
通过前几个问题的讲解,我们已经知道如何定义类了。但面向对象不仅仅是把数据和函数打包在一起,更重要的是控制外界如何与我们的对象交互 。这就是我们今天要讲的访问限定符 和封装。
一、访问限定符
想象一下,类的成员就像一栋建筑里的不同房间,而访问限定符就是这些房间的门禁系统,控制着谁可以进入哪个房间。
而房间我们有三个分级权限按秘密性更高分别递增public(公共的)<protected(受保护的)<private(私有的)
C++提供了三种访问限定符:
cpp
class BankAccount {
private:
// 金库区 - 只有银行内部人员可以进入
double balance; // 余额
string password; // 密码
protected:
// 办公区 - 银行员工和经理可以进入
double calculateInterest(); // 计算利息
public:
// 营业大厅 - 所有客户都可以使用
void deposit(double amount); // 存款
void withdraw(double amount); // 取款
double getBalance(); // 查询余额
};
访问限定符详解:
1. private(私有)
-
权限 :只能在类的内部访问
-
用途:隐藏实现细节,保护敏感数据
-
示例 :
balance,password这些数据不应该被外部直接修改不过我们依然可以提供特定的get方法来让外界适当的访问数据,后面说。
2. protected(保护)
-
权限 :在类内部和派生类中可访问
-
用途:为继承体系设计,我们讲到继承时会详细说明
在继承里常见。
3. public(公有)
- 权限 :在任何地方都可以访问
- 用途:提供对外的接口,定义类与外界交互的方式
重要规则:
-
作用域规则:从访问限定符出现的位置开始,到下一个访问限定符或类结束为止
cpp
class Example { public: int a; // public int b; // public private: int c; // private int d; // private public: int e; // public }; // 类结束 -
默认访问权限:
class:默认是privatestruct:默认是public(为了兼容C语言)
-
编译时检查:访问权限只在编译阶段检查,运行时没有任何区别
二、封装:面向对象的"黑箱哲学"
什么是封装?
封装 = 数据 + 操作数据的方法 + 访问控制(对应成员变量,成员函数,访问限定符:封装就是我们来造一个完整的类)
用一句话概括:"隐藏实现细节,只暴露必要的接口"
现实世界的封装例子:
电脑的封装:
- 隐藏的:CPU如何运算、内存如何管理、硬盘如何存储
- 暴露的:开机按钮、USB接口、显示器、键盘鼠标
汽车的封装:
-
隐藏的:发动机工作原理、变速箱机制、燃油喷射系统
-
暴露的:方向盘、油门、刹车、仪表盘
用户管他那么多,只管用就完了(使用,但不考虑底层)
代码示例:没有封装 vs 有封装
没有封装的糟糕设计:
// 糟糕!所有数据都是public的
class Person {
public:
string name;
int age;
double salary;
};
// 使用时可以直接修改,可能出现不合理的数据
Person p;
p.age = -5; // 年龄可以是负数?不合理!
p.salary = 100000; // 随便设置薪水?不安全!
甚至在一个项目中的同事,都有可能无意间改造了你的代码数据!这将导致你遭到无比严峻的惩罚
良好的封装设计:
class Person {
private:
string name;
int age;
double salary;
public:
// 通过公共方法来访问和修改数据
void setAge(int newAge) {
if (newAge >= 0 && newAge <= 150) { // 数据验证
age = newAge;
} else {
cout << "无效的年龄!" << endl;
}
}
void setSalary(double newSalary, bool isAdmin) {
if (isAdmin) { // 权限检查
salary = newSalary;
} else {
cout << "无权修改薪水!" << endl;
}
}
int getAge() const { return age; }
string getName() const { return name; }
// 注意:没有提供getSalary(),薪水信息对外隐藏
};
// 使用封装后的类
Person p;
p.setAge(25); // 正确
p.setAge(-5); // 会被拒绝并提示错误
p.setSalary(50000, false); // 无权限,操作被拒绝
通过public方法,来限制和管理用户访问数据的方式。
如果你是游戏玩家,你大概也会像我一样梦想着通过修改游戏货币,来获得自己想要的角色和皮肤。可惜我们的游戏货币大概是private变量,而游戏公司没给我们public的方法。
三、封装带来的巨大好处
1. 数据保护
class BankAccount {
private:
double balance;
public:
void withdraw(double amount) {
if (amount <= balance) { // 防止透支
balance -= amount;
}
}
};
2. 实现灵活性
class DataStorage {
private:
// 内部实现可以随时改变,不影响外部使用者
vector<int> data; // 今天用vector
// 明天可以改成 map<int, int> 或其他结构
public:
void addData(int value) {
data.push_back(value);
}
int getData(int index) {
return data[index];
}
};
3. 易于维护 当需要修改验证逻辑时,只需要在一个地方修改:
void setAge(int newAge) {
// 只需要在这里修改验证规则
if (newAge >= 0 && newAge <= 120) { // 从150改为120
age = newAge;
}
}
4. 降低复杂度 使用者不需要了解内部实现,只需要知道接口怎么用:
// 使用者只需要知道:
calculator.add(5, 3); // 结果是8
// 不需要知道:
// - 是用CPU加法器实现的?
// - 还是用位运算实现的?
// - 有没有缓存机制?
节省了使用者的精力,我们的软件因此更好用。
四、封装的最佳实践
1. 数据成员通常设为 private
class GoodDesign {
private:
int importantData; // 数据隐藏
public:
// 通过方法访问
};
2. 提供完整的Get/Set方法
class Rectangle {
private:
double width;
double height;
public:
double getWidth() const { return width; }
void setWidth(double w) {
if (w > 0) width = w;
}
double getHeight() const { return height; }
void setHeight(double h) {
if (h > 0) height = h;
}
// 提供有意义的业务方法,而不仅仅是Get/Set
double getArea() const { return width * height; }
};
这一点我们在所有面向对象的语言中都很常见,例如java也通过类似的方式访问
3. 构造函数初始化
class Student {
private:
string name;
int id;
public:
// 通过构造函数确保对象始终处于有效状态
Student(const string& n, int i) : name(n), id(i) {
// 可以在构造函数中进行验证
}
};
第五个重要问题:类的实例化与对象模型------从蓝图到实体
通过前面的学习,我们已经知道如何设计"蓝图"(类),现在让我们看看如何把蓝图变成"实体"(对象),以及这些实体在内存中是如何存储的。
一、类的实例化:从蓝图到建筑
什么是实例化?
类的实例化就是用类类型创建对象的过程。
**生动的比喻:**实例化,就是我们按照类的定义(这个建筑图纸),来创建一个实际的类对象(一栋实际的房子)
关键理解:类本身不占空间
class Person {
public:
void introduce() {
cout << "我叫" << name << ",今年" << age << "岁" << endl;
}
private:
string name;
int age;
};
int main() {
// 错误!类本身没有空间,不能直接访问成员
// Person::age = 100; // 编译错误
// 正确!必须先实例化对象
Person p1; // 创建第一个Person对象
Person p2; // 创建第二个Person对象
return 0;
}
重要结论:
- 类 = 设计图(不占内存空间)
- 对象 = 实际建筑(占用物理内存)
- 必须先实例化才能使用
二、类对象模型:对象在内存中的秘密
当我们创建一个对象时,它到底包含什么?成员变量和成员函数是如何存储的?
三种可能的存储方式:
方式1:对象包含所有成员(变量+函数)
对象A: [变量_a] [函数PrintA代码]
对象B: [变量_b] [函数PrintA代码] ← 重复存储,浪费空间!
方式2:对象包含变量和函数指针
对象A: [变量_a] [指向PrintA的指针]
对象B: [变量_b] [指向PrintA的指针] ← 还是有多余的指针
方式3:对象只包含变量,函数在代码区
对象A: [变量_a]
对象B: [变量_b]
代码区: [PrintA函数代码] ← 只有一份,所有对象共享
真相验证:通过sizeof来探究
让我们用代码来验证计算机到底采用哪种方式:
#include <iostream>
using namespace std;
// 类中既有成员变量,又有成员函数
class A1 {
public:
void f1() {}
private:
int _a;
};
// 类中仅有成员函数
class A2 {
public:
void f2() {}
};
// 空类
class A3 {};
int main() {
cout << "sizeof(A1): " << sizeof(A1) << endl; // 输出多少?
cout << "sizeof(A2): " << sizeof(A2) << endl; // 输出多少?
cout << "sizeof(A3): " << sizeof(A3) << endl; // 输出多少?
return 0;
}
运行结果:
sizeof(A1): 4 // 只有一个int成员变量
sizeof(A2): 1 // 没有成员变量,但空类不能为0
sizeof(A3): 1 // 空类
重要结论:
- 对象只存储成员变量,成员函数存放在公共的代码段
- 空类大小为1字节:为了确保每个对象在内存中有唯一的地址
- 计算类大小 = 所有成员变量大小之和(考虑内存对齐)
三、结构体内存对齐规则
为什么sizeof(A1)是4而不是其他值?这就涉及到内存对齐。
为什么要内存对齐?
- 性能优化:CPU读取对齐的数据更快
- 硬件要求:某些架构要求数据必须对齐访问
对齐规则(重要!):
- 第一个成员在结构体偏移量0处
- 其他成员 要对齐到
min(成员大小, 编译器默认对齐数)的整数倍地址- VS默认对齐数 = 8
- Linux通常没有默认对齐数(按成员自身大小对齐)
- 结构体总大小 = 最大对齐数的整数倍
- 嵌套结构体:对齐到嵌套结构体自身最大对齐数的整数倍
实战分析:
struct Example1 {
char a; // 1字节,偏移0
int b; // 4字节,对齐到4的倍数(偏移4)
double c; // 8字节,对齐到8的倍数(偏移8)
// 总大小:8(c) + 8 = 16,是8的倍数 ✓
};
// sizeof(Example1) = 16
struct Example2 {
int a; // 4字节,偏移0
char b; // 1字节,偏移4
short c; // 2字节,对齐到2的倍数
// 总大小:4+1+2=7,但要是4的倍数 → 8
};
// sizeof(Example2) = 8
struct Example3 {
char a; // 1字节,偏移0
// 填充3字节
int b; // 4字节,偏移4
char c; // 1字节,偏移8
// 填充7字节(因为嵌套结构体最大对齐数是8)
struct Inner {
double d; // 8字节
} inner;
};
// sizeof(Example3) = 24
关于这一知识点的:重要问题
1. 结构体怎么对齐?为什么要进行内存对齐?
对齐方法:
- 按成员声明顺序依次放置
- 每个成员对齐到特定边界
- 最后补齐到最大对齐数的整数倍
对齐原因:
- 性能:对齐后CPU可以用更少的周期读取数据
- 移植性:避免在不同平台出现兼容性问题
- 硬件限制:某些CPU无法访问未对齐的内存
2. 如何指定对齐参数?
// 指定按4字节对齐
#pragma pack(4)
struct AlignedStruct {
char a; // 1字节
int b; // 4字节,现在按4对齐(而不是8)
double c; // 8字节,但受pack(4)影响,按4对齐
};
#pragma pack() // 恢复默认对齐
// C++11方式
struct alignas(8) MyStruct {
int a;
char b;
};
注意:不能任意对齐(如3、5字节),必须是2的幂次方。
3. 大小端问题
什么是大小端?
- 大端模式:高位字节存储在低地址(人类阅读顺序)
- 小端模式:低位字节存储在低地址(Intel/ARM常用)
测试方法:
#include <iostream>
using namespace std;
void checkEndian() {
int num = 0x12345678;//从左往右字节越来越低
char* ptr = (char*)&num//强制按字节访问我们的整形
if (*ptr == 0x78) {
cout << "小端模式" << endl; // 78在低地址
} else {
cout << "大端模式" << endl; // 12在低地址
}
}
需要考虑大小端的场景:
- 网络编程:网络字节序是大端,需要htonl/ntohl转换
- 文件格式:某些文件格式指定了字节序
- 硬件交互:与特定硬件设备通信时
- 跨平台数据交换:不同架构的系统间传输数据
- 底层对齐层面:内存对齐提升性能,大小端影响本讲数据存储
最后一个重要问题:this指针------对象的自我认知
通过前面的学习,我们已经知道如何定义类、创建对象,但有一个关键问题:当多个对象调用同一个成员函数时,函数如何知道自己在操作哪个对象?
这就是 this 指针要解决的核心问题!
一、this指针的引出:对象身份的困惑
问题场景
class Date {
public:
void Init(int year, int month, int day) {
_year = year; // 问题:这个_year属于哪个对象?
_month = month; // d1的month?还是d2的month?
_day = day;
}
void Print() {
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1, d2;
d1.Init(2022, 1, 11); // 设置d1的日期
d2.Init(2022, 1, 12); // 设置d2的日期
d1.Print(); // 打印d1
d2.Print(); // 打印d2
return 0;
}
关键问题: 同一个Init函数,如何知道现在是要初始化d1还是d2?
二、this指针的真相:编译器的额外处理
编译器在背后做了什么?
实际上,编译器把我们的成员函数"翻译"成了这样:
// 我们写的:
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
// 编译器处理的:
void Init(Date* this, int year, int month, int day) {
this->_year = year;
this->_month = month;
this->_day = day;
}
// 调用时:
d1.Init(2022, 1, 11);
// 被编译器转换为:
Init(&d1, 2022, 1, 11); // 传递d1的地址作为第一个参数!
显式使用this指针
虽然编译器自动处理,但我们也可以显式使用:
class Date {
public:
void Init(int year, int month, int day) {
this->_year = year; // 显式使用this
this->_month = month;
this->_day = day;
}
// 更常见的用途:解决命名冲突
void SetDate(int _year, int _month, int _day) {
this->_year = _year; // this->_year是成员变量
this->_month = _month; // _month是参数
this->_day = _day;
}
// 返回对象自身的引用,支持链式调用
Date& addYear(int years) {
this->_year += years;
return *this; // 返回当前对象
}
Date& addMonth(int months) {
this->_month += months;
return *this;
}
private:
int _year, _month, _day;
};
// 链式调用
Date d;
d.addYear(1).addMonth(2); // 连续操作
三、this指针的特性
1. 类型和不可变性
class MyClass {
public:
void example() {
// this的类型是:MyClass* const
// 意味着:this本身是常量指针,不能修改this指向别的地址
// this = nullptr; // 错误!不能修改this指针本身
}
};
2. 作用域限制
class MyClass {
public:
void memberFunction() {
cout << this << endl; // 正确:在成员函数内使用
}
private:
// int* ptr = this; // 错误!不能在类定义中直接使用
};
void externalFunction() {
// cout << this << endl; // 错误!不能在非成员函数中使用
}
3. 存储位置和传递方式
// this指针是函数的形参,不是对象的一部分
class Empty {
// 空类,没有成员变量
};
void demo() {
Empty e;
// e对象中不包含this指针!
// this指针在调用成员函数时通过寄存器(如ecx)传递
}
四、关键问题解析
问题1:this指针存在哪里?
答案: this指针是成员函数的形参 ,存在于函数调用的栈帧 中,或者通过寄存器传递。
class Test {
public:
void func() { /* 这个函数实际是:void func(Test* this) */ }
};
// 调用时:
Test obj;
obj.func(); // 实际传递:func(&obj)
可以理解为this指针并不存在对象中,只有在调用时,他才会作为形参出现存在栈帧中的特定位置,调用结束又随着函数栈帧销毁消除了
问题2:this指针可以为空吗?
案例1:正常运行
class A {
public:
void Print() {
cout << "Print()" << endl; // 没有访问成员变量
}
private:
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // 正常运行!
return 0;
}
分析: 没有访问成员变量,不涉及this解引用,所以安全。
案例2:运行崩溃
class A {
public:
void PrintA() {
cout << _a << endl; // 访问成员变量 ⇐ 这里会崩溃!
}
private:
int _a;
};
int main() {
A* p = nullptr;
p->PrintA(); // 运行崩溃!
return 0;
}
分析: 访问_a相当于this->_a,而this是nullptr,解引用空指针导致崩溃。
重要结论:
- this指针可以为空
- 但只要不通过空this指针访问成员变量/虚函数,程序就不会崩溃
- 这是一种未定义行为,要避免!
xml
感谢你能够阅读到这里,如果本篇文章对你有帮助,欢迎点赞收藏支持,关注我,
我将更新更多有关C++,Linux系统·网络部分的知识。