На цьому уроці ми розглянемо перевантаження оператора індексації в мові С++.
- Перевантаження оператора індексації []
- Чому оператор індексації [] використовує повернення по посиланню?
- Використання оператора індексації з константними об’єктами класу
- Перевірка помилок
- Вказівники на об’єкти і перевантажений оператор []
- Аргумент, що передається, не обов’язково повинен бути цілим числом
- Висновки
- Тест
Перевантаження оператора індексації []
При роботі з масивами оператор індексації ([]
) використовується для вибору певних елементів:
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
, що є прямою операцією присвоювання значення елементу масиву.
З уроку №13 ми вже знаємо, що будь-яке значення, яке знаходиться зліва від оператора присвоювання, має бути 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 читайте в матеріалах уроку №131).
Доброю новиною є те, що ми можемо визначити окремо неконстантну і константну версії 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
залишиться посиланням на видалену пам’ять. Це і призведе до невизначених результатів.