Menu

Parametry funkcji w C: przekazywanie przez wartość, wskaźniki i tablice

C przekazuje wszystko przez wartość: funkcje dostają kopie, nigdy oryginały. Co to znaczy w praktyce, jak wskaźniki symulują przekazywanie przez referencję, dlaczego tablice zachowują się inaczej i jak zwrócić kilka wyników.

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

Parametry i argumenty

Dwa słowa, których używa się zamiennie, choć warto je rozróżniać:

  • Parametr to zmienna w definicji funkcji, na przykład int n w int square(int n).
  • Argument to wartość podana w miejscu wywołania, na przykład 4 w square(4).

Wywołanie funkcji tworzy jej parametry jako nowe zmienne lokalne i kopiuje do nich argumenty. Ta kopia to cały temat tej strony.

C przekazuje przez wartość, zawsze

To jest reguła i C nie ma od niej wyjątku: funkcja dostaje kopie swoich argumentów. Przypisanie do parametru zmienia tylko kopię.

value nadal wynosi 42. Funkcja dostała kopię o nazwie x, wyzerowała tę kopię, a kopia zniknęła, gdy funkcja się zakończyła. Zmiana nazwy parametru na value niczego nie zmienia: nazwa ich nie łączy, to dwie różne zmienne.

To samo dotyczy każdego typu: int, double, char, wskaźnika, a nawet całej struktury (kopiowanej pole po polu). Jeśli znasz Pythona, Javę albo JavaScript, pamiętaj, że te języki przekazują przez wartość referencje do obiektów, więc zmiana obiektu wewnątrz funkcji jest widoczna na zewnątrz. W C tak nie jest: kopiowany jest sam obiekt.

Przekazywanie przez wartość to zaleta, a nie ograniczenie. Funkcja nie może przypadkiem zepsuć danych wywołującego, dzięki czemu dużo łatwiej przewidzieć zachowanie kodu.

Klasyczna porażka: swap

Podręcznikowy przykład to funkcja, która próbuje zamienić dwie wartości:

Logika jest poprawna, naprawdę zamienia a i b. Ale a i b to kopie, więc cała praca przepada w chwili, gdy funkcja się kończy.

Symulowanie przekazywania przez referencję wskaźnikami

Żeby funkcja mogła zmienić zmienną wywołującego, daj jej adres zmiennej zamiast wartości. Sam adres też jest przekazywany przez wartość, bo wskaźnik jest kopiowany, ale kopia nadal wskazuje oryginalny obiekt, więc zapis przez nią trafia do pamięci wywołującego.

Całą ideę niosą trzy elementy składni:

  • int *a na liście parametrów deklaruje a jako "wskaźnik na int".
  • &x w miejscu wywołania daje adres x.
  • *a wewnątrz funkcji oznacza "ten int, na który wskazuje a": można go czytać i do niego przypisywać.

To właśnie programiści C nazywają "przekazywaniem przez referencję" i warto być precyzyjnym: to nadal przekazywanie przez wartość. Kopiowany jest wskaźnik. Przypisanie do samego a (a = NULL;) zmieniłoby tylko kopię; przypisanie do *a zmienia zmienną wywołującego. Pełny model opisuje strona o wskaźnikach.

Ceną jest to, że samo miejsce wywołania nie mówi już, czy zmienna może się zmienić, i właśnie dlatego & jest wymagane. swap(x, y) się nie skompiluje; musisz napisać swap(&x, &y), a to & jest widocznym znakiem, że to wywołanie może zmienić x.

Zwracanie kilku wyników

return zwraca jedną wartość, więc dodatkowe wyniki wędrują przez parametry wskaźnikowe. Typowy układ to kod statusu jako wartość zwracana i prawdziwe wyniki zapisane przez wskaźniki:

Zauważ, że q i r zachowują poprzednie wartości, gdy wywołanie się nie powiedzie: funkcja zakończyła się, zanim cokolwiek zapisała. Tego kontraktu musi przestrzegać wywołujący i dlatego sprawdzenie statusu następuje przed użyciem wyników.

Alternatywą jest zwrócenie struktury z oboma polami, co całkowicie omija wskaźniki i często jest ładniejszym rozwiązaniem, gdy wartości naprawdę do siebie należą.

Tablice są inne

Tablice nie podlegają regule kopiowania i to myli prawie wszystkich. Przekazana tablica degraduje się do wskaźnika na swój pierwszy element, więc funkcja dostaje adres, a nie kopię danych.

Tablica wywołującego naprawdę się zmieniła, bez żadnego &, bo w tym kontekście data już jest adresem. Wynikają z tego trzy konsekwencje:

int a[] i int *a znaczą na liście parametrów dokładnie to samo. Nawet int a[100], bo rozmiar jest ignorowany. Zapis int a[] dokumentuje intencję, ale nie czytaj go jako "tablica jest kopiowana".

sizeof wewnątrz funkcji daje zły wynik. Oto ten błąd:

W main sizeof(data) to 20 bajtów, a długość wychodzi 5. Wewnątrz funkcji sizeof(a) to rozmiar wskaźnika, czyli 8 w systemie 64-bitowym, więc "długość" wynosi 2. Rozmiar tablicy po prostu nie jest tam dostępny. Zawsze przekazuj długość jako osobny parametr. Nowoczesne kompilatory ostrzegają o tym przy -Wall.

Używaj const, gdy nie zamierzasz zapisywać. const int a[] mówi, że funkcja tylko czyta, co kompilator egzekwuje, a czytelnik może na tym polegać:

int sum(const int a[], int n);        /* obiecuje nie zmieniać tablicy */

Łańcuchy znaków to też tablice, więc to samo dotyczy ich: void greet(const char *name) to standardowa sygnatura funkcji, która czyta łańcuch, nie zmieniając go.

A co z kosztem?

Przekazanie dużej struktury przez wartość kopiuje każdy jej bajt przy każdym wywołaniu. Dla struktury z kilkoma polami to w porządku i często szybsze niż pośredniość, którą dodaje wskaźnik. Dużą strukturę przekazuj przez wskaźnik i oznacz go jako const, jeśli funkcja tylko czyta:

struct Config { char name[64]; int flags[32]; double weights[128]; };

void applyConfig(const struct Config *cfg);   /* bez kopiowania 512 bajtów na wywołanie */

const struct Config *cfg oznacza "wskaźnik na Config, którego nie zmienię": masz wydajność wskaźnika i bezpieczeństwo kopii.

Częste błędy

  • Oczekiwanie, że zwykły parametr zmieni zmienną wywołującego. Nie może. Przekaż wskaźnik.
  • Zapomniane & w miejscu wywołania. swap(x, y), gdy swap oczekuje wskaźników, to błąd typu. Kompilator go wyłapie, ale tylko wtedy, gdy prototyp jest w zasięgu.
  • Zapomniane * wewnątrz funkcji. a = b; na dwóch parametrach wskaźnikowych zamienia lokalne kopie, a nie wartości. Chodzi ci o *a = *b;.
  • Użycie sizeof na parametrze tablicowym. Zawsze źle. Przekaż długość.
  • Zwracanie wskaźnika na zmienną lokalną. Zmienna lokalna znika, gdy funkcja się kończy, więc wywołujący trzyma wiszący wskaźnik, a to niezdefiniowane zachowanie. Zwróć wartość, zapisz do bufora podanego przez wywołującego albo przydziel pamięć przez malloc.
  • Brak sprawdzenia parametru wskaźnikowego pod kątem NULL. Funkcja, która wyłuskuje wszystko, co dostanie, wysypie się na pustym argumencie.

Najczęściej zadawane pytania

Czy C ma przekazywanie przez referencję?

Nie. C ma dokładnie jeden tryb: przekazywanie przez wartość. Funkcja zawsze dostaje kopię każdego argumentu, więc przypisanie do parametru nigdy nie zmienia zmiennej wywołującego. To, co wygląda na przekazywanie przez referencję, jest przekazaniem przez wartość wskaźnika: wskaźnik jest kopiowany, ale nadal wskazuje obiekt wywołującego, więc funkcja może go zmienić przez ten wskaźnik.

Jak przekazać tablicę do funkcji w C?

Zapisz parametr jako int a[] albo int *a (to jedno i to samo) i przekaż długość jako osobny parametr: int sum(const int a[], int n). Tablica nie jest kopiowana; funkcja dostaje wskaźnik na jej pierwszy element, więc zmiany a[i] są widoczne dla wywołującego.

Dlaczego sizeof nie działa na parametrze tablicowym w C?

Bo parametr jest tak naprawdę wskaźnikiem. sizeof(a) wewnątrz funkcji daje rozmiar wskaźnika (zwykle 8 bajtów), a nie rozmiar tablicy, więc sizeof(a)/sizeof(a[0]) daje coś w rodzaju 2 zamiast liczby elementów. Zawsze przekazuj długość jawnie.

Jak funkcja w C może zwrócić więcej niż jedną wartość?

Przekaż wskaźniki na dodatkowe wyniki i zapisz przez nie: int divide(int a, int b, int *quotient, int *remainder) zwraca flagę powodzenia i wypełnia oba wyjścia. Alternatywą jest zwrócenie struktury, która łączy wartości w całość.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ