(一)C++对象内存模型那些事儿:基本概念

(一)C++对象内存模型那些事儿:基本概念

0. concepts

  • 什么是对象?为什么要有对象?

在长期的 C/C++开发过程中,逐渐形成了三种主要的开发范式:

  • 面向过程编程

面向过程编程是一种较为基础的编程范式,以 C 语言为典型代表。在这种范式下,程序被看作是一系列函数的集合,重点在于执行的过程和步骤。它对数据结构的强调相对较少,最多只是利用struct来封装一些成员变量。例如,实现一个简单的计算两个整数之和的程序:

#include <stdio.h>

// 计算两数之和的函数
int add(int a, int b) {
    return a + b;
}

int main() {
    int num1 = 5;
    int num2 = 3;
    int result = add(num1, num2);
    printf("两数之和为: %d\n", result);
    return 0;
}

在这个例子中,数据(num1num2)与操作它们的函数(add)是分离的,没有形成紧密的关联。

  • 抽象数据类型模型(ADT)

随着业务逻辑的逐渐复杂,面向过程编程的局限性逐渐显现。抽象数据类型模型(ADT)应运而生,它提供了封装和抽象的能力。ADT 将数据结构和对该数据结构进行操作的函数封装在一起,形成一个独立的单元,对外隐藏其内部实现细节,只提供必要的接口。以一个简单的栈数据结构为例:

#include <iostream>

class Stack {
private:
    int data[100];
    int topIndex;

public:
    Stack() : topIndex(-1) {}

    void push(int value) {
        if (topIndex < 99) {
            data[++topIndex] = value;
        }
    }

    int pop() {
        if (topIndex >= 0) {
            return data[topIndex--];
        }
        return -1; // 表示栈为空的错误情况
    }
};

int main() {
    Stack stack;
    stack.push(10);
    stack.push(20);
    std::cout << "弹出元素: " << stack.pop() << std::endl;
    return 0;
}

在这个栈的 ADT 实现中,数据(data数组和topIndex)和操作它们的函数(pushpop)被封装在Stack类中,使用者无需了解栈的内部实现细节,只需通过提供的接口进行操作。

  • 面向对象模型

面向对象模型是在 ADT 的基础上进一步发展而来,增加了继承和多态的特性。继承允许一个类从另一个类获取属性和行为,多态则使得不同类的对象可以对相同的消息做出不同的响应。C++通过class的指针(pointers)和引用(references)来支持多态。例如:

#include <iostream>

class Animal {
public:
    virtual void speak() {
        std::cout << "动物发出声音" << std::endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "汪汪汪" << std::endl;
    }
};

class Cat : public Animal {
public:
    void speak() override {
        std::cout << "喵喵喵" << std::endl;
    }
};

void makeSound(Animal& animal) {
    animal.speak();
}

int main() {
    Dog dog;
    Cat cat;

    makeSound(dog);
    makeSound(cat);
    return 0;
}

在上述代码中,DogCat类继承自Animal类,并各自重写了speak函数,实现了多态。通过makeSound函数,根据传入对象的实际类型调用相应的speak函数,展示了面向对象编程的灵活性。

这三种范式的演进反映了随着业务需求的变化,对数据结构的不同要求。面向过程编程适用于简单的程序逻辑,随着业务复杂度增加,ADT 通过封装和抽象提升了代码的可维护性和复用性,而面向对象编程则在 ADT 的基础上,通过继承和多态进一步增强了代码的灵活性和扩展性,更好地应对复杂的业务场景。

封装,继承和多态实际上都是为了适应变化快,业务复杂的场景下逐渐总结出来的一种范式。作为一种范式,他并不是一种普适的、强制的规定。但这种范式的变化,还是足以说明 OOP 对于业务开发的重要性。至于,新的编程范式,其实又逐渐不提倡继承,而是提倡组合,多态则通过组合接口类的方式去实现,如新的Gorust皆是如此。但体会不深,就不在展开。

本文将以 c++切入,讲述编译器为了支持 OOP,或者说为了支持封装、继承和多态需要在背后做什么。

这三种编程范式的演进,充分反映了随着业务需求的不断变化,对数据结构的要求也在相应改变。面向过程编程因其简单直接的特点,适用于处理较为简单的程序逻辑。例如,编写一个简单的文件读取并统计行数的程序,通过按顺序编写读取文件、逐行计数等函数,就能轻松实现功能。

然而,当业务复杂度逐渐增加时,面向过程编程在代码维护和复用方面的局限性就凸显出来。此时,ADT 通过封装和抽象提升了代码的可维护性和复用性。以实现一个简单的队列数据结构为例,ADT 将队列的数据存储(如数组或链表)以及对队列进行操作的函数(如入队、出队等)封装在一起,形成一个独立的单元。使用者无需关心队列内部是如何存储数据的,只需要通过提供的接口来操作队列,这大大提高了代码的模块化程度和复用性。

面向对象编程则是在 ADT 的基础上,进一步增加了继承和多态的特性,从而增强了代码的灵活性和扩展性,使其能够更好地应对复杂的业务场景。例如,在一个游戏开发项目中,存在各种角色类,如战士、法师、刺客等,它们都继承自一个通用的角色类。每个具体角色类可以重写通用角色类中的某些方法(如攻击方法),以实现不同的攻击效果,这就是多态的体现。通过继承和多态,代码可以更灵活地适应不同角色的特性,同时也便于代码的扩展和维护。

封装、继承和多态实际上是在应对变化快速、业务复杂的场景过程中,逐渐总结出来的编程范式。作为一种范式,它并非是一种普遍适用且强制遵循的规定。但这种范式的演变,足以证明 OOP 在业务开发中的重要性。

虽说在新的编程范式中,逐渐不提倡继承,而是更倾向于使用组合的方式,多态则通过组合接口类的方式来实现。例如在 Go 语言中,没有传统意义上的继承,而是通过结构体嵌套和接口实现多态。假设有一个图形绘制的场景,定义一个Shape接口,包含Draw方法,然后不同的图形结构体(如CircleRectangle)实现这个接口。在使用时,通过组合不同的图形结构体来构建复杂的图形系统,而不是通过继承关系。又如 Rust 语言,通过trait来实现类似多态的功能,并且在结构体组合方面也有独特的设计,使得代码更加灵活和安全。

但目前我就对 C++比较熟悉,因此本文将以 C++ 为切入点,深入讲述编译器为了支持 OOP,即支持封装、继承和多态,在背后所做的工作。

1. 对象内存模型的设计

C++对象内存模型的设计,聚焦于如何以零抽象成本达成封装、继承与多态。这一设计重任主要落在 C++编译器层面。C++编译器的主要功能是将 C++代码转换为汇编语言,而汇编语言直接与硬件交互,主要涉及对内存和寄存器的读写操作,以及利用 CPU 进行计算。

具体而言,从满足 C++对象的特性需求出发,要实现零抽象成本的对象,需从编译器(或等效的汇编操作,即对寄存器和算术逻辑单元 ALU 等硬件组件的操作)角度,深入考量以下几个关键方面:

鉴于 C++的零抽象成本语言目的,OOP 是通过零抽象成本达成封装、继承与多态的。而 C++是一种编译型语言,C++需要经由编译成汇编语言,才能运行。如此理解 OOP 是如何零成本实现的,实际上就是站在编译器层面、站在汇编层面去理解如何实现下列要求:

  • 成员变量和成员函数的存储
  • 成员函数的使用
  • 静态成员变量和静态成员函数的存储以及使用
  • 继承对象的存储和实现
  • 多态的实现

1.1 成员变量和成员函数的存储?

  • 成员变量的存储
class Foo {
    int a;
    int b;
};

int main(){
  Foo foo;
  foo.a;
  return 0;
}

在这个例子中,fooFoo类的一个对象实例,通常存储在栈上(若在函数内部定义)。对于编译器而言,foo在栈上有特定的地址。Foo类中的成员变量ab按照其声明顺序依次存储在foo对象所占据的内存空间内。基于foo对象所在的地址,编译器会根据a在类中的布局位置,计算出一个特定的偏移值。当需要读取a的值时,编译器会按照int类型的大小,从foo对象地址加上该偏移值的位置去读取数据,从而得到a的值。

  • 访问限制符号

C++中的访问限制符号publicprivateprotected用于控制类成员的访问权限。然而,在汇编层面并不存在这些概念。这些访问控制实际上是由编译器来实现对函数和成员变量访问的限制。

当编译器识别到代码在类的外部尝试调用private修饰的函数或访问private成员变量时,会导致编译错误,阻止程序继续编译。需要强调的是,理论上如果深入研究编译器的内存布局规则,通过计算偏移值可以在内存层面获取到private成员变量的数据的。但这种做法严重违反了 C++的访问控制机制,在正常的 C++编程中是不被允许的,也不应该这样做,因为这破坏了代码的封装性和安全性(事实上,存在一种通过模板技巧,可以在不修改原有类的前提下,能够安全取private函数和变量的方法)。

1.2 成员函数如何存储和使用?

  • 成员函数的存储

考虑以下 C++代码:

class Foo {
    void func1();
    void func2();
};

int main() {
  Foo foo;
  foo.func1();
  return 0;
}

在 C++中,一般成员函数主要起到封装逻辑的作用。从编译器角度看,成员函数与非成员函数的处理有相似之处,但存在关键区别。成员函数并非为每个对象实例单独存储一份副本,而是所有对象共享同一份代码,这些代码存储在程序的代码段中。

成员函数调用时,会隐式携带一个this指针。例如,调用foo.func1()实际上等价于调用经过编译器特殊处理的类似非成员函数形式,如_Z3foo4func1EP3Foo(这里_Z3foo4func1EP3Foo是编译器为支持函数重载及标识函数所属类而生成的修饰后的函数名,不同编译器生成规则不同,EP3Foo表示指向Foo类对象的指针,即this指针)。编译器在处理函数重载时,会对函数名进行修饰,添加类名、参数类型等信息,以确保同名函数在符号表中的唯一性。

当成员函数内部访问成员变量时,编译器利用this指针来确定要操作的具体对象实例的成员变量。具体来说,编译器根据this指针所指向的对象地址,结合成员变量在类中的偏移量,实现对成员变量的读写操作。例如,若func1函数内部访问成员变量a,编译器会将其转换为通过this指针偏移获取a的操作,即(*this).a,从而准确访问到foo对象中的a变量。这种机制使得成员函数能够对不同对象实例的数据进行独立操作,同时保证了代码的封装性和复用性。

因此,对于没有使用内部成员变量的成员函数,其实 C++是建议使用 static 去修饰的。这个时候的函数形参不会带上this指针。

1.3 静态成员变量和静态成员函数的存储和使用

  • 静态成员变量静态成员变量与普通静态变量非常相似性,它们都存放在静态存储区。只是静态成员变量的作用域、访问方式不同而已。静态成员变量是类的成员变量,但它们不属于类的某个具体对象,而是属于整个类本身。所有对象共享同一个静态成员变量,这使得它们在内存中只占用一份空间。

  • 静态成员函数将静态成员函数与普通静态函数其实也比较相似。静态函数的static表示的是静态函数的作用域被限制在定义它的源文件内,其他源文件无法访问该函数。

    而静态成员函数的static表明的是,函数在这个类的内部,但是不会传 this 指针的。注意的是,因为静态成员函数在内部,所以其实这个函数是可以访问私有成员的。

1.4 继承的实现

在 C++的继承体系中,对于基类的成员变量,基类的数据成员会直接放置在派生类对象中。这意味着派生类对象的内存布局包含了基类成员变量的空间,就如同派生类自身的成员变量一样。

对于基类的非虚成员函数,在派生类中也没有特别的额外操作。派生类对象可以直接调用这些非虚成员函数,其调用机制与普通成员函数调用类似,遵循常规的函数调用规则。

然而,在菱形继承场景下,会出现一些问题。例如:

class A {
public:
    int a;
};
class B : virtual A {
public:
    int b;
};
class C : virtual A {
public:
    int c;
};
class D : public B, public C {
public:
    int d;
};

如果不使用虚继承,从BC继承而来的A类子对象会在D类对象中存在两份,这不仅浪费内存,还可能导致访问A类成员时的歧义。为了解决菱形继承问题,使基类不管被派生多少次,都只存在一个子对象实例,C++引入了虚继承。

当使用虚继承时(如上述代码中BCA的虚继承),派生类(BC)会添加一个虚基类指针(vbptr),该指针指向一个虚基类表(vbtable),通过这个表再指向虚基类(A)的数据。在D类对象中,只有一个指向虚基类A数据的vbptr

虚基类表(vbtable)记录了虚基类相对于派生类对象起始地址的偏移量等信息。当访问D类对象中的虚基类成员(如D.a)时,编译器会根据vbptr找到对应的虚基类表(vbtable),然后依据表中的偏移量信息,准确地定位到虚基类A的成员变量aD类对象内存中的位置,从而实现对虚基类成员的正确访问。这种机制保证了在菱形继承结构中,虚基类子对象的唯一性,避免了数据冗余和访问歧义问题。

1.5 多态的实现

在 C++中,类的多态性主要通过虚函数来实现。以下面的代码为例:

class A {
public:
    virtual int foo() {
        return 0;
    }
};

class B : public A {
public:
    int foo() override {
        return 1;
    }
};

int main() {
    A* b = new B();
    b->foo();
    return 0;
}

当编译器处理这段代码时,会为包含虚函数的类(如A类)创建虚函数表(virtual function table,简称vtable)。在类A中,由于foo函数被声明为虚函数,编译器会在A类对应的虚函数表中为foo函数分配一个条目,记录其函数地址。对于派生类B,编译器同样会为其生成虚函数表,并且因为B重写了A中的虚函数fooB的虚函数表中对应foo函数条目的地址将指向B::foo的实现。

当执行A* b = new B();时,new B()按照B类的构造函数进行对象构造,此时b指针虽然声明为A*类型,但实际指向的是B类对象。B类对象的内存布局中包含一个指向B类虚函数表的指针(通常称为虚函数表指针,vptr)。

当调用b->foo();时,编译器首先根据b指针找到B类对象,进而通过对象中的vptr找到B类的虚函数表(vtable)。由于编译器在编译阶段就确定了虚函数foo在虚函数表中的索引位置(假设为 0),所以b->foo()的调用过程实际上类似于通过b指针找到B类对象的虚函数表指针vptr,再由vptr找到B类的虚函数表vtable,然后根据索引 0 获取到B::foo函数的地址,即vtable[0],最后调用该函数,也就是执行vtable[0]()

1.6 类对象所占的空间

  1. 成员函数:无论是静态成员函数还是非静态成员函数,它们的代码存储在程序的代码段,并不占用类对象本身的空间。成员函数通过对象的地址(this指针)来操作对象的数据成员,其代码的共享实现节省了内存空间。
  2. 静态成员变量:静态成员变量为类的所有对象所共享,它的存储与类对象分离,不占用单个类对象的空间。其内存位置通常在程序的静态存储区,通过类名来访问,与具体的对象实例无关。

  3. 虚函数:当类中包含虚函数时,类对象会持有一个虚函数表指针(vptr),该指针指向基于类的虚函数表(vtbl)。虚函数表存储了类中虚函数的地址。无论类中有多少个虚函数,类对象只需要一个vptr来指向虚函数表,因此虚函数相关部分仅占用一个指针大小的空间,虚函数表本身并不直接计入类对象的空间大小。
  4. 非静态成员变量:非静态成员变量是类对象的组成部分,它们占用类对象的空间。每个类对象都有自己独立的非静态成员变量副本,其空间大小取决于成员变量的类型和数量。
  5. 字节对齐:如果类中有多个非静态数据成员,为了提高内存访问速度和性能,编译器会对数据成员进行字节对齐。字节对齐是指按照特定的规则调整数据成员在内存中的存储位置,使得每个数据成员的地址都满足其自身类型的对齐要求。例如,某些系统中要求int类型数据从 4 字节对齐的地址开始存储。这可能会导致类对象占用的空间比所有成员变量实际大小之和要大。
  6. 虚继承:在虚继承的情况下,为了确保虚基类在派生类对象中只有一个实例,派生类对象会额外增加一个指针。这个指针通常称为虚基类指针(vbptr),它指向一个包含虚基类偏移信息的表(虚基类表,vbtable),通过该表可以正确定位虚基类子对象在派生类对象内存中的位置。因此,使用虚继承会使类对象多占用一个指针大小的空间。

alt text

99. quiz

1. class 和 struct 的区别

struct 的默认访问修饰符是 public;而 class 的默认访问修饰符是 private。除此之外使用时没有区别。

但是他们背后直接承载的设计意义有一定区别。 class 它还会引入它所支持的封装和继承的哲学,是 oop 概念的。而 struct 作为 c 语言的关键字,更多时候是作为纯粹数据类型集合而存在的,C 语言的 struct 没有继承,也没有成员函数。因此 C++中的struct尽管有继承多态、甚至有模板,但一般都不会用,而是类似于 C 中纯粹数据类型集合的存在。

2. 空类对象所占的空间是多少,为什么?

1byte。这是为了确保每个空类对象都有一个唯一的地址,从而使得不同的空类对象在内存中是可区分的。

3. 空对象也可以运行成员函数吗?

#include <iostream>

class Foo {
  public:
    void bar() { std::cout << "Bar method called." << std::endl; }
};

int main() {
    static_cast<Foo*>(nullptr)->bar();
    return 0;
}

这个代码是可以运行的,但值得注意的是,这种使用容易触发一些 ub 行为。我见过的一种 ub 行为,就是比如说在bar()函数里面增加一段if(this)相关的判断的时候,编译器会基于这是一个非静态函数的原因,直接把if(this)的判断给干掉。




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (六)模板那些事儿:类型擦除
  • (五)多线程那些事儿:并行库 openmp
  • (五)模板那些事儿:模板元
  • (四)多线程那些事儿:并行库 tbb
  • (三)多线程那些事儿:怎么用好