Uczę się C++, kiedy używać new i delete?

Ten wpis jest skierowany do nowicjuszy i jest odpowiedzią na nagminną praktykę uczenia C++ jako C with classes (witamy w roku 1984), skutkującą potem zalewem kodu fatalnej jakości na wszelkiej maści forach.

Kiedy? NIGDY!

Ok, jest to pewne uproszczenie i są od tej reguły wyjątki. Ale chciałbym wyraźnie przekazać, że to użycie new jest odstępstwem od reguły, a nie odwrotnie. W przypadku delete takich wyjątków nie ma wcale.

To jak mam utworzyć obiekt?

C++ to nie Java. Poniższy kod nie jest niejawną definicją wskaźnika na Foo, tylko obiektu tego typu:

Foo foo;

Tutaj wstawię link do wyjaśnienia czym jest wskaźnik/referencja/obiekt/klasa, gdy takie napiszę.

Chcę mieć tablicę o rozmiarze nieznanym w czasie kompilacji

Super. Od tego jest std::vector. Dodatkowo dzięki RAII nie musisz martwić się o zwolnienie zasobów (zero- lub wielokrotne).

int* arr = new T[n];
for(int i = 0; i < n; ++i) {
    arr[i] = process(i);
}
display(arr, n);
delete[] arr;

W powyższym kodzie, jeśli display lub process rzuci wyjątek, pamięć arr wycieknie. Jeśli w procesie refaktoringu zostanie dodane nowe wyjście z funkcji, programista będzie musiał pamiętać aby dodać odpowiednią komendę delete[]. Wszystko to jest zbędne, gdy pozwolisz kompilatorowi się wyręczyć za sprawą RAII. Analogiczne użycie std::vector może wyglądać następująco:

std::vector<int> arr(n);
for(int i = 0; i < n; ++i) {
    arr[i] = process(i);
}
display(arr, n);

Opisane wyżej problemy go nie dotyczą.

A co jeśli chcę mieć dostęp do tego samego obiektu z wielu miejsc/obiekt jest duży i jego wielokrotne kopiowanie jest złe?

Nic nie stoi na przeszkodzie, aby przekazać referencję albo wskaźnik do obiektu o automatycznym czasie życia (kolokwialnie: znajdującego się na stosie). Można tez użyć std::unique_ptr/std::shared_ptr wraz z std::make_unique/std::make_shared.

Najistotniejszą sprawą jest jasne określenie kto, gdzie i w jaki sposób odpowiada za czas życia, a więc i usunięcie obiektu. W większości przypadków wystarczy automatyczny czas życia, czyli “zwykła zmienna” i to powinno być rozwiązanie domyślne:

void process_ptr(Foo const*);
void process_ref(Foo const&);
 
// ...
Foo foo;
 
process_ptr(&foo);
process_ref(foo);

Jeśli z jakiegoś powodu nie jest możliwe użycie takiego sposobu, pierwszą alternatywą powinien być std::unique_ptr. Może tak być np. jeśli chcemy dynamicznie zadecydować o typie obiektu lub jeśli jakieś API zwraca wskaźnik do obiektu, za którego usunięcie odpowiada użytkownik:

void process_ptr(Widget const*);
void process_ref(Widget const&);
 
// ...
std::unique_ptr<Widget> someWidget{createSomeWidget()};
 
process_ptr(someWidget.get());
process_ref(*someWidget);

Dopiero gdy użycie std::unique_ptr okaże się niemożliwe powinien zostać rozważony std::shared_ptr. Dzieje się tak najczęściej gdy nie ma jasno określonego czasu życia obiektu. Pozwala on na utrzymanie go przy życiu dopóki istnieje chociaż jeden std::shared_ptr na dany obiekt.

Ten wybór, wraz z życiowymi przykładami został fantastycznie przedstawiony przez Herba Suttera na ostatnej konferencji C++Con:

Herb Sutter: Leak-Freedom in C++… By Default.

Ok, to kiedy mogę użyć new?

Znam dwa akceptowalne powody:

Używasz jakiegoś frameworka/biblioteki, który to wymusza

Np. Qt, gdzie częstym widokiem jest:

QSomeWidget* w = new QSomeWidget(this);

Należy jednak zauważyć, że w powyższym przypadku QWidget przejmuje odpowiedzialność za zwolnienie zaalokowanego obiektu w odpowiednim momencie. Nie ma więc potrzeby samodzielnego wywoływania delete.

make_shared/make_unique nie jest w stanie skonstruować obiektu

Jest tak np. dla agregatów (w pewnym uproszczeniu klasa lub tablica klas bez konstruktorów niedomyślnych, zawierająca tylko publiczne elementy), które trzeba inicjalizować za pomocą {}. Dlatego poniższy kod jest akceptowalny:

struct person
{
    std::string name;
    int number_of_hands;
};
 
auto ptr = std::unique_ptr<person>(new person{"Jan Kowalski", 2});

A delete?

Nigdy. Serio, we wszystkich wyżej opisanych przypadkach nie ma potrzeby ręcznego pisania delete. Jeśli Twój kod jest napisany w taki sposób, że jest to niezbędne – robisz coś źle.

A co jeśli nie jestem nowicjuszem?

W takim wypadku wszystko powyższe powinieneś już wiedzieć i stosować. Jedyne dodatkowe przypadki, gdzie sensowne jest ręczne użycie new to implementacja własnych kontenerów/smart pointerów lub alokatorów (wtedy również ma sens użycie delete) lub placement new. Ewentualnie new (std::nothrow) T jako argument konstruktora smart pointera.

Podsumowując

Użycie nagich new/delete jest niezalecane. Jeśli jesteś nowicjuszem: stosuj się do tych rad. W przeciwnym wypadku zdaj się na własne doświadczenie. Temat ten poruszyłem także w tym artykule.

17 thoughts on “Uczę się C++, kiedy używać new i delete?

  1. Artykuł ciekawy i pouczający, chociaż dla mnie trochę zdawkowy. Chętnie poczytam coś więcej na ten temat, czy polecasz jakąś książkę, która pozwala uczyć się C++ od podstaw bez wpadania w złe nawyki, m.in. takie, jak ten opisany powyżej?

    1. Później jeszcze przejrzę i postaram się zmniejszyć zdawkowość 🙂

      Jeśli chodzi o książki to ta lista jeszcze mnie nie zawiodła, chociaż pozycje dla nowicjuszy co najwyżej przejrzałem.

  2. Ok, a co w takim przypadku: załóżmy że pisze np grę.
    Mam klasę GameObject, po której dziedziczy Sprite, Object3d i dajmy na to LightSource
    Teraz żeby móc trzymać wszystkie obiekty w jednym miejscu i się wygodnie iterować robię
    std::vector vec;
    i wrzucanie na taki wektor wymaga użycia new a potem delete w usuwaniu z wektora.

    Jestem ciekaw czy da się mieć podobną funkcjonalność bez użycia new/delete?

    1. Trzymaj wtedy std::vector<std::unique_ptr<GameObject>>. Przy okazji nie będziesz musiał się bawić w ręczne wołanie delete 🙂 Ewentualnie, ostatnio pojawiają się opinie aby tego typu kolekcje trzymać jakos wektor wariantów (std::variant/boost::variant), ale to rozwiązanie sensowne dopiero od C++14/17.

      Aha, no i ogólnie w tym przypadku sensowne też może być boost::ptr_vector.

  3. Dziękuję. Przydało mi się. Niestety przesiadka z C#/Javy na C++ bywa trudna;)

    A propos “display lub process rzuci wyjątek” – ale C++ chyba też ma obsługę wyjątków, co nie? Można obsłużyć delete w catchu…

    1. Można. Ale problem jest taki, że ten catch (a więc i try) musi być wewnątrz tej funkcji, ponieważ jeśli będzie kilka wywołań wyżej to nie będziesz miał wskaźnika dla delete. Więc w takim przypadku musiałbyś ręcznie dodać try/catch dla każdego wywołania funkcji, która może rzucić wyjątek, w miejscu gdzie masz zaalokowane zasoby (tutaj pamięć). Jest to możliwe, ale szalenie niepraktyczne i i tak łatwo o pomyłkę (np. jeśli wskaźników do zwolnienia jest więcej, rodzajów wyjątków jest więcej, i jeśli jest kilka stopni zagłębienia try/catchów w funkcji 😉 )

      Zamiast się tak męczyć można sięgnąć po idiomatyczne rozwiązania i pozwolić kompilatorowi się wyręczyć w sposób bezpieczny i wydajny. Dzięki temu w dobrym kodzie C++ błędy typu goto fail nie mają prawa mieć miejsca.

  4. Jestem początkujący ale chyba za bardzo 🙂 wiem tylko z jakiś kursów internetowych, że new służy do tworzenia nowego obiektu danej klasy a delete kasuje tablicę utworzoną dynamicznie resztę słabo ogarniam z tego wpisu al e ucze się sam, mimo wszystko dzięki za artykuł i proszę o coś prostszego dla początkujących
    np: to co pisałeś ‘Tutaj wstawię link do wyjaśnienia czym jest wskaźnik/referencja/obiekt/klasa, gdy takie napiszę.’

    1. Te kursy są przestarzałe (albo niedokładnie je cytujesz 😉 ) Za pomocą new faktycznie możesz utworzyć nowy obiekt, i taki obiekt faktycznie powinien zostać usunięty za pomocą delete. Rzecz w tym, że, tak jak opisałem wyżej, jest to dość niebezpieczne, nakłada na programistę zbędne obowiązki nie oferując nic w zamian. Dlatego też jest to złą praktyką, zamiast której powinno się stosować zamienniki, takie jak smart pointery (głównie unique_ptr) i kontenery (std::vector jako domyślny).

  5. Nie wiem jak umieścić Ci tutaj kod. Pewnie się posypie..

    #include <iostream>
    #include <memory>
    #include <string>
     
    struct Person
    {
        std::string name;
        int number_of_hands;
    };
     
    namespace example {
     
    template<class T, class... U>
    auto make_unique_agregate(U&&... u) {
        return std::unique_ptr<T>(new T{std::forward<U>(u)...});
    }
     
    template<class T, class... U>
    auto make_shared_agregate(U&&... u) {
        return std::shared_ptr<T>(new T{std::forward<U>(u)...});
    }
     
    } // end example namespace 
     
    int main() {
        // Kilka ad-hoc testów.. 
        auto ptr = example::make_unique_agregate<Person>("Jan Kowalski", 2);
        std::cout << ptr->name << 'n'
            << ptr->number_of_hands << 'n';
        auto sPtr1 = example::make_shared_agregate<Person>("Adam Nowak", 8);
        auto sPtr2 = sPtr1;
        std::cout << "Count: " << sPtr2.use_count() << 'n';
    }
  6. Jak oceniłbyś dwa przypadki, gdy `new` potencjalnie może być niezbędne:
    – placement `new` do alokacji danych w puli pamięci (z grubsza: alokujesz na stercie pulę pamięci np. na 1000 obiektów, a placement `new` ‘dobiera’ z tejże puli kolejne bajty pamięci; po skończeniu pracy z pulą – orasz całość)
    – gdy Twoja aplikacja ma jak najszybciej wykonać jakiś kawałek kodu, a potem skonać – nie interesuje Cię “odzyskanie pamięci”, bo całość i tak zaraz zginie, ale z jakiegoś powodu nie możesz wrzucić danych na stos

    1. Przede wszystkim: tekst jest skierowany do nowicjuszy, więc takich tematów nawet nie podejmowałem.

      Placement new można rozumieć jako utworzenie/inicjalizację obiektu lub wywołanie konstruktora. W przykładzie opisałeś coś zbliżonego do standardowego wektora 😉 Mój główny problem jest ze “wskaźnikami posiadającymi”, w przypadku których ich użytkownik jest odpowiedzialny za zwolnienie zasobów. W przypadku placement new ten problem nie musi występować. Ale mimo wszystko jest to nieskopoziomowe narzędzie, którego używałbym z rozwagą. Rozmyślając na szybko, nie jestem w stanie wymyślić sensownego zastosowania placement new poza pisaniem własnego kontenera.

      W przypadku aplikacji “run and die”, faktycznie może to być szybsze (citation & benchmark needed). O ile dobrze pamiętam, GCC działa/działało w ten sposób. W przypadku samego kompilatora jest to sensowne, ale jest równocześnie jednym z powodów nieistnienia gcc jako biblioteki, w przeciwieństwie do clanga/LLVM. Inaczej mówiąc, jeśli dla Twojego use-case’u ma to sens, to nie widzę przeszkód; ale to powinna być świadoma decyzja, inaczej może spowodować problemy w przyszłości.

  7. Fajny artykul. Na pewno sie przyda poczatkujacym.

    Wkradl sie jednak blad:

    “Dzieje się tak najczęściej gdy nie ma jasno określonego czasu życia obiektu. Pozwala on na utrzymanie go przy życiu dopóki istnieje chociaż jeden std::unique_ptr na dany obiekt.”

    Moim zdaniem powinno byc: “dopóki istnieje chociaż jeden std::shared_ptr na dany obiekt.” Istnienie wiecej niz jednego unique_ptr dla tego samego obiektu jest bledem. Nazwa unique_ptr o tym swiadczy oraz fakt, ze unique_ptr ma move constructor a nie ma copy constructora.

Leave a Reply

Your email address will not be published.