Урок №138. Перегрузка оператора индексации []

  Юрий  | 

  |

  Обновл. 13 Сен 2021  | 

 74818

 ǀ   15 

На этом уроке мы рассмотрим перегрузку оператора индексации в языке С++, его использование и нюансы, связанные с этим.

Перегрузка оператора индексации []

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

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

Поскольку переменная-член m_array является закрытой, то мы не имеем прямого доступа к m_array через объект array. Это означает, что мы не можем напрямую получить или установить значения элементов m_array. Что делать?

Можно использовать геттеры и сеттеры:

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

Присваиваем ли мы элементу 4 значение 5 или элементу 5 значение 4? Без просмотра определения метода setItem() этого не понять.

Можно также просто возвращать весь массив (m_array) и использовать оператор [] для доступа к его элементам:

Но можно сделать еще проще, перегрузив оператор индексации.

Оператор индексации является одним из операторов, перегрузка которого должна выполняться через метод класса. Функция перегрузки оператора [] всегда будет принимать один параметр: значение индекса (элемент массива, к которому требуется доступ). В нашем случае с IntArray нам нужно, чтобы пользователь просто указал в квадратных скобках индекс для возврата значения элемента по этому индексу:

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

Всё просто. При обработке 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:

Строку carray[4] = 5; нужно закомментировать и программа скомпилируется (это проверка на изменение данных константных объектов — изменять данные нельзя, можно только выводить).

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


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

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

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

Указатели на объекты и перегруженный оператор []

Если вы попытаетесь вызвать operator[]() для указателя на объект, то C++ предположит, что вы пытаетесь индексировать массив. Рассмотрим следующий пример:

Дело в том, что указатель указывает на адрес памяти, а не на значение. Поэтому сначала указатель нужно разыменовать, а затем уже использовать оператор []:

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

Передаваемый аргумент не обязательно должен быть целым числом


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

Результат выполнения программы:

Hello, world!

Заключение

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

Тест

Задание №1

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

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

Ответ №1.a)

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

Ответ №1.b)

c) Реализуйте перегрузку оператора [] для этого класса. Функция перегрузки должна принимать параметр std::string (имя ученика) и возвращать ссылку на его оценку. В функции перегрузки сначала выполните поиск указанного имени ученика в векторе (используйте цикл foreach). Если ученик нашелся, то возвращайте ссылку на его оценку, и всё — готово!

В противном случае, используйте функцию std::vector::push_back() для добавления StudentGrade нового ученика. Когда вы это сделаете, std::vector добавит себе копию нового StudentGrade (при необходимости изменив размер). Наконец, вам нужно будет возвратить ссылку на оценку студента, которого вы только что добавили в std::vector — для этого используйте std::vector::back().

Следующая программа должна скомпилироваться без ошибок:

Ответ №1.c)

Задание №2

Класс GradeMap и программа, которую мы написали, неэффективна по нескольким причинам. Опишите один способ улучшения класса GradeMap.

Ответ №2

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

Задание №3

Почему следующая программа не работает должным образом?

Ответ №3

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

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (174 оценок, среднее: 4,83 из 5)
Загрузка...

Комментариев: 15

  1. Name:

    Второй вопрос показался несуразным. Тратить время на сортировку массива из 4-х элементов? Чтобы использовать более быстрый бинарный поиск? Затраты не сопоставимы выгоде. Я пока с трудом могу представить, в каких ситуациях, в каком моменте необходимо сортировать элементы, где поиск перебором будет сильно накладнее бинарного в отсортированном списке. Программирование, как мне кажется, не столько про оптимальность, сколько целесообразность принимаемых решений. А из приведённого примера ничто не говорит, что сортировать список хоть из 4, хоть из 400 или даже 4000 студентов имеет смысл, ведь это в теории может больше сказаться на производительности, чем простой поиск перебором всех значений. Откуда нам знать, как часто будет меняться список? Какие операции, добавление или изменение, будут задействоваться чаще. Мне, как новичку, не хватает данных, чтобы понимать это и принимать такие логические выводы.

    А третий вопрос хороший. Я хорошо понял указатели, но переварить ссылки и изменение адреса при изменении размера массива — это ещё в процессе.

  2. Taras:

    Скажите почему эта программа может не работать ?
    Компилируется и Вылетает:
    "Process returned -1073741819 (0xC0000005) execution time : 2.096 s
    Press any key to continue."

    А скопированная с сайта работает.

    1. Dan:

      Ты добавляешь новый не существующий элемент внутри цикла.

  3. Владимир:

    Ответ в 3-ем задании поверг меня в ступор. И я подумал что плохо изучал ваши уроки. Я вернулся перечитал полностью урок №95 и не нашёл там данного ответа. Просьба более подробно описать данную ситуацию почему так происходит. Ведь в уроке №95 говорится о том что std::vector сам выделяет нужное количество память под себя без утечки памяти.

    1. Анатолий:

      Уже думаю неактуально но выскажусь. Как я понял, производиться выделение новой памяти под требуемый размер вектора очисткой старой памяти. Адреса памяти меняются и соответственно ссылка уже будет указывать на освобождённую память, т.е. неизвестно на что.

    2. Павел:

      Элементы массива расположены в памяти последовательно, т.е массиву нужен непрерывный участок памяти. Благодаря этому работает адресная арифметика и оператор индекса.
      Если размер массива изменяется — нужно искать новый участок в памяти соответствующего размера. Поэтому если у вас есть указатели/ссылки на конкретный элемент в массиве — они в таком случае окажутся некорректными.
      Вектор сам выделяет и очищает память под элементы, переносит их в новое место при расширении. Но он не знает о том, что вы создали ссылку на какой-либо элемент.

  4. Константин:

    Здравствуйте, в задании 1 я сделал так, всё работает! Это допустимо?

  5. Александр:

    В задании 1-с вместо 2-ух этих строк:

    можно одной этой??

  6. rutrud:

    Не понял сути 3-го вопроса, думал что дело в синтаксисе, даже подумать не мог что дело в перевыделение памяти. А вообще круто.

  7. Анастасия:

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

  8. Алекс:

    Возникает вопрос: для чего мы всё это строим, если ничего не сломано? Массив закрыт от всех, чтобы нельзя было его менять. Имеются геттеры и сеттеры, которые разрешают менять и указывают на то, что меняется инкапсулированный массив. Почему нам внезапно это неудобно и нужно городить перегрузку, чтобы работать с закрытым массивом по принципу публичного массива?

    Я понимаю, что это часть обучающей программы, но в чём целесообразность уничтожения закрытости массивов класса и пренебрежение имеющимися инструментами?

    1. Алекс:

      Извиняюсь за недопонимание. Видимо, 5 непростых уроков подряд переутомили меня.

      Потерял из виду, что массивы класса не хранятся внутри него, а создаются на основе, поэтому m_array закрыт как шаблон для массивов. И не пренебрегаются инструменты, а создаётся более удобная форма геттера и сеттера, позволяющие работать с массивами конкретного класса напрямую, что никак не нарушает инкапсуляцию.

      1. Фото аватара Юрий:

        Не загоняйте себя (в хорошем смысле этого слова) 🙂 Если уроки большие, то лучше сконцентрируйтесь на лучшем понимании 2-3 тем, нежели на простом скорочтении текста.

  9. kmish:

    "При добавлении Martin-а, std::vector должен увеличить свой размер. А для этого потребуется динамическое выделение нового блока памяти, копирование элементов массива в этот новый блок и удаление старого блока. "

    Может я запамятовал, но в предыдущих уроках об std::vector не упоминалось о реализации работы динамической памяти для std::vector. Иными словами мы не могли это знать, отвечая на вопрос: "Почему следующая программа не работает должным образом?"

    1. Снова я:

      Поддерживаю. Для меня это тоже шок. Я запомнил про выделение памяти под std::vector и выделение нового участка памяти при добавлении элемента кажется вполне логичным, но я думал С++ автоматически перепишет и обновит ссылки под существующие связи. Иначе это выглядит ужасными костылями.

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *