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

Материал из Википедии — свободной энциклопедии
Перейти к навигации Перейти к поиску
Полиморфизм
Специальный полиморфизм
Параметрический полиморфизм
Полиморфизм подтипов

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

Терминология

[править | править код]

Термин «перегрузка» — это калька английского слова overloading. Такой перевод появился в книгах по языкам программирования в первой половине 1990-х годов. В изданиях советского периода аналогичные механизмы назывались переопределением или повторным определением, перекрытием операций.

Причины появления

[править | править код]

Иногда возникает потребность описывать и применять к созданным программистом типам данных операции, по смыслу эквивалентные уже имеющимся в языке. Классический пример — библиотека для работы с комплексными числами. Они, как и обычные числовые типы, поддерживают арифметические операции, и естественным было бы создать для данного типа операции «плюс», «минус», «умножить», «разделить», обозначив их теми же самыми знаками операций, что и для других числовых типов. Запрет на использование определённых в языке элементов вынуждает создавать множество функций с именами вида ComplexPlusComplex, IntegerPlusComplex, ComplexMinusFloat и так далее.

Когда одинаковые по смыслу операции применяются к операндам различных типов, их вынужденно приходится называть по-разному. Невозможность применять для разных типов функции с одним именем приводит к необходимости выдумывать различные имена для одного и того же, что создаёт путаницу, а может и приводить к ошибкам. Например, в классическом языке Си существует два варианта стандартной библиотечной функции нахождения модуля числа: abs() и fabs() — первый предназначен для целого аргумента, второй — для вещественного. Такое положение, в сочетании со слабым контролем типов Си, может привести к труднообнаруживаемой ошибке: если программист напишет в вычислении abs(x), где x — вещественная переменная, то некоторые компиляторы без предупреждений сгенерируют код, который будет преобразовывать x к целому путём отбрасывания дробной части и вычислять модуль от полученного целого числа.

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

Средства, позволяющие расширять язык, дополнять его новыми операциями и синтаксическими конструкциями (а перегрузка операций является одним из таких средств, наряду с объектами, макрокомандами, функционалами, замыканиями) превращают его уже в метаязык — средство описания языков, ориентированных на конкретные задачи. С его помощью можно для каждой конкретной задачи построить языковое расширение, наиболее ей соответствующее, которое позволит описывать её решение в наиболее естественной, понятной и простой форме. Например, в приложении к перегрузке операций: создание библиотеки сложных математических типов (векторы, матрицы) и описание операций с ними в естественной, «математической» форме, создаёт «язык для векторных операций», в котором сложность вычислений скрыта, и возможно описывать решение задач в терминах векторных и матричных операций, концентрируясь на сути задачи, а не на технике. Именно из этих соображений подобные средства были в своё время включены в язык Алгол-68.

Механизм перегрузки

[править | править код]

Реализация

[править | править код]

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

  • Чтобы разрешить существование нескольких одноимённых операций, достаточно ввести в язык правило, согласно которому операция (процедура, функция или оператор) опознаются компилятором не только по имени (обозначению), но и по типам их параметров. Так например в C++ abs(int i), где i объявлено как целое, и abs(float x), где x объявлено как вещественное — это две разные операции. Принципиально в обеспечении именно такой трактовки нет никаких сложностей.
  • Чтобы дать возможность определять и переопределять операции, необходимо ввести в язык соответствующие синтаксические конструкции. Вариантов их может быть достаточно много, но по сути они ничем друг от друга не отличаются, — достаточно помнить, что запись вида «<операнд1> <знакОперации> <операнд2>» принципиально аналогична вызову функции «<знакОперации>(<операнд1>,<операнд2>)». Достаточно разрешить программисту описывать поведение операторов в виде функций, — и проблема описания решена.

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

[править | править код]

В C++ можно выделить четыре типа перегрузок операторов:

  1. Перегрузка обычных операторов + - * / % ˆ & | ~ ! = < > += -= *= /= %= ˆ= &= |= << >> >>= <<= == != <= >= && || ++ -- , ->* -> ( ) <=> [ ]
  2. Перегрузка операторов преобразования типа
  3. Перегрузка операторов размещения '''new''' и уничтожения '''delete''' объектов в памяти.
  4. Перегрузка литералов operator""

Обычные операторы

[править | править код]

Важно помнить, что перегрузка расширяет возможности языка, а не изменяет язык, поэтому перегружать операторы для встроенных типов нельзя. Нельзя менять приоритет и ассоциативность (слева направо или справа налево) операторов. Нельзя создавать собственные операторы и перегружать некоторые встроенные: :: . .* ?: sizeof typeid. Также операторы && || , теряют свои уникальные свойства при перегрузке: ленивость для первых двух и очерёдность для запятой (порядок выражений между запятыми строго определён, как лево-ассоциативный, то есть слева-направо). Оператор -> должен возвращать либо указатель, либо объект (по копии или ссылке).

Операторы могут быть перегружены и как отдельные функции, и как функции-члены класса. Во втором случае левым аргументом оператора всегда выступает объект *this. Операторы = -> [] () могут быть перегружены только как методы (функции-члены), но не как функции.

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

class Point
{
    int x, y;
  public:
    Point(int x, int xx): x(x), y(xx) {}  // Конструктор по-умолчанию исчез. 
    // Имена аргументов конструктора могут совпадать с именами полей класса.
}
  • Операторы присваивания копированием и перемещением operator=
    Стоит учитывать, что по-умолчанию C++ помимо конструктора создаёт пять базовых функций. Поэтому перегрузку операторов присваивания копированием и перемещением лучше поручить компилятору или реализовать с помощью идиомы Copy-and-swap.
  • Комбинированные арифметические операторы += *= -= /= %= и т. д.
    Если мы хотим реализовать обычные бинарные арифметические операторы, удобнее будет реализовать вначале данную группу операторов.
    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;
    }
    
Оператор возвращает const значение. Это защитит нас от написания конструкций подобного вида (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. Внимание: оператор & имеет унарный аналог и используется для взятия адреса; обычно не перегружается.
  • Логические операторы && ||
    Эти операторы потеряют свои уникальные свойства ленивости при перегрузке.
  • Инкремент и декремент ++ --
    C++ позволяет перегрузить как постфиксные, так и префиксные инкремент и декремент. Рассмотрим инкремент:
Point& Point::operator++() { //префиксный
	x++;
	y++;
	return *this;
}
Point Point::operator++(int) { //постфиксный
	Point tmp(x,y,i);
	++(*this);
	return tmp;
}
Заметим, что функция-член operator++(int) принимает значение типа int, но у этого аргумента нет имени. C++ позволяет создавать такие функции. Мы можем присвоить ему (аргументу) имя и увеличивать значения точек на этот коэффициент, однако в операторной форме этот аргумент по-умолчанию будет равен нулю и вызывать его можно будет только в функциональном стиле: 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.[1]

Варианты и проблемы

[править | править код]

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

Проблема идентификации

[править | править код]

Проблема первая — контекстная зависимость. Т. е. первый вопрос с которым сталкивается разработчик транслятора языка, разрешающего перегрузку процедур и функций: каким образом из числа одноимённых процедур выбрать ту, которая должна быть применена в данном конкретном случае? Всё хорошо, если существует вариант процедуры, типы формальных параметров которого в точности совпадают с типами параметров фактических, применённых в данном вызове. Однако практически во всех языках в употреблении типов существует некоторая степень свободы, предполагающая, что компилятор в определённых ситуациях автоматически безопасно преобразовывает (приводит) типы данных. Например, в арифметических операциях над вещественным и целочисленными аргументами целочисленный обычно приводится к вещественному типу автоматически, и результат получается вещественным. Предположим, что существует два варианта функции 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? Нужно отметить, что приведённая ситуация — простейшая. Но возможны гораздо более запутанные случаи, которые усугубляются тем, что не только встроенные типы могут преобразовываться по правилам языка, но и объявленные программистом классы при наличии у них родственных отношений допускают приведение одного к другому. Решений у этой проблемы два:

  • Запретить неточную идентификацию вообще. Требовать, чтобы для каждой конкретной пары типов существовал в точности подходящий вариант перегруженной процедуры или операции. Если такого варианта нет, транслятор должен выдавать ошибку. Программист в этом случае должен применить явное преобразование, чтобы привести фактические параметры к нужному набору типов. Этот подход неудобен в языках типа C++, допускающих достаточную свободу в обращении с типами, поскольку он приводит к существенному различию поведения встроенных и перегруженных операций (к обычным числам арифметические операции можно применять, не задумываясь, а к другим типам — только с явным преобразованием) либо к появлению огромного количества вариантов операций.
  • Установить определённые правила выбора «ближайшего подходящего варианта». Обычно в этом варианте компилятор выбирает те из вариантов, вызовы которых можно получить из исходного только безопасными (не приводящими к потере информации) преобразованиями типов, а если их несколько — может выбирать, исходя из того, какой вариант требует меньше таких преобразований. Если в результате остаётся несколько возможностей, компилятор выдаёт ошибку и требует явного указания варианта от программиста.

Специфические вопросы перегрузки операций

[править | править код]

В отличие от процедур и функций, инфиксные операции языков программирования имеют два дополнительных свойства, существенным образом влияющие на их функциональность: приоритет и ассоциативность, наличие которых обусловливается возможностью «цепочной» записи операторов (как понимать 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. Разработчик и реализатор языка должен каким-то образом решать подобные проблемы. Варианты, опять-таки, могут быть различными: потребовать, чтобы все новые операции были односимвольными, постулировать, что при любых разночтениях выбирается «самый длинный» вариант операции (то есть до тех пор, пока очередной читаемый транслятором набор символов совпадает с какой-либо операцией, он продолжает считываться), пытаться обнаруживать коллизии при трансляции и выдавать ошибки в спорных случаях… Так или иначе, языки, допускающие объявление новых операций, решают эти проблемы.

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

Перегрузка и полиморфные переменные

[править | править код]

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

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

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

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

Критики отмечают, что приведённые выше проблемы идентификации, приоритета и ассоциативности часто делают работу с перегруженными операциями либо неоправданно сложной, либо неестественной:

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

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

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

  • Сторонники «пуританского» подхода к построению языков, такие как Вирт или Хоар, выступают против перегрузки операций уже просто потому, что без неё якобы можно легко обойтись. По их мнению, подобные средства лишь усложняют язык и транслятор, не предоставляя соответствующих этому усложнению дополнительных возможностей. По их мнению, сама идея создания ориентированного на задачу расширения языка лишь выглядит привлекательно. В действительности же использование средств расширения языка делает программу понятной только её автору — тому, кто это расширение разработал. Программу становится гораздо труднее понимать и анализировать другим программистам, что затрудняет сопровождение, изменение и групповую разработку.
  • Отмечается, что сама возможность использования перегрузки часто играет провоцирующую роль: программисты начинают пользоваться ею где только возможно, в результате средство, призванное упростить и упорядочить программу, становится причиной её избыточного усложнения и запутывания.
  • Перегруженные операции могут делать не совсем то, что ожидается от них, исходя из их вида. Например, a + b обычно (но не всегда) означает то же самое, что b + a, но «один» + «два» отличается от «два» + «один» в языках, где оператор + перегружен для конкатенации строк.
  • Перегрузка операций делает фрагменты программы более контекстно-зависимыми. Не зная типов участвующих в выражении операндов, невозможно понять, что это выражение делает, если в нём используются перегруженные операции. Например, в программе на C++ оператор << может означать и побитовый сдвиг, и вывод в поток, и сдвиг символов в строке на заданное число позиций. Выражение a << 1 возвращает:
    • результат побитового сдвига значения a на один бит влево, если a — целое число;
    • если a — строка, то результатом будет строка с добавленным в конец одним пробельным символом (будет сделан посимвольный сдвиг на 1 позицию влево), причём в разных компьютерных системах код пробельного символа может различаться;
    • но если a является выходным потоком, то же выражение выведет число 1 в этот поток «1».

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

Классификация

[править | править код]

Ниже приведена классификация некоторых языков программирования по тому, допускают ли они перегрузку операторов, и ограничены ли операторы предопределённым набором:

Множество
операторов
Перегрузки
нет
Перегрузка
есть
Только
предопределённые

Си
Java
JavaScript
Objective-C
Паскаль
PHP
ActionScript
Go

Ада
C++
C#
D
Object Pascal
Perl
Python
Руби
VB.NET
Delphi
Kotlin
Rust
Swift

Groovy

Возможно
вводить новые

ML
Pico
Лисп

Алгол 68
Фортран
Haskell
PostgreSQL
Пролог
Perl 6
Seed7
Smalltalk
Julia

Примечания

[править | править код]
  1. Герберт Шилдт. Полное руководство C# 4.0, 2011.