Перейти к содержимому

Что такое перегрузка в программировании

  • автор:

Что такое перегрузка в программировании

Перегрузка операторов (operator overloading) позволяет определить для объектов классов втроенные операторы, такие как +, -, * и т.д. Для определения оператора для объектов своего класса, необходимо определить функцию, название которой содержит слово operator и символ перегружаемого оператора. Функция оператора может быть определена как член класса, либо вне класса.

Перегрузить можно только те операторы, которые уже определены в C++. Создать новые операторы нельзя. Также нельзя изменить количество операндов, их ассоциативность, приоритет.

Если функция оператора определена как отдельная функция и не является членом класса, то количество параметров такой функции совпадает с количеством операндов оператора. Например, у функции, которая представляет унарный оператор, будет один параметр, а у функции, которая представляет бинарный оператор, — два параметра. Если оператор принимает два операнда, то первый операнд передается первому параметру функции, а второй операнд — второму параметру. При этом как минимум один из параметров должен представлять тип класса.

Формальное определение операторов в виде функций-членов класса:

// бинарный оператор ReturnType operator Op(Type right_operand); // унарный оператор ClassType& operator Op();

Формальное определение операторов в виде функций, которые не являются членами класса:

// бинарный оператор ReturnType operator Op(const ClassType& left_operand, Type right_operand); // альтернативное определение, где класс, для которого создается оператор, представляет правый операнд ReturnType operator Op(Type left_operand, const ClassType& right_operand); // унарный оператор ClassType& operator Op(ClassType& obj);

Здесь ClassType представляет тип, для которого определяется оператор. Type — тип другого операнда, который может совпадать, а может и не совпадать с первым. ReturnType — тип возвращаемого результата, который также может совпадать с одним из типов операндов, а может и отличаться. Op — сама операция.

Рассмотрим пример с классом Counter, который хранит некоторое число:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout Counter operator + (const Counter& counter) const < return Counter; > private: int value; >; int main() < Counter c1; Counter c2; Counter c3 = c1 + c2; c3.print(); // Value: 30 >

Здесь в классе Counter определен оператор сложения, цель которого сложить два объекта Counter:

Counter operator + (const Counter& counter) const < return Counter; >

Текущий объект будет представлять левый операнд операции. Объект, который передается в функцию через параметр counter, будет представлять правый операнд операции. Здесь параметр функции определен как константная ссылка, но это необязательно. Также функция оператора определена как константная, но это тоже не обязательно.

Результатом оператора сложения является новый объект Counter, в котором значение value равно сумме значений value обоих операндов.

После опеределения оператора можно складывать два объекта Counter:

Counter c1; Counter c2; Counter c3 ; c3.print(); // Value: 30

Подобным образом можно определить функцию оператора вне класса:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout int value; // к приватным переменным внешняя функция оператора не может обращаться >; // определяем оператор сложения вне класса Counter operator + (const Counter& c1, const Counter& c2) < return Counter; > int main() < Counter c1; Counter c2; Counter c3 ; c3.print(); // Value: 30 >

Если бинарный оператор определяется в виде внешней функции, как здесь, то он принимает два параметра. Первый параметр будет представлять левый операнд операции, а второй параметр — правый операнд.

Но по сравнению с предыдущим кодом здесь сделано еще пару изменений. Во-первых, внешняя функция естественно не может обращаться к приватным полям класса, поэтому для доступа к ним придется создавать отдельные функции, которые бы возвращали значения полей. Я для простоты просто сделал переменную value публичной. Другим решением в данном случае могло быть определение дружественной функции оператора. Второй момент — внешние функции оператора не могут быть константными. Поэтому гораздо определение операторов внутри класса имеет некоторые преимущества.

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

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout Counter operator + (const Counter& counter) const < return Counter; > int operator + (int number) const < return value + number; >private: int value; >; int main() < Counter counter; int number = counter + 30; std::cout 

Здесь определена вторая версия оператора сложения, которая складывает объект Counter с числом и возвращает также число. Поэтому левый операнд операции должен представлять тип Counter, а правый операнд - тип int.

Какие операторы где переопределять? Операторы присвоения, индексирования ([]), вызова (()), доступа к члену класса по указателю (->) следует определять в виде функций-членов класса. Операторы, которые изменяют состояние объекта или непосредственно связаны с объектом (инкремент, декремент), обычно также определяются в виде функций-членов класса. Операторы выделения и удаления памяти ( new new[] delete delete[] ) определяются только в виде функций, которые не являются членами класса. Все остальные операторы можно определять как отдельные функции, а не члены класса.

Операторы сравнения

Результатом операторов сравнения ( == , != , < , >), как правило, является значение типа bool . Например, перегрузим данные операторы для типа Counter:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout bool operator == (const Counter& counter) const < return value == counter.value; >bool operator != (const Counter& counter) const < return value != counter.value; >bool operator > (const Counter& counter) const < return value >counter.value; > bool operator < (const Counter& counter) const < return value < counter.value; >private: int value; >; int main() < Counter c1(20); Counter c2(10); bool b1 = c1 == c2; // false bool b2 = c1 >c2; // true std::cout c2 b">default:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout bool operator == (const Counter& counter) const = default; bool operator != (const Counter& counter) const = default; private: int value; >; int main() < Counter c1(20); Counter c2(10); bool b1 = c1 == c2; // false bool b2 = c1 != c2; // true std::cout bool operator == (const Counter& counter) const = default;

По умолчанию будут сравниваться все поля класса, для которых определен оператор ==. Если значения всех полей будут равны, то оператор возвратить true

Операторы присвоения

Оператор присвоения обычно возвращает ссылку на свой левый операнд:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout // оператор присвоения Counter& operator += (const Counter& counter) < value += counter.value; return *this; // возвращаем ссылку на текущий объект >private: int value; >; int main() < Counter c1; Counter c2; c1 += c2; c1.print(); // Value: 70 >

Унарные операции

Унарные операции обычно возвращают новый объект, созданный на основе имеющегося. Например, возьмем операцию унарного минуса:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout // оператор унарного минуса Counter operator - () const < return Counter; > private: int value; >; int main() < Counter c1; Counter c2 = -c1; // применяем оператор унарного минуса c2.print(); // Value: -20 >

Здесь операция унарного минуса возвращает новый объект Counter, значение value в котором фактически равно значению value текущего объекта, умноженного на -1.

Операции инкремента и декремента

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

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout // префиксные операторы Counter& operator++ () < value += 1; return *this; >Counter& operator-- () < value -= 1; return *this; >// постфиксные операторы Counter operator++ (int) < Counter copy ; ++(*this); return copy; > Counter operator-- (int) < Counter copy ; --(*this); return copy; > private: int value; >; int main() < Counter c1; Counter c2 = c1++; c2.print(); // Value: 20 c1.print(); // Value: 21 --c1; c1.print(); // Value: 20 >

Префиксные операторы должны возвращать ссылку на текущий объект, который можно получить с помощью указателя this:

Counter& operator++ ()

В самой функции можно определить некоторую логику по инкременту значения. В данном случае значение value увеличивается на 1.

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

Counter operator++ (int) < Counter copy ; ++(*this); return copy; >

Чтобы постфиксная форма отличалась от префиксной постфиксные версии получают дополнительный параметр типа int, который не используется. Хотя в принципе мы можем его использовать.

Переопределение оператора

#include class Counter < public: Counter(int val) < value =val; >int getValue()const private: int value; >; std::ostream& operator <<(std::ostream& stream, const Counter& counter) < stream << "Value: "; stream << counter.getValue(); return stream; >int main() < Counter counter1; Counter counter2; std::cout 

Стандартный выходной поток cout имеет тип std::ostream . Поэтому первый параметр (левый операнд) представляет объект ostream , а второй (правый операнд) - выводимый объект Counter. Поскольку мы не можем изменить стандартное определение std::ostream, поэтому определяем функцию оператора, которая не является членом класса.

std::ostream& operator

В данном случае для выводим значение переменной value. Для получения значения value извне класса Counter я добавил функцию getValue() .

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

После определения функции оператора можно выводить на консоль объекты Counter:

Counter counter1; std::cout 

Выражение одних операторов через другие

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

#include class Counter < public: Counter(int n) < value = n; >void print() const < std::cout Counter& operator+=(const Counter& counter) < value += counter.value; return *this; >; Counter& operator+(const Counter& counter) < Counter copy< value >; // копируем данные текущего объекта return copy += counter; >; private: int value; >; int main() < Counter counter1; Counter counter2; counter1 += counter2; counter1.print(); // value: 30 Counter counter3 ; counter3.print(); // value: 40 >

Здесь вначале реализован оператор сложения с присвоением +=:

Counter& operator+=(const Counter& counter) < value += counter.value; return *this; >;

В функции оператора сложения мы создаем копию текущего объекта и к этой копии и аргументу применяем оператор +=:

Counter& operator+(const Counter& counter) < Counter copy< value >; // копируем данные текущего объекта return copy += counter; >;

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

Что такое перегрузка операторов

В статье про С++ мы упоминали перегрузку операторов. Это мощный и гибкий инструмент, который может оказаться опасным и непредсказуемым в неумелых руках. Настало время разобраться.

�� Опытным программистам: мы намеренно упростим детали для понимания сути. Ну сорян.

На примере сложения

Во всех языках есть оператор «плюс» — обычно он умеет складывать числа и соединять строки:

‘два’ + ‘два’ = ‘двадва’

‘четы’ + ‘ре’ = ‘четыре’

Допустим, мы пишем софт для интернет-магазина, и у нас есть там класс объектов «заказ». Напомним, что класс — это как бы чертёж, по которому создаются объекты. А объект — это такая коробка с данными и функциями, которыми мы можем управлять как единым целым. Подробнее об этом — в статьях про объекты и классы.

В объекте типа «заказ» лежит куча всего:

  • массив с содержимым корзины,
  • дата и время, когда сформирован заказ,
  • метод «очистить корзину»,
  • место для промокода,
  • метод «применить промокод»,
  • метод «проверить наличие товаров по складу»
  • идентификатор пользователя,
  • что-нибудь ещё интересное.

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

  • Если идентификатор постоянный, значит, мы однозначно пользователя узнали (его адрес, номер кредитки и т. д.).
  • Если идентификатор временный, значит, мы не знаем, что за пользователь — просто храним его корзину, пока он не оформит заказ. Это может быть новый человек или старый, но ещё не залогинившийся. В любом случае мы должны хранить его данные.

В какой-то момент пользователь с временным идентификатором логинится в систему, и нам хочется сделать следующую операцию:

Обе части выражения — это объекты класса «Заказ». А наш язык программирования не знает, что значит «сложить два объекта класса „Заказ“». Он не знает:

  • Что с чем складывать? Число товаров? Суммы? Номера товаров? Номера телефонов? Ведь язык не понимает, что за объект перед ним. Для него это просто коробка с данными, он может с ними делать что хочешь.
  • Что возвращать? Число? Строку? Объект? Список заказов?
  • Может быть, нужно сравнить два заказа и по каким-то критериям определить самый актуальный?
  • Или нужно объединить две корзины в одну?
  • А что тогда делать с повторяющимися товарами? Заменить? Добавить в количество? Проигнорировать?

Вроде бы простая операция — а столько вопросов. Вот этому всему мы можем обучить оператор «+», и это будет перегрузка оператора.

�� Короче

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

В случае с нашим примером мы можем сказать, что если складываются два заказа, делай следующее:

  1. Найди, какой из этих заказов постоянный.
  2. Переложи из временного в постоянный все уникальные товары.
  3. Если есть неуникальные товары (например, и в том, и в другом заказе была одна и та же позиция), склей их и поставь максимальное количество. Например, если в постоянном заказе стояло 3 штуки одного артикула, а во временном этого же артикула 9 штук, то поставь в постоянный 9 штук.
  4. Временному заказу поставь статус «Склеено».
  5. Залогируй время склейки заказов.
  6. Верни постоянный заказ с обновлёнными данными.

Довольно много действий для одного плюса, не находите?

Что хорошего в перегрузке

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

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

Чем опасна перегрузка операторов

Когда вы используете в коде перегруженный оператор, он выглядит как самый обычный оператор. Ну складывает и складывает. Ну умножает и умножает, чего такого-то?

Это, с одной стороны, элегантно. А с другой, создаёт проблемы в отладке.

  1. Представьте, что после вас какой-то программист переделал структуру класса «Заказ», и теперь там по-другому работает массив с товарными позициями. Раньше у товаров были числовые идентификаторы типа integer (целые числа), а новый программист переделал их на строки.
  2. Ваш язык в неявном виде поддерживает сравнение чисел со строками и наоборот. Он производит какие-то свои преобразования и позволяет сравнить число со строкой. В 99,9% случаев это не сломает вашу программу, и даже перегруженный оператор будет работать.
  3. Но в 0,1% случаев сравнение случится некорректно, и никто не будет понимать, в чём дело. Где-то под капотом перегруженный оператор некорректно склеивает списки покупок, у пользователя вываливаются какие-то «левые» товары, которых он не заказывал. Он не глядя их оплачивает и потом катает жалобу на ваш магазин.

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

И что?

Перегрузка операторов — это полезно, но сложно.

Если программист не понимает полностью механизма работы перегрузок, лучше не перегружать.

Если понимает — он молодец и может учить стандартные инструменты нестандартному поведению.

Не перегружайтесь, берегите себя.

Получите ИТ-профессию

В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.

Перегрузка

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

Если для вызываемой функции нет точного соответствия, то компилятор производит поиск подходящей функции по трем уровням последовательно:

  1. поиск среди методов класса;
  2. поиск среди методов базовых классов, последовательно от ближайшего предка до самого первого;
  3. поиск среди остальных функций.

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

Перегрузка в C++. Часть II. Перегрузка операторов

Продолжаем серию «C++, копаем вглубь». Цель этой серии — рассказать максимально подробно о разных особенностях языка, возможно довольно специальных. Эта статья посвящена перегрузке операторов. Особое внимание уделено использованию перегруженных операторов в стандартной библиотеке. Это вторая статья из серии, первая, посвященная перегрузке функций и шаблонов, находится здесь. Следующая статья будет посвящена перегрузке операторов управления памятью.

Оглавление

Оглавление

Введение

Перегрузка операторов (operator overloading) — это возможность применять встроенные операторы языка к разным типам, в том числе и пользовательским. На самом деле, это достаточно старая идея. Уже в первых языках программирования символы арифметических операций: + , - , etc. использовались для операций над целыми и вещественными числами, несмотря на то, что они имеют разный размер и разное внутреннее представление и, соответственно, эти операции реализованы по разному. С появлением объектно-ориентированных языков эта идея получила дальнейшее развитие. Если операции над пользовательскими типами имеют сходную семантику с операциями над встроенными типами, то почему бы не использовать синтаксис встроенных операторов. Это может повысить читаемость кода, сделать его более лаконичным и выразительным, упростить написание обобщенного кода. В C++ перегрузка операторов имеет серьезную поддержку и активно используется в стандартной библиотеке.

1. Общие вопросы перегрузки операторов

1.1. Перегружаемые операторы

В C++17 стандарт разрешает перегружать следующие операторы: + , - , * , / , % , ^ , & , | , ~ , ! , , , = , < , >, = , ++ , –- , > , == , != , && , || , += , -= , /= , %= , ^= , &= , |= , *= , <>= , [] , () , -> , ->* , new , new[] , delete , delete[] .
(Обратим внимание на то, что этот список не менялся с C++98.) Последние четыре оператора, связанные с распределением памяти, в данной статье не рассматриваются, эта довольно специальная тема будет рассмотрена в следующей статье. Остальные операторы можно разделить на унарные, бинарные и оператор () , который может иметь произвольное число параметров. Операторы + , - , * , & , ++ , –- имеют два варианта (иногда семантически совершенно разных) — унарный и бинарный, так, что фактически перегружаемых операторов на 6 больше.

1.2. Общие правила при выборе перегружаемого оператора

При перегрузке операторов надо стараться, чтобы смысл перегруженного оператора был очевиден для пользователя. Хороший пример перегрузки в этом смысле — это использование операторов + и += для конкатенации экземпляров std::basic_string<> . Оригинальное решение используется в классе std::filesystem::path (C++17). В этом классе операторы / и /= перегружены для конкатенации элементов пути. Конечно к делению это никакого отношения не имеет, но зато этот символ оператора совпадает с традиционным разделителем элементов пути. Запоминается с первого раза.

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

std::сout
(std::сout<
std::сout

Проблема усугубляется наличием неявного преобразования от std::сout к void* , из-за чего эти инструкции компилируются без ошибок и предупреждений. По-хорошему, приоритет оператора записи данных в поток должен быть очень низким, на уровне оператора присваивания. Например оператор += подошел бы по смыслу и приоритету, но, увы, он правоассоциативный, а для вывода в поток нужен левоассоциативный оператор.

1.3. Операторы, не рекомендуемые для перегрузки

Не рекомендуется перегружать следующие три бинарных оператора: , (запятая), && , || . Дело в том, что для них стандарт предусматривает порядок вычисления операндов (слева направо), а для последних двух еще и так называемую семантику быстрых вычислений (short-circuit evaluation), но для перегруженных операторов это уже не гарантируется или просто бессмысленно, что может оказаться весьма неприятной неожиданностью для программиста. (Семантика быстрых вычислений, называемая еще закорачиванием, заключается в том, для оператора && второй операнд не вычисляется, если первый равен false , а для оператора || второй операнд не вычисляется, если первый равен true .)

Также не рекомендуется перегружать унарный оператор & (взятие адреса). Тип с перегруженным оператором & опасно использовать с шаблонами, так как они могут использовать стандартную семантику этого оператора. Правда в С++11 появилась стандартная функция (точнее шаблон функции) std::addressof() , которая умеет получать адрес без оператора & и правильно написанные шаблоны должны использовать именно эту функцию вместо встроенного оператора.

1.4. Интерфейс и семантика перегруженных операторов

Стандарт регламентирует не все детали реализации перегруженных операторов. При реализации почти всегда можно произвольно выбирать тип возвращаемого значения, для бинарных операторов тип одного из параметров. Тем не менее, весьма желательно, чтобы перегруженные операторы максимально близко воспроизводили интерфейс и семантику соответствующих встроенных операторов. В этом случае поведение кода, использующего перегруженные операторы, было бы максимально похожим на поведение кода, использующего встроенные операторы. Например, оператор присваивания должен возвращать ссылку на левый операнд, которая может быть использована как правый операнд в другом присваивании. В этом случае становятся допустимыми привычные выражения типа a=b=c . Операторы сравнения должны возвращать bool и не изменять операнды. Унарные операторы + , - , ~ должны возвращать модифицированное значение и не изменять операнд. Если реализация оператора возвращает объект по значению, то его часто объявляют константным. Это предотвращает модификацию возвращаемого значения, что позволяет предотвратить ряд синтаксических странностей, которых нет при использовании встроенных операторов (подробнее см. [Sutter1]). Но если возвращаемый тип является перемещаемым, то его нельзя объявлять константным, так как это ломает всю семантику перемещения. Другие примеры будут рассмотрены далее.

1.5. Реализация перегрузки операторов

1.5.1. Два варианта реализации перегрузки операторов

Операторы можно перегружать в двух вариантах: как функцию-член и как свободную (не-член) функцию. Четыре оператора можно перегрузить только как функцию-член — это = , -> , [] , () . Для перечислений операторы можно перегружать только как свободные функции.

Для того, чтобы перегрузить оператор как функцию-член необходимо объявить нестатическую функцию-член с именем operator@ , где @ символ(ы) оператора. В случае перегрузки унарного оператора эта функция не должна иметь параметров, а в случае бинарного должна иметь ровно один параметр. В случае перегрузки оператора () эта функция может иметь произвольное число параметров.

Для того, чтобы перегрузить оператор как свободную (не-член) функцию, необходимо объявить функцию с именем operator@ , где @ символ(ы) оператора. В случае перегрузки унарного оператора, эта функция должна иметь один параметр, а в случае бинарного должна иметь два параметра. В случае перегрузки бинарного оператора — по крайней мере один из двух параметров, а в случае унарного единственный параметр должен быть того же типа (или типа ссылки), что и тип, для которого реализуется перегрузка. Так же эта функция должна находится в том же пространстве имен, что и тип, для которого реализуется перегрузка. Вот пример:

namespace N < class X < // . X operator+() const; // унарный плюс X operator+(const X& x) const; // бинарный плюс void operator()(int x, int y); // вызов функции char operator[](int i); // индексатор >; X operator-(const X& x); // унарный минус X operator-(const X& x, const X& y); // бинарный минус >

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

1.5.2. Две формы использования перегруженных операторов

Использовать перегруженный оператор можно в двух формах (нотациях): инфиксной и функциональной. Инфиксная форма как раз и есть привычный синтаксис использования операторов.

Вот пример для класса из предыдущего раздела (будем считать, что код находится вне пространства имен N ):

N::X x, y; // инфиксная форма N::X z = x + y; N::X v = x – y; N::X w = +x; N::X u = -x; x(1,2); char p = x[4]; // функциональная форма N::X z = x.operator+(y); N::X v = operator-(x, y); N::X w = x.operator+(); N::X u = operator-(x); x.operator()(1,2); char p = x.operator[](4);

Инфиксная форма, конечно, используется чаще, собственно ради нее и реализована перегрузка операторов. В ряде случаев, например, в контейнерах или в алгоритмах используется только инфиксная нотация. Но иногда удобно (и даже необходимо) использовать и функциональную форму, например для устранения неоднозначности. (Типичный пример — это вызов соответствующего оператора базового класса.) Особенности функциональной формы для оператора -> будут рассмотрены далее, в разделе, посвященном этому оператору.

Обратим внимание на то, что при использовании перегруженных операторов работает поиск, зависимый от типа аргумента (argument depended lookup, ADL), без него это использование, особенно в инфиксной форме, было бы весьма неудобно в случае, когда класс, для которого перегружается оператор, находится в другом пространстве имен. Вполне возможно, что ADL и появился в основном для решения этой проблемы.

1.5.3. Одновременное использование двух вариантов реализации перегрузки

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

2. Дополнительные подробности реализации перегрузки операторов

2.1. Множественная перегрузка

Один и тот же оператор можно перегрузить несколько раз. Для унарных операторов может быть всего два варианта — с квалификатором const и без него (для функций-членов), или варианты с параметром типа константная ссылка или обычная ссылка (для свободных функций). Для бинарных операторов и оператора () количество перегрузок не ограничено.

Например для std::string бинарный оператор + перегружен несколько раз: в одной версии оба параметра имеет тип const std::string& , в других один из параметров имеет тип const char* .

В разделе 3.4.2 рассматривается множественная перегрузка оператора () .

Бинарные операторы и оператор () могут быть шаблонами, что по существу является множественной перегрузкой.

2.2. Особенности перегрузки операторов с использованием свободных функций

Рассмотрим несколько ситуаций, когда перегрузка операторов с использованием свободных функций предпочтительней или, вообще, безальтернативна.

2.2.1. Симметрия

Одна из причин по которой для бинарных операторов свободные функции могут оказаться предпочтительными — это симметрия. Часто желательно, чтобы если корректным выражением является x@y , то корректным выражением было бы и y@x для любых допустимых типов. Для свободных функций мы можем выбирать произвольный тип первого операнда, когда как в случае функции-члена мы этого лишены. В качестве примера можно привести оператор + для std::string , когда один из операндов имеет тип const char* .

2.2.2. Расширение интерфейса класса

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

2.2.3. Неявные преобразования

Еще одна причина по которой перегрузка с использованием свободных функций может оказаться предпочтительней — это неявные преобразования. Если оператор перегружен для некоторого класса X с использованием свободных функций, то этот оператор автоматически становится перегруженным для любого класса, имеющего неявное преобразование к X . Это справедливо как для унарных, так и для бинарных операторов. Для операторов, перегруженных как функция-член, это не всегда так. Вот пример.

int i = 42; std::reference_wrapper rwi(i); std::cout 

Операторы вставки и извлечения из потока не перегружены для std::reference_wrapper , но этот класс имеет неявное преобразование к int& , поэтому приведенный код компилируется и выполняется. Правда проблемы могут возникнуть, если перегруженный оператор является шаблоном, так как при конкретизации шаблона функции неявные преобразования не используются. В этом случае может помочь прием с определением оператора как дружественной свободной функции внутри шаблона, рассмотренный в разделе 2.3.

2.2.4. Перечисления

Для перечислений операторы можно перегружать только как свободные функции, так как у перечислений просто не может быть функций-членов, пример см. в разделе 2.6.

2.3. Определение дружественной свободной функции внутри класса

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

class X < // . friend X operator+(const X& x, const X& y) // бинарный плюс < // . >>;

Такой стиль подчеркивает связь оператора с классом и позволяет сделать определение более лаконичным. В случае шаблонов этот прием не только делает определение более лаконичным, но и расширяет функциональность оператора, позволяет использовать неявные преобразования аргументов, которые недоступны при определении шаблона функции вне класса. Поэтому его можно использовать, даже когда не нужен доступ к закрытым членам. Рассмотрим пример, являющийся небольшой переработкой примера из [Meyers1]. В этом примере бинарный оператор + определен внутри класса с использованием ключевого слова friend , а бинарный оператор - определен вне класса.

// rational number (рациональное число) template class Rational < T num; // numerator (числитель) T den; // denominator (знаменатель) public: Rational(T n = 0, T d = 1) : num(n), den(d) T Num() const < return num; >T Den() const < return den; >friend const Rational operator+( const Rational& x, const Rational& y) < return Rational( x.num * y.den + y.num * x.den, x.den * y.den); >>; template const Rationaloperator-( const Rational& x, const Rational& y) < return Rational( x.Num() * y.Den() - y.Num() * x.Den(), x.Den() * y.Den()); >

Определение оператора + позволяет использовать закрытые члены класса. Но это еще не все, такое определение дает возможность при сложении использовать неявное преобразование от T к Rational , определенное в классе с помощью конструктора с одним параметром. Вот пример:

Rational r1(1, 2), r2(31, 64); Rational r3 = r1 + r2; // Rational + Rational Rational r4 = r1 + 3; // Rational + int Rational r5 = 4 + r2; // int + Rational

В последних двух инструкциях мы складываем объекты типа Rational со значениями типа int . К аргументам типа int применяется неявное преобразование от int к Rational , инструкции компилируются и выполняются без ошибки.

Попробуем теперь использовать оператор - .

Rational r6 = r1 - 3; // Rational - int Rational r7 = 4 - r2; // int - Rational

В обоих случаях компилятор выдает ошибку, неявное преобразование от int к Rational в данном случае не работает. Для того, что бы эти инструкции были корректными, надо добавить еще два варианта перегрузки оператора - :

template const Rational operator-(const Rational& x, T y) < return operator-(x, Rational(y)); > template const Rational operator-(T x, const Rational& y) < return operator-(Rational(x), y); >

Подробнее см. [Meyers1].

2.4. Вычислительные конструкторы

Если оператор возвращает объект по значению, иногда целесообразно определить специальный закрытый конструктор, называемый вычислительным конструктором (computational constructor). В этом случае компилятор сможет применить оптимизацию возвращаемого значения (return value optimization, RVO). Подробнее см. [Dewhurst].

2.5. Виртуальные операторы

Если оператор перегружен как функция-член, его можно объявить виртуальным. Реализация оператора, перегруженного как свободная функция, может использовать виртуальные функции параметров (своего рода идиома NVI – non virtual interface). Но «философия» перегрузки операторов плохо согласуется с полиморфизмом. Полиморфные объекты обычно доступны через указатели. В инфиксной форме вызов оператора можно сделать только через ссылку, поэтому приходится использовать не очень изящные выражения, например *a+*b . При реализации некоторых бинарных операторов, перегруженных как свободная функция (например + ), приходится реализовывать двойную диспетчеризацию, а это не очень просто (паттерн Visitor). Оператор присваивания не рекомендуется делать виртуальным, про это написано довольно много, см. например [Dewhurst]. Присваивание является неполиморфной по своей сути операцией. В общем, можно сказать, что виртуальные перегруженные операторы — это не самая лучшая идея.

2.6. Перегрузка операторов для перечислений

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

enum class Color < Begin, Red = Begin, Green, Blue, End>; // перегрузка инкремента Color& operator++(Color& col)

Теперь перебрать все элементы перечисления можно так:

void Foo(Color col); // . for (Color col = Color::Begin; col

Перегрузим еще один оператор

Color operator*(Color col)

Теперь перебрать все элементы перечисления можно с помощью стандартного алгоритма:

std::for_each(Color::Begin, Color::End, Foo);

И еще один вариант. Определим класс:

struct Colors < Color begin() const < return Color::Begin; >Color end() const < return Color::End; >>;

После этого перебрать все элементы перечисления можно с помощью диапазонного for :

for (auto col : Colors())

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

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

3.1. Оператор ->

Этот оператор является унарным и может быть реализован только как функция-член (обычно константная). Он должен возвращать либо указатель на класс (структуру, объединение), либо тип, для которого перегружен оператор -> . Перегрузка этого оператора используется для «указателеподобных» типов — интеллектуальных указателей и итераторов. Вот пример:

class X < // . void Foo(); >; class XPtr < // . X* operator->() const; >; // . X x; x->Foo(); // инфиксная форма x.operator->()->Foo(); // функциональная форма

В стандартной библиотеке оператор -> перегружен для интеллектуальных указателей и итераторов.

3.2. Унарный оператор *

Этот унарный оператор часто перегружают в паре с оператором -> . Как правило, он возвращает ссылку на элемент, указатель на который возвращает оператор -> . Этот оператор обычно реализуется как константная функция-член.

В стандартной библиотеке оператор * перегружен для интеллектуальных указателей и итераторов.

3.3. Оператор []

Этот бинарный оператор, который обычно называют индексатором, может быть реализован только, как функция-член, которая должна иметь ровно один параметр. Тип этого параметра произвольный, соответственно, перегрузок может быть несколько, для разных типов параметра. Индексатор обычно перегружается для «массивоподобных» типов, а также для других контейнеров, например ассоциативных массивов. Возвращаемое значение обычно является ссылкой на элемент контейнера. Также, в принципе, может быть возврат по значению, но следует иметь в виду, что при этом для получения адреса элемента нельзя будет использовать выражения &х[i] , допустимые для встроенного индексатора. Такое выражение не будет компилироваться, если возвращаемый тип встроенный, и будет давать адрес временного объекта для пользовательского возвращаемого типа.

Индексатор часто перегружают в двух вариантах — константном и неконстантном.

T& operator[](int ind); const T& operator[](int ind) const;

Первая версия позволяет модифицировать элемент, вторая только прочитать и она будет выбрана для константных экземпляров и в константных функциях-членах.

В стандартной библиотеке индексатор перегружен для последовательных контейнеров std::vector<> , std::array<> , std::basic_string<> , std::deque<> и ассоциативных контейнеров std::map<> , std::unordered_map<> . Специализация для массивов интеллектуального указателя std::unique_ptr<> также перегружает индексатор.

3.3.1. Многомерные массивы

C++ поддерживает только одномерные массивы, то есть выражение a[i,j] некорректно, но многомерность моделируется в виде «массива массивов», то есть можно использовать выражение a[i][j] . Этот синтаксис несложно поддержать для пользовательских индексаторов с помощью промежуточного прокси-класса. Вот пример простого шаблона матрицы.

template class Matrix < public: Matrix(int rowCount, int colCount); class RowProxy; RowProxy operator[](int i) const; class RowProxy < public: T& operator[](int j); const T& operator[](int j) const; // . >; // . >; // . Matrix mtx(5, 6); double s = mtx[1][2]; mtx[2][3] = 3.14;

3.4. Оператор ()

Этот оператор можно реализовать только как функцию-член. Он может иметь любое число параметров любого типа, тип возвращаемого значения также произвольный. Классы, с перегруженным оператором () , называются функциональными, их экземпляры называются функциональными объектами или функторами. Функциональные классы и объекты играют очень важную роль в программировании на C++ и в частности активно используются в стандартной библиотеке. Именно с помощью таких классов и объектов в C++ реализуется парадигма функционального программирования. Функциональные классы и объекты, используемые в стандартной библиотеке, в зависимости от назначения имеют свои названия: предикаты, компараторы, хеш-функции, аккумуляторы, удалители. В зависимости от контекста использования, стандартная библиотека предъявляет определенные требования к функциональным классам. Экземпляры этих классов должны быть копируемыми по значению, не модифицировать аргументы, не иметь побочных эффектов и изменяемое состояние (чистые функции), соответственно реализация перегрузки оператора () обычно является константной функцией-членом. Есть исключение — алгоритм std::for_each() , для него функциональный объект может модифицировать аргумент и иметь изменяемое состояние.

3.4.1. Локальные определения и лямбда-выражения

В C++ нельзя определить функцию локально (в блоке). Но можно определить локальный класс и этот класс может быть функциональным. Столь популярные в народе лямбда-выражения как раз и представляют из себя средство для быстрого и удобного определения анонимного локального функционального класса на «на лету».

3.4.2. Мультифункциональные типы и объекты

Функциональный класс может иметь несколько вариантов перегрузки оператора () , с разными параметрами. Такие классы и соответствующие объекты можно назвать мультифункциональными. Пример использования мультифункциональных объектов в стандартной библиотеке приведен в Приложении А.

3.4.3. Хеш-функция

Неупорядоченные контейнеры ( std::unordered_set<> , std::unordered_multiset<> , std::unordered_map<> , std::unordered_multimap<> ) требуют для своей работы функциональные объекты, которые реализуют вычисление хеш-функции для элементов контейнера или ключей. Такие контейнеры предусматривают шаблонный параметр функционального типа для реализации вычисления хеш-функции. Для этого типа перегруженный оператор () должен принимать ссылку на элемент или ключ и возвращать хеш-значение типа std::size_t . Если пользователь не задал необходимый функциональный тип, контейнер предоставляет необходимый тип по умолчанию. Для этого используются шаблон класса std::hash<> , которые конкретизируются для типа элементов контейнера или ключа. Этот шаблон специализирован для числовых типов, указателей и некоторых стандартных типов. Для типов, не имеющих специализации, программист должен самостоятельно реализовать хеш-функцию. Это можно сделать двумя способами.

  1. Определить полную специализацию этого шаблона.
  2. Определить нужный функциональный класс и передать его в качестве шаблонного аргумента при конкретизации шаблона контейнера.

В Приложении Б приводится пример решения для C-строк на основе полной специализации стандартного шаблона.

3.4.4. Сравнение элементов и ключей в контейнерах

Ассоциативные и неупорядоченные контейнеры требуют для своей работы функциональные объекты, которые реализуют необходимые операции сравнения для элементов контейнера или ключей. Такие контейнеры предусматривают шаблонный параметр функционального типа для реализации необходимых операций. Для этого типа перегруженный оператор () должен иметь два параметра, ссылки на элементы или ключи, и возвращать bool . Если пользователь не задал необходимый функциональный тип, контейнер предоставляет необходимый тип по умолчанию. Для этого используются шаблоны std::less<> и std::equal_to<> , которые конкретизируются для типа элементов контейнера. Первый из них для реализации необходимой функциональности использует встроенный или перегруженный оператор < , второй встроенный или перегруженный оператор == .

Шаблон std::less<> используется для сравнения по умолчанию элементов или ключей в ассоциативных контейнерах std::set<> , std::multiset<> , std::map<> , std::multimap<> , а также в контейнере std::priority_queue<> .

Шаблон std::equal_to<> используется для сравнения по умолчанию элементов или ключей в неупорядоченных контейнерах std::unordered_set<> , std::unordered_multiset<> , std::unordered_map<> , std::unordered_multimap<> .

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

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

В Приложении Б приводится пример решения для C-строк на основе полной специализации стандартного шаблона.

3.4.5. Удалители в интеллектуальных указателях

Деструктор интеллектуального указателя должен освободить объект, которым владеет. Для этого используется функциональный объект, называемый удалителем (deleter). Соответствующий функциональный тип перегружает оператор () который должен принимать указатель на объект и не возвращать значение. Для std::unique_ptr<> по умолчанию используется шаблон std::default_delete<> , который конкретизируются для типа управляемого объекта и для его удаления использует оператор delete (или delete[] в случае специализации для массивов). Для std::shared_ptr<> по умолчанию используется оператор delete . Если необходима иная операция освобождения объекта, то необходимо определить свой функциональный тип. Это можно сделать двумя способами.

  1. Для std::unique_ptr<> определить полную специализацию стандартного шаблона-удалителя.
  2. Определить функциональный класс и использовать его или его экземпляры в качестве аргумента при создании интеллектуального указателя в соответствии с синтаксисом инициализации используемого интеллектуального указателя.

Полную специализацию стандартного шаблона-удалителя можно также использовать и для std::shared_ptr<> , для этого экземпляр этого удалителя надо передать вторым аргументом в конструктор std::shared_ptr<> .

3.4.6. Алгоритмы

Алгоритмы стандартной библиотеки активно используют функциональные объекты и, соответственно, многие из них имеют параметр функционального типа. Часто алгоритмы имеют версию без такого параметра, в этом случае для реализации необходимых операций используется оператор (встроенный или перегруженный), определенный для элементов диапазона.

Оператор == используется по умолчанию в алгоритме std::equal() , который сравнивает диапазоны, в алгоритме std::count() , который подсчитывает количество заданных элементов, в алгоритмах поиска ( std::find() , etc), в алгоритмах std::replace() и std::remove() , которые модифицируют диапазон.

Оператор + используется по умолчанию в алгоритме accumulate() . (Подробнее см. Приложение А.)

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

  1. Перегрузить оператор, который будет использован по умолчанию. Такой способ не всегда подходит, это обсуждалось выше.
  2. Определить нужный функциональный класс или функцию, и использовать их как аргумент алгоритма. (В этом случае часто используют лямбда-выражение.)

Пример для алгоритма сортировки C-строк приведен в Приложение Б.

3.4.7. Функциональный шаблон

В C++11 появился универсальный функциональный шаблон. Он конкретизируется типом функции и перегружает оператор () в соответствии с сигнатурой функции. Экземпляры конкретизации можно инициализировать указателем на функцию, функциональным объектом или лямбда-выражением с соответствующей сигнатурой. Вот пример.

#include int Foo(const char* s) < return *s; >struct X < int operator() const (const char* s) < return *s; >>; std::function f1 = Foo, f2 = X(), f3 = [](const char* s) < return *s; >; int r1 = f1("1"), r2 = f2("2"), r3 = f3("3");

3.5. Операторы сравнения

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

#include class X < /* . */ >; // базовые операторы bool operator==(const X& lh, const X& rh); bool operator <(const X& lh, const X& rh); // зависимые операторы bool operator<=(const X& lh, const X& rh) < return std::rel_ops::operator<=(lh, rh); >// остальные зависимые операторы

Перегружать зависимые операторы для класса не обязательно, можно использовать операторы из std::rel_ops непосредственно, для этого надо воспользоваться using -директивой:

using namespace std::rel_ops;

В стандартной библиотеке полный набор операторов сравнения — < , , >= , == , != , перегружают контейнеры и интеллектуальные указатели, а также некоторые более специальные классы: std::thread::id , std::type_index , std::monostate . Контейнеры для реализации этих перегрузок используют соответствующие операторы для элементов, если элементы не поддерживают операцию, возникает ошибка. Интеллектуальные указатели используют соответствующие встроенные операторы для указателей.

Операторы == и != перегружают std::error_code , std::bitset . Также эти операторы является частью стандартного интерфейса любого итератора, а оператор < является частью стандартного интерфейса итераторов произвольного доступа.

3.6. Арифметические операторы

Бинарные операторы арифметических операций обычно перегружают в паре с соответствующим присваивающим оператором, например + и += . Первый перегружают как свободную функцию с двумя аргументами, второй — как функцию-член. Унарные операторы, + , - , перегружают как функцию-член. Вот пример.

class X < // . const X operator-() const; // унарный минус X& operator+=(const X& x); // присваивающий плюс >; const X operator+(const X& x, const X& y); // бинарный плюс

Унарные операторы + , - не должны изменять операнд (в отличии от инкремента и декремента) и должны возвращать результат по значению. Такую семантику имеют встроенные версии этих операторов. Свободная функция, реализующая бинарный оператор, также не изменяет операндов и возвращает результат по значению. Присваивающая версия реализована как функция-член, которая не изменяет второй операнд и возвращает *this . В данном примере возвращаемый тип для операторов + и += объявлен константным, причина описана в разделе 1.4.1, но это надо делать только, если тип X не поддерживает семантику перемещения, в противном случае const надо убрать.

В бинарных операторах тип операндов может не совпадать. Например для строк один из операндов может быть C-строкой, для итераторов произвольного доступа второй операнд является сдвигом. Но в таком случае надо подумать о симметрии (см. раздел 2.2).

В стандартной библиотеке полный набор арифметических операторов перегружает std::complex<> . Операторы + , += , - , -= перегружают итераторы произвольного доступа. В std::basic_string<> операторы + и += перегружаются для реализации конкатенации. Оригинальное решение используется в классе std::filesystem::path (C++17). В этом классе операторы / и /= перегружены для конкатенации элементов пути. Конечно к делению это никакого отношения не имеет, но зато этот символ оператора совпадает с традиционном разделителем элементов пути. Запоминается с первого раза.

3.7. Инкремент, декремент

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

class Iter < public: Iter& operator++() // префиксный инкремент < // реализация инкремента return *this; >const Iter operator++(int) // постфиксный инкремент < Iter it(*this); ++*this; return it; >// . >;

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

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

3.8. Операторы >

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

#include struct Point < int X; int Y; >; std::ostream& operator

Главная проблема этих перегрузок — довольно высокий приоритет операторов, поэтому скобками приходится пользоваться чаще, чем хотелось бы. Проблема усугубляется наличием неявного преобразования потоковых типов к void* , из-за чего компилятор может не выдавать ошибок. Примеры см. в разделе 1.2.

3.9. Оператор присваивания

Оператор присваивания можно реализовать только, как функцию-член, которая должна иметь ровно один параметр. Тип этого параметра произвольный, соответственно, перегрузок может быть несколько, для разных типов параметра. Перегрузка оператора присваивания является составной частью поддержки семантики копирования/перемещения и к ней приходится прибегать достаточно часто. Оператор присваивания практически всегда идет в паре с конструктором, имеющим один параметр. Нормальная ситуация — это когда каждому конструктору с одним параметром прилагается соответствующий оператор присваивания. Если описать семантику присваивания «на пальцах», то присваивание должно полностью освободить все текущие ресурсы, которыми владеет объект (левый операнд), и на его месте создать новый объект, определяемый правым операндом.

Среди операторов присваивания выделяются два стандартных — оператор копирующего присваивания и оператор перемещающего присваивания, которые соответствуют копирующему конструктору и перемещающему конструктору.

 class X < public: X(const X& src); // копирующий конструктор X(X&& src) noexcept; // перемещающий конструктор X& operator=X(const X& src); // оператор копирующего присваивания X& operator=X(X&& src) noexcept; // оператор перемещающего присваивания // . >; 

Обратим внимание на то, что для перемещающих операций крайне желательно гарантировать, чтобы они не выбрасывали исключений. Такие функции-члены надо объявлять как noexcept .

Стандартные операторы присваивания могут быт сгенерированы компилятором. Для этого при объявлении надо использовать конструкцию "=default" .

class X < public: X& operator=X(const X& src) = default; X& operator=X(X&& src) = default; // . >;

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

class X < public: X& operator=X(const X& src) = delete; X& operator=X(X&& src) = delete; // . >;

Рассмотрим теперь вопрос реализации операторов присваивания. Оператор присваивания обычно возвращает ссылку на текущий объект, то есть *this . Это нужно для того, чтобы для стандартных операторов присваивания были допустимыми выражения типа a=b=c . Наиболее прогрессивный вариант реализации операторов присваивания — это использование идиомы «копирование и обмен». Для этого в классе должна быть определена функция-член обмена состояниями, которая не должна выбрасывать исключений.

class X < public: void Swap(X& src) noexcept; // обмен состояниями X(const X& src); // копирующий конструктор X(X&& src) noexcept; // перемещающий конструктор X& operator=X(const X& src); X& operator=X(X&& src) noexcept; // . >;

И тогда операторы присваивания реализуются с помощью соответствующего конструктора и функции обмена состояниями следующим образом:

X& X::operator=X(const X& src) < X tmp(src); // копирование Swap(tmp); return *this; >X& X::operator=X(X&& src) noexcept < X tmp(std::move(src)); // перемещение Swap(tmp); return *this; >

Аналогично можно определить оператор присваивания, соответствующий любому другому конструктору с одним параметром.
Главное достоинства этой идиомы состоит в обеспечении строгой гарантии безопасности исключений: если в конструкторе произошло исключение, то объект останется в том же состоянии, что и до начала операции (транзакционная семантика).

Если идиома «копирование и обмен» не используется, то необходима проверка на самоприсваивание.

Также, в случае наследования, надо вызвать соответствующий оператор базового класса. Еще одно достоинство идиомы «копирование и обмен» как раз и состоит в том, что она корректно работает при самоприсваивании, хотя, конечно, и не оптимально.

Ну и, наконец, рассмотрим довольно известную антиидиому для реализации присваивания.

X& X::operator=X(const X& src) < if (this != std::addressof(src)) < this->~X(); new (this)(src); > return *this; >

В этом случае сначала явно вызывается деструктор для this , потом с помощью размещающего new на месте, куда указывает this , создается новый объект. На первый взгляд этот код соответствует описанию семантики оператора, приведенной в начале раздела, но если разобраться, то он имеет существенные дефекты. Если конструктор выбрасывает исключение, то место в памяти, на которое указывает this , превращается в кусок памяти, содержимое которого не определено. Любая попытка использовать объект закончится неопределенным поведением. Другая проблема возникает, когда X является базовым классом для какого-нибудь другого класса и деструктор класса X виртуальный. В этом случае this->~X() уничтожает объект производного класса, что может полностью сломать взаимодействие базового класса и производного. Никогда так не делайте.

Оператор копирующего присваивания и оператор перемещающего присваивания (вместе с соответствующим конструктором) приходится перегружать практически всегда, когда нужна нестандартная семантика копирования/перемещения. (Запрет копирующего или перемещающего присваивания также можно рассматривать как перегрузку.) Также оператор присваивания обычно перегружается, как парный для конструктора с одним параметром. Практически все классы стандартной библиотеки перегружают операторы присваивания.

3.10. Оператор !

Этот унарный оператор иногда перегружают для того чтобы проверять, не является ли объект не инициализированным. Он должен возвращать true , если объект не инициализирован («пустой», «нулевой»). В настоящее время такое решение не очень популярно. Сейчас чаще используют explicit преобразование к bool , с противоположной семантикой, оно должно возвращать true , если объект инициализирован.

explicit operator bool() const noexcept;

4. Итоги

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

При реализации перегрузки оператора учитывайте интерфейс и семантику встроенного оператора.

Приложения

Приложение А. Пример использования мультифункциональных объектов

Первый пример относится к шаблону std::variant<> (C++17). Шаблон функции std::visit() в качестве первого параметра используют мультифункциональный класс, у которого оператор () перегружен для всех типов конкретизации std::variant<> , а второй параметр является этой самой конкретизацией. Вызов std::visit() обеспечивает вызов версии оператора () мультифункционального объекта, соответствующей фактическому типу std::variant<> . Вот пример.

using IntStr = std::variant; struct Visitor < void operator()(int x) const < std::cout void operator()(const std::string& x) const < std::cout >; // . IntStr a(42), b("meow"); Visitor v; std::visit(v, a); // вывод: int, val=42 std::visit(v, b); // вывод: string, val=meow

Другой пример относится к алгоритму std::reduce() (C++17). Этот алгоритм является параллельной версией алгоритма std::accumulate() . Рассмотрим сначала старый std::accumulate() .

template T accumulate(InputIt first, InputIt last, T init, BinOper oper);

BinOper — это функциональный тип, совместимой с сигнатурой

T f(T t, S s);

Первый параметр имеет тип T — аккумулирующий тип, второй параметр имеет тип S — тип элементов последовательности, возвращаемое значение имеет типа T . В простейших случаях, таких как сумма, T и S могут совпадать, но в общем случае это не так. Алгоритм последовательно вызывает функциональный объект для всех элементов последовательности, передавая их как второй аргумент, а в качестве первого аргумента использует результат вызова на предыдущим шаге. На первом шаге используется init . Это исключительно последовательный алгоритм, поэтому в C++17 добавили алгоритм std::reduce() , решающий ту же задачу, но с поддержкой распараллеливания.

template T reduce(ExecutionPolicy&& policy, InputIt first, InputIt last, T init, BinOper oper);

Ключевое отличие BinOper от аналогичного в std::accumulate() — это то, что BinOper должен поддерживать несколько сигнатур:

T f(T t, S s); T f(S s1, S s2); T f(T t1, T t2);

Приложение Б. Хэш-функция и сравнение для C-строк

C-строки — строки с завершающим нулем, — обычно представляются типом T* или const T* , где T один из символьных типов ( char , wchar_t , etc). Но соответствующие конкретизации std::hash<> , std::less<> и std::equal_to<> будут рассматривать этот тип как указатель, игнорируя содержимое строки, что в большинстве случаев неприемлемо. Вот пример возможного решения.

#include template inline void hash_combine(std::size_t& seed, const T& v) < std::hashhasher; seed ^= hasher(v) + 0x9e3779b9 + (seed > 2); > #include namespace std < template <>struct hash  < size_t operator()(const char* str) const < std::size_t hash = 0; for (; *str; ++str) < hash_combine(hash, *str); >return hash; > >; template <> struct equal_to  < bool operator()(const char* x, const char* y) const < return strcmp(x, y) == 0; >>; template <> struct less  < bool operator()(const char* x, const char* y) const < return strcmp(x, y) < 0; >>; > // namespace std

Функция hash_combine() — это хорошо известная функция из библиотеки Boost. Она может быть использована при создании других пользовательских хеш-функций.

Ну и, наконец, пример сортировки C-строк в котором используется лямбда-выражение для определения нужного функционального объекта.

#include const char* cc[] = < "one", "two", "three", "four" >; std::sort(cc, cc + _countof(cc), [](const char* x, const char* y) < return std::strcmp(x, y) < 0; >);

Список литературы

[Josuttis]
Джосаттис, Николаи М. Стандартная библиотека C++: справочное руководство, 2-е изд.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2014.
[Dewhurst]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.
[Meyers1]
Мэйерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.
[Sutter1]
Саттер, Герб. Решение сложных задач на C++.: Пер. с англ. — М: ООО «И.Д. Вильямс», 2015.

  • C++
  • перегрузка операторов

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *