C++是一门支持面向对象的高级语言。那C++对象的内存是怎么管理的呢?

为了更直观地理解,下面我会多次使用ANSI C来近似模拟C++类和对象。

引入:一个简单例子

C++类中的成员变量位于数据段内存和堆栈段内存(进一步定位:是否const或static)。C++成员函数就是单纯的函数,位于代码段内存。

这里定义了一个简单的类Animal:

// C++
class Animal {
public:
    Animal(int _age, double _energy) {
        age = _age;
        energy = _energy;
    };
    ~Animal() = default;

    void eat(double klr) {
        energy += klr;
    }

    int getAge() const {
        return age;
    }

    int age;
    double energy;
    static float height;
}

// 使用Animal对象
{
    Animal obj(4, 64.22);
    obj.eat(10.33);
}

用ANSI C做等价模拟,可以这样表示:

// ANSI C
typedef struct  {
    int age;
    double energy;
    static float height;
} Animal;
void _ZN4Animal5eatEv(Animal* this, double klr) { // Animal::eat(double kl)
    this->energy += klr;
}
int _ZN4Animal5getAgeEv(const Animal* this) { // Animal::getAge() const
    return this->age;
}
void _ZN4AnimalC1Ev(Animal* this, int age, double energy) { // 构造函数Animal::Animal()
    this->age = age;
    this->energy = energy;
}

// 使用Animal对象
{
    Animal obj;
    _ZN4AnimalC1Ev(&obj, 4, 64.22);
    _ZN4Animal5eatEv(&obj, 10.33);
}

站在C++编译器的视角来看obj的内存空间是这样的:

.data
-------------------
| 4 bytes  height |
-------------------

.stack
-------------------
| 4 bytes     age |
-------------------
  4 bytes     空
-------------------
| 8 bytes  energy |
-------------------

.text
-------------------
_ZN4AnimalC2Ev ...
-------------------
_ZN4Animal5eatEv ...
-------------------
_ZN4Animal5getAgeEv ...
-------------------

面向对象之封装

类内的三个标记public,protected,private是在编译语法检查的时候起作用,语法检查会这样判断是否合格:

  • public:不受任何限制
  • protected:子类和成员函数可以访问
  • private:只有友元函数和成员函数可以访问

对于内存布局,C++11标准是这样规定的:

Members with the same access control level (public, protected, private) are to be allocated in order of declaration within class, not necessarily contiguously.

即,public的成员依次放一起,protected的成员依次放一起,private的成员依次放一起。三者间相对顺序由编译器适情况决定。

面向对象之继承

从内存层次看,子类 = 父类 + 程序员写的子类。

这里我们自定义一个Animal的子类Tiger, 并使用了Tiger的对象:

class Tiger: protected Animal {
public:
    void eatMeat() {
        energy += 20;
    }

private:
    char breed;
};

{
    Tiger tobj;
    tobj.eatMeat();
}

那么tobj的内存布局是这样的:

// 栈区
-------------------
| 4 bytes     age |
-------------------
  4 bytes     空
-------------------
| 8 bytes  energy |
-------------------
| 1 bytes   breed |
-------------------
  7 bytes     空
-------------------

// 全局变量区
-------------------
| 4 bytes  height |
-------------------

面向对象之多态

在x86_64机器上,sizeof(Animal) = sizeof(double) * 2 = 16。如果我们把Animal::eat()改为虚函数,会发现sizeof(Animal) = 24。多了什么呢?8 bytes,应该是个指针。对就是指针。void* vptr。这个指针指向放在.data段(静态量、常量区)里的虚函数表,可以理解为一个指针数组。这个类有多少个虚函数,虚函数表就有多少个槽,放着多少个指针,指向函数地址。

现在,我们把Animal::eat()和Animal::getAge()都改为虚函数。

那么修改后的C++ Animal类就近似于ANSI C中的:

// ANSI C
typedef struct  {
    static void* vptr;
    int age;
    double energy;
    static float height;
} Animal;
void _ZN4Animal5eatEv(Animal* this, double klr) { // Animal::eat(double kl)
    this->energy += klr;
}
int _ZN4Animal5getAgeEv(const Animal* this) { // Animal::getAge() const
    return this->age;
}
const static void* vTable[2] = {&_ZN4Animal5eatEv, &_ZN4Animal5getAgeEv};
void _ZN4AnimalC1Ev(Animal* this, int age, double energy) { // 构造函数Animal::Animal()
    this->v_ptr = vTable;
    this->age = age;
    this->energy = energy;
}

然后我们把Tiger类改为:

class Tiger: protected Animal {
public:
    void eat(double klr) {
        energy += 0.5 * klr;
    }
    virtual void eatMeat() {
        energy += 20;
    }

private:
    char breed;
}

并声明一个Tiger对象Tiger tobj;。那么tobj的栈内存布局为:

vptr    8字节
age     4字节
空      4字节
energy  8字节
breed   1字节
空      7字节

而vptr指向的虚函数表依次存放着Tiger::eat(),Animal::getAge(),Tiger::eatMeat()三个函数的地址。虚函数表的内容是在编译阶段就确定的。

对于有虚函数的派生类来说,他的堆栈内存空间分布依次为:vptr、父类成员、子类成员。

Reference

  • “Intro to the C++ Object Model”, Richard Powell, 2015
  • https://en.cppreference.com/w/cpp/language/constructor