Перевантаження всіх 49-й операторів у C++

Доброго часу доби, шановні читачі Хабра!

Коли я тільки почав свій шлях по вивченню C++, у мене виникало багато запитань, на які, часом, не вдавалося швидко знайти відповідей. Не стала винятком і така тема як перевантаження операторів. Тепер, коли я розібрався в цій темі, я хочу допомогти іншим розставити всі крапки над i.

У цій публікації я розповім: про різні тонкощі перевантаження операторів, навіщо взагалі потрібна ця перевантаження, про типи операторів (унарні/бінарні), про перевантаження оператора з friend (дружня функція), а так само про типи приймаються і повертаються перевантаженнями значень.

Для чого потрібна перевантаження?
Припустимо, що ви створюєте свій клас або структуру, нехай він буде описувати вектор в 3-х мірному просторі:

struct Vector3
{
int x, y, z;

Vector3()
{}
Vector3(int x, int y, int z) : x(x), y(y), z(z)
{}
};

Тепер, Ви створюєте 3 об'єкта цієї структури:

Vector3 v1, v2, v3;
//Ініціалізація
v1(10, 10, 10);
//...

І хочете прирівняти об'єкт v2 об'єкту v1, пишете:

v1 = v2;

Все працює, але приклад з вектором дуже сильно спрощено, може бути у вас така структура, в якій необхідно не сліпо копіювати всі значення з одного об'єкта в інший (як це відбувається за замовчуванням), а проводити з ними якісь маніпуляції. Приміром, не копіювати останню змінну z. Звідки програма про це дізнається? Їй потрібні чіткі команди, які вона буде виконувати.

Тому нам необхідно перевантажити оператор присвоювання (=).

Загальні відомості про перевантаження операторів
Для цього додамо в нашу структуру перевантаження:

Vector3 operator = (Vector3 v1)
{
return Vector3(this->x = v1.x, this->y = v1.y, this->z = 0);
}

Тепер, у коді вище ми вказали, що при присвоєнні необхідно скопіювати змінні x та y, z обнулити.

Але така перевантаження далека від досконалості, уявімо, що наша структура містить в собі не 3 змінні типу int, а безліч об'єктів інших класів, в такому випадку цей варіант перевантаження буде працювати досить повільно.

  • Перше, що ми можемо зробити, це передавати в метод перевантаження не весь об'єкт, а посилання на те місце, де він зберігається:

    //Передача об'єкта за посиланням (&v1)
    Vector3 operator = (Vector3 &v1)
    {
    return Vector3(this->x = v1.x, this->y = v1.y, this->z = 0);
    }
    

    Коли ми передаємо об'єкт за адресою, по суті створюється новий об'єкт (копія того, який передається в метод), що може нести за собою певні витрати як за часом виконання програми, так і за споживаної їй пам'яті. — Стосовно великих об'єктів.

    Передаючи об'єктза адресою, не відбувається виділення пам'яті під об'єкт (припустимо, 128 байт) і операції копіювання, пам'ять виділяється лише під курсор на комірку пам'яті, з якою ми працюємо, а це близько 4 — 8 байт. Таким чином, виходить робота з об'єктом на пряму.

  • Але, якщо ми передаємо об'єкт по ссилці, то він стає змінним. Тобто ніщо не завадить нам при операції присвоювання (v1 = v2) змінювати не тільки значення v1, але ще і v2!

    Приклад:

    //Зміна переданого об'єкта
    Vector3 operator = (Vector3 &v)
    {
    //Змінюємо об'єкт, який праворуч від знака =
    v.x = 10; v.y = 50;
    //Повертаємо значення для об'єкта зліва від знака =
    return Vector3(this->x = v.x, this->y = v.y, this->z = 0);
    }
    

    Зрозуміло, навряд чи хтось при здоровому глузді стане виробляти такі не очевидні маніпуляції. Але все ж, не завадить виключити навіть ймовірність.

    Для цього нам лише потрібно додати const перед приймаються аргументом, таким чином ми вкажемо, що зсередини методу не можна змінити цей об'єкт.

    //Заборона зміни переданого об'єкта
    Vector3 operator = (const Vector3 &v)
    {
    //Не вийде змінити об'єкт, який праворуч від знака =
    //v.x = 10; v.y = 50;
    //Повертаємо значення для об'єкта зліва від знака =
    return Vector3(this->x = v.x, this->y = v.y, this->z = 0);
    }
    

  • Тепер, давайте звернемо наші погляди на тип значення, що повертається. Метод перевантаження повертає об'єкт Vector3, тобто створюється новий об'єкт, що може призводити до таких же проблем, які я описав в першому пункті. І рішення не буде відрізнятися оригінальністю, нам не потрібно створювати новий об'єкт — значить просто передаємо посилання на вже існуючий.

    //Повертається не об'єкт, а посилання на об'єкт
    Vector3& operator = (const Vector3 &v)
    {
    return Vector3(this->x = v.x, this->y = v.y, this->z = 0);
    }
    

    Але при поверненні посилання, можуть виникнути певні проблеми.

    Ми вже не напишемо таке вираз: v1 = (v2 + v3);

    Невеликий відступ про return:
    Коли я вивчав перевантаження, то не розумів:

    //Навіщо писати this->x = ... (що може призводити до помилок в бінарних операторів)
    return Vector3(this->x = v.x, this->y = v.y, this->z = 0);
    //Якщо ми все одно повертаємо об'єкт з модифікованими даними? 
    //Чому такий запис не буде працювати? (Стосовно до унарным операторів)
    return Vector3(v.x, v.y, 0);
    

    Справа в тому, що всі операції ми повинні самостійно і явно вказати в тілі методу. Що значить, написати: this->x = v.x і т. д.

    Але для чого тоді return, що ми повертаємо? Насправді return у цьому прикладі грає досить формальну роль, ми цілком можемо обійтися і без нього:

    //Повертається void (нічого)
    void operator = (const Vector3 &v1)
    {
    this->x = v1.x, this->y = v1.y, this->z = 0;
    }
    

    І такий код цілком собі працює. Т. до. все, що потрібно зробити, ми вказуємо в тілі методу.
    Але в такому разі у нас не вийде зробити такий запис:

    v1 = (v2 = v3);
    //Приклад для void operator +
    //v1 = void? - Не можна
    v1 = (v2 + v3);
    

    Т. к. нічого не повертається, не можна виконати і призначення. Або ж у випадку з посиланням, що виходить аналогічно void, повертається посилання на тимчасовий об'єкт, який вже не буде існувати в момент його використання (зітреться після виконання методу).

    Виходить, що краще повертати об'єкт а не посилання? Не все так однозначно, і вибирати тип значення, що повертається, (об'єкт, або посилання) необхідно в кожному конкретному випадку. Але для більшості невеликих об'єктів — краще повертати сам об'єкт, щоб ми мали можливість подальшої роботи з результатом.

    Відступ 2 (як робити не треба):
    Тепер, знаючи про різницю операції return та безпосереднього виконання операції, ми можемо написати такий код:

    v1(10, 10, 10);
    v2(15, 15, 15);
    v3;
    
    v3 = (v1 + v2);
    
    cout << v1; // Не (10, 10, 10), а (12, 13, 14)
    cout << v2; // Не (15, 15, 15), а (50, 50, 50)
    cout << v3; // Не (25, 25, 25), а також, що завгодно
    

    Для того, що б реалізувати цей жах ми визначимо перевантаження таким чином:

    Vector3 operator + (Vector3 &v1, Vector3 &v2)
    {
    v1.x += 2, v1.y += 13, v1.z += 4;
    v2(50, 50, 50);
    return Vector3(/*також, що завгодно*/);
    }
    

  • І коли ми перевантажуємо оператор присвоювання, залишається необхідність виключити поперемінне присвоювання в тому рідкісному випадку, коли з якоїсь причини об'єкт присвоюється сам собі: v1 = v1.
    Для цього додамо таке умова:

    Vector3 operator = (const Vector3 &v1)
    {
    //Якщо спроба зробити об'єкт рівним собі, просто повертаємо покажчик на нього
    //(або можна видати попередження/виключення)
    if (&v1 == this)
    return *this;
    return Vector3(this->x = v1.x, this->y = v1.y, this->z = v1.z);
    }
    

Відмінності унарных і бінарних операторів
Унарні оператори — це такі оператори, де задіяний лише один об'єкт, до якого застосовуються всі зміни

Vector3 operator + (const Vector3 &v1); // Унарний плюс
Vector3 operator - (const Vector3 &v1); // Унарний мінус
//А також:
//++, --, !, ~, [], *, &, (), (type), new, delete

Бінарні оператори працюють з 2-я об'єктами

Vector3 operator + (const Vector3 &v1, const Vector3 &v2); //Додавання - це НЕ унарний плюс!
Vector3 operator - (const Vector3 &v1, const Vector3 &v2); //Віднімання - це НЕ унарний мінус!
//А також:
//*, /, %, ==, !=, > <, >=, <=, &&, ||, &, |, ^, <<, >>, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=, ->, ->*, (,), ","

Перевантаження в тілі і за тілом класу
Ми можемо оголосити і реалізувати перевантаження безпосередньо в самому тілі класу або структури. Думаю, що як це зробити вже зрозуміло. Давайте розглянемо варіант, в якому оголошення перевантаження відбувається в тілі класу, а її реалізація вже за межами класу.

struct Vector3
{
//Дані, конструктори, ...
//Оголошуємо про те, що в даній структурі перевантажений оператор =
Vector3 operator = (Vector3 &v1);
};
//Реалізуємо перевантаження за межами тіла структури
//Для цього додаємо "Vector3::", що вказує на те, членом якої структури є перевантажується оператор
//Перша напис Vector3 - це тип значення, що повертається
Vector3 Vector3::operator = (Vector3 &v1);
{
return Vector3(this->x = v1.x, this->y = v1.y, this->z = 0);
}

Навіщо перевантаження операторів дружні функції (friend)?
Дружні функції — це такі функції які мають доступ до приватним методам класу або структури.

Припустимо, що в нашій структурі Vector3, такі як члени x,y,z — є приватними, тоді ми не зможемо звернутися до них за межами тіла структури. Тут то і допомагають дружні функції.
Єдина зміна, що нам необхідно внести, — це додати ключове слово fried перед оголошенням перевантаження:

struct Vector3
{
friend Vector3 operator = (Vector3 &v1);
};
//За тілом структури пишемо реалізацію

Коли не обійтися без дружніх функцій перевантаження операторів?
1) Коли ми реалізуємо інтерфейс (.h файл) в який поміщаються тільки оголошення методів, а реалізація виноситься в прихований .dll файл

2) Коли операція здійснюється над об'єктами різних класів. Приклад:

struct Vector2
{
//Складаємо Vector2 і Vector3
Vector2 operator + (Vector3 v3) {/*...*/}
}
//Об'єкта Vector2 присвоюємо суму об'єктів Vector2 і Vector3
vec2 = vec2 + vec3; //Ok
vec2 = vec3 + vec2; //Помилка

Помилка відбудеться з наступної причини, у структурі Vector2 ми перевантажили оператор +, який в якості значення праворуч приймає тип Vector3, тому перший варіант працює. Але у другому випадку, необхідно писати перевантаження вже для структури Vector3, а не 2. Щоб не лізти в реалізацію класу Vector3, ми можемо написати таку дружню функцію:

struct Vector2
{
//Складаємо Vector2 і Vector3
Vector2 operator + (Vector3 v3) {/*...*/}
//Дружність необхідна для того, щоб ми мали доступ до приватних членів класу Vector3
friend Vector2 operator + (Vector3 v3, Vector2 v2) {/*...*/}
}
vec2 = vec2 + vec3; //Ok
vec2 = vec3 + vec2; //Ok



Приклади перевантажень різних операторів з деякими поясненнями
Приклад перевантаження для бінарних +, -, *, /, %

Vector3 operator + (const Vector3 &v1, const Vector3 &v2)
{
return Vector3(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z);
}

Приклад перевантаження для постфиксных форм инкремента і декремента (var++, var--)

Vector3 Vector3::operator ++ (int)
{
return Vector3(this->x++, this->y++, this->z++);
}

Приклад перевантаження для префиксных форм инкремента і декремента (++var, --var)

Vector3 Vector3::operator ++ ()
{
return Vector3(++this->x ++this->y ++this->z);
}

Перевантаження арифметичних операцій з об'єктами інших класів

Vector3 operator * (const Vector3 &v1, const int i)
{
return Vector3(v1.x * i, v1.y * i, v1.z * i);
}

Перевантаження плюс плюса (+)

//Нічого не робить, просто повертаємо об'єкт
Vector3 operator + (const Vector3 &v)
{
return v;
}

Перевантаження плюс мінус (-)

//Примножує об'єкт на -1
Vector3 operator - (const Vector3 &v)
{
return Vector3(v.x * -1, v.y * -1, v.z * -1);
}

Приклад перевантаження операцій складеного присвоювання +=, -=, *=, /=, %=

Vector3 operator += (const Vector3 &v1, const Vector3 &v2)
{
return Vector3(v1.x = v1.x + v2.x, v1.y = v1.y + v2.y, v1.z = v1.z + v2.z);
}

Хороший приклад перевантаження операторів порівняння ==, !=, > <, >=, <=

const bool operator < (const Vector3 &v1, const Vector3 &v2)
{
double vTemp1(sqrt(pow(v1.x, 2) + pow(v1.y, 2) + pow(v1.z, 2)));
double vTemp2(sqrt(pow(v2.x, 2) + pow(v2.y, 2) + pow(v2.z, 2)));

return vTemp1 < vTemp2;
}
const bool operator == (const Vector3 &v1, const Vector3 &v2)
{
if ((v1.x == v2.x) && (v1.y == v2.y) && (v1.z == v2.z))
return true;
return false;
}
//Перевантажуємо != використовуючи інший перевантажений оператор
const bool operator != (const Vector3 &v1, const Vector3 &v2)
{
return !(v1 == v2);
}

Приклад перевантаження операцій приведення типів (type)

//Якщо вектор не нульовий - повернути true
Vector3::bool operator()
{
if (*this != Vector3(0, 0, 0))
return true;
return false;
}
//Приведення до типу int - повертати суму всіх змінних
Vector3::operator int()
{
return int(this->x + this->y + this->z);
}

Приклад перевантаження логічних операторів, &&, ||

//Знову ж, використовуємо вже перевантажену операцію приведення типу bool
const bool operator ! (Vector3 &v1)
{
return !(bool)v1;
}
const bool operator && (Vector3 &v1, Vector3 &v2)
{
return (bool)v1 && (bool)v2;
}

Приклад перевантаження двійковими операторів ~, &, |, ^, <<, >>

//Операція побітовою інверсії (як множення на -1, тільки трохи інакше)
const Vector3 operator ~ (Vector3 &v1)
{
return Vector3(~(v1.x), ~(v1.y), ~(v1.z));
}
const Vector3 operator & (const Vector3 &v1, const Vector3 &v2)
{
return Vector3(v1.x & v2.x, v1.y & v2.y, v1.z & v2.z);
}
//Побітове виключне АБО (xor)
const Vector3 operator ^ (const Vector3 &v1, const Vector3 &v2)
{
return Vector3(v1.x ^ v2.x, v1.y ^ v2.y, v1.z ^ v2.z);
}
//Перевантаження операції виведення в потік
ostream& operator < < (ostream &s, const Vector3 &v)
{
s << '(' << v.x << ", " << v.y << ", " << v.z << ')';
return s;
}
//Перевантаження операції вводу з потоку (дуже зручний варіант)
istream& operator >> (istream &s, Vector3 &v)
{
std::cout << "Введіть Vector3.\nX:";
std::cin >> v.x;
std::cout << "\nY:";
std::cin >> v.y;
std::cout << "\nZ:";
std::cin >> v.z;
std::cout << endl;
return s;
}

Приклад перевантаження побітного складеного присвоювання &=, |=, ^=, <<=, >>=

Vector3 operator ^= (Vector3 &v1, Vector3 &v2)
{
v1(Vector3(v1.x = v1.x ^ v2.x, v1.y = v1.y ^ v2.y, v1.z = v1.z ^ v2.z));
return v1;
}
//Попередньо очищаємо потік
ostream& operator <<= (ostream &s, Vector3 &v)
{
s.clear();
s << '(' << v.x << ", " << v.y << ", " << v.z << ')';
return s;
}

Приклад перевантаження операторів роботи з покажчиками і членами класу [], (), *, &, ->, ->*
Не бачу сенсу перевантажувати (*, &, ->, ->*), тому прикладів нижче не буде.

//Не робіть такого! Така перевантаження [] може ввести в оману, це просто приклад реалізації
//Аналогічно можна зробити для ()
int Vector3::operator [] (int n)
{
try
{
if (n < 3)
{
if (n == 0)
return this->x;
if (n == 1)
return this->y;
if (n == 2)
return this->z;
}
else
throw "Помилка: Вихід за межі розмірності вектора";
}
catch (char *str)
{
cerr << str << endl;
}
return NULL;
}
//Цей приклад також не має практичного сенсу
Vector3 Vector3::operator () (Vector3 &v1, Vector3 &v2)
{
return Vector3(v1 & v2);
}

Як перевантажувати new та delete? Приклади:

//Виділяємо пам'ять під 1 об'єкт
void* Vector3::operator new(size_t v)
{
void *ptr = malloc(v);
if (ptr == NULL)
throw std::bad_alloc();
return ptr;
}
//Виділення пам'яті під кілька об'єктів
void* Vector3::operator new[](size_t v)
{
void *ptr = malloc(sizeof(Vector3) * v);
if (ptr == NULL)
throw std::bad_alloc();
return ptr;
}
void Vector3::operator delete(void* v)
{
free(v);
}
void Vector3::operator delete[](void* v)
{
free(v);
}

Перевантаження new і delete окрема і досить велика тема, яку я не стану торкатися в цій публікації.

Перевантаження оператора кома

Увага! Не варто плутати оператор комою з знаком перерахування! (Vector3 var1, var2;)

const Vector3 operator , (Vector3 &v1, Vector3 &v2)
{
return Vector3(v1 * v2);
}

v1 = (Vector3(10, 10, 10), Vector3(20, 25, 30));
// Висновок: (200, 250, 300)

Джерела

1) https://ru.wikipedia.org/wiki/Операторы в C і C++
2) Р. Лафоре Об'єктно-Орієнтоване Програмування в С++
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.