48

C++通过虚拟机制支持动态绑定。但据我了解,虚拟机制是编译器的实现细节,标准只是指定在特定场景下应该发生的行为。大多数编译器通过虚拟表和虚拟指针来实现虚拟机制。这与虚拟指针和表的实现细节无关。我的问题是:

  1. 除了虚拟指针和虚拟表机制之外,是否有任何编译器以任何其他方式实现虚拟函数的动态调度?据我所知,大多数(读G++,微软Visual Studio)都是通过虚拟表、指针机制来实现的。那么实际上还有其他编译器实现吗?
  2. sizeof任何只有一个虚函数的类都将是该编译器上一个指针(vptr inside )this大小。那么鉴于虚拟指针和 TBL 机制本身就是编译器实现,我上面的这句话会永远成立吗?
4

11 回答 11

22

对象中的 vtable 指针总是最有效的说法并不正确。我的另一种语言的编译器曾经出于类似的原因使用对象内指针,但现在不再这样做了:相反,它使用了一个单独的数据结构,将对象地址映射到所需的元数据:在我的系统中,这恰好是使用的形状信息由垃圾收集器。

这种实现对于单个简单对象会花费更多的存储空间,对于具有许多基础的复杂对象来说效率更高,而且它非常来说效率更高,因为数组中的所有对象在映射表中只需要一个条目。我的特定实现还可以找到给定指向对象内部任何点的指针的元数据。

实际查找速度非常快,存储要求也非常适中,因为我使用的是地球上最好的数据结构:Judy 数组。

我也知道没有使用 vtable 指针以外的任何 C++ 编译器,但这不是唯一的方法。事实上,具有基类的初始化语义会使任何实现变得混乱。这是因为在构造对象时,完整的类型必须上下左右摆动。由于这些语义,复杂的 mixin 对象会导致生成大量的 vtables、大型对象和缓慢的对象初始化。这可能不是 vtable 技术的结果,而是需要严格遵循子对象的运行时类型始终正确的要求。实际上在构造过程中没有很好的理由,因为构造函数不是方法并且不能明智地使用虚拟调度:这对我来说不太清楚销毁因为析构函数是真正的方法。

于 2010-12-07T20:57:37.190 回答
7

据我所知,所有 C++ 实现都使用 vtable 指针,尽管在对象中保留一个小的类型索引(1-2 B)和随后通过小表查找获得 vtable 和类型信息。

另一个有趣的方法可能是 BIBOP (http://foldoc.org/BIBOP)——一大堆页面——尽管它对于 C++ 会有问题。想法:将相同类型的对象放在一个页面上。通过简单地关闭对象指针的不太重要的位来获取指向页面顶部的类型描述符/ vtable 的指针。(当然,不适用于堆栈上的对象!)

另一种方法是在对象指针本身中编码某些类型标签/索引。例如,如果通过构造所有对象都是 16 字节对齐的,则可以使用 4 个 LSB 在其中放置一个 4 位类型标记。(还不够。)或者(特别是对于嵌入式系统)如果您保证地址中有未使用的更高有效位,您可以在那里放置更多标记位,并使用移位和掩码恢复它们。

虽然这两种方案对于其他语言实现都很有趣(有时也会使用),但它们对于 C++ 来说是有问题的。某些 C++ 语义,例如在(基类)对象构造和销毁过程中调用了哪些基类虚函数覆盖,会驱使您进入一个模型,在该模型中,当您进入基类 ctors/dtors 时,您可以修改对象中的某些状态。

您可能会发现我关于 Microsoft C++ 对象模型实现的旧教程很有趣。 http://www.openrce.org/articles/files/jangrayhood.pdf

快乐黑客!

于 2010-12-12T04:42:40.957 回答
6
  1. 我认为没有任何现代编译器采用 vptr/vtable 以外的方法。确实,很难找出其他不仅效率低下的东西。

    然而,在这种方法中仍有相当大的设计权衡空间。也许特别是关于如何处理虚拟继承。所以使这个实现定义是有意义的。

    如果您对这类东西感兴趣,我强烈建议您阅读Inside the C++ Object Model

  2. sizeof class取决于编译器。如果您想要可移植的代码,请不要做任何假设。

于 2010-12-04T10:36:49.917 回答
5

除了虚拟指针和虚拟表机制之外,是否有任何编译器以任何其他方式实现虚拟机制?据我所见,大多数(读g++,Microsoft Visual Studio)通过虚拟表、指针机制实现它。那么实际上还有其他编译器实现吗?

我所知道的所有当前编译器都使用 vtable 机制。

这是一种可能的优化,因为 C++ 是静态类型检查的。

在一些更动态的语言中,会在基类链上进行动态搜索,从对象的最派生类开始搜索被虚拟调用的成员函数的实现。例如,这就是它在最初的 Smalltalk 中的工作方式。C++ 标准描述了虚拟调用的效果,就好像使用了这种搜索一样。

在 1990 年代的 Borland/Turbo Pascal 中,这种动态搜索被用于查找 Windows API“窗口消息”的处理程序。我认为在 Borland C++ 中可能也是如此。它是对普通 vtable 机制的补充,仅用于消息处理程序。

如果它在 Borland/Turbo C++ 中使用——我不记得了——那么它支持一种语言扩展,允许你将消息 ID 与消息处理函数相关联。

任何只有一个虚函数的类的大小将是该编译器上一个指针(this 中的 vptr)的大小,因此,鉴于虚拟 ptr 和 tbl 机制本身就是编译器实现,我在上面所做的这个陈述是否总是正确的?

形式上没有(即使假设 vtable 机制),它取决于编译器。由于标准不需要 vtable 机制,它没有说明 vtable 指针在每个对象中的位置。其他规则允许编译器在末尾自由添加填充、未使用的字节。

但在实践中也许。;-)

但是,这不是您应该依赖或需要依赖的东西。但在另一个方向上,您可以要求这样做,例如,如果您正在定义 ABI。然后任何不符合您要求的编译器。

干杯&hth.,

于 2010-12-07T11:31:29.810 回答
4

除了虚拟指针和虚拟表机制之外,是否有任何编译器以任何其他方式实现虚拟机制?据我所见,大多数(读g++,Microsoft Visual Studio)通过虚拟表、指针机制实现它。那么实际上还有其他编译器实现吗?

我不知道 C++ 编译器在使用什么,尽管您可能会发现阅读有关二叉树调度的内容很有趣。如果您对以任何方式利用虚拟调度表的期望感兴趣,您应该知道编译器可以(在编译时已知类型)有时会在编译时解析虚拟函数调用,因此可能不会查阅该表。

任何只有一个虚函数的类的大小将是该编译器上一个指针(this 中的 vptr)的大小,因此,鉴于虚拟 ptr 和 tbl 机制本身就是编译器实现,我在上面所做的这个陈述是否总是正确的?

假设没有具有自己的虚拟成员的基类,也没有虚拟基类,那么这很有可能是真的。可以设想替代方案 - 例如整个程序分析仅揭示类层次结构中的一个成员,以及切换到编译时分派。如果需要运行时分派,很难想象为什么任何编译器会引入进一步的间接性。尽管如此,该标准还是故意没有精确地规定这些事情,以便实现可能会有所不同,或者将来会有所不同。

于 2010-12-07T07:44:01.580 回答
4

在试图想象一个替代方案时,我按照Yttril 的回答提出了以下内容。据我所知,没有编译器使用它!

给定足够大的虚拟地址空间和灵活的操作系统内存分配例程,就可以new在固定的、不重叠的地址范围内分配不同类型的对象。然后可以使用右移操作从其地址中快速推断出对象的类型,并将结果用于索引 vtable 表,从而为每个对象节省 1 个 vtable 指针。

乍一看,这个方案似乎遇到了堆栈分配对象的问题,但这可以干净地处理:

  1. 对于每个堆栈分配的对象,编译器添加代码,(address range, type)在创建对象时将记录添加到对的全局数组中,并在对象被销毁时删除记录。
  2. 组成堆栈的地址范围将映射到包含大量读取this指针的 thunk 的单个 vtable,扫描数组以找到该地址处对象的相应类型 (vptr),并调用指向的 vtable 中的相应方法至。(即第 42 个 thunk 将调用 vtable 中的第 42 个方法——如果任何类中使用的虚拟函数最多的是n,那么至少n需要 thunk。)

对于基于堆栈的对象上的虚拟方法调用,这种方案显然会产生不小的开销(至少 O(log n) 用于查找)。在没有基于堆栈的对象的数组或组合(包含在另一个对象中)的情况下,可以使用一种更简单且更快的方法,其中 vptr 紧挨​​在对象之前的堆栈上(请注意,它不被视为对象并且不影响其大小,由sizeof) 测量。在这种情况下,thunk 只需减去sizeof (vptr)fromthis即可找到要使用的正确 vptr,然后像以前一样转发。

于 2010-12-12T00:21:35.737 回答
4

IIRC Eiffel 使用不同的方法,方法的所有覆盖最终合并并编译在同一个地址中,并带有一个检查对象类型的序言(因此每个对象都必须有一个类型 ID,但它不是指向 VMT 的指针)。对于 C++,这当然需要在链接时创建最终函数。但是,我不知道任何使用这种方法的 C++ 编译器。

于 2010-12-12T00:37:34.113 回答
3
  1. 我从未听说过或见过任何使用任何替代实现的编译器。vtables 如此受欢迎的原因是因为它不仅是最有效的实现,而且也是最简单的设计和最明显的实现。

  2. 在您想使用的几乎所有编译器上,几乎可以肯定这是正确的。然而,它并不能保证并且并不总是正确的——你不能依赖它,即使它几乎总是如此。你最喜欢的编译器也可以改变它的对齐方式,增加它的大小,为了好玩,而不告诉你。它还可以从内存中插入任何调试信息以及它喜欢的任何内容。

于 2010-12-04T12:05:32.477 回答
3

C++/CLI偏离了这两个假设。如果你定义了一个 ref 类,它根本不会被编译成机器码;相反,编译器将其编译为 .NET 托管代码。在中间语言中,类是一个内置特性,虚拟方法的集合是在元数据中定义的,而不是在方法表中。

实现对象布局和分派的具体策略取决于 VM。在 Mono 中,只包含一个虚方法的对象没有一个指针的大小,但在MonoObject struct中需要两个指针;第二个用于对象的同步。由于这是实现定义的,而且知道起来也没什么用,因此 C++/CLI 中的 ref 类不支持 sizeof。

于 2010-12-09T19:47:24.870 回答
1

首先,提到了 Borland 对 C++ 的专有扩展,Dynamic Dispatch Virtual Tables (DDVT),您可以在名为DDISPATC.ZIP的文件中阅读有关它的内容。Borland Pascal 既有虚拟方法也有动态方法,Delphi 引入了另一种“消息”语法,类似于动态,但用于消息。在这一点上,我不确定 Borland C++ 是否具有相同的功能。在 Pascal 或 Delphi 中都没有多重继承,因此 Borland C++ DDVT 可能与 Pascal 或 Delphi 不同。

其次,在 1990 年代或更早的时候,人们尝试了不同的对象模型,而 Borland 并不是最先进的。我个人认为关闭 IBM SOMobjects 对我们所有人仍在遭受的世界造成了损害。在关闭 SOM 之前,曾对 Direct-to-SOM C++ 编译器进行了实验。因此,使用 SOM 代替 C++ 调用方法的方式。它在许多方面类似于 C++ vtable,但有几个例外。首先,为了防止脆弱的基类问题,程序不使用 vtable 内部的偏移量,因为它们不知道这个偏移量。如果基类引入新方法,它可能会改变。相反,调用者调用在运行时创建的 thunk,它在其汇编代码中具有此知识。还有一个不同之处。在 C++ 中,当使用多重继承时,一个对象可以包含多个 VMT IIRC。

有一个与 SOM 相关的文档,SOM 中的Release-to-Release Binary Compatibility。您可以找到 SOM 与我不太了解的其他项目的比较,例如Delta/C++Sun OBI。他们解决了 SOM 解决的问题的一个子集,并且通过这样做,他们还对调用代码进行了一些调整。

我最近发现 Visual Age C++ v3.5 for Windows 编译器片段足以让事情运行并实际接触它。大多数用户不太可能仅仅为了玩 DTS C++ 而获得 OS/2 VM,但拥有 Windows 编译器完全是另一回事。VAC v3.5 是第一个也是最后一个支持 Direct-to-SOM C++ 功能的版本。VAC v3.6.5 和 v4.0 不适用。

  1. 从 IBM FTP下载VAC 3.5 fixpak 9 。此修订包包含许多文件,因此您甚至不需要完整的编译器(我有 3.5.7 发行版,但修订包 9 足够大,可以进行一些测试)。
  2. 解压到例如 C:\home\OCTAGRAM\DTS
  3. 启动命令行并在那里运行后续命令
  4. 运行:设置 SOMBASE=C:\home\OCTAGRAM\DTS\ibmcppw
  5. 运行:C:\home\OCTAGRAM\DTS\ibmcppw\bin\SOMENV.BAT
  6. 运行:cd C:\home\OCTAGRAM\DTS\ibmcppw\samples\compiler\dts
  7. 运行:nmake clean
  8. 运行:nmake
  9. hhmain.exe和它的dll在不同的目录下,所以我们必须让它们以某种方式找到对方;因为我做了几个实验,所以我执行了一次“set PATH=%PATH%;C:\home\OCTAGRAM\DTS\ibmcppw\samples\compiler\dts\xhmain\dtsdll”,但你可以只复制 dll 到 hhmain 附近。可执行程序
  10. 运行:hhmain.exe

我有这样的输出:

Local anInfo->x = 5
Local anInfo->_get_x() = 5
Local anInfo->y = A
Local anInfo->_get_y() = B
{An instance of class info at address 0092E318

}
于 2014-12-07T15:38:22.160 回答
0

Tony D 的回答正确地指出,允许编译器使用整个程序分析来将虚拟函数调用替换为对唯一可能的函数实现的静态调用;或编译obj->method()成相当于

if (auto frobj = dynamic_cast<FrequentlyOccurringType>(obj)) {
    frobj->FrequentlyOccurringType::method();  // static dispatch on hot path
} else {
    obj->method();  // vtable dispatch on cold path
}

Karel Driesen 和 Urs Hölzle 早在 1996 年就写了一篇非常引人入胜的论文,他们在其中模拟了完美的全程序优化对典型 C++ 应用程序的影响:“C++ 中虚拟函数调用的直接成本”。(如果你用谷歌搜索,PDF 是免费的。)不幸的是,他们只对 vtable 调度和完美的静态调度进行了基准测试。他们没有将它与二叉树调度进行比较。

他们确实指出实际上有两种 vtable,当您谈论支持多重继承的语言(如 C++)时。对于多重继承,当您调用从第二个基类继承的虚方法时,您需要“修复”对象指针,使其指向第二个基类的实例。这个修正偏移量可以作为数据存储在 vtable 中,也可以作为代码存储在“thunk”中。(有关详细信息,请参阅论文。)

我相信现在所有体面的编译器都使用 thunk,但市场渗透率达到 100% 确实需要 10 或 20 年。

于 2013-04-25T22:22:59.187 回答