Przejdź do głównej zawartości

Obsługa pamięci

W zarządzaniu pamięcią można popełnić wiele błędów. Poniższe porady pomogą Ci popełniać ich mniej 😄

Poprawność i bezpieczeństwo

Używaj referencji

Zasada jest prosta: jeśli dany fragment kodu wymaga tego, żeby obiekt istniał, użyj referencji a nie wskaźnika.

Przykład

Mamy klasę gracza 👨‍💼:

struct Player {
int maxHealth = 100;
int health = 100;
int damage = 15;
int score = 0;
};

Chcemy stworzyć funkcje, która pozwala wykonać atak na innym graczu:

❌ ŹLE
void attack(Player* player, Player* target)
{
target->health -= player->damage;

player->score += 10;
}
✔ DOBRZE
void attack(Player& player, Player& target)
{
target.health -= player.damage;

player.score += 10;
}

Surowe wskaźniki

Surowe wskaźniki (np. int* ptr) budzą wiele kontrowersji. Warto wiedzieć kiedy można ich używać.

Na początek:

dobra rada

Nie ma nic złego w używaniu surowych wskaźników... w odpowiedni sposób.

Rolą surowego wskaźnika jest wyłącznie uzyskanie dostępu do określonego miejsca w pamięci.

uwaga

Nie używaj surowych wskaźników do zarządzania pamięcią (dopóki nie masz absolutnej pewności że wiesz co robisz).

// ❌❌❌
Player *player = new Player;
// ...
delete player;
// ❌❌❌

Surowe wskaźniki nie służą do kontrolowania czasu życia obiektu (czyli tego ile dany obiekt istnieje w pamięci komputerowej).

Zamiast tego użyj inteligentnego wskaźnika (ang.: smart pointer):

#include <memory>

struct Player {
std::string name;
int health;
// ...
};

int main()
{
// W make_unique parametry konstruktora klasy Player
auto player = std::make_unique<Player>( /*tutaj*/ );

// "player" jest wskaźnikiem typu std::unique_ptr<Player>


Player& ref = *player;
Player* ptr = player.get(); // surowy wskaźnik ✅, nie zarządza czasem życia


} // <-- następuje automatyczne usunięcie obiektu spod wskaźnika "player"

Przekazywanie std::unique_ptr do funkcji

Jeśli w środku nie zarządzasz czasem życia obiektu, przekaż referencję!

Jeśli chcesz przekazać cały obiekt do jakiegoś rejestru/magazynu/managera (nie nadużywajmy nazw XyzManager), przekaż przez wartość i użyj przeniesienia:

struct Scene
{
void add(std::unique_ptr<Actor> actor)
{
// Przenieś do vectora
actors.emplace_back( std::move(actor) );
}

private:
std::vector< std::unique_ptr<Actor> > actors;
};

// Użycie:
int main() {
Scene scene;
// ...
auto actor = std::make_unique<Actor>( /* ... */ );
// ...
scene.add( std::move(actor) ); // Przenieś do parametru funkcji "add"
}

Rozróżniaj stos od sterty

Tymczasowe zmienne, które tworzymy wewnątrz funkcji są alokowane na stosie, a później automatycznie usuwane, gdy wykonanie programu wyjdzie poza ich zakres:

struct Player { /* cokolwiek */ };

int main()
{
// Blok kodu:
{
Player p;
// ...
} // <-- automatyczne zdjęcie ze stosu "p"
}

Na stercie (ang.: heap) znajdują się obiekty alokowane dynamicznie.

Zastanów się: czy poniższy zapis oznacza, że zmienna health znajdzie się na stosie?

struct Player {
int health;
// ...
};
ODPOWIEDŹ

⚠ NIE

Wszystko zależy od tego jakiego sposobu alokacji użyjemy, by zaalokować sam obiekt typu Player:

int main() {
Player p1;
p1.health = 30; // "p1.health" jest na stosie razem z całym obiektem "p1"

auto p2 = std::make_unique<Player>();
p2->health = 30; // "p2->health" jest na stercie!
}

Wydajność

Unikaj kopii

Jeśli nie potrzebujesz kopiować obiektu, przekaż go przez referencje (do stałej, lub nie - w zależności od potrzeby).

Jeśli nie jest to zwykły typ prosty (int, double itp.), tylko jest to typ złożony, np.:

struct Player
{
std::string name;
float posX, posY, posZ;
};

to:

❌ ŹLE
void print(Player player) // player zostaje skopiowany do parametru funkcji
{
std::cout << player.posX << ", " << player.posY << ", " << player.posZ;
}
✔ DOBRZE
void print(Player const& player) // referencja do stałej, nie potrzebujemy tutaj kopiować
{
std::cout << player.posX << ", " << player.posY << ", " << player.posZ;
}

Ogranicz dynamiczne alokacje

Nie chcemy wytworzyć w Tobie panicznego strachu przed dynamicznymi alokacjami, jednak warto wiedzieć kiedy się przed nimi powstrzymać.

Czasami warto skorzystać z std::array zamiast std::vector czy nawet std::string. Jeśli możesz oszacować ile maksymalnie elementów będziesz potrzebował i ta liczba nie będzie zbyt duża, możesz śmiało użyć tablicy o stałym rozmiarze zamiast dynamicznie alokowanej.

Optymalizacja string-a

Klasa std::string w wyniku optymalizacji (tzw. SSO) może przechowywać małe napisy (na 64-bitowych komputerach poniżej 22 znaków), bez używania dynamicznej alokacji.

Ile to za dużo? Musisz to sam(a) oszacować. Jeśli ta pamięć będzie zużyta tylko tymczasowo (np. podręczny bufor o wielkości kilku KB do czytania z pliku) to bez problemu możesz użyć:

constexpr size_t BUFFER_SIZE = 16 * 1024;

std::array<char, BUFFER_SIZE> buf;

zamiast:

std::string buf;

Jeśli potrzebujesz większych pojemności (większych niż megabajt) to nie alokuj ich na stosie:

❌ ŹLE
int main()
{
constexpr size_t BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB

std::array<char, BUFFER_SIZE> buf; // ❌ przepełnienie stosu ❌
}

W powyższej sytuacji już jesteśmy skazani na użycie dynamicznej alokacji, przez skorzystanie np. z std::string.

Rezerwuj pamięć z wyprzedzeniem

Jeśli korzystasz z kontenera, który będzie przechowywał wiele obiektów, warto na początek luźno oszacować ile w najbliższym czasie ich będzie potrzebne.

Jeśli wiesz, że zaraz będziesz formatował tekst, który będzie miał np. 100 - 1000 znaków, możesz śmiało zarezerwować trochę pamięci z góry (nawet nadmiarowo):

std::string str;

// Rezerwacja pamięci
str.reserve(128);

// Formatowanie:
str += "Gracz ";
str += player.name;
str += " posiada ";
str += std::to_string(player.health);
str += " HP";
zanotuj

Powyższy sposób formatowania nie jest najlepszym pomysłem. Do formatowania tekstu możesz użyć np. biblioteki fmtlib:

std::string str = fmt::format("Gracz {} posiada {} HP", player.name, player.health);

Jeśli zapomnimy zarezerwować pamięć wcześniej, nasz program będzie musiał wykonać sporo alokacji w trakcie dodawania kolejnych znaków do tekstu, przez co będziemy tracili cenny czas.

Nie bagatelizuj tego.

Nie tyczy się to tylko string-a, ale również innych kontenerów, które trzymają zawartość w ciągłych fragmentach pamięci i dynamicznie zmieniają swój rozmiar (np. std::vector)

Nie nadużywaj std::shared_ptr

Ten typ wskaźnika pozwala na kopiowanie go w dowolnej ilości, przez co można naiwnie uznać, że możemy go swobodnie przekazywać w ten sposób np. do funkcji:

❌ ŹLE
struct Player {
int maxHealth = 100;
int health = 100;
int damage = 15;
int score = 0;
};

void attack(std::shared_ptr<Player> player, std::shared_ptr<Player> target)
{
target->health -= player->damage;

player->score += 10;
}

Nikt Ci nie zabroni w ten sposób z nich korzystać, ale jeśli będziesz tego nadużywać, to możesz kiedyś się zdziwić, że Twoja gra lub aplikacja będzie tak świetna, że aż sam procesor się na chwile zatrzyma, żeby popatrzeć na to cudo 😉

Inteligentne wskaźniki służą do zarządzania czasem życia obiektu. Jeśli musisz jedynie skorzystać z dynamicznie zaalokowanego obiektu, możesz śmiało użyć referencji lub wskaźnika zgodnie z tą zasadą.

✔ DOBRZE
int main() {
std::shared_ptr<Player> p1, p2;

// ...

attack(*p1, *p2);
}

void attack(Player& player, Player& target)
{
target.health -= player.damage;

player.score += 10;
}

Używaj std::string_view

Został on dodany do biblioteki standardowej (nagłówek <string_view>) w wersji C++17.

string_view to widok na ciąg znaków, bez znaczenia czy pochodzi on ze std::string, czy nie. Pozwala on korzystać wygodnie z funkcji takich jak porównywanie, .substr(), .find() bez konieczności kopiowania lub tworzenia obiektu std::string.

uwaga

std::string jest alokowany dynamicznie, przez co nie jest najwydajniejszą z opcji.

Bardzo dobrym przykładem są argumenty programu:

int main(int argc, char *argv[])
{
if (argc < 2) return 0; // brak wystarczającej ilości argumentów

// ❌ ŹLE:
if ( argv[1] == "generate-something" )
{
// NIE ZADZIAŁA, porównywanie wskaźników (czyli porównywanie adresów)
}
// ❌ ŹLE:
if ( std::string(argv[1]) == "generate-something" )
{
// ZADZIAŁA, ale jest to niepotrzebnie wolne
}
// ❌ ŹLE:
if ( std::strcmp(argv[1], "generate-something") == 0 )
{
// ZADZIAŁA, ale jest to niewygodne rozwiązanie z C
}

// ✅ DOBRZE:
if ( std::string_view(argv[1]) == "generate-something" )
{
// Szybkie i wygodne
}
}

Obsługa pamięci

W zarządzaniu pamięcią można popełnić wiele błędów. Poniższe porady pomogą Ci popełniać ich mniej 😄

Poprawność i bezpieczeństwo

Używaj referencji

Zasada jest prosta: jeśli dany fragment kodu wymaga tego, żeby obiekt istniał, użyj referencji a nie wskaźnika.

Przykład

Mamy klasę gracza 👨‍💼:

struct Player {
int maxHealth = 100;
int health = 100;
int damage = 15;
int score = 0;
};

Chcemy stworzyć funkcje, która pozwala wykonać atak na innym graczu:

❌ ŹLE
void attack(Player* player, Player* target)
{
target->health -= player->damage;

player->score += 10;
}
✔ DOBRZE
void attack(Player& player, Player& target)
{
target.health -= player.damage;

player.score += 10;
}

Surowe wskaźniki

Surowe wskaźniki (np. int* ptr) budzą wiele kontrowersji. Warto wiedzieć kiedy można ich używać.

Na początek:

dobra rada

Nie ma nic złego w używaniu surowych wskaźników... w odpowiedni sposób.

Rolą surowego wskaźnika jest wyłącznie uzyskanie dostępu do określonego miejsca w pamięci.

uwaga

Nie używaj surowych wskaźników do zarządzania pamięcią (dopóki nie masz absolutnej pewności że wiesz co robisz).

// ❌❌❌
Player *player = new Player;
// ...
delete player;
// ❌❌❌

Surowe wskaźniki nie służą do kontrolowania czasu życia obiektu (czyli tego ile dany obiekt istnieje w pamięci komputerowej).

Zamiast tego użyj inteligentnego wskaźnika (ang.: smart pointer):

#include <memory>

struct Player {
std::string name;
int health;
// ...
};

int main()
{
// W make_unique parametry konstruktora klasy Player
auto player = std::make_unique<Player>( /*tutaj*/ );

// "player" jest wskaźnikiem typu std::unique_ptr<Player>


Player& ref = *player;
Player* ptr = player.get(); // surowy wskaźnik ✅, nie zarządza czasem życia


} // <-- następuje automatyczne usunięcie obiektu spod wskaźnika "player"

Przekazywanie std::unique_ptr do funkcji

Jeśli w środku nie zarządzasz czasem życia obiektu, przekaż referencję!

Jeśli chcesz przekazać cały obiekt do jakiegoś rejestru/magazynu/managera (nie nadużywajmy nazw XyzManager), przekaż przez wartość i użyj przeniesienia:

struct Scene
{
void add(std::unique_ptr<Actor> actor)
{
// Przenieś do vectora
actors.emplace_back( std::move(actor) );
}

private:
std::vector< std::unique_ptr<Actor> > actors;
};

// Użycie:
int main() {
Scene scene;
// ...
auto actor = std::make_unique<Actor>( /* ... */ );
// ...
scene.add( std::move(actor) ); // Przenieś do parametru funkcji "add"
}

Rozróżniaj stos od sterty

Tymczasowe zmienne, które tworzymy wewnątrz funkcji są alokowane na stosie, a później automatycznie usuwane, gdy wykonanie programu wyjdzie poza ich zakres:

struct Player { /* cokolwiek */ };

int main()
{
// Blok kodu:
{
Player p;
// ...
} // <-- automatyczne zdjęcie ze stosu "p"
}

Na stercie (ang.: heap) znajdują się obiekty alokowane dynamicznie.

Zastanów się: czy poniższy zapis oznacza, że zmienna health znajdzie się na stosie?

struct Player {
int health;
// ...
};
ODPOWIEDŹ

⚠ NIE

Wszystko zależy od tego jakiego sposobu alokacji użyjemy, by zaalokować sam obiekt typu Player:

int main() {
Player p1;
p1.health = 30; // "p1.health" jest na stosie razem z całym obiektem "p1"

auto p2 = std::make_unique<Player>();
p2->health = 30; // "p2->health" jest na stercie!
}

Wydajność

Unikaj kopii

Jeśli nie potrzebujesz kopiować obiektu, przekaż go przez referencje (do stałej, lub nie - w zależności od potrzeby).

Jeśli nie jest to zwykły typ prosty (int, double itp.), tylko jest to typ złożony, np.:

struct Player
{
std::string name;
float posX, posY, posZ;
};

to:

❌ ŹLE
void print(Player player) // player zostaje skopiowany do parametru funkcji
{
std::cout << player.posX << ", " << player.posY << ", " << player.posZ;
}
✔ DOBRZE
void print(Player const& player) // referencja do stałej, nie potrzebujemy tutaj kopiować
{
std::cout << player.posX << ", " << player.posY << ", " << player.posZ;
}

Ogranicz dynamiczne alokacje

Nie chcemy wytworzyć w Tobie panicznego strachu przed dynamicznymi alokacjami, jednak warto wiedzieć kiedy się przed nimi powstrzymać.

Czasami warto skorzystać z std::array zamiast std::vector czy nawet std::string. Jeśli możesz oszacować ile maksymalnie elementów będziesz potrzebował i ta liczba nie będzie zbyt duża, możesz śmiało użyć tablicy o stałym rozmiarze zamiast dynamicznie alokowanej.

Optymalizacja string-a

Klasa std::string w wyniku optymalizacji (tzw. SSO) może przechowywać małe napisy (na 64-bitowych komputerach poniżej 22 znaków), bez używania dynamicznej alokacji.

Ile to za dużo? Musisz to sam(a) oszacować. Jeśli ta pamięć będzie zużyta tylko tymczasowo (np. podręczny bufor o wielkości kilku KB do czytania z pliku) to bez problemu możesz użyć:

constexpr size_t BUFFER_SIZE = 16 * 1024;

std::array<char, BUFFER_SIZE> buf;

zamiast:

std::string buf;

Jeśli potrzebujesz większych pojemności (większych niż megabajt) to nie alokuj ich na stosie:

❌ ŹLE
int main()
{
constexpr size_t BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB

std::array<char, BUFFER_SIZE> buf; // ❌ przepełnienie stosu ❌
}

W powyższej sytuacji już jesteśmy skazani na użycie dynamicznej alokacji, przez skorzystanie np. z std::string.

Rezerwuj pamięć z wyprzedzeniem

Jeśli korzystasz z kontenera, który będzie przechowywał wiele obiektów, warto na początek luźno oszacować ile w najbliższym czasie ich będzie potrzebne.

Jeśli wiesz, że zaraz będziesz formatował tekst, który będzie miał np. 100 - 1000 znaków, możesz śmiało zarezerwować trochę pamięci z góry (nawet nadmiarowo):

std::string str;

// Rezerwacja pamięci
str.reserve(128);

// Formatowanie:
str += "Gracz ";
str += player.name;
str += " posiada ";
str += std::to_string(player.health);
str += " HP";
zanotuj

Powyższy sposób formatowania nie jest najlepszym pomysłem. Do formatowania tekstu możesz użyć np. biblioteki fmtlib:

std::string str = fmt::format("Gracz {} posiada {} HP", player.name, player.health);

Jeśli zapomnimy zarezerwować pamięć wcześniej, nasz program będzie musiał wykonać sporo alokacji w trakcie dodawania kolejnych znaków do tekstu, przez co będziemy tracili cenny czas.

Nie bagatelizuj tego.

Nie tyczy się to tylko string-a, ale również innych kontenerów, które trzymają zawartość w ciągłych fragmentach pamięci i dynamicznie zmieniają swój rozmiar (np. std::vector)

Nie nadużywaj std::shared_ptr

Ten typ wskaźnika pozwala na kopiowanie go w dowolnej ilości, przez co można naiwnie uznać, że możemy go swobodnie przekazywać w ten sposób np. do funkcji:

❌ ŹLE
struct Player {
int maxHealth = 100;
int health = 100;
int damage = 15;
int score = 0;
};

void attack(std::shared_ptr<Player> player, std::shared_ptr<Player> target)
{
target->health -= player->damage;

player->score += 10;
}

Nikt Ci nie zabroni w ten sposób z nich korzystać, ale jeśli będziesz tego nadużywać, to możesz kiedyś się zdziwić, że Twoja gra lub aplikacja będzie tak świetna, że aż sam procesor się na chwile zatrzyma, żeby popatrzeć na to cudo 😉

Inteligentne wskaźniki służą do zarządzania czasem życia obiektu. Jeśli musisz jedynie skorzystać z dynamicznie zaalokowanego obiektu, możesz śmiało użyć referencji lub wskaźnika zgodnie z tą zasadą.

✔ DOBRZE
int main() {
std::shared_ptr<Player> p1, p2;

// ...

attack(*p1, *p2);
}

void attack(Player& player, Player& target)
{
target.health -= player.damage;

player.score += 10;
}

Używaj std::string_view

Został on dodany do biblioteki standardowej (nagłówek <string_view>) w wersji C++17.

string_view to widok na ciąg znaków, bez znaczenia czy pochodzi on ze std::string, czy nie. Pozwala on korzystać wygodnie z funkcji takich jak porównywanie, .substr(), .find() bez konieczności kopiowania lub tworzenia obiektu std::string.

uwaga

std::string jest alokowany dynamicznie, przez co nie jest najwydajniejszą z opcji.

Bardzo dobrym przykładem są argumenty programu:

int main(int argc, char *argv[])
{
if (argc < 2) return 0; // brak wystarczającej ilości argumentów

// ❌ ŹLE:
if ( argv[1] == "generate-something" )
{
// NIE ZADZIAŁA, porównywanie wskaźników (czyli porównywanie adresów)
}
// ❌ ŹLE:
if ( std::string(argv[1]) == "generate-something" )
{
// ZADZIAŁA, ale jest to niepotrzebnie wolne
}
// ❌ ŹLE:
if ( std::strcmp(argv[1], "generate-something") == 0 )
{
// ZADZIAŁA, ale jest to niewygodne rozwiązanie z C
}

// ✅ DOBRZE:
if ( std::string_view(argv[1]) == "generate-something" )
{
// Szybkie i wygodne
}
}