Что такое перегрузка в программировании
Перейти к содержимому

Что такое перегрузка в программировании

  • автор:

Перегрузка операций

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

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

Термин «перегрузка» — это калька английского «overloading», появившаяся в русских переводах книг по языкам программирования в первой половине 1990-х годов. Возможно, это не самый лучший вариант перевода, поскольку слово «перегрузка» в русском языке имеет устоявшееся собственное значение, кардинально отличающееся от вновь предложенного, тем не менее, он прижился и распространён достаточно широко. В изданиях советского времени аналогичные механизмы назывались по-русски «переопределением» или «повторным определением» операций, но и этот вариант небесспорен: возникают разночтения и путаница в переводах английских «override», «overload» и «redefine».

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

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

Подобное положение неудобно в некоторых, достаточно часто встречающихся случаях.

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

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

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

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

Реализация

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

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

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

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

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

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

Возникает вопрос: должен ли транслятор разрешать подобное использование перегруженных функций, а если должен, то на каком основании он будет выбирать конкретный используемый вариант? В частности, в приведённом выше примере, должен ли транслятор при выборе учитывать тип переменной 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)

Таким образом, int используется для внесения различия в сигнатуры

Объявление новых операций

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

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

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

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

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

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

Критика

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

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

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

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

С позиции реализации языка те же самые проблемы приводят к усложнению трансляторов и понижению их эффективности и надёжности. А использование перегрузки совместно с полиморфными переменными, к тому же заведомо медленнее, чем вызов жёстко прошитой при компиляции операции, и даёт меньше возможностей для оптимизации объектного кода. Отдельной критике подвергаются конкретные особенности реализации перегрузки в различных языках. Так, в C++ объектом критики может стать отстутствие соглашения о внутреннем представление имён перегруженных функций, что порождает несовместимость на уровне бибилиотек, скомпилированных разными компиляторами C++.

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

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

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

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

См. также

Wikimedia Foundation . 2010 .

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

В статье про С++ мы упоминали перегрузку операторов. Это мощный и гибкий инструмент, который может оказаться опасным и непредсказуемым в неумелых руках. Настало время разобраться.

�� Опытным программистам: мы намеренно упростим детали для понимания сути. Ну сорян.

На примере сложения

Во всех языках есть оператор «плюс» — обычно он умеет складывать числа и соединять строки:

‘два’ + ‘два’ = ‘двадва’

‘четы’ + ‘ре’ = ‘четыре’

Допустим, мы пишем софт для интернет-магазина, и у нас есть там класс объектов «заказ». Напомним, что класс — это как бы чертёж, по которому создаются объекты. А объект — это такая коробка с данными и функциями, которыми мы можем управлять как единым целым. Подробнее об этом — в статьях про объекты и классы.

В объекте типа «заказ» лежит куча всего:

  • массив с содержимым корзины,
  • дата и время, когда сформирован заказ,
  • метод «очистить корзину»,
  • место для промокода,
  • метод «применить промокод»,
  • метод «проверить наличие товаров по складу»
  • идентификатор пользователя,
  • что-нибудь ещё интересное.

Допустим, наша система устроена так, что у любого заказа может быть два идентификатора пользователя: постоянный или временный.

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

В какой-то момент пользователь с временным идентификатором логинится в систему, и нам хочется сделать следующую операцию:

Обе части выражения — это объекты класса «Заказ». А наш язык программирования не знает, что значит «сложить два объекта класса „Заказ“». Он не знает:

  • Что с чем складывать? Число товаров? Суммы? Номера товаров? Номера телефонов? Ведь язык не понимает, что за объект перед ним. Для него это просто коробка с данными, он может с ними делать что хочешь.
  • Что возвращать? Число? Строку? Объект? Список заказов?
  • Может быть, нужно сравнить два заказа и по каким-то критериям определить самый актуальный?
  • Или нужно объединить две корзины в одну?
  • А что тогда делать с повторяющимися товарами? Заменить? Добавить в количество? Проигнорировать?

Вроде бы простая операция — а столько вопросов. Вот этому всему мы можем обучить оператор «+», и это будет перегрузка оператора.

�� Короче

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

В случае с нашим примером мы можем сказать, что если складываются два заказа, делай следующее:

  1. Найди, какой из этих заказов постоянный.
  2. Переложи из временного в постоянный все уникальные товары.
  3. Если есть неуникальные товары (например, и в том, и в другом заказе была одна и та же позиция), склей их и поставь максимальное количество. Например, если в постоянном заказе стояло 3 штуки одного артикула, а во временном этого же артикула 9 штук, то поставь в постоянный 9 штук.
  4. Временному заказу поставь статус «Склеено».
  5. Залогируй время склейки заказов.
  6. Верни постоянный заказ с обновлёнными данными.

Довольно много действий для одного плюса, не находите?

Что хорошего в перегрузке

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

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

Чем опасна перегрузка операторов

Когда вы используете в коде перегруженный оператор, он выглядит как самый обычный оператор. Ну складывает и складывает. Ну умножает и умножает, чего такого-то?

Это, с одной стороны, элегантно. А с другой, создаёт проблемы в отладке.

  1. Представьте, что после вас какой-то программист переделал структуру класса «Заказ», и теперь там по-другому работает массив с товарными позициями. Раньше у товаров были числовые идентификаторы типа integer (целые числа), а новый программист переделал их на строки.
  2. Ваш язык в неявном виде поддерживает сравнение чисел со строками и наоборот. Он производит какие-то свои преобразования и позволяет сравнить число со строкой. В 99,9% случаев это не сломает вашу программу, и даже перегруженный оператор будет работать.
  3. Но в 0,1% случаев сравнение случится некорректно, и никто не будет понимать, в чём дело. Где-то под капотом перегруженный оператор некорректно склеивает списки покупок, у пользователя вываливаются какие-то «левые» товары, которых он не заказывал. Он не глядя их оплачивает и потом катает жалобу на ваш магазин.

А ещё, в особо экзотических случаях и больших проектах, программист шутки ради может перегрузить оператор сложения так, что он будет не складывать, а вычитать. И заметить, в чём тут ошибка, в таких случаях бывает очень сложно.

И что?

Перегрузка операторов — это полезно, но сложно.

Если программист не понимает полностью механизма работы перегрузок, лучше не перегружать.

Если понимает — он молодец и может учить стандартные инструменты нестандартному поведению.

Не перегружайтесь, берегите себя.

Получите ИТ-профессию

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

Что такое перегрузка в программировании

Перегрузка операторов (operator overloading) позволяет определить для объектов классов втроенные операторы, такие как +, -, * и т.д. Для определения оператора для объектов своего класса, необходимо определить функцию, название которой содержит слово operator и символ перегружаемого оператора. Функция оператора может быть определена как член класса, либо вне класса.

Перегрузить можно только те операторы, которые уже определены в C++. Создать новые операторы нельзя. Также нельзя изменить количество операндов, их ассоциативность, приоритет.

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

Формальное определение операторов в виде функций-членов класса:

// бинарный оператор ReturnType operator Op(Type right_operand); // унарный оператор ClassType& operator Op();

Формальное определение операторов в виде функций, которые не являются членами класса:

// бинарный оператор ReturnType operator Op(const ClassType& left_operand, Type right_operand); // альтернативное определение, где класс, для которого создается оператор, представляет правый операнд ReturnType operator Op(Type left_operand, const ClassType& right_operand); // унарный оператор ClassType& operator Op(ClassType& obj);

Здесь ClassType представляет тип, для которого определяется оператор. Type — тип другого операнда, который может совпадать, а может и не совпадать с первым. ReturnType — тип возвращаемого результата, который также может совпадать с одним из типов операндов, а может и отличаться. Op — сама операция.

Рассмотрим пример с классом Counter, который хранит некоторое число:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout Counter operator + (const Counter& counter) const < return Counter; > private: int value; >; int main() < Counter c1; Counter c2; Counter c3 = c1 + c2; c3.print(); // Value: 30 >

Здесь в классе Counter определен оператор сложения, цель которого сложить два объекта Counter:

Counter operator + (const Counter& counter) const < return Counter; >

Текущий объект будет представлять левый операнд операции. Объект, который передается в функцию через параметр counter, будет представлять правый операнд операции. Здесь параметр функции определен как константная ссылка, но это необязательно. Также функция оператора определена как константная, но это тоже не обязательно.

Результатом оператора сложения является новый объект Counter, в котором значение value равно сумме значений value обоих операндов.

После опеределения оператора можно складывать два объекта Counter:

Counter c1; Counter c2; Counter c3 ; c3.print(); // Value: 30

Подобным образом можно определить функцию оператора вне класса:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout int value; // к приватным переменным внешняя функция оператора не может обращаться >; // определяем оператор сложения вне класса Counter operator + (const Counter& c1, const Counter& c2) < return Counter; > int main() < Counter c1; Counter c2; Counter c3 ; c3.print(); // Value: 30 >

Если бинарный оператор определяется в виде внешней функции, как здесь, то он принимает два параметра. Первый параметр будет представлять левый операнд операции, а второй параметр — правый операнд.

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

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

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout Counter operator + (const Counter& counter) const < return Counter; > int operator + (int number) const < return value + number; >private: int value; >; int main() < Counter counter; int number = counter + 30; std::cout 

Здесь определена вторая версия оператора сложения, которая складывает объект Counter с числом и возвращает также число. Поэтому левый операнд операции должен представлять тип Counter, а правый операнд - тип int.

Какие операторы где переопределять? Операторы присвоения, индексирования ([]), вызова (()), доступа к члену класса по указателю (->) следует определять в виде функций-членов класса. Операторы, которые изменяют состояние объекта или непосредственно связаны с объектом (инкремент, декремент), обычно также определяются в виде функций-членов класса. Операторы выделения и удаления памяти ( new new[] delete delete[] ) определяются только в виде функций, которые не являются членами класса. Все остальные операторы можно определять как отдельные функции, а не члены класса.

Операторы сравнения

Результатом операторов сравнения ( == , != , < , >), как правило, является значение типа bool . Например, перегрузим данные операторы для типа Counter:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout bool operator == (const Counter& counter) const < return value == counter.value; >bool operator != (const Counter& counter) const < return value != counter.value; >bool operator > (const Counter& counter) const < return value >counter.value; > bool operator < (const Counter& counter) const < return value < counter.value; >private: int value; >; int main() < Counter c1(20); Counter c2(10); bool b1 = c1 == c2; // false bool b2 = c1 >c2; // true std::cout c2 b">default:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout bool operator == (const Counter& counter) const = default; bool operator != (const Counter& counter) const = default; private: int value; >; int main() < Counter c1(20); Counter c2(10); bool b1 = c1 == c2; // false bool b2 = c1 != c2; // true std::cout bool operator == (const Counter& counter) const = default;

По умолчанию будут сравниваться все поля класса, для которых определен оператор ==. Если значения всех полей будут равны, то оператор возвратить true

Операторы присвоения

Оператор присвоения обычно возвращает ссылку на свой левый операнд:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout // оператор присвоения Counter& operator += (const Counter& counter) < value += counter.value; return *this; // возвращаем ссылку на текущий объект >private: int value; >; int main() < Counter c1; Counter c2; c1 += c2; c1.print(); // Value: 70 >

Унарные операции

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

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout // оператор унарного минуса Counter operator - () const < return Counter; > private: int value; >; int main() < Counter c1; Counter c2 = -c1; // применяем оператор унарного минуса c2.print(); // Value: -20 >

Здесь операция унарного минуса возвращает новый объект Counter, значение value в котором фактически равно значению value текущего объекта, умноженного на -1.

Операции инкремента и декремента

Особую сложность может представлять переопределение операций инкремента и декремента, поскольку нам надо определить и префиксную, и постфиксную форму для этих операторов. Определим подобные операторы для типа Counter:

#include class Counter < public: Counter(int val) < value =val; >void print() < std::cout // префиксные операторы Counter& operator++ () < value += 1; return *this; >Counter& operator-- () < value -= 1; return *this; >// постфиксные операторы Counter operator++ (int) < Counter copy ; ++(*this); return copy; > Counter operator-- (int) < Counter copy ; --(*this); return copy; > private: int value; >; int main() < Counter c1; Counter c2 = c1++; c2.print(); // Value: 20 c1.print(); // Value: 21 --c1; c1.print(); // Value: 20 >

Префиксные операторы должны возвращать ссылку на текущий объект, который можно получить с помощью указателя this:

Counter& operator++ ()

В самой функции можно определить некоторую логику по инкременту значения. В данном случае значение value увеличивается на 1.

Постфиксные операторы должны возвращать значение объекта до инкремента, то есть предыдущее состояние объекта. Поэтому постфиксная форма возвращает копию объекта до инкремента:

Counter operator++ (int) < Counter copy ; ++(*this); return copy; >

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

Переопределение оператора

#include class Counter < public: Counter(int val) < value =val; >int getValue()const private: int value; >; std::ostream& operator <<(std::ostream& stream, const Counter& counter) < stream << "Value: "; stream << counter.getValue(); return stream; >int main() < Counter counter1; Counter counter2; std::cout 

Стандартный выходной поток cout имеет тип std::ostream . Поэтому первый параметр (левый операнд) представляет объект ostream , а второй (правый операнд) - выводимый объект Counter. Поскольку мы не можем изменить стандартное определение std::ostream, поэтому определяем функцию оператора, которая не является членом класса.

std::ostream& operator

В данном случае для выводим значение переменной value. Для получения значения value извне класса Counter я добавил функцию getValue() .

Возвращаемое значение всегда должно быть ссылкой на тот же объект потока, на который ссылается левый операнд оператора.

После определения функции оператора можно выводить на консоль объекты Counter:

Counter counter1; std::cout 

Выражение одних операторов через другие

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

#include class Counter < public: Counter(int n) < value = n; >void print() const < std::cout Counter& operator+=(const Counter& counter) < value += counter.value; return *this; >; Counter& operator+(const Counter& counter) < Counter copy< value >; // копируем данные текущего объекта return copy += counter; >; private: int value; >; int main() < Counter counter1; Counter counter2; counter1 += counter2; counter1.print(); // value: 30 Counter counter3 ; counter3.print(); // value: 40 >

Здесь вначале реализован оператор сложения с присвоением +=:

Counter& operator+=(const Counter& counter) < value += counter.value; return *this; >;

В функции оператора сложения мы создаем копию текущего объекта и к этой копии и аргументу применяем оператор +=:

Counter& operator+(const Counter& counter) < Counter copy< value >; // копируем данные текущего объекта return copy += counter; >;

В данном случае суть сложения: к полю value прибавляем значение value другого объекта. Однако логика оператора может быть более сложной, и чтобы не повторяться, мы можем таким образом выражать одни операторы через другие.

Перегрузка []

Кроме нескольких вышеперечисленных операторов, можно перегрузить и любые другие операторы С++. Большей частью требуется перегружать стандартные операторы, такие как арифметические, логические или операторы отношения. Тем не менее имеется один достаточно экзотичный опера­тор, который бывает полезно перегружать: []. В С++ оператор [] при перегрузке рассматривается как бинарный оператор. Его следует перегружать с помощью функции-члена. Нельзя использовать дружественную функцию. Общая форма функции-оператора operator[]() имеет следующий вид:

Параметр не обязан иметь тип int, но поскольку функция operator[]() обычно используется для индексации массива, то в таком качестве обычно используется целое значение.
Для заданного объекта О выражение

преобразуется в вызов функции operator[]():

В таком случае значение индекса передается функции operator[]() в качестве явного параметра. Указатель this указывает на объект О, тот самый, который вызывает функцию.

В следующей программе класс atype содержит массив из трех переменных целого типа. Конст­руктор инициализирует каждый элемент массива заданным значением. Перегруженная функция-оператор operator[]() возвращает величину элемента массива, определяемого индексом, передава­емым в качестве параметра.

#include
class atype int a[3];
public:
atype (int i, int j, int k) a[0] = i;
a[1] = j;
a[2] = k;
>
int operator[] (int i) < return a[i]; >
>;
int main()
atype ob(1, 2, 3);
cout return 0;
>

Можно создать функцию-оператор operator[]() таким образом, чтобы оператор [] можно было использовать как с левой, так и с правой стороны оператора присваивания. Для этого достаточ­но в качестве возвращаемой величины для operator[]() задать ссылку. Сказанное проиллюстриро­вано в следующей программе:

Поскольку operator[]() возвращает ссылку на элемент массива, отвечающий индексу i, то он может быть использован с левой стороны операции присваивания для модификации элемента массива. Разумеется, этот оператор может быть использован и с правой стороны оператора присваивания.

Как известно, в С++ во время выполнения программы можно выйти за пределы массива, и при этом не будет выдаваться сообщение об ошибке. Одним из достоинств перегрузки оператора [] служит то, что с его помощью можно предотвратить подобные эффекты.

Если создается класс, содержащий массив, и разрешен доступ к этому массиву только через перегруженный оператор [], то можно перехватывать значение, содержащее величину индекса за пределами допустимых значений. Например, следующая программа осуществляет проверку на принадлежность значений допустимой области:

// пример безопасного массива
#include
#include
class atype int a[3];
public:
atype (int i, int j, int k) a[0] = i;
a[1] = j;
a[2] = k;
>
int &operator[] (int i);
>;
// проверка диапазона для atype.
int &atype::operator [] (int i)
if (i2) cout exit (1);
>
return a[i];
>
int main()
atype ob(1, 2, 3);
cout cout ob[1] = 25; // [] слева
cout ob[3] = 44; // генерируется ошибка времени выполнения, поскольку 3 выходит за допустимые пределы
return 0;
>

При выполнении инструкции

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *