17.8 — Zanik tablicy w stylu C

Wyzwanie polegające na przekazywaniu tablicy w stylu C

Projektantzy języka C mieli problem. Rozważmy następujący prosty program:

#include <iostream>

void print(int val)
{
    std::cout << val;
}

int main()
{
    int x { 5 };
    print(x);

    return 0;
}

Po wywołaniu print(x) wartość argumentu x (5) jest kopiowana do parametru val. Wewnątrz treści funkcji na konsolę wypisana jest wartość val (5). Ponieważ x kopiowanie jest tanie, nie ma tu problemu.

Rozważmy teraz następujący podobny program, który zamiast pojedynczej int używa 1000-elementowej tablicy int w stylu C:

#include <iostream>

void printElementZero(int arr[1000])
{
    std::cout << arr[0]; // print the value of the first element
}

int main()
{
    int x[1000] { 5 };   // define an array with 1000 elements, x[0] is initialized to 5
    printElementZero(x);

    return 0;
}

Ten program kompiluje również i wypisuje oczekiwaną wartość (5) na konsolę.

Chociaż kod w tym przykładzie jest podobny do kodu w poprzednim przykładzie, mechanicznie działa on nieco inaczej, niż można by się spodziewać (wyjaśnimy to poniżej). Dzieje się tak dzięki rozwiązaniu, które projektanci języka C wpadli na dwa główne wyzwania.

Po pierwsze, kopiowanie tablicy zawierającej 1000 elementów przy każdym wywołaniu funkcji jest kosztowne (a tym bardziej, jeśli kopiowanie elementów jest kosztowne), więc chcemy tego uniknąć. Ale jak? C nie ma referencji, więc użycie przekazywania przez referencję w celu uniknięcia tworzenia kopii argumentów funkcji nie wchodziło w grę.

Po drugie, chcemy móc napisać pojedynczą funkcję, która będzie akceptować argumenty tablicowe o różnej długości. W idealnym przypadku nasza funkcja printElementZero() z powyższego przykładu powinna być wywoływana z argumentami tablicowymi o dowolnej długości (ponieważ na pewno istnieje element 0). Nie chcemy pisać innej funkcji dla każdej możliwej długości tablicy, której chcemy użyć jako argumentu. Ale jak? C nie ma składni umożliwiającej określenie tablic o „dowolnej długości”, nie obsługuje szablonów ani nie można konwertować tablic o jednej długości na tablice o innej długości (prawdopodobnie dlatego, że wiązałoby się to z wykonaniem kosztownej kopii).

Projektanci języka C wpadli na sprytne rozwiązanie (odziedziczone przez C++ ze względu na kompatybilność), które rozwiązuje oba te problemy:

#include <iostream>

void printElementZero(int arr[1000]) // doesn't make a copy
{
    std::cout << arr[0]; // print the value of the first element
}

int main()
{
    int x[7] { 5 };      // define an array with 7 elements
    printElementZero(x); // somehow works!

    return 0;
}

W jakiś sposób powyższy przykład przekazuje 7-elementową tablicę do funkcji oczekującej tablicę składającą się z 1000 elementów, bez tworzenia żadnych kopii. Podczas tej lekcji sprawdzimy, jak to działa.

Przyjrzymy się także, dlaczego rozwiązanie wybrane przez projektantów C jest niebezpieczne i niezbyt nadaje się do użycia we współczesnym C++.

Ale najpierw musimy omówić dwa podtematy.

Konwersja tablicy na wskaźnik (zanik tablicy)

W większości przypadków, gdy w wyrażeniu używana jest tablica w stylu C, tablica zostanie niejawnie przekonwertowana na wskaźnik do typu elementu, zainicjowany adresem pierwszego elementu (z indeksem 0). Potocznie nazywa się to rozpadem tablicy (lub po prostu rozpadem w skrócie).

Możesz to zobaczyć w następującym programie:

#include <iomanip> // for std::boolalpha
#include <iostream>

int main()
{
    int arr[5]{ 9, 7, 5, 3, 1 }; // our array has elements of type int

    // First, let's prove that arr decays into an int* pointer

    auto ptr{ arr }; // evaluation causes arr to decay, type deduction should deduce type int*
    std::cout << std::boolalpha << (typeid(ptr) == typeid(int*)) << '\n'; // Prints true if the type of ptr is int*

    // Now let's prove that the pointer holds the address of the first element of the array

    std::cout << std::boolalpha << (&arr[0] == ptr) << '\n';

    return 0;
}

Na maszynie autora wydrukowało to:

true
true

Nie ma nic specjalnego we wskaźniku, na który rozpada się tablica. Jest to normalny wskaźnik przechowujący adres pierwszego elementu.

Podobnie tablica const (np. const int arr[5]) rozpada się na wskaźnik do const (const int*).

Wskazówka

W C++ istnieje kilka typowych przypadków, w których tablica w stylu C nie zanika:

  1. Gdy jest używana jako argument do sizeof() lub typeid().
  2. Podczas brania adres tablicy przy użyciu operator&.
  3. Gdy jest przekazywany jako członek typu klasy.
  4. Gdy przekazywany przez referencję.

Ponieważ tablice w stylu C w większości przypadków rozpadają się na wskaźnik, częstym błędem jest wierzyć, że tablice wskaźniki nie są prawdą. Obiekt tablicy jest sekwencją elementów, podczas gdy obiekt wskaźnika zawiera tylko adres.

Informacje o typie tablicy i tablicy po rozpadzie są różne w powyższym przykładzie, tablica arr ma typ int[5], podczas gdy tablica po rozpadzie ma typ int*. W szczególności typ tablicy int[5] zawiera informację o długości, natomiast typ wskaźnika tablicy z rozkładem int* nie.

Kluczowa informacja

Zepsuty wskaźnik tablicy nie wie, jak długa jest tablica, na którą wskazuje. Termin „zanik” wskazuje na utratę informacji o typie długości.

Właściwie ma zastosowanie subskrypcja tablicy w stylu C operator[] do zepsutego wskaźnika

Ponieważ tablice w stylu C podczas oceny zamieniają się w wskaźnik, gdy tablica w stylu C jest poddawana indeksowi dolnemu, indeks dolny w rzeczywistości działa na wskaźniku tablicy z rozkładem:

#include <iostream>

int main()
{
    const int arr[] { 9, 7, 5, 3, 1 };
    std::cout << arr[2]; // subscript decayed array to get element 2, prints 5

    return 0;
}

Możemy również skorzystać operator[] bezpośrednio na wskaźniku. Jeśli ten wskaźnik przechowuje adres pierwszego elementu, wynik będzie identyczny:

#include <iostream>

int main()
{
    const int arr[] { 9, 7, 5, 3, 1 };
    
    const int* ptr{ arr };  // arr decays into a pointer
    std::cout << ptr[2];    // subscript ptr to get element 2, prints 5

    return 0;
}

Za chwilę zobaczymy, gdzie jest to wygodne, a w następnej lekcji przyjrzymy się bliżej, jak to faktycznie działa (a także co się dzieje, gdy wskaźnik trzyma coś innego niż adres pierwszego elementu) 17,9 -- Arytmetyka wskaźników i indeksy dolne.

Zanik tablicy rozwiązuje nasz problem z przekazywaniem tablicy w stylu C

Rozpad tablicy rozwiązuje oba problemy, które napotkaliśmy na początku lekcji.

Podczas przekazywania tablicy w stylu C jako argumentu tablica rozpada się na wskaźnik, a wskaźnik przechowujący adres pierwszego elementu tablicy jest przekazywany do funkcji. Zatem chociaż wygląda na to, że przekazujemy tablicę w stylu C według wartości, w rzeczywistości przekazujemy ją według adresu! W ten sposób unika się tworzenia kopii argumentu tablicowego w stylu C.

Kluczowa informacja

Tablice w stylu C są przekazywane przez adres, nawet jeśli wygląda na to, że są przekazywane przez wartość.

Rozważmy teraz dwie różne tablice tego samego typu elementu, ale o różnych długościach (np. int[5] i int[7]). Są to odrębne typy, niezgodne ze sobą. Jednak oba ulegną rozpadowi na ten sam typ wskaźnika (np. int*). Ich zepsute wersje są wymienne! Usunięcie informacji o długości z typu pozwala nam przekazywać tablice o różnych długościach bez niezgodności typów.

Kluczowa informacja

Dwie tablice w stylu C z tym samym typem elementu, ale o różnej długości, zostaną podzielone na ten sam typ wskaźnika.

W poniższym przykładzie zilustrujemy dwie rzeczy:

  • Że możemy przekazywać tablice o różnej długości do pojedynczej funkcji (ponieważ obie rozpadają się na ten sam typ wskaźnika).
  • Że nasz parametr funkcji odbierający tablicę może być (stałym) wskaźnikiem typu elementu tablicy.
#include <iostream>

void printElementZero(const int* arr) // pass by const address
{
    std::cout << arr[0];
}

int main()
{
    const int prime[] { 2, 3, 5, 7, 11 };
    const int squares[] { 1, 4, 9, 25, 36, 49, 64, 81 };

    printElementZero(prime);   // prime decays to an const int* pointer
    printElementZero(squares); // squares decays to an const int* pointer

    return 0;
}

Ten przykład działa dobrze i drukuje:

2
1

W main(), kiedy dzwonimy printElementZero(prime), instrukcja prime array rozpada się z tablicy typu const int[5] do wskaźnika typu const int* który przechowuje adres pierwszego elementu prime. Podobnie, gdy dzwonimy printElementZero(squares), squares rozpada się z tablicy typu const int[8] do wskaźnika typu const int* który przechowuje adres pierwszego elementu squares. Te wskaźniki typu const int* są tym, co faktycznie jest przekazywane do funkcji jako argument.

Ponieważ przekazujemy wskaźniki typu const int*, nasz printElementZero() funkcja musi mieć parametr tego samego typu wskaźnika (const int*).

W ramach tej funkcji indeksujemy wskaźnik, aby uzyskać dostęp do wybranego elementu tablicy.

Ponieważ tablica w stylu C jest przekazywana przez adres, funkcja ma bezpośredni dostęp do przekazanej tablicy (a nie do kopii) i może modyfikować jej elementy. Z tego powodu dobrym pomysłem jest upewnienie się, że parametr funkcji ma wartość const, jeśli funkcja nie zamierza modyfikować elementów tablicy.

Składnia parametrów funkcji tablicowej w stylu C

Jeden problem z deklaracją parametru funkcji jako int* arr jest to, że nie jest to oczywiste arr ma być wskaźnikiem do tablicy wartości, a nie wskaźnikiem do pojedynczej liczby całkowitej. Z tego powodu przekazując tablicę w stylu C, lepiej jest użyć alternatywnej formy deklaracji int arr[]:

#include <iostream>

void printElementZero(const int arr[]) // treated the same as const int*
{
    std::cout << arr[0];
}

int main()
{
    const int prime[] { 2, 3, 5, 7, 11 };
    const int squares[] { 1, 4, 9, 25, 36, 49, 64, 81 };

    printElementZero(prime);  // prime decays to a pointer
    printElementZero(squares); // squares decays to a pointer

    return 0;
}

Program ten zachowuje się identycznie jak poprzedni, gdyż kompilator zinterpretuje parametr funkcji const int arr[] taki sam jak const int*. Ma to jednak tę zaletę, że przekazuje wywołującemu informację, że arr oczekuje się, że będzie to tablica w stylu C, która ma być przestarzałą, a nie wskaźnik do pojedynczej wartości. Należy pamiętać, że w nawiasach kwadratowych nie jest wymagana żadna informacja o długości (ponieważ i tak nie jest ona używana). Jeśli podana zostanie długość, zostanie ona zignorowana.

Najlepsza praktyka

Parametr funkcji oczekujący typu tablicy w stylu C powinien używać składni tablicy (np. int arr[]), a nie składni wskaźnika (np. int *arr).

Wadą stosowania tej składni jest to, że sprawia, że mniej oczywiste jest, że arr zanikł (podczas gdy jest to całkiem jasne ze składnią wskaźnika), więc będziesz potrzebować zachować szczególną ostrożność, aby nie zrobić niczego, co nie działa zgodnie z oczekiwaniami w przypadku zniszczonej tablicy (niektóre z nich omówimy za chwilę).

Problemy z rozpadem tablicy

Chociaż rozpad tablicy był sprytnym rozwiązaniem zapewniającym, że tablice w stylu C o różnych długościach będą mogły być przekazywane do funkcji bez tworzenia kosztownych kopii, utrata informacji o długości tablicy ułatwia popełnienie kilku rodzajów błędów.

Najpierw sizeof() zwróci różne wartości dla tablic i zniszczone tablice:

#include <iostream>

void printArraySize(int arr[])
{
    std::cout << sizeof(arr) << '\n'; // prints 4 (assuming 32-bit addresses)
}

int main()
{
    int arr[]{ 3, 2, 1 };

    std::cout << sizeof(arr) << '\n'; // prints 12 (assuming 4 byte ints)

    printArraySize(arr);

    return 0;
}

Oznacza to, że używanie sizeof() na tablicy w stylu C jest potencjalnie niebezpieczne, ponieważ musisz upewnić się, że używasz jej tylko wtedy, gdy możesz uzyskać dostęp do rzeczywistego obiektu tablicy, a nie do zniszczonej tablicy lub wskaźnika.

W poprzedniej lekcji (17.7 -- Wprowadzenie do tablic w stylu C) wspomnieliśmy, że sizeof(arr)/sizeof(*arr) było w przeszłości używane jako hack pozwalający uzyskać rozmiar tablicy w stylu C. Ten hack jest niebezpieczny, ponieważ jeśli arr upadnie, sizeof(arr) zwróci rozmiar wskaźnika, a nie rozmiar tablicy, tworząc w rezultacie niewłaściwą długość tablicy, co prawdopodobnie spowoduje nieprawidłowe działanie programu.

Na szczęście lepszy zamiennik C++17 std::size() (i C++20 std::ssize()) nie skompiluje się, jeśli przejdzie pomyślnie wartość wskaźnika:

#include <iostream>

int printArrayLength(int arr[])
{
    std::cout << std::size(arr) << '\n'; // compile error: std::size() won't work on a pointer
}

int main()
{
    int arr[]{ 3, 2, 1 };

    std::cout << std::size(arr) << '\n'; // prints 3

    printArrayLength(arr);

    return 0;
}

Po drugie i być może najważniejsze, rozpad tablicy może utrudnić refaktoryzację (rozbicie długich funkcji na krótsze, bardziej modułowe funkcje). Kod, który działa zgodnie z oczekiwaniami z niezniszczoną tablicą, może nie zostać skompilowany (lub, co gorsza, może po cichu działać nieprawidłowo), gdy ten sam kod używa zniszczonej tablicy.

Po trzecie, brak informacji o długości stwarza kilka problemów programistycznych bez informacji o długości, trudno jest rozsądnie sprawdzić długość użytkownicy mogą łatwo przekazywać tablice, które są krótsze niż oczekiwano (lub nawet wskaźniki do pojedynczej wartości), co spowoduje niezdefiniowane zachowanie, gdy zostaną indeksowane nieprawidłowym indeksem.

#include <iostream>

void printElement2(int arr[])
{
    // How do we ensure that arr has at least three elements?
    std::cout << arr[2] << '\n';
}

int main()
{
    int a[]{ 3, 2, 1 };
    printElement2(a);  // ok

    int b[]{ 7, 6 };
    printElement2(b);  // compiles but produces undefined behavior

    int c{ 9 };
    printElement2(&c); // compiles but produces undefined behavior

    return 0;
}

Brak długości tablicy stwarza również wyzwania podczas przechodzenia przez tablicę — skąd mamy wiedzieć, że dotarliśmy do końca?

Istnieją rozwiązania tych problemów, ale rozwiązania te zwiększają zarówno złożoność, jak i kruchość tablicy. program.

Obejście problemów z długością tablicy

W przeszłości programiści rozwiązywali problem braku informacji o długości tablicy za pomocą jednej z dwóch metod.

Po pierwsze, możemy przekazać zarówno tablicę, jak i długość tablicy jako osobne argumenty:

#include <cassert>
#include <iostream>

void printElement2(const int arr[], int length)
{
    assert(length > 2 && "printElement2: Array too short"); // can't static_assert on length

    std::cout << arr[2] << '\n';
}

int main()
{
    constexpr int a[]{ 3, 2, 1 };
    printElement2(a, static_cast<int>(std::size(a)));  // ok

    constexpr int b[]{ 7, 6 };
    printElement2(b, static_cast<int>(std::size(b)));  // will trigger assert

    return 0;
}

Jednak nadal wiąże się to z wieloma problemami:

  • Osoba wywołująca musi się upewnić, że tablica i długości tablicy są sparowane — jeśli zostanie przekazana niewłaściwa wartość długości, funkcja nadal będzie działać nieprawidłowo.
  • Istnieją potencjalne problemy z konwersją znaków, jeśli używasz std::size() lub funkcji zwracającej długość jako std::size_t.
  • Środowisko wykonawcze potwierdza wyzwalanie tylko wtedy, gdy zostanie napotkane w czasie wykonywania. Jeśli nasza ścieżka testowa nie obejmuje wszystkich wywołań funkcji, istnieje ryzyko wysłania do klienta programu, który potwierdzi, gdy zrobi coś, czego my nie zrobiliśmy. jawnie przetestować. We współczesnym C++ chcielibyśmy użyć static_assert do sprawdzania długości tablicy constexpr w czasie kompilacji, ale nie ma na to łatwego sposobu (ponieważ parametry funkcji nie mogą być constexpr, nawet w funkcjach constexpr ani constexpr!).
  • Ta metoda działa tylko wtedy, gdy wykonujemy jawne wywołanie funkcji. Jeśli wywołanie funkcji jest niejawne (np. wywołujemy operator z tablicą jako operandem), nie ma możliwości przekazania długości.

Po drugie, jeśli istnieje wartość elementu, która nie jest semantycznie poprawna (np. wynik testu -1), możemy zamiast tego oznaczyć koniec tablicy za pomocą elementu o tej wartości. W ten sposób długość tablicy można obliczyć, licząc, ile elementów istnieje między początkiem tablicy a elementem kończącym. Tablicę można również przeglądać, iterując od początku, aż trafimy na element kończący. Zaletą tej metody jest to, że działa ona nawet z niejawnymi wywołaniami funkcji.

Kluczowa informacja

Ciągi w stylu C (które są tablicami w stylu C) używają terminatora zerowego do oznaczenia końca łańcucha, dzięki czemu można je przechodzić, nawet jeśli uległy zniszczeniu.

Ale ta metoda ma również wiele problemów:

  • Jeśli element kończący nie istnieje, przechodzenie będzie przebiegać tuż obok końca tablicy, powodując niezdefiniowane zachowanie.
  • Funkcje przechodzące przez tablicę wymagają specjalnej obsługi elementu kończącego (np. funkcja drukująca ciąg znaków w stylu C musi wiedzieć, aby nie drukować elementu kończącego).
  • Występuje niezgodność pomiędzy rzeczywistą długością tablicy a liczbą semantycznie poprawnych elementów. Jeśli zostanie użyta niewłaściwa długość, semantycznie nieprawidłowy element kończący może zostać „przetworzony”.
  • To podejście działa tylko wtedy, gdy istnieje semantycznie niepoprawna wartość, co często nie ma miejsca.

W większości przypadków należy unikać tablic w stylu C

Ze względu na niestandardową semantykę przekazywania (zamiast przekazywania wartości używany jest adres przejścia) i ryzyko związane z utratą długości uszkodzonych tablic informacji, tablice w stylu C generalnie wypadły z łask. Zalecamy ich unikać w miarę możliwości.

Najlepsza praktyka

Unikaj tablic w stylu C, jeśli jest to praktyczne.

  • Preferuj std::string_view łańcuchy tylko do odczytu (ciągowe stałe symboliczne dosłowne i parametry łańcuchów).
  • Preferuj std::string łańcuchy modyfikowalne.
  • Preferuj std::array dla nieglobalne tablice constexpr.
  • Preferuj std::vector tablice inne niż constexpr.

Do globalnych tablic constexpr można używać tablic w stylu C. Omówimy to za chwilę.

Na marginesie…

W C++ tablice można przekazywać przez referencję, w którym to przypadku argument tablicy nie ulegnie zmniejszeniu po przekazaniu do funkcji (ale odwołanie do tablicy nadal ulegnie zmniejszeniu podczas oceny). Łatwo jednak zapomnieć o konsekwentnym stosowaniu tej zasady, a jedno pominięte odniesienie doprowadzi do upadku argumentów. Ponadto parametry odniesienia do tablicy muszą mieć stałą długość, co oznacza, że ​​funkcja może obsługiwać tylko tablice o jednej określonej długości. Jeśli chcemy funkcji, która poradzi sobie z tablicami o różnej długości, musimy również użyć szablonów funkcji. Ale jeśli masz zamiar zrobić obie te rzeczy, aby „naprawić” tablice w stylu C, równie dobrze możesz po prostu użyć std::array!

A więc kiedy tablice w stylu C są używane we współczesnym C++?

We współczesnym C++ tablice w stylu C są zwykle używane w dwóch przypadkach:

  1. Do przechowywania globalnych (lub constexpr lokalnych) danych programu. Ponieważ dostęp do takich tablic można uzyskać bezpośrednio z dowolnego miejsca w programie, nie musimy przekazywać tablicy, co pozwala uniknąć problemów związanych z zanikaniem. Składnia definiowania tablic w stylu C może być nieco mniej skomplikowana niż std::array. Co ważniejsze, indeksowanie takich tablic nie powoduje problemów z konwersją znaków, jak w przypadku klas kontenerów standardowej biblioteki.
  2. Jako parametry funkcji lub klas, które chcą bezpośrednio obsługiwać argumenty łańcuchowe w stylu C inne niż constexpr (zamiast wymagać konwersji do std::string_view). Są ku temu dwie możliwe przyczyny: Po pierwsze, konwersja ciągu znaków w stylu C innego niż constexpr na std::string_view wymaga przejścia przez ciąg znaków w stylu C w celu określenia jego długości. Jeśli funkcja znajduje się w sekcji kodu krytycznej dla wydajności i długość nie jest potrzebna (np. ponieważ funkcja i tak będzie przechodzić przez ciąg znaków), przydatne może być uniknięcie konwersji. Po drugie, jeśli funkcja (lub klasa) wywołuje inne funkcje, które oczekują ciągów w stylu C, konwersja na std::string_view tylko w celu konwersji z powrotem może być nieoptymalna (chyba że masz inne powody, dla których chcesz std::string_view).

Czas quizu

Pytanie nr 1

Co to jest zanikanie tablicy i dlaczego stanowi problem?

Pokaż rozwiązanie

Pytanie nr 2

Dlaczego ciągi w stylu C (które są tablicami w stylu C) używają terminatora zerowego?

Pokaż rozwiązanie

Pytanie nr 3

Dodatkowa zasługa: Dlaczego ciągi w stylu C używają terminatora zerowego zamiast wymagać przekazania do funkcji zarówno uszkodzonego ciągu w stylu C, jak i jawnej informacji o długości?

Pokaż rozwiązanie

Dodatkowy kredyt nr 2: Nawet gdyby C++ chciał zaimplementować konieczność przekazywania jawnych informacji o długości, dlaczego to nie zadziałałoby?

Pokaż wskazówkę

Pokaż rozwiązanie

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
370 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze