Tablica to blok o stałym rozmiarze, złożony z elementów tego samego typu ułożonych w pamięci jeden za drugim. To ciągłe ułożenie wyjaśnia wszystko: dzięki niemu indeksowanie jest natychmiastowe, tablice i wskaźniki są tak blisko spokrewnione, a C może dać ci adres elementu zerowego i pozwolić znaleźć resztę arytmetyką.
Deklaracja tablicy
Typ elementów, nazwa i rozmiar w nawiasach kwadratowych:
int scores[5]; // 5 ints
double prices[100]; // 100 doubles
char initials[3]; // 3 chars
Rozmiar to liczba elementów i musi być znany w miejscu deklaracji tablicy: zwykle jest to literał albo stała zdefiniowana przez #define. Pamięć jest rezerwowana od razu; nic nie jest przydzielane później.
Świeżo zadeklarowana lokalna tablica zawiera śmieci, a nie zera:
Drugi wiersz może wypisać zera, może wypisać ogromne liczby i może się różnić między uruchomieniami albo między kompilacją debug i release. Odczyt tych wartości to niezdefiniowane zachowanie; zainicjalizuj tablicę, zanim ją odczytasz.
Inicjalizacja
Kilka form, wszystkie przydatne:
Idiom {0} warto zapamiętać: każdy element, którego nie wymienisz, jest inicjalizowany zerem, więc jedno zero zeruje całą tablicę. Inicjalizatory desygnowane ([4] = 99) pozwalają ustawić konkretne pozycje i zostawić resztę na zerze, co przydaje się przy tablicach wyszukiwania, które są w większości puste.
Czego zrobić nie można, to przypisać tablicy po fakcie:
int a[3] = {1, 2, 3};
int b[3];
b = a; // błąd: tablicy nie można przypisać
memcpy(b, a, sizeof a); // tak się ją kopiuje
Indeksy zaczynają się od zera
Pierwszy element to arr[0], a ostatni to arr[n-1]. Nie ma arr[n].
Uruchom tę ostatnią pętlę i przyjrzyj się adresom: każdy leży dokładnie sizeof(int) bajtów za poprzednim. Elementy naprawdę są ułożone jeden za drugim:
indeks 0 1 2 3 4
+--------+--------+--------+--------+--------+
wartość | 88 | 95 | 75 | 60 | 100 |
+--------+--------+--------+--------+--------+
offset +0 +4 +8 +12 +16 bajtów
^
&scores[0], czyli także to, do czego degraduje się `scores`
Indeksowanie od zera nie jest przypadkowym wyborem: dzięki niemu indeks jest przesunięciem od początku, a właśnie tego potrzebuje arytmetyka adresów. scores[i] jest zdefiniowane jako *(scores + i); zobacz wskaźniki i tablice.
Obliczanie długości
C nigdzie nie przechowuje długości tablicy w czasie działania programu, ale kompilator ją zna, a sizeof daje do niej dostęp:
sizeof(arr) / sizeof(arr[0]) to standardowy idiom. Dzielenie przez arr[0] zamiast przez wpisane na sztywno sizeof(double) sprawia, że linia dalej działa, gdy zmienisz typ elementów.
Pułapka: to działa tylko tam, gdzie tablica została zadeklarowana. Przekaż tablicę do funkcji, a parametr stanie się wskaźnikiem, więc sizeof zmierzy wskaźnik: zwykle 8 bajtów, co daje "długość" 1 albo 2. Dlatego każda funkcja w C, która przyjmuje tablicę, przyjmuje też liczbę elementów:
Przechodzenie pętlą po tablicy
Standardowy kształt to pętla for od 0, dopóki i < n:
Zwróć uwagę na i < n, a nie i <= n. Przy n elementach poprawne indeksy to od 0 do n - 1, więc <= wykonuje jeden obieg za dużo i czyta za końcem tablicy. Drugi nawyk wart skopiowania to ustawienie początkowych hottest i coldest na temps[0] zamiast na 0: maksimum startujące od zera po cichu psuje się przy danych, które są w całości ujemne.
Wyjście poza zakres to niezdefiniowane zachowanie
Ta część C zaskakuje osoby przychodzące z innych języków. Nie ma sprawdzania granic. Ani w czasie kompilacji, ani w czasie działania. arr[10] na tablicy pięcioelementowej nie jest błędem: to obliczenie adresu, które kompilator wykonuje bez słowa komentarza.
int arr[5] = {1, 2, 3, 4, 5};
arr[7] = 99; // zapisuje 8 bajtów za końcem tablicy
int x = arr[-1]; // czyta przed początkiem
To, co dzieje się dalej, jest niezdefiniowanym zachowaniem, a jego objawy są wyjątkowo różnorodne:
- program pozornie działa, bo trafiona pamięć nie była używana;
- inna zmienna zmienia się bez widocznego powodu;
- program wysypuje się z segmentation fault, być może dużo później;
- zachowanie różni się między
-O0a-O2.
"Pozornie działa" to przypadek najgroźniejszy, bo oznacza, że błąd trafia na produkcję. Trzy sposoby obrony:
- Pętla z
i < n. Większość przekroczeń to błędy o jeden w warunku pętli. - Sprawdzaj indeksy, które pochodzą z wejścia.
- Podczas pracy kompiluj z sanitizerem.
gcc -fsanitize=address -g prog.czamienia większość przekroczeń w natychmiastowy raport z nazwą pliku, numerem linii i tablicą, której to dotyczy.
Tablice innych typów
Ta sama składnia działa dla dowolnego typu elementów, także dla struktur:
Linia char word[6] = "hello"; zasługuje na chwilę uwagi: string w C to po prostu tablica char, której ostatnim znaczącym bajtem jest '\0'. Właśnie przez ten terminator tablica potrzebuje sześciu miejsc na pięć liter.
Tablice o zmiennej długości w skrócie
C99 pozwala na tablicę, której rozmiar jest wartością znaną dopiero w czasie działania:
int n = get_count();
int buffer[n]; // tablica o zmiennej długości
Dwie przestrogi. Rozmiar jest ustalony w chwili utworzenia tablicy, więc VLA nie jest listą, która może rosnąć. Poza tym pamięć pochodzi ze stosu, więc duże albo kontrolowane przez atakującego n może go przepełnić i wysypać program. Dlatego wiele projektów, w tym jądro Linuksa, całkowicie zakazuje VLA. W implementacjach C11 ich obsługa jest też opcjonalna.
Gdy rozmiar naprawdę nie jest znany aż do czasu działania, przydziel pamięć dynamicznie:
Zauważ, że arr[i] wygląda identycznie niezależnie od tego, czy arr jest tablicą, czy wskaźnikiem na przydzieloną pamięć: składni indeksowania to nie obchodzi. Wszystkie szczegóły znajdziesz w artykule o pamięci dynamicznej.
Najczęściej zadawane pytania
Jak zadeklarować tablicę w C?
Podaj typ elementów, nazwę i rozmiar w nawiasach kwadratowych: int scores[5]; rezerwuje miejsce na pięć liczb int. Rozmiar musi być stałą znaną w miejscu deklaracji (poza tablicami o zmiennej długości), a elementy nie są zainicjalizowane, dopóki nie nadasz im wartości.
Jak obliczyć długość tablicy w C?
sizeof(arr) / sizeof(arr[0]), czyli całkowity rozmiar w bajtach podzielony przez rozmiar jednego elementu. Działa to tylko w zasięgu, w którym tablica została zadeklarowana: po przekazaniu do funkcji tablica degraduje się do wskaźnika i sizeof mierzy już wskaźnik.
Co się dzieje po wyjściu poza koniec tablicy w C?
Nic cię nie zatrzyma. C nie sprawdza granic, więc arr[10] na tablicy 5-elementowej czyta albo zapisuje pamięć, która akurat tam leży. To niezdefiniowane zachowanie: program może wypisać śmieci, uszkodzić inną zmienną albo się wysypać, a może też pozornie działać, dopóki nie przestanie.
Jak wyzerować wszystkie elementy tablicy w C?
int arr[100] = {0};: każdy element, którego nie wymienisz, jest inicjalizowany zerem, więc jedno zero zeruje całą tablicę. W C23 działa też int arr[100] = {};. Bez żadnego inicjalizatora zawartość lokalnej tablicy to nieokreślone śmieci.