Menu

Tablice w C: deklaracja, inicjalizacja i przechodzenie pętlą

Jak zadeklarować i zainicjalizować tablicę w C, indeksować ją od zera, obliczyć długość przez sizeof, przejść pętlą po elementach i dlaczego odczyt za końcem tablicy to niezdefiniowane zachowanie, a nie komunikat o błędzie.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

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 -O0 a -O2.

"Pozornie działa" to przypadek najgroźniejszy, bo oznacza, że błąd trafia na produkcję. Trzy sposoby obrony:

  1. Pętla z i < n. Większość przekroczeń to błędy o jeden w warunku pętli.
  2. Sprawdzaj indeksy, które pochodzą z wejścia.
  1. Podczas pracy kompiluj z sanitizerem. gcc -fsanitize=address -g prog.c zamienia 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.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ