Доброго времени суток, уважаемые читатели !
Когда я только начал свой путь по изучению 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::operator bool()
{
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) Р. Лафоре Объектно-Ориентированное Программирование в С++
Автор: jah_lives_in_me
Единственное изменение, которое нам необходимо внести, — это добавить ключевое слово fried перед объявлением перегрузки:
FRIED.
Офигенная статья. Ничто мне так всё не объяснило, как это. Спасибо огромное.