C++ 的垃圾回收框架 - 第二部分






4.13/5 (6投票s)
2001年1月26日

124538

1266
重新审视 gc_ptr<> 以增加对多态类型的支持。
引言
本文旨在重构最初在 C++ 的垃圾回收框架 中提出的代码,以便允许使用多态类型。如果您还没有阅读,请先阅读该文章。本文主要介绍代码重构的过程,以向新程序员说明代码在实际生活中是如何维护的。如果您只关心在代码中使用 gc_ptr<> 智能指针,则无需阅读本文。只需从上面的链接下载代码,替换上一篇文章中的实现即可。您仍然会发现其中有几部分值得阅读,特别是关于编译器错误和推荐用法的章节。
问题
如果您阅读了原始文章,您会记得,虽然我提供了一个可以为 C++ 提供真正的垃圾回收的智能指针,但其实现存在一个严重的问题。按原样编写,它无法处理多态类型。为了说明这个问题,您需要了解一些关于当指针转换为基类型时如何行为的知识。以下代码将有助于说明这个问题,并在我后面的描述中继续使用。
struct base1 { virtual ~base1() { } int value1; }; struct base2 { virtual ~base2() { } int value2; }; struct derived : public base1, public base2 { }; int main() { derived obj; base1* p1 = &obj; base2* p2 = &obj; assert(static_cast<void*>(p1) == static_cast<void*>(p2)); }
上面的示例会让许多人感到惊讶的是,p1' 和 p2 在内存中不会指向同一个地址。这意味着上面的断言将失败。原始实现依赖于转换为“指向 cv void 的指针”返回的值与 new(gc)
分配的内存块的起始地址相同。显然,对于多态类型,情况并非总是如此。
寻找解决方案
在尝试为此问题寻找解决方案时,有些人认为以下代码会起作用。
struct base1 { virtual ~base1() { } int value1; }; struct base2 { virtual ~base2() { } int value2; }; struct derived : public base1, public base2 { }; int main() { derived obj; base1* p1 = &obj; base2* p2 = &obj; assert(dynamic_cast<void*>(p1) == dynamic_cast<void*>(p2)); }
这段代码之所以有效,是因为 dynamic_cast<void*>(p)
将返回 'p' 的最派生类型的地址(ISO/IEC 14822, section 5.2.7/7)。起初,这似乎是解决我们问题的完美方案。然而,dynamic_cast<>
不能用于非多态类型。将 gc_ptr<>
修改为使用 dynamic_cast<>
而不是 static_cast<> 将允许使用多态类型,但那样就不能再允许非多态类型了。根据我的经验,大多数类型都是非多态的,所以我无法接受这种问题交换。
尽管如此,这似乎仍然是显而易见的解决方案,所以我开始寻找一种方法,可以将完全类型的指针转换为“指向 cv void 的指针”,该指针引用最派生类型的地址,而无论原始类型是否是多态的。我做的第一件事之一就是在 comp.lang.c++ 上提出这个问题。但我措辞错误。我没有说“指向最派生类型的地址”,而是说“指向对象的开始”。有人很快指出,标准并没有规定对象的布局方式,对象开始之前可能存在填充。我早就知道了,所以不应该有帮助。然而,令人惊讶的是,它却有所帮助。
你看,我太拘泥于问题本身了。我一直在与标记-清除的复杂性、算法复杂度的计算、语言中的边缘情况(如全局数据的生命周期)等问题作斗争。所以我戴着“近视眼镜”。我专注于“显而易见”的解决方案,而没有考虑其他可能性。关于对象布局概念的那个小小的提醒,不知何故,激发了我的思考,让我跳出了这个狭隘的视野,解决方案也随之而来了。
令人惊讶的是,真正的解决方案是如此简单和显而易见。事实上,当我将解决方案告诉 Thant Tessman(我原始代码所基于的名为 circ_ptr 的模板类的作者)时,他回应道:“ Duh!我很尴尬竟然没想到。” 我知道他的感受。毕竟,如果我们回到这段代码的起源,特别是传统的垃圾回收库,我们会发现它们自己就使用了这个解决方案。
那么,解决方案是什么呢?停止只跟踪指向分配内存块开始处的指针,开始跟踪引用整个块中任何内存的指针!转换为基类型的指针仍然指向该块内。因此,修改查找实现中“节点”的代码,搜索所有已知的节点,直到找到一个“包含”该地址的节点,就可以解决大部分问题。作为一个额外的奖励,我们现在可以拥有指向用 new(gc) 分配的对象的成员的 gc_ptr<>
。
我说过,修改搜索已注册节点的代码可以解决 **大部分** 问题。还缺什么?此时还缺少两样东西。第一个是最容易解决的。gc_ptr<>::get()
方法将一个指向基内存块的“指向 cv void”的指针转换回实际类型。这与转换为“指向 cv void”的指针存在同样的问题,而且解决方案甚至更简单。我只是在 gc_ptr<>
中添加了一个实际的指针,该指针设置为传递给构造函数的指针值,并修改了相应的赋值以处理此问题。
需要解决的第二个问题是,当不再有“根指针”指向该对象时,删除该对象。同样,实现依赖于将基指针转换回实际类型,以便对其调用 operator delete。解决方案是向节点状态添加第二个指针。此指针将指向用于在节点中“注册”析构函数的对象(有关详细信息,请参阅实现……这部分我很难在此文本中解释)。
通过这些更改,我们现在有了一个处理多态类型的实现!
性能问题
第一次修改代码以支持多态类型时,一切都进行得很顺利。然而,用于查找节点的代码效率不高。因此,又进行了一轮重构。为了减少客户端的编译时间,一些内容被从头文件移到了实现文件,因为它们在实现之外不再需要。几个函数被分解成多个函数。用于跟踪指针和节点的容器被更改了。最后,搜索节点的代码被分解成一个返回迭代器的函数。
我不确定我是否拥有 find_node
(搜索包含给定地址的节点的函数)的最快实现。第一个实现只是遍历所有节点,直到找到包含该地址的节点。最终的实现只遍历到找到节点或地址大于当前节点的基指针为止。这依赖于 std::set<>
是一个排序容器的事实。如果我们可以使用 set 的内置搜索功能来查找我们的节点,那就太好了,但是它们(find
、lower_bound
、upper_bound
和 equal_range
)都使用基于 set 的谓词模板参数的精确搜索。因此,如果地址不等于节点的基指针,它们都将简单地返回一个指向容器 end()
的迭代器。标准算法也没有太大帮助。它们中的任何一个都不会比手动编写的 for 循环更有效,实际上可能会更差。std::binary_search
算法甚至无法帮助我们,因为即使容器已排序,它也不支持随机访问迭代器。
编译器错误
文章的这一部分可能有些争议。许多人认为 VC++ 对标准的支持足够,并且不太关心 VC++ 7 不完全符合标准的事实。好吧,gc_ptr<>
很好地说明了 VC++ 6 在某些方面不够符合标准,无法处理简单的代码。在这种情况下,我预计 VC++ 7 将会更好地处理代码,但这仍然说明了为什么合规性不仅仅是一个不错的特性,而是我们应该要求的。我知道负责 VC++ 编译器的团队对此非常关注,并且他们正在努力解决,所以我不会在这里过多地纠缠这个问题。但是,由于我至少无法完全满意地绕过这些错误,所以我必须告诉您遇到的问题。
在修复了多态类型问题后,我可以比第一个版本更详细地处理接口。我希望 gc_ptr<>
遵循与 std::auto_ptr<>
相同的通用风格,以便用户熟悉,并且我不会重蹈标准委员会已经解决的错误。其中一个改变是提供了两个不同的构造函数和赋值运算符,用于与其他的 gc_ptr<>
实例一起使用。在我第一个实现中,只有一个模板形式,允许从一个 gc_ptr<>
类型转换为另一个。标准还包含与相同 gc_ptr<>
类型进行赋值和构造的版本。我不太确定为什么这有必要,但我不会质疑他们。我为相同类型添加了非模板版本。令人惊讶的是,编译器抱怨重复定义。事实证明,VC++ 只有在模板形式在前时才能处理这种情况,这是一个解析错误,因为标准并不要求这样做。
另一个改变是移除对“实际指针”的赋值,而是添加一个 reset()
方法。对“实际指针”进行赋值在某些情况下可能导致一些微妙且意想不到的结果,因此不应允许。进行此更改后,我重新编译了代码并运行了测试套件。令人惊讶的是,一个对象被过早地回收了。或者至少表面上看起来是这样。逐步调试代码表明,实际发生的情况更糟。我未能更正测试套件中将 gc_ptr<>
赋值给“实际指针”的某一行。编译器本应捕获这一点并给出编译错误,但它没有。相反,它对此命令没有任何输出(在调试器中单步执行时,该行被跳过了),并且继续执行,就好像什么都没发生一样。我没有收到任何错误或警告,而是得到了一个行为与预期不同的可执行文件。我尝试将其提炼成一个更简单的示例通过电子邮件发送给某人,并发现用更简单的形式,我收到了令人憎恶的 INTERNAL COMPILER ERROR
。上面链接中包含的测试套件包含了重现这两种编译器行为的代码,以及一个“修复”,如果您绝对必须使用,可以阻止 VC++ 6 出现此问题。我不喜欢这个修复,因为它会产生一个奇怪的编译器错误,会让任何不知道修复原因的人感到困惑。由于我预计 VC++ 7 将会修复这个问题,所以我将修复注释掉了。如果您需要 VC++ 6 的版本,请取消注释。
使用注意事项
现在我已经有了一个功能齐全的实现,我认为有必要讨论何时应该使用这个智能指针。它不是所有指针的良好即插即用替代品,甚至不是所有情况下引用计数智能指针的良好替代品。首先,如果您想完全使用垃圾回收,您可能选择了错误的语言。没有语言支持垃圾回收,因此所有可用的解决方案都存在一个问题,即它们无法处理由其他库(包括标准库)分配的内存。
如果您不需要随处使用,但更愿意大部分时间使用它,那么使用传统的 C/C++ 垃圾回收库可能更适合您。它们完全替换了分配逻辑,而不是构建在正常例程之上。这使它们能够进行一些此处不可能(或至少不理想)的内存和速度优化。但是,请记住上一篇文章中讨论的此解决方案的缺点。这些库的保守性质可能是不理想的,尽管在速度和内存使用方面有好处。
即使您决定仅在手动内存管理过于困难且容易出错的情况下使用它,您也可能选择更传统的引用计数智能指针。此实现涉及一些明显的开销。您分配的每个对象和存在的每个指针都需要分配额外的内存。分配会更慢,并且如果管理不当,调用收集可能会导致明显的速度问题。相比之下,引用计数指针使用的内存不会比标准指针多多少,并且几乎和标准指针一样高效。因此,如果速度和/或内存对您很重要,那么 gc_ptr<>
类应谨慎使用。仅在您知道将存在循环引用,或者担心可能存在的地方使用它。当您知道不会有任何循环引用时,请改用引用计数指针。
既然我已经谈论了 gc_ptr<>
的效率问题,我应该指出,已尽一切努力确保最佳性能。谨慎使用时,我预计您不会在此区域注意到任何问题。
我很想听听您使用这个类的实际经验。如果您有时间,请在下面的评论中发布,以便其他人了解您的经验。