Что такое перегрузка операторов
В статье про С++ мы упоминали перегрузку операторов. Это мощный и гибкий инструмент, который может оказаться опасным и непредсказуемым в неумелых руках. Настало время разобраться.
Опытным программистам: мы намеренно упростим детали для понимания сути. Ну сорян.
На примере сложения
Во всех языках есть оператор «плюс» — обычно он умеет складывать числа и соединять строки:
‘два’ + ‘два’ = ‘двадва’
‘четы’ + ‘ре’ = ‘четыре’
Допустим, мы пишем софт для интернет-магазина, и у нас есть там класс объектов «заказ». Напомним, что класс — это как бы чертёж, по которому создаются объекты. А объект — это такая коробка с данными и функциями, которыми мы можем управлять как единым целым. Подробнее об этом — в статьях про объекты и классы.
В объекте типа «заказ» лежит куча всего:
- массив с содержимым корзины,
- дата и время, когда сформирован заказ,
- метод «очистить корзину»,
- место для промокода,
- метод «применить промокод»,
- метод «проверить наличие товаров по складу»
- идентификатор пользователя,
- что-нибудь ещё интересное.
Допустим, наша система устроена так, что у любого заказа может быть два идентификатора пользователя: постоянный или временный.
- Если идентификатор постоянный, значит, мы однозначно пользователя узнали (его адрес, номер кредитки и т. д.).
- Если идентификатор временный, значит, мы не знаем, что за пользователь — просто храним его корзину, пока он не оформит заказ. Это может быть новый человек или старый, но ещё не залогинившийся. В любом случае мы должны хранить его данные.
В какой-то момент пользователь с временным идентификатором логинится в систему, и нам хочется сделать следующую операцию:
Обе части выражения — это объекты класса «Заказ». А наш язык программирования не знает, что значит «сложить два объекта класса „Заказ“». Он не знает:
- Что с чем складывать? Число товаров? Суммы? Номера товаров? Номера телефонов? Ведь язык не понимает, что за объект перед ним. Для него это просто коробка с данными, он может с ними делать что хочешь.
- Что возвращать? Число? Строку? Объект? Список заказов?
- Может быть, нужно сравнить два заказа и по каким-то критериям определить самый актуальный?
- Или нужно объединить две корзины в одну?
- А что тогда делать с повторяющимися товарами? Заменить? Добавить в количество? Проигнорировать?
Вроде бы простая операция — а столько вопросов. Вот этому всему мы можем обучить оператор «+», и это будет перегрузка оператора.
Короче
Перегрузка оператора — это когда мы обучаем язык программирования, как оператору типа плюс, минус, умножить и т. д. вести себя с определённым типом вводных — например, с объектами, матрицами или картинками.
В случае с нашим примером мы можем сказать, что если складываются два заказа, делай следующее:
- Найди, какой из этих заказов постоянный.
- Переложи из временного в постоянный все уникальные товары.
- Если есть неуникальные товары (например, и в том, и в другом заказе была одна и та же позиция), склей их и поставь максимальное количество. Например, если в постоянном заказе стояло 3 штуки одного артикула, а во временном этого же артикула 9 штук, то поставь в постоянный 9 штук.
- Временному заказу поставь статус «Склеено».
- Залогируй время склейки заказов.
- Верни постоянный заказ с обновлёнными данными.
Довольно много действий для одного плюса, не находите?
Что хорошего в перегрузке
Перегруженные операторы позволяют совершать привычные операции над необычными объектами. Если на интуитивном уровне логично, что можно складывать некоторые вещи между собой, то первое, что приходит в голову — использовать для этого стандартный плюс. Единственное, что нужно сделать — перегрузить его новыми обязанностями, а потом можно дальше им пользоваться как привычным сложением, даже с новыми объектами.
В результате программист экономит много кода, не пишет специальный отдельный обработчик такого сложения и не держит в голове параметры его вызова.
Чем опасна перегрузка операторов
Когда вы используете в коде перегруженный оператор, он выглядит как самый обычный оператор. Ну складывает и складывает. Ну умножает и умножает, чего такого-то?
Это, с одной стороны, элегантно. А с другой, создаёт проблемы в отладке.
- Представьте, что после вас какой-то программист переделал структуру класса «Заказ», и теперь там по-другому работает массив с товарными позициями. Раньше у товаров были числовые идентификаторы типа integer (целые числа), а новый программист переделал их на строки.
- Ваш язык в неявном виде поддерживает сравнение чисел со строками и наоборот. Он производит какие-то свои преобразования и позволяет сравнить число со строкой. В 99,9% случаев это не сломает вашу программу, и даже перегруженный оператор будет работать.
- Но в 0,1% случаев сравнение случится некорректно, и никто не будет понимать, в чём дело. Где-то под капотом перегруженный оператор некорректно склеивает списки покупок, у пользователя вываливаются какие-то «левые» товары, которых он не заказывал. Он не глядя их оплачивает и потом катает жалобу на ваш магазин.
А ещё, в особо экзотических случаях и больших проектах, программист шутки ради может перегрузить оператор сложения так, что он будет не складывать, а вычитать. И заметить, в чём тут ошибка, в таких случаях бывает очень сложно.
И что?
Перегрузка операторов — это полезно, но сложно.
Если программист не понимает полностью механизма работы перегрузок, лучше не перегружать.
Если понимает — он молодец и может учить стандартные инструменты нестандартному поведению.
Что такое перегрузка операторов
Перегрузка операторов позволяет определить действия, которые будет выполнять оператор. Перегрузка подразумевает создание функции, название которой содержит слово operator и символ перегружаемого оператора. Функция оператора может быть определена как член класса, либо вне класса.
Перегрузить можно только те операторы, которые уже определены в C++. Создать новые операторы нельзя.
Если функция оператора определена как отдельная функция и не является членом класса, то количество параметров такой функции совпадает с количеством операндов оператора. Например, у функции, которая представляет унарный оператор, будет один параметр, а у функции, которая представляет бинарный оператор, — два параметра. Если оператор принимает два операнда, то первый операнд передается первому параметру функции, а второй операнд — второму параметру. При этом как минимум один из параметров должен представлять тип класса
Рассмотрим пример с классом Counter, который представляет секундомер и хранит количество секунд:
Здесь функция оператора не является частью класса Counter и определена вне его. Данная функция перегружает оператор сложения для типа Counter. Она является бинарной, поэтому принимает два параметра. В данном случае мы складываем два объекта Counter. Возвращает функция также объект Counter, который хранит общее количесто секунд. То есть по сути здесь операция сложения сводится к сложению секунд обоих объектов:
При этом необязательно возвращать объект класса. Это может быть и объект встроенного примитивного типа. И также мы можем определять дополнительные перегруженные функции операторов:
Данная версия складывает объект Counter с числом и возвращает также число. Поэтому левый операнд операции должен представлять тип Counter, а правый операнд — тип int. И, к примеру, мы можем применить данную версию оператора следующим образом:
Также функции операторов могут быть определены как члены классов. Если функция оператора определена как член класса, то левый операнд доступен через указатель this и представляет текущий объект, а правый операнд передается в подобную функцию в качестве единственного параметра:
В данном случае к левому операнду в функциях операторов мы обращаемся через указатель this.
Какие операторы где переопределять? Операторы присвоения, индексирования ([]), вызова (()), доступа к члену класса по указателю (->) следует определять в виде функций-членов класса. Операторы, которые изменяют состояние объекта или непосредственно связаны с объектом (инкремент, декремент,), обычно также определяются в виде функций-членов класса. Все остальные операторы чаще определяются как отдельные функции, а не члены класса.
Операторы сравнения
Ряд операторов перегружаются парами. Например, если мы определяем оператор == , то необходимо также определить и оператор != . А при определении оператора < надо также определять функцию для оператора > . Например, перегрузим данные операторы:
Операторы присвоения
Операторы присвоения обычно возвращает ссылку на свой левый операнд:
Операции инкремента и декремента
Особую сложность может представлять переопределение операций инкремента и декремента, поскольку нам надо определить и префиксную, и постфиксную форму для этих операторов. Определим подобные операторы для типа Counter:
Префиксные операторы должны возвращать ссылку на текущий объект, который можно получить с помощью указателя this:
В самой функции можно определить некоторую логику по инкременту значения. В данном случае количество секунд увеличивается на 5.
Постфиксные операторы должны возвращать значение объекта до инкремента, то есть предыдущее состояние объекта. Чтобы постфиксная форма отличалась от префиксной постфиксные версии получают дополнительный параметр типа int, который не используется. Хотя в принципе мы можем его использовать.
Перегрузка операторов в C++. Способы применения
В прошлой части мы рассмотрели основные аспекты использования перегрузки операторов. В этом материалы вашему вниманию будут представлены перегружаемые операторы C++. Для каждого раздела характерна семантика, т.е. ожидаемое поведение. Кроме того, будут показаны типичные способы объявления и реализации операторов.
В примерах кода X означает пользовательский тип, для которого реализован оператор. T — это необязательный тип, пользовательский либо встроенный. Параметры бинарного оператора будут называться lhs и rhs . Если оператор будет объявлен как метод класса, у его объявления будет префикс X:: .
operator=
- Определение справа налево: в отличие от большинства операторов, operator= правоассоциативен, т.е. a = b = c означает a = (b = c) .
Копирование
- Семантика: присваивание a = b . Значение или состояние b передаётся a . Кроме того, возвращается ссылка на a . Это позволяет создавать цепочки вида c = a = b .
- Типичное объявление: X& X::operator= (X const& rhs) . Возможны другие типы аргументов, но используется это нечасто.
- Типичная реализация:
Перемещение (начиная с C++11)
- Семантика: присваивание a = temporary() . Значение или состояние правой величины присваивается a путём перемещения содержимого. Возвращается ссылка на a .
- Типичные объявление и реализация:
- Сгенерированный компилятором operator= : компилятор может создать только два вида этого оператора. Если же оператор не объявлен в классе, компилятор пытается создать публичные операторы копирования и перемещения. Начиная с C++11 компилятор может создавать оператор по умолчанию:
operator+, -, *, /, %
- Семантика: операции сложения, вычитания, умножения, деления, деления с остатком. Возвращается новый объект с результирующим значением.
- Типичные объявление и реализация:
Обычно, если существует operator+ , имеет смысл также перегрузить и operator+= для того, чтобы использовать запись a += b вместо a = a + b . Если же operator+= не перегружен, реализация будет выглядеть примерно так:
Унарные operator+, —
- Семантика: положительный или отрицательный знак. operator+ обычно ничего не делает и поэтому почти не используется. operator- возвращает аргумент с противоположным знаком.
- Типичные объявление и реализация:
operator<<, >>
- Семантика: во встроенных типах операторы используются для битового сдвига левого аргумента. Перегрузка этих операторов с именно такой семантикой встречается редко, на ум приходит лишь std::bitset . Однако, для работы с потоками была введена новая семантика, и перегрузка операторов ввода/вывода весьма распространена.
- Типичные объявление и реализация: поскольку в стандартные классы iostream добавлять методы нельзя, операторы сдвига для определённых вами классов нужно перегружать в виде свободных функций:
Кроме того, тип левого операнда может быть любым классом, которые должен вести себя как объект ввода/вывода, то есть правый операнд может быть и встроенного типа.
Бинарные operator&, |, ^
- Семантика: Битовые операции «и», «или», «исключающее или». Эти операторы перегружаются очень редко. Опять же, единственным примером является std::bitset .
operator+=, -=, *=, /=, %=
- Семантика: a += b обычно означает то же, что и a = a + b . Поведение остальных операторов аналогично.
- Типичные определение и реализация: поскольку операция изменяет левый операнд, скрытое приведение типов нежелательно. Поэтому эти операторы должны быть перегружены как методы класса.
operator&=, |=, ^=, <<=, >>=
- Семантика: аналогична operator+= , но для логических операций. Эти операторы перегружаются так же редко, как и operator| и т.д. operator<<= и operator>>= не используются для операций ввода/вывода, поскольку operator<< и operator>> уже изменяют левый аргумент.
operator==, !=
- Семантика: проверка на равенство/неравенство. Смысл равенства очень сильно зависит от класса. В любом случае, учитывайте следующие свойства равенств:
- Рефлексивность, т.е. a == a .
- Симметричность, т.е. если a == b , то b == a .
- Транзитивность, т.е. если a == b и b == c , то a == c .
- Типичные объявление и реализация:
operator<, <=, >, >=
- Семантика: проверка на соотношение (больше, меньше и т.д.). Обычно используется, если порядок элементов однозначно определён, то есть сложные объекты с несколькими характеристиками сравнивать бессмысленно.
- Типичные объявление и реализация:
Реализация operator> с использованием operator< или наоборот обеспечивает однозначное определение. operator<= может быть реализован по-разному, в зависимости от ситуации. В частности, при отношении строго порядка operator== можно реализовать лишь через operator< :
operator++, —
- Семантика: a++ (постинкремент) увеличивает значение на 1 и возвращает старое значение. ++a (преинкремент) возвращает новое значение. С декрементом operator— все аналогично.
- Типичные объявление и реализация:
operator()
- Семантика: исполнение объекта-функции (функтора). Обычно используется не для изменения объекта, а для использования его в качестве функции.
- Нет ограничений на параметры: в отличие от прошлых операторов, в этом случае нет никаких ограничений на количество и тип параметров. Оператор может быть перегружен только как метод класса.
- Пример объявления:
operator[]
- Семантика: доступ к элементам массива или контейнера, например, в std::vector , std::map , std::array .
- Объявление: тип параметра может быть любым. Тип возвращаемого значения обычно является ссылкой на то, что хранится в контейнере. Часто оператор перегружается в двух версиях, константной и неконстантной:
operator!
- Семантика: отрицание в логическом смысле.
- Типичные объявление и реализация:
explicit operator bool
- Семантика: использования в логическом контексте. Чаще всего используется с умными указателями.
- Реализация:
operator&&, ||
- Семантика: логические «и», «или». Эти операторы определены только для встроенного логического типа и работают по «ленивому» принципу, то есть второй аргумент рассматривается, только если первый не определяет результат. При перегрузке это свойство теряется, поэтому перегружают эти операторы редко.
Унарный operator*
- Семантика: разыменовывание указателя. Обычно перегружается для классов с умными указателями и итераторами. Возвращает ссылку на то, куда указывает объект.
- Типичные объявление и реализация:
operator->
- Семантика: доступ к полю по указателю. Как и предыдущий, этот оператор перегружается для использования с умными указателями и итераторами. Если в коде встречается оператор -> , компилятор перенаправляет вызовы на operator-> , если возвращается результат пользовательского типа.
- Usual implementation:
operator->*
- Семантика: доступ к указателю-на-поле по указателю. Оператор берёт указатель на поле и применяет его к тому, на что указывает *this , то есть objPtr->*memPtr — это то же самое, что и (*objPtr).*memPtr . Используется очень редко.
- Возможная реализация:
Унарный operator&
- Семантика: адресный оператор. Этот оператор перегружают очень редко.
operator,
- Семантика: встроенный оператор «запятая», применённый к двум выражениям, выполняет их оба в порядке записи и возвращает значение второго из них. Перегружать его не рекомендуется.
operator
- Семантика: оператор побитовой инверсии. Один из наиболее редко используемых операторов.
Операторы приведения типов
- Семантика: позволяет скрытое или явное приведение объектов класса к другим типам.
- Объявление:
operator new, new[], delete, delete[]
Эти операторы полностью отличаются от всех вышеупомянутых, поскольку они не работают с пользовательскими типами. Их перегрузка весьма сложна, и поэтому не будет здесь рассматриваться.
Заключение
Основной мыслью является следующее: не стоит перегружать операторы только потому, что вы умеете это делать. Перегружайте их лишь в тех случаях, когда это выглядит естественным и необходимым. Но помните, что если вы перегрузите один оператор, то придётся перегружать и другие.