Теория операторов
- 1 year ago
- 0
- 0
Перегрузка операторов в программировании — один из способов реализации полиморфизма , заключающийся в возможности одновременного существования в одной области видимости нескольких различных вариантов применения операторов, имеющих одно и то же имя, но различающихся типами параметров, к которым они применяются.
Термин « перегрузка » — это калька английского слова overloading . Такой перевод появился в книгах по языкам программирования в первой половине 1990-х годов. В изданиях советского периода аналогичные механизмы назывались переопределением или повторным определением , перекрытием операций.
Иногда возникает потребность описывать и применять к созданным программистом типам данных операции, по смыслу эквивалентные уже имеющимся в языке. Классический пример — библиотека для работы с комплексными числами . Они, как и обычные числовые типы, поддерживают арифметические операции, и естественным было бы создать для данного типа операции «плюс», «минус», «умножить», «разделить», обозначив их теми же самыми знаками операций, что и для других числовых типов. Запрет на использование определённых в языке элементов вынуждает создавать множество функций с именами вида ComplexPlusComplex, IntegerPlusComplex, ComplexMinusFloat и так далее.
Когда одинаковые по смыслу операции применяются к операндам различных типов, их вынужденно приходится называть по-разному. Невозможность применять для разных типов функции с одним именем приводит к необходимости выдумывать различные имена для одного и того же, что создаёт путаницу, а может и приводить к ошибкам. Например, в классическом языке Си существует два варианта стандартной библиотечной функции нахождения модуля числа: abs() и fabs() — первый предназначен для целого аргумента, второй — для вещественного. Такое положение, в сочетании со слабым контролем типов Си, может привести к труднообнаруживаемой ошибке: если программист напишет в вычислении abs(x), где x — вещественная переменная, то некоторые компиляторы без предупреждений сгенерируют код, который будет преобразовывать x к целому путём отбрасывания дробной части и вычислять модуль от полученного целого числа.
Отчасти проблема решается средствами объектного программирования — когда новые типы данных объявляются как классы, операции над ними могут быть оформлены как методы классов, в том числе и одноимённые (поскольку методы разных классов не обязаны иметь различные имена), но, во-первых, оформление подобным образом операций над значениями разных типов неудобно, а во-вторых, это не решает проблему создания новых операторов.
Средства, позволяющие расширять язык, дополнять его новыми операциями и синтаксическими конструкциями (а перегрузка операций является одним из таких средств, наряду с объектами, макрокомандами, функционалами, замыканиями) превращают его уже в метаязык — средство описания языков, ориентированных на конкретные задачи. С его помощью можно для каждой конкретной задачи построить языковое расширение, наиболее ей соответствующее, которое позволит описывать её решение в наиболее естественной, понятной и простой форме. Например, в приложении к перегрузке операций: создание библиотеки сложных математических типов (векторы, матрицы) и описание операций с ними в естественной, «математической» форме, создаёт «язык для векторных операций», в котором сложность вычислений скрыта, и возможно описывать решение задач в терминах векторных и матричных операций, концентрируясь на сути задачи, а не на технике. Именно из этих соображений подобные средства были в своё время включены в язык Алгол-68 .
Перегрузка операций предполагает введение в язык двух взаимосвязанных особенностей: возможности объявлять в одной области видимости несколько процедур или функций с одинаковыми именами и возможности описывать собственные реализации бинарных операторов (то есть знаков операций, обычно записываемых в инфиксной нотации, между операндами). Принципиально реализация их достаточно проста:
В C++ можно выделить четыре типа перегрузок операторов:
Важно помнить, что перегрузка расширяет возможности языка, а не изменяет язык, поэтому перегружать операторы для встроенных типов нельзя. Нельзя менять приоритет и ассоциативность (слева направо или справа налево) операторов. Нельзя создавать собственные операторы и перегружать некоторые встроенные:
:: . .* ?: sizeof typeid
. Также операторы
&& || ,
теряют свои уникальные свойства при перегрузке:
ленивость
для первых двух и очерёдность для запятой (порядок выражений между запятыми строго определён, как лево-ассоциативный, то есть слева-направо). Оператор
->
должен возвращать либо указатель, либо объект (по копии или ссылке).
Операторы могут быть перегружены и как отдельные функции, и как функции-члены класса. Во втором случае левым аргументом оператора всегда выступает объект *this. Операторы
= -> [] ()
могут быть перегружены только как методы (функции-члены), но не как функции.
Можно сильно облегчить написание кода, если производить перегрузку операторов в определённом порядке. Это не только ускорит написание, но и избавит от дублирования одного и того же кода. Рассмотрим перегрузку на примере класса, представляющего собой геометрическую точку в двумерном векторном пространстве:
class Point
{
int x, y;
public:
Point(int x, int xx): x(x), y(xx) {} // Конструктор по-умолчанию исчез.
// Имена аргументов конструктора могут совпадать с именами полей класса.
}
operator=
+= *= -= /= %=
и т. д.
Point& Point::operator+=(const Point& rhs) {
x += rhs.x;
y += rhs.y;
return *this;
}
(a += b) += c;
+ * - / %
const Point Point::operator+(const Point& rhs) const {
return Point(*this) += rhs;
}
(a + b) = c;
. С другой стороны, для классов, копирование которых дорого обходится, гораздо выгоднее возвращать значение по неконстантной копии, то есть :
MyClass MyClass::operator+(const MyClass& rhs) const;
. Тогда при такой записи
x = y + z;
будет вызван конструктор перемещения, а не копирования.
+ -
Point Point::operator+() {
return Point(*this);
}
Point Point::operator-() {
Point tmp(*this);
tmp.x *= -1;
tmp.y *= -1;
return tmp;
}
== != < <= > >=
bool Point::operator==(const Point& rhs) const {
return (this->x == rhs.x && this->y == rhs.y);
}
bool Point::operator!=(const Point& rhs) const {
return !(*this == rhs);
}
<<= >>= &= |= ^= и << >> & | ^ ~
std::bitset
. Внимание: оператор & имеет унарный аналог и используется для взятия адреса; обычно не перегружается.
&& ||
++ --
Point& Point::operator++() { //префиксный
x++;
y++;
return *this;
}
Point Point::operator++(int) { //постфиксный
Point tmp(x,y,i);
++(*this);
return tmp;
}
A.operator++(5);
friend std::ostream& operator<<(const ostream& s, const Point& p);
На остальные операторы не распространяются какие-либо общие рекомендации к перегрузке.
Преобразования типов позволяют задать правила преобразования нашего класса к другим типам и классам. Также можно указать спецификатор explicit, который позволит преобразовывать типы только, если программист явно это указал (например static_cast<Point3>(Point(2,3)); ). Пример:
Point::operator bool() const {
return this->x != 0 || this->y != 0;
}
Операторы
new new[] delete delete[]
могут быть перегружены и могут принимать любое количество аргументов. Причём операторы
new и new[]
первым аргументом обязаны принять аргумент типа
std::size_t
и возвращать значение типа
void *
, а операторы
delete delete[]
принимать первым
void *
и ничего не возвращать (
void
). Эти операторы могут быть перегружены как функции, так и для конкретных классов.
Пример:
void* MyClass::operator new(std::size_t s, int a)
{
void * p = malloc(s*a);
if(p == nullptr)
throw "No free memory!";
return p;
}
// ...
// Вызов:
MyClass * p = new(12) MyClass;
Пользовательские литералы появились с одиннадцатого стандарта C++. Литералы ведут себя как обычные функции. Они могут быть с квалификатором
inline
или
constexpr
. Желательно, чтобы литерал начинался с символа нижнего подчёркивания, так как может возникнуть коллизия с будущими стандартами. Например, литерал i уже принадлежит комплексным числам из
std::complex
.
Литералы могут принимать только один из следующих типов:
const char * , unsigned long long int , long double , char , wchar_t , char16_t , char32_t
. Достаточно перегрузить литерал только для типа
const char *
. Если не найдено более подходящего кандидата, то будет вызван оператор с этим типом. Пример преобразования миль в километры:
constexpr int operator "" _mi (unsigned long long int i)
{ return 1.6 * i;}
constexpr double operator "" _mi (long double i)
{ return 1.6 * i;}
Строковые литералы принимают вторым аргументом
std::size_t
, а первым один из:
const char * , const wchar_t *, const char16_t * , const char32_t *
. Строковые литералы применяются к записям, сделанным в двойных кавычках.
В C++ встроен префиксный строковый литерал
R
, который воспринимает все символы в кавычках как обычные и не интерпретирует определённые последовательности в специальные символы. Например, такая команда
std::cout << R"(Hello!\n)"
выведет на экран
Hello!\n
.
Перегрузка операторов тесно связана с перегрузкой методов. Для перегрузки оператора служит ключевое слово Operator, определяющее «операторный метод», который, в свою очередь, определяет действие оператора относительно своего класса. Существует две формы операторных методов (operator): одна — для унарных операторов , другая для бинарных . Ниже приведена общая форма для каждой разновидности этих методов.
// общая форма перегрузки унарного оператора.
public static возвращаемый_тип operator op (тип_параметра операнд)
{
// операции
}
// Общая форма перегрузки бинарного оператора.
public static возвращаемый_тип operator op (тип_параметра1 операнд1,
тип_параметра2 операнд2)
{
// операции
}
Здесь вместо «op» подставляется перегружаемый оператор, например + или /; а «возвращаемый_тип» обозначает конкретный тип значения, возвращаемого указанной операцией. Это значение может быть любого типа, но зачастую оно указывается такого же типа, как и у класса, для которого перегружается оператор. Такая корреляция упрощает применение перегружаемых операторов в выражениях. Для унарных операторов операнд обозначает передаваемый операнд, а для бинарных операторов то же самое обозначают «операнд1 и операнд2». Следует обратить внимание, что операторные методы должны иметь оба типа, public и static. Тип операнда унарных операторов должен быть таким же, как и у класса, для которого перегружается оператор. А в бинарных операторах хотя бы один из операндов должен быть такого же типа, как и у его класса. Следовательно, в C# не допускается перегрузка любых операторов для объектов, которые еще не были созданы. Например, назначение оператора + нельзя переопределить для элементов типа int или string . В параметрах оператора нельзя использовать модификатор ref или out.
Перегрузка процедур и функций на уровне общей идеи, как правило, не представляет сложности ни в реализации, ни в понимании. Однако даже в ней имеются некоторые «подводные камни», которые необходимо учитывать. Разрешение перегрузки операций создаёт гораздо больше проблем как для реализатора языка, так и для работающего на этом языке программиста.
Проблема первая — контекстная зависимость . Т. е. первый вопрос с которым сталкивается разработчик транслятора языка, разрешающего перегрузку процедур и функций: каким образом из числа одноимённых процедур выбрать ту, которая должна быть применена в данном конкретном случае? Всё хорошо, если существует вариант процедуры, типы формальных параметров которого в точности совпадают с типами параметров фактических, применённых в данном вызове. Однако практически во всех языках в употреблении типов существует некоторая степень свободы, предполагающая, что компилятор в определённых ситуациях автоматически безопасно преобразовывает (приводит) типы данных. Например, в арифметических операциях над вещественным и целочисленными аргументами целочисленный обычно приводится к вещественному типу автоматически, и результат получается вещественным. Предположим, что существует два варианта функции add:
int add(int a1, int a2); float add(float a1, float a2);
Каким образом компилятор должен обработать выражение
y = add(x, i)
, где x имеет тип float, а i — тип int? Очевидно, что точного совпадения нет. Имеется два варианта: либо
y=add_int((int)x,i)
, либо как
y=add_flt(x, (float)i)
(здесь именами
add_int
и
add_flt
обозначены соответственно, первый и второй варианты функции).
Возникает вопрос: должен ли транслятор разрешать подобное использование перегруженных функций, а если должен, то на каком основании он будет выбирать конкретный используемый вариант? В частности, в приведённом выше примере, должен ли транслятор при выборе учитывать тип переменной y? Нужно отметить, что приведённая ситуация — простейшая. Но возможны гораздо более запутанные случаи, которые усугубляются тем, что не только встроенные типы могут преобразовываться по правилам языка, но и объявленные программистом классы при наличии у них родственных отношений допускают приведение одного к другому. Решений у этой проблемы два:
В отличие от процедур и функций, инфиксные операции языков программирования имеют два дополнительных свойства, существенным образом влияющие на их функциональность:
приоритет
и
ассоциативность
, наличие которых обусловливается возможностью «цепочной» записи операторов (как понимать
a+b*c
: как
(a+b)*c
или как
a+(b*c)
? Выражение
a-b+c
— это
(a-b)+c
или
a-(b+c)
?).
Встроенные в язык операции всегда имеют наперёд заданные традиционные приоритеты и ассоциативность. Возникает вопрос: какие приоритеты и ассоциативность будут иметь переопределённые версии этих операций или, тем более, новые созданные программистом операции? Есть и другие тонкости, которые могут требовать уточнения. Например, в Си существуют две формы операций увеличения и уменьшения значения
++
и
--
— префиксная и постфиксная, поведение которых различается. Как должны вести себя перегруженные версии таких операций?
Различные языки по-разному решают приведённые вопросы. Так, в C++ приоритет и ассоциативность перегруженных версий операций сохраняются такими же, как и у предопределённых в языке, а описания перегрузки префиксной и постфиксной формы операторов инкремента и декремента используют различные сигнатуры:
Префиксная форма | Постфиксная форма | |
---|---|---|
Функция | T &operator ++(T &) | T operator ++(T &, int) |
Функция-член | T &T::operator ++() | T T::operator ++(int) |
Фактически целого параметра у операции нет — он фиктивен, и добавляется только для внесения различия в сигнатуры
Ещё один вопрос: допускать ли возможность перегрузки операций для встроенных и для уже объявленных типов данных? Может ли программист изменить реализацию операции сложения для встроенного целочисленного типа? Или для библиотечного типа «матрица»? Как правило, на первый вопрос отвечают отрицательно. Изменение поведения стандартных операций для встроенных типов — чрезвычайно специфическое действие, реальная необходимость в котором может возникать лишь в редких случаях, тогда как вредные последствия бесконтрольного применения такой возможности трудно даже предугадать во всей полноте. Поэтому язык обычно либо запрещает переопределять операции для встроенных типов, либо реализует механизм перегрузки операторов таким образом, чтобы с его помощью стандартные операции просто невозможно было бы перекрыть. Что касается второго вопроса (переопределение операторов, уже описанных для существующих типов), то необходимая функциональность полностью обеспечивается механизмом наследования классов и переопределения методов: если требуется изменить поведение уже имеющегося класса, его нужно унаследовать и переопределить описанные в нём операторы. При этом старый класс останется без изменений, новый получит нужную функциональность, а никаких коллизий не возникнет.
Ещё сложнее обстоит дело с объявлением новых операций. Включить в язык саму возможность такого объявления несложно, но вот реализация его сопряжена со значительными трудностями. Объявление новой операции — это, фактически, создание нового ключевого слова языка программирования, осложнённое тем фактом, что операции в тексте, как правило, могут следовать без разделителей с другими лексемами. При их появлении возникают дополнительные трудности в организации лексического анализатора. Например, если в языке уже есть операции «+» и унарный «-» (изменение знака), то выражение
a+-b
можно безошибочно трактовать как
a + (-b)
, но если в программе объявляется новая операция
+-
, тут же возникает неоднозначность, ведь то же выражение можно уже разобрать и как
a (+-) b
. Разработчик и реализатор языка должен каким-то образом решать подобные проблемы. Варианты, опять-таки, могут быть различными: потребовать, чтобы все новые операции были односимвольными, постулировать, что при любых разночтениях выбирается «самый длинный» вариант операции (то есть до тех пор, пока очередной читаемый транслятором набор символов совпадает с какой-либо операцией, он продолжает считываться), пытаться обнаруживать коллизии при трансляции и выдавать ошибки в спорных случаях… Так или иначе, языки, допускающие объявление новых операций, решают эти проблемы.
Не следует забывать, что для новых операций также стоит вопрос определения ассоциативности и приоритета. Здесь уже нет готового решения в виде стандартной языковой операции, и обычно приходится просто задать эти параметры правилами языка. Например, сделать все новые операции левоассоциативными и дать им один и тот же, фиксированный, приоритет, либо ввести в язык средства задания того и другого.
Когда перегружаемые операции, функции и процедуры используются в языках со строгой типизацией, где каждая переменная имеет предварительно описанный тип, задача выбора варианта перегруженной операции, используемого в каждом конкретном случае, независимо от её сложности, решается транслятором. Это означает, что для компилируемых языков использование перегрузки операций никак не снижает быстродействие, — в любом случае, в объектном коде программы присутствует вполне определённая операция или вызов функции. Иначе обстоит дело при возможности использования в языке полиморфных переменных — переменных, могущих в разные моменты времени содержать значения разных типов.
Так как тип значения, к которому будет применяться перегруженная операция, неизвестен на момент трансляции кода, то компилятор лишается возможности выбрать нужный вариант заранее. В этой ситуации он вынужден встраивать в объектный код фрагмент, который непосредственно перед выполнением данной операции определит типы находящихся в аргументах значений и динамически выберет вариант, соответствующий этому набору типов. Причём такое определение нужно производить при каждом выполнении операции, — ведь даже тот же самый код, будучи вызван второй раз, вполне может исполняться по-другому…
Таким образом, использование перегрузки операций в сочетании с полиморфными переменными делает неизбежным динамическое определение вызываемого кода.
Использование перегрузки не всеми специалистами считается благом. Если перегрузка функций и процедур, в общем, не находит серьёзных возражений (отчасти, потому, что не приводит к некоторым типично «операторным» проблемам, отчасти — из-за меньшего соблазна её использования не по назначению), то перегрузка операций, как в принципе, так и в конкретных языковых реализациях, подвергается достаточно жёсткой критике со стороны многих теоретиков и практиков программирования.
Критики отмечают, что приведённые выше проблемы идентификации, приоритета и ассоциативности часто делают работу с перегруженными операциями либо неоправданно сложной, либо неестественной:
Object
, а не от соответствующих классов (сравнения, числовых операций, битовых и т.д.) или предопределённых интерфейсов.
\/
и
/\
для
дизъюнкции
и
конъюнкции
).
Насколько удобство от использования собственных операций способно перевесить неудобства от ухудшения управляемости программы — вопрос, не имеющий однозначного ответа.
Часть критиков высказываются против перегрузки операций, исходя из общих принципов теории разработки программного обеспечения и реальной промышленной практики.
a + b
обычно (но не всегда) означает то же самое, что
b + a
, но
«один» + «два»
отличается от
«два» + «один»
в языках, где оператор
+
перегружен для
конкатенации
строк
.
<<
может означать и побитовый сдвиг, и вывод в поток, и сдвиг символов в строке на заданное число позиций. Выражение
a << 1
возвращает:
a
на один бит влево, если
a
— целое число;
a
— строка, то результатом будет строка с добавленным в конец одним пробельным символом (будет сделан посимвольный сдвиг на 1 позицию влево), причём в разных компьютерных системах код пробельного символа может различаться;
a
является выходным
потоком
, то же выражение выведет число 1 в этот поток
«1»
.
Данная проблема естественным образом вытекает из двух предыдущих. Она легко нивелируется принятием соглашений и общей культурой программирования.
Ниже приведена классификация некоторых языков программирования по тому, допускают ли они перегрузку операторов, и ограничены ли операторы предопределённым набором:
Множество
операторов |
Перегрузки
нет |
Перегрузка
есть |
---|---|---|
Только
предопределённые |
Ада
|
|
Возможно
вводить новые |
Алгол 68
|
|
В статье
не хватает
ссылок на источники
(см.
рекомендации по поиску
).
|
Для улучшения этой статьи
желательно
:
|