3.从内存角度看cpp类与对象

102 阅读10分钟

cpp内存管理

内存分布

image.png

补充知识:

  1. 代码就是在操作内存(如取值写值)

  2. 编译器将代码分为三部分写入可执行文件:

  • 各函数代码(因为此时并没有执行)(代码区)

  • 已初始化的全局及静态变量及常量数据(如字符串常量)(数据区)

  • 未初始化的全局及静态变量,内核在加载时清零(BSS)

  1. 在将可执行程序转载进内存后成为进程,除去PCB控制块,整个进程虚拟空间如上(32位),除了可执行文件外还有低位区域共享库地址映射以及高位的内核空间,总共4G空间

  • 低位区域:如nullptr,只读
  • 栈帧
    • 形参,调用时才会从右到左压进栈顶esp
    • 父函数中调用函数的下一条指令,用于返回
    • 父函数的栈底ebp
    • 函数中的临时变量 image.png
  • 内核空间
    • 占据最高的1GB(0xC0000000-0xFFFFFFFF),用户进程无法直接访问,需通过系统调用进入内核态 
    • 注意:内核空间被所有进程共享,但每个进程的页表会映射到同一物理内存,匿名管道其实就是在这块区域开辟了内存,多个进程同时借助系统调用来访问该区域实现通信
  • 函数返回值,大小如果
    • <=4 eax
    • >4&&<=8 eax edx
    • >8 产生临时变量带出

  1. 类的核心作用是为编译器提供内存布局规则,同时通过继承和多态实现动态行为
  2. 类的成员一部分来自自定义,一部分来自继承

成员函数

  • 所有的成员函数(包括非静态)都存储在代码区
  • 非静态成员函数调用时转换为普通函数,并将当前对象传递为第一个参数thisobj.func()等价于func(&obj)
  • 静态成员函数无this指针,只能访问静态成员或其他静态函数

全局静态函数:使用static修饰的全局符号仅限当前文件访问,避免命名冲突

  • 注意区分:隐藏、覆盖和重载
  • c++编译器一般情况下会给类添加6个函数
  1. 默认构造函数(无参,函数体为空)
  2. 默认析构函数(无参,函数体为空)
  3. 默认拷贝构造函数,对属性进行值拷贝
  4. 赋值运算符 operator=, 对属性进行值拷贝
  5. 移动构造函数c11
  6. 移动赋值运算符c11

静态数据成员

  • 初始化则存储在全局数据区。
  • 没有初始化则在BSS

虚函数表

  1. 如果类存在虚函数,则在编译阶段就会写入可执行文件的数据区只读数据段.rodata
  2. 注意虚函数表是属于类的,所有对象共享这张表
  3. 类可能不止一张虚函数表,比如多个基类都有虚函数表时
  4. 派生类的虚函数表是拷贝的基类虚函数表,两者并不共享

非静态数据成员

  1. 这部分是在对象内,在实际内存中看对象是位于哪,如果对象位于全局数据区,则非静态数据成员也是位于全局数据区
  2. 后续是分析这部分在对象内部的布局

最简单

  1. 数据的内存分配上基本与结构体无异,也存在内存对齐
  2. 注意如果对象没有数据成员,则内存大小为1,而不是0;
  3. 无法在成员函数中使用this定义成员数据,因为对象内存在编译时可能就已经确定,而成员函数调用是在运行时
class A
{
private:
	short pri_short_a;
public:
	int i_a;
	double d_a;
	static char ch_a;
	void funcA1() {}
};

image.png

增加虚函数

通常会在对象添加自己的数据(非继承)之前增加虚函数表指针,指向虚函数表,虚函数表存储着当前对象存在的虚函数(继承或自定义)

  • 纯虚函数:virtual double area() = 0;
  • 若基类中某成员函数被声明为virtual,则所有派生类中同名、同参数列表的函数自动成为虚函数,无论是否显式添加virtual关键字
class A
{
private:
	short pri_short_a;
public:
	int i_a;
	double d_a;
	static char ch_a;
	void funcA1() {}
	virtual void funcA2_v();
};

image.png

简单继承

  1. 会将父对象按照继承顺序放前面,最后放上自己的数据
class A
{
public:
	int i_a;
	static char ch_a;
	void funcA1() {}
};

class B : public A
{
public:
	int i_b;
	void funcB1() {}
};

image.png

2. 如果派生类和基类成员名(包括成员函数)相同,派生类会隐藏基类成员,即使成员函数的参数不同

  • 直接访问成员派生对象.成员名会访问到派生类自定义的成员
  • 若想访问父类的成员,则需要在成员前加限定派生对象.基类名::成员名
  • 若想实现成员函数的重载(即调用函数时根据参数选择调用方法),则需要将基类中实现的方法暴露在派生类中:using 基类::成员名
class Base { 
public: 
    void func(int) { /*...*/ } // 虚函数 
};
class Derived : public Base { 
public: 
    using Base::func; // 引入基类函数 
    void func(double) { /*...*/ } 
}; 
Derived d; 
d.func(1); // 调用 Base::func(int) 
d.func(1.0); // 调用 Derived::func(double)

继承且存在虚函数

优先放父对象,当放自己数据时,决定是否添加虚函数指针;

  • 每个类都是独立的虚函数表,继承会出现虚函数表的拷贝
  1. 如果基类有虚函数,则基类就是虚函数表指针开头,此时派生类的虚函数表就是这张表,如果有单独的虚函数,则直接添加到这张表上
  • override显式标记虚函数覆盖,确保派生类函数签名与基类虚函数完全匹配(防止因拼写错误或参数不一致导致意外重载而非覆盖)。
  • final禁止进一步覆盖:标记虚函数或类,阻止派生类覆盖该函数或继承该类。
class A
{
public:
	int i_a;
	static char ch_a;
	void funcA1() {}
	virtual void funcA_v1();
	virtual void funcA_v2();
};
 
class B : public A
{
public:
	int i_b;
	void funcB1() {}
	virtual void funcA_v1 override final();
};

image.png

  1. 如果基类没有虚函数,且派生类有单独的虚函数,则先把基类数据写入,写自己的数据时先加上虚函数表指针
class Base { 
    public: int base_data; // 基类无虚函数,无vptr 
}; 
class Derived : public Base { 
    public: virtual void foo() {} // 派生类新增虚函数 
    int derived_data; 
};

image.png

多继承

  1. 类可以拥有多张虚函数表
  2. 派生类中按照其继承的基类的顺序,存放了各个基类的数据成员(基类会包括自己的基类),然后才是自己的数据成员,如果对象起始地址不是虚函数表指针,则在自己数据成员前插入虚表指针
  3. 如果多个基类存在相同的成员,派生类依然可以使用增加作用域的方式来实现访问(见简单继承)
class A
{
public:
	int i_a;
	void funcA1() {}
	virtual ~A() {}
};
 
class B
{
public:
	int i_b;
	void funcB1() {}
	virtual ~B() {};
};
 
class C :public A, public B
{
public:
	int i_c;
	virtual ~C() {}//这个会存放在A虚表的副本之中
};

image.png

如果多个父类有相同的祖父类,则子类中会存在多个相同的祖父类成员,导致冲突,此时就需要虚继承

虚拟继承

  • 普通继承基类都是在最前面,虚拟继承基类会放到最后
  • 虚拟继承会增加虚基指针,指向虚基类表。表中存储了虚基类对象相对于当前类对象的偏移量;(为了依然能满足多态)
  • 虚基类指针通常位于虚函数表指针的之后
  • 虚基类表结构:虚基指针vbptr自身的偏移量,从vbptr到共享基类子对象的偏移
  • 虚基类成员的实际地址:对象地址 + vbptr偏移 + 虚基类偏移 + 成员偏移
  • 调用构造函数顺序:虚基类-->基类(按照声明顺序)-->派生类;最终派生类负责初始化虚基类,中间派生类对虚基类的构造调用会被忽略
  • 析构函数调用顺序相反
class B
{
public:
    int ib;
public:
    B(int i = 1) :ib(i) {}
    virtual void f() { cout << "B::f()" << endl; }
    virtual void Bf() { cout << "B::Bf()" << endl; }
};
class B1 : virtual public B
{//B1中会增加vbptr,以维持多态
public:
    int ib1;
public:
    B1(int i = 100) :ib1(i) {}
    virtual void f() { cout << "B1::f()" << endl; }
    virtual void f1() { cout << "B1::f1()" << endl; }
    virtual void Bf1() { cout << "B1::Bf1()" << endl; }
};
class B2 : virtual public B
{
public:
    int ib2;
public:
    B2(int i = 1000) :ib2(i) {}
    virtual void f() { cout << "B2::f()" << endl; }
    virtual void f2() { cout << "B2::f2()" << endl; }
    virtual void Bf2() { cout << "B2::Bf2()" << endl; }
};
class D : public B1, public B2
{
public:
    int id;
public:
    D(int i = 10000) :id(i) {}
    virtual void f() { cout << "D::f()" << endl; }
    virtual void f1() { cout << "D::f1()" << endl; }
    virtual void f2() { cout << "D::f2()" << endl; }
    virtual void Df() { cout << "D::Df()" << endl; }
};

image.png

多态

  1. 基类类型的指针指向派生类,调用被派生类覆盖的函数时,实际调用的是派生类的函数

  2. 覆盖要求:

  • 基类中该成员函数是虚函数
  • 派生类中对应成员函数返回值类型 函数名 参数列表完全一致
class Animal { 
public: 
    virtual void speak() { cout << "Animal sound" << endl; } 
}; 
class Dog : public Animal { 
public: 
    void speak() override { cout << "Woof!" << endl; } 
}; 
Animal* animal = new Dog(); 
animal->speak(); // 输出 "Woof!"(运行时决定)

实现:

调用虚函数时先去函数表查看函数的入口地址

  1. 编译器生成代码,通过animalvptr找到Dog的虚表。
  2. 从虚表中取出speak()的地址并调用。

抽象类

拥有纯虚函数的类为抽象类

  • 不能被实例化
  • 强制派生类实现所有纯虚函数,否则派生类也是抽象类

虚析构函数

  • 也是虚函数,且并不是按照类型名字存储在虚函数表中,故派生类和基类的构造/析构函数存在覆盖
  • 基类是虚析构函数,派生类析构函数即使不加virtual默认就是虚析构函数
  • 如果派生类没有堆区数据,其实没必要使用虚析构函数

作用

用于确保通过基类指针删除派生类对象时,能正确调用派生类的析构函数,避免资源泄漏。

原理

  • 派生类析构函数执行后会自动调用基类析构函数
  • 基类指针指向派生类时,调用delete 基类对象时,会调用析构函数,如果不是基类不是虚析构,则会调用基类的析构函数,而不是子类的,导致子类中自己的数据可能没有得到释放

纯虚虚构函数

虽然可以使用,但是必须得给出函数定义,因为派生析构会调用该析构函数

class base { 
public: 
    virtual ~base() = 0; // 声明为纯虚函数 
}; 
base::~base() { } // 必须提供定义

参考链接

[引擎开发] 深入C++内存管理

浅析C++类的内存布局

【C++核心】一文理解C++面向对象(超级详细!)