Операторы можно переопределять для существующих типов данных. Основы перегрузки операторов

Доброго времени суток, уважаемые читатели!

Когда я только начал свой путь по изучению 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); }

    Небольшое отступление о 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)

Минимальный оператор присваивания - это

Void Cls::operator=(Cls other) { swap(*this, other); }

Согласно стандарту, это копирующий оператор присваивания.
Однако он также может выполнять перемещение, если у Cls есть перемещающий конструктор:

Cls a, b; a = std::move(b); // Работает как // Cls other(std::move(b)); a.operator=(other); // ^^^^^^^^^^ // перемещение: вызов Cls::Cls(Cls&&)

После обмена (swap) текущие члены класса оказываются во временном объекте other и удаляются при выходе из оператора присваивания.
При копирующем присваивании самому себе будет сделана лишняя копия, но никаких ошибок не будет.

Тип результата может быть любым.
Автоматически сгенерированный оператор присваивания имеет тип возвращаемого значения Cls& и возвращает *this . Это позволяет писать код вида a = b = c или (a = b) > c .
Но многие соглашения по стилю кода такое не одобряют, в частности см. CppCoreGuidelines ES.expr "Avoid complicated expressions" .

Для работы этого оператора присваивания нужны конструкторы копирования/перемещения и функция обмена (swap).
Вместе это выглядит так:

Class Cls { public: Cls() {} // Конструктор копирования Cls(const Cls& other) : x(other.x), y(other.y) {} // Конструктор перемещения Cls(Cls&& other) noexcept { swap(*this, other); } // Оператор присваивания void operator=(Cls other) noexcept { swap(*this, other); } // Обмен friend void swap(Cls& a, Cls& b) noexcept { using std::swap; // Добавление стандартной функции в список перегрузок... swap(a.x, b.x); // ... и вызов с использованием поиска по типам аргументов (ADL). swap(a.y, b.y); } private: X x; Y y; };

Конструктор копирования копирует каждый член класса.

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

Функция swap может быть свободной функцией-другом. Многие алгоритмы ожидают наличие свободной функции swap , и вызывают ее через поиск по типу аргументов (ADL).
Раньше рекомендовалось также писать метод swap , чтобы можно было писать f().swap(x); , но с появлением семантики перемещения это стало не нужно.

Если функции не могут бросать исключений, то они должны быть помечены как noexcept . Это нужно для std::move_if_noexcept и других функций, которые могут использовать более эффективный код, если присваивание или конструктор не бросает исключений. Для такого класса выдают

Std::is_nothrow_copy_constructible == 0 std::is_nothrow_move_constructible == 1 std::is_nothrow_copy_assignable == 0 std::is_nothrow_move_assignable == 1

Хотя оператор присваивания и помечен как noexcept , при его вызове с аргументом const Cls& произойдет копирование, которое может бросить исключение. По этому is_nothrow_copy_assignable возвращает false .

Бинарный оператор, такой как, например, оператор сложения operator + должен быть определен либо как нестатическая функция - член класса с одним параметром, либо как функция, которая не является членом класса, с двумя параметрами.

Выданные вам сообщения компилятора

Main.cpp:17:5: error: C++ requires a type specifier for all declarations operator+(Cat a, Cat b) { ^ main.cpp:18:16: error: cannot initialize return object of type "int" with an rvalue of type "Cat *" return new Cat(a.value + b.value); ^~~~~~~~~~~~~~~~~~~~~~~~~~

говорят о том, что у определенного вами оператора отсутствует тип возвращаемого значения.

Тип возвращаемого значения может отсутствовать только у функций преобразования. В случае же оператора сложения вы обязаны указать тип возвращаемого значения.

При сложении двух объектов класса не имеет никакого смысла возвращать указатель на объект. В этому случае вы не сможете связывать операторы сложения в цепочку без применения дополнительных операторов, и, более того, это может привести к утечке памяти.

Оператор должен возвращать сам объект либо с квалификатором const либо без него.

Как уже выше упомянуто, оператор может быть объявлен как нестатическая функция-член класса с одним параметром.

В этом случае оператор operator + может выглядеть так

Class Cat { private: int value = 1; public: Cat(int _value) { value = _value; } Cat operator +(const Cat &a) const { return Cat(this->value + a.value); } };

Class Cat { private: int value = 1; public: Cat(int _value) { value = _value; } const Cat operator +(const Cat &a) const { return Cat(this->value + a.value); } };

Вы могли бы перегрузить оператор также для rvalue -ссылок, как, например,

Cat operator +(const Cat &&a) const { return Cat(this->value + a.value); }

Cat operator +(Cat &&a) const { return Cat(this->value + a.value); }

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

Обратите внимание на присутствие квалификатора condt после списка параметров. Это говорит о том, что сам объект, который будет присутствовать в левой части от оператора, изменяться не будет, так же, как и правый объект, так как соответствующий ему параметр оператора определен также с квалификатором const .

Имейте в виду, так как конструктор класса объявлен как преобразующий конструктор, то вы в этом случае можете складывать объекты класса Cat с числами. Например,

#include class Cat { private: int value = 1; public: Cat(int _value) { value = _value; } const Cat operator +(const Cat &a) const { return Cat(this->

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

#include class Cat { private: int value = 1; public: explicit Cat(int _value) { value = _value; } const Cat operator +(const Cat &a) const { return Cat(this->value + a.value); } }; int main() { Cat c1(10); c1 + 5.5; return 0; }

Для этой программы компилятор выдаст сообщение об ошибке на подобие следующего

Prog.cpp:24:5: error: no match for "operator+" (operand types are "Cat" and "double") c1 + 5.5; ^

Второй способ объявить этот оператор - это объявить его как функцию, которая не является членом класса. Так как эта функция должна иметь доступ к закрытому члену класса value , то ее нужно будет объявить как дружественную функцию класса.

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

Например,

#include class Cat { private: int value = 1; public: Cat(int _value) { value = _value; } friend const Cat operator +(const Cat &a, const Cat &b) { return Cat(a.value + b.value); } }; int main() { Cat c1(10); Cat c2(5); Cat c3 = c1 + c2; return 0; }

Имейте в виду, что это плохая идея объявлять переменные, начинающиеся с подчеркивания. В общем случае такие имена, начинающиеся с подчеркивания, зарезервированы за компилятором.

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

Cat(int value) : value(value) { }

Если член класса value не может принимать отрицательные значения, то лучше его и соответствующий параметр конструктора объявить, как имеющие тип unsigned int .

Во многих языках программирования используются операторы: как минимум, присваивания (= , := или похожие) и арифметические операторы (+ , - , * и /). В большинстве языков со статической типизацией эти операторы привязаны к типам. Например, в Java сложение с оператором + возможно лишь для целых чисел, чисел с плавающей запятой и строк. Если мы определим свои классы для математических объектов, например, для матриц, мы можем реализовать метод их сложения, но вызвать его можно лишь чем-то вроде этого: a = b.add(c) .

В C++ этого ограничения нет - мы можем перегрузить практически любой известный оператор. Возможностей не счесть: можно выбрать любую комбинацию типов операндов, единственным ограничением является необходимость того, чтобы присутствовал как минимум один операнд пользовательского типа. То есть определить новый оператор над встроенными типами или переписать существующий нельзя .

Когда стоит перегружать операторы?

Запомните главное: перегружайте операторы тогда и только тогда, когда это имеет смысл. То есть если смысл перегрузки очевиден и не несёт в себе скрытых сюрпризов. Перегруженные операторы должны действовать так же, как и их базовые версии. Естественно, допустимы исключения, но лишь в тех случаях, когда они сопровождаются понятными объяснениями. Наглядным примером являются операторы << и >> стандартной библиотеки iostream , которые явно ведут себя не как обычные операторы .

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

Matrix a, b; Matrix c = a + b;

Примером плохой перегрузки оператора сложения будет сложение двух объектов типа “игрок” в игре. Что имел в виду создатель класса? Каким будет результат? Мы не знаем, что делает операция, и поэтому пользоваться этим оператором опасно.

Как перегружать операторы?

Перегрузка операторов похожа на перегрузку функций с особенными именами. На самом деле, когда компилятор видит выражение, в котором присутствует оператор и пользовательский тип, он заменяет это выражение вызовом соответствующей функции перегруженного оператора. Большая часть их названий начинается с ключевого слова operator , за которым следует обозначение соответствующего оператора. Когда обозначение не состоит из особых символов, например, в случае оператора приведения типа или управления памятью (new , delete и т.д.), слово operator и обозначение оператора должны разделяться пробелом (operator new), в прочих случаях пробелом можно пренебречь (operator+).

Большую часть операторов можно перегрузить как методами класса, так и простыми функциями, но есть несколько исключений. Когда перегруженный оператор является методом класса, тип первого операнда должен быть этим классом (всегда *this), а второй должен быть объявлен в списке параметров. Кроме того, операторы-методы не статичны, за исключением операторов управления памятью.

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

Class Rational { public: //Constructor can be used for implicit conversion from int: Rational(int numerator, int denominator = 1); Rational operator+(Rational const& rhs) const; }; int main() { Rational a, b, c; int i; a = b + c; //ok, no conversion necessary a = b + i; //ok, implicit conversion of the second argument a = i + c; //ERROR: first argument can not be implicitly converted }

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

Реализуйте унарные операторы и бинарные операторы типа “X =” в виде методов класса, а прочие бинарные операторы - в виде свободных функций.

Какие операторы можно перегружать?

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

  • Нельзя определить новый оператор, например, operator** .
  • Следующие операторы перегружать нельзя:
    1. ?: (тернарный оператор);
    2. :: (доступ к вложенным именам);
    3. . (доступ к полям);
    4. .* (доступ к полям по указателю);
    5. sizeof , typeid и операторы каста.
  • Следующие операторы можно перегрузить только в качестве методов:
    1. = (присваивание);
    2. -> (доступ к полям по указателю);
    3. () (вызов функции);
    4. (доступ по индексу);
    5. ->* (доступ к указателю-на-поле по указателю);
    6. операторы конверсии и управления памятью.
  • Количество операндов, порядок выполнения и ассоциативность операторов определяется стандартной версией.
  • Как минимум один операнд должен быть пользовательского типа. Typedef не считается.

В следующей части вашему вниманию будут представлены перегружаемые операторы C++, в группах и по отдельности. Для каждого раздела характерна семантика, т.е. ожидаемое поведение. Кроме того, будут показаны типичные способы объявления и реализации операторов.

Основы перегрузки операторов

В C#, подобно любому языку программирования, имеется готовый набор лексем, используемых для выполнения базовых операций над встроенными типами. Например, известно, что операция + может применяться к двум целым, чтобы дать их сумму:

// Операция + с целыми. int а = 100; int b = 240; int с = а + b; //с теперь равно 340

Здесь нет ничего нового, но задумывались ли вы когда-нибудь о том, что одна и та же операция + может применяться к большинству встроенных типов данных C#? Например, рассмотрим такой код:

// Операция + со строками. string si = "Hello"; string s2 = " world!"; string s3 = si + s2; // s3 теперь содержит "Hello world!"

По сути, функциональность операции + уникальным образом базируются на представленных типах данных (строках или целых в данном случае). Когда операция + применяется к числовым типам, мы получаем арифметическую сумму операндов. Однако когда та же операция применяется к строковым типам, получается конкатенация строк.

Язык C# предоставляет возможность строить специальные классы и структуры, которые также уникально реагируют на один и тот же набор базовых лексем (вроде операции +). Имейте в виду, что абсолютно каждую встроенную операцию C# перегружать нельзя. В следующей таблице описаны возможности перегрузки основных операций:

Операция C# Возможность перегрузки
+, -, !, ++, --, true, false Этот набор унарных операций может быть перегружен
+, -, *, /, %, &, |, ^, > Эти бинарные операции могут быть перегружены
==, !=, <, >, <=, >= Эти операции сравнения могут быть перегружены. C# требует совместной перегрузки "подобных" операций (т.е. < и >, <= и >=, == и!=)
Операция не может быть перегружена. Oднако, аналогичную функциональность предлагают индексаторы
() Операция () не может быть перегружена. Однако ту же функциональность предоставляют специальные методы преобразования
+=, -=, *=, /=, %=, &=, |=, ^=, >= Сокращенные операции присваивания не могут перегружаться; однако вы получаете их автоматически, перегружая соответствующую бинарную операцию

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

// Общая форма перегрузки унарного оператора. public static возвращаемый_тип operator op(тип_параметра операнд) { // операции } // Общая форма перегрузки бинарного оператора. public static возвращаемый_тип operator op(тип_параметра1 операнд1, тип_параметра2 операнд2) { // операции }

Здесь вместо op подставляется перегружаемый оператор, например + или /, а возвращаемый_тип обозначает конкретный тип значения, возвращаемого указанной операцией. Это значение может быть любого типа, но зачастую оно указывается такого же типа, как и у класса, для которого перегружается оператор. Такая корреляция упрощает применение перегружаемых операторов в выражениях. Для унарных операторов операнд обозначает передаваемый операнд, а для бинарных операторов то же самое обозначают операнд1 и операнд2 . Обратите внимание на то, что операторные методы должны иметь оба спецификатора типа - public и static.

Перегрузка бинарных операторов

Давайте рассмотрим применение перегрузки бинарных операторов на простейшем примере:

Using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace ConsoleApplication1 { class MyArr { // Координаты точки в трехмерном пространстве public int x, y, z; public MyArr(int x = 0, int y = 0, int z = 0) { this.x = x; this.y = y; this.z = z; } // Перегружаем бинарный оператор + public static MyArr operator +(MyArr obj1, MyArr obj2) { MyArr arr = new MyArr(); arr.x = obj1.x + obj2.x; arr.y = obj1.y + obj2.y; arr.z = obj1.z + obj2.z; return arr; } // Перегружаем бинарный оператор - public static MyArr operator -(MyArr obj1, MyArr obj2) { MyArr arr = new MyArr(); arr.x = obj1.x - obj2.x; arr.y = obj1.y - obj2.y; arr.z = obj1.z - obj2.z; return arr; } } class Program { static void Main(string args) { MyArr Point1 = new MyArr(1, 12, -4); MyArr Point2 = new MyArr(0, -3, 18); Console.WriteLine("Координаты первой точки: " + Point1.x + " " + Point1.y + " " + Point1.z); Console.WriteLine("Координаты второй точки: " + Point2.x + " " + Point2.y + " " + Point2.z + "\n"); MyArr Point3 = Point1 + Point2; Console.WriteLine("\nPoint1 + Point2 = " + Point3.x + " " + Point3.y + " " + Point3.z); Point3 = Point1 - Point2; Console.WriteLine("\nPoint1 - Point2 = " + Point3.x + " " + Point3.y + " " + Point3.z); Console.ReadLine(); } } }

Перегрузка унарных операторов

Унарные операторы перегружаются таким же образом, как и бинарные. Главное отличие заключается, конечно, в том, что у них имеется лишь один операнд. Давайте модернизируем предыдущий пример, дополнив перегрузки операций ++, --, -:

Using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace ConsoleApplication1 { class MyArr { // Координаты точки в трехмерном пространстве public int x, y, z; public MyArr(int x = 0, int y = 0, int z = 0) { this.x = x; this.y = y; this.z = z; } // Перегружаем бинарный оператор + public static MyArr operator +(MyArr obj1, MyArr obj2) { MyArr arr = new MyArr(); arr.x = obj1.x + obj2.x; arr.y = obj1.y + obj2.y; arr.z = obj1.z + obj2.z; return arr; } // Перегружаем бинарный оператор - public static MyArr operator -(MyArr obj1, MyArr obj2) { MyArr arr = new MyArr(); arr.x = obj1.x - obj2.x; arr.y = obj1.y - obj2.y; arr.z = obj1.z - obj2.z; return arr; } // Перегружаем унарный оператор - public static MyArr operator -(MyArr obj1) { MyArr arr = new MyArr(); arr.x = -obj1.x; arr.y = -obj1.y; arr.z = -obj1.z; return arr; } // Перегружаем унарный оператор ++ public static MyArr operator ++(MyArr obj1) { obj1.x += 1; obj1.y += 1; obj1.z +=1; return obj1; } // Перегружаем унарный оператор -- public static MyArr operator --(MyArr obj1) { obj1.x -= 1; obj1.y -= 1; obj1.z -= 1; return obj1; } } class Program { static void Main(string args) { MyArr Point1 = new MyArr(1, 12, -4); MyArr Point2 = new MyArr(0, -3, 18); Console.WriteLine("Координаты первой точки: " + Point1.x + " " + Point1.y + " " + Point1.z); Console.WriteLine("Координаты второй точки: " + Point2.x + " " + Point2.y + " " + Point2.z + "\n"); MyArr Point3 = Point1 + Point2; Console.WriteLine("\nPoint1 + Point2 = " + Point3.x + " " + Point3.y + " " + Point3.z); Point3 = Point1 - Point2; Console.WriteLine("Point1 - Point2 = " + Point3.x + " " + Point3.y + " " + Point3.z); Point3 = -Point1; Console.WriteLine("-Point1 = " + Point3.x + " " + Point3.y + " " + Point3.z); Point2++; Console.WriteLine("Point2++ = " + Point2.x + " " + Point2.y + " " + Point2.z); Point2--; Console.WriteLine("Point2-- = " + Point2.x + " " + Point2.y + " " + Point2.z); Console.ReadLine(); } } }