65.9K
CodeProject 正在变化。 阅读更多。
Home

C++ 低级对象模型简介

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (21投票s)

2012 年 10 月 7 日

CPOL

5分钟阅读

viewsIcon

32356

C++ 低级设计

引言

在本文中,我将介绍 C++ 编译器如何解释对象。我还会介绍一些有趣的语言行为案例。对一些人来说,这篇文章可能看起来微不足道。但我相信它对许多 C++ 开发者来说都很有用。

什么是结构体?  

首先,让我们考虑一下编译器如何看待结构体。假设我们有一个结构体

struct SomeStruct
{ 
    int  field1;
    char field2 
    double field3;
    bool  field4; 
};  

假设我们有一个函数 

void SomeFunction()
{
    SomeStruct someStructVariable;
    // usage of someStructVariable
   ... 
}     

如果我们像下面这样用 for 循环中的变量替换 someStructVariable,你认为会发生什么?

void SomeFunction()
{ 
    int  field1;
    char field2 
    double field3;
    bool  field4;      
    // usage of 4 variables
   ... 
} 

正确答案是:什么都不会发生!生成的机器代码与此函数相同。因此,对于编译器来说,结构体只是几个变量按顺序排列。所以指向结构体的指针实际上是指向结构体第一个成员的指针。因此,像这样的代码: 

someStructPtr->field1 = 123; 

将被解释为: 

*(int*)((void*)someStructPtr) = 123; 

以及代码: 

someStructPtr->field3 = 0.123; 

将是

*((double*)((char*)someStructPtr + sizeof(int) + sizeof(char))) = 0.123; 

等等…… 

只要结构体只是一组简单的字段,没有虚函数,这就是正确的。这种结构体称为POD 结构体。 

带有成员函数的结构体。  

假设我们在结构体定义中添加了一个成员函数: 

struct SomeStruct 
{ 
    int  field1;
    char field2 
    double field3;
    bool  field4;
    void SetAllFields(int f1, char f2, double f3, bool f4)
    { 
        field1 = f1;
        field2 = f2;
        field3 = f3;
        field4 = f4; 
    } 
};   

你认为 SetAllFields 函数有多少个参数?正确答案是五个。因为对于所有成员函数,我们都有一个隐藏的参数“this”。这个隐藏参数允许编译器只保留一份成员函数代码副本,并将其用于所有类实例。因此,对于编译器来说,我们的成员函数将如下所示: 

void SetAllFields(SomeStruct* this, int f1, char f2, double f3, bool f4)
{ 
    this->field1 = f1;
    this->field2 = f2;
    this->field3 = f3;
    this->field4 = f4; 
}  

因此,像这样的成员函数调用: 

someStructInstance.SetAllFields(123, 'a', 0.123, true); 

将被转换为: 

SetAllFields(&someStructInstance, 123, 'a', 0.123, true);  

顺便说一句,根据 Stroustrup 的说法,类和结构体之间唯一的区别是结构体的所有成员默认都是公共的,而类的成员默认是私有的。因此,我们可以说我所提到的所有内容都适用于类。 

展望未来,我们可以看看一个标准的面试问题

假设我们有一个类 SomeClass1 和一个派生自 SomeClass1 的类 a SomeClass2。它们都有一个函数 PrintClassName,用于打印类名。 

... 
class SomeClass1
{ 
public:
    void Print() 
    { 
        std::cout <<  "SomeClass1\n"; 
    } 
};
 
class SomeClass2 : public SomeClass1
{
public:
    void Print()
    {
        std::cout << "SomeClass2\n"; 
    }  
};
... 

以下代码显而易见

... 
SomeClass1* ptr = new SomeClass2();
ptr->Print();
...   

将打印“SomeClass1”,现在我们可以解释原因。调用 print 函数将被解释为下面的代码。 

... 
SomeClass* ptr = new SomeClass2(); 
Print(ptr);
...    

因此,编译器使用了最佳匹配的函数,因为它实际上不知道“ptr”指向什么类型的对象。 

继承的实现。

我们已经在本文中使用了继承,但我们没有解释其背后的机制。 

假设我们有两个类:class SomeClass 和 class SomeDerivedClass。 

...
class SomeClass
{
public:
    int field1; 
    double field2;
    char field3; 
};
 
class SomeDerivedClass : public SomeClass
{
public:
    int field1;
    double field2;
    char field3; 
}; 
...    

我们都知道 SomeDerrivedClass 的实例将同时包含基类和派生类的字段。因此,在内存中放置所有字段的最明显的方法就是先放置基类成员,然后放置派生类成员。这实际上是 C++ 编译器实现的方式。 

someDerivedClassPtr -> 

 





int field1 
double field2 
char field3 

int field1 
double field2 
char field3 
如果存在更深的继承,情况也不会改变。我们先放基类,然后放派生自基类的类,接着放派生自派生自基类的类,以此类推…… 

多重继承在对象表示方面并没有带来太多变化。在继承自两个类的情况下,第一个祖先的字段会先放置,然后是第二个祖先的字段,最后是派生类的字段。 

菱形继承问题。 

假设我们有一个基类“BaseClass”,然后有两个类派生自它:“SomeClass1”和“SomeClass2”。然后我们又创建了一个类“SomeFinalClass”,它继承自“SomeClass1”和“SomeClass2”。 

...
class BaseClass
{ 
... 
};
class SomeClass1 : public BaseClass
{
... 
};
class SomeClass2 : public BaseClass
{
... 
};
class SomeFinalClass : public SomeClass1, public SomeClass2
{
... 
};
...  

该类在内存中的结构如下: 

BaseClass 成员 
SomeClass1 成员 
BaseClass 成员 
SomeClass2 成员 
SomeFinalClass 成员 

通常,我们只需要一份基类成员的副本,所以我们需要避免第二份副本,因为它会占用额外的内存并调用两次 BaseClass 构造函数。 

为了避免这种情况,我们可以使用虚继承。在这种情况下,我们只会有一份基类成员的副本,并且只会调用一次基类构造函数。 

虚函数的实现。  

让我们回到“带有成员函数的结构体”章节的最后一个例子,但做一些小修改。我们在函数定义前加上 virtual 关键字。 

... 
class SomeClass1
{
public:
    virtual void Print()
    {
        std::cout <<  "SomeClass1\n";
    }
};
class SomeClass2 : public SomeClass1
{
public:
    virtual void Print()
    {
        std::cout << "SomeClass2\n";
    }
};
...   

以下代码的输出将与之前的不同。 

... 
SomeClass1* ptr = new SomeClass2();
ptr->Print();
...    

它将是“SomeClass2”。因此,虚函数的调用机制显然应该有所不同。 

所有带有虚函数的类都有一个特殊的隐藏成员,它是一个指向虚函数表的指针。因此,对虚函数的调用是通过带有某个偏移量的指针进行的调用。 

那么,让我们看看下面的代码。 

... 
SomeClass1* ptr = new SomeClass1(); 
ptr->Print(); 
...    

 在机器代码中,我们会得到类似这样的结果: 

...
//class  initialization 
...  
ptr->vftbl[0](ptr); 
...   

你可能想知道为什么我们创建 SomeClass1 的实例而不是 SomeClass2?这是因为当我们尝试从派生类调用虚函数时,情况变得有趣。在继承的情况下,会在旧的虚函数表的基础上创建一个新的虚函数表。所有来自基类的重载函数都会被祖先的相应函数重载。未出现在父类中的虚函数会按它们声明的顺序追加到虚函数表中。 

现在,让我们看看类的初始化。众所周知,对于派生类,构造过程从调用基类构造函数开始,然后是派生类构造函数。基类构造函数将 vftbl 指针设置为基类的虚函数表。然后,在继承类构造函数中,vftbl 指针会被重置为派生类的表。这就是为什么当我们调用虚函数时,就像本段开头示例中的那样,我们会得到输出“SomeClass2”。vftbl 指针指向了另一个虚函数表,其中偏移量为 0 的地方是另一个函数。 

我应该提一下,虚函数机制在构造函数和析构函数中不起作用。对于构造函数,这是因为 vftbl 指针在构造结束时被重置。这样做的原因是,在构造函数完成之前,我们应该认为类的字段尚未初始化。例如,以下代码将打印“SomeClass1”。 

 class SomeClass1 
{
public:
    virtual void Print()
    {
        std::cout <<  "SomeClass1\n";
    }
};
class SomeClass2 : public SomeClass1
{
public:
    SomeClass2()
    {
        Print();
    }
    virtual void Print()
    {
        std::cout << "SomeClass2\n";
    }
};
...
SomeClass1* ptr = new SomeClass2();
...    

至于析构函数,它也会在开始时重置 vftbl,因此会调用基类函数。 

注意:不必为所有函数定义都加上 virtual。如果你加了 virtual,那么具有相同签名且来自同一继承树的所有函数都将是虚函数。 

© . All rights reserved.