CodeGym /Kursy /C++ SELF /Zasada modern C++: unikamy delete w kodzie aplikacyjnym

Zasada modern C++: unikamy delete w kodzie aplikacyjnym

C++ SELF
Poziom 42, Lekcja 2
Dostępny

1. delete w kodzie aplikacyjnym — znak problemu

Jeśli jesteś początkujący, delete często wydaje się czymś w rodzaju „dorosłej magii”: o, teraz ręcznie zarządzam pamięcią, więc jestem prawdziwym programistą C++. W praktyce jest odwrotnie: im częściej w kodzie aplikacyjnym przewija się delete, tym większe prawdopodobieństwo, że program będzie się sypał „gdzie indziej”, „czasami”, „po piątym uruchomieniu”, „tylko na komputerze prowadzącego”. I nie dlatego, że C++ jest zły, lecz dlatego, że ręczne zarządzanie zasobami wymaga żelaznej dyscypliny i bardzo jasnych kontraktów własności.

Warto doprecyzować termin „kod aplikacyjny”. To twój kod biznesowy: obsługa poleceń, przechowywanie zadań, liczenie statystyk, formatowanie wyjścia, praca z modelami danych. To nie jest niskopoziomowy fragment biblioteki standardowej ani odrębna „systemowa” podsystem zajmujący się zasobami. W kodzie aplikacyjnym chcesz myśleć o zadaniach, a nie o tym, kto miał wywołać delete i czy o tym pamiętał.

Ciekawostka: wymagania dotyczące deklaracji operator delete zmieniały się między standardami, i w nowoczesnym C++ powinien on być noexcept. To tylko podkreśla myśl: nawet „wnętrze języka” żyje według surowych kontraktów — w kodzie aplikacyjnym takie kontrakty trudno utrzymać ręcznie i jest to ryzykowne.

Zasada dnia: jeśli chcesz napisać delete, zatrzymaj się

Ta zasada brzmi trochę patetycznie, ale jest bardzo praktyczna: gdy ręka sięga po delete, warto zrobić mikro‑pauzę i mentalnie zadać dwa pytania.

Pierwsze: „Dlaczego w ogóle mam goły T*, który trzeba zwalniać ręcznie?”

Drugie: „Dlaczego zwalnianie zasobu nie znajduje się w jednym jedynym miejscu, które na pewno zostanie wywołane?”

Problem delete rzadko polega na tym, że „nie pamiętasz składni”. Problem w tym, że delete — to operacja zakończenia czasu życia i zwolnienia pamięci, i musi ona zajść dokładnie raz. Jeśli robisz to „po drodze” w logice biznesowej, przekształcasz logikę biznesową w mini‑dispatcher pamięci, który musi pamiętać za dużo.

Przy okazji, standard rozróżnia „usuwanie pojedynczego obiektu” i „usuwanie tablicy” jako różne formy wyrażeń usuwania. Wyobraź sobie: piszesz program „menedżer zadań”, a dodatkowo musisz w pamięci trzymać, która forma usuwania powinna być użyta. To zbędne obciążenie umysłowe, które niczego nie czyni programu użyteczniejszym.

2. Strategie: jak żyć bez delete

Nie alokuj pamięci ręcznie: std::vector zamiast new[]

Bardzo typowa historia początkującego: „Potrzebuję listy zadań, ale rozmiar nieznany — więc trzeba użyć new”. To rozumowanie jest zrozumiałe, ale w nowoczesnym C++ zwykle złe w doborze narzędzia. Jeśli potrzebujesz „tablicy o zmiennym rozmiarze”, to niemal zawsze to std::vector, a nie new[].

Kontynuujmy nasze konsolowe ćwiczenie — niech to będzie TaskBook: prosty program, który przechowuje zadania i je wypisuje. Kiedyś trzymalibyśmy zadania w std::vector<Task>, i to już prawidłowa droga w modern C++. Ale specjalnie zasymulujemy „zły skręt”: ktoś postanowił przechowywać zadania jako wskaźniki, bo „tak elastyczniej”.

Zły wariant: std::vector<Task*> i ręczne delete

Od razu ważna uwaga: poniższy przykład to ilustracja projektu, a nie coś, co chcemy powielać.

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
};

int main() {
    std::vector<Task*> tasks;
    tasks.push_back(new Task{1, "Przeczytać o RAII"});
    // ... a potem trzeba gdzieś wszystko usunąć ...
}

Problem nawet nie w tym, że tutaj nie ma jeszcze delete (na razie). Problem w tym, że musisz go gdzieś napisać, i musisz zrobić to poprawnie: usunąć każdy element dokładnie raz, nie zapomnieć żadnego, nie usunąć cudzego, nie usunąć dwa razy.

Dobry wariant: std::vector<Task> — własność przez wartość

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
};

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, "Przeczytać o RAII"});
}

Tutaj nie ma ani new, ani delete. I to nie jest „uproszczenie dla początkujących”, lecz styl nowoczesnego C++: kontener zarządza pamięcią, sam się rozszerza i zwalnia wszystko w destruktorze.

Aby uczynić myśl jeszcze bardziej praktyczną, warto mieć małą tabelę „co potrzebuję” → „co zwykle wybieram w modern C++”:

Co potrzebujesz w zadaniu Pomysł początkującego Rozwiązanie w modern C++
Tablica o zmiennej długości
new T[n]
std::vector<T>
Łańcuch o zmiennej długości
new char[n]
std::string
„Może być wartość, a może nie” -1, nullptr, „magia”
std::optional<T>
Tymczasowy bufor znaków char* + delete[] std::vector<char> lub std::string

Uczyń własność oczywistą: „surowe” wskaźniki nie powinny być właścicielami

Jedna z głównych przyczyn, dla których delete jest tak niebezpieczny w kodzie aplikacyjnym — to że typ T* zwykle nie mówi, kto jest właścicielem. Wskaźnik to tylko adres. Doskonale nadaje się do „spójrz tam” lub „może tam coś jest”, ale jest fatalny jako „to ja odpowiadam za zwolnienie”.

Warto uzgodnić proste (i praktyczne) rozróżnienie znaczeń.

Referencja T& zwykle oznacza: „obiekt na pewno istnieje, nie jest null, po prostu z nim pracuję”.

Wskaźnik T* zwykle oznacza: „obiekt może nie istnieć (nullptr), pracuję z nim, ale niekoniecznie jestem właścicielem”.

I bardzo ważne: domyślnie parametr typu T* nie powinien oznaczać „weź własność i usuń”.

Zobaczmy przykład z TaskBook: chcemy znaleźć zadanie po id. Jeśli przechowywanie jest przez wartość (std::vector<Task>), można zwrócić wskaźnik do elementu jako „nie‑właściciela”, czyli „spójrz, oto ono, jeśli znalezione”.

#include <vector>
#include <string>

struct Task { int id{}; std::string title; };

Task* find_task_by_id(std::vector<Task>& tasks, int id) {
    for (auto& t : tasks) {
        if (t.id == id) return &t;
    }
    return nullptr;
}

Tutaj Task* — to nie kwestia własności, a możliwości „nie znalezienia”. I to jest w porządku. Problem zaczyna się, gdy ktoś w kodzie pomyśli: „O, Task*, trzeba usuwać”. Nie! Ten wskaźnik wskazuje na element wewnątrz std::vector i nie wolno go (ani nie trzeba) usuwać.

Aby utrwalić kontrakt, można zrobić funkcję drukującą, która niczego nie usuwa i po prostu szanuje nullable‑parametr:

#include <iostream>

void print_task(const Task* t) {
    if (t == nullptr) {
        std::cout << "Task not found\n";
        return;
    }
    std::cout << t->id << ": " << t->title << '\n';
}

Zwróć uwagę na styl: wyraźnie pokazujemy, że nullptr to dopuszczalny scenariusz. To jest „nullable‑design” bez własności.

Zwracaj wyniki przez wartość

Początkujący często zaczynają pisać funkcje zwracające wskaźnik, bo „w końcu to C++, więc tak trzeba”. W praktyce zwracanie T* z funkcji prawie zawsze oznacza kontrakt „zgadnij, kto potem robi delete”.

Porównajmy dwa podejścia.

Zły kontrakt: funkcja tworzy new i zwraca T*

#include <string>

struct Task { int id{}; std::string title; };

Task* create_task_bad(int id, const std::string& title) {
    return new Task{id, title}; // po typie nie widać, kto powinien delete
}

Jeśli widzisz taki kod w projekcie aplikacyjnym — to niemal gwarancja wycieku lub double‑free w przyszłości. Proste: za tydzień zapomnisz, kto jest odpowiedzialny.

Dobry kontrakt: funkcja zwraca obiekt przez wartość

#include <string>

struct Task { int id{}; std::string title; };

Task create_task(int id, const std::string& title) {
    return Task{id, title};
}

Teraz w kodzie użycia wszystko staje się spokojniejsze:

#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back(create_task(1, "Zrobić herbatę"));
}

Żadnych new, żadnych delete. Własność żyje tam, gdzie znajdują się dane: w tasks.

Ukryj delete w RAII‑właścicielu i nie wypuszczaj na zewnątrz

Zdarzają się sytuacje, gdy z powodów edukacyjnych lub ze względu na specyficzne API musisz alokować przez new. Na przykład piszesz edukacyjny bufor albo musisz współpracować z API zwracającym „surowy” zasób. Na dzisiejszym spotkaniu nie katalogujemy wszystkich gotowych właścicieli, ale zostaje jedna myśl: jeśli delete już się pojawi, powinien on być nie w logice biznesowej, lecz wewnątrz właściciela.

Zrobimy prostą RAII‑opakówkę dla tablicy int, aby poczuć ten wzorzec. Tak, w realnym kodzie częściej wzięlibyśmy std::vector<int>, ale ważne jest zobaczyć sam trick.

struct IntBuffer {
    int* data{nullptr};

    ~IntBuffer() {
        delete[] data;
    }
};

Teraz w kodzie aplikacyjnym możesz używać bufora bez jawnego delete[]:

#include <iostream>

int main() {
    IntBuffer buf{new int[3]{10, 20, 30}};
    std::cout << buf.data[1] << '\n'; // 20
}

Zauważ, jak to zmienia „psychologię kodu”. Nie myślisz już „gdzie mogę zapomnieć delete[]”. Myślisz „buf żyje tyle, ile żyje obiekt buf”. To jest RAII.

Jest tu subtelny punkt, który omawiamy wprost słowami, bez wchodzenia w głębsze tematy: takich właścicieli nie wolno bezmyślnie kopiować, bo otrzymasz dwóch właścicieli tego samego adresu i znowu trafisz na double‑free. Dlatego teraz prosta zasada: trzymaj RAII‑właściciela lokalnie i przekazuj przez referencję, nie próbuj go kopiować „jak liczbę”.

Trzymaj jedną odpowiedzialność: delete nie powinien mieszkać w gałęziach

Nawet jeśli tymczasowo piszesz kod z ręczną pamięcią (np. w eksperymentach edukacyjnych), istnieje diagnostyka: jeśli masz delete w kilku miejscach funkcji, szczególnie w różnych gałęziach if i przed różnymi return, kod staje się już kruchy.

Porównajmy dwa style.

Kruche podejście: delete w każdej gałęzi

#include <iostream>

int main() {
    int* p = new int{5};

    bool ok = false;
    if (!ok) {
        delete p;
        return 0;
    }

    std::cout << *p << '\n';
    delete p;
}

Formalnie da się to napisać poprawnie, ale to ćwiczenie na bystrość, a nie zdrowy rozsądek. Każda nowa gałąź — to nowa szansa, by o czymś zapomnieć.

Spokojny styl: zwalnianie robi właściciel w destruktorze

#include <iostream>

struct IntOwner {
    int* p{nullptr};
    ~IntOwner() { delete p; }
};

int main() {
    IntOwner x{new int{5}};

    bool ok = false;
    if (!ok) return 0;

    std::cout << *x.p << '\n';
}

Teraz gałęzie if mogą mnożyć się bez końca, a zwalnianie nie rozmywa się: i tak nastąpi raz w jednym miejscu — w destruktorze właściciela.

Mini‑schemat decyzji bez delete

Gdy projektujesz fragment kodu i nagle łapiesz się na myśli „gdzie tu miałbym wpisać delete”, warto mieć krótką „schemat‑blok” w głowie. Nie jest to magia, ale dobrze sprowadza na tory modern C++.

flowchart TD
    A["Potrzebny zasób / pamięć o zmiennej wielkości"] --> B{"Czy można przechowywać przez wartość?"}
    B -->|Tak| C["Używamy std::string / std::vector / struct"]
    B -->|Nie| D{"Czy można schować zasób w RAII‑właścicielu?"}
    D -->|Tak| E["Piszesz mały obiekt‑właściciel z destruktorem"]
    D -->|Nie| F["Przeglądasz projekt: gdzie jest granica odpowiedzialności?"]
    C --> G["W kodzie aplikacyjnym brak delete"]
    E --> G

Sens nie w tym, by zapamiętać mermaid, lecz w dyscyplinie: pierwsze pytanie zawsze dotyczy wartości i kontenerów, drugie — RAII i jednej odpowiedzialnej lokalizacji, i dopiero na końcu przyznajesz, że trzeba przejrzeć projekt.

4. Typowe błędy

Błąd nr 1: „Używam new, bo rozmiar nieznany”.
To jedna z najczęstszych przyczyn pojawienia się delete tam, gdzie nie ma dla niego miejsca. Nieznany rozmiar — to nie powód, by iść w new[], to powód, by wziąć std::vector. Vector istnieje po to, byś nie pisał kodu „alokuję → kopiuję → nie pamiętam, by zwolnić stare”. W kodzie aplikacyjnym dynamiczna tablica prawie zawsze powinna być vectorem.

Błąd nr 2: zwracać T* z funkcji jako „wynik”, nie wyjaśniając własności.
Taki interfejs zmusza wywołującego do zgadywania: „czy mam to usuwać czy nie?”. Jeśli trzeba — gdzie jest opisane, kiedy i czym? Jeśli nie — to po co wskaźnik? Znacznie bezpieczniej zwracać przez wartość (T, std::string, std::vector<T>) albo użyć std::optional<T>, gdy wynik może być nieobecny.

Błąd nr 3: trzymać „właścicielskie” wskaźniki w kontenerze bez jasnej zasady czyszczenia.
std::vector<T*> sam w sobie nie jest zły typem, ale dla początkującego w zadaniu aplikacyjnym to prawie zawsze zły pomysł: musisz napisać czyszczenie, pamiętać o nim we wszystkich scenariuszach wyjścia i nie dopuścić do podwójnego zwolnienia. W aplikacyjnych przykładach niemal zawsze można zastąpić to std::vector<T> i skreślić cały zestaw problemów.

Błąd nr 4: myśleć, że „po delete wskaźnik staje się nullptr”.
Po delete zmienna p; wciąż przechowuje stary adres (jedynie ten adres już nie wskazuje na żywy obiekt). To dangling pointer. Ustawienie p = nullptr; jest użyteczne jako dyscyplina, ale w modern C++ lepiej unikać sytuacji, w których ręcznie przypisujesz nullptr po całym kodzie, i przechowywać zasób wewnątrz RAII‑właściciela lub kontenera.

Błąd nr 5: rozmazywać delete po gałęziach i „łatać” wycieki dodaniem kolejnego delete w nowym miejscu.
To typowa droga do double‑free: najpierw zapomniałeś usunąć w jednej gałęzi, potem „dodałeś usuwanie”, ale nie zauważyłeś, że w innej gałęzi usuwanie już było. Jeśli łapiesz się na takim „remoncie”, właściwy krok — zatrzymać się i przenieść zwalnianie do destruktora właściciela, aby było w jednym punkcie odpowiedzialności.

1
Zadanie
C++ SELF, poziom 42, lekcja 2
Niedostępne
Podsumowanie pomiarów
Podsumowanie pomiarów
1
Zadanie
C++ SELF, poziom 42, lekcja 2
Niedostępne
Notatnik wierszy
Notatnik wierszy
1
Zadanie
C++ SELF, poziom 42, lekcja 2
Niedostępne
Wyszukiwanie bez własności
Wyszukiwanie bez własności
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION