1. Po co potrzebny custom deleter
Kiedy dopiero zaczynasz, wydaje się, że świat dzieli się na dwie części: „jest pamięć” i „nie ma pamięci”. Dlatego zwalnianie wydaje się uniwersalne: skoro jest wskaźnik — to delete. Ale bardzo szybko okazuje się, że w rzeczywistym życiu program ma wiele zasobów, które wyglądają jak „jakakolwiek wartość”, lecz są zwalniane specjalną funkcją: plik trzeba zamknąć, socket — zamknąć, uchwyt systemowy — zwolnić.
Często nazywa się to handle („uchwyt zasobu”): nie sam zasób, lecz obiekt/wskaźnik/liczba, przez który nim zarządzasz.
Najbardziej zrozumiały przykład edukacyjny to FILE* z C. To wskaźnik, ale on nie oznacza „pamięć przydzielona przez new”. To „uchwyt otwartego pliku”. Otwiera się go przez fopen, zamyka przez fclose. A jeśli spróbujesz zamknąć plik przez delete, to jest to mniej więcej jak próba wyłączyć komputer pilotem od telewizora: przyciski są, ale sensu brak (i skutki mogą być nieprzyjemne).
I tutaj pojawia się główna idea: std::unique_ptr — to nie „inteligentny delete”, a uniwersalne RAII-opakowanie. Po prostu domyślnie rzeczywiście wywołuje delete. Ale możemy nauczyć je wywoływać dowolną funkcję zwalniającą.
Czym jest deleter i jak unique_ptr rozumie, czym zwalniać
Deleter — to obiekt (lub funkcja), który potrafi „poprawnie zwolnić zasób”. Dla zwykłego std::unique_ptr<int> deleter domyślny to coś w stylu „wywołaj delete”.
Ale jeśli zasób jest zwalniany inaczej, możemy powiedzieć unique_ptr: „gdy będziesz niszczony (albo wykonasz reset()), nie rób delete, tylko wywołaj tę funkcję”.
Składnia wygląda tak:
std::unique_ptr<T, Deleter> p;
Czyli unique_ptr zyskuje drugi parametr szablonu — typ deletera. I tu ważna uwaga: std::unique_ptr<FILE, FileCloser> oraz std::unique_ptr<FILE, AnotherCloser> — to różne typy, nie można ich po prostu przypisać między sobą.
Dlatego w praktycznym kodzie prawie zawsze robi się alias typu (using), aby nie rozpraszać „złożonej nazwy” po projekcie.
2. FILE* pod RAII: fclose bez ręcznego zamykania
Mini-przykład: FILE* + fclose przez funktor-deleter
Teraz stworzymy praktyczną rzecz: plik zostanie zamknięty automatycznie, nawet jeśli wcześniej opuścimy funkcję. To jest właśnie RAII, tylko nie dla new/delete, lecz dla fopen/fclose.
Zacznijmy od małego deletera. Najbardziej zrozumiały styl — struct z operator() (nazywa się to funktor):
#include <cstdio>
struct FileCloser {
void operator()(std::FILE* f) const {
if (f) {
std::fclose(f);
}
}
};
Zwróć uwagę: deleter spokojnie przyjmuje nullptr. To użyteczna dyscyplina, ponieważ unique_ptr może być pusty, i zamykanie „niczego” — to normalna sytuacja.
Teraz zadeklarujmy wygodny alias typu:
#include <memory>
#include <cstdio>
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
I otwórzmy plik tak, aby od razu trafił pod zarządzanie:
#include <cstdio>
#include <memory>
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
int main() {
FilePtr f(std::fopen("log.txt", "w"));
if (!f) {
return 1;
}
} // tutaj automatycznie zostanie wywołane fclose przez FileCloser
Ważne jest, aby uchwycić sens: nie pisaliśmy delete, ale uzyskaliśmy to samo udogodnienie, co z normalnym unique_ptr: zasoby są zwalniane automatycznie przy wyjściu z zakresu.
Wbudowanie w aplikację: logger na FILE* bez ręcznego fclose
Aby nie było wrażenia „przykład dla przykładu”, kontynuujmy edukacyjny projekt konsolowy (umownie TodoLite). Dodajmy ideę „zapisywania logu do pliku”, ale tak, aby nie pojawiało się ręczne fclose w każdym kącie.
Zróbmy kontekst aplikacji z „uchwytem” pliku logu:
#include <cstdio>
#include <memory>
#include <string>
struct FileCloser {
void operator()(std::FILE* f) const {
if (f) std::fclose(f);
}
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
struct AppContext {
FilePtr log;
};
Teraz funkcja zapisująca do logu. Zwróć uwagę: nie jesteśmy właścicielem pliku w tej funkcji, tylko go używamy. Dlatego bierzemy kontekst przez referencję i odwołujemy się do ctx.log.get().
#include <cstdio>
#include <string>
void write_log(AppContext& ctx, const std::string& line) {
if (!ctx.log) return;
std::fputs(line.c_str(), ctx.log.get());
std::fputs("\n", ctx.log.get());
}
Inicjalizacja kontekstu:
#include <cstdio>
AppContext make_context() {
AppContext ctx;
ctx.log = FilePtr(std::fopen("todolite.log", "a"));
return ctx;
}
Nawet jeśli zapomnisz „zamknąć plik”, to przy zakończeniu main (albo przy wyjściu z bieżącego bloku) unique_ptr wywoła deleter i plik zostanie zamknięty poprawnie.
Deleter jako wskaźnik na funkcję: &std::fclose
Czasami chce się napisać wszystko bez struct FileCloser. W przypadku FILE* można to zrobić, ponieważ mamy już funkcję zwalniającą std::fclose. Możemy trzymać wskaźnik na funkcję jako deleter.
#include <cstdio>
#include <memory>
int main() {
using FilePtr = std::unique_ptr<std::FILE, decltype(&std::fclose)>;
FilePtr f(std::fopen("log.txt", "w"), &std::fclose);
if (!f) return 1;
std::fputs("Hello!\n", f.get());
}
Dwa subtelne punkty.
Pierwszy: deleter przekazywany jest do konstruktora jako drugi argument. Jeśli zapomnisz &std::fclose, kod się nie skompiluje, ponieważ unique_ptr nie będzie w stanie „domyślić się”, jakiego deletera użyć.
Drugi: typ FilePtr staje się bardziej „szablonowy”. W projekcie edukacyjnym to OK, ale w dużym kodzie często wolą struct FileCloser + using, bo tak łatwiej czytać i utrzymywać kod.
get() i release() przy pracy z C-API
W realnych projektach prawie zawsze znajdziesz się w sytuacji: „mam właściciela RAII, ale biblioteka C przyjmuje surowy T*”. I to normalne: większość C-API nie zna unique_ptr.
Rozwiązanie proste: przekazuj get(), i pamiętaj, że własność nie jest przekazywana.
Mini-przykład: wypisujemy linię do pliku.
#include <cstdio>
void print_line(std::FILE* f) {
if (!f) return;
std::fputs("line\n", f);
}
int main() {
FilePtr f(std::fopen("a.txt", "w"));
print_line(f.get());
} // fclose zostanie wywołane automatycznie
Jeśli pamiętasz dyscyplinę „get() nie przekazuje własności”, to praktycznie automatycznie unikasz dwóch głównych problemów: podwójnego zwolnienia i use-after-free.
Z release() sprawa wygląda inaczej: on sprawia, że unique_ptr staje się pusty i zwraca surowy wskaźnik. Ale przy custom deleter pojawia się dodatkowa pułapka: zasób trzeba zamknąć nie przez delete, lecz w sposób, dla którego zaprojektowano deleter.
#include <cstdio>
int main() {
FilePtr f(std::fopen("a.txt", "w"));
std::FILE* raw = f.release(); // f przestaje być właścicielem
if (raw) {
std::fclose(raw); // zamykamy ręcznie (nie delete!)
}
}
To działający kod, ale wracamy do świata „ręcznego zarządzania zasobami”. Dlatego najbezpieczniejszy styl użycia release() — „natychmiast przekaż zasób następnemu właścicielowi”:
#include <cstdio>
int main() {
FilePtr a(std::fopen("a.txt", "w"));
std::FILE* raw = a.release();
FilePtr b(raw); // b jest teraz właścicielem
} // b zamknie plik, a jest już puste
Jeśli widzisz release(), to prawie zawsze warto zapytać siebie: „kto teraz jest właścicielem i kto jest odpowiedzialny za zwolnienie?”.
Schemat cyklu życia FILE* pod unique_ptr
Czasami pomaga wizualizacja. Oto prosta blokowa schemat „stworzono zasób → pracujemy → automatyczne zamknięcie”:
flowchart TD
A["std::fopen(...) -> FILE*"] --> B["FilePtr(file) bierze własność"]
B --> C["Pracujemy: f.get() przekazujemy do fputs/fprintf"]
C --> D["Wyjście ze scope / reset()"]
D --> E["FileCloser::operator() -> std::fclose(file)"]
Sens diagramu jest prosty: w kodzie znika etap „ręcznego zamykania”, a co za tym idzie znika cały zbiór błędów typu „opuściliśmy funkcję wcześniej i zapomnieliśmy zamknąć”.
3. Mechanika unique_ptr i zasady prostego projektowania
Kiedy unique_ptr wywołuje deleter
Warto przegadać mechanikę, bo wiele błędów to „myślałem, że zostanie zwolnione tu, a ono zwalnia się tam”.
unique_ptr wywołuje deleter w tych samych sytuacjach, w których wywołałby delete w zwykłym przypadku:
| Zdarzenie w życiu unique_ptr | Co się dzieje z zasobem |
|---|---|
| unique_ptr jest niszczony (wyjście ze scope) | wywoływany jest deleter |
| reset() bez argumentu | wywoływany jest deleter dla starego zasobu, wskaźnik staje się nullptr |
| reset(new_ptr) | deleter dla starego zasobu, potem przechowywany jest nowy |
| przypisanie przez przeniesienie (p = std::move(q)) | deleter dla starego zasobu p, potem p przejmuje zasób z q |
|
deleter nie jest wywoływany, odpowiedzialność przechodzi na zewnątrz |
Dlatego release() pozostaje „niebezpiecznym przyciskiem” także w przypadku custom deleter: wyjęliście zasób spod RAII i teraz musicie zamknąć go ręcznie odpowiednią funkcją, a nie byle czym.
Jak nie komplikować projektu: trzy praktyczne zasady
Bardzo łatwo zamienić custom deleter w „szablonową magię dla samej magii szablonów”. Pomogą trzy zasady.
Pierwsza zasada: jeśli zasób występuje w projekcie więcej niż raz, nie przechowuj unique_ptr<..., decltype(lambda)> wszędzie. Zrób struct Deleter i using Alias = unique_ptr<...>. To zamienia straszną nazwę z powrotem w czytelną i nie zmusza czytelnika kodu do analizowania decltype.
Druga zasada: deleter powinien być nudny. Jego zadanie — jedno: zwolnić zasób. Nie pisz w deleterze logowania, retryów, „jeśli nie zamknęło się — otwórz jeszcze raz” i innych historii. Jeśli potrzebujesz złożonej polityki — lepiej osobna funkcja, która przyjmuje właściciela zasobu i wykonuje złożoną logikę jawnie.
Trzecia zasada: unikaj używania release(), jeśli nie masz bardzo jasnej odpowiedzi „komu dalej przekazuję własność”. Szczególnie nie używaj release() „tylko żeby dostać T*”. Do „obejrzenia” jest get().
Uchwyt zasobu nie musi być wskaźnikiem
Aby utrwalić ideę, warto zobaczyć, że custom deleter to ogólnie rzecz biorąc „dowolne zasoby”, nie tylko pliki.
Wyobraź sobie bibliotekę C (umownie), która tworzy jakiś zasób i zwraca wskaźnik:
struct Connection; // typ ukryty w bibliotece
Connection* connect_create();
void connect_close(Connection* c);
W twoim kodzie robisz to samo, co z FILE*: piszesz deleter i dostajesz RAII-właściciela.
#include <memory>
struct ConnectionCloser {
void operator()(Connection* c) const {
if (c) connect_close(c);
}
};
using ConnPtr = std::unique_ptr<Connection, ConnectionCloser>;
I teraz logika staje się „domyślnie poprawna”: stworzyłeś, opakowałeś, i dalej po prostu przekazujesz ConnPtr tam, gdzie potrzebne jest posiadanie, albo c.get() tam, gdzie potrzeba tylko użycia.
Nawet jeśli konkretny zasób w realnej bibliotece nie jest wskaźnikiem (czasem handle to liczba, np. int), zasada pozostaje ta sama. Dziś nie zagłębiamy się w takie warianty, ale ważna jest myśl: unique_ptr + deleter — to sposób, by powiedzieć kodowi: „oto zasady sprzątania, wykonaj je automatycznie”.
4. Typowe błędy przy użyciu custom deleter
Błąd nr 1: używać std::unique_ptr<T> domyślnie dla zasobu, który nie można zwalniać przez delete.
To najczęstszy logiczny błąd: skoro „mam wskaźnik”, to „unique_ptr bez drugiego parametru”. Ale jeśli zasób zamykany jest funkcją close_xxx, fclose, DestroyHandle itd., to unique_ptr musi znać właściwy deleter. Inaczej dostaniesz niezdefiniowane zachowanie (w najlepszym razie — crash, w najgorszym — ciche zepsucie pamięci).
Błąd nr 2: ręcznie zamknąć zasób, dopóki nadal jest pod unique_ptr.
Na przykład, wywołać std::fclose(f.get()) mając żyjący FilePtr f. Wygląda niewinnie, ale tak naprawdę zamknąłeś zasób „przedwcześnie”, a potem unique_ptr spróbuje zamknąć go jeszcze raz w destruktorze. Z punktu widzenia modelu własności to podwójne zwolnienie, tylko nie przez delete, lecz przez fclose.
Błąd nr 3: trzymać „surowe obserwacje” zbyt długo.
Jeśli pobrałeś std::FILE* raw = f.get(), a potem gdzieś wykonałeś f.reset(), to raw staje się potencjalnie wiszącym wskaźnikiem. W przykładach edukacyjnych rzadko się to ujawnia, ale w realnych programach tacy „obserwatorzy” lubię przeżyć właściciela i stać się błędem, który ujawnia się w najmniej odpowiednim momencie.
Błąd nr 4: release() bez planu „kto będzie następny właściciel”.
release() to legalne narzędzie, ale ono gwałtownie wyłącza RAII i przekazuje odpowiedzialność programiście. Nowicjusze często używają release() „po prostu żeby dostać wskaźnik”, chociaż do tego jest get(). Jeśli wywołałeś release(), musisz mieć bardzo konkretną odpowiedź na pytanie „kto teraz zwolni zasób i jak”.
Błąd nr 5: komplikować deleter do poziomu „mini-frameworka”.
Deleter to nie miejsce na logikę biznesową. Im więcej warunków i efektów ubocznych w nim, tym trudniej zrozumieć, co się stanie przy wyjściu ze scope (a to może się wydarzyć w dowolnym miejscu). Dobry deleter zwykle mieści się w 3–5 liniach i wykonuje dokładnie jedną operację zwalniania.
GO TO FULL VERSION