13.9 – Перегрузка оператора индекса

Добавлено 18 июля 2021 в 21:56
Глава 13 – Перегрузка операторов  (содержание)

При работе с массивами мы обычно используем оператор индекса ([]) для указания на определенные элементы массива:

myArray[0] = 7; // помещаем значение 7 в первый элемент массива

Однако рассмотрим следующий класс IntList, у которого есть переменная-член, которая является массивом:

class IntList
{
private:
    int m_list[10]{};
};
 
int main()
{
    IntList list{};
    // как получить доступ к элементам из m_list?
    return 0;
}

Поскольку переменная-член m_list является закрытой, мы не можем получить к ней доступ напрямую из переменной list. Это означает, что у нас нет возможности напрямую получать или устанавливать значения в массиве m_list. Итак, как нам получить или поместить элементы в наш список?

Без перегрузки оператора типовым методом было бы создание функций доступа:

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]; }
};

Хотя это работает, для пользователя это не очень удобно. Рассмотрим следующий пример:

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

Мы устанавливаем элемент 2 в значение 3 или элемент 3 в значение 2? Без определения setItem() это просто непонятно.

Вы также можете просто вернуть весь список и использовать operator[] для доступа к элементу:

class IntList
{
private:
    int m_list[10]{};
 
public:
    int* getList() { return m_list; }
};

Хотя это тоже работает, синтаксически это странно:

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

Перегрузка operator[]

Однако лучшим решением в этом случае является перегрузка оператора индекса ([]), чтобы разрешить доступ к элементам m_list. Оператор индекса – это один из операторов, которые необходимо перегружать как функцию-член. Перегруженная функция operator[] всегда принимает один параметр: индекс, который пользователь помещает между квадратными скобками. В случае с нашим IntList мы ожидаем, что пользователь передаст целочисленный индекс, и в качестве результата мы вернем целочисленное значение.

class IntList
{
private:
    int m_list[10]{};
 
public:
    int& operator[] (int index);
};
 
int& IntList::operator[] (int index)
{
    return m_list[index];
}

Теперь, когда мы используем оператор индекса ([]) для объекта нашего класса, компилятор будет возвращать соответствующий элемент из переменной-члена m_list! Это позволяет нам напрямую получать и устанавливать значения m_list:

IntList list{};
list[2] = 3;                  // устанавливаем значение
std::cout << list[2] << '\n'; // получаем значение

Это легко как синтаксически, так и с точки зрения понимания. Когда вычисляется list[2], компилятор сначала проверяет, есть ли перегруженная функция operator[]. Если она есть, он передает значение внутри квадратных скобок (в данном случае 2) в качестве аргумента функции.

Обратите внимание, что хотя вы можете указать значение по умолчанию для параметра функции, на самом деле использование operator[] без индекса внутри считается недопустимым синтаксисом, поэтому в этом нет смысла.

Почему operator[] возвращает ссылку

Давайте подробнее рассмотрим, как вычисляется list[2] = 3. Поскольку оператор индекса имеет более высокий приоритет, чем оператор присваивания, list[2] вычисляется первым. list[2] вызывает функцию operator[], которую мы определили для возврата ссылки на list.m_list[2]. Поскольку operator[] возвращает ссылку, он возвращает фактический элемент массива list.m_list[2]. Наше частично вычисленное выражение становится list.m_list[2] = 3, что является простым целочисленным присваиванием.

В уроке «1.3 – Знакомство с переменными в C++» вы узнали, что любое значение в левой части оператора присваивания должно быть l-значением (которое представляет собой переменную, имеющую фактический адрес в памяти). Поскольку результат operator[] может использоваться в левой части присваивания (например, list[2] = 3), возвращаемое значение operator[] должно быть l-значением. Оказывается, ссылки всегда являются l-значениями, потому что вы можете взять ссылки только на переменные, которые имеют адреса в памяти. Итак, возвращая ссылку, компилятор удовлетворен тем, что мы возвращаем l-значение.

Подумайте, что произойдет, если operator[] вернет число int по значению, а не по ссылке. list[2] вызовет operator[], который вернет значение list.m_list[2]. Например, если бы m_list[2] имел значение 6, operator[] вернул бы значение 6. list[2] = 3 частично вычислится как 6 = 3, что не имеет смысла! Если вы попытаетесь это сделать, компилятор C++ пожалуется:

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

Работа с константными объектами

В приведенном выше примере IntList функция operator[] не является константной, и мы можем использовать ее как l-значение для изменения состояния неконстантных объектов. Однако что, если бы наш объект IntList был константным? В этом случае мы не сможем вызвать неконстантную версию operator[], потому что это потенциально позволит нам изменить состояние константного объекта.

Хорошая новость в том, что мы можем определять неконстантную и константную версии operator[] по отдельности. Неконстантная версия будет использоваться с неконстантными объектами, а константная версия – с константными объектами.

#include <iostream>
 
class IntList
{
private:
    // задаем классу начальное состояние для этого примера
    int m_list[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; 
 
public:
    int& operator[] (int index);
    const int& operator[] (int index) const;
};

// для неконстантных объектов: может использоваться для присваивания 
int& IntList::operator[] (int index) 
{
    return m_list[index];
}

// для константных объектов: может использоваться только для доступа 
const int& IntList::operator[] (int index) const 
{
    return m_list[index];
}
 
int main()
{
    IntList list{};
    // хорошо: вызывает неконстантную версию operator[]
    list[2] = 3;
    std::cout << list[2] << '\n';
 
    const IntList clist{};
    // ошибка компиляции: вызывает константную версию operator[],
    // которая возвращает константную ссылку.
    // Ей невозможно выполнить присваивание.
    clist[2] = 3; 
    std::cout << clist[2] << '\n';
 
    return 0;
}

Если мы закомментируем строку clist[2] = 3, показанная выше программа будет компилироваться и выполняться, как ожидалось.

Проверка на ошибки

Еще одно преимущество перегрузки оператора индекса состоит в том, что мы можем сделать его более безопасным, чем прямой доступ к массивам. Обычно при доступе к массивам оператор индекса не проверяет, корректен ли индекс. Например, компилятор не будет жаловаться на следующий код:

int list[5]{};
list[7] = 3; // индекс 7 выходит за пределы массива!

Однако если мы знаем размер нашего массива, мы можем выполнить проверку в нашем перегруженном операторе индекса, чтобы убедиться, что индекс находится в пределах границ:

#include <cassert> // для assert()
 
class IntList
{
private:
    int m_list[10]{};
 
public:
    int& operator[] (int index);
};
 
int& IntList::operator[] (int index)
{
    assert(index >= 0 && index < 10);
 
    return m_list[index];
}

В приведенном выше примере мы использовали функцию assert() (включенную в заголовок cassert), чтобы убедиться, что наш индекс корректен. Если выражение внутри assert вычисляется как false (что означает, что пользователь передал недопустимый индекс), программа завершится с сообщением об ошибке, что намного лучше, чем альтернатива (повреждение памяти). Это, вероятно, наиболее распространенный метод проверки ошибок подобного рода.

Указатели на объекты и перегруженный operator[] не смешиваются

Если вы попытаетесь вызвать operator[] для указателя на объект, C++ будет считать, что вы пытаетесь указать на элемент в массиве объектов этого типа.

Рассмотрим следующий пример:

#include <cassert> // для assert()
 
class IntList
{
private:
    int m_list[10]{};
 
public:
    int& operator[] (int index);
};
 
int& IntList::operator[] (int index)
{
    assert(index >= 0 && index < 10);
 
    return m_list[index];
}
 
int main()
{
    IntList *list{ new IntList{} };
    list[2] = 3; // ошибка: это предполагает, что мы обращаемся к индексу 2 массива из IntList
    delete list;
 
    return 0;
}

Поскольку мы не можем присвоить число int объекту IntList, этот код не будет компилироваться. Однако если присваивание числа int было допустимо, этот код скомпилируется и запустится с неопределенными результатами.

Правило


Убедитесь, что вы не пытаетесь вызвать перегруженный operator[] для указателя на объект.

Правильный синтаксис – сначала разыменовать указатель (обязательно использовать круглые скобки, поскольку operator[] имеет более высокий приоритет, чем operator*), а затем вызвать operator[]:

int main()
{
    IntList *list{ new IntList{} };
    // получаем наш объект IntList, затем вызываем перегруженный operator[]
    (*list)[2] = 3; 
    delete list;
 
    return 0;
}

Это выглядит коряво и подвержено ошибкам. А еще лучше не устанавливайте указатели на свои объекты, если в этом нет необходимости.

Параметр функции не обязательно должен быть числом int

Как упоминалось выше, C++ передает то, что пользователь вводит между квадратными скобками, в качестве аргумента перегруженной функции. В большинстве случаев это будет целочисленное значение. Однако в этом нет необходимости – и на самом деле вы можете определить, чтобы ваш перегруженный operator[] принимал значение любого типа, который вам нужен. Вы можете определить свой перегруженный operator[] так, чтобы он принимал double, std::string или что угодно.

В качестве нелепого примера, чтобы вы могли убедиться, что это работает:

#include <iostream>
#include <string>
 
class Stupid
{
private:
 
public:
    void operator[] (const std::string& index);
};
 
// Нет смысла перегружать operator[], чтобы что-то напечатать,
// но это самый простой способ показать, что параметр функции
// может не быть числом int
void Stupid::operator[] (const std::string& index)
{
    std::cout << index;
}
 
int main()
{
    Stupid stupid{};
    stupid["Hello, world!"];
 
    return 0;
}

Как и следовало ожидать, этот код печатает:

Hello, world!

Перегрузка operator[] для принятия параметра std::string может быть полезна при написании определенных видов классов, например тех, которые используют слова в качестве индексов.

Заключение

Оператор индекса обычно перегружается, чтобы обеспечить прямой доступ к отдельным элементам из массива (или другой подобной структуры), содержащегося в классе. Поскольку строки часто реализуются как массивы символов, operator[] часто реализуется в строковых классах, чтобы позволить пользователю получить доступ к одиночному символу строки.

Небольшой тест

Вопрос 1

Карта (map) – это класс, в котором элементы хранятся в виде пар ключ-значение. Ключ должен быть уникальным и используется для доступа к связанной паре. В этом тесте мы собираемся написать приложение, которое позволит нам присваивать оценки студентам по именам, используя простой класс карты. Имя студента будет ключом, а оценка (в виде char) будет значением.

a) Сначала напишите структуру с именем StudentGrade, содержащую имя студента (в виде std::string) и оценку (в виде char).

#include <string>
 
struct StudentGrade
{
    std::string name{};
    char grade{};
};

b) Добавьте класс с именем GradeMap, который содержит std::vector из StudentGrade с именем m_map.

#include <string>
#include <vector>
 
struct StudentGrade
{
    std::string name{};
    char grade{};
};
 
class GradeMap
{
private:
    std::vector<StudentGrade> m_map{};
};

c) Напишите перегруженный operator[] для этого класса. Эта функция должна принимать параметр std::string и возвращать ссылку на char. В теле функции сначала проверьте, существует ли уже имя студента (вы можете использовать std::find_if из <algorithm>). Если студент есть, верните ссылку на оценку, и всё готово. В противном случае используйте функцию std::vector::push_back(), чтобы добавить StudentGrade для этого нового студента. Когда вы это сделаете, std::vector добавит себе копию вашего StudentGrade (при необходимости изменив размер, аннулируя все ранее возвращенные ссылки). Наконец, нам нужно вернуть ссылку на оценку студента, которого мы только что добавили в std::vector. Мы можем получить доступ к только что добавленному студенту с помощью функции std::vector::back().

Должна запуститься следующая программа:

#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;
}

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
 
struct StudentGrade
{
    std::string name{};
    char grade{};
};
 
class GradeMap
{
private:
    std::vector<StudentGrade> m_map{};
 
public:
    char& operator[](const std::string &name);
};
 
char& GradeMap::operator[](const std::string &name)
{
    auto found{ std::find_if(m_map.begin(), m_map.end(),
                [&](const auto& student){
                      return (student.name == name);
                }) };
    
    if (found != m_map.end())
    {
        return found->grade;
    }
 
    // в противном случае создать новый StudentGrade
    // для этого студента и добавить его в конец нашего вектора.
    m_map.push_back({ name });
 
    // и вернуть этот элемент
    return m_map.back().grade;
}
 
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;
}

Совет


Поскольку карты – это распространенный контейнер, стандартная библиотека предлагает std::map, который в пока не рассматривался в данной серии статей. Используя std::map, мы можем упростить наш код до следующего вида

#include <iostream>
#include <map> // std::map
#include <string>
 
int main()
{
    // std::map можно инициализировать
    std::map<std::string, char> grades{
        { "Joe", 'A' },
        { "Frank", 'B' }
    };
 
    // и присвоить
    grades["Susan"] = 'C';
    grades["Tom"] = 'D';
 
    std::cout << "Joe has a grade of " << grades["Joe"] << '\n';
    std::cout << "Frank has a grade of " << grades["Frank"] << '\n';
 
    return 0;
}

Предпочитайте использовать std::map вместо написания собственной реализации.


Вопрос 2

Дополнительный вопрос №1: Написанный нами класс GradeMap и пример программы неэффективны по многим причинам. Опишите один способ улучшения класса GradeMap.

std::vector по своей природе не отсортирован. Это означает, что каждый раз, когда мы вызываем operator[], мы потенциально обходим весь std::vector, чтобы найти нужный элемент. С несколькими элементами это не проблема, но по мере того, как мы продолжаем добавлять имена, программа будет становиться всё медленнее. Мы могли бы оптимизировать ее, сохраняя нашу m_map отсортированной и используя бинарный поиск, так мы минимизируем количество элементов, которые нужно будет просмотреть, чтобы найти те, которые нам интересны.


Вопрос 3

Дополнительный вопрос №2: Почему эта программа не работает, как ожидается?

#include <iostream>
 
int main()
{
    GradeMap grades{};
 
    char& gradeJoe{ grades["Joe"] };     // выполняет push_back
    gradeJoe = 'A';
 
    char& gradeFrank{ grades["Frank"] }; // выполняет push_back
    gradeFrank = 'B';
 
    std::cout << "Joe has a grade of " << gradeJoe << '\n';
    std::cout << "Frank has a grade of " << gradeFrank << '\n';
 
    return 0;
}

Когда добавляется Frank, std::vector должен вырасти, чтобы сохранить его. Это требует динамического выделения нового блока памяти, копирования элементов массива в этот новый блок и удаления старого блока. Когда это происходит, любые ссылки на существующие элементы в std::vector становятся недействительными! Другими словами, после того, как мы выполнили push_back("Frank"), GradeJoe становится висячей ссылкой на удаленную память. Это приведет к неопределенному поведению.

Теги

C++ / CppLearnCppДля начинающихОбучениеОператор (программирование)Оператор индексаПерегрузка (программирование)Перегрузка операторовПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.