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

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

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

Содержание

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

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

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

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

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

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

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

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

Реализация

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

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

Пример реализации на С#

Перегрузка операторов тесно связана с перегрузкой методов. Для перегрузки оператора служит ключевое слово Operator, определяющее «операторный метод», который, в свою очередь, определяет действие оператора относительно своего класса. Существует две формы операторных методов (operator): одна — для унарных операторов, другая для бинарных. Ниже приведена общая форма для каждой разновидности этих методов.

// общая форма перегрузки унарного оператора.
public static возвращаемый тип operator op (тип_параметра операнд)
{
// операции
}
// Общая форма перегрузки бинарного оператора.
public static возвращаемый_тип operator op (тип_параметра1 операнд1,
                                            тип_параметра1 операнд2)
{
// операции
}

Здесь вместо «op» подставляется перегружаемый оператор, например + или /; а «возвращаемый_тип» обозначает конкретный тип значения, возвращаемого указанной операцией. Это значение может быть любого типа, но зачастую оно указывается такого же типа, как и у класса, для которого перегружается оператор. Такая корреляция упрощает применение перегружаемых операторов в выражениях. Для унарных операторов операнд обозначает передаваемый операнд, а для бинарных операторов то же самое обозначают «операнд1 и операнд2». Следует обратить внимание, что операторные методы должны иметь оба типа, public и static. Тип операнда унарных операторов должен быть таким же, как и у класса, для которого перегружается оператор. А в бинарных операторах хотя бы один из операндов должен быть такого же типа, как и у его класса. Следовательно, в C# не допускается перегрузка любых операторов для объектов, которые еще не были созданы. Например, назначение оператора + нельзя переопределить для элементов типа int или string. И еще одно замечание: в параметрах оператора нельзя использовать модификатор ref или out. [2]

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

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

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

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

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

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

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

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

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

Критика

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

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

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

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

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

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

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

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

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

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

Ада
C++
C#
D
Object Pascal
Perl
Python
VB.NET

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

ML
Pico
Лисп

Алгол 68
Фортран
Haskell
PostgreSQL
Пролог
Руби
Perl 6
Smalltalk

Примечания

  1. Этот вариант тоже не бесспорен: возникают разночтения и путаница[источник не указан 1169 дней] в переводах английских «override»[источник не указан 804 дня], «overload»[источник не указан 1169 дней] и «redefine»[источник не указан 1169 дней].
  2. [Герберт Шилдт|2011|Полное руководство C# 4.0]

См. также



Wikimedia Foundation. 2010.

Смотреть что такое "Перегрузка операторов" в других словарях:

  • Перегрузка процедур и функций — У этого термина существуют и другие значения, см. Перегрузка. Перегрузка процедур и функций  возможность использования одноимённых подпрограмм: процедур или функций в языках программирования. Содержание 1 Причина появления 2 Реализация …   Википедия

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

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

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

  • C++ — У этого термина существуют и другие значения, см. C. См. также: Си (язык программирования) C++ Семантика: мультипарадигмальный: объектно ориентированное, обобщённое, процедурное, метапрограммирование Тип исполнения: компилируемый Появился в …   Википедия

  • Python — У этого термина существуют и другие значения, см. Python (значения). Python Класс языка: му …   Википедия

  • D (язык программирования) — У этого термина существуют и другие значения, см. D. D Семантика: мультипарадигменный: императивное, объектно ориентированное, обобщённое программирование Тип исполнения: компилятор Появился в: 1999 Автор(ы) …   Википедия

  • С++ — См. также: Си (язык программирования) C++ Семантика: мультипарадигмальный: объектно ориентированное, обобщённое, процедурное, метапрограммирование Тип исполнения: компилируемый Появился в: 1985 г. Автор(ы): Бьёрн Страуструп …   Википедия

  • Object Pascal — Семантика: императивная Класс языка: мультипарадигмальный: императивный, структурный, объектно ориентированный, обобщённый[1], процедурный Тип исполнения: компилируемый …   Википедия

  • Сравнение языков программирования — Эту статью следует викифицировать. Пожалуйста, оформите её согласно правилам оформления статей.  Условные обозначения  …   Википедия

Книги

Другие книги по запросу «Перегрузка операторов» >>


Поделиться ссылкой на выделенное

Прямая ссылка:
Нажмите правой клавишей мыши и выберите «Копировать ссылку»