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


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


Оглавление

Оглавление

  Введение
  1. Общие вопросы перегрузки операторов
    1.1. Перегружаемые операторы
    1.2. Общие правила при выборе перегружаемого оператора
    1.3. Операторы, не рекомендуемые для перегрузки
    1.4. Интерфейс и семантика перегруженных операторов
    1.5. Реализация перегрузки операторов
      1.5.1. Два варианта реализации перегрузки операторов
      1.5.2. Две формы использования перегруженных операторов
      1.5.3. Одновременное использование двух вариантов реализации перегрузки
  2. Дополнительные подробности реализации перегрузки операторов
    2.1. Множественная перегрузка
    2.2. Особенности перегрузки операторов с использованием свободных функций
    2.3. Определение дружественной свободной функции внутри класса
    2.4. Вычислительные конструкторы
    2.5. Виртуальные операторы
    2.6. Перегрузка операторов для перечислений
  3. Особенности перегрузки некоторых операторов
    3.1. Оператор ->
    3.2. Оператор *
    3.3. Оператор []
      3.3.1. Многомерные массивы
    3.4. Оператор ()
      3.4.1. Локальные определения и лямбда-выражения
      3.4.2. Мультифункциональные типы и объекты
      3.4.3. Хеш-функция
      3.4.4. Сравнение элементов в контейнерах
      3.4.5. Удалители в интеллектуальных указателях
      3.4.6. Алгоритмы
      3.4.7. Функциональный шаблон
    3.5. Операторы сравнения
    3.6. Арифметические операторы
    3.7. Инкремент, декремент
    3.8. Операторы << и >>
    3.9. Оператор присваивания
    3.10. Оператор !
  4. Итоги
  Приложения
    Приложение А. Пример использования мультифункциональных объектов
    Приложение Б. Хэш-функция и сравнение для 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<<c?x:y;

это

(std::сout<<c)?x:y;

а не

std::сout<<(c?x:y);

как надо.

Проблема усугубляется наличием неявного преобразования от 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. Особенности перегрузки операторов с использованием свободных функций

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

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

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

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

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

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

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

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

// rational number (рациональное число)
template<typename T>
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<typename T>
const Rational<T>operator-(
    const Rational<T>& x, const Rational<T>& y)
{
    return Rational<T>(
        x.Num() * y.Den() - y.Num() * x.Den(), 
        x.Den() * y.Den());
}

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

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

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

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

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

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

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

template<typename T>
const Rational<T> operator-(T x, const Rational<T>& y)
{
    return operator-(Rational<T>(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) { return (Color&)(++(int&)col); }

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

void Foo(Color col);
// ...
for (Color col = Color::Begin; col < Color::End; ++col)
{
    Foo(col);
}

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

Color operator*(Color col) { return 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())
{
    Foo(col);
}

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<typename T>
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<double> 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::lexicographical_compare(), который сравнивает диапазоны, в алгоритмах поиска минимума/максимума (min_element(), etc), в алгоритмах, связанных с сортировкой и отсортированными данными (std::sort(), etc), в алгоритмах, связанных с пирамидой (std::make_heap(), etc).

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

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

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

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

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

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

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

#include <functional>

int Foo(const char* s) { return *s; }

struct X
{
    int operator() const (const char* s) { return *s; }
};

std::function<int(const char*)>
    f1 = Foo,
    f2 = X(),
    f3 = [](const char* s) { return *s; };

int r1 = f1("1"),
    r2 = f2("2"),
    r3 = f3("3");

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

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

Чаще всего пользовательские типы перегружают операторы < и ==, для того чтобы элементы этого типа можно было хранить в контейнерах и использовать в алгоритмах. Об этом достаточно много говорилось в предыдущем разделе. Но для корректной работы контейнеров и алгоритмов операторы должны удовлетворять определенным критериям (см. [Josuttis]). Для оператора < это следующие свойства: антисимметричность (если x<y равно true, то y<x равно false), транзитивность (если x<y и y<z, то x<z), иррефлексивность (x<x всегда равно false), транзитивная эквивалентность (если !(x<y) && !(y<x) и !(y<z) && !(z<y), то !(x<z) && !(z<x)). Для оператора == это следующие свойства: симметричность (если x==y, то y==x), транзитивность ( если x==y и y==z, то x==z), рефлексивность (x==x всегда равно true). Естественно, что встроенные операторы отвечают этим критериям. Если для контейнеров и алгоритмов используются пользовательские функциональный типы, то они должны отвечать этим же критериям.

Рассмотрим теперь перегрузку остальных операторов сравнения. Встроенные операторы сравнения являются сильно зависимыми. Базовыми являются операторы < и ==, остальные можно выразить через них переставляя операнды и используя встроенный оператор !. Естественно, что при перегрузке операторов сравнения надо поступать таким же образом. Специально для этого в пространстве имен std::rel_ops таким способом определены операторы <=, >, >=, !=. (Заголовочный файл <utility>.) Вот пример использования этих операторов.

#include <utility>

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 <iostream>

struct Point
{
    int X;
    int Y;
};
std::ostream& operator<<(std::ostream& strm, const Point& p)
{
    strm << '[' << p.X << ',' << p.Y << ']';
    return strm;
}

Главная проблема этих перегрузок — довольно высокий приоритет операторов, поэтому скобками приходится пользоваться чаще, чем хотелось бы. Проблема усугубляется наличием неявного преобразования потоковых типов к 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))
    {
        // ...
    }
    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<int, std::string>;

struct Visitor
{
    void operator()(int x) const
    { 
        std::cout << "int, val=" << x << 'n';
    }
    void operator()(const std::string& x) const
    {
        std::cout << "string, val=" << x << 'n';
    }
};
// ...
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<class InputIt, class T, class BinOper>
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<class ExecutionPolicy,
    class InputIt, class T, class BinOper>
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 <functional>

template <class T>
inline void hash_combine(std::size_t& seed, const T& v)
{
    std::hash<T> hasher;
    seed ^= hasher(v) + 0x9e3779b9 + 
        (seed << 6) + (seed >> 2);
}

#include <cstring>

namespace std
{
    template <>
    struct hash<const char*>
    {
        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<const char*>
    {
        bool operator()(const char* x, const char* y) const
        {
            return strcmp(x, y) == 0;
        }
    };

    template <>
    struct less<const char*>
    {
        bool operator()(const char* x, const char* y) const
        {
            return strcmp(x, y) < 0;
        }
    };
} // namespace std

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

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

#include <cstring>
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.

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр