21.9 — Przeciążenie operatora indeksu dolnego

Podczas pracy z tablicami zwykle używamy operatora indeksu dolnego ([]) do indeksowania określonych elementów tablicy:

myArray[0] = 7; // put the value 7 in the first element of the array

Rozważmy jednak następującą IntList klasę, której zmienna składowa jest tablicą:

class IntList
{
private:
    int m_list[10]{};
};

int main()
{
    IntList list{};
    // how do we access elements from m_list?
    return 0;
}

Ponieważ zmienna składowa m_list jest prywatna, nie możemy uzyskać do niej dostępu bezpośrednio z listy zmiennych. Oznacza to, że nie mamy możliwości bezpośredniego pobrania lub ustawienia wartości w tablicy m_list. Jak więc pobrać lub umieścić elementy na naszej liście?

Bez przeciążania operatora typową metodą byłoby utworzenie funkcji dostępu:

class IntList
{
private:
    int m_list[10]{};

public:
    void setItem(int index, int value) { m_list[index] = value; }
    int getItem(int index) const { return m_list[index]; }
};

Chociaż to działa, nie jest szczególnie przyjazne dla użytkownika. Rozważmy następujący przykład:

int main()
{
    IntList list{};
    list.setItem(2, 3);

    return 0;
}

Czy ustawiamy element 2 na wartość 3, czy element 3 na wartość 2? Bez zobaczenia definicji setItem() jest to po prostu niejasne.

Możesz także zwrócić całą listę i użyć operatora[], aby uzyskać dostęp do elementu:

class IntList
{
private:
    int m_list[10]{};

public:
    int* getList() { return m_list; }
};

Chociaż to również działa, jest to syntaktycznie dziwne:

int main()
{
    IntList list{};
    list.getList()[2] = 3;

    return 0;
}

Przeciążenie operatora[]

Jednak lepszym rozwiązaniem w tym przypadku jest przeciążenie operatora indeksu dolnego ([]), aby umożliwić dostęp do elementów m_list. Operator indeksu dolnego jest jednym z operatorów, który musi być przeciążony jako funkcja składowa. Przeciążona funkcja operator[] zawsze będzie przyjmować jeden parametr: indeks dolny, który użytkownik umieszcza pomiędzy twardymi nawiasami klamrowymi. W naszym przypadku IntList oczekujemy, że użytkownik przekaże indeks będący liczbą całkowitą, w wyniku czego zwrócimy wartość całkowitą.

#include <iostream>

class IntList
{
private:
    int m_list[10]{};

public:
    int& operator[] (int index)
    {
        return m_list[index];
    }
};

/*
// Can also be implemented outside the class definition
int& IntList::operator[] (int index)
{
    return m_list[index];
}
*/

int main()
{
    IntList list{};
    list[2] = 3; // set a value
    std::cout << list[2] << '\n'; // get a value

    return 0;
}

Teraz, ilekroć użyjemy operatora indeksu dolnego ([]) na obiekcie naszej klasy, kompilator zwróci odpowiedni element ze zmiennej składowej m_list! Pozwala nam to bezpośrednio pobierać i ustawiać wartości m_list.

Jest to łatwe zarówno pod względem syntaktycznym, jak i ze zrozumienia. Kiedy list[2] wykonuje ocenę, kompilator najpierw sprawdza, czy istnieje przeciążona funkcja operator[]. Jeśli tak, przekazuje wartość w nawiasach twardych (w tym przypadku 2) jako argument funkcji.

Zauważ, że chociaż możesz podać wartość domyślną parametru funkcji, w rzeczywistości użycie operatora[] bez indeksu dolnego w środku nie jest uważane za prawidłową składnię, więc nie ma sensu.

Wskazówka

C++23 dodaje obsługę przeciążania operatora[] wieloma indeksami dolnymi.

Dlaczego operator[] zwraca odniesienie

Przyjrzyjmy się bliżej, jak list[2] = 3 ocenia. Ponieważ operator indeksu dolnego ma wyższy priorytet niż operator przypisania, list[2] wykonuje najpierw ocenę. list[2] wywołuje operator[], który zdefiniowaliśmy tak, aby zwracał referencję do list.m_list[2]. Ponieważ operator[] zwraca referencję, zwraca rzeczywisty list.m_list[2] element tablicy. Nasze częściowo ocenione wyrażenie staje się list.m_list[2] = 3, co jest prostym przypisaniem liczby całkowitej.

W lekcji 12.2 — Kategorie wartości (lwartości i rwartości), nauczyłeś się, że każda wartość po lewej stronie instrukcji przypisania musi być l-wartością (która jest zmienną mającą rzeczywisty adres pamięci). Ponieważ wynik operatora[] może zostać użyty po lewej stronie przypisania (np. list[2] = 3), wartość zwracana przez operator[] musi być wartością l. Jak się okazuje, referencje są zawsze l-wartościami, ponieważ referencje można przyjmować tylko do zmiennych, które mają adresy pamięci. Zatem zwracając referencję, kompilator jest przekonany, że zwracamy l-wartość.

Zastanów się, co by się stało, gdyby operator[] zwrócił liczbę całkowitą według wartości, a nie referencji. list[2] wywołałby operator[], który zwróciłby wartość list.m_list[2]. Na przykład, jeśli m_list[2] miałby wartość 6, operator[] zwróciłby wartość 6. list[2] = 3 częściowo wyceniłby 6 = 3, co nie ma sensu! Jeśli spróbujesz to zrobić, kompilator C++ zasygnalizuje:

C:VCProjectsTest.cpp(386) : error C2106: '=' : left operand must be l-value

Przeciążony operator[] dla obiektów const

W powyższym przykładzie IntList operator[] nie jest stałą i możemy go użyć jako wartości l do zmiany stanu obiektów innych niż const. A co by było, gdyby nasz obiekt IntList był stały? W tym przypadku nie bylibyśmy w stanie wywołać innej niż stała wersji operatora[], ponieważ umożliwiłoby nam to potencjalną zmianę stanu obiektu const.

Dobrą wiadomością jest to, że możemy osobno zdefiniować wersję operator[] niebędącą stałą i stałą. Wersja inna niż const będzie używana z obiektami innymi niż const, a wersja const z obiektami const.

#include <iostream>

class IntList
{
private:
    int m_list[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // give this class some initial state for this example

public:
    // For non-const objects: can be used for assignment
    int& operator[] (int index)
    {
        return m_list[index];
    }

    // For const objects: can only be used for access
    // This function could also return by value if the type is cheap to copy
    const int& operator[] (int index) const
    {
        return m_list[index];
    }
};

int main()
{
    IntList list{};
    list[2] = 3; // okay: calls non-const version of operator[]
    std::cout << list[2] << '\n';

    const IntList clist{};
    // clist[2] = 3; // compile error: clist[2] returns const reference, which we can't assign to
    std::cout << clist[2] << '\n';

    return 0;
}

Usuwanie zduplikowanego kodu pomiędzy przeciążeniami const i non-const

W powyższym przykładzie należy zauważyć, że implementacje int& IntList::operator[](int) i const int& IntList::operator[](int) const są identyczne. Jedyną różnicą jest typ zwracany przez funkcję.

W przypadkach, gdy implementacja jest trywialna (np. pojedyncza linia), dobrze (i preferowane) jest, aby obie funkcje korzystały z identycznej implementacji. Nie warto usuwać niewielkiej ilości redundancji, jaką to wprowadza.

Ale co by było, gdyby implementacja tych operatorów była złożona i wymagała wielu instrukcji? Na przykład może ważne jest, abyśmy sprawdzili, czy indeks jest rzeczywiście poprawny, co wymaga dodania wielu zbędnych linii kodu do każdej funkcji.

W takim przypadku redundancja wprowadzona przez wiele zduplikowanych instrukcji jest bardziej problematyczna i pożądana byłaby jedna implementacja, której moglibyśmy użyć dla obu przeciążeń. Ale jak? Zwykle po prostu implementujemy jedną funkcję w odniesieniu do drugiej (np. jedna funkcja wywołuje drugą). Ale w tym przypadku jest to trochę trudne. Stała wersja funkcji nie może wywołać wersji innej niż stała, ponieważ wymagałoby to odrzucenia const obiektu const. I chociaż wersja funkcji inna niż stała może wywołać stałą wersję funkcji, wersja stała funkcji zwraca odwołanie do stałej, gdy musimy zwrócić odwołanie inne niż stała. Na szczęście można to obejść.

Preferowane rozwiązanie jest następujące:

  • Zaimplementuj logikę dla stałej wersji funkcji.
  • Poproś funkcję inną o wywołanie funkcji const i użyj const_cast aby usunąć stałą.

Wynikowe rozwiązanie wygląda mniej więcej tak to:

#include <iostream>
#include <utility> // for std::as_const

class IntList
{
private:
    int m_list[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // give this class some initial state for this example

public:
    int& operator[] (int index)
    {
        // use std::as_const to get a const version of `this` (as a reference) 
        // so we can call the const version of operator[]
        // then const_cast to discard the const on the returned reference
        return const_cast<int&>(std::as_const(*this)[index]);
    }

    const int& operator[] (int index) const
    {
        return m_list[index];
    }
};

int main()
{
    IntList list{};
    list[2] = 3; // okay: calls non-const version of operator[]
    std::cout << list[2] << '\n';

    const IntList clist{};
    // clist[2] = 3; // compile error: clist[2] returns const reference, which we can't assign to
    std::cout << clist[2] << '\n';

    return 0;
}

Zwykle używanie const_cast do usuwania const jest czymś, czego chcemy uniknąć, ale w tym przypadku jest to dopuszczalne. Jeśli wywołano przeciążenie inne niż const, to wiemy, że pracujemy z obiektem innym niż const. Można usunąć const z odwołania const do obiektu innego niż const.

Dla zaawansowanych czytelników

W C++23 możemy zrobić jeszcze lepiej, korzystając z kilku funkcji, których nie omówiliśmy jeszcze w tej serii samouczków:

#include <iostream>

class IntList
{
private:
    int m_list[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // give this class some initial state for this example

public:
    // Use an explicit object parameter (self) and auto&& to differentiate const vs non-const
    auto&& operator[](this auto&& self, int index)
    {
        // Complex code goes here
        return self.m_list[index];
    }
};

int main()
{
    IntList list{};
    list[2] = 3; // okay: calls non-const version of operator[]
    std::cout << list[2] << '\n';

    const IntList clist{};
    // clist[2] = 3; // compile error: clist[2] returns const reference, which we can't assign to
    std::cout << clist[2] << '\n';

    return 0;
}

Wykrywanie ważności indeksu

Jedną inną zaletą przeciążania operatora indeksu dolnego jest to, że możemy uczynić go bezpieczniejszym niż uzyskiwanie dostępu tablice bezpośrednio. Zwykle podczas uzyskiwania dostępu do tablic operator indeksu dolnego nie sprawdza, czy indeks jest prawidłowy. Na przykład kompilator nie będzie narzekał na następujący kod:

int list[5]{};
list[7] = 3; // index 7 is out of bounds!

Jeśli jednak znamy rozmiar naszej tablicy, możemy sprawdzić nasz przeciążony operator indeksu dolnego, aby upewnić się, że indeks mieści się w dopuszczalnych granicach:

#include <cassert> // for assert()
#include <iterator> // for std::size()

class IntList
{
private:
    int m_list[10]{};

public:
    int& operator[] (int index)
    {
        assert(index >= 0 && static_cast<std::size_t>(index) < std::size(m_list));

        return m_list[index];
    }
};

W powyższym przykładzie użyliśmy funkcji Assert() (zawartej w nagłówku Cassert), aby upewnić się, że nasz indeks jest prawidłowy. Jeśli wyrażenie wewnątrz potwierdzenia ma wartość false (co oznacza, że ​​użytkownik przekazał nieprawidłowy indeks), program zakończy się komunikatem o błędzie, co jest znacznie lepszym rozwiązaniem niż alternatywa (uszkodzenie pamięci). Jest to prawdopodobnie najpowszechniejsza metoda sprawdzania błędów tego rodzaju.

Jeśli nie chcesz używać potwierdzenia (które zostanie skompilowane z kompilacji bez debugowania), możesz zamiast tego użyć instrukcji if i swojej ulubionej metody obsługi błędów (np. zgłosić wyjątek, wywołać std::exit itd.):

#include <iterator> // for std::size()

class IntList
{
private:
    int m_list[10]{};

public:
    int& operator[] (int index)
    {
        if (!(index >= 0 && static_cast<std::size_t>(index) < std::size(m_list))
        {
            // handle invalid index here
        }

        return m_list[index];
    }
};

Wskaźniki do obiektów i przeciążony operator[] nie działają mix

Jeśli spróbujesz wywołać operator[] na wskaźniku do obiektu, C++ założy, że próbujesz zaindeksować tablicę obiektów tego typu.

Rozważ następujący przykład:

#include <cassert> // for assert()
#include <iterator> // for std::size()

class IntList
{
private:
    int m_list[10]{};

public:
    int& operator[] (int index)
    {
        return m_list[index];
    }
};

int main()
{
    IntList* list{ new IntList{} };
    list [2] = 3; // error: this will assume we're accessing index 2 of an array of IntLists
    delete list;

    return 0;
}

Ponieważ nie możemy przypisać liczby całkowitej do IntList, nie zostanie to skompilowane. Jeśli jednak przypisanie liczby całkowitej byłoby prawidłowe, zostałoby to skompilowane i uruchomione z niezdefiniowanymi wynikami.

Reguła

Upewnij się, że nie próbujesz wywołać przeciążonego operatora[] na wskaźniku do obiektu.

Właściwa składnia polegałaby na tym, aby najpierw usunąć referencję do wskaźnika (upewnij się, że używasz nawiasów, ponieważ operator[] ma wyższy priorytet niż operator*), a następnie wywołaj operator[]:

int main()
{
    IntList* list{ new IntList{} };
    (*list)[2] = 3; // get our IntList object, then call overloaded operator[]
    delete list;

    return 0;
}

To jest brzydkie i podatne na błędy. Jeszcze lepiej, jeśli nie musisz, nie ustawiaj wskaźników do swoich obiektów.

Parametr funkcji nie musi być typem całkowitym

Jak wspomniano powyżej, C++ przekazuje to, co użytkownik wpisuje między nawiasami klamrowymi, jako argument przeciążonej funkcji. W większości przypadków będzie to wartość całkowita. Nie jest to jednak wymagane — w rzeczywistości możesz zdefiniować, że przeciążony operator [] przyjmuje wartość dowolnego typu. Możesz zdefiniować przeciążonego operatora[], aby pobierał wartość double, std::string lub cokolwiek innego.

Jako śmieszny przykład, żebyś mógł zobaczyć, że to działa:

#include <iostream>
#include <string_view> // C++17

class Stupid
{
private:

public:
	void operator[] (std::string_view index);
};

// It doesn't make sense to overload operator[] to print something
// but it is the easiest way to show that the function parameter can be a non-integer
void Stupid::operator[] (std::string_view index)
{
	std::cout << index;
}

int main()
{
	Stupid stupid{};
	stupid["Hello, world!"];

	return 0;
}

Jak można się spodziewać, wyświetli się:

Hello, world!

Przeciążenie operatora[] w celu pobrania parametru std::string może być przydatne podczas pisania niektórych rodzajów klas, takich jak te, które używają słów jako indeksy.

Czas quizu

Pytanie nr 1

Mapa to klasa przechowująca elementy w postaci pary klucz-wartość. Klucz musi być unikalny i służy do uzyskiwania dostępu do powiązanej pary. W tym quizie napiszemy aplikację, która pozwoli nam przypisywać oceny uczniom po imieniu, za pomocą prostej klasy mapy. Imię ucznia będzie kluczem, a ocena (jako znak) będzie wartością.

a) Najpierw napisz strukturę o nazwie StudentGrade zawierającą imię i nazwisko ucznia (jako a std::string) i ocenę (jako a char).

Pokaż rozwiązanie

b) Dodaj klasę o nazwie GradeMap zawierającą a std::vector z StudentGrade nazwany m_map.

Pokaż rozwiązanie

c) Napisz przeciążony operator[] dla tej klasy. Ta funkcja powinna przyjmować parametr std::string i zwracać referencję do znaku. W treści funkcji najpierw sprawdź, czy imię ucznia już istnieje (możesz użyć std::find_if z <algorithm>). Jeśli uczeń istnieje, zwróć odniesienie do oceny i gotowe. W przeciwnym razie użyj funkcji std::vector::emplace_back() lub std::vector::push_back() , aby dodać StudentGrade dla tego nowego ucznia. Gdy to zrobisz, std::vector doda kopię Twojego StudentGrade do siebie (w razie potrzeby zmieni rozmiar, unieważniając wszystkie wcześniej zwrócone odniesienia). Na koniec musimy zwrócić odniesienie do oceny ucznia, którą właśnie dodaliśmy do std::vector. Dostęp do ucznia, którego właśnie dodaliśmy, możemy uzyskać za pomocą std::vector::back() .

Powinien uruchomić się następujący program:

#include <iostream>

// ...

int main()
{
	GradeMap grades{};

	grades["Joe"] = 'A';
	grades["Frank"] = 'B';

	std::cout << "Joe has a grade of " << grades["Joe"] << '\n';
	std::cout << "Frank has a grade of " << grades["Frank"] << '\n';

	return 0;
}

Pokaż rozwiązanie

Pytanie nr 2

Dodatkowy punkt nr 1: GradeMap klasa i przykładowy program, który napisaliśmy, jest nieefektywny z wielu powodów. Opisz jeden ze sposobów ulepszenia klasy GradeMap .

Pokaż rozwiązanie

Pytanie nr 3

Dodatkowy kredyt nr 2: Dlaczego ten program potencjalnie nie działa zgodnie z oczekiwaniami?

#include <iostream>

int main()
{
	GradeMap grades{};

	char& gradeJoe{ grades["Joe"] }; // does an emplace_back
	gradeJoe = 'A';

	char& gradeFrank{ grades["Frank"] }; // does a emplace_back
	gradeFrank = 'B';

	std::cout << "Joe has a grade of " << gradeJoe << '\n';
	std::cout << "Frank has a grade of " << gradeFrank << '\n';

	return 0;
}

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