Czy ręczne wywołanie destruktora zawsze jest oznaką złego projektu?

84

Myślałem: mówią, że jeśli dzwonisz do destruktora ręcznie - robisz coś nie tak. Ale czy zawsze tak jest? Czy są jakieś kontrprzykłady? Sytuacje, w których konieczne jest ręczne wywołanie lub w których uniknięcie tego jest trudne / niemożliwe / niepraktyczne?

Fioletowa żyrafa
źródło
Jak zamierzasz zwolnić przydział obiektu po wywołaniu dtor, bez ponownego wywoływania?
ssube
2
@peachykeen: możesz wywołać umieszczanie w newcelu zainicjowania nowego obiektu w miejsce starego. Generalnie nie jest to dobry pomysł, ale nie jest to niespotykane.
D.Shawley,
14
Przyjrzyj się „regułom”, które zawierają słowa „zawsze” i „nigdy”, które nie pochodzą bezpośrednio ze specyfikacji z podejrzanym: w większości przypadków, kto ich uczy, chce ukryć rzeczy, które powinieneś wiedzieć, ale tego nie robi wiedzieć, jak uczyć. Tak jak dorosły odpowiadający dziecku na pytanie o seks.
Emilio Garavaglia
Myślę, że jest w porządku w przypadku manipulacji przy konstruowaniu obiektów z techniką umieszczania stroustrup.com/bs_faq2.html#placement-delete (ale jest to raczej rzecz niskiego poziomu i jest używana tylko wtedy, gdy optymalizujesz oprogramowanie nawet na takim poziomie)
bruziuz

Odpowiedzi:

95

Ręczne wywołanie destruktora jest wymagane, jeśli obiekt został skonstruowany przy użyciu przeciążonej postaci operator new(), z wyjątkiem sytuacji, gdy używano std::nothrowprzeciążeń " ":

T* t0 = new(std::nothrow) T();
delete t0; // OK: std::nothrow overload

void* buffer = malloc(sizeof(T));
T* t1 = new(buffer) T();
t1->~T(); // required: delete t1 would be wrong
free(buffer);

Zewnętrzne zarządzanie pamięcią na raczej niskim poziomie, jak powyżej jawne wywoływanie destruktorów, jest jednak oznaką złego projektu. Prawdopodobnie nie jest to tylko zły projekt, ale wręcz błędny (tak, użycie jawnego destruktora, po którym następuje wywołanie konstruktora kopiującego w operatorze przypisania, jest złym projektem i prawdopodobnie będzie błędne).

W C ++ 2011 istnieje jeszcze jeden powód, dla którego warto używać jawnych wywołań destruktorów: podczas używania uogólnionych związków konieczne jest jawne zniszczenie bieżącego obiektu i utworzenie nowego obiektu przy użyciu umieszczania new podczas zmiany typu reprezentowanego obiektu. Ponadto po zniszczeniu unii konieczne jest jawne wywołanie destruktora bieżącego obiektu, jeśli wymaga on zniszczenia.

Dietmar Kühl
źródło
26
Zamiast mówić „używając przeciążonej formy operator new”, poprawnym wyrażeniem jest „używanie placement new”.
Remy Lebeau
5
@RemyLebeau: Cóż, chciałem wyjaśnić, że nie mówię tylko o operator new(std::size_t, void*)(i wariacji tablicy), ale raczej o wszystkich przeciążonych wersjach operator new().
Dietmar Kühl,
A co, jeśli chcesz skopiować obiekt, aby wykonać w nim operację, nie zmieniając go podczas obliczania operacji? temp = Class(object); temp.operation(); object.~Class(); object = Class(temp); temp.~Class();
Jean-Luc Nacif Coelho
yes, using an explicit destructor followed by a copy constructor call in the assignment operator is a bad design and likely to be wrong. Dlaczego to powiedziałeś? Myślę, że jeśli destruktor jest trywialny lub prawie trywialny, to ma minimalne obciążenie i zwiększa wykorzystanie zasady DRY. Jeśli zostanie użyty w takich przypadkach z ruchem operator=(), może być nawet lepszy niż użycie zamiany. YMMV.
Adrian
1
@Adrian: wywołanie destruktora i odtworzenie obiektu bardzo łatwo zmienia typ obiektu: odtworzy obiekt ze statycznym typem przypisania, ale typ dynamiczny może być inny. W rzeczywistości jest to problem, gdy klasa ma virtualfunkcje ( virtualfunkcje nie zostaną odtworzone), a inaczej obiekt jest tylko częściowo [re-] konstruowany.
Dietmar Kühl
105

Wszystkie odpowiedzi opisują konkretne przypadki, ale jest odpowiedź ogólna:

Wywołujesz dtor jawnie za każdym razem, gdy musisz po prostu zniszczyć obiekt (w sensie C ++) bez zwalniania pamięci, w której obiekt się znajduje.

Zwykle dzieje się tak we wszystkich sytuacjach, w których alokacja / zwalnianie pamięci jest zarządzana niezależnie od konstrukcji / zniszczenia obiektu. W takich przypadkach konstrukcja odbywa się poprzez umieszczenie new na istniejącym fragmencie pamięci, a zniszczenie następuje poprzez jawne wywołanie dtor.

Oto surowy przykład:

{
  char buffer[sizeof(MyClass)];

  {
     MyClass* p = new(buffer)MyClass;
     p->dosomething();
     p->~MyClass();
  }
  {
     MyClass* p = new(buffer)MyClass;
     p->dosomething();
     p->~MyClass();
  }

}

Innym godnym uwagi przykładem jest wartość domyślna, std::allocatorgdy jest używana przez std::vector: elementy są konstruowane w vectortrakcie push_back, ale pamięć jest alokowana w fragmentach, więc istnieje przed utworzeniem elementu. A zatem vector::erasemusi zniszczyć elementy, ale niekoniecznie zwalnia pamięć (zwłaszcza jeśli wkrótce ma nastąpić nowy push_back ...).

Jest to „zły projekt” w ścisłym sensie OOP (powinieneś zarządzać obiektami, a nie pamięcią: fakt, że obiekty wymagają pamięci jest „incydentem”), jest to „dobry projekt” w „programowaniu niskopoziomowym” lub w przypadkach, gdy pamięć jest nie pochodzi z „darmowego sklepu”, w którym domyślnie operator newkupuje.

Jest to zły projekt, jeśli dzieje się to losowo wokół kodu, dobry projekt, jeśli dzieje się to lokalnie w klasach specjalnie zaprojektowanych do tego celu.

Emilio Garavaglia
źródło
8
Ciekawe, dlaczego nie jest to akceptowana odpowiedź.
Francis Cugler
12

Nie, nie powinieneś nazywać tego wprost, ponieważ zostałby wywołany dwukrotnie. Raz dla wywołania ręcznego, a innym razem, gdy kończy się zakres, w którym zadeklarowano obiekt.

Na przykład.

{
  Class c;
  c.~Class();
}

Jeśli naprawdę potrzebujesz wykonać te same operacje, powinieneś mieć oddzielną metodę.

Istnieje specyficzna sytuacja, w której możesz chcieć wywołać destruktor na dynamicznie przydzielonym obiekcie z umiejscowieniem, newale nie brzmi to na coś, czego kiedykolwiek będziesz potrzebować.

Jacek
źródło
11

Nie, zależy od sytuacji, czasami jest to uzasadniony i dobry projekt.

Aby zrozumieć, dlaczego i kiedy należy jawnie wywoływać destruktory, przyjrzyjmy się, co się dzieje z „nowym” i „usuń”.

Aby dynamicznie utworzyć obiekt, T* t = new T;pod maską: 1. przydzielany jest rozmiar pamięci (T). 2. Konstruktor T jest wywoływany w celu zainicjowania przydzielonej pamięci. Operator new robi dwie rzeczy: alokację i inicjalizację.

Aby zniszczyć obiekt delete t;pod maską: 1. Wzywa się destruktor T. 2. pamięć przydzielona dla tego obiektu zostaje zwolniona. operator delete robi również dwie rzeczy: zniszczenie i cofnięcie alokacji.

Jeden pisze konstruktor, aby przeprowadzał inicjalizację, a destruktor, aby przeprowadzał niszczenie. Kiedy jawnie wywołujesz destruktor, następuje tylko zniszczenie, ale nie cofnięcie alokacji .

Dlatego uzasadnione użycie jawnego wywołania destruktora mogłoby brzmieć: „Chcę tylko zniszczyć obiekt, ale nie (lub nie mogę) zwolnić alokacji pamięci (jeszcze)”.

Typowym tego przykładem jest wstępne przydzielanie pamięci dla puli określonych obiektów, które w innym przypadku muszą być przydzielane dynamicznie.

Tworząc nowy obiekt, otrzymujesz porcję pamięci ze wstępnie przydzielonej puli i wykonujesz „umieszczenie nowego”. Po zakończeniu pracy z obiektem możesz chcieć jawnie wywołać destruktor, aby zakończyć czyszczenie, jeśli takie ma miejsce. Ale tak naprawdę nie zwolnisz pamięci, tak jak zrobiłby to operator delete. Zamiast tego zwracasz fragment do puli w celu ponownego wykorzystania.


źródło
6

Za każdym razem, gdy zajdzie potrzeba oddzielenia alokacji od inicjalizacji, konieczne będzie ręczne umieszczenie nowego i jawnego wywołania destruktora. Dziś rzadko jest to konieczne, ponieważ mamy standardowe kontenery, ale jeśli musisz zaimplementować jakiś nowy rodzaj kontenera, będziesz go potrzebować.

James Kanze
źródło
3

Są przypadki, kiedy są konieczne:

W kodzie, nad którym pracuję, używam jawnego wywołania destruktora w alokatorach, mam implementację prostego alokatora, który używa umieszczania new do zwracania bloków pamięci do kontenerów stl. W zniszczeniu mam:

  void destroy (pointer p) {
    // destroy objects by calling their destructor
    p->~T();
  }

podczas konstruowania:

  void construct (pointer p, const T& value) {
    // initialize memory with placement new
    #undef new
    ::new((PVOID)p) T(value);
  }

alokacja jest również wykonywana w funkcji assign (), a zwalnianie pamięci w funkcji deallocate (), przy użyciu mechanizmów przydzielania i zwalniania specyficznych dla platformy. Ten alokator był używany do ominięcia doug lea malloc i użycia bezpośrednio, na przykład LocalAlloc w systemie Windows.

marcinj
źródło
1

Znalazłem 3 sytuacje, w których musiałem to zrobić:

  • alokowanie / zwalnianie obiektów w pamięci utworzonej przez pamięć-mapped-io lub pamięć współdzieloną
  • przy implementacji danego interfejsu C za pomocą C ++ (tak, niestety nadal się to dzieje (bo nie mam wystarczającej siły, by to zmienić))
  • przy implementacji klas alokatora

źródło
1

Nigdy nie spotkałem się z sytuacją, w której trzeba by ręcznie wywołać destruktor. Wydaje mi się, że nawet Stroustrup twierdzi, że to zła praktyka.

Lieuwe
źródło
1
Masz rację. Ale użyłem nowego miejsca docelowego. Udało mi się dodać funkcję czyszczenia w metodzie innej niż destruktor. Destruktor jest dostępny, więc można go „automatycznie” wywołać, gdy usunie się, jeśli ręcznie chcesz zniszczyć, ale nie cofnąć przydziału, możesz po prostu napisać „onDestruct”, prawda? Chciałbym usłyszeć, czy istnieją przykłady, w których obiekt musiałby dokonać zniszczenia w destruktorze, ponieważ czasami trzeba by go usunąć, a innym razem chciałbyś tylko zniszczyć, a nie zwolnić.
Lieuwe
I nawet w takim przypadku można by wywołać onDestruct () z poziomu destruktora - więc nadal nie widzę powodu, aby ręcznie wywoływać destruktor.
Lieuwe
4
@JimBalter: twórca C+
Mark K Cowan
@MarkKCowan: co to jest C +? Powinien to być C ++
Destructor,
1

A co z tym?
Destructor nie jest wywoływany, jeśli wyjątek jest wyrzucany z konstruktora, więc muszę wywołać go ręcznie, aby zniszczyć uchwyty, które zostały utworzone w konstruktorze przed wyjątkiem.

class MyClass {
  HANDLE h1,h2;
  public:
  MyClass() {
    // handles have to be created first
    h1=SomeAPIToCreateA();
    h2=SomeAPIToCreateB();        
    try {
      ...
      if(error) {
        throw MyException();
      }
    }
    catch(...) {
      this->~MyClass();
      throw;
    }
  }
  ~MyClass() {
    SomeAPIToDestroyA(h1);
    SomeAPIToDestroyB(h2);
  }
};
CITBL
źródło
1
Wydaje się to wątpliwe: kiedy twój konstruktor przerzuca, nie wiesz (lub możesz nie wiedzieć), które części obiektu zostały zbudowane, a które nie. Na przykład nie wiesz, dla których podobiektów wywołać destruktory. Lub które z zasobów przydzielonych przez konstruktora do zwolnienia.
Violet Giraffe
@VioletGiraffe jeśli obiekty podrzędne są zbudowane na stosie, tj. Nie z „nowym”, zostaną automatycznie zniszczone. W przeciwnym razie możesz sprawdzić, czy są NULL przed zniszczeniem ich w destruktorze. To samo z zasobami
CITBL
Sposób, w jaki ctortutaj napisałeś , jest zły, dokładnie z powodu, który sam podałeś: jeśli alokacja zasobów się nie powiedzie, występuje problem z czyszczeniem. „Ktor” nie powinien wzywać this->~dtor(). dtornależy wywołać na skonstruowanych obiektach, aw tym przypadku obiekt nie jest jeszcze skonstruowany. Cokolwiek się stanie, ctorpowinno zająć się czyszczeniem. Wewnątrz ctorkodu powinieneś używać narzędzi takich jak std::unique_ptrdo obsługi automatycznego czyszczenia w przypadkach, gdy coś się wyrzuca. Zmiana HANDLE h1, h2pól w klasie w celu obsługi automatycznego czyszczenia może być również dobrym pomysłem.
quetzalcoatl
Oznacza to, że ktor powinien wyglądać tak: MyClass(){ cleanupGuard1<HANDLE> tmp_h1(&SomeAPIToDestroyA) = SomeAPIToCreateA(); cleanupGuard2<HANDLE> tmp_h2(&SomeAPIToDestroyB) = SomeAPIToCreateB(); if(error) { throw MyException(); } this->h1 = tmp_h1.release(); this->h2 = tmp_h2.release(); }i to wszystko . Bez ryzykownego ręcznego czyszczenia, bez przechowywania uchwytów w częściowo zbudowanym obiekcie, dopóki wszystko nie będzie bezpieczne, jest bonusem. Jeśli zmienisz HANDLE h1,h2klasę na cleanupGuard<HANDLE> h1;itp., Możesz nawet w ogóle nie potrzebować dtor.
quetzalcoatl
Realizacja cleanupGuard1i cleanupGuard2zależy od tego, co xxxToCreatezwraca dany zwrot i jakie parametry xxxxToDestroyprzyjmuje dany parametr . Jeśli są proste, możesz nawet nie potrzebować niczego pisać, ponieważ często okazuje się, że std::unique_ptr<x,deleter()>(lub podobny) może załatwić sprawę w obu przypadkach.
quetzalcoatl
-2

Znalazłem inny przykład, w którym musiałbyś ręcznie wywołać destruktor (y). Załóżmy, że zaimplementowałeś klasę podobną do wariantu, która zawiera jeden z kilku typów danych:

struct Variant {
    union {
        std::string str;
        int num;
        bool b;
    };
    enum Type { Str, Int, Bool } type;
};

Jeśli Variantinstancja trzymała a std::string, a teraz przypisujesz inny typ do unii, musisz zniszczyć std::stringpierwszy. Kompilator nie zrobi tego automatycznie .

Fioletowa żyrafa
źródło
-4

Mam inną sytuację, w której myślę, że nazwanie destruktora jest całkowicie uzasadnione.

Pisząc metodę typu „Reset” w celu przywrócenia obiektu do jego stanu początkowego, całkowicie uzasadnione jest wywołanie Destructor w celu usunięcia starych danych, które są resetowane.

class Widget
{
private: 
    char* pDataText { NULL  }; 
    int   idNumber  { 0     };

public:
    void Setup() { pDataText = new char[100]; }
    ~Widget()    { delete pDataText;          }

    void Reset()
    {
        Widget blankWidget;
        this->~Widget();     // Manually delete the current object using the dtor
        *this = blankObject; // Copy a blank object to the this-object.
    }
};
abelenky
źródło
1
Czy nie wyglądałoby lepiej, gdybyś zadeklarował specjalną cleanup()metodę do wywołania w tym przypadku i w destruktorze?
Violet Giraffe
„Specjalna” metoda wywoływana tylko w dwóch przypadkach? Jasne ... to brzmi całkowicie poprawnie (/ sarkazm). Metody powinny być uogólnione i możliwe do wywołania w dowolnym miejscu. Kiedy chcesz usunąć obiekt, nie ma nic złego w wywołaniu jego destruktora.
abelenky
4
W tej sytuacji nie możesz jawnie wywoływać destruktora. W każdym razie musiałbyś zaimplementować operator przypisania.
Rémi