16.3 — std::vector oraz problem długości bez znaku i indeksu dolnego

W poprzedniej lekcji 16.2 — Wprowadzenie do std::vector i konstruktorów list, którą wprowadziliśmy operator[], którego można użyć do indeksowania tablicy w celu uzyskania dostępu do elementu.

W tej lekcji przyjrzymy się innym sposobom dostępu do elementów tablicy, a także kilku różnym sposobom uzyskania długości klasy kontenera (liczby elementów aktualnie znajdujących się w klasie kontenera).

Ale zanim przejdziemy do możemy to zrobić, musimy omówić jeden poważny błąd, który popełnili projektanci C++ i jego wpływ na wszystkie klasy kontenerów w standardowej bibliotece C++.

Problem ze znakiem długości kontenera

Zacznijmy od stwierdzenia: typ danych używany do indeksowania tablicy powinien odpowiadać typowi danych używanemu do przechowywania długości tablicy. Dzieje się tak, aby indeksować można wszystkie elementy najdłuższej możliwej tablicy i nic więcej.

Jak Bjarne Stroustrup przypomina, kiedy projektowano klasy kontenerów w standardowej bibliotece C++ (około 1997 r.), projektanci musieli wybrać, czy długość (i indeksy dolne tablicy) mają być ze znakiem czy bez znaku. Zdecydowali się na uczynienie ich bez znaku.

Podano ku temu powody: indeksy dolne typów tablic z biblioteki standardowej nie mogą być ujemne, użycie typu bez znaku pozwala na tablice o większej długości ze względu na dodatkowy bit (coś, co było ważne w czasach 16-bitowych), a sprawdzanie zakresu indeksu dolnego wymaga jednego sprawdzenia warunkowego zamiast dwóch (ponieważ nie było konieczne sprawdzanie, aby upewnić się, że indeks jest mniejszy niż 0).

W Z perspektywy czasu powszechnie uważa się, że był to zły wybór. Teraz rozumiemy, że używanie wartości bez znaku w celu wymuszenia nieujemności nie działa ze względu na niejawne reguły konwersji (ponieważ ujemna liczba całkowita ze znakiem zostanie po prostu niejawnie przekonwertowana na dużą liczbę całkowitą bez znaku, co spowoduje śmieciowy wynik), dodatkowy bit zakresu zwykle nie jest potrzebny w systemach 32-bitowych lub 64-bitowych (ponieważ prawdopodobnie nie tworzysz tablic zawierających więcej niż 2 miliardy elementów) i powszechnie używany operator[] i tak nie sprawdza zakresu.

W lekcji 4.5 -- Liczby całkowite bez znaku i dlaczego ich unikać, omówiliśmy powody, dla których wolimy używać wartości ze znakiem do przechowywania ilości. Zauważyliśmy również, że mieszanie wartości ze znakiem i bez znaku jest receptą na nieoczekiwane zachowanie. Zatem fakt, że klasy kontenerów bibliotek standardowych używają wartości bez znaku dla długości (i indeksów) jest problematyczny, ponieważ uniemożliwia uniknięcie wartości bez znaku podczas korzystania z tych typów.

Na razie utknęliśmy z tym wyborem i niepotrzebną złożonością, jaką powoduje.

Przegląd: konwersje znaków zawężają konwersje, z wyjątkiem przypadku constexpr

Zanim przejdziemy dalej, szybko podsumujmy niektóre materiał z lekcji 10.4 — Konwersje zawężające, inicjowanie list i inicjatory constexpr dotyczące konwersji na znak (konwersja całkowa ze znaku na bez znaku i odwrotnie), ponieważ będziemy o nich dużo mówić w tym rozdziale.

Konwersje znaków są uważane za konwersje zawężające, ponieważ typ ze znakiem lub bez znaku nie może pomieścić wszystkich wartości zawartych w zakresie przeciwnego typu. Gdy taka konwersja zostanie wykonana w czasie wykonywania, kompilator zgłosi błąd w kontekstach, w których konwersje zawężające są niedozwolone (np. podczas inicjowania listy), i może, ale nie musi, wygenerować ostrzeżenie w innych kontekstach, w których taka konwersja jest wykonywana.

Na przykład:

#include <iostream>

void foo(unsigned int)
{
}

int main()
{
    int s { 5 };
    
    [[maybe_unused]] unsigned int u { s }; // compile error: list initialization disallows narrowing conversion
    foo(s);                                // possible warning: copy initialization allows narrowing conversion

    return 0;
}

W powyższym przykładzie inicjalizacja zmiennej u powoduje błąd kompilacji, ponieważ konwersje zawężające są niedozwolone podczas inicjowania listy. Zadzwoń do foo() wykonuje inicjalizację kopiowania, która pozwala na zawężenie konwersji i która może, ale nie musi, wygenerować ostrzeżenie, w zależności od tego, jak agresywnie kompilator generuje ostrzeżenia dotyczące konwersji znaków. Na przykład zarówno GCC, jak i Clang będą generować ostrzeżenia w tym przypadku, gdy używana jest flaga kompilatora -Wsign-conversion .

Jeśli jednak wartość do konwersji znaku to constexpr i można ją przekonwertować na równoważną wartość w przeciwstawnym typie, konwersja znaku jest nie uważana za zawężającą. Dzieje się tak, ponieważ kompilator może zagwarantować, że konwersja jest bezpieczna lub zatrzymać proces kompilacji.

#include <iostream>

void foo(unsigned int)
{
}

int main()
{
    constexpr int s { 5 };                 // now constexpr
    [[maybe_unused]] unsigned int u { s }; // ok: s is constexpr and can be converted safely, not a narrowing conversion
    foo(s);                                // ok: s is constexpr and can be converted safely, not a narrowing conversion

    return 0;
}

W tym przypadku, ponieważ s jest constexpr, a wartość do przekonwertowania (5) może być reprezentowana jako wartość bez znaku, konwersja nie jest uważana za zawężającą i można ją przeprowadzić w sposób dorozumiany bez problemu.

Ta niezawężająca konwersja constexpr (from constexpr int Do constexpr std::size_t) będzie czymś, z czego często będziemy korzystać.

Długość i indeksy std::vector ma typ size_type

W lekcji 10.7 -- Typedefs i aliasy typów, wspomnieliśmy, że typedefs i aliasy typów są często używane w przypadkach, gdy potrzebujemy nazwy typu, który może się różnić (np. ponieważ jest on zdefiniowany w implementacji). Na przykład std::size_t jest typedef dla jakiegoś dużego typu całkowitego bez znaku, zwykle unsigned long lub unsigned long long.

Każda ze standardowych klas kontenerów bibliotek definiuje zagnieżdżony element typedef o nazwie size_type (czasami zapisywane jako T::size_type), który jest aliasem typu używanego dla długości (i indeksów, jeśli są obsługiwane) kontenera.

Zazwyczaj size_type pojawia się w dokumentacji oraz w ostrzeżeniach/komunikatach o błędach kompilatora. Na przykład ta dokumentacja dla size() funkcją składową z std::vector wskazuje, że size() zwraca wartość size_type.

Powiązana treść

Zagnieżdżone typy defibrylacji omawiamy w lekcji 15.3 -- Typy zagnieżdżone (typy elementów).

size_type jest prawie zawsze aliasem std::size_t, ale można je zastąpić (w rzadkich przypadkach), aby użyć innego type.

Kluczowa informacja

size_type to zagnieżdżony typedef zdefiniowany w klasach kontenerów biblioteki standardowej, używany jako typ długości (i indeksów, jeśli są obsługiwane) klasy kontenera.

size_type domyślnie std::size_t, a ponieważ prawie nigdy się to nie zmienia, możemy rozsądnie założyć size_type jest pseudonimem dla std::size_t.

Dla zaawansowanych czytelników

Wszystkie standardowe kontenery bibliotek z wyjątkiem std::array use std::allocator przydzielić pamięć. W przypadku tych kontenerów T::size_type wychodzi z size_type użytego alokatora. Ponieważ std::allocator może przydzielić do std::size_t bajtów pamięci, std::allocator<T>::size_type definiuje się jako std::size_t. Dlatego T::size_type domyślnie std::size_t.

Tylko w przypadkach, gdy niestandardowy alokator, którego T::size_type jest zdefiniowany jako coś innego niż std::size_t kontener T::size_type będzie czymś innym niż std::size_t. Jest to rzadkie zjawisko i jest wykonywane dla poszczególnych aplikacji, więc ogólnie można bezpiecznie założyć, że T::size_type będzie std::size_t chyba że Twoja aplikacja korzysta z takiego niestandardowego alokatora (i będziesz wiedzieć, czy tak jest).

Podczas uzyskiwania dostępu do size_type członkiem klasy kontenera, musimy zakwalifikować go zakresem za pomocą w pełni szablonowej nazwy klasy kontenera. Na przykład, std::vector<int>::size_type.

Uzyskiwanie długości a std::vector za pomocą konstruktora size() lub std::size()

Możemy zapytać obiekt klasy kontenera o jego długość za pomocą metody size() funkcji składowej (która zwraca długość jako bez znaku size_type):

#include <iostream>
#include <vector>

int main()
{
    std::vector prime { 2, 3, 5, 7, 11 };
    std::cout << "length: " << prime.size() << '\n'; // returns length as type `size_type` (alias for `std::size_t`)
    return 0;
}

Wypisuje:

length: 5

W przeciwieństwie do std::string i std::string_view, które mają obie length() oraz a size() funkcje członkowskie (które robią to samo rzeczą), std::vector (i większość innych typów kontenerów w C++) ma tylko size(). Teraz rozumiesz, dlaczego długość kontenera jest czasami dwuznacznie nazywana jego rozmiarem.

W C++17 możemy również użyć std::size() funkcji niebędącej składową (która dla klas kontenerów po prostu wywołuje funkcję składową size() ).

#include <iostream>
#include <vector>

int main()
{
    std::vector prime { 2, 3, 5, 7, 11 };
    std::cout << "length: " << std::size(prime); // C++17, returns length as type `size_type` (alias for `std::size_t`)

    return 0;
}

Dla zaawansowanych czytelników

Ponieważ std::size() można również zastosować w przypadku niezniszczonych tablic w stylu C, ta metoda jest czasami preferowana w stosunku do używania size() funkcja składowa (szczególnie podczas pisania szablonów funkcji, które mogą akceptować klasę kontenera lub niezniszczalny argument tablicy w stylu C).

Rozpad tablicy w stylu C omawiamy na lekcji 17.8 -- Zanikanie tablicy w stylu C.

Jeśli chcemy użyć którejkolwiek z powyższych metod do przechowywania długości w zmiennej ze znakiem, prawdopodobnie spowoduje to ostrzeżenie lub błąd konwersji ze znakiem/bez znaku. Najprostszą rzeczą, jaką można tutaj zrobić, jest static_cast wynik do żądanego typu:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime { 2, 3, 5, 7, 11 };
    int length { static_cast<int>(prime.size()) }; // static_cast return value to int
    std::cout << "length: " << length ;

    return 0;
}

Uzyskiwanie długości a std::vector za pomocą std::ssize() C++20

C++20 wprowadza std::ssize() funkcja niebędąca członkiem, która zwraca długość jako dużą podpisany typ integralny (zwykle std::ptrdiff_t, który jest typem zwykle używanym jako podpisany odpowiednik std::size_t):

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };
    std::cout << "length: " << std::ssize(prime); // C++20, returns length as a large signed integral type

    return 0;
}

To jest jedyna funkcja z trzech, która zwraca długość jako typ ze znakiem.

Jeśli chcesz użyć tej metody do przechowywania długości w zmiennej ze znakiem, masz kilka opcji.

Po pierwsze, ponieważ typ int może być mniejszy niż typ ze znakiem zwrócony przez std::ssize(), jeśli jesteś zamierzasz przypisać długość do int zmiennej, powinieneś static_cast wynik do int aby każda taka konwersja była jawna (w przeciwnym razie możesz otrzymać ostrzeżenie lub błąd zawężającej konwersji):

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };
    int length { static_cast<int>(std::ssize(prime)) }; // static_cast return value to int
    std::cout << "length: " << length;

    return 0;
}

Alternatywnie możesz użyć auto aby kompilator wydedukował prawidłowy typ ze znakiem, który ma zostać użyty dla zmiennej:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };
    auto length { std::ssize(prime) }; // use auto to deduce signed type, as returned by std::ssize()
    std::cout << "length: " << length;

    return 0;
}

Dostęp do elementów tablicy za pomocą operator[] nie sprawdza granic

W poprzedniej lekcji wprowadziliśmy operator indeksu dolnego (operator[]):

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    std::cout << prime[3];  // print the value of element with index 3 (7)
    std::cout << prime[9]; // invalid index (undefined behavior)

    return 0;
}

operator[] nie sprawdza granic. Indeks operatora [] może być inny niż stały. Omówimy to szerzej w dalszej części.

Dostęp do elementów tablicy za pomocą metody at() funkcja członkowska sprawdza granice środowiska wykonawczego

Klasy kontenerów tablic obsługują inną metodę dostępu do tablicy. The at() funkcji członkowskiej można użyć do uzyskania dostępu do tablicy ze sprawdzaniem granic środowiska wykonawczego:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    std::cout << prime.at(3); // print the value of element with index 3
    std::cout << prime.at(9); // invalid index (throws exception)

    return 0;
}

W powyższym przykładzie wywołanie prime.at(3) sprawdza, czy indeks 3 jest prawidłowy, a ponieważ tak jest, zwraca odwołanie do elementu tablicy 3. Możemy następnie wydrukować tę wartość. Jednak wezwanie do prime.at(9) kończy się niepowodzeniem (w czasie wykonywania), ponieważ 9 nie jest prawidłowym indeksem dla tej tablicy. Zamiast zwracać referencję, plik at() funkcja generuje błąd, który kończy działanie programu.

Dla zaawansowanych czytelników

Gdy at() funkcja członkowska napotyka indeks spoza zakresu, w rzeczywistości zgłasza wyjątek typu std::out_of_range. Jeśli wyjątek nie zostanie obsłużony, program zostanie zakończony. Omawiamy wyjątki i sposoby ich obsługi rozdziale 27.

Po prostu podobnie jak operator[], indeks przekazany do at() może być niestała.

Ponieważ sprawdza granice czasu wykonywania przy każdym wywołaniu, at() jest wolniejszy (ale bezpieczniejszy) niż operator[]. Mimo że jest mniej bezpieczny, operator[] jest zwykle używany at(), głównie dlatego, że lepiej jest sprawdzić granice przed indeksowaniem, więc w pierwszej kolejności nie próbujemy używać nieprawidłowego indeksu.

Indeksowanie std::vector z constexpr podpisanym int

Podczas indeksowania A std::vector za pomocą int constexpr (ze znakiem) możemy pozwolić kompilatorowi na niejawną konwersję tego na a std::size_t bez konwersji zawężającej:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    std::cout << prime[3] << '\n';     // okay: 3 converted from int to std::size_t, not a narrowing conversion
 
    constexpr int index { 3 };         // constexpr
    std::cout << prime[index] << '\n'; // okay: constexpr index implicitly converted to std::size_t, not a narrowing conversion
   
    return 0;
}

Indeksowanie std::vector z wartością inną niż constexpr

Indeks dolny używany do indeksowania tablicy może nie być stały:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    std::size_t index { 3 };           // non-constexpr
    std::cout << prime[index] << '\n'; // operator[] expects an index of type std::size_t, no conversion required
   
    return 0;
}

Jednakże zgodnie z naszymi najlepszymi praktykami (4.5 -- Liczby całkowite bez znaku i dlaczego ich unikać), generalnie chcemy unikać używania typów bez znaku do przechowywania ilości.

Kiedy nasz indeks dolny jest wartością ze znakiem innym niż constexpr, pojawiają się problemy:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    int index { 3 };                   // non-constexpr
    std::cout << prime[index] << '\n'; // possible warning: index implicitly converted to std::size_t, narrowing conversion
   
    return 0;
}

W tym przykładzie index jest inteligencją niebędącą constexpr ze znakiem. Indeks dolny operator[] zdefiniowany jako część std::vector ma typ size_type (alias dla std::size_t). Dlatego kiedy dzwonimy prime[index], nasz znak int musi zostać przekonwertowany na std::size_t.

Taka konwersja nie powinna być niebezpieczna (ponieważ indeks a std::vector oczekuje się, że będzie nieujemna, a nieujemna wartość ze znakiem zostanie bezpiecznie przekonwertowana na wartość bez znaku). Jednak wykonywana w czasie wykonywania jest uważana za konwersję zawężającą i kompilator powinien wyświetlić ostrzeżenie, że jest to niebezpieczna konwersja (jeśli tak nie jest, powinieneś rozważyć zmodyfikowanie ostrzeżeń, aby tak było).

Ponieważ indeksowanie tablic jest powszechne i każda taka konwersja generuje ostrzeżenie, może to łatwo zaśmiecić dziennik kompilacji fałszywymi ostrzeżeniami. Lub, jeśli masz włączoną opcję „traktuj ostrzeżenie jako błąd”, zatrzyma to kompilację.

Istnieje wiele sposobów uniknięcia tego problemu (np. static_cast twój int do std::size_t za każdym razem, gdy indeksujesz tablicę), ale wszystko to nieuchronnie kończy się w jakiś sposób zaśmiecaniem lub komplikowaniem kodu. Najprostszą rzeczą do zrobienia w tym przypadku jest użycie zmiennej typu std::size_t jako indeksu i nie używaj tej zmiennej do niczego innego niż indeksowanie. W ten sposób przede wszystkim unikniesz wszelkich konwersji innych niż constexpr.

Wskazówka

Inną dobrą alternatywą jest zamiast indeksowania pliku std::vector sam, zindeksuj wynik data() funkcja członkowska:

#include <iostream>
#include <vector>

int main()
{
    std::vector prime{ 2, 3, 5, 7, 11 };

    int index { 3 };                          // non-constexpr signed value
    std::cout << prime.data()[index] << '\n'; // okay: no sign conversion warnings
   
    return 0;
}

Pod maską, std::vector przechowuje swoje elementy w tablicy w stylu C. The data() Funkcja członkowska zwraca wskaźnik do podstawowej tablicy w stylu C, którą możemy następnie zaindeksować. Ponieważ tablice w stylu C umożliwiają indeksowanie zarówno typów ze znakiem, jak i bez znaku, nie napotykamy żadnych problemów z konwersją znaków. Tablice w stylu C omówimy w dalszej części lekcji 17.7 -- Wprowadzenie do tablic w stylu C i 17.8 -- Zanikanie tablicy w stylu C.

Nota autora

Na lekcji omówimy dodatkowe opcje radzenia sobie z takimi wyzwaniami związanymi z indeksowaniem 16.7 — Tablice, pętle i rozwiązania w zakresie wyzwań związanych ze znakami.

Czas quizu

Pytanie nr 1

Zainicjuj a std::vector o wartościach: „h”, „e”, „l”, „l”, „o”. Następnie wydrukuj długość tablicy (użyj std::size()). Na koniec wypisz wartość elementu o indeksie 1, używając operatora indeksu dolnego i at() funkcja składowa.

Program powinien wypisać co następuje:

The array has 5 elements.
ee

Pokaż rozwiązanie

Pytanie nr 2

a) Co to jest size_type i do czego jest używany?

Pokaż rozwiązanie

b) Jaki typ jest size_type domyślny? Czy jest podpisany czy niepodpisany?

Pokaż rozwiązanie

c) Które funkcje obliczają długość kontenera, zwracają size_type?

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:  
61 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze