简体   繁体   中英

new[] / delete[] and throwing constructors / destructors in C++

What happens, in the following code, if construction / destruction of some array element throws?

X* x = new X[10]; // (1)
delete[] x;       // (2)

I know that memory leaks are prevented, but additionally:

  1. Ad (1), are the previously constructed elements destructed? If yes, what happens if destructor throws in such a case?

  2. Ad (2), are the not-yet-destructed elements destructed? If yes, what happens if destructor throws again?

  1. Yes, if the constructor of x[5] throws, then the five array elements x[0]..x[4] already successfully constructed will be destroyed correctly.

    • Destructors should not throw. If a destructor does throw, this happens while the previous (constructor) exception is still being handled. As nested exceptions aren't supported, std::terminate is called immediately. This is why destructors shouldn't throw.
  2. There are two mutually-exclusive options here:

    1. If you reach label (2) , the constructor didn't throw. That is, if x was successfully created, all ten elements were successfully constructed. In this case, yes, they all get deleted. No, your destructor still shouldn't throw.

    2. If the constructor threw part-way through step (1) , then the array x never really existed . The language tried to create it for you, failed, and threw an exception - so you don't reach (2) at all.

The key thing to understand is that x either exists - in a sane and predictable state - or it doesn't.

The language doesn't give you some un-usable half-initialized thing, if a constructor failed, because you couldn't do anything with it anyway. (You couldn't even safely delete it, because there would be no way to track which of the elements were constructed, and which were just random garbage).

It might help to consider the array as an object with ten data members. If you're constructing an instance of such a class, and one of the base-class or member constructors throws, all the previously-constructed bases and members are destroyed in exactly the same way and your object never starts existing.

We can test with the following code:

#include <iostream>

//`Basic` was borrowed from some general-purpose code I use for testing various issues 
//relating to object construction/assignment
struct Basic {
    Basic() { 
        std::cout << "Default-Constructor" << std::endl; 
        static int val = 0;
        if(val++ == 5) throw std::runtime_error("Oops!");
    }
    Basic(Basic const&) { std::cout << "Copy-Constructor" << std::endl; }
    Basic(Basic &&) { std::cout << "Move-Constructor" << std::endl; }
    Basic & operator=(Basic const&) { std::cout << "Copy-Assignment" << std::endl; return *this; }
    Basic & operator=(Basic &&) { std::cout << "Move-Assignment" << std::endl; return *this; }
    ~Basic() noexcept { std::cout << "Destructor" << std::endl; }
};

int main() {
    Basic * ptrs = new Basic[10];
    delete[] ptrs;
    return 0;
}

This code yields the following output before crashing:

Default-Constructor
Default-Constructor
Default-Constructor
Default-Constructor
Default-Constructor
Default-Constructor
[std::runtime_error thrown and uncaught here]

Note that at no point were the Destructors called. This isn't necessarily a critical thing, since an uncaught exception will crash the program anyways. But if we catch the error, we see something reassuring:

int main() {
    try {
        Basic * ptrs = new Basic[10];
        delete[] ptrs;
    } catch (std::runtime_error const& e) {std::cerr << e.what() << std::endl;}
    return 0;
}

The output changes to this:

Default-Constructor
Default-Constructor
Default-Constructor
Default-Constructor
Default-Constructor
Default-Constructor
Destructor
Destructor
Destructor
Destructor
Destructor
Oops!

So Destructors will be automatically called for fully constructed objects, even without an explicit delete[] call, because the new[] call has handling mechanisms to deal with this.

But you do have to worry about that sixth object: in our case, because Basic doesn't do any resource management (and a well-designed program wouldn't have Basic do resource management if its constructor could throw like this), we don't have to worry. But we might have to worry if our code looks like this instead:

#include <iostream>

struct Basic {
    Basic() { std::cout << "Default-Constructor" << std::endl; }
    Basic(Basic const&) { std::cout << "Copy-Constructor" << std::endl; }
    Basic(Basic &&) { std::cout << "Move-Constructor" << std::endl; }
    Basic & operator=(Basic const&) { std::cout << "Copy-Assignment" << std::endl; return *this; }
    Basic & operator=(Basic &&) { std::cout << "Move-Assignment" << std::endl; return *this; }
    ~Basic() noexcept { std::cout << "Destructor" << std::endl; }
};

class Wrapper {
    Basic * ptr;
public:
    Wrapper() : ptr(new Basic) { 
        std::cout << "WRDefault-Constructor" << std::endl;
        static int val = 0;
        if(val++ == 5) throw std::runtime_error("Oops!");
    }
    Wrapper(Wrapper const&) = delete; //Disabling Copy/Move for simplicity
    ~Wrapper() noexcept { delete ptr; std::cout << "WRDestructor" << std::endl; }
};

int main() {
    try {
        Wrapper * ptrs = new Wrapper[10];
        delete[] ptrs;
    } catch (std::runtime_error const& e) {std::cout << e.what() << std::endl;}
    return 0;
}

Here, we get this output:

Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Destructor
WRDestructor
Destructor
WRDestructor
Destructor
WRDestructor
Destructor
WRDestructor
Destructor
WRDestructor
Oops!

The large block of Wrapper objects will not leak memory, but the sixth Wrapper object will leak a Basic object because it was not properly cleaned up!


Fortunately, as is usually the case with any resource-management scheme, all these problems go away if you use smart pointers:

#include <iostream>
#include<memory>

struct Basic {
    Basic() { std::cout << "Default-Constructor" << std::endl; }
    Basic(Basic const&) { std::cout << "Copy-Constructor" << std::endl; }
    Basic(Basic &&) { std::cout << "Move-Constructor" << std::endl; }
    Basic & operator=(Basic const&) { std::cout << "Copy-Assignment" << std::endl; return *this; }
    Basic & operator=(Basic &&) { std::cout << "Move-Assignment" << std::endl; return *this; }
    ~Basic() noexcept { std::cout << "Destructor" << std::endl; }
};

class Wrapper {
    std::unique_ptr<Basic> ptr;
public:
    Wrapper() : ptr(new Basic) { 
        std::cout << "WRDefault-Constructor" << std::endl;
        static int val = 0;
        if(val++ == 5) throw std::runtime_error("Oops!");
    }
    //Wrapper(Wrapper const&) = delete; //Copy disabled by default, move enabled by default
    ~Wrapper() noexcept { std::cout << "WRDestructor" << std::endl; }
};

int main() {
    try {
        std::unique_ptr<Wrapper[]> ptrs{new Wrapper[10]}; //Or std::make_unique
    } catch (std::runtime_error const& e) {std::cout << e.what() << std::endl;}
    return 0;
}

And the output:

Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Default-Constructor
WRDefault-Constructor
Destructor
WRDestructor
Destructor
WRDestructor
Destructor
WRDestructor
Destructor
WRDestructor
Destructor
WRDestructor
Destructor
Oops!

Note that the number of calls to Destructor now match the number of calls to Default-Constructor , which tells us that the Basic objects are now getting properly cleaned up. And because the resource management that Wrapper was doing has been delegated to the unique_ptr object, the fact that the sixth Wrapper object doesn't have its deleter called is no longer a problem.

Now, a lot of this involves strawmanned code: no reasonable programmer would ever have a resource manager throw without proper handling code, even if it were made "safe" by use of smart-pointers. But some programmers just aren't reasonable, and even if they are, it's possible you might come across a weird, exotic scenario where you have to write code like this. The lesson, then, as far as I'm concerned, is to always use smart pointers and other STL objects to manage dynamic memory. Don't try to roll your own. It'll save you headaches exactly like this when trying to debug things.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM