我一直听到人们抱怨 C++ 没有垃圾收集。我还听说 C++ 标准委员会正在考虑将其添加到该语言中。恐怕我只是不明白它的意义......使用带有智能指针的 RAII 消除了对它的需要,对吧?
我唯一的垃圾收集经验是在几台便宜的 80 年代家用电脑上,这意味着系统每隔一段时间就会死机几秒钟。我确信从那以后它已经有所改善,但正如你所猜想的那样,这并没有让我对它有很高的评价。
垃圾收集可以为经验丰富的 C++ 开发人员提供哪些优势?
我一直听到人们抱怨 C++ 没有垃圾收集。我还听说 C++ 标准委员会正在考虑将其添加到该语言中。恐怕我只是不明白它的意义......使用带有智能指针的 RAII 消除了对它的需要,对吧?
我唯一的垃圾收集经验是在几台便宜的 80 年代家用电脑上,这意味着系统每隔一段时间就会死机几秒钟。我确信从那以后它已经有所改善,但正如你所猜想的那样,这并没有让我对它有很高的评价。
垃圾收集可以为经验丰富的 C++ 开发人员提供哪些优势?
我为他们感到难过。严重地。
C++ 有 RAII,我总是抱怨在垃圾收集语言中找不到 RAII(或阉割的 RAII)。
另一个工具。
Matt J 在他的帖子( C++ 中的垃圾收集——为什么? )中写得非常正确:我们不需要 C++ 特性,因为它们中的大多数都可以用 C 编码,而且我们不需要 C 特性,因为它们中的大多数都可以用汇编等编码。C++ 必须发展。
作为开发人员:我不关心 GC。我尝试了 RAII 和 GC,我发现 RAII 非常优越。正如 Greg Rogers 在他的帖子(C++ 中的垃圾收集——为什么?)中所说,内存泄漏并不是那么可怕(至少在 C++ 中,如果真正使用 C++,它们很少见)以证明 GC 而不是 RAII。GC 具有非确定性的解除分配/终结,只是一种编写不关心特定内存选择的代码的方法。
最后一句话很重要:编写“不关心”的代码很重要。就像在 C++ RAII 中一样,我们不关心资源释放,因为 RAII 为我们做这件事,或者因为构造函数为我们做对象初始化,所以有时只编写代码而不关心谁是什么内存的所有者,这很重要,以及我们需要什么样的指针(共享的、弱的等)来处理这段或这段代码。C++ 中似乎需要 GC。(即使我个人没有看到它)
有时,在应用程序中,您有“浮动数据”。想象一下数据的树状结构,但没有人真正成为数据的“所有者”(也没有人真正关心它何时会被销毁)。多个对象可以使用它,然后丢弃它。当没有人再使用它时,您希望它被释放。
C++ 方法使用智能指针。boost::shared_ptr 浮现在脑海中。因此,每条数据都由其自己的共享指针拥有。凉爽的。问题是当每条数据都可以引用另一条数据时。您不能使用共享指针,因为它们使用的是引用计数器,它不支持循环引用(A 指向 B,B 指向 A)。所以你必须知道在哪里使用弱指针(boost::weak_ptr),以及何时使用共享指针。
使用 GC,您只需使用树结构数据。
缺点是您不必关心“浮动数据”何时真正被破坏。只有它会被摧毁。
所以最后,如果做得好,并且与当前的 C++ 习语兼容,GC 将是 C++ 的又一个好工具。
C++ 是一种多范式语言:添加 GC 可能会让一些 C++ 粉丝因为叛国而哭泣,但最终,这可能是个好主意,我猜 C++ 标准委员会不会让这种主要特性破坏语言,因此我们可以相信他们会进行必要的工作,以启用不会干扰 C++ 的正确 C++ GC:与 C++中的往常一样,如果您不需要某个功能,请不要使用它,这将花费您没有。
简短的回答是垃圾收集在原理上与智能指针的 RAII 非常相似。如果您分配的每一块内存都位于一个对象中,并且该对象仅由智能指针引用,那么您就有了接近垃圾收集的东西(可能更好)。优势在于不必对每个对象的范围和智能指针如此明智,并让运行时为您完成工作。
这个问题似乎类似于“C++ 必须为经验丰富的汇编开发人员提供什么?指令和子例程消除了对它的需求,对吧?”
随着像 valgrind 这样好的内存检查器的出现,我认为垃圾收集作为安全网没有多大用处,“以防”我们忘记释放某些东西 - 特别是因为它对管理更通用的资源情况没有多大帮助除了内存(尽管这些不太常见)。此外,在我见过的代码中,显式分配和释放内存(即使使用智能指针)也很少见,因为容器通常是一种更简单、更好的方法。
但是垃圾收集可以潜在地提供性能优势,尤其是在堆分配大量短期对象的情况下。GC 还可能为新创建的对象(类似于堆栈上的对象)提供更好的引用位置。
我不明白人们怎么能说 RAII 取代了 GC,或者说是非常优越。有很多情况是由 gc 处理的,而 RAII 根本无法处理。他们是不同的野兽。
首先,RAII 不是万无一失的:它可以解决 C++ 中普遍存在的一些常见故障,但在很多情况下 RAII 根本没有帮助;它对异步事件(如 UNIX 下的信号)很脆弱。从根本上说,RAII 依赖于作用域:当一个变量超出作用域时,它会被自动释放(当然,假设析构函数是正确实现的)。
这是一个简单的示例,其中 auto_ptr 或 RAII 都无法帮助您:
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <memory>
using namespace std;
volatile sig_atomic_t got_sigint = 0;
class A {
public:
A() { printf("ctor\n"); };
~A() { printf("dtor\n"); };
};
void catch_sigint (int sig)
{
got_sigint = 1;
}
/* Emulate expensive computation */
void do_something()
{
sleep(3);
}
void handle_sigint()
{
printf("Caught SIGINT\n");
exit(EXIT_FAILURE);
}
int main (void)
{
A a;
auto_ptr<A> aa(new A);
signal(SIGINT, catch_sigint);
while (1) {
if (got_sigint == 0) {
do_something();
} else {
handle_sigint();
return -1;
}
}
}
A 的析构函数永远不会被调用。当然,这是一个人为的、有点做作的例子,但实际上也可能发生类似的情况;例如,当您的代码被另一个处理 SIGINT 并且您根本无法控制的代码调用时(具体示例:matlab 中的 mex 扩展)。这也是为什么 finally 在 python 中不保证某些东西的执行的原因。在这种情况下,gc 可以为您提供帮助。
其他习语对此并不好用:在任何非平凡的程序中,您都需要有状态的对象(我在这里使用非常广泛的对象一词,它可以是语言允许的任何构造);如果你需要控制一个函数之外的状态,你不能用 RAII 轻松地做到这一点(这就是为什么 RAII 对异步编程没有那么有帮助的原因)。OTOH,gc 可以查看您的进程的整个内存,即它知道它分配的所有对象,并且可以异步清理。
使用 gc 也可以更快,原因相同:如果您需要分配/取消分配许多对象(尤其是小对象),gc 将大大优于 RAII,除非您编写自定义分配器,因为 gc 可以分配/一次清洁许多物体。一些著名的 C++ 项目使用 gc,即使在性能问题上也是如此(例如,参见 Tim Sweenie 关于在 Unreal Tournament 中使用 gc:http: //lambda-the-ultimate.org/node/1277)。GC 基本上以延迟为代价来增加吞吐量。
当然,也有RAII优于gc的情况;特别是,gc 概念主要与内存有关,而这并不是唯一的资源。RAII 可以很好地处理文件等内容。对于这些情况,没有内存处理的语言(如 python 或 ruby)确实有类似 RAII 的东西,顺便说一句(在 python 中使用语句)。当您需要精确控制何时释放资源时,RAII 非常有用,例如文件或锁通常就是这种情况。
C++ 中支持 GC 的激励因素似乎是 lambda 编程、匿名函数等。事实证明,lambda 库受益于分配内存而不关心清理的能力。普通开发人员的好处将是更简单、更可靠和更快地编译 lambda 库。
GC 还有助于模拟无限内存;您需要删除 POD 的唯一原因是您需要回收内存。如果您有 GC 或无限内存,则无需再删除 POD。
该委员会并没有添加垃圾收集,而是添加了一些功能,可以更安全地实现垃圾收集。只有时间才能证明它们是否真的对未来的编译器有任何影响。具体实现可能会有很大差异,但很可能涉及基于可达性的收集,这可能涉及轻微的挂起,具体取决于它是如何完成的。
但有一件事是,没有符合标准的垃圾收集器能够调用析构函数——只能默默地重用丢失的内存。
不必在经验不足的同事的代码中追踪资源泄漏。
垃圾收集允许推迟决定谁拥有一个对象。
C++ 使用值语义,因此使用 RAII 时,确实会在超出范围时重新收集对象。这有时被称为“立即 GC”。
当您的程序开始使用引用语义(通过智能指针等...)时,该语言不再支持您,您只能使用智能指针库。
GC 的棘手之处在于决定何时不再需要对象。
假设由于 C++ 没有将垃圾收集融入到语言中,因此您不能在 C++ 时期使用垃圾收集,这是一个普遍的错误。这是无稽之谈。我知道精英 C++ 程序员在他们的工作中理所当然地使用 Boehm 收集器。
GC 有一个属性在某些情况下可能非常重要。在大多数平台上,指针的分配自然是原子的,而创建线程安全的引用计数(“智能”)指针非常困难,并且引入了显着的同步开销。结果,智能指针经常被告知在多核架构上“不能很好地扩展”。
垃圾收集使RCU无锁同步更容易正确有效地实现。
垃圾收集确实是自动资源管理的基础。GC 会以难以量化的方式改变您解决问题的方式。例如,当您进行手动资源管理时,您需要:
在平凡的情况下,没有复杂性。例如,您在方法开始时打开文件并在结束时关闭它。或者调用者必须释放这个返回的内存块。
当您有多个与资源交互的模块并且不清楚谁需要清理时,事情开始迅速变得复杂。最终结果是解决问题的整个方法包括某些编程和设计模式,这是一种折衷方案。
在具有垃圾收集功能的语言中,您可以使用一次性模式,您可以在其中释放您知道已完成的资源,但如果您未能释放它们,GC 可以挽救这一天。
智能指针实际上是我提到的妥协的一个完美例子。除非您有备份机制,否则智能指针无法使您免于泄漏循环数据结构。为了避免这个问题,你经常妥协并避免使用循环结构,即使它可能是最合适的。
我也怀疑 C++ 委员会是否在标准中添加了一个成熟的垃圾收集。
但我想说,在现代语言中添加/进行垃圾收集的主要原因是反对垃圾收集的好理由太少了。自八十年代以来,内存管理和垃圾收集领域取得了一些巨大的进步,我相信甚至还有垃圾收集策略可以为您提供类似软实时的保证(例如,“GC 不会花费超过 .. ..在最坏的情况下”)。
使用带有智能指针的 RAII 消除了对它的需要,对吧?
智能指针可用于在 C++ 中实现引用计数,这是垃圾收集(自动内存管理)的一种形式,但生产 GC 不再使用引用计数,因为它有一些重要的缺陷:
引用计数泄漏循环。考虑 A↔B,对象 A 和 B 都相互引用,因此它们的引用计数均为 1,并且都没有被收集,但它们都应该被回收。像试删除这样的高级算法解决了这个问题,但增加了很多复杂性。使用weak_ptr
作为一种解决方法是回退到手动内存管理。
天真的引用计数很慢有几个原因。首先,它需要经常增加缓存外引用计数(参见Boost 的 shared_ptr 比 OCaml 的垃圾收集慢 10 倍)。其次,在作用域末尾注入的析构函数会导致不必要且昂贵的虚函数调用,并抑制尾调用消除等优化。
基于作用域的引用计数会保持浮动垃圾,因为对象直到作用域结束才被回收,而跟踪 GC 可以在它们变得无法访问时立即回收它们,例如,在循环期间可以回收循环之前分配的本地吗?
垃圾收集可以为经验丰富的 C++ 开发人员提供哪些优势?
生产力和可靠性是主要优势。对于许多应用程序,手动内存管理需要大量的程序员工作。通过模拟无限内存机器,垃圾收集将程序员从这种负担中解放出来,使他们能够专注于解决问题并避免一些重要的错误类别(悬空指针、缺失free
、双精度free
)。此外,垃圾收集有助于其他形式的编程,例如通过解决向上的 funarg 问题 (1970)。
在支持 GC 的框架中,对不可变对象(例如字符串)的引用可以以与原语相同的方式传递。考虑类(C# 或 Java):
public class MaximumItemFinder
{
String maxItemName = "";
int maxItemValue = -2147483647 - 1;
public void AddAnother(int itemValue, String itemName)
{
if (itemValue >= maxItemValue)
{
maxItemValue = itemValue;
maxItemName = itemName;
}
}
public String getMaxItemName() { return maxItemName; }
public int getMaxItemValue() { return maxItemValue; }
}
请注意,此代码不必对任何字符串的内容做任何事情,并且可以简单地将它们视为原语。类似的语句maxItemName = itemName;
可能会生成两条指令:寄存器加载,然后是寄存器存储。将MaximumItemFinder
无法知道的调用者AddAnother
是否会保留对传入字符串的任何引用,并且调用者将无法知道MaximumItemFinder
将对它们的引用保留多长时间。的调用者getMaxItemName
将无法知道MaximumItemFinder
返回字符串的原始供应商是否以及何时放弃了对它的所有引用。因为代码可以像原始值一样简单地传递字符串引用,但是,这些都不重要。
还要注意,虽然上面的类在同时调用 的情况下不是线程安全的AddAnother
,但任何调用GetMaxItemName
都可以保证返回对空字符串或已传递给的字符串之一的有效引用AddAnother
。如果要确保最大项名称与其值之间的任何关系,则需要线程同步,但即使在没有它的情况下也可以确保内存安全。
我不认为有任何方法可以在 C++ 中编写像上面这样的方法,它可以在存在任意多线程使用的情况下维护内存安全,而无需使用线程同步或要求每个字符串变量都有自己的内容副本,保存在自己的存储空间中,在相关变量的生命周期内可能不会被释放或重新定位。肯定不可能定义一个可以像int
.
处理诸如循环引用之类的事情的成熟 GC 在某种程度上是对 ref-counted 的升级shared_ptr
。我会在 C++ 中有点欢迎它,但不是在语言级别。
C++ 的优点之一是它不会强制你进行垃圾收集。
我想纠正一个常见的误解:垃圾收集神话,它以某种方式消除了泄漏。根据我的经验,调试其他人编写的代码并试图发现最昂贵的逻辑泄漏的最糟糕的噩梦涉及通过资源密集型主机应用程序使用嵌入式 Python 等语言进行垃圾收集。
在谈论像 GC 这样的主题时,有理论,然后有实践。从理论上讲,它很棒并且可以防止泄漏。然而在理论层面上,每一种语言都很棒而且没有泄漏,因为理论上,每个人都会编写完全正确的代码,并测试每一个可能的情况,其中一段代码可能会出错。
在我们的案例中,垃圾收集加上不太理想的团队协作导致了最糟糕、最难调试的泄漏。
问题仍然与资源的所有权有关。当涉及持久对象时,您必须在此处做出明确的设计决策,而垃圾收集使您很容易认为您不这样做。
给定一些资源,R
在一个团队环境中,开发人员并不总是不断地交流和仔细审查彼此的代码(在我的经验中这有点太常见了),开发人员很容易A
存储该资源的句柄. 开发人员B
也是如此,也许以一种模糊的方式间接添加R
到某些数据结构中。也是如此C
。在垃圾收集系统中,这创建了 3 个R
.
因为开发者A
是最初创建资源的人,并且认为他是它的所有者,所以他记得R
在用户表示他不再想使用它时释放引用。毕竟,如果他不这样做,什么都不会发生,从测试中可以看出,用户端删除逻辑什么也没做。所以他记得发布它,就像任何有能力的开发人员都会做的那样。这会触发一个事件来B
处理它,并且还记得释放对 的引用R
。
然而,C
忘记了。他不是团队中更强大的开发人员之一:一个在系统中只工作了一年的新人。或者也许他甚至不在团队中,只是一个受欢迎的第三方开发人员为我们的产品编写插件,许多用户添加到软件中。使用垃圾收集,这是我们得到那些无声的逻辑资源泄漏的时候。它们是最糟糕的类型:它们不一定会在软件的用户可见方面表现为一个明显的错误,除了在运行程序的持续时间内,内存使用量会出于某种神秘目的而继续上升和上升。尝试使用调试器缩小这些问题的范围可能与调试时间敏感的竞争条件一样有趣。
如果没有垃圾收集,开发人员C
将创建一个悬空指针。他可能会在某个时候尝试访问它并导致软件崩溃。现在这是一个测试/用户可见的错误。C
有点尴尬并纠正了他的错误。在 GC 场景中,仅仅试图找出系统泄漏的位置可能非常困难,以至于某些泄漏永远无法纠正。这些不是valgrind
类型的物理泄漏,可以很容易地检测到并精确定位到特定的代码行。
通过垃圾收集,开发人员C
制造了一个非常神秘的泄漏。他的代码可以继续访问R
,现在只是软件中的一些不可见实体,此时与用户无关,但仍处于有效状态。并且随着C
的代码造成更多泄漏,他在无关资源上创建更多隐藏处理,并且软件不仅泄漏内存而且每次都变得越来越慢。
所以垃圾收集不一定能减轻逻辑资源泄漏。在不太理想的情况下,它可以使泄漏更容易被默默地忽视并保留在软件中。开发人员可能会因为试图追踪他们的 GC 逻辑泄漏而感到沮丧,以至于他们只是告诉用户定期重新启动软件作为一种解决方法。它确实消除了悬空指针,并且在一个注重安全的软件中,在任何情况下崩溃都是完全不可接受的,那么我更喜欢 GC。但我经常在安全性较低但资源密集型、性能关键型产品中工作,在这些产品中,可以及时修复的崩溃比真正晦涩难懂且神秘的静默 bug 更可取,并且资源泄漏在那里并不是微不足道的 bug。
在这两种情况下,我们讨论的是不驻留在堆栈中的持久对象,例如 3D 软件中的场景图或合成器中可用的视频剪辑或游戏世界中的敌人。当资源将它们的生命周期绑定到堆栈时,C++ 和任何其他 GC 语言都倾向于使正确管理资源变得微不足道。真正的困难在于引用其他资源的持久资源。
在 C 或 C++ 中,如果您未能清楚地指定谁拥有资源以及何时应该释放它们的句柄(例如:设置为 null 以响应事件),则可能会出现由段错误导致的悬空指针和崩溃。然而,在 GC 中,响亮而令人讨厌但通常很容易发现的崩溃被交换为可能永远不会被检测到的无声资源泄漏。