Dlaczego użycie słowa „nowy” powoduje wycieki pamięci?

132

Najpierw nauczyłem się C #, a teraz zaczynam od C ++. Jak rozumiem, operator neww C ++ nie jest podobny do tego w C #.

Czy możesz wyjaśnić przyczynę wycieku pamięci w tym przykładowym kodzie?

class A { ... };
struct B { ... };

A *object1 = new A();
B object2 = *(new B());
Xeo
źródło

Odpowiedzi:

465

Co się dzieje

Podczas pisania T t;tworzysz obiekt typu Tz automatycznym czasem przechowywania . Zostanie wyczyszczony automatycznie, gdy wyjdzie poza zakres.

Podczas pisania new T()tworzysz obiekt typu Tz dynamicznym czasem przechowywania . Nie zostanie wyczyszczone automatycznie.

nowy bez czyszczenia

Musisz przekazać do niego wskaźnik, aby go deletewyczyścić:

nowe z usunięciem

Jednak twój drugi przykład jest gorszy: wyłuskujesz wskaźnik i tworzysz kopię obiektu. W ten sposób tracisz wskaźnik do obiektu utworzonego za pomocą new, więc nigdy nie możesz go usunąć, nawet jeśli chcesz!

newing z deref

Co powinieneś zrobić

Powinieneś preferować automatyczny czas przechowywania. Potrzebujesz nowego obiektu, po prostu napisz:

A a; // a new object of type A
B b; // a new object of type B

Jeśli potrzebujesz dynamicznego czasu trwania przechowywania, przechowuj wskaźnik do przydzielonego obiektu w obiekcie automatycznego czasu trwania, który usunie go automatycznie.

template <typename T>
class automatic_pointer {
public:
    automatic_pointer(T* pointer) : pointer(pointer) {}

    // destructor: gets called upon cleanup
    // in this case, we want to use delete
    ~automatic_pointer() { delete pointer; }

    // emulate pointers!
    // with this we can write *p
    T& operator*() const { return *pointer; }
    // and with this we can write p->f()
    T* operator->() const { return pointer; }

private:
    T* pointer;

    // for this example, I'll just forbid copies
    // a smarter class could deal with this some other way
    automatic_pointer(automatic_pointer const&);
    automatic_pointer& operator=(automatic_pointer const&);
};

automatic_pointer<A> a(new A()); // acts like a pointer, but deletes automatically
automatic_pointer<B> b(new B()); // acts like a pointer, but deletes automatically

newing z automatic_pointer

Jest to powszechny idiom, który ma niezbyt opisową nazwę RAII ( pozyskiwanie zasobów to inicjalizacja ). Kiedy zdobywasz zasób, który wymaga czyszczenia, umieszczasz go w obiekcie o automatycznym czasie przechowywania, więc nie musisz się martwić o jego czyszczenie. Dotyczy to każdego zasobu, czy to pamięci, otwartych plików, połączeń sieciowych czy czegokolwiek zechcesz.

Ta automatic_pointerrzecz już istnieje w różnych formach, podałem ją tylko jako przykład. Bardzo podobna klasa istnieje w standardowej bibliotece o nazwie std::unique_ptr.

Istnieje również stara nazwa (sprzed C ++ 11), auto_ptrale teraz jest przestarzała, ponieważ ma dziwne zachowanie podczas kopiowania.

Są też jeszcze inteligentniejsze przykłady, na przykład std::shared_ptr, które pozwalają na wiele wskaźników do tego samego obiektu i czyści go tylko wtedy, gdy ostatni wskaźnik zostanie zniszczony.

R. Martinho Fernandes
źródło
4
@ user1131997: cieszę się, że zadałeś kolejne pytanie. Jak widać w komentarzach niełatwo to wytłumaczyć :)
R. Martinho Fernandes
@ R.MartinhoFernandes: doskonała odpowiedź. Tylko jedno pytanie. Dlaczego użyłeś zwrotu przez odwołanie w funkcji operatora * ()?
Destructor,
@Destructor późna odpowiedź: D. Powrót przez odniesienie pozwala zmodyfikować punkt, dzięki czemu możesz to zrobić, na przykład *p += 2tak, jak w przypadku zwykłego wskaźnika. Gdyby nie zwrócił przez odniesienie, nie naśladowałby normalnego zachowania wskaźnika, co jest tutaj zamiarem.
R. Martinho Fernandes
Bardzo dziękuję za poradę dotyczącą „przechowywania wskaźnika do przydzielonego obiektu w obiekcie z automatycznym czasem przechowywania, który usuwa go automatycznie”. Gdyby tylko istniał sposób, aby wymagać od programistów nauczenia się tego wzorca, zanim będą w stanie skompilować jakikolwiek C ++!
Andy,
35

Wyjaśnienie krok po kroku:

// creates a new object on the heap:
new B()
// dereferences the object
*(new B())
// calls the copy constructor of B on the object
B object2 = *(new B());

Więc pod koniec tego, masz obiekt na stercie bez wskaźnika do niego, więc nie można go usunąć.

Druga próbka:

A *object1 = new A();

jest wyciekiem pamięci tylko wtedy, gdy zapomnisz o deleteprzydzielonej pamięci:

delete object1;

W C ++ istnieją obiekty z automatycznym magazynowaniem, te utworzone na stosie, które są automatycznie usuwane, oraz obiekty z magazynem dynamicznym, na stercie, z którym alokujesz i które musisz newuwolnić delete. (to wszystko jest z grubsza ujęte)

Pomyśl, że powinieneś mieć deleteprzydzielony for każdy obiekt new.

EDYTOWAĆ

Pomyśl o tym, object2nie musi to być wyciek pamięci.

Poniższy kod jest tylko po to, by zwrócić uwagę, to zły pomysł, nigdy nie lubię takiego kodu:

class B
{
public:
    B() {};   //default constructor
    B(const B& other) //copy constructor, this will be called
                      //on the line B object2 = *(new B())
    {
        delete &other;
    }
}

W tym przypadku, ponieważ otherjest przekazywana przez referencję, będzie to dokładny obiekt wskazywany przez new B(). Dlatego pobranie adresu przez &otheri usunięcie wskaźnika zwolniłoby pamięć.

Ale nie mogę tego wystarczająco podkreślić, nie rób tego. Jest tu tylko po to, aby zwrócić uwagę.

Luchian Grigore
źródło
2
Myślałem tak samo: możemy go zhakować, aby nie przeciekać, ale nie chcesz tego robić. object1 również nie musi przeciekać, ponieważ jego konstruktor mógłby dołączyć się do jakiejś struktury danych, która w pewnym momencie go usunie.
CashCow
2
Zawsze tak kuszące jest pisanie odpowiedzi „można to zrobić, ale nie”! :-) Znam to uczucie
Kos
11

Biorąc pod uwagę dwa „obiekty”:

obj a;
obj b;

Nie zajmą tego samego miejsca w pamięci. Innymi słowy,&a != &b

Przypisanie wartości jednego do drugiego nie zmieni ich lokalizacji, ale zmieni ich zawartość:

obj a;
obj b = a;
//a == b, but &a != &b

Intuicyjnie „obiekty” wskaźnika działają w ten sam sposób:

obj *a;
obj *b = a;
//a == b, but &a != &b

Spójrzmy teraz na Twój przykład:

A *object1 = new A();

To jest przypisywanie wartości new A()do object1. Wartość jest wskaźnikiem, co oznacza object1 == new A(), ale &object1 != &(new A()). (Zwróć uwagę, że ten przykład nie jest prawidłowym kodem, służy tylko do wyjaśnienia)

Ponieważ wartość wskaźnika jest zachowana, możemy zwolnić pamięć, na którą wskazuje: delete object1;Z powodu naszej reguły zachowuje się to tak samo, jak w przypadku delete (new A());braku przecieku.


W drugim przykładzie kopiujesz wskazany obiekt. Wartość to zawartość tego obiektu, a nie rzeczywisty wskaźnik. Jak w każdym innym przypadku &object2 != &*(new A()).

B object2 = *(new B());

Straciliśmy wskaźnik do przydzielonej pamięci i dlatego nie możemy jej zwolnić. delete &object2;może wydawać się, że to zadziała, ale ponieważ &object2 != &*(new A())nie jest równoważne, delete (new A())a więc nieważne.

Pubby
źródło
9

W C # i Javie, używasz new do tworzenia instancji dowolnej klasy i nie musisz się martwić o jej późniejsze zniszczenie.

C ++ ma również słowo kluczowe „new”, które tworzy obiekt, ale w przeciwieństwie do Javy czy C # nie jest to jedyny sposób tworzenia obiektu.

C ++ ma dwa mechanizmy tworzenia obiektu:

  • automatyczny
  • dynamiczny

Dzięki automatycznemu tworzeniu obiekt tworzysz w środowisku o określonym zakresie: - w funkcji lub - jako element członkowski klasy (lub struktury).

W funkcji utworzyłbyś ją w ten sposób:

int func()
{
   A a;
   B b( 1, 2 );
}

W klasie normalnie utworzyłbyś ją w ten sposób:

class A
{
  B b;
public:
  A();
};    

A::A() :
 b( 1, 2 )
{
}

W pierwszym przypadku obiekty są niszczone automatycznie po wyjściu z bloku zasięgu. Może to być funkcja lub blok zakresu w funkcji.

W tym drugim przypadku obiekt b jest niszczony wraz z instancją A, w której jest członkiem.

Obiekty są przydzielane jako nowe, gdy trzeba kontrolować okres istnienia obiektu, a następnie wymaga usunięcia, aby go zniszczyć. Dzięki technice znanej jako RAII zajmujesz się usuwaniem obiektu w miejscu, w którym go tworzysz, umieszczając go w obiekcie automatycznym i czekając, aż destruktor tego automatycznego obiektu zacznie działać.

Jednym z takich obiektów jest shared_ptr, który wywoła logikę „deleter”, ale tylko wtedy, gdy wszystkie instancje shared_ptr, które współużytkują obiekt, zostaną zniszczone.

Ogólnie, chociaż twój kod może mieć wiele wywołań new, powinieneś mieć ograniczone wywołania do usuwania i zawsze powinieneś upewnić się, że są one wywoływane z destruktorów lub obiektów „deleter”, które są umieszczane w inteligentnych wskaźnikach.

Twoje destruktory również nigdy nie powinny rzucać wyjątków.

Jeśli to zrobisz, będziesz miał kilka wycieków pamięci.

Dojną krową
źródło
4
Jest więcej niż automatici dynamic. Jest też static.
Mooing Duck
9
B object2 = *(new B());

Ta linia jest przyczyną wycieku. Oddzielmy to trochę od siebie ..

object2 to zmienna typu B, przechowywana pod, powiedzmy, adresem 1 (tak, wybieram tutaj dowolne liczby). Po prawej stronie poprosiłeś o nowe B lub wskaźnik do obiektu typu B. Program chętnie ci to da i przypisuje twoje nowe B do adresu 2, a także tworzy wskaźnik w adresie 3. Teraz, jedynym sposobem uzyskania dostępu do danych pod adresem 2 jest użycie wskaźnika w adresie 3. Następnie wyłuskiwałeś wskaźnik przy użyciu, *aby uzyskać dane, na które wskazuje wskaźnik (dane w adresie 2). To skutecznie tworzy kopię tych danych i przypisuje ją do obiektu 2, przypisanego w adresie 1. Pamiętaj, że jest to KOPIA, a nie oryginał.

Oto problem:

Nigdy nie przechowywałeś tego wskaźnika w dowolnym miejscu, w którym możesz go użyć! Po zakończeniu tego przypisania wskaźnik (pamięć w adresie3, którego użyłeś do uzyskania dostępu do adresu2) jest poza zakresem i poza twoim zasięgiem! Nie możesz już wywołać usuwania na nim i dlatego nie możesz wyczyścić pamięci w adresie2. Zostaje Ci kopia danych z address2 w address1. Dwie takie same rzeczy tkwią w pamięci. Do jednego masz dostęp, do drugiego nie (bo zgubiłeś do niego ścieżkę). Dlatego jest to wyciek pamięci.

Sugerowałbym, że pochodząc z Twojego C # tła, dużo czytasz o tym, jak działają wskaźniki w C ++. Są tematem zaawansowanym i może zająć trochę czasu, ale ich użycie będzie dla ciebie bezcenne.

MGZero
źródło
8

Jeśli to ułatwi, pomyśl o pamięci komputera jak o hotelu, a programy to klienci, którzy wynajmują pokoje, kiedy ich potrzebują.

Sposób działania tego hotelu polega na rezerwacji pokoju i poinformowaniu portiera o wyjeździe.

Jeśli zaprogramujesz rezerwację pokoju i wyjdziesz bez powiadomienia portiera, portier pomyśli, że pokój jest nadal używany i nie pozwoli nikomu go używać. W takim przypadku występuje wyciek w pomieszczeniu.

Jeśli twój program przydziela pamięć i nie usuwa jej (po prostu przestaje jej używać), to komputer uważa, że ​​pamięć jest nadal używana i nie pozwoli nikomu innemu jej używać. To wyciek pamięci.

Nie jest to dokładna analogia, ale może pomóc.

Stefan
źródło
5
Całkiem podoba mi się ta analogia, nie jest doskonała, ale zdecydowanie jest to dobry sposób na wyjaśnienie przecieków pamięci osobom, które są w niej nowe!
AdamM
1
Wykorzystałem to w wywiadzie dla starszego inżyniera w Bloomberg w Londynie, aby wyjaśnić dziewczynie z działu HR wycieki pamięci. Przeszedłem przez ten wywiad, ponieważ byłem w stanie wyjaśnić wycieki pamięci (i problemy z tworzeniem wątków) osobie, która nie jest programistą, w sposób dla niej zrozumiały.
Stefan
7

Podczas tworzenia object2tworzysz kopię obiektu, który utworzyłeś za pomocą nowego, ale tracisz (nigdy nie przypisany) wskaźnik (więc nie ma sposobu, aby go później usunąć). Aby tego uniknąć, musisz zrobić object2odniesienie.

Mario
źródło
3
Korzystanie z adresu odniesienia w celu usunięcia obiektu jest niezwykle złą praktyką. Użyj inteligentnego wskaźnika.
Tom Whittock
3
Niesamowicie zła praktyka, co? Jak myślisz, czego inteligentne wskazówki używają za kulisami?
Blindy
3
@Blindy inteligentne wskaźniki (przynajmniej przyzwoicie wdrożone) używają wskaźników bezpośrednio.
Luchian Grigore
2
Cóż, szczerze mówiąc, cały pomysł nie jest taki wspaniały, prawda? Właściwie nie jestem nawet pewien, gdzie wzorzec wypróbowany w OP byłby przydatny.
Mario
7

Cóż, tworzysz wyciek pamięci, jeśli w pewnym momencie nie zwolnisz pamięci, którą przydzieliłeś za pomocą newoperatora, przekazując wskaźnik do tej pamięci deleteoperatorowi.

W dwóch powyższych przypadkach:

A *object1 = new A();

Tutaj nie używasz deletedo zwolnienia pamięci, więc jeśli i kiedy object1wskaźnik wyjdzie poza zakres, będziesz mieć wyciek pamięci, ponieważ straciłeś wskaźnik i nie możesz użyć na nim deleteoperatora.

I tu

B object2 = *(new B());

odrzucasz wskaźnik zwrócony przez new B(), więc nigdy nie możesz przekazać tego wskaźnika do, aby zwolnić deletepamięć. Stąd kolejny wyciek pamięci.

razlebe
źródło
7

To ta linia, która natychmiast przecieka:

B object2 = *(new B());

Tutaj tworzysz nowy Bobiekt na stercie, a następnie tworzysz kopię na stosie. Nie można już uzyskać dostępu do tego, który został przydzielony na stercie, i stąd wyciek.

Ta linia nie jest od razu nieszczelna:

A *object1 = new A();

Nie byłoby wyciek, jeśli nigdy nie deletebyłyby object1jednak.

mattjgalloway
źródło
4
Nie używaj stosu / stosu przy wyjaśnianiu dynamicznego / automatycznego przechowywania.
Pubby
2
@Pubby, dlaczego nie używać? Ponieważ dynamiczne / automatyczne przechowywanie jest zawsze stertą, a nie stosem? I dlatego nie ma potrzeby szczegółowego opisywania stosu / stosu, prawda?
4
@ user1131997 Sterta / stos to szczegóły implementacji. Ważne jest, aby o nich wiedzieć, ale nie mają one znaczenia dla tego pytania.
Pubby
2
Hmm, chciałbym osobnej odpowiedzi na to pytanie, tj. Takiej samej jak moja, ale zastąpienie sterty / stosu tym, co uważasz za najlepsze. Chciałbym się dowiedzieć, jak wolałbyś to wyjaśnić.
mattjgalloway