长话短说-我正在编写一个编译器,并且要实现OOP功能,所以我面临一个涉及处理析构函数的难题。基本上我有两个选择:
1-将所有需要在此时调用的对象的析构函数放入程序中。这个选项听起来像是性能友好且简单,但会使代码膨胀,因为根据控制流,某些析构函数可以重复多次。
2-带有代码的每个代码块的分区析构函数,并且仅通过需要的内容进行“意大利面条跳转”。有利的一面是-没有重复的析构函数,不利的一面-它涉及到非顺序执行和跳转,还涉及额外的隐藏变量和条件,例如,确定执行是否离开某个块以继续在父级执行将是必需的阻止或中断/继续/转到/返回,这也增加了其复杂性。而且,额外的变量和检查很可能会消耗掉此方法所节省的空间,具体取决于对象的数量以及其中的结构和控制流的复杂性。
而且我知道通常对此类问题的回答是“同时做,分析和决定”,如果这是一项琐碎的任务,那就是我会做的,但是事实证明编写全功能的编译器有些艰巨,所以我更愿意找一些专家输入而不是建两个桥,看看哪个桥更好,然后烧另一个桥。
我将c ++放入标签中,因为这是我正在使用的语言,并且对它和RAII范例有所了解,这也是我的编译器正在建模的方式。
在大多数情况下,析构函数调用可以与普通函数调用相同的方式对待。
小部分是处理EH。我注意到MSC在“普通”代码中生成了一系列内联析构函数调用,并且对于x86-64,创建了单独的清除代码,该代码本身可能具有也可能没有析构函数逻辑的副本。
IMO,最简单的解决方案是始终将非平凡的析构函数称为普通函数。
如果在不久的将来似乎可以进行优化,则将上述调用与其他任何方法一样对待:它是否可以与其他所有内容一起放入缓存?这样做会占用图像中过多的空间吗?等等。
前端可以在其输出AST中每个可操作块的末尾插入对非平凡析构函数的“调用”。
后端可能将诸如普通函数调用之类的东西,将它们连接在一起,在某个地方进行大的块O析构函数调用逻辑,然后跳转到该处,等等...
将函数链接到相同的逻辑似乎很常见。例如,MSC倾向于将所有琐碎的功能链接到相同的实现,析构函数或其他方法,无论是否进行优化。
这主要来自经验。和往常一样,YMMV。
EH清理逻辑往往像跳转表一样工作:对于给定的函数,您可以直接跳转到单个析构函数调用列表,具体取决于抛出异常的位置(如果适用)。
我不知道商业编译器是如何编写代码的,但是假设此时我们忽略异常[1],我将采用的方法是调用析构函数,而不是内联它。每个析构函数都将包含该对象的完整析构函数。使用循环来处理数组的析构函数。
内联呼叫是一种优化,除非您“知道它会有所收获”(代码大小与速度),否则您不应该这样做。
您将需要处理“封闭块中的破坏”,但是假设您没有从块中跳出,那应该很容易。跳出块(例如返回,中断等)将意味着您必须跳到一段代码来清理您所在的块。
[1]商业编译器有一个特殊的表,基于“抛出异常的位置”,并生成了一段代码来进行清理-通常通过在每个清理块中都具有多个跳转标签,对许多异常点重复使用同一清理。
编译器同时使用两种方法。 MSVC使用内联析构函数调用进行正常的代码流,并以相反的顺序清理代码块,以实现早期返回和异常。在正常流程中,它使用单个隐藏的局部整数来跟踪到目前为止的构造函数进度,因此它知道在早期返回时跳转到哪里。一个单一的整数就足够了,因为作用域总是形成一棵树(而不是对已经成功构建或尚未成功构建的每个类使用位掩码)。例如,以下相当简短的代码使用带有非平凡析构函数的类,并在整个过程中散布了一些随机返回值...
...
if (randomBool()) return;
Foo a;
if (randomBool()) return;
Foo b;
if (randomBool()) return;
{
Foo c;
if (randomBool()) return;
}
{
Foo d;
if (randomBool()) return;
}
...
...可以像下面在x86上那样扩展为伪代码,其中每次调用构造函数后,构造函数的进度都会立即递增(有时会增加一个以上的值到下一个唯一值),然后立即递减(或“弹出”为先前的值)在每个析构函数调用之前。请注意,带有琐碎析构函数的类不会影响此值。
...
save previous exception handler // for x86, not 64-bit table based handling
preallocate stack space for locals
set new exception handler address to ExceptionCleanup
set constructor progress = 0
if randomBool(), goto Cleanup0
Foo a;
set constructor progress = 1 // Advance 1
if randomBool(), goto Cleanup1
Foo b;
set constructor progress = 2 // And once more
if randomBool(), goto Cleanup2
{
Foo c;
set constructor progress = 3
if randomBool(), goto Cleanup3
set constructor progress = 2 // Pop to 2 again
c.~Foo();
}
{
Foo d;
set constructor progress = 4 // Increment 2 to 4, not 3 again
if randomBool(), goto Cleanup4
set constructor progress = 2 // Pop to 2 again
d.~Foo();
}
// alternate Cleanup2
set constructor progress = 1
b.~Foo();
// alternate Cleanup1
set constructor progress = 0
a.~Foo();
Cleanup0:
restore previous exception handler
wipe stack space for locals
return;
ExceptionCleanup:
switch (constructor progress)
{
case 0: goto Cleanup0; // nothing to destroy
case 1: goto Cleanup1;
case 2: goto Cleanup2;
case 3: goto Cleanup3;
case 4: goto Cleanup4;
}
// admitting ignorance here, as I don't know how the exception
// is propagated upward, and whether the exact same cleanup
// blocks are shared for both early returns and exceptions.
Cleanup4:
set constructor progress = 2
d.~Foo();
goto Cleanup2;
Cleanup3:
set constructor progress = 2
c.~Foo();
// fall through to Cleanup2;
Cleanup2:
set constructor progress = 1
b.~Foo();
Cleanup1:
set constructor progress = 0
a.~Foo();
goto Cleanup0;
// or it may instead return directly here
当然,编译器可能会以其认为更有效的方式重新排列这些块,而不是将所有清理工作都放在最后。较早的返回值可能会跳转到该函数结尾处的备用Cleanup1 / 2。在64位MSVC代码上,通过表处理异常,这些表将异常发生时的指令指针映射到相应的代码清除块。
优化的编译器正在转换已编译源代码的内部表示。
它通常会构建basic blocks的有向图(通常是循环图)。构建此control flow graph时,它将调用添加到析构函数。
对于GCC(它是一个自由软件编译器-Clang/LLVM也是如此,因此您可以研究其源代码),您可能可以尝试使用-fdump-tree-all
编译一些简单的C ++测试用例代码,然后查看它是在gimplification时间完成的。顺便说一句,您可以使用g++
自定义MELT以探索其内部表示形式。
顺便说一句,我不认为您如何处理析构函数那么重要(请注意,在C ++中,它们在语法上定义的位置(如其定义范围的}
)被隐式调用)。这样的编译器的大部分工作都是在优化上(然后,处理析构函数不是很相关;它们几乎像其他例程一样)。