为什么 C ++ 程序员应尽量减少对 “新” 的使用?

在使用 std :: list 偶然发现了堆栈溢出问题std :: string 的内存泄漏其中一条评论说:

停止使用new这么多。我看不到您在任何地方使用新产品的任何原因。您可以在 C ++ 中按值创建对象,这是使用该语言的巨大优势之一。您不必在堆上分配所有内容。不要像 Java 程序员那样思考。

我不确定他的意思。为什么要在 C ++ 中尽可能频繁地通过值创建对象,它在内部有什么不同?我误解了答案吗?

答案

有两种广泛使用的内存分配技术:自动分配和动态分配。通常,每个都有一个对应的内存区域:堆栈和堆。

堆栈始终以顺序方式分配内存。之所以这样做,是因为它要求您以相反的顺序释放内存(先进先出,最后用尽:FILO)。这是许多编程语言中用于局部变量的内存分配技术。它非常非常快,因为它需要最少的簿记,并且下一个要分配的地址是隐式的。

在 C ++ 中,这称为自动存储,因为该存储在作用域末尾自动声明。当前代码块(使用{}分隔)的执行完成后,将自动收集该块中所有变量的内存。这也是调用析构函数清理资源的时刻。

堆允许更灵活的内存分配模式。簿记更加复杂,分配也较慢。由于没有隐式释放点,因此必须使用deletedelete[] (在 C 语言中free )手动释放内存。但是,缺少隐式释放点是堆灵活性的关键。

使用动态分配的原因

即使使用堆的速度较慢,并且可能导致内存泄漏或内存碎片,但由于动态内存分配的局限性较小,因此存在很好的用例。

使用动态分配的两个主要原因:

  • 您不知道在编译时需要多少内存。例如,在将文本文件读取为字符串时,通常不知道文件的大小,因此在运行程序之前,您无法确定要分配多少内存。

  • 您要分配在离开当前块后仍将保留的内存。例如,您可能想编写一个函数string readfile(string path) ,该文件返回文件的内容。在这种情况下,即使堆栈可以容纳整个文件内容,也无法从函数返回并保留分配的内存块。

为什么通常不需要动态分配

在 C ++ 中,有一个名为destructor的简洁构造。该机制允许您通过将资源的生存期与变量的生存期对齐来管理资源。这种技术称为RAII ,是 C ++ 的区别点。它将资源 “包装” 为对象。 std::string是一个很好的例子。此代码段:

int main ( int argc, char* argv[] )
{
    std::string program(argv[0]);
}

实际上分配了可变数量的内存。 std::string对象使用堆分配内存,并在其析构函数中释放它。在这种情况下,你不需要手动管理的任何资源,还是把动态内存分配的好处。

特别是,它暗示在此代码段中:

int main ( int argc, char* argv[] )
{
    std::string * program = new std::string(argv[0]);  // Bad!
    delete program;
}

不需要动态内存分配。该程序需要更多的键入(!),并带来了忘记重新分配内存的风险。它这样做没有明显的好处。

为什么您应该尽可能频繁地使用自动存储

基本上,最后一段进行了总结。尽可能频繁地使用自动存储可以使您的程序:

  • 打字速度更快;
  • 运行时更快
  • 不太容易发生内存 / 资源泄漏。

奖励积分

在提到的问题中,还有其他问题。特别是以下类:

class Line {
public:
    Line();
    ~Line();
    std::string* mString;
};

Line::Line() {
    mString = new std::string("foo_bar");
}

Line::~Line() {
    delete mString;
}

实际上,使用风险要高于以下风险:

class Line {
public:
    Line();
    std::string mString;
};

Line::Line() {
    mString = "foo_bar";
    // note: there is a cleaner way to write this.
}

原因是std::string正确定义了复制构造函数。考虑以下程序:

int main ()
{
    Line l1;
    Line l2 = l1;
}

使用原始版本,此程序可能会崩溃,因为它对同一字符串两次使用delete 。使用修改后的版本,每个Line实例将拥有自己的字符串实例 ,每个都有自己的内存,并且两者都将在程序结尾处释放。

其他注意事项

由于上述所有原因,广泛使用RAII被认为是 C ++ 中的最佳实践。但是,还有一个额外的好处,这种好处不是立即显而易见的。基本上,它比各部分的总和要好。整个机制组成 。它可以缩放。

如果将Line类用作构建基块:

class Table
 {
      Line borders[4];
 };

然后

int main ()
 {
     Table table;
 }

分配四个std::string实例,四个Line实例,一个Table实例以及所有字符串的内容,并且所有内容都会自动释放

因为堆栈速度更快且防漏

在 C ++ 中,只需要一条指令就可以为给定函数中的每个局部作用域对象在堆栈上分配空间,并且不可能泄漏任何该内存。该注释意图(或应该意图)说诸如“使用堆栈而不是堆” 之类的内容。

原因很复杂。

首先,C ++ 不会被垃圾收集。因此,对于每个新项,必须有一个对应的删除项。如果您无法放入此删除项,则内存泄漏。现在,对于这样的简单情况:

std::string *someString = new std::string(...);
//Do stuff
delete someString;

这很简单。但是,如果 “东西” 抛出异常会怎样?糟糕:内存泄漏。如果 “东西” 问题早日return怎么办?糟糕:内存泄漏。

这是最简单的情况 。如果您碰巧将该字符串返回给某人,则现在他们必须删除它。如果他们将其作为参数传递,接收它的人是否需要删除它?他们什么时候应该删除它?

或者,您可以执行以下操作:

std::string someString(...);
//Do stuff

没有delete 。对象是在 “堆栈” 上创建的,一旦超出范围,它将被销毁。您甚至可以返回对象,从而将其内容传输到调用函数。您可以将对象传递给函数(通常作为引用或 const-reference: void SomeFunc(std::string &iCanModifyThis, const std::string &iCantModifyThis)等等。

全部没有newdelete 。毫无疑问,谁拥有内存或由谁负责删除内存。如果您这样做:

std::string someString(...);
std::string otherString;
otherString = someString;

可以理解, otherString具有someString数据副本。它不是指针;它是一个单独的对象。它们可能恰好具有相同的内容,但是您可以更改其中一个而不影响另一个:

someString += "More text.";
if(otherString == someString) { /*Will never get here */ }

看到这个主意了吗?

new创建的对象必须最终delete以免泄漏。整个过程都不会调用析构函数,也不会释放内存。由于 C ++ 没有垃圾回收,因此这是一个问题。

由值创建的对象(即在堆栈上)在超出范围时会自动死亡。析构函数调用由编译器插入,并且在函数返回时自动释放内存。

智能指针(如unique_ptrshared_ptr解决了悬而未决的引用问题,但它们需要编码规范,并且还存在其他潜在问题(可复制性,引用循环等)。

同样,在高度多线程的情况下, new是线程之间的争用点。过度使用new可能会对性能产生影响。根据定义,堆栈对象的创建是线程局部的,因为每个线程都有自己的堆栈。

值对象的缺点是,一旦宿主函数返回,它们就会死掉 - 您不能仅通过复制,返回或按值移动将对它们的引用传递回调用方。

  • C ++ 自己没有使用任何内存管理器。其他语言,例如 C#,Java 具有垃圾收集器来处理内存
  • C ++ 实现通常使用操作系统例程来分配内存,太多的新操作 / 删除操作可能会分割可用的内存
  • 对于任何应用程序,如果经常使用内存,建议对其进行预分配并在不需要时释放。
  • 内存管理不当可能导致内存泄漏,并且很难跟踪。因此,在功能范围内使用堆栈对象是一种行之有效的技术
  • 使用堆栈对象的缺点是,它会在返回,传递给函数等时创建对象的多个副本。但是,精巧的编译器充分意识到了这些情况,并且对其性能进行了优化。
  • 如果在两个不同的位置分配和释放内存,则在 C ++ 中确实很繁琐。发布的责任始终是一个问题,并且大多数情况下,我们依赖于一些通常可访问的指针,堆栈对象(最大可能)和 auto_ptr 之类的技术(RAII 对象)
  • 最好的事情是,您已经控制了内存,最糟糕的事情是,如果我们对应用程序使用了不正确的内存管理,则您将无法控制内存。由于内存损坏而导致的崩溃是最令人讨厌且难以跟踪的。

我发现错过了一些尽可能少的新事物的重要原因:

运算符new具有不确定的执行时间

调用new可能会或可能不会导致操作系统向您的进程分配新的物理页面,如果您经常这样做,则可能会很慢。或者它可能已经准备好合适的存储位置,我们不知道。如果你的程序需要有一致的和可预测的执行时间(如在一个实时系统或游戏 / 物理模拟),你需要避免new在你的时间临界循环。

运算符new是隐式线程同步

是的,您听说过,您的操作系统需要确保页面表是一致的,因此调用new将导致您的线程获取隐式互斥锁。如果您始终从多个线程中调用new ,那么您实际上是在对线程进行序列化(我已经用 32 个 CPU 进行了此操作,每个 CPU 都单击new以获得每个数百字节的数据,哎呀!这是调试的皇家皮塔饼)

诸如慢速,碎片化,易于出错等之类的其他问题已经被其他答案提及。

C ++ 17 之前的版本:

因为即使将结果包装在智能指针中,它也容易发生细微的泄漏。

考虑一个 “谨慎” 的用户,他记得将对象包装在智能指针中:

foo(shared_ptr<T1>(new T1()), shared_ptr<T2>(new T2()));

该代码很危险,因为不能保证 T1T2 之前构造了shared_ptr 。因此,如果new T1()new T2()的一个在另一个成功之后失败,则第一个对象将被泄漏,因为不存在用于破坏和释放它的shared_ptr

解决方案:使用make_shared

C ++ 17 后:

这不再是问题:C ++ 17 对这些操作的顺序施加了约束,在这种情况下,确保每个对new()调用都必须立即跟随着相应智能指针的构造,而在其中没有其他操作之间。这意味着,在调用第二个new() ,可以确保第一个对象已经包装在其智能指针中,从而防止在引发异常的情况下发生任何泄漏。

Barry 在另一个答案中提供了对 C ++ 17 引入的新评估顺序的更详细说明。

感谢@Remy Lebeau指出,这在 C ++ 17 下仍然是一个问题(尽管更少): shared_ptr构造函数可能无法分配其控制块并抛出,在这种情况下,传递给它的指针不会被删除。

解决方案:使用make_shared

在很大程度上,这是将自己的弱点提升为一般规则的人。使用new运算符创建对象本身没有错。有一个论点是,您必须遵循一定的纪律:如果创建一个对象,则需要确保该对象将被销毁。

最简单的方法是在自动存储中创建对象,因此 C ++ 知道在对象超出范围时会销毁它:

{
    File foo = File("foo.dat");

    // do things

 }

现在,请注意,当您在尾括号后掉落该块时, foo不在范围内。 C ++ 将自动为您调用其 dtor。与 Java 不同,您无需等待 GC 找到它。

你写过吗

{
     File * foo = new File("foo.dat");

您可能想要明确地将其与

delete foo;
  }

甚至更好,将您的File *分配为 “智能指针”。如果您不小心,可能会导致泄漏。

答案本身就做出了错误的假设,即如果您不使用new那么就不会在堆上进行分配。实际上,在 C ++ 中您并不知道。最多,您知道在堆栈上肯定分配了少量内存,例如一个指针。但是,请考虑 File 的实现是否类似于

class File {
    private:
      FileImpl * fd;
    public:
      File(String fn){ fd = new FileImpl(fn);}

那么FileImpl 仍将分配在堆栈上。

是的,您最好确保拥有

~File(){ delete fd ; }

在课堂上没有它,即使您显然根本没有在堆上分配内存,您也会从堆中泄漏内存。

new()不应被尽可能地使用。应该尽可能小心地使用它。而且,应根据实用主义的需要尽可能多地使用它。

依赖于对象的隐式破坏,在堆栈上分配对象是一个简单的模型。如果对象的要求范围适合该模型,则无需使用new()以及关联的delete()和 NULL 指针检查。如果您在堆栈上分配了许多短期对象,则应减少堆碎片的问题。

但是,如果对象的生存期需要扩展到当前范围之外,则new()是正确的答案。只要确保您注意何时,如何调用delete()以及使用已删除对象以及使用指针附带的所有其他陷阱的 NULL 指针的可能性即可。

使用 new 时,会将对象分配给堆。通常在预期扩展时使用。当您声明诸如

Class var;

它放在堆栈上。

您将始终必须用 new 调用放置在堆上的对象的 destroy。这为内存泄漏打开了可能。放置在堆栈上的对象不容易发生内存泄漏!