13.9 – Перегрузка оператора индекса
При работе с массивами мы обычно используем оператор индекса ([]
) для указания на определенные элементы массива:
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
становится висячей ссылкой на удаленную память. Это приведет к неопределенному поведению.