Potrzeba dynamicznej alokacji pamięci
C++ obsługuje trzy podstawowe typy alokacji pamięci, o których już słyszałeś dwa.
- Statyczna alokacja pamięci zachodzi dla zmiennych statycznych i globalnych. Pamięć dla tego typu zmiennych jest przydzielana jednorazowo podczas wykonywania programu i jest zachowywana przez cały okres istnienia programu.
- Automatyczna alokacja pamięci zachodzi dla parametrów funkcji i zmiennych lokalnych. Pamięć dla tego typu zmiennych jest przydzielana po wejściu do odpowiedniego bloku i zwalniana po wyjściu z bloku tyle razy, ile jest to konieczne.
- Dynamiczna alokacja pamięci jest tematem tego artykułu.
Zarówno alokacja statyczna, jak i automatyczna mają dwie wspólne cechy:
- Rozmiar zmiennej/tablicy musi być znany podczas kompilacji czasu.
- Alokacja i dezalokacja pamięci odbywa się automatycznie (kiedy tworzona jest instancja zmiennej/zniszczona).
W większości przypadków jest to w porządku. Jednakże możesz spotkać się z sytuacjami, w których jedno lub oba z tych ograniczeń powodują problemy, zwykle w przypadku danych wejściowych z zewnątrz (użytkownika lub pliku).
Możemy na przykład chcieć użyć ciągu znaków do przechowywania czyjegoś imienia i nazwiska, ale nie wiemy, jak długo trwa ta nazwa, zanim ją wprowadzi. Możemy też chcieć wczytać pewną liczbę rekordów z dysku, ale nie wiemy z góry, ile ich jest. Albo możemy tworzyć grę, w której zmienna liczba potworów (zmienia się w czasie w miarę umierania niektórych potworów i pojawiania się nowych) próbuje zabić gracza.
Jeśli musimy zadeklarować rozmiar wszystkiego w czasie kompilacji, najlepsze, co możemy zrobić, to spróbować odgadnąć maksymalny rozmiar potrzebnych nam zmiennych i mieć nadzieję, że to wystarczy:
char name[25]; // let's hope their name is less than 25 chars!
Record record[500]; // let's hope there are less than 500 records!
Monster monster[40]; // 40 monsters maximum
Polygon rendering[30000]; // this 3d rendering better not have more than 30,000 polygons!To kiepskie rozwiązanie dla co najmniej czterech powody:
Po pierwsze, prowadzi to do marnowania pamięci, jeśli zmienne nie są faktycznie używane. Na przykład, jeśli przydzielimy 25 znaków na każde imię, ale nazwy mają średnio tylko 12 znaków, zużyjemy ponad dwukrotnie więcej, niż naprawdę potrzebujemy. Lub rozważ powyższą tablicę renderującą: jeśli renderowanie wykorzystuje tylko 10 000 wielokątów, mamy nieużywaną pamięć o wartości 20 000 wielokątów!
Po drugie, jak możemy stwierdzić, które bity pamięci są faktycznie używane? W przypadku ciągów jest to proste: ciąg zaczynający się od \0 najwyraźniej nie jest używany. Ale co z potworem[24]? Czy to jest teraz żywe czy martwe? Czy w ogóle zostało to zainicjowane? Wymaga to posiadania sposobu na określenie statusu każdego potwora, co zwiększa złożoność i może zająć dodatkową pamięć.
Po trzecie, większość normalnych zmiennych (w tym stałych tablic) jest alokowanych w części pamięci zwanej stos. Ilość pamięci stosu dla programu jest zazwyczaj dość mała — Visual Studio domyślnie ustawia rozmiar stosu na 1 MB. Jeśli przekroczysz tę liczbę, nastąpi przepełnienie stosu i system operacyjny prawdopodobnie zamknie program.
W Visual Studio możesz to zobaczyć podczas uruchamiania tego programu:
int main()
{
int array[1000000]; // allocate 1 million integers (probably 4MB of memory)
}Ograniczenie do zaledwie 1MB pamięci byłoby problematyczne dla wielu programów, szczególnie tych zajmujących się grafiką.
Po czwarte i najważniejsze, może to prowadzić do sztucznych ograniczeń i/lub przepełnienia tablicy. Co się stanie, gdy użytkownik spróbuje wczytać z dysku 600 rekordów, a my przydzieliliśmy pamięć tylko na maksymalnie 500 rekordów? Albo musimy dać użytkownikowi błąd, odczytać tylko 500 rekordów, albo (w najgorszym przypadku, gdy w ogóle nie zajmiemy się tym przypadkiem) przepełnić tablicę rekordów i zobaczyć, że dzieje się coś złego.
Na szczęście problemy te można łatwo rozwiązać poprzez dynamiczną alokację pamięci. Dynamiczna alokacja pamięci to sposób, w jaki uruchamiane programy mogą żądać pamięci od systemu operacyjnego, gdy jest to potrzebne. Pamięć ta nie pochodzi z ograniczonej pamięci stosu programu — zamiast tego jest przydzielana ze znacznie większej puli pamięci zarządzanej przez system operacyjny, zwanej stertą. Na nowoczesnych maszynach sterta może mieć rozmiar gigabajtów.
Dynamiczne przydzielanie pojedynczych zmiennych
Aby przydzielać pojedynczą zmienną dynamicznie, używamy skalarnej (nietablicowej) postaci nowego :
new int; // dynamically allocate an integer (and discard the result)W powyższym w tym przypadku żądamy od systemu operacyjnego pamięci o wartości całkowitej. Operator new tworzy obiekt przy użyciu tej pamięci, a następnie zwraca wskaźnik zawierający adres przydzielonej pamięci.
Najczęściej przypisujemy zwracaną wartość do naszej własnej zmiennej wskaźnikowej, aby móc później uzyskać dostęp do przydzielonej pamięci.
int* ptr{ new int }; // dynamically allocate an integer and assign the address to ptr so we can access it laterMożemy następnie wydereferować wskaźnik, aby uzyskać dostęp do pamięci:
*ptr = 7; // assign value of 7 to allocated memoryJeśli tak wcześniej nie było, teraz powinno być jasne co najmniej jeden przypadek, w którym wskaźniki są przydatne. Bez wskaźnika przechowującego adres właśnie przydzielonej pamięci nie mielibyśmy możliwości uzyskania dostępu do właśnie przydzielonej nam pamięci!
Zauważ, że dostęp do obiektów alokowanych na stercie jest generalnie wolniejszy niż dostęp do obiektów alokowanych na stosie. Ponieważ kompilator zna adresy obiektów przydzielonych do stosu, może przejść bezpośrednio pod ten adres, aby uzyskać wartość. Dostęp do obiektów przydzielonych przez stertę można zwykle uzyskać za pomocą wskaźnika. Wymaga to dwóch kroków: jednego, aby uzyskać adres obiektu (ze wskaźnika), a drugiego, aby uzyskać wartość.
Jak działa dynamiczna alokacja pamięci?
Twój komputer ma pamięć (prawdopodobnie dużo), która jest dostępna dla aplikacji. Kiedy uruchamiasz aplikację, system operacyjny ładuje ją do części tej pamięci. Pamięć używana przez Twoją aplikację jest podzielona na różne obszary, z których każdy służy innemu celowi. Jeden obszar zawiera Twój kod. Inny obszar jest używany do normalnych operacji (śledzenie, które funkcje zostały wywołane, tworzenie i niszczenie zmiennych globalnych i lokalnych itp.). Porozmawiamy o nich więcej później. Jednak znaczna część dostępnej pamięci po prostu tam jest i czeka, aż zostanie udostępniona programom, które jej zażądają.
Kiedy dynamicznie przydzielasz pamięć, prosisz system operacyjny o zarezerwowanie części tej pamięci do użytku programu. Jeśli uda mu się spełnić to żądanie, zwróci adres tej pamięci do Twojej aplikacji. Od tego momentu aplikacja może używać tej pamięci według własnego uznania. Gdy aplikacja zakończy pracę z pamięcią, może zwrócić ją z powrotem do systemu operacyjnego w celu przekazania innemu programowi.
W przeciwieństwie do pamięci statycznej lub automatycznej, sam program jest odpowiedzialny za żądanie i pozbycie się dynamicznie przydzielonej pamięci.
Kluczowa informacja
Alokacja i dezalokacja obiektów stosu odbywa się automatycznie. Nie musimy zajmować się adresami pamięci - kod napisany przez kompilator może to za nas zrobić.
Alokacja i dezalokacja obiektów sterty nie odbywa się automatycznie. Musimy być zaangażowani. Oznacza to, że potrzebujemy jednoznacznego sposobu odniesienia się do konkretnego obiektu przydzielonego stercie, abyśmy mogli zażądać jego zniszczenia, gdy będziemy gotowi. Odwoływanie się do takich obiektów odbywa się poprzez adres pamięci.
Kiedy używamy operatora new, zwraca on wskaźnik zawierający adres pamięci nowo przydzielonego obiektu. Generalnie chcemy zapisać to we wskaźniku, abyśmy mogli później użyć tego adresu, aby uzyskać dostęp do obiektu (i ostatecznie zażądać jego zniszczenia).
Inicjowanie dynamicznie przydzielanej zmiennej
Kiedy dynamicznie przydzielasz zmienną, możesz ją także inicjować poprzez inicjalizację bezpośrednią lub inicjalizację równomierną:
int* ptr1{ new int (5) }; // use direct initialization
int* ptr2{ new int { 6 } }; // use uniform initializationUsuwanie pojedynczej zmiennej
Kiedy skończymy z dynamicznie alokowaną zmienną, musimy wyraźnie powiedzieć C++, aby zwolnił pamięć do ponownego użycia. W przypadku pojedynczych zmiennych odbywa się to za pomocą skalarnej (nietablicowej) postaci operatora delete :
// assume ptr has previously been allocated with operator new
delete ptr; // return the memory pointed to by ptr to the operating system
ptr = nullptr; // set ptr to be a null pointerCo to znaczy usunąć pamięć?
Operator usuwania w rzeczywistości nie usuwa niczego. Po prostu zwraca wskazywaną pamięć z powrotem do systemu operacyjnego. System operacyjny może następnie ponownie przypisać tę pamięć do innej aplikacji (lub ponownie do tej aplikacji później).
Chociaż składnia sprawia wrażenie, jakbyśmy usuwali zmienną, tak nie jest! Zmienna wskaźnikowa ma nadal ten sam zakres co poprzednio i można jej przypisać nową wartość (np. nullptr) tak jak każdej innej zmiennej.
Pamiętaj, że usunięcie wskaźnika, który nie wskazuje na dynamicznie alokowaną pamięć, może spowodować złe rzeczy.
Wiszące wskaźniki
C++ nie gwarantuje, co stanie się z zawartością zwolnionej alokacji pamięci lub do wartości usuwanego wskaźnika. W większości przypadków pamięć zwrócona do systemu operacyjnego będzie zawierać te same wartości, co przed jej zwróceniem, a wskaźnik pozostanie wskazujący na pamięć, która została zwolniona.
Wskaźnik wskazujący na zwolnioną pamięć nazywany jest a wskaźnikiem wiszącym. Dereferencja lub usunięcie wiszącego wskaźnika doprowadzi do niezdefiniowanego zachowania. Rozważmy następujący program:
#include <iostream>
int main()
{
int* ptr{ new int }; // dynamically allocate an integer
*ptr = 7; // put a value in that memory location
delete ptr; // return the memory to the operating system. ptr is now a dangling pointer.
std::cout << *ptr; // Dereferencing a dangling pointer will cause undefined behavior
delete ptr; // trying to deallocate the memory again will also lead to undefined behavior.
return 0;
}W powyższym programie wartość 7, która została wcześniej przypisana do przydzielonej pamięci, prawdopodobnie nadal tam będzie, ale możliwe jest, że wartość pod tym adresem pamięci mogła się zmienić. Możliwe jest również, że pamięć może zostać przydzielona innej aplikacji (lub na własny użytek systemu operacyjnego), a próba uzyskania dostępu do tej pamięci spowoduje zamknięcie programu przez system operacyjny.
Cofnięcie alokacji pamięci może spowodować utworzenie wielu nieaktualnych wskaźników. Rozważmy następujący przykład:
#include <iostream>
int main()
{
int* ptr{ new int{} }; // dynamically allocate an integer
int* otherPtr{ ptr }; // otherPtr is now pointed at that same memory location
delete ptr; // return the memory to the operating system. ptr and otherPtr are now dangling pointers.
ptr = nullptr; // ptr is now a nullptr
// however, otherPtr is still a dangling pointer!
return 0;
}Istnieje kilka najlepszych praktyk, które mogą być tutaj pomocne.
Po pierwsze, staraj się unikać sytuacji, w których wiele wskaźników wskazuje ten sam fragment pamięci dynamicznej. Jeśli nie jest to możliwe, wyraźnie określ, który wskaźnik „jest właścicielem” pamięci (i jest odpowiedzialny za jej usunięcie), a które wskaźniki właśnie do niej uzyskują dostęp.
Po drugie, kiedy usuwasz wskaźnik, jeśli wskaźnik ten nie wyjdzie natychmiast poza zakres, ustaw wskaźnik na nullptr. Za chwilę porozmawiamy więcej o wskaźnikach zerowych i o tym, dlaczego są one przydatne.
Najlepsza praktyka
Ustaw usunięte wskaźniki na nullptr, chyba że natychmiast potem wyjdą poza zakres.
Operator nowy może się nie powieść
Podczas żądania pamięci od systemu operacyjnego, w rzadkich przypadkach, system operacyjny może nie mieć żadnej pamięci, z której mógłby spełnić żądanie.
Domyślnie, jeśli nowy kończy się niepowodzeniem, zgłaszany jest bad_alloc wyjątek. Jeśli ten wyjątek nie zostanie prawidłowo obsłużony (i nie będzie, ponieważ nie omówiliśmy jeszcze wyjątków ani obsługi wyjątków), program po prostu zakończy działanie (zawieszenie) z powodu nieobsługiwanego błędu wyjątku.
W wielu przypadkach nowe zgłoszenie wyjątku (lub awaria programu) jest niepożądane, dlatego istnieje alternatywna forma new, której można użyć zamiast tego, aby poinformować new o zwróceniu wskaźnika zerowego, jeśli nie można przydzielić pamięci. Odbywa się to poprzez dodanie stałej std::nothrow pomiędzy słowem kluczowym new a typem alokacji:
int* value { new (std::nothrow) int }; // value will be set to a null pointer if the integer allocation failsW powyższym przykładzie, jeśli new nie przydzieli pamięci, zwróci wskaźnik zerowy zamiast adresu przydzielonej pamięci.
Zauważ, że jeśli następnie spróbujesz usunąć referencję do tego wskaźnika, spowoduje to niezdefiniowane zachowanie (najprawdopodobniej program ulegnie awarii). W związku z tym najlepszą praktyką jest sprawdzanie wszystkich żądań pamięci, aby upewnić się, że faktycznie zostały one zrealizowane, zanim zostanie wykorzystana przydzielona pamięć.
int* value { new (std::nothrow) int{} }; // ask for an integer's worth of memory
if (!value) // handle case where new returned null
{
// Do error handling here
std::cerr << "Could not allocate memory\n";
}Ponieważ żądanie nowej pamięci kończy się niepowodzeniem rzadko (a prawie nigdy w środowisku programistycznym), często zapomina się o tym sprawdzeniu!
Wskaźniki zerowe i dynamiczna alokacja pamięci
Wskaźniki zerowe (wskaźniki ustawione na nullptr) są szczególnie przydatne w przypadku dynamicznej alokacji pamięci. W kontekście dynamicznej alokacji pamięci wskaźnik zerowy w zasadzie mówi „do tego wskaźnika nie przydzielono żadnej pamięci”. Dzięki temu możemy na przykład warunkowo przydzielać pamięć:
// If ptr isn't already allocated, allocate it
if (!ptr)
ptr = new int;Usunięcie wskaźnika zerowego nie przynosi żadnego efektu. Zatem nie ma potrzeby wykonywania następujących czynności:
if (ptr) // if ptr is not a null pointer
delete ptr; // delete it
// otherwise do nothingZamiast tego możesz po prostu napisać:
delete ptr;Jeśli ptr nie ma wartości null, dynamicznie przydzielona pamięć zostanie usunięta. Jeśli ptr ma wartość null, nic się nie stanie.
Najlepsza praktyka
Usunięcie wskaźnika zerowego jest w porządku i nic nie daje. Nie ma potrzeby warunkowania instrukcji usuwania.
Wycieki pamięci
Dynamicznie alokowana pamięć pozostaje przydzielona do czasu jej wyraźnego zwolnienia lub do zakończenia programu (a system operacyjny ją wyczyści, zakładając, że robi to twój system operacyjny). Jednakże wskaźniki używane do przechowywania dynamicznie przydzielanych adresów pamięci podlegają normalnym regułom określania zakresu zmiennych lokalnych. Ta niezgodność może powodować ciekawe problemy.
Rozważ następującą funkcję:
void doSomething()
{
int* ptr{ new int{} };
}Ta funkcja dynamicznie przydziela liczbę całkowitą, ale nigdy jej nie zwalnia za pomocą usuwania. Ponieważ zmienne wskaźnikowe są zwykłymi zmiennymi, po zakończeniu funkcji ptr wyjdzie poza zakres. A ponieważ ptr jest jedyną zmienną przechowującą adres dynamicznie alokowanej liczby całkowitej, po zniszczeniu ptr nie ma już żadnych odniesień do dynamicznie alokowanej pamięci. Oznacza to, że program „stracił” adres dynamicznie przydzielanej pamięci. W rezultacie tej dynamicznie przydzielonej liczby całkowitej nie można usunąć.
Nazywa się to wyciekiem pamięci. Wycieki pamięci mają miejsce, gdy program traci adres części dynamicznie przydzielanej pamięci przed oddaniem jej systemowi operacyjnemu. Gdy tak się stanie, program nie będzie mógł usunąć dynamicznie przydzielonej pamięci, ponieważ nie wie już, gdzie ona się znajduje. System operacyjny również nie może używać tej pamięci, ponieważ jest ona uznawana za nadal używaną przez program.
Wycieki pamięci pochłaniają wolną pamięć podczas działania programu, przez co mniej pamięci jest dostępne nie tylko dla tego programu, ale także dla innych programów. Programy wykazujące poważne problemy z wyciekiem pamięci mogą pochłonąć całą dostępną pamięć, powodując spowolnienie lub nawet awarię całej maszyny. Dopiero po zakończeniu działania programu system operacyjny jest w stanie oczyścić i „odzyskać” całą wyciekającą pamięć.
Chociaż wycieki pamięci mogą wynikać z wyjścia wskaźnika poza zakres, istnieją inne sposoby wycieku pamięci. Na przykład wyciek pamięci może wystąpić, jeśli wskaźnikowi przechowującemu adres dynamicznie alokowanej pamięci zostanie przypisana inna wartość:
int value = 5;
int* ptr{ new int{} }; // allocate memory
ptr = &value; // old address lost, memory leak resultsMożna to naprawić, usuwając wskaźnik przed ponownym przypisaniem:
int value{ 5 };
int* ptr{ new int{} }; // allocate memory
delete ptr; // return memory back to operating system
ptr = &value; // reassign pointer to address of valueW związku z tym możliwe jest również uzyskanie wycieku pamięci poprzez podwójną alokację:
int* ptr{ new int{} };
ptr = new int{}; // old address lost, memory leak resultsAdres zwrócony z drugiej alokacji zastępuje adres pierwszej alokacji. W rezultacie pierwsza alokacja powoduje wyciek pamięci!
Podobnie można tego uniknąć, usuwając wskaźnik przed ponownym przypisaniem.
Wnioski
Operatory new i Delete umożliwiają nam dynamiczną alokację pojedynczych zmiennych dla naszych programów.
Dynamicznie alokowana pamięć ma dynamiczny czas trwania i pozostanie przydzielona do chwili zwolnienia jej lub zakończenia programu.
Uważaj, aby nie dereferować wskaźników zawieszonych lub zerowych.
W następnej lekcji przyjrzymy się używaniu nowych i usuwających do alokacji i usuwania tablic.

