- Перегрузка операторов
-
Перегрузка операторов — в программировании — один из способов реализации полиморфизма, заключающийся в возможности одновременного существования в одной области видимости нескольких различных вариантов применения оператора, имеющих одно и то же имя, но различающихся типами параметров, к которым они применяются.
Содержание
Терминология
Термин перегрузка — это калька английского слова 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»
. В первом случае состояние левого операнда остается неизменным, во втором — нет.
Классификация
Ниже приведена классификация некоторых языков программирования по тому, позволяют ли они перегрузку операторов, и ограничены ли операторы предопределённым набором:
Множество
операторовПерегрузки
нетПерегрузка
естьТолько
предопределённыеВозможно
вводить новыеАлгол 68
Фортран
Haskell
PostgreSQL
Пролог
Руби
Perl 6
SmalltalkПримечания
См. также
В этой статье не хватает ссылок на источники информации. Информация должна быть проверяема, иначе она может быть поставлена под сомнение и удалена.
Вы можете отредактировать эту статью, добавив ссылки на авторитетные источники.
Эта отметка установлена 14 мая 2011.Категория:- Концепции языков программирования
Wikimedia Foundation. 2010.