C++ 低级对象模型简介






4.80/5 (21投票s)
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 ->
|
| |||
|
多重继承在对象表示方面并没有带来太多变化。在继承自两个类的情况下,第一个祖先的字段会先放置,然后是第二个祖先的字段,最后是派生类的字段。
菱形继承问题。
假设我们有一个基类“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,那么具有相同签名且来自同一继承树的所有函数都将是虚函数。