Повторные вызовы деструкторов и дескрипторы отслеживания в С++/CLI

Я играю с С++/CLI, используя документацию MSDN и стандарт ECMA и Visual С++ Express 2010. Что поразило меня был следующий отход от С++:

Для классов ref как финализатор, так и деструктор должны быть записаны так, чтобы их можно было выполнить несколько раз и на объектах, которые еще не были полностью построены.

Я придумал небольшой пример:

#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
}

В конце блока в #1 автоматическая переменная x умирает, и вызывается деструктор (который, в свою очередь, вызывает финализатор явно, как и обычная идиома). Все хорошо и хорошо. Но затем я удаляю объект снова через ссылку r! Вывод:

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

Вопросы:

  • Это поведение undefined, или вполне приемлемо, вызвать delete r в строке #2?

  • Если мы удалим строку #2, имеет ли значение, что r все еще является дескриптором отслеживания для объекта, который (в смысле С++) больше не существует? Это "оборванная ручка"? Считает ли его подсчет ссылок, что будет предпринята попытка двойного удаления?

    Я знаю, что нет фактического двойного удаления, поскольку выход становится следующим:

    Foo()
    ~Foo()
    !Foo()
    

    Тем не менее, я не уверен, что это счастливая случайность или гарантированное четкое поведение.

  • При каких других обстоятельствах деструктор управляемого объекта может вызываться более одного раза?

  • Можно ли вставить x.~Foo(); непосредственно перед или после r = %x;?

Другими словами, управляемые объекты "живут вечно" и могут снова и снова повторяться как их деструкторы, так и их финализаторы?


В ответ на требование @Hans для нетривиального класса вы также можете рассмотреть эту версию (с деструктором и финализатором, выполненным в соответствии с требованием множественного вызова):

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;
};

Ответы

Ответ 1

Я просто попытаюсь решить возникшие проблемы:

Для классов ref как финализатор, так и деструктор должны быть записаны так, чтобы их можно было выполнить несколько раз и на объектах, которые еще не были полностью построены.

Деструктор ~Foo() просто автоматически генерирует два метода, реализацию метода IDisposable:: Dispose(), а также защищенный метод Foo:: Dispose (bool), который реализует одноразовый шаблон. Это простые методы, поэтому их можно вызвать несколько раз. В С++/CLI разрешено напрямую вызывать финализатор, this->!Foo() и обычно делается так же, как и вы. Сборщик мусора только когда-либо называет финализатор один раз, он отслеживает внутренне независимо от того, было ли это сделано. Учитывая, что вызов финализатора напрямую разрешен и разрешен многократный вызов Dispose(), таким образом, можно запускать код финализатора более одного раза. Это специфично для С++/CLI, другие управляемые языки не позволяют этого. Вы можете легко предотвратить это, проверка nullptr обычно выполняет задание.

Это поведение undefined, или вполне приемлемо, для вызова delete r в строке # 2?

Это не UB и вполне приемлемо. Оператор delete просто вызывает метод IDisposable:: Dispose() и тем самым запускает ваш деструктор. То, что вы делаете внутри него, очень типично называя деструктор неуправляемого класса, вполне может вызвать UB.

Если мы удалим строку # 2, имеет ли значение, что r по-прежнему является маркером отслеживания

Нет. Вызов деструктора полностью необязателен без хорошего способа его принудительного применения. Ничего не получается, финализатор в конечном счете всегда будет работать. В данном примере, когда CLR запускает поток финализатора в последний раз перед выключением. Единственным побочным эффектом является то, что программа работает "тяжелая", удерживая ресурсы дольше, чем необходимо.

В каких случаях другие обстоятельства могут вызвать деструктор управляемого объекта более одного раза?

Это довольно распространенный вопрос, чрезмерный программист на С# может еще раз вызвать ваш метод Dispose(). Классы, которые предоставляют метод Close и Dispose, довольно распространены в структуре. Есть некоторые шаблоны, где это почти неизбежно, случай, когда другой класс предполагает принадлежность к объекту. Стандартный пример - это бит кода С#:

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

Объект StreamWriter будет владеть базовым потоком и вызвать его метод Dispose() в последней фигурной скобке. Оператор using на объекте FileStream вызывает Dispose() второй раз. Написание этого кода, чтобы этого не произошло и по-прежнему предоставлять исключения, слишком сложно. Указание того, что Dispose() может быть вызвано более одного раза, решает проблему.

Можно ли вставить x. ~ Foo(); непосредственно перед или после r =% x;?

Все в порядке. Результат вряд ли будет приятным, исключение NullReferenceException будет наиболее вероятным результатом. Это то, что вы должны проверить, поднять ObjectDisposedException, чтобы дать программисту лучшую диагностику. Все стандартные классы .NET Framework делают это.

Другими словами, управляемые объекты "живут вечно"

Нет, сборщик мусора объявляет объект мертвым и собирает его, когда он больше не может найти ссылки на объект. Это безопасный способ управления памятью, нет возможности случайно ссылаться на удаленный объект. Потому что для этого требуется ссылка, которую GC всегда будет видеть. Общие проблемы управления памятью, такие как круговые ссылки, также не являются проблемой.

Фрагмент кода

Удаление объекта a не требуется и не имеет никакого эффекта. Вы удаляете только объекты, реализующие IDisposable, массив не делает этого. Общим правилом является то, что класс .NET реализует только IDisposable, когда он управляет ресурсами, отличными от памяти. Или если у него есть поле типа класса, которое само реализует IDisposable.

Кроме того, сомнительно, следует ли вам реализовать деструктор в этом случае. Ваш примерный класс держится за довольно скромный неуправляемый ресурс. Внедряя деструктор, вы накладываете нагрузку на код клиента, чтобы использовать его. Это сильно зависит от использования класса, насколько это просто для клиентского программиста, это определенно не означает, что объект, как ожидается, будет жить долгое время, за пределами тела метода, чтобы оператор using не использовался, Вы можете позволить сборщику мусора узнать о потреблении памяти, который он не может отслеживать, вызвать GC:: AddMemoryPressure(). Который также заботится о случае, когда клиент-программист просто не использует Dispose(), потому что это слишком сложно.

Ответ 2

Правила со стандартного С++ все еще применяются:

  • Вызов delete для автоматической переменной или той, которая уже была очищена, по-прежнему является плохой идеей.

  • Это указатель отслеживания объекта. Разъединение - это плохая идея. С сборкой мусора память хранится до тех пор, пока существует какая-либо неслабая ссылка, поэтому вы не можете случайно получить доступ к неправильному объекту, но вы все равно не можете использовать этот удаленный объект любым полезным способом, так как его инварианты, вероятно, больше не выполняются.

  • Многократное уничтожение может произойти только на управляемых объектах, когда ваш код написан в очень плохом стиле, который был бы UB в стандартном С++ (см. выше 1 и 4 ниже).

  • Явным образом вызов деструктора в автоматическую переменную, а затем не создание нового на своем месте для вызова автоматического уничтожения для поиска, по-прежнему плохая идея.

В общем, вы считаете, что время жизни объекта как отдельное от распределения памяти (как это делает стандартный С++). Сбор мусора используется для управления освобождением - так что память все еще существует, но объект мертв. В отличие от стандартного С++ вы не можете использовать и повторно использовать эту память для хранения сырого байта, поскольку части среды выполнения .NET могут считать, что метаданные все еще действительны.

Ни сборщик мусора, ни семантика стека (автоматический синтаксис переменных) не используют подсчет ссылок.

(Ужасные подробности: удаление объекта не нарушает собственные инварианты среды выполнения .NET в отношении этого объекта, поэтому вы, вероятно, даже можете использовать его в качестве монитора потоков, но это просто делает уродливый трудно понятный дизайн, поэтому, пожалуйста, не делайте этого.)