内存管理 - 为什么C++程序员应该尽量减少"new"的使用?

memory management - Why should C++ programmers minimize use of 'new'?

本文关键字:new 为什么 管理 C++ 程序员 内存      更新时间:2023-10-16

我偶然发现了堆栈溢出问题 使用 std::list<std::string> 时带有 std::string 的内存泄漏,其中一条评论是这样说的:

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

我不太确定他的意思。

为什么要尽可能频繁地按

C++中的值创建对象,它在内部有什么区别?我是否误解了答案?

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

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

在C++中,这称为自动存储,因为存储是在范围结束时自动声明的。 一旦当前代码块(使用 {} 分隔(的执行完成,就会自动收集该块中所有变量的内存。 这也是调用析构函数来清理资源的时刻。

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

使用动态分配的原因

即使使用堆的速度较慢,并可能导致内存泄漏或内存碎片,动态分配也有非常好的用例,因为它的限制较少。

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

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

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

为什么动态分配通常是不必要的

在C++中,有一个简洁的结构称为析构函数。 此机制允许您通过将资源的生存期与变量的生存期对齐来管理资源。这种技术称为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 d,以免它们泄漏。析构函数不会被调用,内存不会被释放,整个位。由于C++没有垃圾回收,所以这是一个问题。

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

unique_ptr这样的智能指针shared_ptr解决悬而未决的引用问题,但它们需要编码纪律并存在其他潜在问题(可复制性、引用循环等(。

此外,在高度多线程的方案中,new是线程之间的争用点;过度使用new可能会影响性能。根据定义,堆栈对象的创建是线程本地的,因为每个线程都有自己的堆栈。

值对象的缺点是,一旦主机函数返回,它们就会死亡 - 您不能通过复制、返回或按值移动来将对这些对象的引用传递回调用者。

  • C++本身不使用任何内存管理器。其他语言(如 C# 和 Java(具有垃圾回收器来处理内存
  • C++实现通常使用操作系统例程来分配内存,过多的新建/删除可能会使可用内存碎片化
  • 对于任何应用程序,如果内存经常被使用,建议预先分配内存并在不需要时释放。
  • 内存管理不当可能会导致内存泄漏,并且很难跟踪。因此,在函数范围内使用堆栈对象是一种经过验证的技术
  • 使用堆栈对象的缺点是,它在返回、传递给函数等时创建对象的多个副本。但是,智能编译器非常了解这些情况,并且它们已针对性能进行了很好的优化。
  • 如果在两个不同的地方分配和释放内存,C++真的很乏味。发布的责任始终是一个问题,我们主要依赖于一些通常可访问的指针、堆栈对象(最大可能(和auto_ptr(RAII 对象(等技术
  • 最好的事情是,您可以控制内存,最糟糕的是,如果我们对应用程序使用不正确的内存管理,您将无法控制内存。由于内存损坏而导致的崩溃是最肮脏且难以追踪的。

我看到尽可能少地做新的事情有几个重要原因被遗漏了:

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

调用new可能会导致也可能不会导致操作系统为您的进程分配新的物理页面。如果您经常这样做,这可能会很慢。或者它可能已经准备好了合适的内存位置;不知道。如果您的程序需要具有一致且可预测的执行时间(例如在实时系统或游戏/物理模拟中(,则需要避免时间关键循环中的new

运算符new是隐式线程同步

是的,你听到了我的声音。您的操作系统需要确保页表是一致的,因此调用new将导致您的线程获取隐式互斥锁。如果您始终从许多线程调用new,那么您实际上是在序列化您的线程(我已经用 32 个 CPU 完成了此操作,每个 CPU 都点击 new 以获得每个几百字节,哎哟!那是皇家的p.i.t.a.调试。

其余的,例如缓慢,碎片,容易出错等,已经由其他答案提及。

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++将自动为您调用其析构函数。 与 Java 不同,您无需等待垃圾回收找到它。

你写过吗

 {
     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;

它被放置在堆栈上。

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

避免过度使用堆的一个值得注意的原因是性能 - 特别是涉及C++使用的默认内存管理机制的性能。虽然在微不足道的情况下分配可能非常快,但在没有严格顺序的情况下对大小不均匀的对象进行大量newdelete不仅会导致内存碎片,而且还会使分配算法复杂化,并且在某些情况下绝对会破坏性能。

这就是创建内存池以解决的问题,允许减轻传统堆实现的固有缺点,同时仍然允许您根据需要使用堆。

不过,更好的是完全避免这个问题。如果可以将其放在堆栈上,请这样做。

我倾向于不同意使用新的"太多"的想法。虽然原始海报使用带有系统类的new有点荒谬。( int *i; i = new int[9999]; ? 真的吗? int i[9999];更清楚。我认为这就是获得评论者的山羊的原因。

使用系统对象时,很少需要对完全相同的对象进行多个引用。只要值相同,这才是最重要的。系统对象通常不会占用太多内存空间。(字符串中每个字符一个字节(。如果他们这样做,库的设计应该考虑到内存管理(如果它们写得很好(。在这些情况下(除了他代码中的一两个新闻之外(,new实际上是毫无意义的,只会带来混乱和潜在的错误。

但是,当您使用自己的类/对象(例如原始海报的 Line 类(时,您必须自己开始考虑内存占用、数据持久性等问题。在这一点上,允许对同一值的多个引用是非常宝贵的 - 它允许链表、字典和图形等结构,其中多个变量不仅需要具有相同的值,而且需要引用内存中完全相同的对象。但是,Line 类没有任何这些要求。所以原版海报的代码其实完全不需要new

我认为海报的意思是说您不必将所有内容分配给而不是堆栈

基本上,对象是在堆栈上分配的

(当然,如果对象大小允许的话(,因为堆栈分配的成本很低,而不是基于堆的分配,这涉及分配器相当多的工作,并且增加了冗长,因为这样你必须管理堆上分配的数据。

两个原因:

  1. 在这种情况下,这是不必要的。你正在使你的代码不必要地变得更加复杂。
  2. 它在堆上分配空间,这意味着您必须记住稍后delete它,否则会导致内存泄漏。

new是新的goto

回想一下为什么goto如此受人诟病:虽然它是一个强大的、低级的流量控制工具,但人们经常以不必要的复杂方式使用它,使代码难以理解。此外,最有用和最容易阅读的模式被编码在结构化编程语句中(例如 forwhile(;最终的效果是,goto是适当方式的代码相当罕见,如果你想写goto,你可能做得不好(除非你真的知道你在做什么(。

new是相似的——它通常用于使事情变得不必要的复杂和难以阅读,并且最有用的使用模式可以被编码到各种类中。此外,如果您需要使用任何尚未有标准类的新使用模式,您可以编写自己的类来编码它们!

我什至认为newgoto更糟糕,因为需要将newdelete语句配对。

goto一样,如果你认为你需要使用new,你可能做得很糟糕——特别是如果你在一个类的实现之外这样做,这个类在生活中的目的是封装你需要做的任何动态分配。

许多答案已经涉及各种性能考虑因素。我想解决令OP感到困惑的评论:

不要再像Java程序员那样思考了。

事实上,在Java中,正如对这个问题的回答所解释的那样,

首次显式创建对象时使用 new 关键字。

但在C++中,T 类型的对象是这样创建的:T{}(或T{ctor_argument1,ctor_arg2}为带有参数的构造函数(。这就是为什么通常您没有理由想要使用new

那么,为什么要使用它呢?嗯,有两个原因:

  1. 您需要创建许多值,这些值的数量在编译时是未知的。
  2. 由于普通机器上C++实现的限制 - 通过分配过多空间来防止堆栈溢出,以常规方式创建值。

现在,除了您引用的评论所暗示的内容之外,您应该注意,即使是上述两种情况也得到了很好的涵盖,而您不必自己"诉诸"new

  • 您可以使用标准库中的容器类型,这些容器类型可以容纳运行时可变数量的元素(如 std::vector (。
  • 您可以使用智能指针,它为您提供类似于 new 的指针,但请确保在"指针"超出范围的地方释放内存。

出于这个原因,它是C++社区编码指南中的官方项目,以避免明确的newdelete:指南 R.11。

核心原因是堆上的对象总是比简单值更难使用和管理。编写易于阅读和维护的代码始终是任何认真程序员的首要任务。

另一种情况是,我们使用的库提供值语义,不需要动态分配。 Std::string就是一个很好的例子。

然而,对于面向对象的代码,使用指针(这意味着使用new事先创建指针(是必须的。为了简化资源管理的复杂性,我们有几十种工具使其尽可能简单,例如智能指针。基于对象的范式或泛型范式假定价值语义,需要较少或不需要new,正如其他地方的海报所述。

传统的设计模式,尤其是GoF书中提到的那些,使用new很多,因为它们是典型的OO代码。

以上所有正确答案还有一点,这取决于你正在做什么样的编程。例如,在Windows中开发内核 -> 堆栈受到严重限制,您可能无法像在用户模式下那样处理页面错误。

在此类环境中,首选甚至需要新的或类似 C 的 API 调用。

当然,这只是规则的例外。

new

堆上分配对象。 否则,将在堆栈上分配对象。 查找两者之间的区别。