xajker 0 / 0 / 1 Регистрация: 05.11.2012 Сообщений: 12 |
||||||||||||
1 |
||||||||||||
Ошибка при переопределении операторов26.11.2013, 22:26. Показов 683. Ответов 2 Метки нет (Все метки)
Есть класс квадратной матрицы:
Реализация:
Использование:
Выдает ошибку, не понимаю в чем дело. Миниатюры
0 |
MrCold 873 / 771 / 173 Регистрация: 11.01.2012 Сообщений: 1,942 |
||||
27.11.2013, 01:03 |
2 |
|||
Не хватает перегрузки оператора присваивания и конструктора копии. Добавлено через 27 секунд
delete &matr, &matr1; ну и это лишнее
0 |
0 / 0 / 1 Регистрация: 05.11.2012 Сообщений: 12 |
|
29.11.2013, 20:49 [ТС] |
3 |
спасибо. помогло
0 |
Каждая не статическая функция-член класса имеет неявный параметр, который получает значение this
, то есть указатель на сам объект класса.
Вам надо либо этот оператор определить в классе как дружественную функцию (если требуется обращение к закрытым или защищенным членам класса), например
friend MyClass operator+(const MyClass &a, const MyClass &b) {
...
return a.value + b.value;
}
Или объявить ее как обычную функцию вне класса, если не требуется обращение к закрытым или защищенным членам класса.
Либо сделать оператор функцией-членом класса, но с одним явным параметром
MyClass operator+( const MyClass &b) const {
...
return this->value + b.value;
}
Продолжаем серию «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. Особенности перегрузки операторов с использованием свободных функций
Рассмотрим несколько ситуаций, когда перегрузка операторов с использованием свободных функций предпочтительней или, вообще, безальтернативна.
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<int> rwi(i);
std::cout << rwi << 'n'; // вывод: 42
Операторы вставки и извлечения из потока не перегружены для std::reference_wrapper<int>
, но этот класс имеет неявное преобразование к 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<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<>
, которые конкретизируются для типа элементов контейнера или ключа. Этот шаблон специализирован для числовых типов, указателей и некоторых стандартных типов. Для типов, не имеющих специализации, программист должен самостоятельно реализовать хеш-функцию. Это можно сделать двумя способами.
- Определить полную специализацию этого шаблона.
- Определить нужный функциональный класс и передать его в качестве шаблонного аргумента при конкретизации шаблона контейнера.
В Приложении Б приводится пример решения для 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<>
.
Если для использования некоторого типа в контейнере стандартной библиотеки требуется изменить или определить сравнение элементов этого типа, то существует три способа решить эту проблему.
- Перегрузить оператор сравнения, который будет использован по умолчанию. Этот способ не подходит для встроенных типов, если нужно сравнение, отличное от стандартного, и для пользовательских типов, у которых нужный оператор уже перегружен (но не так как надо).
- Определить полную специализацию стандартных функциональных шаблонов, используемых для сравнения по умолчанию.
- Определить нужный функциональный класс и передать его в качестве шаблонного аргумента при конкретизации шаблона контейнера.
В Приложении Б приводится пример решения для C-строк на основе полной специализации стандартного шаблона.
3.4.5. Удалители в интеллектуальных указателях
Деструктор интеллектуального указателя должен освободить объект, которым владеет. Для этого используется функциональный объект, называемый удалителем (deleter). Соответствующий функциональный тип перегружает оператор ()
который должен принимать указатель на объект и не возвращать значение. Для std::unique_ptr<>
по умолчанию используется шаблон std::default_delete<>
, который конкретизируются для типа управляемого объекта и для его удаления использует оператор delete
(или delete[]
в случае специализации для массивов). Для std::shared_ptr<>
по умолчанию используется оператор delete
. Если необходима иная операция освобождения объекта, то необходимо определить свой функциональный тип. Это можно сделать двумя способами.
- Для
std::unique_ptr<>
определить полную специализацию стандартного шаблона-удалителя. - Определить функциональный класс и использовать его или его экземпляры в качестве аргумента при создании интеллектуального указателя в соответствии с синтаксисом инициализации используемого интеллектуального указателя.
Полную специализацию стандартного шаблона-удалителя можно также использовать и для 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()
. (Подробнее см. Приложение А.)
Если для использования некоторого типа в алгоритме стандартной библиотеки требуется изменить или определить необходимые операции для элементов этого типа, то существует два способа решить эту проблему.
- Перегрузить оператор, который будет использован по умолчанию. Такой способ не всегда подходит, это обсуждалось выше.
- Определить нужный функциональный класс или функцию, и использовать их как аргумент алгоритма. (В этом случае часто используют лямбда-выражение.)
Пример для алгоритма сортировки 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.
Программирование, C++
Рекомендация: подборка платных и бесплатных курсов Python — https://katalog-kursov.ru/
Доброго времени суток, уважаемые читатели Хабра!
Когда я только начал свой путь по изучению C++, у меня возникало много вопросов, на которые, порой, не удавалось быстро найти ответов. Не стала исключением и такая тема как перегрузка операторов. Теперь, когда я разобрался в этой теме, я хочу помочь другим расставить все точки над i.
В этой публикации я расскажу: о различных тонкостях перегрузки операторов, зачем вообще нужна эта перегрузка, о типах операторов (унарные/бинарные), о перегрузке оператора с friend (дружественная функция), а так же о типах принимаемых и возвращаемых перегрузками значений.
UPD.: Для тех, кто предпочитает видео-формат есть видео: https://youtu.be/Qn6mu9l6Xj8
Для чего нужна перегрузка?
Предположим, что вы создаете свой класс или структуру, пусть он будет описывать вектор в 3-х мерном пространстве:
struct Vector3
{
int x, y, z;
Vector3()
{}
Vector3(int x, int y, int z) : x(x), y(y), z(z)
{}
};
Теперь, Вы создаете 3 объекта этой структуры:
Vector3 v1(10, 10, 10), v2(20, 20, 25), v3;
//...
И хотите прировнять объект v2 объекту v1, пишете:
v1 = v2;
Все работает, но пример с вектором очень сильно упрощен, может быть у вас такая структура, в которой необходимо не слепо копировать все значения из одного объекта в другой (как это происходит по умолчанию), а производить с ними некие манипуляции. К примеру, не копировать последнюю переменную z. Откуда программа об этом узнает? Ей нужны четкие команды, которые она будет выполнять.
Поэтому нам необходимо перегрузить оператор присваивания (=).
Общие сведения о перегрузке операторов
Для этого добавим в нашу структуру перегрузку:
Vector3 operator = (Vector3 v1)
{
return Vector3(this->x = v1.x, this->y = v1.y, this->z = 0);
}
Теперь, в коде выше мы указали, что при присваивании необходимо скопировать переменные x и y, а z обнулить.
UPD.: Спасибо за подсказку Eivind. Не учел в статье, что операторы присвоения должны возвращать референс на *this, а не значение. Это де-факто стандартное ожидаемое поведение.
То есть более правильным будет является такой код:
Vector3 operator = (Vector3 v1)
{
this->x = v1.x, this->y = v1.y, this->z = 0;
return *this;
}
Но такая перегрузка далека от совершенства, давайте представим, что наша структура содержит в себе не 3 переменные типа int, а множество объектов других классов, в таком случае этот вариант перегрузки будет работать довольно медленно.
- Первое, что мы можем сделать, это передавать в метод перегрузки не весь объект целиком, а ссылку на то место, где он хранится:
//Передача объекта по ссылке (&v1) Vector3 operator = (Vector3 &v1) { return Vector3(this->x = v1.x, this->y = v1.y, this->z = 0); }
Когда мы передаем объект не по ссылке, то по сути создается новый объект (копия того, который передается в метод), что может нести за собой определенные издержки как по времени выполнения программы, так и по потребляемой ей памяти. — Применительно к большим объектам.
Передавая объект по ссылке, не происходит выделения памяти под сам объект (предположим, 128 байт) и операции копирования, память выделяется лишь под указатель на ячейку памяти, с которой мы работаем, а это около 4 — 8 байт. Таким образом, получается работа с объектом на прямую.
- Но, если мы передаем объект по ссылке, то он становится изменяемым. То есть ничто не помешает нам при операции присваивания (v1 = v2) изменять не только значение v1, но еще и v2!
Пример:
//Изменение передаваемого объекта Vector3 operator = (Vector3 &v) { //Меняем объект, который справа от знака = v.x = 10; v.y = 50; //Возвращаем значение для объекта слева от знака = return Vector3(this->x = v.x, this->y = v.y, this->z = 0); }
Разумеется, вряд ли кто-то в здравом уме станет производить такие не очевидные манипуляции. Но все же, не помешает исключить даже вероятность такого изменения.
Для этого нам всего-лишь нужно добавить const перед принимаемым аргументом, таким образом мы укажем, что изнутри метода нельзя изменить этот объект.
//Запрет изменения передаваемого объекта Vector3 operator = (const Vector3 &v) { //Не получится изменить объект, который справа от знака = //v.x = 10; v.y = 50; //Возвращаем значение для объекта слева от знака = return Vector3(this->x = v.x, this->y = v.y, this->z = 0); }
- Теперь, давайте обратим наши взоры на тип возвращаемого значения. Метод перегрузки возвращает объект Vector3, то есть создается новый объект, что может приводить к таким же проблемам, которые я описал в самом первом пункте. И решение не будет отличаться оригинальностью, нам не нужно создавать новый объект — значит просто передаем ссылку на уже существующий.
//Возвращается не объект, а ссылка на объект Vector3& operator = (const Vector3 &v) { return Vector3(this->x = v.x, this->y = v.y, this->z = 0); }
Но при возврате ссылки, могут появиться определенные проблемы.
Мы уже не напишем такое выражение: v1 = (v2 + v3);
Небольшое отступление о return:
Когда я изучал перегрузки, то не понимал://Зачем писать this->x = ... (что может приводить к ошибкам в бинарных операторах) return Vector3(this->x = v.x, this->y = v.y, this->z = 0); //Если мы все равно возвращаем объект с модифицированными данными? //Почему такая запись не будет работать? (Применительно к унарным операторам) return Vector3(v.x, v.y, 0);
Дело в том, что все операции мы должны самостоятельно и явно указать в теле метода. Что значит, написать: this->x = v.x и т.д.
Но для чего тогда return, что мы возвращаем? На самом деле return в этом примере играет достаточно формальную роль, мы вполне можем обойтись и без него:
//Возвращается void (ничего) void operator = (const Vector3 &v1) { this->x = v1.x, this->y = v1.y, this->z = 0; }
И такой код вполне себе работает. Т.к. все, что нужно сделать, мы указываем в теле метода.
Но в таком случае у нас не получится сделать такую запись:v1 = (v2 = v3); //Пример для void operator + //v1 = void? - Нельзя v1 = (v2 + v3);
Т.к. ничего не возвращается, нельзя выполнить и присваивание. Либо же в случае со ссылкой, что получается аналогично void, возвращается ссылка на временный объект, который уже не будет существовать в момент его использования (сотрется после выполнения метода).
Получается, что лучше возвращать объект а не ссылку? Не все так однозначно, и выбирать тип возвращаемого значения (объект или ссылка) необходимо в каждом конкретном случае. Но для большинства небольших объектов — лучше возвращать сам объект, чтобы мы имели возможность дальнейшей работы с результатом.
Отступление 2 (как делать не нужно):
Теперь, зная о разнице операции return и непосредственного выполнения операции, мы можем написать такой код:v1(10, 10, 10); v2(15, 15, 15); v3; v3 = (v1 + v2); cout << v1; // Не (10, 10, 10), а (12, 13, 14) cout << v2; // Не (15, 15, 15), а (50, 50, 50) cout << v3; // Не (25, 25, 25), а также, что угодно
Для того, что бы реализовать этот ужас мы определим перегрузку таким образом:
Vector3 operator + (Vector3 &v1, Vector3 &v2) { v1.x += 2, v1.y += 13, v1.z += 4; v2(50, 50, 50); return Vector3(/*также, что угодно*/); }
- И когда мы перегружаем оператор присваивания, остается необходимость исключить попеременное присваивание в том редком случае, когда по какой-то причине объект присваивается сам себе: v1 = v1.
Для этого добавим такое условие:Vector3 operator = (const Vector3 &v1) { //Если попытка сделать объект равным себе же, просто возвращаем указатель на него //(или можно выдать предупреждение/исключение) if (&v1 == this) return *this; return Vector3(this->x = v1.x, this->y = v1.y, this->z = v1.z); }
Отличия унарных и бинарных операторов
Унарные операторы — это такие операторы, где задействуется только один объект, к которому и применяются все изменения
Vector3 operator + (const Vector3 &v1); // Унарный плюс
Vector3 operator - (const Vector3 &v1); // Унарный минус
//А так же:
//++, --, !, ~, [], *, &, (), (type), new, delete
Бинарные операторы — работают с 2-я объектами
Vector3 operator + (const Vector3 &v1, const Vector3 &v2); //Сложение - это НЕ унарный плюс!
Vector3 operator - (const Vector3 &v1, const Vector3 &v2); //Вычитание - это НЕ унарный минус!
//А так же:
//*, /, %, ==, !=, >, <, >=, <=, &&, ||, &, |, ^, <<, >>, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=, ->, ->*, (,), ","
Перегрузка в теле и за телом класса
Мы можем объявить и реализовать перегрузку непосредственно в самом теле класса или структуры. Думаю, что как это сделать уже понятно. Давайте рассмотрим вариант, в котором объявление перегрузки происходит в теле класса, а ее реализация уже за пределами класса.
struct Vector3
{
//Данные, конструкторы, ...
//Объявляем о том, что в данной структуре перегружен оператор =
Vector3 operator = (Vector3 &v1);
};
//Реализуем перегрузку за пределами тела структуры
//Для этого добавляем "Vector3::", что указывает на то, членом какой структуры является перегружаемый оператор
//Первая надпись Vector3 - это тип возвращаемого значения
Vector3 Vector3::operator = (Vector3 &v1);
{
return Vector3(this->x = v1.x, this->y = v1.y, this->z = 0);
}
Зачем в перегрузке операторов дружественные функции (friend)?
Дружественные функции — это такие функции которые имеют доступ к приватным методам класса или структуры.
Предположим, что в нашей структуре Vector3, такие члены как x,y,z — являются приватными, тогда мы не сможем обратиться к ним за пределами тела структуры. Здесь то и помогают дружественные функции.
Единственное изменение, которое нам необходимо внести, — это добавить ключевое слово fried перед объявлением перегрузки:
struct Vector3
{
friend Vector3 operator = (Vector3 &v1);
};
//За телом структуры пишем реализацию
Когда не обойтись без дружественных функций в перегрузке операторов?
1) Когда мы реализуем интерфейс (.h файл) в который помещаются только объявления методов, а реализация выносится в скрытый .dll файл
2) Когда операция производится над объектами разных классов. Пример:
struct Vector2
{
//Складываем Vector2 и Vector3
Vector2 operator + (Vector3 v3) {/*...*/}
}
//Объекту Vector2 присваиваем сумму объектов Vector2 и Vector3
vec2 = vec2 + vec3; //Ok
vec2 = vec3 + vec2; //Ошибка
Ошибка произойдет по следующей причине, в структуре Vector2 мы перегрузили оператор +, который в качестве значения справа принимает тип Vector3, поэтому первый вариант работает. Но во втором случае, необходимо писать перегрузку уже для структуры Vector3, а не 2. Чтобы не лезть в реализацию класса Vector3, мы можем написать такую дружественную функцию:
struct Vector2
{
//Складываем Vector2 и Vector3
Vector2 operator + (Vector3 v3) {/*...*/}
//Дружественность необходима для того, чтобы мы имели доступ к приватным членам класса Vector3
friend Vector2 operator + (Vector3 v3, Vector2 v2) {/*...*/}
}
vec2 = vec2 + vec3; //Ok
vec2 = vec3 + vec2; //Ok
Примеры перегрузок различных операторов с некоторыми пояснениями
Пример перегрузки для бинарных +, -, *, /, %
Vector3 operator + (const Vector3 &v1, const Vector3 &v2)
{
return Vector3(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z);
}
Пример перегрузки для постфиксных форм инкремента и декремента (var++, var—)
Vector3 Vector3::operator ++ (int)
{
return Vector3(this->x++, this->y++, this->z++);
}
Пример перегрузки для префиксных форм инкремента и декремента (++var, —var)
Vector3 Vector3::operator ++ ()
{
return Vector3(++this->x, ++this->y, ++this->z);
}
Перегрузка арифметических операций с объектами других классов
Vector3 operator * (const Vector3 &v1, const int i)
{
return Vector3(v1.x * i, v1.y * i, v1.z * i);
}
Перегрузка унарного плюса (+)
//Ничего не делает, просто возвращаем объект
Vector3 operator + (const Vector3 &v)
{
return v;
}
Перегрузка унарного минуса (-)
//Умножает объект на -1
Vector3 operator - (const Vector3 &v)
{
return Vector3(v.x * -1, v.y * -1, v.z * -1);
}
Пример перегрузки операций составного присваивания +=, -=, *=, /=, %=
Vector3 operator += (Vector3 &v1, const Vector3 &v2)
{
return Vector3(v1.x = v1.x + v2.x, v1.y = v1.y + v2.y, v1.z = v1.z + v2.z);
}
Хороший пример перегрузки операторов сравнения ==, !=, >, <, >=, <=
const bool operator < (const Vector3 &v1, const Vector3 &v2)
{
double vTemp1(sqrt(pow(v1.x, 2) + pow(v1.y, 2) + pow(v1.z, 2)));
double vTemp2(sqrt(pow(v2.x, 2) + pow(v2.y, 2) + pow(v2.z, 2)));
return vTemp1 < vTemp2;
}
const bool operator == (const Vector3 &v1, const Vector3 &v2)
{
if ((v1.x == v2.x) && (v1.y == v2.y) && (v1.z == v2.z))
return true;
return false;
}
//Перегружаем != используя другой перегруженный оператор
const bool operator != (const Vector3 &v1, const Vector3 &v2)
{
return !(v1 == v2);
}
Пример перегрузки операций приведения типов (type)
//Если вектор не нулевой - вернуть true
Vector3::operator bool() const
{
if (*this != Vector3(0, 0, 0))
return true;
return false;
}
//При приведении к типу int - возвращать сумму всех переменных
Vector3::operator int() const
{
return int(this->x + this->y + this->z);
}
Пример перегрузки логических операторов !, &&, ||
//Опять же, используем уже перегруженную операцию приведения типа к bool
const bool operator ! (Vector3 &v1)
{
return !(bool)v1;
}
const bool operator && (Vector3 &v1, Vector3 &v2)
{
return (bool)v1 && (bool)v2;
}
Пример перегрузки побитовых операторов ~, &, |, ^, <<, >>
//Операция побитовой инверсии (как умножение на -1, только немного иначе)
const Vector3 operator ~ (Vector3 &v1)
{
return Vector3(~(v1.x), ~(v1.y), ~(v1.z));
}
const Vector3 operator & (const Vector3 &v1, const Vector3 &v2)
{
return Vector3(v1.x & v2.x, v1.y & v2.y, v1.z & v2.z);
}
//Побитовое исключающее ИЛИ (xor)
const Vector3 operator ^ (const Vector3 &v1, const Vector3 &v2)
{
return Vector3(v1.x ^ v2.x, v1.y ^ v2.y, v1.z ^ v2.z);
}
//Перегрузка операции вывода в поток
ostream& operator << (ostream &s, const Vector3 &v)
{
s << '(' << v.x << ", " << v.y << ", " << v.z << ')';
return s;
}
//Перегрузка операции ввода из потока (очень удобный вариант)
istream& operator >> (istream &s, Vector3 &v)
{
std::cout << "Введите Vector3.nX:";
std::cin >> v.x;
std::cout << "nY:";
std::cin >> v.y;
std::cout << "nZ:";
std::cin >> v.z;
std::cout << endl;
return s;
}
Пример перегрузки побитного составного присваивания &=, |=, ^=, <<=, >>=
Vector3 operator ^= (Vector3 &v1, Vector3 &v2)
{
v1(Vector3(v1.x = v1.x ^ v2.x, v1.y = v1.y ^ v2.y, v1.z = v1.z ^ v2.z));
return v1;
}
//Предварительно очищаем поток
ostream& operator <<= (ostream &s, Vector3 &v)
{
s.clear();
s << '(' << v.x << ", " << v.y << ", " << v.z << ')';
return s;
}
Пример перегрузки операторов работы с указателями и членами класса [], (), *, &, ->, ->*
Не вижу смысла перегружать (*, &, ->, ->*), поэтому примеров ниже не будет.
//Не делайте подобного! Такая перегрузка [] может ввести в заблуждение, это просто пример реализации
//Аналогично можно сделать для ()
int Vector3::operator [] (int n)
{
try
{
if (n < 3)
{
if (n == 0)
return this->x;
if (n == 1)
return this->y;
if (n == 2)
return this->z;
}
else
throw "Ошибка: Выход за пределы размерности вектора";
}
catch (char *str)
{
cerr << str << endl;
}
return NULL;
}
//Этот пример также не имеет практического смысла
Vector3 Vector3::operator () (Vector3 &v1, Vector3 &v2)
{
return Vector3(v1 & v2);
}
Как перегружать new и delete? Примеры:
//Выделяем память под 1 объект
void* Vector3::operator new(size_t v)
{
void *ptr = malloc(v);
if (ptr == NULL)
throw std::bad_alloc();
return ptr;
}
//Выделение памяти под несколько объектов
void* Vector3::operator new[](size_t v)
{
void *ptr = malloc(sizeof(Vector3) * v);
if (ptr == NULL)
throw std::bad_alloc();
return ptr;
}
void Vector3::operator delete(void* v)
{
free(v);
}
void Vector3::operator delete[](void* v)
{
free(v);
}
Перегрузка new и delete отдельная и достаточно большая тема, которую я не стану затрагивать в этой публикации.
Перегрузка оператора запятая ,
Внимание! Не стоит путать оператор запятой с знаком перечисления! (Vector3 var1, var2;)
const Vector3 operator , (Vector3 &v1, Vector3 &v2)
{
return Vector3(v1 * v2);
}
v1 = (Vector3(10, 10, 10), Vector3(20, 25, 30));
// Вывод: (200, 250, 300)
Источники
1) https://ru.wikipedia.org/wiki/Операторы в C и C++
2) Р. Лафоре Объектно-Ориентированное Программирование в С++
3) Спасибо всем за комментарии к публикации и указания на недочеты!
Содержание
- Перегрузка операторов
- Перегрузка арифметических операторов в C#
- Перегрузка логических операторов в C#
- Что стоит учитывать при перегрузке операторов в C#
- Операции преобразования типов
- Итого
уважаемые посетители блога, если Вам понравилась, то, пожалуйста, помогите автору с лечением. Подробности тут.
В одной из статей, посвященных работе в C#, мы рассматривали вопрос о перегрузке методов. Вместе с этим, в C# можно перегружать не только методы но и операторы, например, операторы сложения и вычитания, сравнения и так далее. Более того, мы можем сделать перегрузку операций преобразования типов. И сегодня мы рассмотрим некоторые практические примеры, когда нам может пригодиться такая возможность языка программирования C# как перегрузка операторов и операций преобразования типов.
Перегрузка операторов
Перегрузка операторов заключается в определении в классе, для объектов которого мы хотим определить оператор, специального метода:
public static возвращаемый_тип operator оператор(параметры) { }
Например, рассмотрим такой класс:
public class Point { public double X { get; set; } public double Y { get; set; } public Point(double x, double y) { X = x; Y = y; } }
Этот класс определяет точку на плоскости с координатами (X, Y). Нам необходимо обеспечить векторное сложение и векторное вычитание. Компилятор C# умеет складывать, вычитать, сравнивать примитивные типы данных, однако про то, как сравнивать наши собственные классы и объекты он не знает. Технически мы могли бы каждый раз писать что-то наподобие такого:
Point point1 = new Point(10,10); Point point2 = new Point(7,7); Point point3 = new Point(point1.X+point2.X,point1.Y+point2.Y);
И это вполне себе работоспособный код. Однако можно сделать лучше и красивее — переопределить оператор сложения:
public class Point { public double X { get; set; } public double Y { get; set; } public Point(double x, double y) { X = x; Y = y; } //переопределенный оператор сложения public static Point operator +(Point p1, Point p2) { return new Point(p1.X + p2.X, p1.Y + p2.Y); } }
Так как перегружаемый оператор будет использоваться для всех объектов данного класса, то он имеет модификаторы доступа public static
. При сложении возвращается объект класса Point
. Теперь мы можем сделать наш код более элегантным и понятным:
Point point1 = new Point(10, 10); Point point2 = new Point(7, 7); Point point3 = point1 + point2;//используем перегруженный оператор Console.WriteLine($"X = {point3.X} Y = {point3.Y}"); //X = 17 Y = 17
Аналогичным образом можно переопределять и другие арифметические операторы, в том числе операторы сложения, вычитания, умножения, деления и так далее. Например, вот так может выглядеть оператор * для выполнения операция скалярного умножения точки на плоскости:
public static Point operator *(double s, Point point) { return new Point(s * point.X, s * point.Y); }
И теперь мы можем умножать точку на любое число (выполнять скалярное умножение):
Point point2 = new Point(7, 7); Point point3 = 2.5*point2; Console.WriteLine($"X = {point3.X} Y = {point3.Y}"); //X = 17,5 Y = 17,5
Также следует упомянуть, что операторы в C# бывают унарные и бинарные, но в любом случае один из параметров должен представлять тот тип — класс или структуру, в котором определяется оператор.
Перегрузка логических операторов в C#
Немного иначе обстоит дело с перегрузкой логических операторов в C#. Отличие заключается в том, что операторы сравнения должны переопределяться попарно. Парными являются следующие операторы:
- Операторы
==
и!=
- Операторы
<
и>
- Операторы
<=
и>=
Например, переопределим оператор >. Переопределенный оператор в классе Point
может быть таким:
public static bool operator >(Point point1, Point point2) { return (point1.X > point2.X) || ((point1.X == point2.X) && (point1.Y > point2.Y)); }
При этом, как только мы переопределим один из парных операторов, компилятор C# сообщит нам об ошибке:
Ошибка CS0216 Для оператора «Point.operator >(Point, Point)» требуется, чтобы был определен соответствующий оператор «<«.
Поэтому, переопределяем и парный оператор <.
public static bool operator <(Point point1, Point point2) { return (point1.X < point2.X) || ((point1.X == point2.X) && (point1.Y < point2.Y)); }
Мы можем также переопределить операторы true
и false
. Например, определим их в классе Point
:
public class Point { public double X { get; set; } public double Y { get; set; } public Point(double x, double y) { X = x; Y = y; } public static bool operator true(Point p1) { return (p1.X != 0) && (p1.Y != 0); } public static bool operator false(Point p1) { return (p1.X == 0) && (p1.Y == 0); } }
Использовать эти операторы можно следующим образом:
Point point1 = new Point(10, 10); if (point1) //--используем операторы true/false у Point Console.WriteLine("Координаты точки point1 больше нуля"); else Console.WriteLine("Координаты точки point1 равны нулю"); Point point2 = new Point(0, 0); if (point2) //--используем операторы true/false у Point Console.WriteLine("Координаты точки point2 больше нуля"); else Console.WriteLine("Координаты точки point2 равны нулю");
Консольный вывод будет следующим:
Координаты точки point1 больше нуля
Координаты точки point2 равны нулю
Что стоит учитывать при перегрузке операторов в C#
При переопределении операторов в C# следует учитывать следующее:
- так как определение оператора представляет собой метод, то этот метод мы также можем перегрузить, то есть создать для него еще одну версию. О том, как перегружать методы в C# мы говорили здесь.
- при перегрузке не должны изменяться те объекты, которые передаются в оператор через параметры.
Второй пункт наиболее наглядно демонстрирует перегрузка унарных операторов, например, ++. Например, мы можем определить для класса Point
оператор инкремента:
public static Point operator ++(Point p1) { p1.X += 1; p1.Y += 1; return p1; }
Так как оператор ++ унарный, то он принимает один параметр — объект того класса, в котором данный оператор определен. Несмотря на то, что компилятор C# не предупредит нас об ошибке, это неправильное определение инкремента, так как оператор не должен менять значения своих параметров.
Более корректная перегрузка оператора инкремента будет выглядеть так:
public static Point operator ++(Point p1) { return new Point(p1.X + 1, p1.Y + 1); }
Использование унарного оператора будет таким же, как и при его использовании для простых типов данных. При этом нам не надо определять отдельно операторы для префиксного и для постфиксного инкремента (а также декремента), так как одна реализация будет работать в обоих случаях.
Point point1 = new Point(10, 10); point1++; ++point1; Console.WriteLine($"X = {point1.X} Y = {point1.Y}"); //X = 12 Y = 12
Полный список перегружаемых операторов можно найти в документации msdn
При перегрузке операторов также следует помнить, что мы не можем изменить приоритет оператора или его ассоциативность, мы не можем создать новый оператор или изменить логику операторов в типах, который есть по умолчанию в .NET.
Операции преобразования типов
С этой темой перегрузки операторов в C# тесно связана тема перегрузки операторов преобразования типов. В прошлой статье мы рассматривали восходящее и нисходящее преобразование типов. Было бы не плохо иметь возможность определять логику преобразования одних типов в другие. С помощью перегрузки операторов мы можем это делать. Для этого в классе необходимо определить метод, который имеет следующую форму:
public static implicit|explicit operator Тип_в_который_надо_преобразовать(исходный_тип param) { // логика преобразования }
После модификаторов public static
идет ключевое слово explicit
(если преобразование явное, то есть нужна операция приведения типов) или implicit
(если преобразование неявное). Затем идет ключевое слово operator
и далее возвращаемый тип, в который надо преобразовать объект. В скобках в качестве параметра передается объект, который надо преобразовать.
Например, пусть у нас есть следующий класс Counter
, который представляет секундомер и который хранит количество секунд в свойстве Seconds
:
class Counter { public int Seconds { get; set; } public static implicit operator Counter(int x) { return new Counter { Seconds = x }; } public static explicit operator int(Counter counter) { return counter.Seconds; } }
Первый оператор преобразует число — объект типа int
к типу Counter
. Его логика проста — создается новый объект Counter
, у которого устанавливается свойство Seconds
. Второй оператор преобразует объект Counter
к типу int
, то есть получает из Counter
число.
Применение операторов преобразования типов в программе может быть следующим:
Counter counter1 = new Counter { Seconds = 23 }; int x = (int)counter1; Console.WriteLine(x); // 23 Counter counter2 = x; Console.WriteLine(counter2.Seconds); // 23
Поскольку операция преобразования из Counter
в int
определена с ключевым словом explicit
, то есть как явное преобразование, то в этом случае необходимо применить операцию приведения типов:
int x = (int)counter1;
В случае с операцией преобразования от int
к Counter
операция определена с ключевым словом implicit
, то есть как неявная, поэтому в коде выше мы ничего не указывали перед переменной x
. Какие операции преобразования делать явными, а какие неявные — решает разработчик по своему усмотрению.
Отметим, что оператор преобразования типов должен преобразовывать из типа или в тип, в котором этот оператор определен. То есть оператор преобразования, определенный в типе Counter
, должен либо принимать в качестве параметра объект типа Counter
, либо возвращать объект типа Counter
. Рассмотрим также более сложные преобразования, к примеру, из одного составного типа в другой составной тип. Допустим, у нас есть еще класс Timer
:
class Timer { public int Hours { get; set; } public int Minutes { get; set; } public int Seconds { get; set; } } class Counter { public int Seconds { get; set; } public static implicit operator Counter(int x) { return new Counter { Seconds = x }; } public static explicit operator int(Counter counter) { return counter.Seconds; } //преобразования в Timer и из Timer public static explicit operator Counter(Timer timer) { int h = timer.Hours * 3600; int m = timer.Minutes * 60; return new Counter { Seconds = h + m + timer.Seconds }; } public static implicit operator Timer(Counter counter) { int h = counter.Seconds / 3600; int m = (counter.Seconds % 3600) / 60; int s = counter.Seconds % 60; return new Timer { Hours = h, Minutes = m, Seconds = s }; } }
Класс Timer
представляет условный таймер, который хранит часы, минуты и секунды. Класс Counter
представляет секундомер, который хранит только количество секунд. Исходя из этого мы можем определить логику преобразования из одного типа к другому, то есть получение из секунд в объекте Counter
часов, минут и секунд в объекте Timer
.
Применение операций преобразования:
Counter counter1 = new Counter { Seconds = 115 }; Timer timer = counter1; Console.WriteLine($"{timer.Hours}:{timer.Minutes}:{timer.Seconds}"); // 0:1:55 Counter counter2 = (Counter)timer; Console.WriteLine(counter2.Seconds); //115
Итого
Сегодня мы рассмотрели вопросы, связанные с перегрузкой операторов и операций преобразования типов в C#. Перегрузка операторов позволяет определять собственную логику при использовании операторов, например, арифметических или логических при использовании собственных классов. С помощью перегрузки операций преобразования типов мы можем определить логику преобразования одного типа данных (например, класса) в другой.
уважаемые посетители блога, если Вам понравилась, то, пожалуйста, помогите автору с лечением. Подробности тут.