Перегрузка операторов в C++
Доброго времени суток!
Желание написать данную статью появилось после прочтения поста Перегрузка C++ операторов, потому что в нём не были раскрыты многие важные темы.
Самое главное, что необходимо помнить — перегрузка операторов, это всего лишь более удобный способ вызова функций, поэтому не стоит увлекаться перегрузкой операторов. Использовать её следует только тогда, когда это упростит написание кода. Но, не настолько, чтобы это затрудняло чтение. Ведь, как известно, код читается намного чаще, чем пишется. И не забывайте, что вам никогда не дадут перегрузить операторы в тандеме со встроенными типами, возможность перегрузки есть только для пользовательских типов/классов.
Синтаксис перегрузки
В данном случае, оператор оформлен как член класса, аргумент определяет значение, находящееся в правой части оператора. Вообще, существует два основных способа перегрузки операторов: глобальные функции, дружественные для класса, или подставляемые функции самого класса. Какой способ, для какого оператора лучше, рассмотрим в конце топика.
В большинстве случаев, операторы (кроме условных) возвращают объект, или ссылку на тип, к которому относятся его аргументы (если типы разные, то вы сами решаете как интерпретировать результат вычисления оператора).
Перегрузка унарных операторов
Рассмотрим примеры перегрузки унарных операторов для определенного выше класса Integer. Заодно определим их в виде дружественных функций и рассмотрим операторы декремента и инкремента:
Теперь вы знаете, как компилятор различает префиксные и постфиксные версии декремента и инкремента. В случае, когда он видит выражение ++i, то вызывается функция operator++(a). Если же он видит i++, то вызывается operator++(a, int). То есть вызывается перегруженная функция operator++, и именно для этого используется фиктивный параметр int в постфиксной версии.
Бинарные операторы
Рассмотрим синтаксис перегрузки бинарных операторов. Перегрузим один оператор, который возвращает l-значение, один условный оператор и один оператор, создающий новое значение (определим их глобально):
Во всех этих примерах операторы перегружаются для одного типа, однако, это необязательно. Можно, к примеру, перегрузить сложение нашего типа Integer и определенного по его подобию Float.
Аргументы и возвращаемые значения
Оптимизация возвращаемого значения
При создании новых объектов и возвращении их из функции следует использовать запись как для вышеописанного примера оператора бинарного плюса.
Честно говоря, не знаю, какая ситуация актуальна для C++11, все рассуждения далее справедливы для C++98.
На первый взгляд, это похоже на синтаксис создания временного объекта, то есть как будто бы нет разницы между кодом выше и этим:
Но на самом деле, в этом случае произойдет вызов конструктора в первой строке, далее вызов конструктора копирования, который скопирует объект, а далее, при раскрутке стека вызовется деструктор. При использовании первой записи компилятор изначально создаёт объект в памяти, в которую нужно его скопировать, таким образом экономится вызов конструктора копирования и деструктора.
Особые операторы
В C++ есть операторы, обладающие специфическим синтаксисом и способом перегрузки. Например оператор индексирования []. Он всегда определяется как член класса и, так как подразумевается поведение индексируемого объекта как массива, то ему следует возвращать ссылку.
Оператор запятая
В число «особых» операторов входит также оператор запятая. Он вызывается для объектов, рядом с которыми поставлена запятая (но он не вызывается в списках аргументов функций). Придумать осмысленный пример использования этого оператора не так-то просто. Хабраюзер AxisPod в комментариях к предыдущей статье о перегрузке рассказал об одном.
Оператор разыменования указателя
Перегрузка этих операторов может быть оправдана для классов умных указателей. Этот оператор обязательно определяется как функция класса, причём на него накладываются некоторые ограничения: он должен возвращать либо объект (или ссылку), либо указатель, позволяющий обратиться к объекту.
Оператор присваивания
Оператор присваивания обязательно определяется в виде функции класса, потому что он неразрывно связан с объектом, находящимся слева от «=». Определение оператора присваивания в глобальном виде сделало бы возможным переопределение стандартного поведения оператора «=». Пример:
Как можно заметить, в начале функции производится проверка на самоприсваивание. Вообще, в данном случае самоприсваивание безвредно, но ситуация не всегда такая простая. Например, если объект большой, можно потратить много времени на ненужное копирование, или при работе с указателями.
Неперегружаемые операторы
Некоторые операторы в C++ не перегружаются в принципе. По всей видимости, это сделано из соображений безопасности.
Перегрузка операторов
operator Ключевое слово объявляет функцию, указывающую, какой оператор-Symbol означает при применении к экземплярам класса. Это дает оператору более одного значения — «перегружает» его. Компилятор различает разные значения оператора, проверяя типы его операндов.
Синтаксис
тип operator operator-символ ( parameter-list )
Remarks
Функцию большинства встроенных операторов можно переопределить глобально или для отдельных классов. Перегруженные операторы реализуются в виде функции.
Имя перегруженного оператора — operator x, где x — это оператор, как показано в следующей таблице. Например, для перегрузки оператора сложения необходимо определить функцию с именем operator +. Аналогично, чтобы перегрузить оператор сложения и присваивания, += Определите функцию с именем operator + =.
Переопределяемые операторы
| Оператор | Имя | Тип |
|---|---|---|
| , | Запятая | Двоичные данные |
| ! | Логическое НЕ | Унарный |
| != | Неравенство | Двоичные данные |
| % | Modulus | Двоичные данные |
| %= | Назначение модуля | Двоичные данные |
| & | Побитовое И | Двоичные данные |
| & | Взятие адреса | Унарный |
| && | Логическое И | Двоичные данные |
| &= | Назначение побитового И | Двоичные данные |
| ( ) | Вызов функции | — |
| ( ) | Оператор приведения | Унарный |
| * | Умножение | Двоичные данные |
| * | Разыменование указателя | Унарный |
| *= | Присваивание умножения | Двоичные данные |
| + | Сложение | Двоичные данные |
| + | Унарный плюс | Унарный |
| ++ | Шаг 1 | Унарный |
| += | Присваивание сложения | Двоичные данные |
| — | Вычитание | Двоичные данные |
| — | Унарное отрицание | Унарный |
| — | Уменьшить 1 | Унарный |
| -= | Присваивание вычитания | Двоичные данные |
| -> | Выбор члена | Двоичные данные |
| — >* | Выбор указателя на член | Двоичные данные |
| / | Отдел | Двоичные данные |
| /= | Присваивание деления | Двоичные данные |
| Больше | Двоичные данные | |
| >= | Больше или равно | Двоичные данные |
| >> | Сдвиг вправо | Двоичные данные |
| >>= | Сдвиг вправо и присваивание | Двоичные данные |
| [ ] | Индекс массива | — |
| ^ | Исключающее ИЛИ | Двоичные данные |
| ^= | Исключающее ИЛИ/присваивание | Двоичные данные |
| | | Побитовое ИЛИ | Двоичные данные |
| |= | Назначение побитового включающего ИЛИ | Двоичные данные |
| || | Логическое ИЛИ | Двоичные данные |
| Дополнение до единицы | Унарный | |
| delete | Удаление | — |
| new | Создать | — |
| операторы преобразования | операторы преобразования | Унарный |
Существует 1 две версии унарных операторов инкремента и декремента: добавочное и инкрементное.
Общие правила перегрузки операторов
Нельзя определить новые операторы, например .
Не допускается переопределение операторов применительно ко встроенным типам данных.
Перегруженные операторы должны быть нестатической функцией-членом класса или глобальной функцией. Глобальная функция, которой требуется доступ к частным или защищенным членам класса, должна быть объявлена в качестве дружественной функции этого класса. Глобальная функция должна принимать хотя бы один аргумент, имеющий тип класса или перечисляемый тип либо являющийся ссылкой на тип класса или перечисляемый тип. Пример:
В предыдущем примере кода оператор «меньше чем» объявляется как функция-член; однако операторы сложения объявляются как глобальные функции, имеющие дружественный доступ. Обратите внимание, что для каждого оператора можно предоставить несколько реализаций. Выше для оператора сложения предоставлены две реализации, обеспечивающие его коммутативность. Это так же вероятно, что могут быть реализованы операторы, добавляющие в, в Point Point int Point и т. д.
Унарные операторы, объявленные как функции-члены, не принимают аргументов; при объявлении как глобальные функции они принимают один аргумент.
Бинарные операторы, объявленные как функции-члены, принимают один аргумент; при объявлении как глобальные функции они принимают два аргумента.
Перегруженные операторы не могут иметь аргументов по умолчанию.
Все перегруженные операторы, за исключением присваивания (operator =), наследуются производными классами.
Первым аргументов операторов, перегруженных в виде функций-членов, всегда является тип класса объекта, для которого вызывается этот оператор (класса, в котором объявлен оператор, или класса, производного от этого класса). Для первого аргумента никакие преобразования не предоставляются.
Для типов классов с перегруженными операторами на такую эквивалентность полагаться невозможно. Более того, некоторые из неявных требований, существующих при использовании этих операторов для базовых типов, для перегруженных операторов ослабляются. Например, оператор сложения/присваивания требует, += чтобы левый операнд был l-значением при применении к базовым типам. такое требование не предусмотрено, если оператор перегружен.
Для согласованности при определении перегруженных операторов рекомендуется следовать модели для встроенных типов. Если семантика перегруженного оператора существенно отличается от его значения в других контекстах, это может скорее запутывать ситуацию, чем приносить пользу.
Перегрузка операторов в C++. Основы
Авторизуйтесь
Перегрузка операторов в C++. Основы
В C++ этого ограничения нет — мы можем перегрузить практически любой известный оператор. Возможностей не счесть: можно выбрать любую комбинацию типов операндов, единственным ограничением является необходимость того, чтобы присутствовал как минимум один операнд пользовательского типа. То есть определить новый оператор над встроенными типами или переписать существующий нельзя.
Когда стоит перегружать операторы?
Приведём хороший и плохой примеры перегрузки операторов. Вышеупомянутое сложение матриц — наглядный случай. Здесь перегрузка оператора сложения интуитивно понятна и, при корректной реализации, не требует пояснений:
Примером плохой перегрузки оператора сложения будет сложение двух объектов типа «игрок» в игре. Что имел в виду создатель класса? Каким будет результат? Мы не знаем, что делает операция, и поэтому пользоваться этим оператором опасно.
Как перегружать операторы?
Большую часть операторов можно перегрузить как методами класса, так и простыми функциями, но есть несколько исключений. Когда перегруженный оператор является методом класса, тип первого операнда должен быть этим классом (всегда *this ), а второй должен быть объявлен в списке параметров. Кроме того, операторы-методы не статичны, за исключением операторов управления памятью.
При перегрузке оператора в методе класса он получает доступ к приватным полям класса, но скрытая конверсия первого аргумента недоступна. Поэтому бинарные функции обычно перегружают в виде свободных функций. Пример:
Когда унарные операторы перегружаются в виде свободных функций, им доступна скрытая конверсия аргумента, но этим обычно не пользуются. С другой стороны, это свойство необходимо бинарным операторам. Поэтому основным советом будет следующее:
Реализуйте унарные операторы и бинарные операторы типа “X=” в виде методов класса, а прочие бинарные операторы — в виде свободных функций.
Какие операторы можно перегружать?
Мы можем перегрузить почти любой оператор C++, учитывая следующие исключения и ограничения:
В следующей части вашему вниманию будут представлены перегружаемые операторы C++, в группах и по отдельности. Для каждого раздела характерна семантика, т.е. ожидаемое поведение. Кроме того, будут показаны типичные способы объявления и реализации операторов.
Перегрузка в C++. Часть II. Перегрузка операторов
Продолжаем серию «C++, копаем в глубь». Цель этой серии — рассказать максимально подробно о разных особенностях языка, возможно довольно специальных. Эта статья посвящена перегрузке операторов. Особое внимание уделено использованию перегруженных операторов в стандартной библиотеке. Это вторая статья из серии, первая, посвященная перегрузке функций и шаблонов, находится здесь. Следующая статья будет посвящена перегрузке операторов управления памятью.
Оглавление
Введение
1. Общие вопросы перегрузки операторов
1.1. Перегружаемые операторы
1.2. Общие правила при выборе перегружаемого оператора
Необходимо учитывать приоритет и ассоциативность операторов, они при перегрузке не меняются и должны соответствовать ожиданиям пользователя. Характерный пример — это использование оператора для вывода данных в поток. К сожалению, приоритет этого оператора довольно высок, поэтому скобками приходится пользоваться чаще, чем хотелось бы. Например
1.3. Операторы, не рекомендуемые для перегрузки
1.4. Интерфейс и семантика перегруженных операторов
должны возвращать модифицированное значение и не изменять операнд. Если реализация оператора возвращает объект по значению, то его часто объявляют константным. Это предотвращает модификацию возвращаемого значения, что позволяет предотвратить ряд синтаксических странностей, которых нет при использовании встроенных операторов (подробнее см. [Sutter1]). Но если возвращаемый тип является перемещаемым, то его нельзя объявлять константным, так как это ломает всю семантику перемещения. Другие примеры будут рассмотрены далее.
1.5. Реализация перегрузки операторов
1.5.1. Два варианта реализации перегрузки операторов
Среди операторов, которые можно перегружать двумя способами, унарные операторы и присваивающие версии бинарных операторов обычно перегружают как функцию-член, а оставшиеся бинарные операторы как свободные функции.
1.5.2. Две формы использования перегруженных операторов
Использовать перегруженный оператор можно в двух формах (нотациях): инфиксной и функциональной. Инфиксная форма как раз и есть привычный синтаксис использования операторов.
Вот пример для класса из предыдущего раздела (будем считать, что код находится вне пространства имен N ):
Обратим внимание на то, что при использовании перегруженных операторов работает поиск, зависимый от типа аргумента (argument depended lookup, ADL), без него это использование, особенно в инфиксной форме, было бы весьма неудобно в случае, когда класс, для которого перегружается оператор, находится в другом пространстве имен. Вполне возможно, что ADL и появился в основном для решения этой проблемы.
1.5.3. Одновременное использование двух вариантов реализации перегрузки
Оператор, для которого возможна реализация в виде свободной функции, может быть перегружен одновременно как функция-член и как свободная функция. В этом случае при использовании инфиксной формы может возникнуть неоднозначность. Конечно, если такие перегрузки различаются параметрами, то компилятор сможет сделать выбор по типу аргументов. Но при одинаковых параметрах возникнет ошибка. Понятно, что подобной ситуации лучше избегать. Но если такое случилось, то помочь сможет только функциональная форма.
2. Дополнительные подробности реализации перегрузки операторов
2.1. Множественная перегрузка
Один и тот же оператор можно перегрузить несколько раз. Для унарных операторов может быть всего два варианта — с квалификатором const и без него (для функций-членов), или варианты с параметром типа константная ссылка или обычная ссылка (для свободных функций). Для бинарных операторов и оператора () количество перегрузок не ограничено.
Бинарные операторы и оператор () могут быть шаблонами, что по существу является множественной перегрузкой.
2.2. Особенности перегрузки операторов с использованием свободных функций
Рассмотрим несколько ситуаций, когда перегрузка операторов с использованием свободных функций предпочтительней или, вообще, безальтернативна.
2.2.1. Симметрия
2.2.2. Расширение интерфейса класса
Перегрузка бинарных операторов с использованием свободных функций позволяет расширять интерфейс класса без добавления новых функций-членов. (Напомним, что интерфейс класса включает не только функции-члены, но и свободные функции с параметрами тип которых определяется этим классом.) В качестве примера можно привести перегрузку операторов вставки и извлечения из потока. Если бы мы для перегрузки этих операторов использовали функции-члены, то нам бы пришлось для каждого нового типа, вставляемого в поток или извлекаемого из потока, добавлять в потоковые классы соответствующие функции-члены, что понятное дело невозможно. Подробнее про перегрузку операторов вставки и извлечения из потока см. раздел 3.8.
2.2.3. Неявные преобразования
2.2.4. Перечисления
Для перечислений операторы можно перегружать только как свободные функции, так как у перечислений просто не может быть функций-членов, пример см. в разделе 2.6.
2.3. Определение дружественной свободной функции внутри класса
Часто свободным функциям, реализующим оператор, целесообразно иметь доступ к закрытым членам класса и поэтому их объявляют дружественными. Напомним, что синтаксис дружественных функций позволяет разместить их определение непосредственно в теле класса.
Подробнее см. [Meyers1].
2.4. Вычислительные конструкторы
Если оператор возвращает объект по значению, иногда целесообразно определить специальный закрытый конструктор, называемый вычислительным конструктором (computational constructor). В этом случае компилятор сможет применить оптимизацию возвращаемого значения (return value optimization, RVO). Подробнее см. [Dewhurst].
2.5. Виртуальные операторы
2.6. Перегрузка операторов для перечислений
Операторы, перегружаемые как свободная функция, можно перегрузить для перечислений. Вот пример:
Теперь перебрать все элементы перечисления можно так:
Перегрузим еще один оператор
Теперь перебрать все элементы перечисления можно с помощью стандартного алгоритма:
И еще один вариант. Определим класс:
После этого перебрать все элементы перечисления можно с помощью диапазонного for :
3. Особенности перегрузки некоторых операторов
В этом разделе описываются особенности перегрузки некоторых операторов, особое внимание уделяется использованию этих перегрузок в стандартной библиотеке.
3.2. Унарный оператор *
В стандартной библиотеке оператор * перегружен для интеллектуальных указателей и итераторов.
3.3. Оператор []
Индексатор часто перегружают в двух вариантах — константном и неконстантном.
Первая версия позволяет модифицировать элемент, вторая только прочитать и она будет выбрана для константных экземпляров и в константных функциях-членах.
3.3.1. Многомерные массивы
3.4. Оператор ()
3.4.1. Локальные определения и лямбда-выражения
В C++ нельзя определить функцию локально (в блоке). Но можно определить локальный класс и этот класс может быть функциональным. Столь популярные в народе лямбда-выражения как раз и представляют из себя средство для быстрого и удобного определения анонимного локального функционального класса на «на лету».
3.4.2. Мультифункциональные типы и объекты
3.4.3. Хеш-функция
В Приложении Б приводится пример решения для C-строк на основе полной специализации стандартного шаблона.
3.4.4. Сравнение элементов и ключей в контейнерах
Если для использования некоторого типа в контейнере стандартной библиотеки требуется изменить или определить сравнение элементов этого типа, то существует три способа решить эту проблему.
В Приложении Б приводится пример решения для C-строк на основе полной специализации стандартного шаблона.
3.4.5. Удалители в интеллектуальных указателях
3.4.6. Алгоритмы
Алгоритмы стандартной библиотеки активно используют функциональные объекты и, соответственно, многие из них имеют параметр функционального типа. Часто алгоритмы имеют версию без такого параметра, в этом случае для реализации необходимых операций используется оператор (встроенный или перегруженный), определенный для элементов диапазона.
Если для использования некоторого типа в алгоритме стандартной библиотеки требуется изменить или определить необходимые операции для элементов этого типа, то существует два способа решить эту проблему.
Пример для алгоритма сортировки C-строк приведен в Приложение Б.
3.4.7. Функциональный шаблон
В C++11 появился универсальный функциональный шаблон. Он конкретизируется типом функции и перегружает оператор () в соответствии с сигнатурой функции. Экземпляры конкретизации можно инициализировать указателем на функцию, функциональным объектом или лямбда-выражением с соответствующей сигнатурой. Вот пример.
3.5. Операторы сравнения
3.6. Арифметические операторы
В бинарных операторах тип операндов может не совпадать. Например для строк один из операндов может быть C-строкой, для итераторов произвольного доступа второй операнд является сдвигом. Но в таком случае надо подумать о симметрии (см. раздел 2.2).
3.7. Инкремент, декремент
Эти операторы являются частью стандартного интерфейса итератора. Префиксные формы являются унарными операторами, постфиксные бинарными с фиктивным вторым параметром целого типа. Обе они обычно реализуются как функции-члены и постфиксный вариант определяется через префиксный. Вот типичная реализация инкремента.
Итераторы являются копируемыми типами без поддержки перемещения, поэтому постфиксный инкремент должен возвращать константный объект, это предотвращает модификацию возвращаемого значения, см. раздел 1.4.
В стандартной библиотеке инкремент перегружают все итераторы, а декремент двунаправленные итераторы и итераторы произвольного доступа.
3.8. Операторы >
Перегрузка этих операторов используется в стандартной библиотеке для вставки объектов в текстовой поток и извлечения объектов из текстового потока (поэтому в этом качестве их еще называют оператором вставки в поток и оператором извлечения из потока). Перегружаются они всегда как свободные функции, их сигнатура подчиняется правилам: первый операнд является ссылкой на поток, второй операнд является ссылкой на вставляемый или извлекаемый объект, возвращаемое значение является ссылкой на поток. Вот пример.
3.9. Оператор присваивания
Оператор присваивания можно реализовать только, как функцию-член, которая должна иметь ровно один параметр. Тип этого параметра произвольный, соответственно, перегрузок может быть несколько, для разных типов параметра. Перегрузка оператора присваивания является составной частью поддержки семантики копирования/перемещения и к ней приходится прибегать достаточно часто. Оператор присваивания практически всегда идет в паре с конструктором, имеющим один параметр. Нормальная ситуация — это когда каждому конструктору с одним параметром прилагается соответствующий оператор присваивания. Если описать семантику присваивания «на пальцах», то присваивание должно полностью освободить все текущие ресурсы, которыми владеет объект (левый операнд), и на его месте создать новый объект, определяемый правым операндом.
Среди операторов присваивания выделяются два стандартных — оператор копирующего присваивания и оператор перемещающего присваивания, которые соответствуют копирующему конструктору и перемещающему конструктору.
Компилятор может сгенерировать стандартные операторы присваивания и без такой подсказки. Если это не желательно, то можно явно запретить такую генерацию, объявив эти операторы удаленными.
И тогда операторы присваивания реализуются с помощью соответствующего конструктора и функции обмена состояниями следующим образом:
Аналогично можно определить оператор присваивания, соответствующий любому другому конструктору с одним параметром.
Главное достоинства этой идиомы состоит в обеспечении строгой гарантии безопасности исключений: если в конструкторе произошло исключение, то объект останется в том же состоянии, что и до начала операции (транзакционная семантика).
Если идиома «копирование и обмен» не используется, то необходима проверка на самоприсваивание.
Также, в случае наследования, надо вызвать соответствующий оператор базового класса. Еще одно достоинство идиомы «копирование и обмен» как раз и состоит в том, что она корректно работает при самоприсваивании, хотя, конечно, и не оптимально.
Ну и, наконец, рассмотрим довольно известную антиидиому для реализации присваивания.
X() уничтожает объект производного класса, что может полностью сломать взаимодействие базового класса и производного. Никогда так не делайте.
Оператор копирующего присваивания и оператор перемещающего присваивания (вместе с соответствующим конструктором) приходится перегружать практически всегда, когда нужна нестандартная семантика копирования/перемещения. (Запрет копирующего или перемещающего присваивания также можно рассматривать как перегрузку.) Также оператор присваивания обычно перегружается, как парный для конструктора с одним параметром. Практически все классы стандартной библиотеки перегружают операторы присваивания.
4. Итоги
Тщательно продумывайте перегрузку операторов. Она должна повысить наглядность и читаемость кода, но не наоборот.
При реализации перегрузки оператора учитывайте интерфейс и семантику встроенного оператора.
Приложения
Приложение А. Пример использования мультифункциональных объектов
BinOper — это функциональный тип, совместимой с сигнатурой
Ключевое отличие BinOper от аналогичного в std::accumulate() — это то, что BinOper должен поддерживать несколько сигнатур:
Приложение Б. Хэш-функция и сравнение для C-строк
Функция hash_combine() — это хорошо известная функция из библиотеки Boost. Она может быть использована при создании других пользовательских хеш-функций.
Ну и, наконец, пример сортировки C-строк в котором используется лямбда-выражение для определения нужного функционального объекта.
Список литературы
[Josuttis]
Джосаттис, Николаи М. Стандартная библиотека C++: справочное руководство, 2-е изд.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2014.
[Dewhurst]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.
[Meyers1]
Мэйерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.
[Sutter1]
Саттер, Герб. Решение сложных задач на C++.: Пер. с англ. — М: ООО «И.Д. Вильямс», 2015.




