Pamięć na stercie można przydzielić (zaalokować), czyli zarezerwować po to, by zapisać tam jakieś dane, za pomocą operatora new. Operator ten występuje tylko w C++, choć alokować pamięć można i w C; jak to zrobić, powiemy w dalszej części tego rozdziału.
Operator new wymaga wskazania typu danej lub danych, które mają być w przydzielonej pamięci zapisane oraz informacji o ilości tych danych, czyli o wielkości obszaru pamięci, jaki ma być zarezerwowany.
W najprostszej formie operatora new można użyć do utworzenia w pamięci wolnej pojedynczej danej (zmiennej) pewnego typu. Składnia jest następująca:
int* pi = new int;i oznacza polecenie utworzenia w pamięci wolnej zmiennej typu int, co wiąże się z zarezerwowaniem obszaru pamięci o odpowiednim rozmiarze i zaznaczeniu jej jako zajętej. Operator new znajduje i rezerwuje tę pamięć i zwraca adres początku zaalokowanego obszaru pamięci. Inicjalizuje też nowo utworzoną zmienną (w wypadku int a inicjalizacja oznacza „nic nie rób”, ale dla innych typów jest to nietrywialna operacja związana z wywołaniem konstruktora). Zwrócony adres możemy (i zwykle powinniśmy) zapamiętać: oczywiście w zmiennej odpowiedniego typu wskaźnikowego, tak jak w powyższym przykładzie (bo wartość zwracana przez new jest przecież adresem).
Zauważmy, że po tej instrukcji pi jest zmienną wskaźnikową lokalną, to znaczy zmienna ta jest umieszczana na stosie. W szczególności, zostanie ona usunięta po wyjściu sterowania z bloku, w którym była zdefiniowana!
Jeśli nie jesteśmy ostrożni, to w ten sposób stracimy możliwość odwołania się do nowo utworzonej na stercie anonimowej zmiennej; dostępna jest ona jedynie poprzez adres zawarty w zmiennej pi. Jeśli coś takiego się zdarzy, to następuje tzw. wyciek pamięci (ang. memory leakage) — zmienna na stercie istnieje, ale jest bezużyteczna, bo zgubiliśmy jej adres i nie wiemy gdzie ta zmienna jest!
W miejsce typu int, jak w powyższym przykładzie, może oczywiście wystąpić dowolny typ; również typ zdefiniowany przez użytkownika, a więc klasa. Gdyby była to klasa, np. o nazwie Klasa, to przydział pamięci na jeden obiekt tej klasy wyglądałby tak
Klasa* pk = new Klasa;Jeśli klasa ta miałaby jakieś konstruktory wymagające danych, to moglibyśmy użyć składni (podobnej do tej z Javy)
Klasa* pk = new Klasa(12,8);Co ciekawe, tej samej składni możemy użyć do utworzenia na stercie zmiennych typów podstawowych, np.
int* pi = new int(18);utworzy na stercie zmienną typu int i zainicjuje ją wartością 18, zupełnie jakby int było nazwą klasy z jednoargumentowym konstruktorem. Ta cecha języka jest zamierzona: autor (Bjarne Stroustrup) dążył do tego, aby typy wbudowane i definiowane przez użytkownika były, jak to tylko możliwe, traktowane na równych prawach i podlegały tym samym regułom składniowym. W opisany wyżej sposób można też utworzyć dynamicznie stałą:
const int* stala = new const int(1);Nie byłoby to możliwe bez podania w nawiasie inicjatora, gdyż, jak pamiętamy, stała musi być zainicjowana już w czasie tworzenia.
Jeśli niewygodnie nam operować na zmiennej bez nazwy, to można
po jej utworzeniu nadać jej nazwę poprzez zdefiniowanie
do niej odniesienia (referencji); na przykład w poniższym
programiku
rd
jest referencją do (czyli inną nazwą)
zmiennej anonimowej na stercie:
1. #include <iostream> 2. using namespace std; 3. 4. int main() { 5. double *pd = new double(4.5), 6. &rd = *pd; 7. 8. cout << "*pd = " << *pd << endl; 9. cout << " rd = " << rd << endl; 10. *pd = 1.5; 11. cout << "*pd = " << *pd << endl; 12. cout << " rd = " << rd << endl; 13. delete pd; 14. } 15.
Do tej samej zmiennej na stercie możemy się zatem odnosić poprzez dereferencję wskaźnika, *pd, jak i referencję rd — zmieniamy wartość tej zmiennej poprzez *pd, a następnie drukujemy tę wartość odnosząc się do tej samej zmiennej poprzez obie nazwy:
*pd = 4.5 rd = 4.5 *pd = 1.5 rd = 1.5
Można również alokować pamięć na więcej niż jeden obiekt dowolnego typu. Składnia jest wtedy taka:
int* pi = new int[wym];gdzie tym razem, po określeniu typu (w tym przypadku int), podajemy w nawiasach kwadratowych liczbę elementów danego typu, na które alokujemy pamięć. Wyrażenie wym powinno mieć typ size_t — jest to alias nadany za pomocą typedef pewnemu typowi całkowitemu bez znaku (zwykle unsigned long). Kolejne elementy w utworzonym obszarze pamięci będą zajmować kolejne fragmenty w ciągłym obszarze pamięci (jak dla tablic). Fundamentalne znaczenie ma fakt, że wyrażenie wym może być dowolnym wyrażeniem o dodatniej wartości całkowitej. Wymiar ten może zatem być wczytany, lub w jakiś sposób wyliczony, w trakcie działania programu — nie musi być znany już w czasie kompilacji czy ładowania programu, tak jak miało to miejsce dla zwykłych (czyli statycznych) tablic. Dlatego cały ten proces nazywamy dynamicznym przydziałem pamięci, a tablice tak utworzone tablicami dynamicznymi.
Jeśli na przykład aktualną wartością wym jest 40, to w powyższym przykładzie zarezerwowane zostanie 160 bajtów ( 4×40) i adres początku tego obszaru pamięci zostanie zwrócony przez new i zapamiętany w zmiennej wskaźnikowej pi. Tak przydzielona pamięć jest inicjowana, choć dla niestatycznych zmiennych typów prostych inicjalizacja oznacza „nic nie rób” (inaczej będzie dla tablic obiektów klas — powiemy o tym za chwilę). Zmiennej pi można teraz używać tak jak nazwy tablicy, zgodnie z odpowiedniością pomiędzy wskaźnikami i tablicami. Moglibyśmy na przykład nadać sensowne wartości danym w nowo przydzielonym obszarze pamięci za pomocą pętli
for (int i = 0; i < wym; ++i) pi[i] = 2*i;
Nie należy mylić nawiasów okrągłych i kwadratowych w obu formach użycia operatora new: nawiasy okrągłe zawierają dane potrzebne do inicjalizacji tworzonej pojedynczej zmiennej; nawiasy kwadratowe zawierają wyrażenie o wartości całkowitej mówiące o liczbie tworzonych elementów.
Można też alokować w ten sposób pamięć na tablice wielowymiarowe,
ale są one wtedy tylko „półdynamiczne”. Oznacza to, że
tylko jeden, a mianowicie pierwszy, wymiar może nie być stałą
kompilacji. Rozpatrzmy przykład:
1. #include <iostream> 2. using namespace std; 3. 4. int main() { 5. const int DIM = 3; 6. cout << "Podaj pierwszy wymiar: "; 7. int size; 8. cin >> size; 9. int (*t)[DIM] = new int[size][DIM]; ➊ 10. 11. for (int i = 0; i < size; ++i) 12. for (int j = 0; j < DIM; ++j) 13. t[i][j] = 10*i + j; 14. 15. int* p = reinterpret_cast<int*>(t); ➋ 16. 17. for (int i = 0; i < DIM*size; ++i) 18. cout << p[i] << " "; 19. cout << endl; 20. 21. cout << "t[0] : " << t[0] << endl; ➌ 22. cout << "t[1] : " << t[1] << endl; 23. cout << "sizeof(t[0]): " << sizeof(t[0]) << endl; ➍ 24. }
W linii ➊ alokujemy pamięć na tablicę size× DIM, gdzie size nie jest znane z góry, gdyż jest wczytywane z klawiatury w trakcie wykonania. Natomiast drugi (i ewentualne następne) wymiar musi być stałą kompilacji. Zauważmy typ zmiennej t: jest to wskaźnik do trzyelementowej tablicy int'ów. Zatem obiektem wskazywanym jest tu nie „coś” typu int, ale cała tablica int'ów, w tym przypadku o wymiarze 3 (bo tyle wynosi DIM). Zatem t[0] jest taką tablicą i ma rozmiar 12 bajtów (3×4). Odpowiada pierwszemu wierszowi tablicy. Zatem t[1] też jest taką tablicą, odpowiadającą drugiemu wierszowi i powinno leżeć w pamięci o 12 bajtów dalej. Że tak jest rzeczywiście, przekonuje nas wydruk tego programu (0x88d01c-0x88d010=C w układzie szesnastkowym, czyli 12 w układzie dziesiętnym; konkretne adresy mogą być oczywiście inne, ale różnica powinna być właśnie taka). Zauważmy, że drukując t[0] (➌) drukujemy tablicę, a ta jest konwertowana do wskaźnika wskazującego na początek tablicy — dlatego otrzymujemy adresy.
Podaj pierwszy wymiar: 5 0 1 2 10 11 12 20 21 22 30 31 32 40 41 42 t[0] : 0x88d010 t[1] : 0x88d01c sizeof(t[0]): 12W linii ➋ tworzymy zmienną wskaźnikową typu int* i wpisujemy tam adres początku całej tablicy t; o operatorze reinterpret_cast będziemy jeszcze mówić, na razie powiedzmy tylko, że jest tu konieczny ze względu na kontrolę typów: typem t nie jest bowiem int* (równie dobrze mogliśmy użyć tradycyjnej formy rzutowania typów ' (int*)'). Traktując następnie p jak jednowymiarową tablicę liczb całkowitych drukujemy kolejne wartości elementów tablicy. Widać, że są one zgodne z tym, co wpisaliśmy poprzedzającej pętli i że rzeczywiście są ułożone w pamięci wierszami (co nie jest sprawą obojętną, na przykład dla wydajności operacji na macierzach — w Fortranie dane ułożone byłyby kolumnami).
Przydział pamięci może się nie powieść, na przykład jeśli
zażądaliśmy zarezerwowania zbyt dużej jej ilości.
Jak się przekonamy, w takich sytuacjach w języku C zwracany jest
wtedy wskaźnik pusty (NULL). W C++ natomiast generowany jest
wtedy wyjątek typu
bad_alloc
(z nagłówka
new) który możemy
przechwycić i obsłużyć zapobiegając załamaniu programu.
O obsłudze wyjątków powiemy więcej
w osobnym rozdziale ,
ale poniższy przykład powinien być zrozumiały przynajmniej dla
tych, którzy uczyli się już Javy
lub Pythona:
1. #include <iostream> 2. #include <new> 3. #include <iomanip> 4. using namespace std; 5. 6. int main() { 7. const size_t mega = 1024*1024, step = 200*mega; 8. 9. for (size_t size = step; ;size += step) { 10. try { 11. char* buf = new char[size]; 12. delete [] buf; 13. } 14. catch(bad_alloc) { 15. cout << "NIE UDALO SIE: " << setw(4) 16. << size/mega << " MB" << endl; 17. return 1; 18. } 19. cout << " udalo sie: " << setw(4) 20. << size/mega << " MB" << endl; 21. } 22. }
W nieskończonej pętli alokujemy i natychmiast zwalniamy za pomocą operatora delete (patrz następny podrozdział) coraz większy obszar pamięci. W pewnym momencie żądamy tej pamięci za dużo. Zadanie nie może być wykonane, więc zgłaszany jest wyjątek, który przechwytujemy (fraza catch), drukujemy komunikat i kończymy program poprzez wywołanie return w funkcji main. Program ten wygenerował następujący wydruk:
udalo sie: 200 MB udalo sie: 400 MB udalo sie: 600 MB udalo sie: 800 MB udalo sie: 1000 MB udalo sie: 1200 MB udalo sie: 1400 MB udalo sie: 1600 MB udalo sie: 1800 MB udalo sie: 2000 MB udalo sie: 2200 MB udalo sie: 2400 MB udalo sie: 2600 MB udalo sie: 2800 MB NIE UDALO SIE: 3000 MBWydruk nie oznacza, że komputer ma rzeczywiście 3GB pamięci — odliczony jest obszar zajęty a doliczony obszar wymiany (ang. swap).
Zauważmy jeszcze, że alokowanie pamięci na jeden egzemplarz obiektu
int* pi = new int;nie jest równoważne alokowaniu pamięci na tablicę jednoelementową
int* pi = new int[1];Przydział pamięci na tablice implementowany jest inaczej niż przydział pamięci na pojedyncze obiekty, te dwie instrukcje mają więc inny skutek. Różnica przejawia się między innymi podczas zwalniania tak przydzielonej pamięci (patrz następny podrozdział).
T.R. Werner, 21 lutego 2016; 20:17