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

?v=1

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


Оглавление

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


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<<(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.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
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) { 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.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::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 

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.

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

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

#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
    
        

Habrahabr.ru прочитано 44442 раза