Термин перегрузка — это калька английского слова overloading. Такой перевод появился в книгах по языкам программирования в первой половине 1990-х годов.[источник не указан 323 дня] В изданиях советского периода аналогичные механизмы назывались переопределением или повторным определением, перекрытием операций.[1]
Отчасти проблема решается средствами объектного программирования — когда новые типы данных объявляются как классы, операции над ними могут быть оформлены как методы классов, в том числе и одноимённые (поскольку методы разных классов не обязаны иметь различные имена), но, во-первых, оформление подобным образом операций над значениями разных типов неудобно, а во-вторых, это не решает проблему создания новых операторов.
Средства, позволяющие расширять язык, дополнять его новыми операциями и синтаксическими конструкциями (а перегрузка операций является одним из таких средств, наряду с объектами, макрокомандами, функционалами, замыканиями) превращают его уже в метаязык — средство описания языков, ориентированных на конкретные задачи. С его помощью можно для каждой конкретной задачи построить языковое расширение, наиболее ей соответствующее, которое позволит описывать её решение в наиболее естественной, понятной и простой форме. Например, в приложении к перегрузке операций: создание библиотеки сложных математических типов (векторы, матрицы) и описание операций с ними в естественной, «математической» форме, создаёт «язык для векторных операций», в котором сложность вычислений скрыта, и возможно описывать решение задач в терминах векторных и матричных операций, концентрируясь на сути задачи, а не на технике. Именно из этих соображений подобные средства были в своё время включены в язык Алгол-68.
Перегрузка операций предполагает введение в язык двух взаимосвязанных особенностей: возможности объявлять в одной области видимости несколько процедур или функций с одинаковыми именами и возможности описывать собственные реализации операций (то есть знаков операций, обычно записываемых в инфиксной нотации, между операндами). Принципиально реализация их достаточно проста:
Перегрузка процедур и функций на уровне общей идеи, как правило, не представляет сложности ни в реализации, ни в понимании. Однако даже в ней имеются некоторые «подводные камни», которые необходимо учитывать. Разрешение перегрузки операций создаёт гораздо больше проблем как для реализатора языка, так и для работающего на этом языке программиста.
Первый вопрос, с которым сталкивается разработчик транслятора языка, разрешающего перегрузку процедур и функций: каким образом из числа одноимённых процедур выбрать ту, которая должна быть применена в данном конкретном случае? Всё хорошо, если существует вариант процедуры, типы формальных параметров которого в точности совпадают с типами параметров фактических, применённых в данном вызове. Однако практически во всех языках в употреблении типов существует некоторая степень свободы, предполагающая, что компилятор в определённых ситуациях автоматически выполняет безопасные преобразования типов. Например, в арифметических операциях над вещественным и целым аргументами целый обычно приводится к вещественному типу автоматически, и результат получается вещественным. Предположим, что существует два варианта функции 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
.
Разработчик и реализатор языка должен каким-то образом решать подобные
проблемы. Варианты, опять-таки, могут быть различными: потребовать,
чтобы все новые операции были односимвольными, постулировать, что при
любых разночтениях выбирается «самый длинный» вариант операции (то есть
до тех пор, пока очередной читаемый транслятором набор символов
совпадает с какой-либо операцией, он продолжает считываться), пытаться
обнаруживать коллизии при трансляции и выдавать ошибки в спорных
случаях… Так или иначе, языки, допускающие объявление новых операций,
решают эти проблемы.
Не следует забывать, что для новых операций также стоит вопрос определения ассоциативности и приоритета. Здесь уже нет готового решения в виде стандартной языковой операции, и обычно приходится просто задать эти параметры правилами языка. Например, сделать все новые операции левоассоциативными и дать им один и тот же, фиксированный, приоритет, либо ввести в язык средства задания того и другого.
Когда перегружаемые операции, функции и процедуры используются в языках со строгой типизацией, где каждая переменная имеет предварительно описанный тип, задача выбора варианта перегруженной операции, используемого в каждом конкретном случае, независимо от её сложности, решается транслятором. Это означает, что для компилируемых языков использование перегрузки операций не приводит к снижению быстродействия — в любом случае, в объектном коде программы присутствует вполне определённая операция или вызов функции. Иначе обстоит дело при возможности использования в языке полиморфных переменных, то есть переменных, которые могут в разные моменты времени содержать значения разных типов.
Поскольку тип значения, к которому будет применяться перегруженная операция, неизвестен на момент трансляции кода, компилятор лишён возможности выбрать нужный вариант заранее. В этом случае он вынужден встраивать в объектный код фрагмент, который непосредственно перед выполнением данной операции определит типы находящихся в аргументах значений и динамически выберет вариант, соответствующий этому набору типов. Причём такое определение нужно производить при каждом исполнении операции, ведь даже тот же самый код, будучи вызван второй раз, вполне может исполняться по-другому.
Таким образом, использование перегрузки операций в сочетании с полиморфными переменными делает неизбежным динамическое определение вызываемого кода.
Использование перегрузки не всеми специалистами считается благом. Если перегрузка функций и процедур, в общем, не находит серьёзных возражений (отчасти, потому, что не приводит к некоторым типично «операторным» проблемам, отчасти — из-за меньшего соблазна её использования не по назначению), то перегрузка операций, как в принципе, так и в конкретных языковых реализациях, подвергается достаточно жёсткой критике со стороны многих теоретиков и практиков программирования.
Критики отмечают, что приведённые выше проблемы идентификации, приоритета и ассоциативности часто делают работу с перегруженными операциями либо неоправданно сложной, либо неестественной:
Насколько удобство от пользования собственными операциями способно перевесить неудобства от ухудшения управляемости программы — вопрос, не имеющий однозначного ответа.
Часть критиков высказываются против перегрузки операций, исходя из общих принципов теории разработки программного обеспечения и реальной промышленной практики.
a + b
обычно (но не
всегда) означает то же самое, что b + a
, но «один»
+ «два»
отличается от «два» + «один»
в языках, где
оператор +
перегружен для конкатенации строк.<<
может означать и побитовый сдвиг, и
вывод в поток. Выражение a << 1
возвращает
результат побитового сдвига значения a на один бит влево, если a
—
целая переменная, но если a
является выходным потоком, то же выражение выведет в этот поток
строку «1»
. В первом случае состояние левого операнда
остается неизменным, во втором — нет.