75

假设我定义了一些类:

class Pixel {
    public:
      Pixel(){ x=0; y=0;};
      int x;
      int y;
}

然后使用它编写一些代码。为什么我要执行以下操作?

Pixel p;
p.x = 2;
p.y = 5;

来自 Java 世界,我总是这样写:

Pixel* p = new Pixel();
p->x = 2;
p->y = 5;

他们基本上做同样的事情,对吧?一个在堆栈上,另一个在堆上,所以我必须稍后将其删除。两者有什么根本区别吗?为什么我应该更喜欢一个而不是另一个?

4

23 回答 23

188

是的,一个在堆栈上,另一个在堆上。有两个重要的区别:

  • 首先,一个明显但不太重要的问题:堆分配很慢。堆栈分配很快。
  • 其次,更重要的是RAII。因为堆栈分配的版本会自动清理,所以很有用。它的析构函数会被自动调用,这使您可以保证该类分配的任何资源都被清理干净。这本质上是避免 C++ 中的内存泄漏的方法。您可以通过从不调用delete自己来避免它们,而是将其包装在delete内部调用的堆栈分配对象中,通常在它们的析构函数中。如果您尝试手动跟踪所有分配,并delete在正确的时间调用,我向您保证每 100 行代码至少会有一次内存泄漏。

作为一个小例子,请考虑以下代码:

class Pixel {
public:
  Pixel(){ x=0; y=0;};
  int x;
  int y;
};

void foo() {
  Pixel* p = new Pixel();
  p->x = 2;
  p->y = 5;

  bar();

  delete p;
}

很无辜的代码,对吧?我们创建一个像素,然后调用一些不相关的函数,然后删除该像素。有内存泄漏吗?

答案是“可能”。如果bar抛出异常会发生什么?delete永远不会被调用,像素永远不会被删除,并且我们会泄漏内存。现在考虑一下:

void foo() {
  Pixel p;
  p.x = 2;
  p.y = 5;

  bar();
}

这不会泄漏内存。当然,在这个简单的例子中,所有东西都在堆栈上,所以它会自动清理,但即使Pixel类在内部进行了动态分配,也不会泄漏。该类Pixel将被简单地赋予一个将其删除的析构函数,并且无论我们如何离开该函数,都会调用该析构foo函数。即使我们因为bar抛出异常而离开它。以下稍微做作的示例显示了这一点:

class Pixel {
public:
  Pixel(){ x=new int(0); y=new int(0);};
  int* x;
  int* y;

  ~Pixel() {
    delete x;
    delete y;
  }
};

void foo() {
  Pixel p;
  *p.x = 2;
  *p.y = 5;

  bar();
}

Pixel 类现在在内部分配了一些堆内存,但它的析构函数负责清理它,所以在使用该类时,我们不必担心它。(我可能应该提到,这里的最后一个例子被简化了很多,以显示一般原理。如果我们要实际使用这个类,它也包含几个可能的错误。如果 y 的分配失败,x 永远不会被释放,如果 Pixel 被复制,我们最终会导致两个实例都试图删除相同的数据。因此,这里的最后一个例子有点不可信。实际代码有点棘手,但它显示了一般的想法)

当然,同样的技术可以扩展到内存分配以外的其他资源。例如,它可以用来保证文件或数据库连接在使用后关闭,或者线程代码的同步锁被释放。

于 2009-06-30T15:34:14.730 回答
30

在您添加删除之前,它们是不同的。
您的示例过于琐碎,但析构函数实际上可能包含执行某些实际工作的代码。这被称为 RAII。

所以添加删除。确保即使在传播异常时也会发生这种情况。

Pixel* p = NULL; // Must do this. Otherwise new may throw and then
                 // you would be attempting to delete an invalid pointer.
try
{
    p = new Pixel(); 
    p->x = 2;
    p->y = 5;

    // Do Work
    delete p;
}
catch(...)
{
    delete p;
    throw;
}

如果您选择了更有趣的东西,例如文件(这是需要关闭的资源)。然后用你需要的指针在 Java 中正确地执行此操作。

File file;
try
{
    file = new File("Plop");
    // Do work with file.
}
finally
{
    try
    {
        file.close();     // Make sure the file handle is closed.
                          // Oherwise the resource will be leaked until
                          // eventual Garbage collection.
    }
    catch(Exception e) {};// Need the extra try catch to catch and discard
                          // Irrelevant exceptions. 

    // Note it is bad practice to allow exceptions to escape a finally block.
    // If they do and there is already an exception propagating you loose the
    // the original exception, which probably has more relevant information
    // about the problem.
}

C++ 中的相同代码

std::fstream  file("Plop");
// Do work with file.

// Destructor automatically closes file and discards irrelevant exceptions.

尽管人们提到了速度(因为在堆上查找/分配内存)。就我个人而言,这对我来说不是决定因素(分配器非常快,并且已经针对不断创建/销毁的小对象的 C++ 使用进行了优化)。

对我来说,主要原因是对象寿命。本地定义的对象具有非常具体且定义明确的生命周期,并且保证在最后调用析构函数(因此可能具有特定的副作用)。另一方面,指针控制具有动态生命周期的资源。

C++ 和 Java 的主要区别在于:

谁拥有指针的概念。所有者有责任在适当的时候删除该对象。这就是为什么您很少在实际程序中看到像这样的原始指针(因为没有与原始指针关联的所有权信息)。相反,指针通常包装在智能指针中。智能指针定义了谁拥有内存以及谁负责清理它的语义。

例子是:

 std::auto_ptr<Pixel>   p(new Pixel);
 // An auto_ptr has move semantics.
 // When you pass an auto_ptr to a method you are saying here take this. You own it.
 // Delete it when you are finished. If the receiver takes ownership it usually saves
 // it in another auto_ptr and the destructor does the actual dirty work of the delete.
 // If the receiver does not take ownership it is usually deleted.

 std::tr1::shared_ptr<Pixel> p(new Pixel); // aka boost::shared_ptr
 // A shared ptr has shared ownership.
 // This means it can have multiple owners each using the object simultaneously.
 // As each owner finished with it the shared_ptr decrements the ref count and 
 // when it reaches zero the objects is destroyed.

 boost::scoped_ptr<Pixel>  p(new Pixel);
 // Makes it act like a normal stack variable.
 // Ownership is not transferable.

还有其他的。

于 2009-06-30T15:50:12.457 回答
25

从逻辑上讲,他们做同样的事情——除了清理。只是您编写的示例代码在指针情况下存在内存泄漏,因为该内存未释放。

来自 Java 背景,您可能还没有完全准备好 C++ 中有多少是围绕跟踪已分配的内容以及谁负责释放它。

通过在适当的时候使用堆栈变量,您不必担心释放该变量,它会随着堆栈帧而消失。

显然,如果你非常小心,你总是可以在堆上分配并手动释放,但好的软件工程的一部分是以不会破坏的方式构建东西,而不是信任你的超人类程序员 -福永不犯错。

于 2009-06-30T15:33:32.097 回答
24

只要有机会,我更喜欢使用第一种方法,因为:

  • 它更快
  • 我不必担心内存释放
  • p 将是整个当前范围的有效对象
于 2009-06-30T15:32:53.547 回答
14

“为什么不在 C++ 中对所有内容都使用指针”

一个简单的答案 - 因为它成为管理内存的一个巨大问题 - 分配和删除/释放。

自动/堆栈对象消除了一些繁忙的工作。

这只是我对这个问题要说的第一件事。

于 2009-06-30T15:33:03.017 回答
11

编码:

Pixel p;
p.x = 2;
p.y = 5;

没有动态分配内存 - 没有搜索空闲内存,没有更新内存使用,什么都没有。它是完全免费的。编译器在编译时为变量在堆栈上保留空间 - 它可以保留很多空间并创建单个操作码以将堆栈指针移动所需的数量。

使用 new 需要所有内存管理开销。

那么问题就变成了——你想为你的数据使用堆栈空间还是堆空间。像 'p' 这样的堆栈(或局部)变量不需要取消引用,而使用 new 会增加一层间接性。

于 2009-06-30T15:34:07.913 回答
11

一个好的一般经验法则是,除非绝对必须,否则永远不要使用 new。如果您不使用 new,您的程序将更容易维护并且更不容易出错,因为您不必担心在哪里清理它。

于 2009-06-30T15:35:25.820 回答
10

是的,起初这是有道理的,来自 Java 或 C# 背景。记住释放分配的内存似乎没什么大不了的。但是当你遇到第一次内存泄漏时,你会摸不着头脑,因为你发誓你释放了一切。然后第二次发生,第三次你会更加沮丧。最后,在因内存问题而头疼六个月之后,您将开始厌倦它,堆栈分配的内存将开始看起来越来越有吸引力。多么漂亮和干净——只要把它放在堆栈上就可以了。很快你就可以随时使用堆栈了。

但是——这种体验是无可替代的。我的建议?试试你的方式,现在。你会看到的。

于 2009-06-30T15:45:35.737 回答
6

我的直觉反应只是告诉你,这可能会导致严重的内存泄漏。在某些情况下,您可能会使用指针,这可能会导致混淆谁应该负责删除它们。在像您的示例这样的简单情况下,很容易看出应该在何时何地调用 delete,但是当您开始在类之间传递指针时,事情会变得更加困难。

我建议您查看 boost智能指针库以获取您的指针。

于 2009-06-30T15:31:43.440 回答
6

不更新所有内容的最好理由是,当堆栈上的东西时,您可以非常确定地进行清理。在 Pixel 的情况下,这不是很明显,但在说文件的情况下,这变得有利:

  {   // block of code that uses file
      File aFile("file.txt");
      ...
  }    // File destructor fires when file goes out of scope, closing the file
  aFile // can't access outside of scope (compiler error)

在新文件的情况下,您必须记住删除它以获得相同的行为。在上述情况下,这似乎是一个简单的问题。然而,考虑更复杂的代码,例如将指针存储到数据结构中。如果您将该数据结构传递给另一段代码会怎样?谁负责清理。谁会关闭你所有的文件?

当您不更新所有内容时,当变量超出范围时,析构函数只会清理资源。因此,您可以更有信心成功清理资源。

这个概念被称为 RAII——资源分配是初始化,它可以极大地提高你处理资源获取和处置的能力。

于 2009-06-30T15:32:59.647 回答
6

第一种情况并不总是堆栈分配。如果它是对象的一部分,它将被分配到对象所在的任何位置。例如:

class Rectangle {
    Pixel top_left;
    Pixel bottom_right;
}

Rectangle r1; // Pixel is allocated on the stack
Rectangle *r2 = new Rectangle(); // Pixel is allocated on the heap

堆栈变量的主要优点是:

  • 您可以使用RAII 模式来管理对象。一旦对象超出范围,就会调用它的析构函数。有点像 C# 中的“使用”模式,但是是自动的。
  • 没有空引用的可能性。
  • 您无需担心手动管理对象的内存。
  • 它导致更少的内存分配。内存分配,尤其是小的内存分配,在 C++ 中可能比 Java 慢。

创建对象后,在堆上分配的对象与在堆栈(或任何地方)上分配的对象之间没有性能差异。

但是,除非您使用指针,否则您不能使用任何类型的多态性 - 对象具有完全静态的类型,这是在编译时确定的。

于 2009-06-30T15:38:07.470 回答
4

我会说这很大程度上与品味有关。如果您创建一个接口允许方法采用指针而不是引用,则您允许调用者传入 nil。由于您允许用户传入 nil,因此用户传入 nil。

由于您必须问自己“如果此参数为 nil 会发生什么?”,您必须更加防御性地编写代码,始终注意空值检查。这说明使用参考。

但是,有时您真的希望能够传入 nil,然后引用就不可能了 :) 指针给您更大的灵活性,让您更懒惰,这真的很好。永远不要分配,直到知道你必须分配!

于 2009-06-30T15:33:03.750 回答
4

对象生命周期。当您希望对象的生命周期超过当前作用域的生命周期时,您必须使用堆。

另一方面,如果您不需要超出当前范围的变量,请在堆栈上声明它。超出范围时会自动销毁。只是要小心传递它的地址。

于 2009-06-30T15:33:57.197 回答
4

问题不是指针本身(除了引入NULL指针),而是手动进行内存管理。

当然,有趣的是,我看到的每个 Java 教程都提到垃圾收集器是如此酷热,因为你不必记住调用delete,而实际上 C++ 只需要delete在你调用时new(以及delete[]当你调用new[])。

于 2009-06-30T18:39:48.260 回答
2

仅在必须时使用指针和动态分配的对象。尽可能使用静态分配的(全局或堆栈)对象。

  • 静态对象更快(没有新/删除,没有间接访问它们)
  • 无需担心对象生命周期
  • 更少的击键次数 更易读
  • 健壮得多。每个“->”都是对 NIL 或无效内存的潜在访问

为了澄清,在这种情况下,“静态”是指非动态分配。IOW,任何不在堆上的东西。是的,它们也可能存在对象生命周期问题——就单例销毁顺序而言——但将它们放在堆上通常不会解决任何问题。

于 2009-06-30T15:35:49.080 回答
2

为什么不对所有事情都使用指针?

他们比较慢。

编译器优化不会像指针访问语义那样有效,您可以在任意数量的网站上阅读它,但这里有一份来自英特尔的不错的 pdf。

检查页面,13、14、17、28、32、36;

在循环符号中检测不必要的内存引用:

for (i = j + 1; i <= *n; ++i) { 
X(i) -= temp * AP(k); } 

循环边界的符号包含指针或内存引用。编译器没有任何方法可以预测指针 n 引用的值是否正在通过其他赋值的循环迭代而改变。这使用循环为每次迭代重新加载 n 引用的值。当发现潜在的指针别名时,代码生成器引擎也可能拒绝调度软件流水线循环。由于指针 n 引用的值在循环内没有变化并且它对循环索引不变,因此 *ns 的加载将在循环边界之外进行,以便更简单的调度和指针消歧。

......这个主题的许多变化......

复杂的内存引用。或者换句话说,分析复杂指针计算等引用会削弱编译器生成高效代码的能力。代码中编译器或硬件执行复杂计算以确定数据所在位置的位置应该是关注的焦点。指针别名和代码简化有助于编译器识别内存访问模式,允许编译器将内存访问与数据操作重叠。减少不必要的内存引用可能会向编译器展示流水线软件的能力。如果内存引用计算保持简单,则可以轻松识别许多其他数据位置属性,例如别名或对齐。

于 2009-07-14T06:24:07.150 回答
1

换个角度看问题。。。

在 C++ 中,您可以使用指针 ( Foo *) 和引用 ( Foo &) 来引用对象。我尽可能使用引用而不是指针。例如,当通过引用传递给函数/方法时,使用引用允许代码(希望)做出以下假设:

  • 引用的对象不属于函数/方法,因此不应属于delete该对象。这就像说,“在这里,使用这些数据,但在完成后将其归还”。
  • NULL 指针引用的可能性较小。可以传递一个 NULL 引用,但至少它不会是函数/方法的错误。无法将引用重新分配给新的指针地址,因此您的代码不会意外地将其重新分配给 NULL 或其他一些无效的指针地址,从而导致页面错误。
于 2009-06-30T16:02:40.103 回答
1

问题是:为什么要对所有内容都使用指针?堆栈分配的对象不仅创建起来更安全、更快,而且打字更少,代码看起来更好。

于 2009-06-30T17:20:32.917 回答
0

我没有看到提到的是内存使用量增加。假设 4 字节整数和指针

Pixel p;

将使用 8 个字节,并且

Pixel* p = new Pixel();

将使用 12 个字节,增加 50%。在您为 512x512 图像分配足够的空间之前,这听起来并不多。然后你说的是 2MB 而不是 3MB。这忽略了使用所有这些对象管理堆的开销。

于 2009-06-30T17:27:48.960 回答
0

在堆栈上创建的对象比分配的对象创建得更快。

为什么?

因为分配内存(使用默认内存管理器)需要一些时间(找到一些空块甚至分配该块)。

此外,您没有内存管理问题,因为堆栈对象在超出范围时会自动销毁自己。

当您不使用指针时,代码会更简单。如果您的设计允许您使用堆栈对象,我建议您这样做。

我自己不会使用智能指针使问题复杂化。

OTOH 我在嵌入式领域做了一些工作,在堆栈上创建对象并不是很聪明(因为为每个任务/线程分配的堆栈不是很大 - 你必须小心)。

所以这是一个选择和限制的问题,没有任何回应可以适应所有这些。

而且,一如既往地不要忘记尽可能简单

于 2009-06-30T20:46:03.810 回答
0

基本上,当您使用原始指针时,您没有 RAII。

于 2009-07-01T14:36:49.303 回答
0

当我是一个新的 C++ 程序员(而且它是我的第一语言)时,这让我很困惑。有很多非常糟糕的 C++ 教程通常似乎属于以下两类之一:“C / C++”教程,这实际上意味着它是 C 教程(可能带有类),以及认为 C++ 是带有删除功能的 Java 的 C++ 教程.

我认为我花了大约 1 到 1.5 年(至少)在我的代码中的任何地方键入“新”。我经常使用像vector这样的STL容器,它为我解决了这个问题。

我认为很多答案似乎要么忽略,要么只是避免直接说出如何避免这种情况。您通常不需要在构造函数中使用 new 进行分配,并在析构函数中使用 delete 进行清理。相反,您可以直接将对象本身粘贴在类中(而不是指向它的指针)并在构造函数中初始化对象本身。然后,在大多数情况下,默认构造函数会完成您需要的一切。

对于几乎所有这不起作用的情况(例如,如果您冒着用完堆栈空间的风险),您可能应该使用标准容器之一:std::string、std::vector 和 std:: map 是我最常用的三个,但 std::deque 和 std::list 也很常见。其他的(像 std::set 和非标准的rod)没有被使用太多,但行为相似。它们都从免费存储中分配(C++ 用语在其他一些语言中表示“堆”),请参阅:C++ STL 问题:分配器

于 2011-11-29T23:29:11.920 回答
-2

除非将更多成员添加到 Pixel 类中,否则第一种情况是最好的。随着越来越多的成员被添加,有可能出现堆栈溢出异常

于 2009-07-01T09:04:19.380 回答