C++对象实现原理(附常见面试题)

参考资料:《深度探索C++对象模型》

C++实现了类和对象,带来了语法上繁琐的规则和陷阱。但如果了解了它的原理,你会发现所谓类和对象其实就是在C语言上的一层包装,其内部实现并没有什么神秘,很多语法规则结合实现原理就会显得清楚明白。

实际上C++的语法复杂度主要是来源于各种实体之间的复杂关系,包括:

  1. 父类子类之间的关系
  2. 成员类和其外部类的关系
  3. 虚函数和对象的关系
  4. 虚继承关系

在编程语言中,对两个实体建立关系,概括来说有两种方法:

两种实体关系

  1. 将两个实体放在内存上的相邻位置,通过地址偏移可以访问对方。
  2. 通过指针,指向关联实体的位置进行访问。

在C++中,子类及成员类的关联使用了第一种方法,而带“虚”的(虚函数、虚继承)使用了第二种方法。
C++中的关系

下面我们就由浅入深的具体看一下.

内存排布

最简单的类

最简单的类的内存排布实际和C语言的结构体没有什么不一样:

class Naive
{
public:
    int member_1;
    int member_2;
};

int main()
{
    Naive a;   // 实例化,在内存中构造一个对象
    a.member_2 = 1; // 访问对象成员
    return 0;
}

当我们实例化Naive类的对象时,其在内存中存储如下:

简单对象

只是在内存中依次放置两个成员而已,成员int member_1占据了前4个字节,int member_2占据了接下来的4个字节。对象的地址就是所有成员最开始的地址,也即member_1的地址。当使用a.member_2 = 1访问对象成员时,编译器自动的将此改写为对一个偏移地址的访问,相当于*(&a + 4) = 1

总之,这里和C语言的struct完全相同。为了简单起见, 本文不考虑为对齐插入的内存,对齐规则和C语言也是完全相同的。

带有非虚函数的类

如下例,当包含了成员函数时,对象在内存的排布又如何呢?

class Base
{
public:
    int member_1;
    int member_2;

    void func1();  // 成员函数
};

含成员函数的对象

实际上内存排布和没有函数时没有任何区别。非虚成员函数如func1并不会存在对象的内存中,其和C语言中普通的函数一样,都存在代码区,类的所有对象都共用这一个函数。

为体现出函数属于某个类,编译器会对该函数进行改名(name mangling),将类名信息加到函数名中。比如func1就可以改为func1_4BaseFv,其中4代表后面的部分有4个字符也即类名Base。然后是一个字符F做分隔,再后面是函数参数类型,如v代表void,f代表float。需注意这种改名方式只是一个示例,各编译器的实现会有不同(比如gcc里就是_ZN4Base5func1Ev)。

当调用成员函数时,编译器会根据类型信息改写调用方式:

    Base b;
    b.func1(); // 编译器将其转为 func1_4BaseFv(&b);

这里看到,编译器除了会转换函数名外,还会将对象的地址传入函数中。这是因为编译器会为每个成员函数都加一个参数用于传this指针。这也是为什么在成员函数中可以使用this->xx的格式访问成员。当然this->也可以省略,编译器会自动补上。

本例中this指针指向对象b,故使用this指针访问的也就是对象b的成员了。

总结来说,非虚成员函数实际与普通的全局函数没有本质不同,仅仅是通过改名做了一定区分。编译器根据对象类型来找到对应函数的实际名称并调用,反正类型信息在编译时就明确了。

注意到除了对象类型外,函数参数类型也体现在了函数名中,实际上这就是C++实现函数重载的方式,根据参数类型不同调用不同名字的函数。所以实际所有的C++函数都经过了改名,若要兼容调用C的函数必须使用extern "C"告诉编译器调用未改名的版本。

带有虚函数的类

对于非虚函数,编译器直接根据静态的对象类型信息就可以确定实际调用的是哪个函数,而对于虚函数则只能在运行时根据指针指向的实际类型才能知道调用哪个函数。为此,可以将函数以指针的形式存储在对象中,见下例:

class Base
{
public:
    int member_1;
    int member_2;

    void func1();
    virtual void func2(float arg1); // 增加了一个虚函数
};

含虚函数的对象

如图,当类中有虚函数时,编译时会为该类生成一个虚函数表vtable存储在常量区(只读数据区),其中的表项为函数指针。编译器会在类对象中增加一个虚表指针vptr成员,并在对象构造时令其指向本类的虚函数表。该类各对象共用同一个虚函数表。调用与虚函数时只需要从虚函数表中取出函数地址调用即可。

使用这种机制,编译器不会再根据类名Base来确定具体函数,只要vptr指针指向了正确的虚函数表,不管是使用基类指针还是继承类指针访问,都会调用到正确的虚函数。在后面讲继承时,我们会再进一步分析。

注意到虚函数表的第一项为RTTI(Run-Time Type Identification),它显式的存储了该类的类型信息,可以调用typeid获取相关信息,在使用dynamic_cast及异常机制时可以用其做类型判断。此处不做展开。

在调用虚函数时,编译器会做如下自动转换:

    Base *p_b = new Base();
    p_b->func2(1.0); // 编译器将其转为 (*p_b->vptr[1])(p_b, 1.0);

其中由于vptr[0]存了RTTI,故func2的指针存在vptr[1]的位置,从这里取出函数指针后对其解引用并调用。和非虚函数一样,会隐式加一个this指针参数并将对象地址传进去。

继承和成员对象

下例增加了继承和成员对象:

class Base
{
public:
    int member_1;
    int member_2;

    void func1();
    virtual void func2(float arg1);
};

class Memb
{
    int member_4;
    int member_5;
};

class Derive: public Base  // 继承
{
    public:
    int member_3;
    Memb member_obj;  // 成员对象

    virtual void func2(float arg1) override;
    virtual void func3(float arg1);
};

其内存排布如下:

继承和成员对象

成员类Memb只是把其内存放置到外部类Derive的内存中作为一部分而已。

子类Derive的内存实际就是在其父类的后面再添加上自己的内容,需要注意的是编译器在构造Derive对象时会将vptr指向Derive类的vtable而不是基类Base的,该vtable中存储的是Derive的虚函数,包括重写父类的虚函数,也包括此类新添加的虚函数(如func3)

继承并不会添加新的vptr项,而是复用父类的vptr。但如果父类没有虚函数(即没有vptr)则会在该有虚函数的子类后面加vptr项。

注意到的基类Base和子类Derive的初始地址相同,这也是为什么可以使用基类指针指向子类。

    Derive d;

    Base *p_b = &d;
    p_b->func2(1.0); // 编译器将其转为 (*p_b->vptr[1])(p_b);

此例中将Derive对象d赋值给Base类指针p_b,编译器使用该指针时就当作其是一个Base对象,只能访问Base的成员。这没关系,因为Derive对象的内存中前面本来就是一个完整Base对象,各种偏移也和真正的Base对象保持一样,因此这样访问是没问题的。只是无法访问Derive特有的成员而已。(注意到这里只适用于单继承,多继承时编译器还需要调整指针位置来保证此特性)

虽然编译器是很无脑的看到Base指针就认为是Base对象,但这里却可以实现多态特性,即调用虚函数时会调用Derive类的版本。前面说到在构造Derive对象时,会将其vptr指向Derivevtable,现在虽然改为使用Base指针来访问了,但是其vptr依然存的时Derive的vtable的地址。当调用func2时,编译器还是无脑的转换成(*p_b->vptr[1])(p_b),这里从vptr便会取出Derive的虚函数版本。

这就是C++多态的实现原理。归根结底是虽然改变了指针的类型为基类指针,改变了对这块内存的解释方式,但是并没有改变这块内存的内容,而因此vptr的得以保留其子类的vtable地址进而调用子类的函数。

C++子类可以选择重写或者不重写父类的虚函数,这在vtable中很容易实现,如果重写,就让vtable中的表项指向子类的函数,否则就依然指向父类。

多重继承

多重继承时,编译器会在内存依次放置各个基类。如下例:

class Derive: public Base1, public Base2

在内存中的排布即:

多重继承

这里带来的问题是Derive的起始地址和其父类Base2的起始地址不同了,因此将Derive对象地址赋值给Base2指针时编译器会进行地址的调整,否则就会用Base2的方式去访问Base1的内存造成错误了。

    Derive d;
    Base2 *p_b = &d;  // 此处编译器会偏移地址跳过Base1

    cout << &d;  // 输出:0xffffcbc0
    cout << p_b; // 输出:0xffffcbd0

注意父类Base1Base2的内存谁在前谁在后没有统一的规范,不同编译器可能有不一样的实现。

虚继承

在出现菱形继承关系时,会导致存储多余的父类:

class Base1 : public CommonBase
class Base2 : public CommonBase
class Derive: public Base1, public Base2

Derive的内存中包含Base1和Base2,而这两者都包含CommonBase,使得存储了两份CommonBase,不仅浪费空间,还会造成歧义。
C++对此的解决方案就是虚继承,其和虚函数基本是相同的思想,引入一个指针来指向目标。

具体的实现方式各编译器不同,包括:

  • 复用虚函数的指针vptr和虚函数表vtable,将虚继承的父类地址偏移放到虚函数表中。为了和虚函数区分,使用负数索引在vtable中访问(所以开辟vtable内存时负数索引部分也需要有效)。
  • 引入一个类似于vptr的新的指针成员,指向一个专门存虚基类地址信息的表格。

总之是引入指针间接的访问基类,代替原来的根据固定的偏移访问基类。这样就允许虚基类改变和其子类的相对位置,只要把虚基类地址信息写到指针中即可。也可以允许多个子类的该指针指向同一个虚基类内存实现共享。

对象的构造

在上一节我们了解了一个类的对象需要安置其父类成员、虚函数指针、成员对象等关联实体。但是在我们编程时并没有考虑这些。实际上这些相关工作都被编译器在构造函数和析构函数中隐式的替我们完成了。

构造函数的隐藏工作

下例解释了对象构造时编译器为我们做的事情:

class Derive:  public Base, virtual public VBase
{
public:
    int member_5{1};
    int member_6;
    Memb member_obj;

    virtual void func2(float arg1) override{};
    virtual void func3(float arg1){};

    Derive(/*编译器插入 Derive* this  */)
    {
        // 在用户代码前,编译器会插入如下内容:        
        // 调用 VBase::VBase() 构建虚基类部分
        // 调用 Base::Base() 构建基类部分
        // 设置 vptr 指向本类的vtable
        // 初始化 member_5;
        // 初始化 member_6; (由于int不需要初始化,会被省略)
        // 调用 Memb::Memb() 构建member_obj

        cout << member_5 << endl;  // 用户代码,编译器将member_5扩展为this->member_5

        // 编译器插入 return this; 
    }
};

在构造函数中,编译器会在用户代码之前依次插入:

  • 按各虚基类的声明顺序(从左到右)调用构造函数。此步骤只有在继承链最底层(most-derived)的类会执行,而继承链中间的只需共享最底层构建的即可。
  • 按各非虚基类的声明顺序(从左到右)调用构造函数
  • 设置本类的vptr指向本类的vtable
  • 按各成员的声明顺序(从上到下)调用构造函数

以上工作做完之后才开始真正执行用户写的构造函数代码。

需要注意的是vptr的设置位置,其在成员的构造之前,因为这样才能保证成员构造时以及用户代码中调用的虚函数是本类的版本。同时其在各种基类的构造之后,这样才能保证基类构造时使用基类自己的虚函数版本,基类是在其自己的构造函数中设置的vptr的。所以说这个vptr在构造过程中经过了多次的改变,从指向基类的vtable一直沿继承链向下到最终派生类的vtable。

不过编译器也会进行一定的优化,当基类的构造函数没有调用虚函数时,会省略vptr的设置。

下面画一个单继承的简单例子:

构造函数的工作

另外即使用户不定义构造函数,编译器也会合成一个构造函数做上述事情。

注意到上述代码只有在需要时才插入,比如int成员member_6没有默认的构造函数此处就是没有操作。如果一个类所有这些代码都不需要插入,且用户没有显示定义构造函数,就说该类构造函数时trivial的,编译器一般不会真正的生成和调用该构造函数。这些代码都不需要插入的情况即:

  • 没有虚基类,或所有虚基类的构造函数是trivial的,即不需要为其构造。
  • 没有非虚基类,或所有非虚基类的构造函数是trivial的。
  • 没有虚函数,即不需要vptr。
  • 没有需要构造的成员(成员的构造函数都是trivial的)

拷贝构造函数与此类似,除了:

  • 编译器插入的代码调用的是相关类的拷贝构造函数而非构造函数。
  • trivial的构造函数不代表什么都不做,而是代表简单的memcpy。

初始化列表

C++编译器通过插入代码节省了用户的工作量,但是也带来了如下问题:

  1. 用户不能清楚的了解语言机制,容易踩到陷阱。
  2. 用户无法对隐藏部分的代码进行定制。

关于问题1,一个例子就是用户在构造函数调memset将this指向的整个对象清零,这会导致vptr也被清零造成虚函数访问错误。

关于问题2,由于代码都是自动插入的,用户无法看到自然无法修改。对此只能再加一些语法补丁来弥补了。对于构造函数,这个补丁就是初始化列表。

如果不用初始化列表,直接在构造函数的函数体内进行相关成员的初始化工作,比如member_obj= Memb(10);,实际上会造成重复工作,一次是编译器自己插入的无参数初始化,一次是函数体内写的赋值操作。而初始化列表就是允许我们调整编译器插入的初始化过程以避免重复。

下面是一个例子

class Derive:  public Base // 假设Base包含一个单参数构造函数
{
public:
    int member_5;
    int member_6;
    Memb member_obj; // 假设此成员类包含一个单参数构造函数

    virtual void func2(float arg1) override{};
    virtual void func3(float arg1){};

    Derive()
    :member_6(1),
     member_5(member_6), // 陷阱出现,未定义值
     Base(10),
     member_obj(20)
    {
        // 此时编译器根据初始化列表插入的代码如下
        // 调用 Base::Base(10) 构建基类部分
        // 设置 vptr 指向本类的vtable
        // 初始化 member_5为member_6的值,此时member_6未初始化即内存随机值
        // 初始化 member_6为1;
        // 调用 Memb::Memb(20) 构建member_obj

        member_obj = Memb(10); // 用户代码,此处相当于构造之后又进行赋值
    }

从例子中可以看到,初始化的顺序并不会因为加入了初始化列表而改变,初始化列表的顺序并不是真正的初始化顺序。

这里便造成了一个陷阱,如本例中如果按照初始化列表来看,好像是member_6初始化为1之后再用来初始化member_5,但实际上是member_5先初始化被赋予了未定义的值。

像这种因为操作被隐藏而不得不再增加语法补丁调整的例子还有const成员函数,实际上它就是给被隐藏的this指针加了个const:

class Base
{
    void func(float arg1);
    // 相当于 void func(Base *this, float arg1);

    void func(float arg1) const;
    // 相当于 void func(const Base *this, float arg1);
};

当你知道了这个原理自然知道了为什么const函数不能修改成员(this是常量指针),也自然知道了什么时候调用const版本的成员函数(使用const对象调用时)。否则又要背一条奇怪的语法。

对象的析构

编译器也会在析构函数中插入代码以调用相关类的析构函数,如下例:

class Derive:  public Base, public virtual VBase
{
public:
    int member_5;
    int member_6;
    Memb member_obj;

    virtual void func2(float arg1) override{};
    virtual void func3(float arg1){};

    Derive(/*编译器插入 Derive* this  */)
     {
        // 在用户代码前,编译器会插入如下内容:        
        // 调用 VBase::VBase() 构建虚基类部分
        // 调用 Base::Base() 构建基类部分
        // 设置 vptr 指向本类的vtable
        // 初始化 member_5;
        // 初始化 member_6;
        // 调用 Memb::Memb() 构建member_obj

        cout << member_5 << endl;  // 用户代码,编译器将member_5扩展为this->member_5

        // 编译器插入 return this; 
    }

    ~Derive(/*编译器插入 Derive* this  */)
    {
        // 在用户代码前,编译器会插入如下内容:
        // 设置vptr使其指向本类vtable (此操作主要对于基类有用)

        member_5 = 0;

        // 在用户代码后,编译器会插入如下内容:
        // 调用Memb::~Memb() 析构member_obj
        // 析构 member_6 (因为是int所以省略)
        // 析构 member_5 (因为是int所以省略)
        // 调用Base::~Base() 析构基类
        // 调用VBase::~VBase() 析构虚基类
    }

};

对比来说,析构函数基本和就是在做构造函数的反向操作,且执行顺序也基本相反,除了vptr的设置此时放在函数最开始。具体罗列如下:

  • 重设vptr使其指向本类vtable
  • 执行用户代码
  • 对每个成员对象,如果其有析构函数则依次调用。按照声明顺序的相反顺序(从下到上)
  • 对非虚基类,如果有析构函数则依次调用。按照声明顺序的相反顺序(从右到左)
  • 对于虚基类,如果当前类是继承链最底层(most-derived)则依次调用其析构函数。按照声明顺序的相反顺序(从右到左)

通过在析构函数的开始设置vptr,确保了继承链上的每个类的析构函数中都使用自己的虚函数。和构造函数一样,当没必要设置vptr时编译器会省略该步骤。

常见面试题

  1. 介绍一下虚函数的实现原理:
    • 主要基于vptr和vtable实现,有虚函数的对象在构造时设置vptr指向该类的vtable,其中存储虚函数的地址。具体参考上文作答。
  2. vtable的存储位置:
    • 常量区(.rodata/只读数据区)
  3. 初始化列表中成员的初始化顺序是什么,下例构造函数输出是什么:
    class A
     {
     public:
         int n1;
         int n2;
         A() : n2(1), n1(n2)
         {
             cout << n1 << "," << n2 << endl;
         }
     };
    
    • n1为随机值,n2为1。由于成员的初始化是按照从上到下的声明顺序故先初始化n1,而此时n2还未初始化故为随机值。然后才初始化n2为1。初始化列表中的顺序和初始化顺序无关。
  4. 构造函数中调用虚函数时使用哪个版本,如下例:
    class Base
    {
    public:
        Base() {func();}
        virtual void func() {cout << "base" << endl;}
    };
    
    class Derive :public Base
    {
    public:
        Derive() {func();}
        virtual void func() {cout << "derive" << endl;}
    };
    
    int main()
    {
        Derive derived;
        return 0;
    }
    
    • 先输出"base", 再输出"derive"。因为Derive的构造函数会调用Base构造函数,Base构造函数中会先设置vptr到Base的vtable,所以此时的虚函数使用Base版本。从Base构造函数返回后继续执行Derive构造函数,其中会先设置vptr到Derive的vtable,然后再执行用户代码,所以此时使用的是Derive版本。
  5. sizeof一个空对象返回几?
    class Empty { };
    Empty a, b;
    if (&a == &b) cerr << " should not be equal" << endl;
    
    • 这是我正文中没提到的一个实现细节。答案是1。这是编译器特意留出的1个字节,这样可以确保每个对象的地址不同。否则如果其尺寸为0,定义如下两个空对象其地址会都相同:

从这些面试题也可以看出,如果不懂C++对象原理,C++的语法有很多陷阱。

结语

经过本文的剖析,是否觉得C++也不再那么神秘了?实际上很多事情都是C语言的套壳,也因此实际上C语言也可以实现类似的的机制,像linux源码里就是这样。借助C语言的struct和函数指针可以实现类似对象的数据结构。通过修改函数指针指向就可以重写成员函数。通过把Base结构体作为Derive结构体的第一个成员也可以模仿继承关系。C++通过编译器来完成了类似的事情,减少了程序员的重复工作。

C++实际并未对语言底层实现做详细的规范,因此各编译器会有差异,比如vptr的位置、元素存储顺序、name mangling方式、RTTI内容、多重继承时基类排布顺序等。不过大同小异,思想类似。

通过了解底层原理,也可以看到C++相比于传统C语言存在性能代价的地方,包括:

  • 虚函数。由于虚函数需要通过指针间接访问函数,增加了指针内存占用和解引用耗时。
  • 虚继承。由于虚继承需要通过指针间接访问基类,增加了指针内存占用和解引用耗时。
  • 多重继承。多重继承有时需要额外的地址偏移操作。
  • RTTI相关机制。运行时进行类型识别会造成额外耗时,比如dynamic_cast、异常机制。

回到我们开篇提到的,编程语言中要建立两个实体之间的联系,可以通过固定的地址偏移(非虚继承、成员对象),也可以通过指针(虚函数、虚继承)。这之中地址偏移没什么神奇的,C语言中的结构体也是这样的。而通过指针这种方法则是C++多态机制的基石,尤其是虚函数机制。

更扩展来说,这种引入指针实际上是增加了间接性。而正是间接性带来了灵活度。原本是直接访问函数,现在是先访问指针(及虚函数表)然后再找到函数。当然间接性的引入也要付出性能下降的代价,因为必须要先经过间接的部分。原本直接调用函数即可,现在需要额外访问指针。

实际上引用间接性这种手段在计算机技术中屡见不鲜。比如:

  • 域名系统(DNS):原本通过IP就可以直接访问服务器,现在引入间接性先访问域名,再获得IP访问。这样带来的灵活性包括:可以一个域名对应多个服务器IP实现负载均衡、可以多个域名对应一个服务器IP实现复用、可以起一个容易记忆的域名或者修改域名而不影响IP。

  • 编译器中间表示(IR):原本直接将编程语言编译成特定硬件的机器码,现在引入间接性先编译成IR,再转成机器码。这样带来的灵活性包括:可以通过复用IR大幅减少开发工作量(见下图)、可以选择更好的数据格式便于编译处理。

    编译器中间表示

  • 虚拟地址:原本计算机可以直接访问内存物理地址,现在引入间接性先访问虚拟地址再翻译成物理地址。这样带来的灵活性包括:可以一个多个虚拟地址对应同一个物理地址实现内存复用。可以隔离具体的物理内存安排,保证程序每次处理固定的虚拟地址。

可以看到间接性的好处主要就是实现灵活的对应关系(一对多、多对一等)以及隔离变化。

实际上,我认为增加间接性这种技术可以算作是计算机领域的“根技术”之一,可以和模块化(分解)、抽象(分层)、缓存、通用-专用平衡等技术齐名了。

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值