Mroczne czasy surowych wskaźników, czyli dlaczego new i delete to dziś ostateczność
Zarządzanie pamięcią od lat bywa zmorą programistów C i C++. Kiedyś polegaliśmy wyłącznie na tzw. surowych wskaźnikach, alokowaliśmy obiekt operatorem new i musieliśmy pamiętać, by zwolnić go za pomocą delete. Niestety, poleganie na ludzkiej pamięci szybko prowadzi do wycieków pamięci. Co gorsza, nawet jeśli napiszemy delete na końcu funkcji, niespodziewany wyjątek wyrzucony w jej trakcie może sprawić, że kod sprzątający nigdy się nie wykona.
Na szczęście w nowoczesnym C++ (od standardu C++11) ręczne zarządzanie pamięcią to rzadkość. Język mocno opiera się teraz na koncepcji RAII, a jej głównym narzędziem są inteligentne wskaźniki (smart pointers). Zwalniają one pamięć w pełni automatycznie, gdy tylko przestają być potrzebne. Dzięki nim kod jest krótszy, odporny na wyjątki i po prostu bezpieczniejszy. Zobaczmy, jak to działa w praktyce!
Czym jest wyciek pamięci?
Wyciek pamięci to sytuacja, w której program rezerwuje miejsce w pamięci RAM, ale po zakończeniu pracy z danym obiektem zapomina je zwolnić. Ponieważ system operacyjny uważa te zasoby za wciąż używane, zablokowana pamięć staje się całkowicie bezużyteczna dla reszty systemu. Z czasem, gdy takich „zagubionych” bajtów przybywa, aplikacja powoli pochłania kolejne zasoby komputera, co ostatecznie prowadzi do drastycznego spadku wydajności lub nawet nagłego zamknięcia programu.
Memory tracker
Zanim przejdziemy do rzeczy, przygotujemy sobie klasę, która będzie śledzić momenty konstruowana i niszczenia obiektu. Dzięki niej w kolejnych akapitach będziemy obserwować, że inteligentne wskaźniki faktycznie same sprzątają pamięć w odpowiednim momencie, bez jakiejkolwiek ingerencji programisty. Klasa ta powinna się znaleźć w każdym następnym przykładzie, ja nie będę jej tam powielał dla poprawienia czytelności.
#include <iostream>
class MemoryTracker
{
public:
MemoryTracker(const char * n): _nazwa{n} {
std::cout << "Construct: " << _nazwa << std::endl;
}
~MemoryTracker() {
std::cout << "~Destruct: " << _nazwa << std::endl;
}
private:
const char * _nazwa;
};Praca z surowym wskaźnikiem
Ten prosty eksperyment idealnie obrazuje problem wycieku pamięci. Obiekt „Pierwszy” został poprawnie usunięty, ponieważ wywołaliśmy dla niego instrukcję delete. Z kolei obiekt „Drugi” po cichu zajął pamięć RAM i został w niej porzucony – jego destruktor nigdy się nie uruchomił, co jest namacalnym dowodem na błąd w zarządzaniu zasobami.
int main() {
std::cout << "--- start ---" << std::endl;
// Alokujemy dwa obiekty na stercie za pomoca surowych wskaznikow
MemoryTracker* obiekt1 = new MemoryTracker("First");
MemoryTracker* obiekt2 = new MemoryTracker("Second");
//
// O pierwszym pamietamy i poprawnie go zwalniamy
delete obiekt1;
// O drugim celowo zapominamy, aby zilustrowac problem
// delete obiekt2;
std::cout << "--- end ---" << std::endl;
return 0;
}wynik działania tego programu będzie następujący
--- start ---
Construct: First
Construct: Second
~Destruct: First
--- end ---Widać w nim wyraźnie, że brakuje nam ~Destruct: Second
Warto jednak pamiętać, że wyciek pamięci jest problemem tylko tak długo, jak długo działa nasz program. W momencie zamknięcia procesu system operacyjny automatycznie oczyszcza całą jego przestrzeń adresową i odzyskuje zasoby (o ile nie ma takich błędów w samym jądrze systemu). Choć po wyłączeniu aplikacji pamięć RAM bezpiecznie wraca do puli, to błąd ten wciąż pozostaje ogromnym zagrożeniem dla programów, które muszą działać bez przerwy, na przykład na serwerach.
Gwiazda wieczoru – std::unique_ptr – wyłączny właściciel
std::unique_ptr to najprostszy, najszybszy i najczęściej używany inteligentny wskaźnik w nowoczesnym C++. Jego działanie opiera się na zasadzie wyłącznej własności (exclusive ownership). Oznacza to, że dany obiekt w pamięci może mieć tylko jednego właściciela – żaden inny unique_ptr nie może go skopiować ani się nim współdzielić. Kiedy wskaźnik ten kończy swój żywot (na przykład funkcja dobiega końca lub zamyka się blok klamrowy {}), automatycznie wywołuje on destruktor i zwalnia pamięć. Od standardu C++14 zalecaną metodą jego tworzenia jest funkcja std::make_unique, która eliminuje potrzebę bezpośredniego używania słowa kluczowego new.
Zobaczmy, jak std::unique_ptr radzi sobie z automatycznym sprzątaniem w praktyce, używając naszej klasy MemoryTracker:
#include <memory>
int main() {
std::cout << "--- start ---" << std::endl;
{
// Tworzymy inteligentny wskaznik za pomoca std::make_unique
// Obiekt zostaje powołany do życia wewnątrz tego bloku klamrowego
auto obiekt = std::make_unique<MemoryTracker>("TheOnlyOne");
std::cout << "Obiekt jest uzywany wewnatrz bloku." << std::endl;
} // W tym miejscu blok sie konczy. 'obiekt' wychodzi poza zakres i bedzie zniszczony.
std::cout << "--- end ---" << std::endl;
return 0;
}Po uruchomieniu programu w konsoli pojawi się następujący wynik:
--- start ---
Construct: TheOnlyOne
Obiekt jest uzywany wewnatrz bloku.
~Destruct: TheOnlyOne
--- end ---Zwróć uwagę na moment, w którym wywołał się destruktor („~Destruct”). Stało się to przed linijką „— end —„, dokładnie w momencie zamknięcia wewnętrznego bloku klamrowego. std::unique_ptr zauważył, że jego czas życia dobiegł końca, i sam posprzątał pamięć. Nie musieliśmy pamiętać o umieszczeniu instrukcji delete.
O co chodzi z tą własnością?
Ponieważ std::unique_ptr gwarantuje wyłączną własność nad zasobem, jego kopiowanie jest całkowicie zablokowane. Próba przypisania go do innej zmiennej skończy się błędem kompilacji. Jeśli chcemy przekazać taki wskaźnik do funkcji, musimy dokonać transferu własności (przeniesienia) za pomocą funkcji std::move. Po takim zabiegu oryginalny wskaźnik staje się pusty (nullptr), a funkcja przejmuje pełną odpowiedzialność za zarządzany obiekt.
W telegraficznym skrócie własność to odpowiedzialność za zwolnienie zasobu.
#include <memory>
// Funkcja przyjmuje unique_ptr przez wartosc, co oznacza przejecie wlasnosci
void processOwning(std::unique_ptr<MemoryTracker> ptr) {
std::cout << __func__ << ": Mam cie." << std::endl;
// W tym miejscu 'ptr' wychodzi poza zakres i obiekt jest niszczony!
}
// Funkcja przyjmuje unique_ptr przez referencje, nie przejmuje wlasnosci
void processNonOwning(std::unique_ptr<MemoryTracker>& ptr) {
std::cout << __func__ << ": Tylko korzystam." << std::endl;
}
int main() {
std::cout << "--- start ---" << std::endl;
auto ptr = std::make_unique<MemoryTracker>("Przenoszony");
// auto kopia = ptr; //<-- BLAD KOMPILACJI - kopiowanie niemozliwe!
// Przekazujemy wlasnosc do funkcji za pomoca std::move
processNonOwning(ptr);
// Przekazujemy wlasnosc do funkcji za pomoca std::move
processOwning(std::move(ptr));
// Sprawdzamy stan oryginalnego wskaznika
if (ptr == nullptr) {
std::cout << "Oryginalny wskaznik jest teraz pusty, proba uzycia spowoduje crash\n";
}
std::cout << "--- end ---" << std::endl;
return 0;
}Wynik działania programu
--- start ---
Construct: Przenoszony
processNonOwning: Tylko korzystam.
processOwning: Mam cie.
~Destruct: Przenoszony
Oryginalny wskaznik jest teraz pusty, proba uzycia spowoduje crash
--- end ---std::shared_ptr – gdy potrzebujemy współdzielić
Czasami architektura programu wymaga, aby jeden obiekt był używany i zarządzany przez kilka różnych komponentów jednocześnie. W takich sytuacjach naprzeciw wychodzi std::shared_ptr. Działa on w oparciu o mechanizm zliczania referencji (reference counting). Każdy kolejny shared_ptr wskazujący na ten sam zasób zwiększa wewnętrzny licznik, a kiedy dany wskaźnik wychodzi poza zakres, licznik maleje o jeden. Obiekt zostanie automatycznie usunięty z pamięci dopiero wtedy, gdy straci go ostatni właściciel – czyli gdy licznik referencji spadnie dokładnie do zera. Do jego tworzenia zaleca się używanie funkcji std::make_shared.
Zobaczmy to na przykładzie z wykorzystaniem naszej klasy MemoryTracker oraz metody .use_count(), która pozwala podglądać aktualną liczbę właścicieli:
#include <memory>
int main() {
std::cout << "--- start ---" << std::endl;
// Tworzymy pierwszy shared_ptr
auto wskaznik1 = std::make_shared<MemoryTracker>("Wspolny");
std::cout << "Licznik: " << wskaznik1.use_count() << std::endl;
{
// Kopiujemy wskaznik - teraz dwa wskazniki zarzadzaja tym samym obiektem
auto wskaznik2 = wskaznik1;
std::cout << "Licznik: " << wskaznik1.use_count() << std::endl;
std::cout << "Koniec bloku..." << std::endl;
// wskaznik2 zostaje zniszczony, ale obiekt nadal zyje!
}
std::cout << "Licznik po wyjsciu z bloku: " << wskaznik1.use_count() << std::endl;
std::cout << "--- end ---" << std::endl;
return 0;
// wskaznik1 zostaje zniszczony, licznik spada do zera, obiekt jest usuwany
}Wynik działania programu
--- start ---
Construct: Wspolny
Licznik: 1
Licznik: 2
Koniec bloku...
Licznik po wyjsciu z bloku: 1
--- end ---
~Destruct: WspolnyJak widać, zamknięcie wewnętrznego bloku klamrowego zmniejszyło liczbę właścicieli z 2 do 1, ale nie zniszczyło obiektu. MemoryTracker został bezpiecznie usunięty dopiero na samym końcu programu, kiedy zniszczeniu uległ ostatni istniejący wskaźnik (wskaznik1).
Najważniejsze jest w tym to, że mamy gwarancje zniszczenia obiektu w przewidywalnym momencie, że stanie się to, nawet, gdy przedwcześnie zrobimy return, albo zostanie rzucony wyjątek.
Co robić i jak żyć – Dobre praktyki w pigułce
Praca z inteligentnymi wskaźnikami drastycznie zmniejsza liczbę błędów w programie, pod warunkiem że używasz ich świadomie. Pisząc swój codzienny kod, warto trzymać się kilku prostych reguł:
- Zasada 1: Twój domyślny wybór to
std::unique_ptr. W większości przypadków w programowaniu obiekt ma tylko jednego jasnego właściciela (np. funkcja, która go stworzyła, lub klasa, która go przechowuje).std::unique_ptrjest niezwykle szybki, nie ma żadnego narzutu pamięciowego i idealnie sprawdza się w 90% sytuacji. - Zasada 2: Używaj
std::shared_ptrtylko wtedy, gdy to konieczne. Współdzielenie własności brzmi wygodnie, ale pamiętaj, że mechanizm zliczania referencji kosztuje nieco wydajności. Sięgaj po niego tylko wtedy, gdy wiele różnych obiektów lub wątków faktycznie musi wspólnie decydować o cyklu życia danego zasobu. - Zasada 3: Zapomnij o ręcznym wywoływaniu
newidelete. W nowoczesnym kodzie biznesowym bezpośrednie użycie tych słów kluczowych uważa się obecnie za złą praktykę (tzw. code smell). Zamiast tego zawsze używaj funkcji fabrykującychstd::make_uniqueorazstd::make_shared. - Zasada 4: Surowe wskaźniki wciąż żyją – używaj ich do obserwacji. Nowoczesny C++ nie zakazuje używania zwykłych wskaźników (np.
MemoryTracker*). Są one świetnym narzędziem, jeśli chcesz przekazać obiekt do jakiejś funkcji tylko po to, aby ona coś z nim zrobiła lub sprawdziła, bez zmieniania prawa własności czy zarządzania czasem jego życia.
Podsumowanie
Wprowadzenie inteligentnych wskaźników całkowicie odmieniło oblicze języka C++, zdejmując z barków programistów uciążliwy obowiązek ręcznego pilnowania każdego bajtu pamięci. Dzięki mechanizmowi RAII kod staje się odporny na wycieki i bezpieczny w obliczu niespodziewanych wyjątków, zachowując przy tym legendarną wydajność, z której słynie ten język. Jeśli dopiero zaczynasz swoją przygodę z nowoczesnym C++, zastąpienie surowych wskaźników parą std::unique_ptr oraz std::shared_ptr to najlepszy krok, jaki możesz zrobić w stronę czystego, profesjonalnego kodu. Spróbuj przepisać jeden ze swoich dotychczasowych projektów z ich użyciem, a szybko przekonasz się, jak duży komfort psychiczny dają automatyczne mechanizmy sprzątające.


