Wszystkie zmienne używane przez nas do tej pory były zmiennymi statycznymi. Niektóre z nich były dynamicznie alokowane przez system, ale nie było to zależne od naszej woli. Z tej części kursu dowiemy się o dynamicznie przydzielanej pamięci zmiennym. Takie zmienne nie istnieją w momencie załadowanie programu, lecz są tworzone w razie potrzeby. Możemy więc na pewien czas utworzyć jakąś zmienną, a później uwolnić przydzielaną jej pamięć, tak aby ta przestrzeń mogła być wykorzystana przez inną zmienną.
Wyświetlmy przykładowy program zawarty w pliku dynlist.c. Program rozpoczyna się od definicji struktury ptaszek zawierającej trzy pola. Nie definiujemy dalej żadnych zmiennych, a tylko trzy wskaźniki. Jeśli przyjrzeć się całemu programowi, to zauważyć można, że nigdzie nie ma zdefiniowanych zmiennych - nie mamy więc gdzie składować danych. Pracujemy tutaj tylko z trzema wskaźnikami, z których każdy wskazuje na strukturę ptaszek. Aby móc cokolwiek zrobić musimy utworzyć jakieś zmienne - zrobimy to dynamicznie.
Pierwsza instrukcja w programie przydziela coś wskaźnikowi papuga i tworzy dynamiczną strukturę zawierającą trzy zmienne. Najważniejszą częścią tej linii jest funkcja malloc pojawiająca się w jej wnętrzu. Jest to funkcja przydziału pamięci (memory allocate function). Funkcja ta domyślnie alokuje przestrzeń pamięci na tzw. stercie (heap) typu znakowego o pojemności n znaków. Parametr n jest jedynym parametrem przekazywanym do tej funkcji. Za chwilę omówimy krótko ten parametr, lecz najpierw musimy zdefiniować stertę. Sterta - to w systemie DOS obszar pamięci zarezerwowany, ale nie wykorzystywany przez program w momencie uruchomienia.
Każdy bajt w komputerze ma swój indywidualny, jednoznacznie identyfikujący go numer. Ten numer to adres - mówiliśmy o tym przy okazji wskaźników. Postać adresu w dużej mierze zależy od typu procesora. W komputerach kompatybilnych z IBM-PC (opartych na mikroprocesorach typu Intel 80x86), adres składa się z dwóch liczb szesnastobitowych. Ma to związek z właściwością pamięci, która podzielona jest na segmenty. Jeden segment składa się z 64 kB. By zatem zaadresować bajt musimy określić, w którym segmencie się on znajduje i który jest to bajt, licząc od początku segmentu. Dlatego, aby odwołać się do jakiegoś bajta pamięci, musimy podać tzw. adres segmentowy oraz przesunięcie.
Jak wiadomo, procesory 80286 i nowsze posiadają możliwość pracy w tzw. trybie chronionym. Z punktu widzenia programisty różni się on od trybu rzeczywistego tym, że zamiast adresu segmentowego i przesunięcia, używa się w celu wskazania jakiegoś obszaru pamięci tzw. deskryptorów
Każdy kompilator ma pewne ograniczenia, co do przykładowo wielkości pliku wynikowego, ilości zmiennych, rozmiaru kodu źródłowego... Aby program był efektywny musi zajmować mało miejsca w pamięci. Jeśli program jest większy niż jeden segment, mikroprocesor wykonuje specjalne wywołania danych znajdujących się poza pojedynczym segmentem (określa ich obszary pamięci). Jak łatwo się domyśleć program działa szybciej, jeśli tych wywołań nie ma lub są zredukowane do minimum. Sterta znajduje się poza granicą 64 kB, jest to obszar, który może zostać wykorzystany przez program do przechowywania danych i zmiennych. Te informacje są składowane na stercie przez system w momencie wywołania funkcji mallloc. System gromadzi informacje o miejscu przechowywania danych na stercie. Dane i zmienne mogą zostać dealokowane, a na stercie zwolni sie przestrzeń. System wykryje wówczas wolne miejsce i umożliwia nam ponowne zarezerwowanie go w wyniku następnego wywołania funkcji malloc. Struktura sterty może zatem ciągle się zmieniać.
W taki sposób możemy dynamicznie zarządzać pamięcią - rezerwować, w miarę potrzeby, miejsce do przechowywania zmiennych i w miarę potrzeby je zwalniać. Jest to coś zupełnie innego niż sztywne rezerwowanie pamięci przez umieszczanie deklaracji zmiennych. Tym razem możemy to robić tylko wtedy, kiedy jest to nam niezbędne. Inną zaleta jest zaś to, że wielkość sterty jest ograniczona jedynie wielkością dostępnej w systemie DOS pamięci operacyjnej. Dlatego można rezerwować na stercie bloki pamięci o wielkości znacznie przekraczającej 64 kB.
Większość kompilatorów daje użytkownikowi możliwość wyboru modelu pamięci dla pisanego programu. Zwykle można wybrać model ograniczony do 64 kB (modele tiny i small) lub 640* kB pamięci do przechowywania programu i danych. Pierwsza możliwość to szybszy, mniejszy program, druga umożliwia napisanie większego programu, ale prowadzi do mniej efektywnego adresowania - większa przestrzeń pamięci wymaga stosowania między segmentowego adresowania, co powoduje troszkę wolniejsze działanie programu. Oprócz tego istnieją jeszcze inne tego konsekwencje.
*Niezależnie od ilości pamięci RAM umieszczonej w komputerze, dla systemu DOS dostępne są standardowo tylko pierwsze 640 kB. Istnieją oczywiście pewne metody, które umożliwiają przekroczenie tej bariery.
Jeśli program całkowicie mieści się w 64 kB pamięci (kod, zmienne i dane) oraz nie używa stosu (stack), to może przyjąć postać pliku .COM. Tylko model tiny może być typu .COM. Format pliku z rozszerzeniem com jest tzw. obrazem pamięci (memory image format) i może być przez to bardzo szybko załadowany, w odróżnieniu od pliku wykonywalnego w formacie .EXE, z powodu realokacji adresów w czasie jego uruchomienia.
Używanie dynamicznej alokacji pamięci umożliwia przechowywanie danych na stercie przy korzystaniu z niewielkiego modelu pamięci. Oczywiście na stercie nie przechowuje się lokalnych zmiennych, takich jak liczniki czy indeksy, ale bardzo duże tablice struktur.
Jeśli program używa kilku dużych struktur danych, ale nie w tym samym czasie, to można ładować do pamięci jeden blok danych dynamicznie, a następnie zwolnić przestrzeń dla następnego bloku. Sukcesywne przydzielanie pamięci się wtedy opłaca - użycie jednej przestrzeni adresowej pamięci umożliwia uruchomienie całego programu przy mniejszym zapotrzebowaniu na zasoby.
Pojawiająca się w programie funkcja malloc prosi system o obszar pamięci określonego rozmiaru i otrzymuje blok pamięci ze wskaźnikiem wskazującym na jego pierwszy element. Jedynym przekazywanym tej funkcji argumentem (w nawiasach okrągłych) jest właśnie owa wielkość bloku pamięci, jaki mamy zamiar w danym momencie wykorzystać. W naszym przykładzie chcemy, aby ten blok pamięci był w stanie przechowywać strukturę ptaszek, którą zdefiniowaliśmy na początku programu.
Funkcja sizeof zwraca wielkość w bajtach (bytes) przekazanego jej argumentu. W naszym kodzie zwraca ona rozmiar struktury o nazwie ptaszek, a następnie ta liczba bajtów jest przekazywana do systemu poprzez funkcję malloc. Po pomyślnym zakończeniu całej operacji otrzymujemy zaalokowany dla nas blok pamięci na stercie ze wskaźnikiem papuga identyfikującym blok danych.
Na początku wywołania funkcji malloc pojawia się dość dziwna konstrukcja zwana rzutem (cast). Funkcja malloc zwraca blok ze wskaźnikiem nań wskazujacym, który domyślnie jest typu void. Bardzo często chcemy, aby wskaźnik był jakiś "konkretny" typ. Możemy właśnie dzięki tej konstrukcji wymusić zmianę typu na wskazany. W tym przypadku chcemy mieć wskaźnik odwołujący się do struktury ptaszek i uzyskujemy to właśnie w ten sposób: (struct ptaszek *)malloc... Nawet jeśli pominiemy cast większość kompilatorów utworzy prawidłowo program wynikowy (niektóre jednak zgłoszą ostrzeżenie podczas kompilacji).
W części poświęconej strukturom i wskaźnikom powiedzieliśmy sobie, że jeśli mamy strukturę ze wskaźnikiem wskazującym na nią, mamy dostęp do każdej zmiennej tej struktury. Następne linie kodu ilustrują tę zasadę poprzez przypisanie pewnych danych do składników struktury. Wszystko wygląda tak samo, jakbyśmy mieli do czynienia ze statycznie zdefiniowaną zmienną.
W następnym wierszu wskaźnik kanarek wskazuje na tę samą strukturę, co wskaźnik papuga. Mamy teraz dwa wskaźniki do tego samego obiektu ptaszek. Od kiedy kanarek wskazuje na utworzona wcześniej strukturę, możemy papudze przydzielić dynamicznie kolejną strukturę. Nowa struktura jest następnie wypełniana przypadkowymi danymi. W końcu alokujemy następny blok pamięci na stercie używając wskaźnika slowik i wypełniamy go jakimiś danymi. Wyświetlanie danych na ekranie za pomocą funkcji printf nie jest już niczym nowym.
Aby pozbyć się danych i zwolnić miejsce na stercie używana jest nowa funkcja o nazwie free. Przekazujemy jej wskaźnik do bloku pamięci, który chcemy dealokować, jako jedyny argument. W naszym przykładzie przed wywołaniem funkcji free pod wskaźnik papuga jest podstawiana struktura, na którą wskazuje wskaźnik slowik. Poprzez to tracimy dane, na które wskazywała papuga, bowiem nie mamy już wskaźnika wskazującego na blok pamięci wypełniony tymi danymi. Już nigdy nie będziemy mogli odwołać się do tego bloku, zmienić jego zawartość, ani zwolnić tej pamięci. Sytuacja taka nigdy nie zdarza się celowo w programie i jest sztucznie wywołana dla ilustracji takiej możliwości.
Pierwsze wywołanie funkcji free usuwa blok danych, na który wskazywała papuga i slowik, a drugie wywołanie tej samej funkcji usuwa blok danych, na który wskazywał kanarek. I tym sposobem straciliśmy dostęp do wszystkich wcześniej utworzonych danych. Pozostał wprawdzie na stercie blok danych, ale nie mamy już wskaźnika, który by go identyfikował - utraciliśmy adres pamięci. Próba uwolnienia pamięci typu free(papuga); zakończy się niepowodzeniem, bowiem ten blok został już uwolniony podczas pierwszego użycia funkcji free (papuga i slowik wskazywały pod koniec na ten sam obszar pamięci). Po zakończeniu programu całość sterty przydzielonej naszemu programowi i tak ulegnie zapomnieniu.