【C++】类和对象(上)--带你全面理解类和对象的概念,以及this指针的理解和相关面试题

上节内容和大家一起学习了C++的基础入门知识,这节内容,就是真正的走进C++的大门了,先介绍类和对象


面向过程和面向对象初步认识

面向过程:

  • 关注的是解决问题的步骤(算法)

  • 基本单位是函数

  • 数据与操作分离

  • 典型语言:C、Pascal、Fortran

  • 核心思想:程序 = 数据结构 + 算法

面向对象:

  • 关注的是解决问题的参与者(对象)

  • 基本单位是类 + 对象

  • 数据与操作绑定(方法操作自己的数据)

  • 典型语言:C++、Java、C#、Python

  • 核心思想:程序 = 对象 + 消息传递

C++ 是基于面向对象的,但不强制使用面向对象。

C++ 支持三大编程范式:

  • 面向过程(像C一样写)

  • 面向对象(类、继承、多态)

  • 泛型编程(模板)

通俗理解:

面向过程:

像菜谱:洗菜 -->切菜 -->炒菜 -->装盘。一步步按顺序做。

面向对象:

像后厨:厨师负责炒,帮厨负责切,洗碗工负责洗。各干各的,互相配合。

总结:面向过程把一件事拆成步骤,面向对象把一件 事拆成角色。C语言只能拆步骤,C++既可以拆步骤也可以拆角色,还能混着用。


一.引入类

C语言结构体的写法(回顾)

cpp 复制代码
typedef struct ListNodeC
{
    struct ListNodeC* next;
    int val;
}LTNode;

C语言的问题:结构体里只能定义变量,不能定义函数。想初始化节点,必须单独写一个函数在外面。每次使用还要加struct关键字,或者用typedef重命名,比较麻烦。

在 C++中进行了结构体的升级

cpp 复制代码
struct ListNodeCPP
{
    void Init(int x)
    {
        next = nullptr;
        val = x;
    }
    ListNodeCPP* next;
    int val;
};

改进一:结构体里面可以直接定义函数。你看Init函数就直接写在结构体内部了,数据和操作放在一起。

改进二:结构体名本身就是类型。ListNodeCPP直接就能用,不需要在前面加struct,也不需要写typedef。

改进三:完全兼容C语法。以前用C写的结构体代码,放到C++里照样能跑。

**补充:**虽然 C++ 可以兼容 C 语言,但是在 C++ 中更喜欢用 class 来代替对结构体的定义。


二.定义:

最基本的理解如下∶

类 = 同类事物的模板,对象 = 模板造出来的具体个体

通过我给老铁们的讲解,再次慢慢体会这个概念

2.1 类定义格式

class为定义类的关键字,Stack为类的名字,{}中为类的主体,注意类定义结束时后⾯分号不能省略。类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的⽅法或者成员函数。

cpp 复制代码
class studentname
{
    // 类体:由成员函数和成员变量组成
}; // 分号不可以省略否则会被报错

2.2类的两种定义方式

方式一:声明和定义全部放在类体中

把类的声明和成员函数的定义都写在类里面。

cpp 复制代码
class Student
{
public:
    // 声明和定义都在类里面
    void setName(string n)
    {
        name = n;
    }
    
    void printInfo()
    {
        cout << name << endl;
    }
    
private:
    string name;
};

特点: 代码简洁,全在一个地方;成员函数在类里面定义时,编译器可能会把它当成内联函数处理;适合成员函数比较短小的情况(比如只有几行代码)。

简单补充一下内联函数:

内联函数是什么?

普通函数调用时会有跳转、压栈等开销,内联函数会在调用的地方直接把代码展开,省去跳转开销,运行更快。

什么情况下会被当成内联函数?

编译器会自己判断,一般来说函数体很短(比如只有一两行代码)时,编译器倾向于把它内联展开。但注意这只是编译器的建议,最终是否内联由编译器决定。你也可以主动用 inline 关键字提示编译器。

方式二:声明放在 .h 文件,定义放在 .cpp 文件

类的声明放在头文件(.h)中,成员函数的定义放在源文件(.cpp)中。

cpp 复制代码
//student.h
class Student
{
public:
    // 只声明,不定义
    void setName(string n);
    void printInfo();
    
private:
    string name;
};


//student.cpp
#include "Student.h"

// 定义成员函数,需要加 类名::
void Student::setName(string n)
{
    name = n;
}

void Student::printInfo()
{
    cout << name << endl;
}

特点:

  • 声明和定义分离,头文件只保留接口,看起来更清晰

  • 编译更快,修改函数的实现时只需要重新编译 .cpp 文件,不用重新编译所有包含这个头文件的地方

  • 适合函数体比较长的情况,或者需要隐藏实现细节时

注意:第二种常用,也是实际项目中最常用的方式


  • C++中struct也可以定义类,C++兼容C中struct的⽤法,同时struct升级成了类,明显的变化是struct中可以定义函数,⼀般情况下我们还是推荐⽤class定义类。
cpp 复制代码
// 这是C语言的写法,在C++中也能用
struct ListNode
{
    struct ListNode* next;  // 需要加struct关键字
    int val;
};

// 使用
struct ListNode node;  // 需要加struct关键字
cpp 复制代码
// C++的写法,struct名本身就是类型
struct ListNode
{
    ListNode* next;  // 不需要加struct关键字
    int val;
};

// 使用
ListNode node;  // 不需要加struct关键字
  • 在C++中,struct和class几乎是一回事,唯一的区别是默认访问权限不同。
  • struct默认是公开的(public)。也就是说,如果你不写public或private,struct里面所有的成员变量和成员函数,外部都可以直接访问。
  • class默认是私有的(private)。如果你不写public或private,class里面所有的成员变量和成员函数,外部都不能直接访问,必须通过public的接口才能操作。

2-3类的命名

命名风格

在C++中,成员变量和普通变量、函数参数有时候容易混淆,所以一般会加一些特殊标识来区分。

  • 常见的成员变量命名风格:

风格一:成员变量后面加下划线

cpp 复制代码
class Student
{
private:
    string name_;
    int age_;
    int score_;
};

风格二:成员变量前面加下划线

cpp 复制代码
class Student
{
private:
    string _name;
    int _age;
    int _score;
};

风格三:成员变量前面加 m 后面加下划线

cpp 复制代码
class Student
{
private:
    string m_name;
    int m_age;
    int m_score;
};

风格四:什么标识都不加(不推荐)

cpp 复制代码
class Student
{
private:
    string name;   // 容易和参数名混淆
    int age;
    int score;
};

命名方式

类名一般也有统一的命名规范:

风格一:大驼峰(PascalCase)

每个单词首字母大写,例如:

cpp 复制代码
class StudentInfo {};
class LinkedList {};
class FileManager {};

风格二:前面加C

cpp 复制代码
class CStudentInfo {};
class CLinkedList {};
class CFileManager {};

三.类的访问限定符及封装

3.1访问限定符

C++⼀种实现封装的⽅式,⽤类将对象的属性与⽅法结合在⼀块,让对象更加完善,通过访问权限选择性的将其接⼝提供给外部的⽤⼾使⽤

说明:

  • public修饰的成员在类外可以直接被访问protected和private修饰的成员在类外不能直接被访问,protected和private是⼀样的,以后继承章节才能体现出他们的区别。
  • 访问权限作⽤域从该访问限定符出现的位置开始直到下⼀个访问限定符出现时为⽌,如果后⾯没有访问限定符,作⽤域就到 }即类结束
  • class定义成员没有被访问限定符修饰时默认为private ,struct默认为public。
  • ⼀般成员变量都会被限制为private/protected,需要给别⼈使⽤的成员函数会放为public。

public、private、protected 这些关键字,只在编译阶段有意义。编译器看到它们,会检查你有没有乱访问。比如你写代码想拿 private 的变量,编译器就不让你过,直接报错。一旦编译通过,生成可执行文件,跑起来加载到内存之后,这些访问限定符就彻底消失了。内存里只有数据和指令,没有什么"这个变量是私有的、那个变量是公开的"这种标签。不管是 private 还是 public 的变量,在内存里就是一块普通的字节数据,没有任何区别。

举例说明:

cpp 复制代码
class A
{
private:
    int x;   // 私有
public:
    int y;   // 公开
};

编译时,编译器会拦着你,说 x 是私有的不能直接访问。

但编译完成之后,程序运行时,x 和 y 在内存里就是相邻的两个整数。如果你用指针强行去访问 x,完全可以做到,程序也不会报错:

cpp 复制代码
A a;
int* p = (int*)&a;  // 拿到a的起始地址
*p = 100;           // 强行修改了私有的x,编译和运行都没问题

所以说,访问限定符只是编译器用来帮你检查错误的工具,并不是内存层面的安全保护。真正的"私有"是编译时人为约定的规则,不是运行时内存自带的属性。


3.2封装

面向对象的三大特性:封装、 继承 多态

1.什么是封装

封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。

封装本质上是一种管理,目的是让用户更方便、更规范地使用类。


2.生活中的例子:电脑

对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、键盘、鼠标、显示器、USB插孔等。用户通过这些接口和电脑进行交互,完成日常事务。但实际上电脑真正工作的是CPU、显卡、内存、主板等内部硬件元件。对于电脑使用者来说,完全不需要关心内部核心部件。不需要知道主板上线路是如何布局的,不需要知道CPU内部是如何设计的,也不需要知道内存是怎么寻址的。用户只需要知道怎么开机、怎么用键盘打字、怎么用鼠标点击、怎么插U盘就够了。电脑厂商在出厂时,给电脑套上一个外壳,把内部实现细节全部隐藏起来,仅仅对外提供开关机键、键盘、鼠标、USB接口等,让用户通过这些接口与电脑交互。这样做的好处是:用户使用起来很简单,不用担心乱动内部零件把电脑弄坏;厂商也可以自由升级内部硬件,只要接口不变,用户的使用方式就不需要改变。


3.C语言为什么没有封装

C语言没有封装的概念。

在C语言中,结构体只能定义数据,不能定义函数。操作数据的方法只能写成外部函数。

这就导致了一个问题:既可以规范地通过函数来访问数据,也可以直接访问数据。

举个例子:

cpp 复制代码
struct Student
{
    char name[20];
    int age;
    int score;
};

// 规范的方式:通过函数修改年龄
void setAge(struct Student* s, int age)
{
    if (age >= 0 && age <= 150)  // 可以做合法性检查
        s->age = age;
}

int main()
{
    struct Student stu;
    
    // 规范的方式
    setAge(&stu, 20);
    
    // 不规范的方式:直接访问修改,绕过检查
    stu.age = -100;  // 这样也可以,没有任何保护
    
    return 0;
}

C语言没有办法强制用户必须通过函数来访问数据。用户可以遵守规则,也可以不遵守。代码的规范性完全靠程序员的自觉,没有办法从语言层面强制执行。


4.C++如何实现封装

C++通过类来实现封装,具体有以下几个手段:

  • 第一,将数据和操作数据的方法放在一起,定义在类里面。数据和操作绑定成了一个整体。
  • 第二,通过访问限定符(public、private、protected)来控制访问权限。private的成员只能被类内部访问,外部不能直接访问。public的成员是类对外的接口,外部只能通过这些接口来操作对象。
  • 第三,将内部属性设置为private,将对外接口设置为public。这样用户只能通过你提供的公开函数来操作对象,不能在外部直接修改内部数据。

举个例子:

cpp 复制代码
class Student
{
public:
    // 对外提供的公开接口
    void setAge(int age)
    {
        if (age >= 0 && age <= 150)  // 可以在函数内部做合法性检查
        {
            m_age = age;
        }
    }
    
    int getAge()
    {
        return m_age;
    }
    
private:
    // 内部属性,对外隐藏
    string m_name;
    int m_age;
    int m_score;
};

int main()
{
    Student stu;
    
    // 只能通过公开接口访问
    stu.setAge(20);     // 正确
    stu.m_age = -100;   // 编译错误,m_age是私有的,外部不能直接访问
    
    return 0;
}

5.封装的好处

  • 隔离变化。内部的实现细节可以随时修改,只要对外接口保持不变,使用这个类的代码就不需要改动。
  • 易于使用。用户只需要了解公开的接口怎么用,不需要理解内部复杂的实现逻辑。
  • **保证数据的合法性。**可以在公开函数内部做参数检查,防止用户传入非法数据。
  • 降低复杂度。将复杂的实现细节隐藏起来,对外只暴露简单的接口,降低了系统的理解难度。

四.类域

  • 类定义了⼀个新的作⽤域,类的所有成员都在类的作⽤域中,在类体外定义成员时,需要使⽤ :: 作⽤域操作符指明成员属于哪个类域。
cpp 复制代码
class Student
{
public:
    void print();  // 声明
};

// 类外面定义,必须加 Student::
void Student::print()
{
    cout << "Hello" << endl;
}

如果不加 ::

cpp 复制代码
void print()  // 这是全局函数,不是 Student 的成员函数
{
    // 编译器不认识 Student 的成员变量
}
  • 类域影响的是编译的查找规则,下⾯程序中Init如果不指定类域Stack,那么编译器就把Init当成全局函数,那么编译时,找不到array等成员的声明/定义在哪⾥,就会报错。指定类域Stack,就是知道Init是成员函数,当前域找不到的array等成员,就会到类域中去查找。
cpp 复制代码
#include<iostream>
using namespace std;
class Stack
{
public:
// 成员函数
void Init(int n = 4);
private:
// 成员变量
    int* array;
    size_t capacity;
    size_t top;
};
// 声明和定义分离,需要指定类域
void Stack::Init(int n)
{
    array = (int*)malloc(sizeof(int) * n);
    if (nullptr == array)
    {
    perror("malloc申请空间失败");
    return;
    }
    capacity = n;
    top = 0;
}
int main()
{
    Stack st;
    st.Init();
    return 0;
}

五.类的实例化

⽤类类型在物理内存中创建对象的过程,称为类实例化出对象。

  • 类是对象进⾏⼀种抽象描述,是⼀个模型⼀样的东西,限定了类有哪些成员变量,这些成员变量只是声明,没有分配空间,⽤类实例化出对象时,才会分配空间。
  • ⼀个类可以实例化出多个对象,实例化出的对象 占⽤实际的物理空间,存储类成员变量。
cpp 复制代码
#include <iostream>
using namespace std;

// 类定义
class Student {
public:
    // 成员变量
    string name;
    int age;
    float score;

    // 成员函数
    void display() {
        cout << "姓名:" << name << ", 年龄:" << age << ", 分数:" << score << endl;
    }
};

int main() {
    // 类的实例化:创建对象
    Student stu1;  // stu1是Student类的一个实例
    stu1.name = "张三";
    stu1.age = 18;
    stu1.score = 95.5;

    Student stu2;  // 可以实例化出多个对象
    stu2.name = "李四";
    stu2.age = 19;
    stu2.score = 88.0;

    // 调用成员函数
    stu1.display();  // 输出:姓名:张三, 年龄:18, 分数:95.5
    stu2.display();  // 输出:姓名:李四, 年龄:19, 分数:88

    return 0;
}

打个⽐⽅:类实例化出对象就像现实中使⽤建筑设计图建造出房⼦,类就像是设计图,设计图规划了有多少个房间,房间⼤⼩功能等,但是并没有实体的建筑存在,也不能住⼈,⽤设计图修建出房⼦,房⼦才能住⼈。同样类就像设计图⼀样,不能存储数据,实例化出的对象分配物理内存存储数据。


六.类的对象⼤⼩

6-1如何计算类的对象的大小

一个类的对象中只包含成员变量,不包含成员函数。

类的大小 = 所有成员变量的大小之和 + 内存对齐

具体规则:

  1. 成员函数不占空间 --- 有函数和没函数,对象大小一样

  2. 只算成员变量 --- 按内存对齐规则计算所有成员变量的总大小

  3. 空类占1字节 --- 如果类没有任何成员变量(包括只有成员函数或完全空),大小为1字节,用来标识对象存在

  4. 静态成员不占空间 --- 静态成员变量存储在静态区,不算在对象大小内

  5. 虚函数加指针 --- 如果有虚函数,对象中会多一个虚表指针(64位系统8字节)

内存对齐规则(后面接着讲解)

cpp 复制代码
class A
{
public:
    void Print()
    {
        cout << _a << _b << endl;
    }
private:
    char _a;
    char _b;
};

int main()
{
    cout << sizeof(A) << endl; // 输出:2(两个char,各1字节)
    return 0;
}

6-2类的对象的存储方式

  • 对象中有类的各个成员
cpp 复制代码
#include <iostream>
using namespace std;

class Person
{
public:
    void SetPersonInfo(const char* name, char gender, int age)
    {
        _name = name;
        _gender = gender;
        _age = age;
    }
    
    void PrintPersonInfo()
    {
        cout << "姓名:" << _name << " 性别:" << _gender << " 年龄:" << _age << endl;
    }
    
private:
    const char* _name;
    char _gender;
    int _age;
};

int main()
{
    Person p1, p2;
    
    cout << "p1对象大小: " << sizeof(p1) << " 字节" << endl;
    cout << "p2对象大小: " << sizeof(p2) << " 字节" << endl;
    
    // 输出结果:16字节(指针8 + char1 + int4,对齐后16)
    // 成员函数不占用对象空间!
    
    p1.SetPersonInfo("张三", '男', 20);
    p2.SetPersonInfo("李四", '女', 22);
    
    p1.PrintPersonInfo();
    p2.PrintPersonInfo();
    
    return 0;
}
  • 代码只保存一份,在对象中保存存放成员函数代码的地址
  • 只保存成员变量,成员函数存放在公共的代码段

编译阶段

编译器把 obj.PrintA() 处理成 call PrintA(函数名占位),还不知道地址。

链接阶段

链接器找到 PrintA 函数的真实地址(比如 0x401000),把 call PrintA 替换成 call 0x401000

运行阶段

CPU 执行 call 0x401000,直接跳转到代码段该地址,运行函数代码。

代码验证:

cpp 复制代码
class A
{
public:
    void PrintA() 
    { 
        cout << "Hello" << endl; 
    }
};

int main()
{
    A obj;
    obj.PrintA();
    
    // 你可以这样理解底层:
    // 1. 编译器知道 PrintA 函数在代码段的地址(比如 0x401000)
    // 2. call指令直接跳转到 0x401000
    // 3. 从对象obj中取出 _a 成员的值(通过this指针)
    
    return 0;
}

对于上述三种存储方式那计算机存储是选取哪一种呢?

验证一哈:

cpp 复制代码
class Person
{
public:
    void ShowInfo()
    {
        cout << _name << " " << _age << endl;
    }
private:
    char _name[20];
    int _age;
};

class Dog
{
public:
    void Bark()
    {}
private:
    char _type[10];
};

class Cat
{
public:
    void Mew()
    {}
};

class Empty
{};

int main()
{
    cout << sizeof(Person) << endl; // 24 (char[20]占20 + int占4,对齐后24)
    cout << sizeof(Dog) << endl;    // 10 (char[10]占10,无对齐问题)
    cout << sizeof(Cat) << endl;    // 1  (只有成员函数,空类占1)
    cout << sizeof(Empty) << endl;  // 1  (空类占1)
    
    return 0;
}

一个类的大小,实际就是该类中成员变量之和(考虑内存对齐),成员函数不占用对象空间。

  • Person 有成员变量 大小 = 成员变量大小

  • Dog 有成员变量 大小 = 成员变量大小

  • Cat 只有成员函数 大小 = 1(空类占位)

  • Empty 什么都没有 大小 = 1(空类占位)

上⾯我们分析了对象中只存储成员变量,C++规定类实例化的对象也要符合内存对⻬的规则


6-3内存对⻬规则

  • 第⼀个成员在与结构体偏移量为0的地址处。 其他成员变量要对⻬到某个数字(对⻬数)的整数倍的地址处。
  • 注意:对⻬数 = 编译器默认的⼀个对⻬数 与 该成员⼤⼩的较⼩值。 VS中默认的对⻬数为8
  • 结构体总⼤⼩为:**最⼤对⻬数(所有变量类型最⼤者与默认对⻬参数取最⼩)的整数倍。**如果嵌套了结构体的情况,嵌套的结构体对⻬到⾃⼰的最⼤对⻬数的整数倍处,结构体的整体⼤⼩就是所有最⼤对⻬数(含嵌套结构体的对⻬数)的整数倍。
cpp 复制代码
#include<iostream>
using namespace std;

class A
{
public:
    void Print()
    {
        cout << _ch << endl;
    }
private:
    char _ch;   // 1字节
    int _i;     // 4字节
};

class B
{
public:
    void Print()
    {
        //...
    }
};

class C
{};

int main()
{
    A a;
    B b;
    C c;
    cout << sizeof(a) << endl;  // 8
    cout << sizeof(b) << endl;  // 1
    cout << sizeof(c) << endl;  // 1
    
    return 0;
}

上⾯的程序运⾏后,我们看到没有成员变量的B和C类对象的⼤⼩是1,为什么没有成员变量还要给1个字节呢?因为如果⼀个字节都不给,怎么表⽰对象存在过呢!所以这⾥给1字节,纯粹是为了占位标识对象存在。

  • 成员函数不占对象空间 --- B类有函数但大小是1(空类大小)

  • 只算成员变量 --- A类只计算 _ch_i 的大小+对齐

  • 空类占1字节 --- 保证每个对象有唯一内存地址

七.this指针

7-1this指针的引入

  • Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调⽤Init和Print函数时,该函数是如何知道应该访问的是d1对象还是d2对象呢?那么这⾥就要看到C++给了⼀个隐含的this指针解决这⾥的问题

C++ 通过 this 指针解决该问题:编译器给每个非静态成员函数增加一个隐藏的指针参数(即 this),指向当前调用该函数的对象。函数内部对所有成员变量的访问,实际上都是通过 this 指针来完成的。这一切由编译器自动完成,对用户透明,无需手动传递。

  • 编译器编译后,类的成员函数默认都会在形参第⼀个位置,增加⼀个当前类类型的指针,叫做this指针。⽐如Date类的Init的真实原型为, void Init(Date* const this, int year,int month, int day)
  • 类的成员函数中访问成员变量,本质都是通过this指针访问的,如Init函数中给_year赋值, this->_year = year;
  • C++规定不能在实参和形参的位置显⽰的写this指针(编译时编译器会处理),但是可以在函数体内显⽰使⽤this指针
cpp 复制代码
class Date
{
public:
    void Init(int year, int month, int day)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print()
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    Date d1, d2;
    d1.Init(2023, 1, 26);  // 设置d1
    d2.Init(2023, 8, 9);   // 设置d2
    return 0;
}

问题:Init 函数只有一份代码,它怎么知道第一次改的是 d1,第二次改的是 d2

编译器把上面的代码处理成:

cpp 复制代码
class Date
{
public:
    void Init(Date* const this, int year, int month, int day)
    {
        this->_year = year;
        this->_month = month;
        this->_day = day;
    }
    void Print(Date* const this)
    {
        cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    Date d1, d2;
    d1.Init(&d1, 2023, 1, 26);  // 把d1的地址传进去
    d2.Init(&d2, 2023, 8, 9);   // 把d2的地址传进去
    return 0;
}

验证代码:

cpp 复制代码
class A
{
public:
    void Print()
    {
        cout << "this指针地址:" << this << endl;
    }
};

int main()
{
    A a1, a2;
    cout << "a1对象地址:" << &a1 << endl;
    a1.Print();  // this指向a1
    cout << "a2对象地址:" << &a2 << endl;
    a2.Print();  // this指向a2
    return 0;
}

7-2面试题有关this指针的面试题:

第一题:

下⾯程序编译运⾏结果是()
A、编译报错 B、运⾏崩溃 C、正常运⾏

cpp 复制代码
#include<iostream>
using namespace std;
class A
{
public:
	void Print()
	{
		cout << "A::Print()" << endl;
	}
private:
	int _a;
};

int main()
{
	A* p = nullptr;
	p->Print();
	return 0;
}

原因分析

虽然 p 是空指针,但调用 p->Print() 时:

  1. Print 函数在代码段,不在对象中

  2. 没有访问成员变量 _a

  3. 编译器将 p->Print() 处理为 Print(p),传入的 this = nullptr

  4. 函数内只打印字符串,没有通过 this 访问任何成员变量所以不会崩溃。


第二题:

下⾯程序编译运⾏结果是()
A、编译报错 B、运⾏崩溃 C、正常运⾏

cpp 复制代码
#include<iostream>
using namespace std;
class A
{
    public:
    void Print()
{
    cout << "A::Print()" << endl;
    cout << _a << endl;
}
private:
    int _a;
};


int main()
{
    A* p = nullptr;
    p->Print();
    return 0;
}

会发现崩溃了:

原因分析

1.main函数开始执行

在 main 函数中,我们定义了一个指针 p,并且把它初始化为 nullptrnullptr 就是 0 号地址,这个地址是无效的,不允许访问。

2.调用 Print 函数

当执行到 p->Print() 这一行时,编译器会进行如下处理:

虽然 p 是空指针,但是 p->Print() 这个调用本身并不需要去访问 p 所指向的内存。因为编译器在编译阶段就已经知道了 Print 函数的地址在哪里(在代码段中)。所以,编译器把 p->Print() 转换成一个普通的函数调用,只是把 p 的值(也就是 0)作为隐藏的 this 参数传给这个函数。此时,函数被成功调用

3.进入 Print 函数内部

执行第一行代码:

cpp 复制代码
cout << "A::Print()" << endl;

这一行代码,它只是往屏幕上输出一个字符串 "A::Print()"。这个字符串是一个常量,它在编译时就确定了,存储在程序的常量区。这行代码完全不涉及 this 指针 ,也不需要去访问 _a 成员变量。所以,这一行能够正常执行。屏幕上的输出结果就是:

cpp 复制代码
A::Print()

执行第二行代码:

cpp 复制代码
cout << _a << endl;

这一行代码就不同了。_a 是类的成员变量,它不是独立存在的,而是属于对象的。编译器会把这一行解释成:

cpp 复制代码
cout << _a << endl;

这里的 this 是什么?

就是前面传进来的 p 的值,也就是 nullptr(0 号地址)。

现在,程序试图去访问 this->_a。这意味着:从地址 0 开始,偏移一定的字节数(因为 _a 在对象中的偏移量通常是 0 或 4 个字节,取决于是否有别的成员),去读取那个位置的值。

但是,地址 0 是无效的! 操作系统会保护这个地址,不允许任何程序去读取或写入。当程序试图访问地址 0 时,CPU 会触发一个"访问违规"异常,操作系统检测到这个异常后,会立即终止这个程序。

这就是我们常说的"程序崩溃"。

注意:"地址0无效"的意思是:操作系统禁止程序读取或写入地址为0的内存位置

4.程序被终止

因为第二行代码触发了崩溃,程序在这里被操作系统强制终止。后面的任何代码都不会再执行。


问题三:

this指针存在内存哪个区域的 ()
A. 栈 B.堆 C.静态区 D.常量区 E.对象⾥⾯

原因分析:

this 指针本质上是成员函数的隐藏形参。当一个对象调用成员函数时,编译器会把该对象的地址作为实参传递给这个形参。

在绝大多数编译器的实现中:

  • 这个形参(this)和普通函数参数一样,存储在栈区

  • x86架构下,它通常并不是通过"压栈"来传递,而是存放在 ecx 寄存器 中以提高效率。寄存器传参是一种优化,但this作为形参的概念依然属于栈帧的一部分,其逻辑位置在栈上。

  • 尤其是当this作为显式参数参与运算(如取地址)时,它就是一个存在于栈上的变量。


7-3指针的特性

1. this指针的类型:类类型* const即成员函数中,不能给this指针赋值。

cpp 复制代码
class A
{
public:
    void Print()
    {
        // this = nullptr;  // 错误!this是const指针,不能被赋值
        // this = &other;   // 错误!不能改变this的指向
    }
};

2. 只能在成员函数的内部使用

cpp 复制代码
class A
{
public:
    void Print()
    {
        cout << this << endl;  // 成员函数内部可以使用
    }
};

int main()
{
    A a;
    // cout << a.this << endl;  // 错误!类外不能使用this
    return 0;
}

3. this指针本质上是成员函数的形参当对象调用成员函数时,将对象地址作为实参传递给this形参。所以在对象中不存储this指针。

cpp 复制代码
class A
{
public:
    void Print()  // 编译器处理成 void Print(A* const this)
    {
        cout << _a << endl;  // 编译器处理成 cout << this->_a << endl;
    }
private:
    int _a;
};

int main()
{
    A a;
    a.Print();  // 编译器处理成 Print(&a)
    
    cout << sizeof(A) << endl;  // 4字节,只有_a的大小
    // this指针不在对象里面,不占对象空间
    return 0;
}

4. this指针存放在栈区(作为形参)因为this本质上是形参,所以存放在栈区

cpp 复制代码
class A
{
public:
    void Print()
    {
        cout << "this指针本身的地址:" << &this << endl;  // 栈地址
    }
};

5. VS2022中通过ecx寄存器传递this指针

在VS2022下,编译器通过ecx寄存器自动传递this指针,这样访问变量效率更高,且不需要用户传递。

cpp 复制代码
// 底层原理
// 调用前:ecx = &a  (this指针放入ecx寄存器)
// 函数内:通过ecx访问成员变量

7-4.this指针用途:

用途一:解决名称冲突

当形参和成员变量同名时,可用 this 指针来区分。

cpp 复制代码
class Student
{
public:
    // 形参name和成员变量name同名
    void SetName(const char* name)
    {
        // name = name;     // 错误:分不清哪个是成员
        this->name = name;  // 正确:this->name 是成员变量
    }
private:
    const char* name;
};

用途二:返回对象本身

在类的非静态成员函数中返回对象本身,可使用 return *this

cpp 复制代码
class Counter
{
public:
    Counter& Add()        // 返回引用,支持链式调用
    {
        _count++;
        return *this;     // 返回当前对象
    }
    
    Counter& SetValue(int n)
    {
        _count = n;
        return *this;
    }
    
    void Print()
    {
        cout << _count << endl;
    }
private:
    int _count = 0;
};

int main()
{
    Counter c;
    c.Add().Add().SetValue(10).Add();  // 链式调用
    c.Print();  // 输出11
    return 0;
}

用途三:区分同名变量

当局部变量、形参与成员变量三者同名时,this 也能区分。

cpp 复制代码
class Date
{
public:
    void Init(int year, int month, int day)
    {
        // this-> 明确表示访问的是成员变量
        this->year = year;    // 成员 = 形参
        this->month = month;
        this->day = day;
        
        int year = 100;       // 局部变量
        // this->year 依然是成员变量,不受影响
    }
private:
    int year;
    int month;
    int day;
};

用途四:在赋值运算符重载中返回自身

cpp 复制代码
class String
{
public:
    String& operator=(const String& other)
    {
        if (this != &other)  // 防止自己赋值给自己
        {
            // 赋值操作...
        }
        return *this;  // 返回自身,支持连续赋值
    }
};

7-5C++和C语⾔实现Stack对⽐

⾯向对象三⼤特性:封装、继承、多态,下⾯的对⽐我们可以初步了解⼀下封装。

通过下⾯两份代码对⽐,我们发现C++实现Stack形态上还是发⽣了挺多的变化,底层和逻辑上没啥变化。

  • C++中数据和函数都放到了类⾥⾯,通过访问限定符进⾏了限制,不能再随意通过对象直接修改数据 ,这是C++封装的⼀种体现,这个是最重要的变化。这⾥的封装的本质是⼀种更严格规范的管理,避免出现乱访问修改的问题。当然封装不仅仅是这样的,我们后⾯还需要不断的去学习。
  • C++中有⼀些相对⽅便的语法,⽐如Init给的缺省参数会⽅便很多,成员函数每次不需要传对象地址,因为this指针隐含的传递了,⽅便了很多,使⽤类型不再需要typedef⽤类名就很⽅便
  • 在我们这个C++⼊⻔阶段实现的Stack看起来变了很多,但是实质上变化不⼤。但等到后面我们进入到了STL才是C++真正大变样的时候

C实现stack

cpp 复制代码
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
typedef int STDataType;
typedef struct Stack
{
	STDataType* a;
	int top;
	int capacity;
}ST;
void STInit(ST* ps)
{
	assert(ps);
	ps->a = NULL;
	ps->top = 0;
	ps->capacity = 0;
}
void STDestroy(ST* ps)
{
	assert(ps);
	free(ps->a);
	ps->a = NULL;
	ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{
	assert(ps);
	// 满了, 扩容
	if (ps->top == ps->capacity)
	{
		int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
		STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity *
			sizeof(STDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			return;
		}
		ps->a = tmp;
		ps->capacity = newcapacity;
	}
	ps->a[ps->top] = x;
	ps->top++;
}
bool STEmpty(ST* ps)
{
	assert(ps);
	return ps->top == 0;
}
void STPop(ST* ps)
{
	assert(ps);
	assert(!STEmpty(ps));
	ps->top--;
}
STDataType STTop(ST* ps)
{
	assert(ps);
	assert(!STEmpty(ps));
	return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{
	assert(ps);
	return ps->top;
}
int main()
{
	ST s;
	STInit(&s);
	STPush(&s, 1);
	STPush(&s, 2);
	STPush(&s, 3);
	STPush(&s, 4);
	while (!STEmpty(&s))
	{
		printf("%d\n", STTop(&s));
		STPop(&s);
	}
	STDestroy(&s);
	return 0;
}

C++实现stack

cpp 复制代码
#include<iostream>
#include<assert.h>
using namespace std;
typedef int STDataType;
class Stack
{
public:
	// 成员函数
	void Init(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}
	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity *
				sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				return;
			}
			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top++] = x;
	}
	void Pop()
	{
		assert(_top > 0);
		--_top;
	}
	bool Empty()
	{
		return _top == 0;
	}
	int Top()
	{
		assert(_top > 0);
		return _a[_top - 1];
	}
	void Destroy()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	//成员变量
		STDataType * _a;
	size_t _capacity;
	size_t _top;
};
int main()
{
	Stack s;
	s.Init();
	s.Push(1);
	s.Push(2);
	s.Push(3);
	s.Push(4);
	while (!s.Empty())
	{
		printf("%d\n", s.Top());
		s.Pop();
	}
	s.Destroy();
	return 0;
}

各位老铁,本节博客内容就到这,更多精彩等待后续更新,我们下节见~

相关推荐
叶帆1 小时前
【YFIOs】用C#开发硬件之串口通信
开发语言·c#
于先生吖2 小时前
同城物流创业项目,Java源码搭建多车型搬家拉货、就近配货预约小程序
java·开发语言·小程序
码不停蹄的玄黓2 小时前
Java 异常分类
java·开发语言
牛油果子哥q2 小时前
【C++前置声明与头文件】C++前置声明与头文件深度精讲:重复包含、循环依赖、重复定义报错、工程编译架构与实战解决方案
开发语言·c++
-凌凌漆-2 小时前
Qt QML应用层框架
开发语言·qt
少司府2 小时前
C++进阶:map和set的使用
开发语言·数据结构·c++·容器·stl·set·map
江湖中的阿龙2 小时前
23种设计模式
java·开发语言·设计模式
xiaoshuaishuai82 小时前
C# Avaloniaui ListBox样式及用法
开发语言·c#
可可嘻嘻大老虎2 小时前
SpringBoot拦截器防重复提交实战
java·spring boot·后端