C++类和对象--访问限定符与封装-类的实例化与对象模型-this指针(二)

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(公有)

  • 权限 :在任何地方都可以访问
  • 用途:提供对外的接口,定义类与外界交互的方式
重要规则:
  1. 作用域规则:从访问限定符出现的位置开始,到下一个访问限定符或类结束为止

    cpp

    复制代码
    class Example {
    public:
        int a;      // public
        int b;      // public
    
    private:
        int c;      // private
        int d;      // private
    
    public:
        int e;      // public
    };              // 类结束
  2. 默认访问权限

    • class :默认是 private
    • struct :默认是 public(为了兼容C语言)
  3. 编译时检查:访问权限只在编译阶段检查,运行时没有任何区别

二、封装:面向对象的"黑箱哲学"
什么是封装?

封装 = 数据 + 操作数据的方法 + 访问控制(对应成员变量,成员函数,访问限定符:封装就是我们来造一个完整的类)

用一句话概括:"隐藏实现细节,只暴露必要的接口"

现实世界的封装例子:

电脑的封装:

  • 隐藏的: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. 对象只存储成员变量,成员函数存放在公共的代码段
  2. 空类大小为1字节:为了确保每个对象在内存中有唯一的地址
  3. 计算类大小 = 所有成员变量大小之和(考虑内存对齐)
三、结构体内存对齐规则

为什么sizeof(A1)是4而不是其他值?这就涉及到内存对齐。

为什么要内存对齐?
  • 性能优化:CPU读取对齐的数据更快
  • 硬件要求:某些架构要求数据必须对齐访问
对齐规则(重要!):
  1. 第一个成员在结构体偏移量0处
  2. 其他成员 要对齐到min(成员大小, 编译器默认对齐数)的整数倍地址
    • VS默认对齐数 = 8
    • Linux通常没有默认对齐数(按成员自身大小对齐)
  3. 结构体总大小 = 最大对齐数的整数倍
  4. 嵌套结构体:对齐到嵌套结构体自身最大对齐数的整数倍
实战分析:
复制代码
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转换
  • 文件格式:某些文件格式指定了字节序
  • 硬件交互:与特定硬件设备通信时
  • 跨平台数据交换:不同架构的系统间传输数据
  1. 底层对齐层面:内存对齐提升性能,大小端影响本讲数据存储

最后一个重要问题: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,而thisnullptr,解引用空指针导致崩溃。

重要结论:

  • this指针可以为空
  • 但只要不通过空this指针访问成员变量/虚函数,程序就不会崩溃
  • 这是一种未定义行为,要避免!
xml 复制代码
感谢你能够阅读到这里,如果本篇文章对你有帮助,欢迎点赞收藏支持,关注我,
我将更新更多有关C++,Linux系统·网络部分的知识。
相关推荐
一只小bit2 小时前
Qt Widget 控件介绍:覆盖常用属性及API
开发语言·c++·qt·命令模式·cpp
white-persist3 小时前
网络空间安全核心领域技术架构深度解析
c语言·开发语言·网络·python·安全·网络安全·架构
qq_463944863 小时前
如何修改Anaconda虚拟环境的名字?
开发语言·python·anaconda
月明长歌3 小时前
【码道初阶】LeetCode 622:设计循环队列:警惕 Rear() 方法中的“幽灵数据”陷阱
java·算法·leetcode·职场和发展
秦苒&3 小时前
【C语言指针三】一维数组传参的本质、冒泡排序、二级指针、指针数组、指针数组模拟二维数组、字符指针变量
c语言·开发语言
Dylan的码园3 小时前
链表与LinkedList
java·数据结构·链表
【非典型Coder】3 小时前
JVM 垃圾收集器中的记忆集与读写屏障
java·开发语言·jvm
走错路的程序员3 小时前
C语言单片机与C#上位机之间传递大量参数比较好的实践方案
c语言·单片机·c#
mit6.8243 小时前
博弈-翻转|hash<string>|smid
算法