16,7 — Tablice, pętle i wyzwanie dotyczące znaku rozwiązania

W lekcji 4.5 -- Liczby całkowite bez znaku i dlaczego ich unikać, zauważyliśmy, jak my generalnie wolą używać wartości ze znakiem do przechowywania ilości, ponieważ wartości bez znaku mogą działać w zaskakujący sposób. Jednakże na lekcji 16.3 -- std::vector oraz problemu długości bez znaku i indeksu dolnego omówiliśmy, jak std::vector (i inne klasy kontenerów) używa typu całkowitego bez znaku std::size_t dla długości i indeksów.

Może to prowadzić do problemów takich jak ten:

#include <iostream>
#include <vector>

template <typename T>
void printReverse(const std::vector<T>& arr)
{
    for (std::size_t index{ arr.size() - 1 }; index >= 0; --index) // index is unsigned
    {
        std::cout << arr[index] << ' ';
    }

    std::cout << '\n';
}

int main()
{
    std::vector arr{ 4, 6, 7, 3, 8, 2, 1, 9 };

    printReverse(arr);

    return 0;
}

Ten kod zaczyna się od wydrukowania tablicy w odwrotnej kolejności:

9 1 2 8 3 7 6 4

A następnie wykazuje niezdefiniowane zachowanie. Może wydrukować niepotrzebne wartości lub zawiesić aplikację.

Występują tu dwa problemy. Po pierwsze, nasza pętla jest wykonywana tak długo, jak index >= 0 (lub innymi słowy, dopóki index jest dodatnia), co zawsze jest prawdą, gdy index jest bez znaku. Dlatego pętla nigdy się nie kończy.

Po drugie, kiedy zmniejszamy index jeśli ma ona wartość 0, zawija się ona do dużej wartości dodatniej, której następnie używamy do indeksowania tablicy w następnej iteracji. Jest to indeks spoza zakresu i spowoduje niezdefiniowane zachowanie. Napotkamy ten sam problem, jeśli nasz wektor jest pusty.

I chociaż istnieje wiele sposobów obejścia tych konkretnych problemów, tego rodzaju problemy przyciągają błędy.

Użycie typu ze znakiem dla zmiennej pętli łatwiej pozwala uniknąć takich problemów, ale wiąże się z pewnymi wyzwaniami. Oto wersja powyższego problemu, która wykorzystuje podpisany indeks:

#include <iostream>
#include <vector>

template <typename T>
void printReverse(const std::vector<T>& arr)
{
    for (int index{ static_cast<int>(arr.size()) - 1}; index >= 0; --index) // index is signed
    {
        std::cout << arr[static_cast<std::size_t>(index)] << ' ';
    }

    std::cout << '\n';
}

int main()
{
    std::vector arr{ 4, 6, 7, 3, 8, 2, 1, 9 };

    printReverse(arr);

    return 0;
}

Chociaż ta wersja działa zgodnie z przeznaczeniem, kod jest również zaśmiecony z powodu dodania dwóch statycznych rzutowań. arr[static_cast<std::size_t>(index)] jest szczególnie trudny do odczytania. W tym przypadku poprawiliśmy bezpieczeństwo znacznym kosztem czytelności.

Oto kolejny przykład użycia podpisanego indeksu:

#include <iostream>
#include <vector>

// Function template to calculate the average value in a std::vector
template <typename T>
T calculateAverage(const std::vector<T>& arr)
{
    int length{ static_cast<int>(arr.size()) };

    T average{ 0 };
    for (int index{ 0 }; index < length; ++index)
        average += arr[static_cast<std::size_t>(index)];
    average /= length;

    return average;
}

int main()
{
    std::vector testScore1 { 84, 92, 76, 81, 56 };
    std::cout << "The class 1 average is: " << calculateAverage(testScore1) << '\n';

    return 0;
}

Zaśmiecenie naszego kodu statycznymi rzutowaniami jest naprawdę straszne.

Więc co powinniśmy zrobić? Jest to obszar, w którym nie ma idealnego rozwiązania.

Jest tu wiele realnych opcji, które przedstawimy w kolejności od tego, co naszym zdaniem jest najgorsze do najlepszego. Prawdopodobnie spotkasz je wszystkie w kodzie napisanym przez innych.

Nota autora

Chociaż będziemy to omawiać w kontekście std::vector, wszystkie standardowe kontenery biblioteczne (np. std::array) działają podobnie i wiążą się z tymi samymi wyzwaniami. Poniższe omówienie dotyczy każdego z nich.

Pozostaw wyłączone ostrzeżenia o konwersji ze znakiem/niepodpisanym

Jeśli zastanawiasz się, dlaczego ostrzeżenia o konwersji ze znakiem/niepodpisanym są często domyślnie wyłączone, ten temat jest jednym z kluczowych powodów. Za każdym razem, gdy indeksujemy standardowy kontener biblioteki przy użyciu podpisanego indeksu, zostanie wygenerowane ostrzeżenie o konwersji znaku. Spowoduje to szybkie zapełnienie dziennika kompilacji fałszywymi ostrzeżeniami, zagłuszając ostrzeżenia, które w rzeczywistości mogą być uzasadnione.

A więc jednym ze sposobów uniknięcia konieczności radzenia sobie z dużą liczbą ostrzeżeń o konwersji podpisanych/niepodpisanych jest po prostu pozostawienie tych ostrzeżeń wyłączonych.

Jest to najprostsze rozwiązanie, ale nie jest zalecane, ponieważ zapobiegnie to również generowaniu prawidłowych ostrzeżeń o konwersji znaków, które mogą powodować błędy, jeśli nie adresowane.

Używanie zmiennej pętli bez znaku

Wielu programistów uważa, że skoro standardowe typy tablic bibliotek zostały zaprojektowane tak, aby korzystały z indeksów bez znaku, powinniśmy używać indeksów bez znaku! Jest to całkowicie rozsądne stanowisko. Musimy tylko zachować szczególną ostrożność, aby nie napotkać przy tym niedopasowań podpisanych/niepodpisanych. Jeśli to możliwe, unikaj używania zmiennej pętli indeksu do czegokolwiek innego niż indeksowanie.

Jeśli zdecydujemy się zastosować to podejście, jakiego typu bez znaku powinniśmy właściwie użyć?

W lekcji 16.3 -- std::vector oraz problemu długości bez znaku i indeksu dolnego, zauważyliśmy, że standardowe klasy kontenerów bibliotek definiują zagnieżdżony typedef size_type, który jest typem całkowitym bez znaku używanym do określania długości i indeksów tablic. Funkcja składowa size() zwraca size_type, I operator[] używa size_type jako indeksu, zatem użycie size_type jako typu indeksu jest technicznie najbardziej spójnym i bezpiecznym typem bez znaku do użycia (ponieważ będzie działać we wszystkich przypadkach, nawet w niezwykle rzadkim przypadku, gdy size_type jest czymś innym niż size_t.). Na przykład:

#include <iostream>
#include <vector>

int main()
{
	std::vector arr { 1, 2, 3, 4, 5 };

	for (std::vector<int>::size_type index { 0 }; index < arr.size(); ++index)
		std::cout << arr[index] << ' ';

	return 0;
}

Jednakże używanie size_type ma poważną wadę: ponieważ jest to typ zagnieżdżony, aby go użyć, musimy jawnie poprzedzić nazwę pełną nazwą kontenera z szablonu (co oznacza, że ​​musimy wpisać std::vector<int>::size_type a nie tylko std::size_type). Wymaga to dużo pisania, jest trudne do odczytania i różni się w zależności od kontenera i typu elementu.

W przypadku użycia wewnątrz szablonu funkcji możemy użyć T dla argumentów szablonu. Ale musimy także poprzedzić typ typename słowem kluczowym:

#include <iostream>
#include <vector>

template <typename T>
void printArray(const std::vector<T>& arr)
{
	// typename keyword prefix required for dependent type
	for (typename std::vector<T>::size_type index { 0 }; index < arr.size(); ++index)
		std::cout << arr[index] << ' ';
}

int main()
{
	std::vector arr { 9, 7, 5, 3, 1 };

	printArray(arr);

	return 0;
}

Jeśli zapomnisz typename słowem kluczowym, kompilator prawdopodobnie przypomni Ci o jego dodaniu.

Dla zaawansowanych czytelników

Każda nazwa zależna od typu zawierającego parametr szablonu nazywana jest nazwą zależną. Nazwy zależne muszą być poprzedzone słowem kluczowym typename , aby mogły być użyte jako typ.

W powyższym przykładzie std::vector<T> jest typem z parametrem szablonu, zatem typ zagnieżdżony std::vector<T>::size_type jest nazwą zależną i musi być poprzedzony słowem typename , aby można było go używać jako typu.

Czasami możesz zobaczyć alias typu tablicy, aby ułatwić korzystanie z pętli czytaj:

    using arrayi = std::vector<int>;
    for (arrayi::size_type index { 0 }; index < arr.size(); ++index)

Bardziej ogólnym rozwiązaniem jest pobranie przez kompilator typu obiektu typu tablicowego, dzięki czemu nie musimy jawnie określać typu kontenera ani argumentów szablonu. W tym celu możemy użyć słowa kluczowego decltype , które zwraca typ jego parametru.

    // arr is some non-reference type
    for (decltype(arr)::size_type index { 0 }; index < arr.size(); ++index) // decltype(arr) resolves to std::vector<int>

Jeśli jednak arr jest typem referencyjnym (np. tablica przekazywana przez referencję), powyższe nie działa. Musimy najpierw usunąć odniesienie z arr:

template <typename T>
void printArray(const std::vector<T>& arr)
{
	// arr can be a reference or non-reference type
	for (typename std::remove_reference_t<decltype(arr)>::size_type index { 0 }; index < arr.size(); ++index)
		std::cout << arr[index] << ' ';
}

Niestety, nie jest to już bardzo zwięzłe i łatwe do zapamiętania.

Ponieważ size_type jest prawie zawsze typedef dla size_t, wielu programistów po prostu pomija używanie size_type i używa łatwiejszego do zapamiętania i wpisania std::size_t bezpośrednio:

    for (std::size_t index { 0 }; index < arr.size(); ++index)

Chyba że używasz niestandardowe alokatory (a ty prawdopodobnie nie jesteś), uważamy, że jest to rozsądne podejście.

Używanie zmiennej pętli ze znakiem

Chociaż sprawia to, że praca ze standardowymi typami kontenerów biblioteki jest nieco trudniejsza, używanie zmiennej pętli ze znakiem jest zgodne z najlepszymi praktykami zastosowanymi w pozostałej części naszego kodu (aby faworyzować wartości ze znakiem dla ilości). Im częściej będziemy mogli konsekwentnie stosować nasze najlepsze praktyki, tym mniej będziemy mieć ogólnie błędów.

Jeśli mamy używać zmiennych pętli ze znakiem, musimy się zająć trzema kwestiami:

  • Co czy powinniśmy używać typu ze znakiem?
  • Uzyskiwanie długości tablicy jako wartości ze znakiem
  • Konwersja zmiennej pętli ze znakiem na indeks bez znaku

Co czy powinniśmy używać typu ze znakiem?

Są tu trzy (czasami cztery) dobre opcje.

  1. Jeśli nie pracujesz z bardzo dużą tablicą, użycie int powinno być w porządku (szczególnie na architekturach, gdzie int wynosi 4 bajty). int jest domyślnym typem całkowitym ze znakiem, którego używamy do wszystkiego, gdy tak naprawdę nie przejmujemy się typem w przeciwnym razie, i nie ma powodu, aby to robić w przeciwnym razie tutaj.
  2. Jeśli masz do czynienia z bardzo dużymi tablicami lub jeśli chcesz być nieco bardziej defensywny, możesz użyć dziwnie nazwanego std::ptrdiff_t. Ten typedef jest często używany, ponieważ podpisany odpowiednik std::size_t.
  3. Ponieważ std::ptrdiff_t ma dziwną nazwę. Innym dobrym podejściem jest zdefiniowanie własnego aliasu typu dla indeksów:
using Index = std::ptrdiff_t;

// Sample loop using index
for (Index index{ 0 }; index < static_cast<Index>(arr.size()); ++index)

Pełny przykład pokażemy w następnej sekcji.

Zdefiniowanie własnego aliasu typu ma również potencjalną korzyść w przyszłości: jeśli standardowa biblioteka C++ kiedykolwiek wyda typ przeznaczony do użycia jako indeks ze znakiem, będzie to łatwe albo zmodyfikuj Index na alias tego typu, albo znajdź/zamień Index na dowolną nazwę tego typu.

  1. W przypadkach, gdy możesz wyprowadzić typ zmiennej pętli z inicjatora, możesz użyć auto aby kompilator wydedukował typ:
    for (auto index{ static_cast<std::ptrdiff_t>(arr.size())-1 }; index >= 0; --index)

W C++23 sufiks Z może zostać użyty do zdefiniowania literału typu, który jest odpowiednikiem ze znakiem dla std::size_t (prawdopodobnie std::ptrdiff_t):

    for (auto index{ 0Z }; index < static_cast<std::ptrdiff_t>(arr.size()); ++index)

Pobieranie długości tablicy jako wartość ze znakiem

  1. Wcześniej niż C++20, najlepszą opcją jest static_cast zwrócenie wartości funkcji członkowskiej size() lub std::size() do typu ze znakiem:
#include <iostream>
#include <vector>

using Index = std::ptrdiff_t;

int main()
{
    std::vector arr{ 9, 7, 5, 3, 1 };

    for (auto index{ static_cast<Index>(arr.size())-1 }; index >= 0; --index)
        std::cout << arr[static_cast<std::size_t>(index)] << ' ';

    return 0;
}

W ten sposób wartość bez znaku zwrócona przez arr.size() zostanie przekonwertowana na typ ze znakiem, więc nasz operator porównania będzie miał dwa podpisane argumenty nie przepełni się, gdy staną się ujemne, nie mamy problemu z zawijaniem, na który natrafiliśmy podczas używania indeksów bez znaku.

Wadą tego podejścia jest to, że zaśmieca naszą pętlę, przez co jest trudniejsza do odczytania. Możemy temu zaradzić, przesuwając długość pętli:

#include <iostream>
#include <vector>

using Index = std::ptrdiff_t;

int main()
{
    std::vector arr{ 9, 7, 5, 3, 1 };

    auto length{ static_cast<Index>(arr.size()) }; 
    for (auto index{ length-1 }; index >= 0; --index)
        std::cout << arr[static_cast<std::size_t>(index)] << ' ';

    return 0;
}
  1. W C++ 20 użyj std::ssize():

Jeśli chcesz więcej dowodów na to, że projektanci C++ uważają teraz, że znak jest zgodny. indeksy są najlepszym rozwiązaniem, rozważ wprowadzenie std::ssize() w C++20. Ta funkcja zwraca rozmiar typu tablicy jako typ ze znakiem (prawdopodobnie ptrdiff_t).

#include <iostream>
#include <vector>

int main()
{
    std::vector arr{ 9, 7, 5, 3, 1 };

    for (auto index{ std::ssize(arr)-1 }; index >= 0; --index) // std::ssize introduced in C++20
        std::cout << arr[static_cast<std::size_t>(index)] << ' ';

    return 0;
}

Konwersja zmiennej pętli ze znakiem na indeks bez znaku

Gdy już mamy zmienną pętli ze znakiem, za każdym razem, gdy spróbujemy użyć tej zmiennej pętli ze znakiem jako indeksu, napotkamy ostrzeżenia o konwersji znaku. zmienną pętli ze znakiem na wartość bez znaku, gdziekolwiek zamierzamy użyć jej jako indeksu.

  1. Oczywistą opcją jest statyczne rzutowanie naszej zmiennej pętli ze znakiem na indeks bez znaku. Pokazaliśmy to w poprzednim przykładzie. Niestety, musimy to zrobić wszędzie tam, gdzie indeksujemy tablicę, przez co indeksy naszej tablicy są trudne do odczytania.
  2. Użyj funkcji konwersji o krótkiej nazwie:
#include <iostream>
#include <type_traits> // for std::is_integral and std::is_enum
#include <vector>

using Index = std::ptrdiff_t;

// Helper function to convert `value` into an object of type std::size_t
// UZ is the suffix for literals of type std::size_t.
template <typename T>
constexpr std::size_t toUZ(T value)
{
    // make sure T is an integral type
    static_assert(std::is_integral<T>() || std::is_enum<T>());
    
    return static_cast<std::size_t>(value);
}

int main()
{
    std::vector arr{ 9, 7, 5, 3, 1 };

    auto length { static_cast<Index>(arr.size()) };  // in C++20, prefer std::ssize()
    for (auto index{ length-1 }; index >= 0; --index)
        std::cout << arr[toUZ(index)] << ' '; // use toUZ() to avoid sign conversion warning

    return 0;
}

W powyższym przykładzie utworzył funkcję o nazwie toUZ() , która służy do konwertowania wartości całkowitych na wartości typu std::size_t. Pozwala to na indeksowanie naszej tablicy jako arr[toUZ(index)], co jest całkiem czytelne.

  1. Użyj widoku niestandardowego

W poprzednich lekcjach omawialiśmy, w jaki sposób std::string posiada ciąg znaków, podczas gdy std::string_view jest widokiem na string, który istnieje gdzie indziej. Jedną z ciekawych rzeczy w std::string_view jest to, że może wyświetlać różne typy ciągów (literały łańcuchowe w stylu C, std::string i inne std::string_view), ale zachowuje spójny interfejs do użycia.

Chociaż nie możemy modyfikować standardowych kontenerów bibliotecznych, aby akceptowały podpisany indeks całkowity, możemy stworzyć własną niestandardową klasę widoku, aby „przeglądać” standardową klasę kontenera biblioteki w ten sposób możemy zdefiniować nasz własny interfejs, który będzie działał tak, jak chcemy.

W poniższym przykładzie definiujemy niestandardową klasę widoku, która może wyświetlać dowolny standardowy kontener biblioteki obsługujący indeksowanie. Nasz interfejs będzie robił dwie rzeczy:

  • Umożliwi nam dostęp do elementów za pomocą operator[] z typem całkowitym ze znakiem.
  • Uzyskaj długość kontenera jako typ całkowity ze znakiem.<<<M35>>>Uzyskaj długość kontenera jako typ całkowity ze znakiem. (ponieważ std::ssize() jest dostępne tylko w C++20).

To wykorzystuje przeciążenie operatora (które pokrótce przedstawiliśmy w lekcji 13.5 — Wprowadzenie do przeciążania operatorów I/O) w celu zaimplementowania operator[]. Nie musisz wiedzieć, jak SignedArrayView jest zaimplementowane, aby móc z niego skorzystać it.

SignedArrayView.h:

#ifndef SIGNED_ARRAY_VIEW_H
#define SIGNED_ARRAY_VIEW_H

#include <cstddef> // for std::size_t and std::ptrdiff_t

// SignedArrayView provides a view into a container that supports indexing
// allowing us to work with these types using signed indices
template <typename T>
class SignedArrayView // requires C++17
{
private:
    T& m_array;

public:
    using Index = std::ptrdiff_t;

    SignedArrayView(T& array)
        : m_array{ array } {}

    // Overload operator[] to take a signed index
    constexpr auto& operator[](Index index) { return m_array[static_cast<typename T::size_type>(index)]; }
    constexpr const auto& operator[](Index index) const { return m_array[static_cast<typename T::size_type>(index)]; }
    constexpr auto ssize() const { return static_cast<Index>(m_array.size()); }
};

#endif

main.cpp:

#include <iostream>
#include <vector>
#include "SignedArrayView.h"

int main()
{
    std::vector arr{ 9, 7, 5, 3, 1 };
    SignedArrayView sarr{ arr }; // Create a signed view of our std::vector

    for (auto index{ sarr.ssize() - 1 }; index >= 0; --index)
        std::cout << sarr[index] << ' '; // index using a signed type

    return 0;
}

Zamiast tego indeksuj bazową tablicę w stylu C

W lekcji 16.3 -- std::vector oraz problemu długości bez znaku i indeksu dolnego, zauważyliśmy, że zamiast indeksować standardowy kontener biblioteki, możemy zamiast tego wywołać data() funkcję składową i zamiast tego zaindeksować ją, ponieważ data() zwraca dane tablicy w stylu C array oraz tablice w stylu C umożliwiają indeksowanie zarówno wartości ze znakiem, jak i bez znaku, pozwala to uniknąć problemów z konwersją znaku.

int main()
{
    std::vector arr{ 9, 7, 5, 3, 1 };

    auto length { static_cast<Index>(arr.size()) };  // in C++20, prefer std::ssize()
    for (auto index{ length - 1 }; index >= 0; --index)
        std::cout << arr.data()[index] << ' ';       // use data() to avoid sign conversion warning

    return 0;
}

Uważamy, że ta metoda jest najlepszą z opcji indeksowania:

  • Możemy używać zmiennych i indeksów pętli ze znakiem.
  • Nie musimy definiować żadnych niestandardowych typów ani aliasów typów.
  • Utrata czytelności wynikająca z użycia data() nie jest zbyt duża.
  • Zoptymalizowany kod nie powinien powodować żadnego spadku wydajności.

Jedyny rozsądny wybór: całkowicie unikaj indeksowania!

Wszystkie opcje przedstawione powyżej mają swoje wady, więc jest to trudne zalecać jedno podejście zamiast drugiego. Istnieje jednak wybór, który jest o wiele rozsądniejszy niż inne: całkowicie unikaj indeksowania z wartościami całkowitymi.

C++ udostępnia kilka innych metod przechodzenia przez tablice, które w ogóle nie używają indeksów. A jeśli nie mamy indeksów, nie napotkamy wszystkich problemów z konwersją ze znakiem/bez znaku.

Dwie popularne metody przechodzenia po tablicy bez indeksów obejmują pętle for oparte na zakresach i iteratory.

Powiązana treść

Pętle for z zasięgiem omówimy w następnej lekcji (16.8 -- Pętle for oparte na zakresach (dla każdego)).
Iteratory omówimy w nadchodzącej lekcji 18.2 -- Wprowadzenie do iteratorów.

Jeśli używasz tylko zmiennej indeksującej do przechodzenia po tablicy, wybierz metodę, która nie używa indeksów.

Najlepsza praktyka

Unikaj indeksowania tablicy z wartościami całkowitymi, jeśli to możliwe.

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