为什么不在C++中全部使用指针?

76

假设我定义了一个类:

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;

它们基本上做同样的事情,对吧? 一个在堆栈上,另一个在堆上,所以我需要稍后将其删除。它们之间有任何基本区别吗?为什么我应该更喜欢其中一个而不是另一个?

23个回答

187

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

  • 首先,显而易见但不那么重要的是:堆分配很慢。 栈分配很快。
  • 其次,更重要的是 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永远不会被释放,如果像素被复制,我们最终会得到两个实例尝试删除相同数据的情况。因此请谨慎对待这里的最终示例。实际代码有些棘手,但它展示了一般思想)
当然,相同的技术可以扩展到其他资源,如文件或数据库连接,以确保在使用后关闭它们,或者用于释放线程代码的同步锁。

5
虽然每100行代码中出现一个漏洞/错误有些过多,也许可以改为每1000行代码中出现一个漏洞/错误。 - Milan Babuškov
10
面对例外情况,我会说100更接近于1000。 - mghie
5
是的,你很可能能够在前500行代码中没有泄漏任何信息。但当你增加另外100行代码时,同一个函数中会包含6种不同的泄漏数据方式。虽然我没有进行过测量,但听起来很有道理。 :) - jalf
3
@Matt:真的吗?如果不使用异常,就不需要担心内存管理?这对我来说是新闻。我想许多C程序员也希望他们早知道这一点。我相信只要没有异常,许多用C编写的大型软件项目可以显著简化,因为此时无需管理内存。 - jalf
3
@Matt:我并没有误解(misinterpret)它们,而是有意诠释(interpreting)它们。在看到你在我所有回答中留下的一系列评论后,很明显它们的价值不高。无论如何,我在我的帖子中没有看到任何“过度使用的样板”(obsessive boilerplate)。我也没有看到任何旨在保护特性的内容。我看到一个非常简单的习语被用来编写非常简单、易于使用的代码。如果没有它,客户端代码将变得更加复杂且更加脆弱,而类本身的实现可能只能节省几行代码。 - jalf
显示剩余8条评论

30

只有在添加删除(delete)后它们才是相同的。
虽然您的示例过于简单,但析构函数实际上可能包含执行一些真正工作的代码。这被称为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.

还有其他的。


9
我喜欢将C++文件的使用与Java进行比较(这让我感到开心)。 - Martin York
2
同意。而且这显示了RAII被用来管理除内存分配之外的其他类型资源,这是额外加分的。 - jalf

25

逻辑上它们是一样的——除了清理工作。你编写的指针示例代码存在内存泄漏,因为这块内存没有被释放。

如果你来自Java背景,可能没有完全准备好C++中涉及跟踪已分配的内容以及谁负责释放它的事实。

在适当的情况下使用栈变量,你就不必担心释放该变量,它随着堆栈帧消失。

显然,如果你非常小心,总是可以在堆上分配并手动释放,但良好的软件工程的一部分是以这样的方式构建东西,使它们不会破裂,而不是依赖于你超人的程序员技能永远不会犯错。


24

我喜欢尽可能使用第一种方法,因为:

  • 它更快
  • 我不必担心内存释放
  • p将在当前作用域的整个期间保持有效

14

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

简单的答案是,这会成为一个管理内存分配和释放的巨大问题。

自动/堆栈对象可以减少一些繁琐的工作。

这只是我对这个问题的第一个看法。


11

这段代码:

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

此函数不会动态分配内存——没有搜寻可用内存、更新内存使用情况等操作。 它完全自由。编译器在编译时为变量在栈上保留空间——它计算需要保留多少空间并创建一个单一的操作码,将堆栈指针移动所需的量。

使用new关键字需要进行所有的内存管理开销。

问题在于:您想要使用堆栈空间还是堆空间来存储数据。像“p”这样的堆栈(或本地)变量不需要解引用,而使用new则添加了一层间接性。


11

一个很好的经验法则是,除非你绝对必须使用new,否则不要使用它。如果你不使用new,程序将更易于维护,错误率也会降低,因为你不必担心清理的位置。


11

是的,如果你来自Java或C#背景,那么一开始这样做似乎很有道理。记得释放你分配的内存似乎不是什么大问题。但是当你遇到第一个内存泄漏时,你会摇头苦笑,因为你发誓已经释放了所有东西。第二次和第三次发生时,你会更加沮丧。最终,在头痛了六个月后,由于内存问题而开始感到厌倦,然后堆栈分配的内存将变得越来越具有吸引力。多么美好而干净--只需将它放在堆栈上并忘记它。很快你就可以尽可能地使用堆栈。

但是--没有替代那种经验。我的建议?现在尝试你的方式。你会看到。


6
你忘记提到它的邪恶孪生体——双重释放。当你认为你已经释放了所有内存时,你会开始收到错误消息,因为你在使用已经被释放的内存,或者你尝试释放已经被释放的内存。 - jalf

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)

在新建文件的情况下,你必须记得删除它以获得相同的行为。在上述情况下似乎是一个简单的问题。然而,考虑更复杂的代码,例如将指针存储到数据结构中。如果将该数据结构传递给另一段代码呢?谁负责清理?谁会关闭你的所有文件?
当你不使用new时,资源只会在变量超出范围时由析构函数清理。因此,你可以更有信心地确保资源被成功清理。
这个概念被称为RAII——资源分配即初始化,它可以极大地提高你处理资源获取和处理的能力。

6
我的直觉告诉我,这样做可能会导致严重的内存泄漏。在某些情况下,您可能会使用指针,这会导致对谁负责删除它们的混淆。在像您的示例这样简单的情况下,很容易看出何时以及在哪里调用delete,但是当您开始在类之间传递指针时,情况可能会变得更加困难。
我建议您查看boost智能指针库,以便更好地处理指针:smart pointers library

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接