[Из песочницы] Перегрузка всех 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::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) Р. Лафоре Объектно-Ориентированное Программирование в С++

Комментарии (0)

© Habrahabr.ru