12.2 Przydzielanie pamięci — operator new

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!

Usunięcie wskaźnika do zaalokowanej pamięci nie zwalnia tej pamięci. Pozostaje ona w dalszym ciągu „zajęta”.

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:


P82: ref.cpp     Referencja do zmiennej 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:


P83: poldyn.cpp     Wielowymiarowe tablice „półdynamiczne”

      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]): 12
W 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:


P84: allo.cpp     Błąd przy alokacji pamięci

      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 MB
Wydruk 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, 25 lutego 2017; 22:31