2010-06-17 17 views
50

Czy ten program jest dobrze zdefiniowany, a jeśli nie, to dlaczego?Czy destruktor może rekursywnie?

#include <iostream> 
#include <new> 
struct X { 
    int cnt; 
    X (int i) : cnt(i) {} 
    ~X() { 
      std::cout << "destructor called, cnt=" << cnt << std::endl; 
      if (cnt-- > 0) 
       this->X::~X(); // explicit recursive call to dtor 
    } 
}; 
int main() 
{ 
    char* buf = new char[sizeof(X)]; 
    X* p = new(buf) X(7); 
    p->X::~X(); // explicit call to dtor 
    delete[] buf; 
} 

Moje rozumowanie: chociaż invoking a destructor twice is undefined behavior, za 12,4/14, co mówi dokładnie to:

zachowanie jest niezdefiniowane jeżeli destruktor jest wywoływany dla obiektu którego żywotność zakończyła

Co wydaje się nie blokować wywołań rekursywnych. Podczas wykonywania destruktora dla obiektu czas życia obiektu jeszcze się nie skończył, dlatego nie jest to UB, aby ponownie wywołać destruktor. Z drugiej strony, 12,4/6 mówi:

Po wykonaniu ciała [...] przez destruktor dla klasy X nazywa destruktorów dla bezpośrednich członków X., destruktory dla bezpośredniej podstawy X. klas [ ...]

co oznacza, że ​​po powrocie z rekurencyjnym wywołaniem destruktora, wszystkie destruktory członkowskie i base class zostaną powołani i nazywając je ponownie po powrocie do poprzedniego poziomu rekursji byłby UB . Dlatego klasa bez podstawy i tylko członkowie POD mogą mieć rekurencyjny destruktor bez UB. Czy mam rację?

+1

To jest naprawdę dziwne, dlaczego kiedykolwiek chcesz wywołać rekurencję destruktora? – Andrey

+0

Godne pytanie dla wuja Boba. –

+3

Dlaczego, do cholery, kiedykolwiek chciałbyś to zrobić? – Puppy

Odpowiedz

58

Odpowiedź brzmi nie, ponieważ z definicji "życia" w §3.8/1:

Żywotność obiektu typu T kończy się, gdy:

- jeśli T jest typu klasy a, nietrywialną destructor (12.4), przy rozpoczęciu rozmowy destructor lub

- Przechowywanie którym obiekt mieści się ponownie lub zwolniony.

Po wywołaniu destruktora (pierwszy raz) czas życia obiektu dobiegł końca. Tak więc, jeśli wywołanie destruktora obiektu od wewnątrz destruktora, zachowanie jest niezdefiniowane, za §12.4/6:

zachowanie jest niezdefiniowane, jeśli wywoływany jest destruktor dla obiektu, którego żywotność zakończyła

+0

@ James: Niepowiązane pytanie, ale gdzie mogę pobrać/przejrzeć dokumentację standardu? – Jacob

+4

D'oh, nie sprawdził dwukrotnie definicji * czasu życia *. Brzmi to dziwnie, ponieważ oznacza to, że podczas normalnego wywołania destruktora uzyskuję dostęp do obiektów obiektu, których żywotność dobiegła końca. Ale jeśli tak mówi, musi to być prawda. – Cubbi

+0

Jest technicznie niezdefiniowany zgodnie ze standardem, ale wydaje mi się, że niemal każda rozsądna implementacja na to pozwoli, biorąc pod uwagę, że destruktor jest łatwo implementowany jako zwykłe wywołanie funkcji członka tuż przed zwolnieniem obiektu. –

1

Tak, to brzmi dobrze. Myślę, że gdy destruktor zakończy wywoływanie, pamięć zostanie zrzucona z powrotem do puli alokacji, co pozwoli napisać nad nią, potencjalnie powodując problemy z kolejnymi wywoływaniami destruktora ("ten" wskaźnik byłby nieprawidłowy).

Jednak jeśli destruktor nie zakończy się, dopóki pętla rekurencyjna nie zostanie rozwinięta ... teoretycznie powinno być w porządku.

Ciekawe pytanie :)

9

Okej, zrozumieliśmy, że zachowanie nie jest zdefiniowane. Ale zróbmy małą podróż do tego, co naprawdę się dzieje. Używam VS 2008.

Oto mój kod:

class Test 
{ 
int i; 

public: 
    Test() : i(3) { } 

    ~Test() 
    { 
     if (!i) 
      return;  
     printf("%d", i); 
     i--; 
     Test::~Test(); 
    } 
}; 

int _tmain(int argc, _TCHAR* argv[]) 
{ 
    delete new Test(); 
    return 0; 
} 

Niech go uruchomić i ustawić punkt przerwania wewnątrz destructor i niech cud rekursji zdarzyć.

Oto ślad stosu:

alt text http://img638.imageshack.us/img638/8508/dest.png

Co to jest, że scalar deleting destructor? Jest to coś, co kompilator wstawia między delete a nasz aktualny kod. Sam Destruktor jest tylko metodą, nie ma w tym nic szczególnego. To tak naprawdę nie zwalnia pamięci. Jest wydany gdzieś wewnątrz tego scalar deleting destructor.

Chodźmy do scalar deleting destructor i przyjrzeć demontażu:

01341580 mov   dword ptr [ebp-8],ecx 
01341583 mov   ecx,dword ptr [this] 
01341586 call  Test::~Test (134105Fh) 
0134158B mov   eax,dword ptr [ebp+8] 
0134158E and   eax,1 
01341591 je   Test::`scalar deleting destructor'+3Fh (134159Fh) 
01341593 mov   eax,dword ptr [this] 
01341596 push  eax 
01341597 call  operator delete (1341096h) 
0134159C add   esp,4 

robiąc naszą rekurencję tkwimy pod adresem 01341586, a pamięć jest rzeczywiście wydany wyłącznie na adres 01341597.

Wniosek: W VS 2008, ponieważ destruktor jest tylko metodą, a cały kod zwolnienia pamięci jest wprowadzany do funkcji pośredniej (scalar deleting destructor), bezpieczne jest wywoływanie rekurencyjnie destruktora. Ale nadal nie jest to dobry pomysł, IMO.

Edytuj: Ok, ok. Jedyną ideą tej odpowiedzi było przyjrzenie się temu, co się dzieje, gdy wywołuje się rekursywnie destruktor. Ale nie rób tego, to ogólnie nie jest bezpieczne.

+4

To nadal nie jest nawet bezpieczne: mimo że destruktor jest "tylko" funkcją składową, za każdym razem, gdy zwraca, wywołuje destruktory wszystkich klas bazowych i zmiennych składowych (jest to łatwe do przetestowania). –

+10

Testowanie wyjścia czegoś nie jest oznaką legalności jakiegokolwiek fragmentu kodu. W najlepszym razie jest to pewne zachowanie określonego kompilatora na pewnym kodzie, ale to nie ma nic wspólnego z C++ jako językiem. – GManNickG

+1

+1 za nastawienie testowe. Nadal nie mogę sobie wyobrazić, jak możesz mieć taki pomysł. – neuro

5

Powraca do definicji czasu życia obiektu przez kompilator. Tak jak w przypadku, gdy pamięć jest naprawdę anulowana. Sądzę, że nie może to nastąpić, dopóki destruktor nie zostanie ukończony, ponieważ destruktor ma dostęp do danych obiektu. Dlatego oczekiwałbym rekurencyjnych wywołań destruktora do działania.

Ale ... na pewno istnieje wiele sposobów na wdrożenie destruktora i uwolnienie pamięci. Nawet gdyby działało tak, jak chciałem na kompilatorze, którego używam dzisiaj, byłbym bardzo ostrożny, polegając na takim zachowaniu. Jest wiele rzeczy, w których dokumentacja mówi, że nie zadziała lub wyniki są nieprzewidywalne, co w rzeczywistości działa dobrze, jeśli zrozumiesz, co naprawdę dzieje się w środku. Zła praktyka polegać na nich, chyba że naprawdę musisz, bo jeśli specyfikacja mówi, że to nie działa, to nawet jeśli to naprawdę działa, nie masz pewności, że będzie działało w następnej wersji kompilator.

To powiedziawszy, jeśli naprawdę chcesz wywołać rekurencyjnie swój destruktor i nie jest to tylko hipotetyczne pytanie, dlaczego nie po prostu zgrać całe ciało destruktora do innej funkcji, niech destruktor to wywoła, a potem niech to zrobi wywoływać się rekurencyjnie? To powinno być bezpieczne.

+4

+1 za sugerowanie zgodnej z normami alternatywy. –

+0

Nie widzę żadnej funkcjonalności, która pozwoliłaby na to, że nie jest ona dostępna w samym ciele - zarówno w standardzie, jak iw praktyce. Jesteś w * dokładnie * tym samym miejscu w odniesieniu do życia obiektu. Na przykład musisz być bardzo ostrożny, aby nie wywoływać funkcji wirtualnych z tego oddzielonego wywołania rekursywnego, ponieważ uzyskasz niezdefiniowane zachowanie. –

+0

Destruktor nie jest normalną funkcją: nie powinno się bezpośrednio wywoływać destruktora: powinien on być wywoływany jedynie przez "kod struktury", który zarządza dealokacją obiektu. Czy to oznacza, że ​​destruktor może lub nie może się nazywać rekursywnie, jest, o ile mi wiadomo, nieostre zdefiniowane w specyfikacjach językowych. Możliwe, że kompilator może implementować destruktory w taki sposób, że wywołanie rekurencyjne nie działa, np. może przedwcześnie zwolnić obiekt. Tak więc chodzi mi o to: Dlaczego warto ryzykować? (ciąg dalszy) – Jay

0

Dlaczego ktoś chciałby rekurencyjnie wywoływać destruktora w ten sposób? Po wywołaniu destruktora, powinien on zniszczyć obiekt. Jeśli zadzwonisz jeszcze raz, spróbujesz zainicjować zniszczenie częściowo zniszczonego obiektu, kiedy wciąż jesteś w drodze, niszcząc go w tym samym czasie.

Wszystkie przykłady mają pewnego rodzaju przyrostowy/przyrostowy warunek końcowy, , aby zasadniczo odliczać w połączeniach, co sugeruje pewną nieudaną implementację zagnieżdżonych klas, które zawierają elementy tego samego typu, co ona.

Dla takiej zagnieżdżonej klasy matryoshki, wywołującej destruktor na elementach, rekurencyjnie, tj. Destruktor wywołuje destruktor na elemencie A, który z kolei wywołuje destruktor na swoim własnym członku A, który z kolei wywołuje detruktor. i tak dalej jest idealnie w porządku i działa dokładnie tak, jak można by oczekiwać. Jest to rekursywne użycie destruktora, ale jest to rekurencyjne wywoływanie destruktora, co jest niepoczytalne i nie miałoby sensu.