Перегрузка операций
Материал из Википедии — свободной энциклопедии
Перегрузка операций (операторов, функций, процедур) — в программировании — один из способов реализации полиморфизма, заключающийся в возможности одновременного существования в одной области видимости нескольких различных вариантов операции (оператора, функции или процедуры), имеющих одно и то же имя, но различающихся типами параметров, к которым они применяются.
Содержание |
[править] Терминология
Термин «перегрузка» — это калька английского «overloading», появившаяся в русских переводах книг по языкам программирования в первой половине 1990-х годов. Возможно, это не самый лучший вариант перевода, поскольку слово «перегрузка» в русском языке имеет устоявшееся собственное значение, кардинально отличающееся от вновь предложенного, тем не менее, он прижился и распространён достаточно широко. В изданиях советского времени аналогичные механизмы назывались по-русски «переопределением» или «повторным определением» операций, но и этот вариант небесспорен: возникают разночтения и путаница в переводах английских «override», «overload» и «redefine».
[править] Причины появления
В большинстве ранних языков программирования существовало ограничение, согласно которому одновременно в программе не может быть доступно более одной операции с одним и тем же именем. Соответственно, все функции и процедуры, видимые в данной точке программы, должны иметь различные имена. Имена и обозначения функций, процедур и операторов, являющихся частью языка программирования, не могут быть использованы программистом для именования собственных функций, процедур и операторов. В некоторых случаях программист может создать собственный программный объект с именем другого, уже имеющегося, но тогда вновь созданный объект «перекрывает» предыдущий, и одновременно использовать оба варианта становится невозможно.
Подобное положение неудобно в некоторых, достаточно часто встречающихся случаях.
- Иногда возникает потребность описывать и применять к созданным программистом типам данных операции, по смыслу эквивалентные уже имеющимся в языке. Классический пример — библиотека для работы с комплексными числами. Они, как и обычные числовые типы, поддерживают арифметические операции, и естественным было бы создать для данного типа операторы «плюс», «минус», «умножить», «разделить», обозначив их теми же самыми знаками операций, что и для других числовых типов. Запрет на использование определённых в языке элементов вынуждает создавать множество функций с именами вида ComplexPlusComplex, IntegerPlusComplex, ComplexMinusFloat и так далее.
- Когда одинаковые по смыслу операции применяются к операндам различных типов, их вынужденно приходится называть по-разному. Невозможность применять для разных типов функции с одним именем приводит к необходимости выдумывать различные имена для одного и того же, что создаёт путаницу, а может и приводить к ошибкам. Например, в классическом языке Си существует два варианта стандартной библиотечной функции нахождения модуля числа: abs() и fabs() — первый предназначен для целого аргумента, второй — для вещественного. Такое положение, в сочетании со слабым контролем типов Си, может привести к труднообнаруживаемой ошибке: если программист напишет в вычислении abs(x), где x — вещественная переменная, то некоторые компиляторы без предупреждений сгенерируют код, который будет преобразовывать x к целому путём отбрасывания дробной части и вычислять модуль от полученного целого числа!
Отчасти проблема решается средствами объектного программирования — когда новые типы данных объявляются как классы, операции над ними могут быть оформлены как методы классов, в том числе и одноимённые (поскольку методы разных классов не обязаны иметь различные имена), но, во-первых, оформление подобным образом операций над значениями разных типов неудобно, а во-вторых, это не решает проблему создания новых операторов.
Сама по себе перегрузка операторов — всего лишь «синтаксический сахар», хотя даже в таком качестве она может быть полезна, потому что она позволяет разработчику программировать более естественным образом и делает поведение пользовательских типов более похожим на поведение встроенных. Если же подойти к вопросу с более общих позиций, то можно заметить, что средства, позволяющие расширять язык, дополнять его новыми операциями и синтаксическими конструкциями (а перегрузка операций является одним из таких средств, наряду с объектами, макрокомандами, функционалами, замыканиями) превращают его уже в метаязык — средство описания языков, ориентированных на конкретные задачи. С его помощью можно для каждой конкретной задачи построить языковое расширение, наиболее ей соответствующее, которое позволит описывать её решение в наиболее естественной, понятной и простой форме. Например, в приложении к перегрузке операций: создание библиотеки сложных математических типов (векторы, матрицы) и описание операций с ними в естественной, «математической» форме, создаёт «язык для векторных операций», в котором сложность вычислений скрыта, и возможно описывать решение задач в терминах векторных и матричных операций, концентрируясь на сути задачи, а не на технике. Именно из этих соображений подобные средства были в своё время включены в язык Алгол-68.
[править] Механизм перегрузки
[править] Реализация
Перегрузка операций предполагает введение в язык двух взаимосвязанных особенностей: возможности объявлять в одной области видимости несколько процедур или функций с одинаковыми именами и возможности описывать собственные реализации операторов (то есть знаков операций, обычно записываемых в инфиксной нотации, между операндами). Принципиально реализация их достаточно проста:
- Чтобы разрешить существование нескольких одноимённых операций, достаточно ввести в язык правило, согласно которому операция (процедура, функция или оператор) опознаются компилятором не только по имени (обозначению), но и по типам их параметров. Таким образом, abs(i), где i объявлено как целое, и abs(x), где x объявлено как вещественное — это две разные операции. Принципиально в обеспечении именно такой трактовки нет никаких сложностей.
- Чтобы дать возможность определять и переопределять операторы, необходимо ввести в язык соответствующие синтаксические конструкции. Вариантов их может быть достаточно много, но по сути они ничем друг от друга не отличаются, достаточно помнить, что запись вида «<операнд1> <знакОперации> <операнд2>» принципиально аналогична вызову функции «<знакОперации>(<операнд1>,<операнд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)y)
(здесь именами add_int
и add_float
обозначены соответственно, первый и второй варианты функции).
Возникает вопрос: должен ли транслятор разрешать подобное использование перегруженных функций, а если должен, то на каком основании он будет выбирать конкретный используемый вариант? В частности, в приведённом выше примере, должен ли транслятор при выборе учитывать тип переменной y? Нужно отметить, что приведённая ситуация — простейшая, возможны гораздо более запутанные случаи, которые усугубляются тем, что не только встроенные типы могут преобразовываться по правилам языка, но и объявленные программистом классы при наличии у них родственных отношений допускают приведение один к другому. Решений у этой проблемы два:
- Запретить неточную идентификацию вообще. Требовать, чтобы для каждой конкретной пары типов существовал в точности подходящий вариант перегруженной процедуры или оператора. Если такого варианта нет, транслятор должен выдавать ошибку. Программист в этом случае должен применить явное преобразование, чтобы привести фактические параметры к нужному набору типов. Этот подход неудобен в языках типа C++, допускающих достаточную свободу в обращении с типами, поскольку он приводит к существенному различию поведения встроенных и перегруженных операций (к обычным числам арифметические операции можно применять, не задумываясь, а к другим типам — только с явным преобразованием) либо к появлению огромного количества вариантов операций.
- Установить определённые правила выбора «ближайшего подходящего варианта». Обычно в этом варианте компилятор выбирает те из вариантов, вызовы которых можно получить из исходного только безопасными (не приводящими к потере информации) преобразованиями типов, а если их несколько — может выбирать, исходя из того, какой вариант требует меньше таких преобразований. Если в результате остаётся несколько возможностей, компилятор выдаёт ошибку и требует явного указания варианта от программиста.
[править] Специфические вопросы перегрузки операторов
В отличие от процедур и функций, инфиксные операторы языков программирования имеют два дополнительных свойства, существенным образом влияющих на их функциональность: приоритет и ассоциативность, наличие которых обуславливается возможностью «цепочной» записи операторов (как понимать a+b*c
: как (a+b)*c
или как a+(b*c)
? Выражение a-b+c
— это (a-b)+c
или a-(b+c)
?).
Встроенные в язык операторы всегда имеют наперёд заданные традиционные приоритеты и ассоциативность. Возникает вопрос: какие приоритеты и ассоциативность будут иметь переопределённые версии этих операторов или, тем более, новые созданные программистом операторы? Есть и другие тонкости, которые могут требовать уточнения. Например, в Си существуют две формы операторов увеличения и уменьшения значения ++
и --
— префиксная и постфиксная, поведение которых различается. Как должны вести себя перегруженные версии таких операторов?
Различные языки по-разному решают приведённые вопросы. Так, в C++ приоритет и ассоциативность перегруженных версий операторов сохраняются такими же, как и у определённых в языке, а перегрузить отдельно префиксную и постфиксную форму операторов увеличения и уменьшения невозможно, так что применять после перегрузки можно обе формы, но работают они одинаково.
[править] Объявление новых операторов
Ещё сложнее обстоит дело с объявлением новых операторов. Включить в язык саму возможность такого объявления несложно, но вот реализация его сопряжена со значительными трудностями. Объявление нового оператора — это, фактически, создание нового ключевого слова языка программирования, осложнённое тем фактом, что операторы в тексте, как правило, могут следовать без разделителей с другими лексемами. При их появлении возникают дополнительные трудности в организации лексического анализатора. Например, если в языке уже есть операторы «+» и унарный «-» (изменение знака), то выражение a+-b
можно безошибочно трактовать как a + (-b)
, но если в программе объявляется новый оператор +-
, тут же возникает неоднозначность, ведь то же выражение можно уже разобрать и как a (+-) b
. Разработчик и реализатор языка должен каким-то образом решать подобные проблемы. Варианты, опять-таки, могут быть различными: потребовать, чтобы все новые операторы были односимвольными, постулировать, что при любых разночтениях выбирается «самый длинный» вариант оператора (то есть до тех пор, пока очередной читаемый транслятором набор символов совпадает с каким-либо оператором, он продолжает считываться), пытаться обнаруживать коллизии при трансляции и выдавать ошибки в спорных случаях… Так или иначе, языки, допускающие объявление новых операторов, решают эти проблемы.
Не следует забывать, что для новых операторов также стоит вопрос определения ассоциативности и приоритета. Здесь уже нет готового решения в виде стандартного языкового оператора, и обычно приходится просто задать эти параметры правилами языка. Например, сделать все новые операторы левоассоциативными и дать им один и тот же, фиксированный, приоритет, либо ввести в язык средства задания того и другого.
[править] Перегрузка и полиморфные переменные
Когда перегружаемые операторы, функции и процедуры используются в языках со строгой типизацией, где каждая переменная имеет предварительно описанный тип, задача выбора варианта перегруженной операции, используемого в каждом конкретном случае, независимо от её сложности, решается транслятором. Это означает, что для компилируемых языков использование перегрузки операций не приводит к снижению быстродействия — в любом случае, в объектном коде программы присутствует вполне определённая операция или вызов функции. Иначе обстоит дело при возможности использования в языке полиморфных переменных, то есть переменных, которые могут в разные моменты времени содержать значения разных типов.
Поскольку тип значения, к которому будет применяться перегруженная операция, неизвестен на момент трансляции кода, компилятор лишён возможности выбрать нужный вариант заранее. В этом случае он вынужден встраивать в объектный код фрагмент, который непосредственно перед выполнением данной операции определит типы находящихся в аргументах значений и динамически выберет вариант, соответствующий этому набору типов. Причём такое определение нужно производить при каждом исполнении операции, ведь даже тот же самый код, будучи вызван второй раз, вполне может исполняться по-другому.
Таким образом, использование перегрузки операций в сочетании с полиморфными переменными делает неизбежным динамическое определение вызываемого кода.
[править] Критика
Использование перегрузки не всеми специалистами считается благом. Если перегрузка функций и процедур, в общем, не находит серьёзных возражений (отчасти, потому, что не приводит к некоторым типично «операторным» проблемам, отчасти — из-за меньшего соблазна её использования не по назначению), то перегрузка операторов, как в принципе, так и в конкретных языковых реализациях, подвергается достаточно жёсткой критике со стороны многих теоретиков и практиков программирования.
Критики отмечают, что приведённые выше проблемы идентификации, приоритета и ассоциативности часто делают работу с перегруженными операторами либо неоправданно сложной, либо неестественной:
- Идентификация. Если в языке приняты жёсткие правила идентификации, то программист вынужден помнить, для каких именно сочетаний типов существуют перегруженные операции и вручную приводить к ним операнды. Если же язык допускает «приблизительную» идентификацию, никогда нельзя поручиться, что в некоей, достаточно сложной ситуации будет выполнен именно тот вариант операции, который имел в виду программист.
- Приоритет и ассоциативность. Если они определены жёстко — это может быть неудобно и не соответствовать предметной области (например, для операций с множествами приоритеты отличаются от арифметических). Если они могут быть заданы программистом — это становится дополнительным источником ошибок (уже хотя бы потому, что разные варианты одного оператора оказываются имеющими разные приоритеты, а то и ассоциативность).
Насколько удобство от пользования собственными операциями способно перевесить неудобства от ухудшения управляемости программы — вопрос, не имеющий однозначного ответа.
С позиции реализации языка те же самые проблемы приводят к усложнению трансляторов и понижению их эффективности и надёжности. А использование перегрузки совместно с полиморфными переменными, к тому же заведомо медленнее, чем вызов жёстко прошитой при компиляции операции, и даёт меньше возможностей для оптимизации объектного кода.
Отдельной критике подвергаются конкретные особенности реализации перегрузки в различных языках. Так, в C++ объектом критики может стать принципиально разное поведение встроенных и перегруженных операций ++
и --
: как уже говорилось выше, язык не даёт возможности раздельно перегрузить префиксную и постфиксную формы этих операторов. Если программист привык пользоваться особенностями этих форм в приложении к стандартным типам, он может ожидать соответствующего поведения и от перегруженных операторов, и такие ожидания могут порождать ошибки.
Часть критиков высказываются против перегрузки операторов, исходя из общих принципов теории разработки программного обеспечения и реальной промышленной практики.
- Сторонники «пуританского» подхода к построению языков, такие как Вирт или Хоар, выступают против перегрузки операторов уже просто потому, что без неё можно легко обойтись. По их мнению, подобные средства лишь усложняют язык и транслятор, не предоставляя соответствующих этому усложнению дополнительных возможностей. По их мнению, сама идея создания ориентированного на задачу расширения языка лишь выглядит привлекательно. В действительности же использование средств расширения языка делает программу понятной только её автору — тому, кто это расширение разработал. Программу становится гораздо труднее понимать и анализировать другим программистам, что затрудняет сопровождение, модификацию и групповую разработку.
- Отмечается, что сама возможность использования перегрузки часто играет провоцирующую роль: программисты начинают пользоваться ею где только возможно, в результате средство, призванное упростить и упорядочить программу, становится причиной её усложнения и запутывания.
- Перегруженные операторы могут делать не совсем то, что ожидается от них, исходя из их вида. Например,
a + b
обычно (но не всегда) означает то же самое, чтоb + a
, но«один» + «два»
отличается от«два» + «один»
в языках, где оператор+
перегружен для конкатенации строк. - Перегрузка операторов делает фрагменты программы более контекстно-зависимыми. Не зная типов участвующих в выражении операндов невозможно понять, что это выражение делает, если в нём используются перегруженные операторы. Например, в программе на C++ оператор
<<
может означать и побитовый сдвиг, и вывод в поток. Выражениеa << 1
возвращает результат побитового сдвига значения a на один бит влево, еслиa
— целая переменная, но еслиa
является выходным потоком, то же выражение выведет в этот поток строку«1»
.
[править] Классификация
Ниже приведена классификация некоторых языков программирования по тому, позволяют ли они перегрузку операторов, и ограничены ли операторы предопределённым набором:
Операторы | Нет перегрузки | Есть перегрузка |
---|---|---|
Ограниченое множество операторов | ||
Возможно определение новых операторов |