Repeated destructor calls and tracking handles in

2019-01-18 03:48发布

I'm playing around with C++/CLI, using the MSDN documentation and the ECMA standard, and Visual C++ Express 2010. What struck me was the following departure from C++:

For ref classes, both the finalizer and destructor must be written so they can be executed multiple times and on objects that have not been fully constructed.

I concocted a little example:

#include <iostream>

ref struct Foo
{
    Foo()  { std::wcout << L"Foo()\n"; }
    ~Foo() { std::wcout << L"~Foo()\n"; this->!Foo(); }
    !Foo() { std::wcout << L"!Foo()\n"; }
};

int main()
{
    Foo ^ r;

    {
        Foo x;
        r = %x;
    }              // #1

    delete r;      // #2
}

At the end of the block at #1, the automatic variable xdies, and the destructor is called (which in turn calls the finalizer explicitly, as is the usual idiom). This is all fine and well. But then I delete the object again through the reference r! The output is this:

Foo()
~Foo()
!Foo()
~Foo()
!Foo()

Questions:

  1. Is it undefined behavior, or is it entirely acceptable, to call delete r on line #2?

  2. If we remove line #2, does it matter that r is still a tracking handle for an object that (in the sense of C++) no longer exists? Is it a "dangling handle"? Does its reference counting entail that there will be an attempted double deletion?

    I know that there isn't an actual double deletion, as the output becomes this:

    Foo()
    ~Foo()
    !Foo()
    

    However, I'm not sure whether that's a happy accident or guaranteed to be well-defined behaviour.

  3. Under which other circumstances can the destructor of a managed object be called more than once?

  4. Would it be OK to insert x.~Foo(); immediately before or after r = %x;?

In other words, do managed objects "live forever" and can have both their destructors and their finalizers called over and over again?


In response to @Hans's demand for a non-trivial class, you may also consider this version (with destructor and finalizer made to conform to the multiple-call requirement):

ref struct Foo
{
    Foo()
    : p(new int[10])
    , a(gcnew cli::array<int>(10))
    {
        std::wcout << L"Foo()\n";
    }

    ~Foo()
    {
        delete a;
        a = nullptr;

        std::wcout << L"~Foo()\n";
        this->!Foo();
    }

    !Foo()
    {
        delete [] p;
        p = nullptr;

        std::wcout << L"!Foo()\n";
    }

private:
    int             * p;
    cli::array<int> ^ a;
};

2条回答
\"骚年 ilove
2楼-- · 2019-01-18 04:27

Guidelines from standard C++ still apply:

  1. Calling delete on an automatic variable, or one that's already been cleaned up, is still a bad idea.

  2. It's a tracking pointer to a disposed object. Dereferencing such is a bad idea. With garbage collection, the memory is kept around as long as any non-weak reference exists, so you can't access the wrong object by accident, but you still can't use this disposed object in any useful way, since its invariants probably no longer hold.

  3. Multiple destruction can only happen on managed objects when your code is written in really bad style that would have been UB in standard C++ (see 1 above and 4 below).

  4. Explicitly calling the destructor on an automatic variable, then not creating a new one in its place for the automatic destruction call to find, is still a bad idea.

In general, you think think of object lifetime as separate from memory allocation (just like standard C++ does). Garbage collection is used to manage deallocation -- so the memory is still there -- but the object is dead. Unlike standard C++, you can't go and reuse that memory for raw byte storage, because parts of the .NET runtime may assume the metadata is still valid.

Neither the garbage collector nor "stack semantics" (automatic variable syntax) use reference counting.

(Ugly details: disposing an object doesn't break the .NET runtime's own invariants concerning that object, so you can probably even still use it as a threading monitor. But that just makes an ugly hard-to-understand design, so please don't.)

查看更多
混吃等死
3楼-- · 2019-01-18 04:47

I'll just try to address the issues you bring up in order:

For ref classes, both the finalizer and destructor must be written so they can be executed multiple times and on objects that have not been fully constructed.

The destructor ~Foo() simply auto-generates two methods, an implementation of the IDisposable::Dispose() method as well as a protected Foo::Dispose(bool) method which implements the disposable pattern. These are plain methods and therefore may be invoked multiple times. It is permitted in C++/CLI to call the finalizer directly, this->!Foo() and is commonly done, just like you did. The garbage collector only ever calls the finalizer once, it keeps track internally whether or not that was done. Given that calling the finalizer directly is permitted and that calling Dispose() multiple times is allowed, it is thus possible to run the finalizer code more than once. This is specific to C++/CLI, other managed languages don't allow it. You can easily prevent it, a nullptr check usually gets the job done.

Is it undefined behavior, or is it entirely acceptable, to call delete r on line #2?

It is not UB and entirely acceptable. The delete operator simply calls the IDisposable::Dispose() method and thus runs your destructor. What you do inside it, very typically calling the destructor of an unmanaged class, may well invoke UB.

If we remove line #2, does it matter that r is still a tracking handle

No. Invoking the destructor is entirely optional without a good way to enforce it. Nothing goes wrong, the finalizer ultimately will always run. In the given example that will happen when the CLR runs the finalizer thread one last time before shutting down. The only side effect is that the program runs "heavy", holding on to resources longer than necessary.

Under which other circumstances can the destructor of a managed object be called more than once?

It's pretty common, an overzealous C# programmer may well call your Dispose() method more than once. Classes that provide both a Close and a Dispose method are pretty common in the framework. There are some patterns where it is nearly unavoidable, the case where another class assumes ownership of an object. The standard example is this bit of C# code:

using (var fs = new FileStream(...))
using (var sw = new StreamWriter(fs)) {
    // Write file...
}

The StreamWriter object will take ownership of its base stream and call its Dispose() method at the last curly brace. The using statement on FileStream object calls Dispose() a second time. Writing this code so that this doesn't happen and still provide exception guarantees is too difficult. Specifying that Dispose() may be called more than once solves the problem.

Would it be OK to insert x.~Foo(); immediately before or after r = %x;?

It's okay. The outcome is unlikely to be pleasant, a NullReferenceException would be the most likely result. This is something that you should test for, raise an ObjectDisposedException to give the programmer a better diagnostic. All standard .NET framework classes do so.

In other words, do managed objects "live forever"

No, the garbage collector declares the object dead, and collects it, when it cannot find any references to the object anymore. This is a fail-safe way to memory management, there is no way to accidentally reference a deleted object. Because doing so requires a reference, one that the GC will always see. Common memory management problems like circular references are not an issue either.

Code snippet

Deleting the a object is unnecessary and has no effect. You only delete objects that implement IDisposable, an array does not do so. The common rule is that a .NET class only implements IDisposable when it manages resources other than memory. Or if it has a field of a class type that itself implements IDisposable.

It is furthermore questionable whether you should implement a destructor in this case. Your example class is holding on to a rather modest unmanaged resource. By implementing the destructor, you impose the burden on the client code to use it. It strongly depends on the class usage how easy it is for the client programmer to do so, it definitely is not if the object is expected to live for a long time, beyond the body of a method so that the using statement isn't usable. You can let the garbage collector know about memory consumption that it cannot track, call GC::AddMemoryPressure(). Which also takes care of the case where the client programmer simply doesn't use Dispose() because it is too hard.

查看更多
登录 后发表回答