关于C++析构函数

7

我有一些Java经验,但是在C++方面还是初学者。

以下是我的代码,它的输出结果如下:

0 1 2 3 4 5 6 7 8 9
destructor ---s1
8791616 8785704 2
destructor ---s1

我期望得到以下输出:
0 1 2 3 4 5 6 7 8 9
destructor ---abc
0 1 2
destructor ---s1

我不明白为什么析构函数会释放第一个对象的资源。 我该如何打印出我期望的输出?

#include <iostream>
using namespace std;
class Sequence{
    public:
        Sequence(int count=10,string name = "abc");
        void show();
        ~Sequence();

        int* _content;
        int _count;
        string _name;

};

Sequence::Sequence(int count,string name){
    _count = count;
    _content=new int[count];
    _name = name;
    for(int i=0;i<count;i++){
        _content[i]=i;
    }
}

Sequence::~Sequence(){
    cout << "destructor ---"<<_name<<endl;
    delete [] _content;
}

void Sequence::show(){
    for(int i=0;i<_count;i++)
        cout<<_content[i]<<" ";
    cout<<endl;
}

int main(){
    Sequence s1 = Sequence();
    s1.show();
    s1 = Sequence(3,"s1");
    s1.show();
}

5
如果您是C++的初学者,请考虑阅读一本好的入门级C++书籍,比如这个网址上列出的内容。这些书籍将涵盖像这样的问题以及更多内容。 - In silico
只是出于好奇问一下...为什么要从Java转向C++?有具体的原因吗? - Arunmu
2
谢谢,我正在读一本关于C++的书,但我并不是从Java转向C++。学习C++只是出于我的兴趣。 - zhangcheng
2
对于初学者,我建议学习C++而不是一些带有对象的C语言混合体。例如:不要使用动态分配数组,而是使用std::vector。声音、正确、高效:你想要的任何东西 :) - Matthieu M.
6个回答

6
如果您增加编译器的警告级别,您将得到一个提示,表明您的类包含指针但未定义Sequence(const Sequence&)operator=(const Sequence&)(参见什么是三法则?)。
由于您没有提供复制构造函数或赋值运算符,编译器会为您提供这些函数,执行成员逐一赋值。
当您调用s1 = Sequence(3,"s1");时,您正在执行以下操作(这可能对Java开发人员来说是意料之外的):
  • 创建一个新的临时序列,长度为3,名称为“s1”
  • 将其分配给s1,这将:
    • si._content设置为指向刚刚创建的新数组中的三个int的指针,泄漏了长度为10的旧数组。
    • si._count设置为3
    • si._name设置为"s1"
  • 临时对象(而不是 s1)然后被销毁(在上面的实际输出中,您看到“s1”被销毁两次),留下_content指向已释放的内存(这就是为什么第二次调用s1.show()时会看到垃圾内容的原因)。
如果您声明一个赋值运算符,如下所示,则可以得到更接近预期的输出结果:
Sequence& operator =(const Sequence& rhs)
{
    if (this != &rhs)
    {
        delete [] _content;

        _count = rhs._count;
        _content = new int[_count];
        _name = rhs._name + " (copy)";
        for (int i = 0; i < _count ; ++i)
        {
            _content[i] = rhs._content[i];
        }
    }
    return *this;
}

然而,您不会看到以下内容:

destructor ---abc

因为当_name包含"abc"时,您没有销毁s1。在结束的}处,s1超出作用域而被销毁,这就是为什么您会看到第二个析构函数调用。使用您的代码,这将在第二次调用delete[]时调用s1._content(您会记得它在临时变量下已被删除)。这很可能导致程序在最后崩溃。
我在我的赋值运算符中添加了"(copy)"来帮助说明这里发生了什么。
请还要查看什么是复制和交换惯用语?,这是处理具有原始指针类的非常简洁的方法。这也会生成您所需的输出,因为具有_name"abc"s1实例会被swap并销毁。我在这里实现了这个功能,还进行了一些其他小改进,以便您可以看到它的工作方式。 注意:创建类的实例的规范方式是:
Sequence s1; // Default constructor. Do not use parentheses [http://www.parashift.com/c++-faq-lite/ctors.html#faq-10.2]!
Sequence s2(3, "s2") // Constructor with parameters

你认为析构函数什么时候被调用?在结尾还是在 s1 = Sequence(3,"s1"); 过程中? - Shash316
2
在C++中,栈分配的对象会在超出其作用域时被销毁。这意味着,如果它们被声明为变量,则在声明它们的块结束时被销毁;如果它们是匿名/临时对象,则在创建它们的语句结束时被销毁。 - fluffy
2
根据我的回答,无名临时变量在赋值后立即被销毁。 s1 在作用域结束时被销毁。 - johnsyweb
其他答案说,当执行“Sequence s1 = Sequence();”时会创建一个临时对象,并且在执行“s1 = Sequence(3, "s1");”之前将销毁该临时对象,析构函数将被调用三次。那么,当执行“Sequence s1 = Sequence();”时,是否真的会创建一个临时对象呢? - zhangcheng
1
@zhangcheng:好问题!Sequence s1 = Sequence();很可能被优化为Sequence s1,这就是为什么你可能没有看到第三次调用析构函数的原因。 - johnsyweb

3

C++对象与Java对象十分不同,对于C++新手而言,您可能会遇到一些常见的困惑。以下是发生的情况:

Sequence s1 = Sequence();

这将创建一个新的序列s1,使用默认构造函数(注:至少在上面的输出中是这样,尽管正如几位评论者指出的那样,这完全可以创建一个临时序列,然后通过复制构造函数将其分配给s1)。

s1.show();

这将打印出s1上的数据。

s1 = Sequence(3,"s1");

这里的事情有点令人困惑。在这种情况下,会发生以下事情:
  1. 使用参数3,“s1”构建一个新的匿名Sequence对象
  2. 通过操作符=(复制运算符),将该匿名对象按值复制到s1中
  3. 匿名Sequence对象超出作用域并被删除
接下来,最后一部分...
s1.show();

再次在原始s1对象上调用show()函数,但是现在它的数据是匿名数据的副本。

最后,s1超出作用域并被删除。

如果你想要更像Java对象的行为,你需要将它们处理为指针,例如:

Sequence *s1 = new Sequence();  // constructor
s1->show();  // calling a method on a pointer
delete s1;  // delete the old one, as it is about to be assigned over
s1 = new Sequence(3,"s1");  // assign the pointer to a new Sequence object
s1->show();
delete s1;

如果你想让内存管理变得更加容易一些,可以考虑使用boost::shared_ptr。它提供了基于引用计数的自动内存管理,而不是垃圾回收。


-1:Sequence s1 = Sequence()并没有使用s1的默认构造函数,它构造了一个临时对象并使用了s1的复制构造函数。由于s1没有定义复制构造函数,因此它进行了逐字节的复制,然后调用了临时对象的析构函数。 - Seth Carnegie
1
这完全是不正确的,甚至可以通过这个例子来证明,如果是这样的话,那么析构函数将会在匿名临时对象被销毁时再次打印出来。在C++中,MyType foo(bar)和MyType foo=MyType(bar)是等价的,这是一个特殊情况。 - fluffy
4
@fluffy: 未必如此。从技术上讲,可能会产生临时对象,但大多数编译器都会对其进行优化。这并不是一般情况。 - In silico
啊,谢谢,我想答案比我想象的更加复杂。不过,总的来说,这似乎只是我的答案中一个相对较小的问题。 - fluffy
@fluffy:我仍然会避免动态分配内存,来自Java的人应该被告知不要使用“new”来创建对象。 - Matthieu M.
很难知道从哪里开始回答这个问题,以一种有用、正确且不会让提问者尖叫着逃跑的方式。我决定采取一个小步骤的方法。也许唯一的胜利之举就是不去参与。 - fluffy

2

尽可能简单:

Sequence s1 = Sequence() :默认构造的序列(非拷贝构造),没有临时变量,也没有调用析构函数。

s1.show() :打印s1._content中的值。

s1 = Sequence(3,"s1"); :创建一个临时变量,使用隐式拷贝构造函数将值赋给s1。删除临时变量,调用析构函数,从而使s1和临时变量中的指针(_content)无效。

s1.show() :未定义行为,因为它正在从无效指针中打印。

然后当s1超出范围时,它会尝试删除s1._content;更多未定义的行为。


1

这行代码:

Sequence s1 = Sequence();

构造一个临时对象,并使用Sequence的复制构造函数将其复制到s1。然后调用临时对象的析构函数。由于您没有编写复制构造函数,因此将匿名对象成员的字节复制到新对象s1中。然后临时对象超出范围并调用析构函数。析构函数打印名称并删除内存,s1也拥有该内存,因此现在s1拥有一些已删除的内存。

然后您执行

s1 = Sequence(3,"s1");

使用赋值运算符将匿名Sequence分配给s1。同样,在这里,匿名对象超出作用域时,析构函数被调用,而s1仍然拥有指向已销毁内存的指针。

为了解决这个问题,你需要定义一个拷贝构造函数和一个赋值运算符:

Sequence::Sequence(const Sequence& rhs) : _name(rhs._name), _count(rhs._count), _content(new int[_count]) {
    for (int i = 0; i < _count; ++i)
        _content[i] = rhs._content[i];
}

Sequence& operator=(const Sequence& rhs) {
    if (&rhs != this) {
        delete[] _content;
        _count = rhs._count;
        _name = rhs._name;

        _content = new int[_count];

        for (int i = 0; i < _count; ++i)
            _content[i] = rhs._content[i];
    }

    return *this;
}

这是因为当你复制一个Sequence时,新的Sequence需要创建一个新的内存块,并将旧的Sequence的所有数据复制到新的内存块中,而不是复制旧的Sequence所持有的指针(并指向同一块内存)。

在这段代码中可能有几个新概念,所以请仔细研究,并在你不理解的时候提出问题。


如果在s1=Sequence(3,"s1")之前调用了临时对象的析构函数,为什么析构函数没有打印“destructor ---abc”? - zhangcheng
1
@zhangcheng 大多数编译器都会优化复制过程。然而,在某些情况下,它们可能不会这样做,所以请确保您始终定义“大三”:缺省构造函数、复制构造函数和赋值运算符。 - Seth Carnegie
1
谢谢,我困惑的是是否应该总是定义“大三”,因为这与Java不同,现在我知道答案是肯定的。 - zhangcheng
2
@zhangcheng:只有包含指针的类需要定义拷贝构造函数、赋值运算符和析构函数。对于大多数类型来说,由编译器生成的默认版本已经足够了。同时,还要了解RAII和单一职责原则的相关知识。 - Ben Voigt
@Ben,更准确地说,“任何包含任何非自动管理资源的类都需要定义一个...”是不是更准确一些呢?关于指针的说法是正确的,因为指针占据了“非自动管理资源”类别的很大部分。 - Seth Carnegie
1
@Seth:我不会使用“托管资源”这个术语,因为它已经在像Java和C#这样的语言中有了意义。但是,是的,诸如本地操作系统文件描述符之类的东西应该以相同的方式进行封装。如果他按照我建议的阅读RAII和SRP的相关内容,所有这些都应该得到涵盖。 - Ben Voigt

1
Sequence s1 = Sequence();

这将创建两个Sequence对象。第一个是通过Sequence()创建的。第二个是通过复制构造函数Sequence s1创建的。换句话说,这相当于:

const Sequence &temp = Sequence();
Sequence s1 = temp;

Sequence s1 不会创建一个对象的引用。它创建了一个完整的对象。你可以这样做:

Sequence s1;
s1.show();

这很好。

如果你想调用非默认构造函数,只需这样做:

Sequence s2(3,"s1");

为了理解问题的根源,请回顾一下这个版本:
const Sequence &temp = Sequence();
Sequence s1 = temp;

你创建了一个"Sequence"对象。这将导致构造函数使用"new"分配一个数组。没问题。
第二行将临时的"Sequence"对象复制到"s1"中。这被称为“复制赋值”。
由于你没有定义复制赋值运算符,这意味着C++将使用默认的复制算法。这个算法只是简单地进行字节复制(也触发了类成员的复制赋值)。所以,"Sequence"的构造函数没有被调用,而是从临时的"temp"中将数据复制到它里面。
这就是问题所在。在你原始的代码中,使用"Sequence()"创建的临时对象?当该语句结束时,它就会被销毁。它存在的时间足够长,以便将其内容复制到"s1"中,然后它就被销毁了。
销毁意味着调用了它的析构函数。析构函数将删除数组。
现在想想发生了什么。临时对象出现并分配了一个数组。将指向这个数组的指针复制到"s1"中。然后临时对象被销毁,导致数组被释放。

这意味着s1现在持有一个指向已释放数组的指针。这就是为什么裸指针在C++中不好用。使用std::vector代替。

另外,不要像那样使用复制初始化。如果你只想要一个Sequence s1,简单地创建它:

Sequence s1;

1
提问者对C++还很陌生,这已经是一个非常复杂的主题了。过于严谨只会让他更加困惑。我试图以尽可能少的意外概念来解释它,让他能够理解。哦,而在C++中创建一个对临时变量的const引用是完全合法的,即使在C++98中也是如此。 - Nicol Bolas
他已经有了不同于他所想的函数被调用。关键是让他停止使用Java风格的编程,并解释为什么像那样的对象中裸指针是不好的。而不是解释什么是复制构造函数,什么是复制赋值,它们何时被调用,当编译器将构造替换为赋值时以及其他他根本没有准备好的深奥的C++主义。一个好老师的标志就是知道什么是不该说的。 - Nicol Bolas
1
一个好老师的另一个标志是以不会引起混淆的方式简化事物。也许你可以想出另一个例子,我一定会在修正后撤销我的踩票。 - Seth Carnegie
+1 是因为这是最清晰的答案;虽然正确,但我仍然认为对于新手来说,“Sequence s1 = temp”是解释概念的好方法,即使语法不正确。 - deceleratedcaviar
@Nicol:它们并不等价,因为在 Sequence s1 = Sequence(); 中,临时对象(如果没有被优化掉)会在语句结束时被销毁。但是在 const Sequence& temp = Sequence(); Sequence s1(temp); 中,根据反向构造规则,temp 会在 s1 之后被销毁。 - Ben Voigt
显示剩余8条评论

1

让我解释一下在你的主函数中发生了什么:

Sequence s1 = Sequence();

执行这一行代码后发生了几件事情:

  1. 使用默认构造函数创建了s1。
  2. 右侧的Sequence()也创建了一个未命名的临时序列对象,其使用默认构造函数。
  3. 使用编译器提供的默认operator=函数将临时对象复制到s1中。因此,s1的每个成员字段都包含与临时对象相同的值。请注意,_content指针也被复制,因此s1._content指向为临时对象的_content指针动态分配的数据。
  4. 然后,由于超出了其范围,临时对象被销毁。这导致对临时对象的_content指针进行内存释放。但是,由于在第3点中提到,s1._content指向此内存块,因此此释放会导致s1._content现在指向已经释放的内存块,这意味着您在此内存块中得到了垃圾数据。

因此,此时您的输出窗口应显示:destructor ---abc

s1.show(); this shows the garbage data to the output window:

-572662307 -572662307 -572662307 -572662307 -572662307 -572662307 -572662307 -57 2662307 -572662307 -572662307

同样地,s1 = Sequence(3,"s1");也创建了一个临时对象,并将所有数据复制到s1中。现在s1._name是"s1",s1._count为3,s1._content指向分配给临时对象的_content指针的内存块。

到这时,您将会有:

destructor ---abc  // first temp object
-572662307 -572662307 -572662307 -572662307 -572662307 -572662307 -572662307 -57
2662307 -572662307 -572662307  // first s1.show()
destructor ---s1  // second temp object

由于同样的原因,第二个s1.show()也会给你垃圾数据,但计数为3。
当所有这些都完成后,在主函数结束时,s1对象被销毁。这将导致问题,即您正在尝试删除已经被释放的内存(在第二个临时对象的析构函数中已经被删除)。
您看到不同输出的原因可能是您的编译器足够“聪明”,可以消除具有默认复制构造函数的临时对象的构造。
希望这可以帮助您。

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