Перегрузка операторов в С++: основы, примеры

В любой науке есть стандартные обозначения, которые облегчают понимание идей. Например, в математике это умножение, деление, сложение и прочие символьные обозначения. Выражение (x + y * z) понять куда проще, чем «умножить y, с, z и прибавить к x». Представьте, до XVI века математика не имела символьных обозначений, все выражения прописывались словесно так, будто бы это художественный текст с описанием. А привычные для нас обозначения операций появились и того позже. Значение краткой символьной записи сложно переоценить. Исходя из таких соображений, в языки программирования были добавлены перегрузки операторов. Рассмотрим на примере.

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

Практически как и любой язык, C++ поддерживает множество операторов, работающих с типами данных, встроенными в стандарт языка. Но большинство программ используют пользовательские типы для решения тех или иных задач. Например, комплексная математика или матричная алгебра реализуются в программе за счет представления комплексных чисел или матриц в виде пользовательских типов C++. Встроенные операторы не умеют распространять свою работу и совершать необходимые процедуры над пользовательскими классами, какими бы очевидными они не казались. Поэтому для сложения матриц, например, обычно создается отдельная функция. Очевидно, что вызов функции sum_matrix (A, B) в коде будет носить менее ясный характер, чем выражение A + B.

Представление комплексного числа

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

//представим комплексное число в виде пары чисел с плавающей точкой.
class complex {
	double re, im; 
public:
	complex (double r, double i) :re(r), im(i) {} //конструктор
	complex operator+(complex);	//перегрузка сложения
	complex operator*(complex); //перегрузка умножения
};

void main() {
	complex a{ 1, 2 }, b{ 3, 4 }, c{0, 0};
	c = a + b;
	c = a.operator+(b); ////операторная функция может быть вызвана как любая функция, данная запись эквивалентна a+b
	c = a*b + complex(1, 3); //Выполняются обычные правила приоритета операций сложения и умножения
}

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

Операторы, доступные для перегрузки

Полный список всех операторов, для которых можно использовать механизм перегрузки:

+

-

*

/

%

^

&

|

~

!

=

<

>

+=

-=

*=

/=

%=

^=

&=

|=

<<

>>

>>=

<<=

==

!=

<=

>=

&&

||

++

--

->*

,

->

[]

()

new

new[]

delete

delete[]

Как видно из таблицы, перегрузка допустима для большинства операторов языка. Необходимости в перегрузке оператора быть не может. Это делается исключительно для удобства. Поэтому перегрузка операторов в Java, например, отсутствует. А теперь о следующем важном моменте.

Операторы, перегрузка которых запрещена

  • Разрешение области видимости – «::»;
  • Выбор члена – «.»;
  • Выбор члена через указатель на член – «.*»;
  • Тернарный условный оператор – «?:»;
  • Оператор sizeof;
  • Оператор typeid.

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

Ограничения

Ограничения перегрузки операторов:

Диаграмма операторов доступных к перегрузке
  • Нельзя изменить бинарный оператор на унарный и наоборот, как и нельзя добавить третий операнд.
  • Нельзя создавать новые операторы помимо тех, что имеются. Данное ограничение способствует устранению множества неоднозначностей. Если есть необходимость в новом операторе, можно использовать для этих целей функцию, которая будет выполнять требуемое действие.
  • Операторная функция может быть либо членом класса, либо иметь хотя бы один аргумент пользовательского типа. Исключением являются операторы new и delete. Такое правило запрещает изменять смысл выражений в случае, если они не содержат объектов типов, определенных пользователем. В частности, нельзя создать операторную функцию, которая работала бы исключительно с указателями или заставить оператор сложения работать как умножение. Исключением являются операторы "=", "&" и "," для объектов классов.
  • Операторная функция с первым членом, принадлежащим к одному из встроенных типов данных языка C++, не может быть членом класса.
  • Название любой операторной функции начинается с ключевого слова operator, за которым следует символьное обозначение самого оператора.
  • Встроенные операторы определены таким образом, что между ними бывает связь. Например, следующие операторы эквивалентны друг другу: ++x; x + = 1; x = x + 1. После переопределения связь между ними не сохранится. О сохранении их совместной работы подобным образом с новыми типами программисту придется заботиться отдельно.
  • Компилятор не умеет думать. Выражения z + 5 и 5 +z (где z – комплексное число) будут рассматриваться компилятором по-разному. Первое представляет собой «complex + число», а второе - «число + комплекс». Поэтому для каждого выражения нужно определить собственный оператор сложения.
  • При поиске определения оператора компилятор не отдает преимущества ни функциям-членам класса, ни вспомогательным функциям, которые определяются вне класса. Для компилятора они равны.

Интерпретации бинарных и унарных операторов.

Бинарная операция

Бинарный оператор определяется как функция-член с одной переменной или как функция с двумя переменными. Для любого бинарного оператора @ в выражение a@b, @ справедливы конструкции:

a.operator@(b) или operator@(a, b).

Рассмотрим на примере класса комплексных чисел определение операций как членов класса и вспомогательных.

class complex {
	double re, im;
public:
	complex& operator+=(complex z);	
	complex& operator*=(complex z); 
};
//вспомогательные функции
complex operator+(complex z1, complex z2);
complex operator+(complex z, double a);

Какой из операторов будет выбран, и будет ли вообще выбран, определяется внутренними механизмами языка, о которых речь пойдет ниже. Обычно это происходит по соответствию типов.

Выбор, описывать функцию как член класса или вне его – дело, в общем-то, вкуса. В примере выше принцип отбора был следующий: если операция изменяет левый операнд (например, a + = b), то записать ее внутри класса и использовать передачу переменной по адресу, для ее непосредственного изменения; если операция ничего не меняет и просто возвращает новое значение (например, a + b) – вынести за рамки определения класса.

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

Определение перегрузки унарных операторов в C++ происходит аналогичным образом, с той разницей, что они делятся на два вида:

  • префиксный оператор, расположенный до операнда, – @a, например, ++i. o определяется как a.operator@() или operator@(aa);
  • постфиксный оператор, расположенный после операнда, – b@, например, i++. o определяется как b.operator@(int) или operator@(b, int)

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

Правила выбора оператора

Виды операторов C++

Пусть бинарный оператор @ применяется к объектам x из класса X и y из класса Y. Правила для разрешения x@y будут следующие:

  1. если X представляет собой класс, искать внутри него определение оператора operator@ в качестве члена X, либо базового класса X;
  2. просмотреть контекст, в котором находится выражение x@y;
  3. если X относится к пространству имен N, искать объявление оператора в N;
  4. если Y относится к пространству имен M, искать объявление оператора в M.

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

Поиск объявлений унарных операторов происходит точно таким же способом.

Уточненное определение класса complex

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

class complex {
	double re, im;
public:
	complex& operator+=(complex z) { //работает с выражениями вида z1 += z2
		re += z.re;
		im += z.im;
		return *this;
	}
	complex& operator+=(double a) { //работает с выражениями вида z1 += 5;
		re += a;
		return *this;
	}
	complex (): re(0), im(0) {} //конструктор для инициализации по умолчанию. Таким образом, все объявленные комплексные числа будут иметь начальные значения (0, 0)
	complex (double r): re(r), im(0) {} // конструктор делает возможным выражение вида complex z = 11; эквивалентная запись z = complex(11);
	complex (double r, double i): re(r), im(i) {} //конструктор
};
complex operator+(complex z1, complex z2) { //работает с выражениями вида z1 + z2
	complex res = z1;
	return res += z2; //использование оператора, определенного как функция-член
}
complex operator+(complex z, double a) { //обрабатывает выражения вида z+2
	complex res = z;
	return res += a;
}
complex operator+(double a, complex z) { //обрабатывает выражения вида 7+z
	complex res = z;
	return res += a;
}
//…

Как видно из кода, перегрузка операторов имеет весьма сложный механизм, который может сильно разрастись. Однако такой детальный подход позволяет осуществлять перегрузку даже для очень сложных структур данных. Например, перегрузка операторов C++ в классе шаблонов.

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

Особые операторы

Оператор индексации«[]» должен всегда определяться как член класса, так как сводит поведение объекта к массиву. При этом аргумент индексирования может быть любого типа, что позволяет создавать, например, ассоциативные массивы.

Оператор вызова функции «()» может рассматриваться как бинарная операция. Например, в конструкции «выражение(список выражений)» левым операндом бинарной операции () будет «выражение», а правым – список выражений. Функция operator()() должна быть членом класса.

Оператор последовательности «,» (запятая) вызывается для объектов, если рядом с ними есть запятая. Однако в перечислении аргументов функции оператор не участвует.

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

Оператор присваивания также определяется только в качестве члена класса из-за его связи с левым операндом.

Операторы присваивания «=», адреса «&» и последовательности «,» должны определяться в блоке public.

Итог

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

Что такое полиморфизм

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

Рекомендации

Создатель C++ Bjarne Stroustrup
  1. Выполняйте перегрузку операторов только для имитации привычной записи. Для того чтобы сделать код более удобочитаемым. Если код становится сложнее по структуре или читабельности, следует отказаться от перегрузки операторов и использовать функции.
  2. Для больших операндов с целью экономии места используйте для передачи аргументы с типом константных ссылок.
  3. Оптимизируйте возвращаемые значения.
  4. Не трогайте операцию копирования, если она подходит для вашего класса.
  5. Если копирование по умолчанию не подходит, меняйте или явно запрещайте возможность копирования.
  6. Следует предпочитать функции-члены над функциями-нечленами в случаях, когда функции требуется доступ к представлению класса.
  7. Указывайте пространство имен и обозначайте связь функций с их классом.
  8. Используйте функции-нечлены для симметричных операторов.
  9. Используйте оператор () для индексов в многомерных массивах.
  10. С осторожностью используйте неявные преобразования.

Статья закончилась. Вопросы остались?
Комментарии 0
Подписаться
Я хочу получать
Правила публикации
Редактирование комментария возможно в течении пяти минут после его создания, либо до момента появления ответа на данный комментарий.