На этом уроке мы рассмотрим перегрузку оператора индексации в языке С++, его использование и нюансы, связанные с этим.
- Перегрузка оператора индексации []
- Почему оператор индексации [] использует возврат по ссылке?
- Использование оператора индексации с константными объектами класса
- Проверка ошибок
- Указатели на объекты и перегруженный оператор []
- Передаваемый аргумент не обязательно должен быть целым числом
- Заключение
- Тест
Перегрузка оператора индексации []
При работе с массивами оператор индексации ([]
) используется для выбора определенных элементов:
1 |
myArray[0] = 8; // помещаем значение 8 в первый элемент массива |
Рассмотрим следующий класс IntArray, в котором в качестве переменной-члена используется массив:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <iostream> class IntArray { private: int m_array[10]; }; int main() { IntArray array; // Как получить доступ к элементам m_array? return 0; } |
Поскольку переменная-член m_array
является закрытой, то мы не имеем прямого доступа к m_array
через объект array
. Это означает, что мы не можем напрямую получить или установить значения элементов m_array
. Что делать?
Можно использовать геттеры и сеттеры:
1 2 3 4 5 6 7 8 9 |
class IntArray { private: int m_array[10]; public: void setItem(int index, int value) { m_array[index] = value; } int getItem(int index) { return m_array[index]; } }; |
Хотя это работает, но это не очень удобно. Рассмотрим следующий пример:
1 2 3 4 5 6 7 |
int main() { IntArray array; array.setItem(4, 5); return 0; } |
Присваиваем ли мы элементу 4 значение 5
или элементу 5 значение 4
? Без просмотра определения метода setItem() этого не понять.
Можно также просто возвращать весь массив (m_array
) и использовать оператор []
для доступа к его элементам:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include <iostream> class IntArray { private: int m_array[10]; public: int* getArray() { return m_array; } }; int main() { IntArray array; array.getArray()[4] = 5; return 0; } |
Но можно сделать еще проще, перегрузив оператор индексации.
Оператор индексации является одним из операторов, перегрузка которого должна выполняться через метод класса. Функция перегрузки оператора []
всегда будет принимать один параметр: значение индекса (элемент массива, к которому требуется доступ). В нашем случае с IntArray нам нужно, чтобы пользователь просто указал в квадратных скобках индекс для возврата значения элемента по этому индексу:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <iostream> class IntArray { private: int m_array[10]; public: int& operator[] (const int index); }; int& IntArray::operator[] (const int index) { return m_array[index]; } |
Теперь всякий раз, когда мы будем использовать оператор индексации ([]
) с объектом класса IntArray, компилятор будет возвращать соответствующий элемент массива m_array
! Это позволит нам непосредственно как получать, так и присваивать значения элементам m_array
:
1 2 3 4 5 6 7 8 |
int main() { IntArray array; array[4] = 5; // присваиваем значение std::cout << array[4]; // выводим значение return 0; } |
Всё просто. При обработке array[4]
компилятор сначала проверяет, есть ли функция перегрузки оператора []
. Если есть, то он передает в функцию перегрузки значение внутри квадратных скобок (в данном случае 4
) в качестве аргумента.
Обратите внимание, хотя мы можем указать параметр по умолчанию для функции перегрузки оператора []
, но в данном случае, если мы, используя []
, не укажем внутри скобок значение индекса, то получим ошибку.
Почему оператор индексации [] использует возврат по ссылке?
Рассмотрим детально, как обрабатывается стейтмент array[4] = 5
. Поскольку приоритет оператора индексации выше приоритета оператора присваивания, то сначала выполняется часть array[4]
. array[4]
приводит к вызову функции перегрузки оператора []
, которая возвратит array.m_array[4]
. Поскольку оператор []
использует возврат по ссылке, то он возвращает фактический элемент array.m_array[4]
. Наше частично обработанное выражение становится array.m_array[4] = 5
, что является прямой операцией присваивания значения элементу массива.
Из урока №10 мы уже знаем, что любое значение, которое находится слева от оператора присваивания, должно быть l-value (переменной с адресом в памяти). Поскольку результат выполнения оператора []
может использоваться в левой части операции присваивания (например, array[4] = 5
), то возвращаемое значение оператора []
должно быть l-value. Ссылки же всегда являются l-values, так как их можно использовать только с переменными, которые имеют адреса памяти. Поэтому, используя возврат по ссылке, компилятор останется доволен, что возвращается l-value, и никаких проблем не будет.
Рассмотрим, что произойдет, если оператор []
будет использовать возврат по значению вместо возврата по ссылке. array[4]
приведет к вызову функции перегрузки оператора []
, который будет возвращать значение элемента array.m_array[4]
(не индекс, а значение по указанному индексу). Например, если значением m_array[4]
является 7
, то выполнение оператора []
приведет к возврату значения 7
. array[4] = 5
будет обрабатываться как 7 = 5
, что является бессмысленным! Если вы попытаетесь это сделать, то компилятор выдаст следующую ошибку:
C:VCProjectsTest.cpp(386) : error C2106: '=' : left operand must be l-value
Использование оператора индексации с константными объектами класса
В вышеприведенном примере метод operator[]() не является константным, и мы можем использовать этот метод для изменения данных неконстантных объектов. Однако, что произойдет, если наш объект класса IntArray будет const? В этом случае мы не сможем вызывать неконстантный operator[](), так как он изменяет значения объекта (детально о классах и const читайте в материалах урока №123).
Хорошей новостью является то, что мы можем определить отдельно неконстантную и константную версии operator[](). Неконстантная версия будет использоваться с неконстантными объектами, а версия const — с объектами const:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class IntArray { private: int m_array[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // указываем начальные значения public: int& operator[] (const int index); const int& operator[] (const int index) const; }; int& IntArray::operator[] (const int index) // для неконстантных объектов: может использоваться как для присваивания значений элементам, так и для их просмотра { return m_array[index]; } const int& IntArray::operator[] (const int index) const // для константных объектов: используется только для просмотра (вывода) элементов массива { return m_array[index]; } int main() { IntArray array; array[4] = 5; // хорошо: вызывается неконстантная версия operator[]() std::cout << array[4]; const IntArray carray; carray[4] = 5; // ошибка компиляции: вызывается константная версия operator[](), которая возвращает константную ссылку. Выполнять операцию присваивания нельзя std::cout << carray[4]; return 0; } |
Строку carray[4] = 5;
нужно закомментировать и программа скомпилируется (это проверка на изменение данных константных объектов — изменять данные нельзя, можно только выводить).
Проверка ошибок
Еще одним преимуществом перегрузки оператора индексации является то, что мы можем выполнять проверку передаваемых значений индекса. При прямом доступе к элементам массива (через геттеры и сеттеры), оператор индекса не проверяет, является ли индекс корректным. Например, компилятор не будет жаловаться на следующий код:
1 2 |
int array[7]; array[9] = 4; // индекс 9 является некорректным (вне допустимого диапазона)! |
Однако, если мы знаем длину нашего массива, мы можем выполнять проверку передаваемого индекса на корректность в функции перегрузки оператора []
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <cassert> // для assert() class IntArray { private: int m_array[10]; public: int& operator[] (const int index); }; int& IntArray::operator[] (const int index) { assert(index >= 0 && index < 10); return m_array[index]; } |
В примере, приведенном выше, мы использовали стейтмент assert (который находится в заголовочном файле cassert) для проверки диапазона index
. Если выражение внутри assert принимает значение false
(т.е. пользователь ввел некорректный индекс), то программа немедленно завершится с выводом сообщения об ошибке, что лучше, нежели альтернативный вариант — повреждение памяти. Это самый распространенный способ проверки ошибок с использованием функций перегрузки.
Указатели на объекты и перегруженный оператор []
Если вы попытаетесь вызвать operator[]() для указателя на объект, то C++ предположит, что вы пытаетесь индексировать массив. Рассмотрим следующий пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#include <cassert> // для assert() class IntArray { private: int m_array[10]; public: int& operator[] (const int index); }; int& IntArray::operator[] (const int index) { assert(index >= 0 && index < 10); return m_array[index]; } int main() { IntArray *array = new IntArray; array[4] = 5; // ошибка delete array; return 0; } |
Дело в том, что указатель указывает на адрес памяти, а не на значение. Поэтому сначала указатель нужно разыменовать, а затем уже использовать оператор []
:
1 2 3 4 5 6 7 8 |
int main() { IntArray *array = new IntArray; (*array)[4] = 5; // сначала разыменовываем указатель для получения объекта array, а затем вызываем operator[] delete array; return 0; } |
Это ужасно и здесь очень легко наделать ошибок. Не используйте указатели на объекты, если это не является обязательным.
Передаваемый аргумент не обязательно должен быть целым числом
Как упоминалось выше, C++ передает в функцию перегрузки то, что пользователь указал в квадратных скобках в качестве аргумента (в большинстве случаев, это целочисленное значение). Однако это не является обязательным требованием и, на самом деле, вы можете определить функцию перегрузки так, чтобы ваш перегруженный оператор []
принимал значения любого типа, которого вы только пожелаете (double, string и т.д.). Например:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <iostream> #include <string> class Something { private: public: void operator[] (std::string index); }; // Нет смысла перегружать оператор [] только для вывода чего-либо, // но это самый простой способ показать, что параметр функции может быть не только целочисленным значением void Something::operator[] (std::string index) { std::cout << index; } int main() { Something something; something["Hello, world!"]; return 0; } |
Результат выполнения программы:
Заключение
Перегрузка оператора индексации обычно используется для обеспечения прямого доступа к элементам массива, который находится внутри класса (в качестве переменной-члена). Поскольку строки часто используются в реализации массивов символов, то оператор []
часто перегружают в классах со строками, чтобы иметь доступ к каждому символу строки отдельно.
Тест
Задание №1
Контейнер map — это класс, в котором все элементы хранятся в виде пары ключ-значение. Ключ должен быть уникальным и использоваться для доступа к связанной паре. В этом задании вам нужно будет написать программу, которая позволит присваивать оценки ученикам, указывая только имя ученика. Для этого используйте контейнер map: имя ученика — ключ, оценка (тип char) — значение.
a) Сначала напишите структуру StudentGrade с двумя элементами: имя студента (std::string) и оценка (char).
Ответ №1.a)
1 2 3 4 5 6 7 |
#include <string> struct StudentGrade { std::string name; char grade; }; |
b) Добавьте класс GradeMap, который содержит std::vector типа StudentGrade с именем m_map
. Добавьте пустой конструктор по умолчанию.
Ответ №1.b)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#include <string> #include <vector> struct StudentGrade { std::string name; char grade; }; class GradeMap { private: std::vector<StudentGrade> m_map; public: GradeMap() { } }; |
c) Реализуйте перегрузку оператора []
для этого класса. Функция перегрузки должна принимать параметр std::string (имя ученика) и возвращать ссылку на его оценку. В функции перегрузки сначала выполните поиск указанного имени ученика в векторе (используйте цикл foreach). Если ученик нашелся, то возвращайте ссылку на его оценку, и всё — готово!
В противном случае, используйте функцию std::vector::push_back() для добавления StudentGrade нового ученика. Когда вы это сделаете, std::vector добавит себе копию нового StudentGrade (при необходимости изменив размер). Наконец, вам нужно будет возвратить ссылку на оценку студента, которого вы только что добавили в std::vector — для этого используйте std::vector::back().
Следующая программа должна скомпилироваться без ошибок:
1 2 3 4 5 6 7 8 9 10 11 12 |
#include <iostream> int main() { GradeMap grades; grades["John"] = 'A'; grades["Martin"] = 'B'; std::cout << "John has a grade of " << grades["John"] << '\n'; std::cout << "Martin has a grade of " << grades["Martin"] << '\n'; return 0; } |
Ответ №1.c)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
#include <iostream> #include <string> #include <vector> struct StudentGrade { std::string name; char grade; }; class GradeMap { private: std::vector<StudentGrade> m_map; public: GradeMap() { } char& operator[](const std::string &name); }; char& GradeMap::operator[](const std::string &name) { // Смотрим, найдем ли мы имя ученика в векторе for (auto &ref : m_map) { // Если нашли, то возвращаем ссылку на его оценку if (ref.name == name) return ref.grade; } // В противном случае, создаем новый StudentGrade для нового ученика StudentGrade temp { name, ' ' }; // Помещаем его в конец вектора m_map.push_back(temp); // И возвращаем ссылку на его оценку return m_map.back().grade; } int main() { GradeMap grades; grades["John"] = 'A'; grades["Martin"] = 'B'; std::cout << "John has a grade of " << grades["John"] << '\n'; std::cout << "Martin has a grade of " << grades["Martin"] << '\n'; return 0; } |
Задание №2
Класс GradeMap и программа, которую мы написали, неэффективна по нескольким причинам. Опишите один способ улучшения класса GradeMap.
Ответ №2
std::vector не является изначально отсортированным. Это означает, что каждый раз, при вызове operator[](), мы будем перебирать весь std::vector для поиска элемента. С несколькими элементами это не является проблемой, но, по мере того как их количество будет увеличиваться, процесс поиска элемента будет становиться все медленнее и медленнее. Мы могли бы это оптимизировать, сделав m_map
отсортированным и используя бинарный поиск. Таким образом, количество элементов, которые будут использоваться при просмотре во время поиска одного элемента, уменьшится в разы.
Задание №3
Почему следующая программа не работает должным образом?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> int main() { GradeMap grades; char& gradeJohn = grades["John"]; // выполняется push_back gradeJohn = 'A'; char& gradeMartin = grades["Martin"]; // выполняется push_back gradeMartin = 'B'; std::cout << "John has a grade of " << gradeJohn << '\n'; std::cout << "Martin has a grade of " << gradeMartin << '\n'; return 0; } |
Ответ №3
При добавлении Martin
, std::vector должен увеличить свой размер. А для этого потребуется динамическое выделение нового блока памяти, копирование элементов массива в этот новый блок и удаление старого блока. Когда это произойдет, то любые ссылки на существующие элементы в std::vector пропадут! Другими словами, после того, как выполнится push_back("Martin")
, gradeJohn
останется ссылкой на удаленную память. Это и приведет к неопределенным результатам.
Второй вопрос показался несуразным. Тратить время на сортировку массива из 4-х элементов? Чтобы использовать более быстрый бинарный поиск? Затраты не сопоставимы выгоде. Я пока с трудом могу представить, в каких ситуациях, в каком моменте необходимо сортировать элементы, где поиск перебором будет сильно накладнее бинарного в отсортированном списке. Программирование, как мне кажется, не столько про оптимальность, сколько целесообразность принимаемых решений. А из приведённого примера ничто не говорит, что сортировать список хоть из 4, хоть из 400 или даже 4000 студентов имеет смысл, ведь это в теории может больше сказаться на производительности, чем простой поиск перебором всех значений. Откуда нам знать, как часто будет меняться список? Какие операции, добавление или изменение, будут задействоваться чаще. Мне, как новичку, не хватает данных, чтобы понимать это и принимать такие логические выводы.
А третий вопрос хороший. Я хорошо понял указатели, но переварить ссылки и изменение адреса при изменении размера массива — это ещё в процессе.
Скажите почему эта программа может не работать ?
Компилируется и Вылетает:
"Process returned -1073741819 (0xC0000005) execution time : 2.096 s
Press any key to continue."
А скопированная с сайта работает.
Ты добавляешь новый не существующий элемент внутри цикла.
Ответ в 3-ем задании поверг меня в ступор. И я подумал что плохо изучал ваши уроки. Я вернулся перечитал полностью урок №95 и не нашёл там данного ответа. Просьба более подробно описать данную ситуацию почему так происходит. Ведь в уроке №95 говорится о том что std::vector сам выделяет нужное количество память под себя без утечки памяти.
Уже думаю неактуально но выскажусь. Как я понял, производиться выделение новой памяти под требуемый размер вектора очисткой старой памяти. Адреса памяти меняются и соответственно ссылка уже будет указывать на освобождённую память, т.е. неизвестно на что.
Элементы массива расположены в памяти последовательно, т.е массиву нужен непрерывный участок памяти. Благодаря этому работает адресная арифметика и оператор индекса.
Если размер массива изменяется — нужно искать новый участок в памяти соответствующего размера. Поэтому если у вас есть указатели/ссылки на конкретный элемент в массиве — они в таком случае окажутся некорректными.
Вектор сам выделяет и очищает память под элементы, переносит их в новое место при расширении. Но он не знает о том, что вы создали ссылку на какой-либо элемент.
Здравствуйте, в задании 1 я сделал так, всё работает! Это допустимо?
В задании 1-с вместо 2-ух этих строк:
можно одной этой??
Не понял сути 3-го вопроса, думал что дело в синтаксисе, даже подумать не мог что дело в перевыделение памяти. А вообще круто.
Мне задания к этому уроку тоже показались сложными: и про вектор уже забылось, и что не так с кодом я тоже не поняла, то ли забыла, то ли не было этого, но в этом контексте ответ мне вообще показался неожиданным.
Возникает вопрос: для чего мы всё это строим, если ничего не сломано? Массив закрыт от всех, чтобы нельзя было его менять. Имеются геттеры и сеттеры, которые разрешают менять и указывают на то, что меняется инкапсулированный массив. Почему нам внезапно это неудобно и нужно городить перегрузку, чтобы работать с закрытым массивом по принципу публичного массива?
Я понимаю, что это часть обучающей программы, но в чём целесообразность уничтожения закрытости массивов класса и пренебрежение имеющимися инструментами?
Извиняюсь за недопонимание. Видимо, 5 непростых уроков подряд переутомили меня.
Потерял из виду, что массивы класса не хранятся внутри него, а создаются на основе, поэтому m_array закрыт как шаблон для массивов. И не пренебрегаются инструменты, а создаётся более удобная форма геттера и сеттера, позволяющие работать с массивами конкретного класса напрямую, что никак не нарушает инкапсуляцию.
Не загоняйте себя (в хорошем смысле этого слова) 🙂 Если уроки большие, то лучше сконцентрируйтесь на лучшем понимании 2-3 тем, нежели на простом скорочтении текста.
"При добавлении Martin-а, std::vector должен увеличить свой размер. А для этого потребуется динамическое выделение нового блока памяти, копирование элементов массива в этот новый блок и удаление старого блока. "
Может я запамятовал, но в предыдущих уроках об std::vector не упоминалось о реализации работы динамической памяти для std::vector. Иными словами мы не могли это знать, отвечая на вопрос: "Почему следующая программа не работает должным образом?"
Поддерживаю. Для меня это тоже шок. Я запомнил про выделение памяти под std::vector и выделение нового участка памяти при добавлении элемента кажется вполне логичным, но я думал С++ автоматически перепишет и обновит ссылки под существующие связи. Иначе это выглядит ужасными костылями.