Перейти к содержимому

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

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

Исправить эту проблему можно, переопределив, что присваивание делает для нашего типа. Это называется перегрузкой оператора присваивания: «оператор присваивания» — это = в операции присваивания, а «перегрузка» — определение функции, которая подменяет стандартное поведение для нашего типа.

class Example
{
int* memory;
public:
Example(int value)
{
this->memory = new int{value};
}
~Example()
{
delete this->memory;
}
void operator=(const Example& other)
{
// Это стандартное поведение.
// Как показано в предыдущем примере,
// стандартное поведение легко приводит к утечкам памяти и повторным освобождениям.
// this->memory = other.memory;
// А вот это — то поведение, которое нам нужно.
*this->memory = *other.memory;
}
};
int main()
{
Example example1{1};
Example example2{2};
// Это вызывает оператор присваивания.
example2 = example1;
// `operator=` — это просто имя этого метода,
// вызвать его можно и как обычный метод:
example2.operator=(example1);
return 0;
}

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

class Example
{
// ...
// Из-за этого оператор = становится непригодным.
void operator=(const Example& other) = delete;
};
int main()
{
Example example1{1};
Example example2{2};
example1 = example2; // ошибка времени компиляции
return 0;
}

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

Для этого функции присваивается ключевое слово default.

Пример завершится с ошибкой во время выполнения; он приведен в демонстрационных целях.

class Example
{
// ...
// Разрешаем стандартное поведение для конструктора без параметров.
Example() = default;
// Разрешаем стандартное поведение для конструктора копирования.
Example(const Example& other) = default;
// Заодно явно укажем, что мы хотим,
// чтобы присваивание тоже делало стандартное.
// Чтобы это работало, типом возврата должно быть `Example&` (почему — объясню чуть позже).
Example& operator=(const Example& other) = default;
};
int main()
{
// Наш собственный конструктор.
Example example1{1};
// Конструктор без параметров
Example example2{};
// Конструктор копирования
Example example3{example1};
// Оператор присваивания
example2 = example1;
return 0;
}

Перегрузка математических операторов: пример с Vector

Заголовок раздела «Перегрузка математических операторов: пример с Vector»

Я реализовал структуру Vector с операциями сложения, вычитания и масштабирования.

Одна реализация процедурная, другая — с перегрузкой операторов. См. код примера здесь и здесь.

Возврат значений или ссылок из изменяющих операторов

Заголовок раздела «Возврат значений или ссылок из изменяющих операторов»

Под «изменяющими операторами» я подразумеваю такие, как =, +=, -= и т.п.

Можно пойти дальше и реализовать заодно операторы += и -=. Возвращаемый тип можно выбрать любой, какой сочтете логичным, но обычно это void, Vector или Vector&. См. пример. Ссылка возвращается для того, чтобы разрешить конструкции вроде такой (но это не обязательно):

int main()
{
Vector a{6, 9};
Vector b{2, 3};
Vector c{5, 6};
// Можно сделать это в несколько шагов
a = a + b;
Vector result = a;
result = result + c;
// Или в один шаг
Vector result = (a += b) + c;
// Идея в том, что `a += b` возвращает либо значение `a`,
// либо ссылку на `a`, которая будет результатом `a + b`,
// то есть новое значение `a` после сложения.
return 0;
}

Вы могли заметить, что C++ использует оператор << (побитовый сдвиг влево) для взаимодействия с потоком вывода. Это достигается за счет friend-функций, которые позволяют оператору обращаться к закрытым полям Vector.

friend-функции неявно статичны, то есть у них нет скрытого параметра this.

#include <iostream>
struct Vector
{
int x;
int y;
// Вторым параметром может быть `Vector`, `Vector&` или `const Vector&`.
// (Технически можно сделать его чем угодно, но смысла нет).
friend std::ostream& operator<<(std::ostream& outputStream, Vector vector)
{
outputStream << "(" << vector.x << ", " << vector.y << ")" << std::endl;
return outputStream;
}
};
int main()
{
Vector v{1, 2};
std::cout << v; // (1, 2)
return 0;
}

Обратите внимание, как перегруженный оператор возвращает ссылку на объект потока вывода. Благодаря этому оператор << можно сразу применить еще раз. Это называется fluent-интерфейсом; при желании изучите тему самостоятельно.

Например, такой код работает без проблем именно потому, что мы возвращаем ссылку на объект потока:

int main()
{
Vector v1{1, 2};
Vector v2{5, 6};
// Это:
(std::cout << v1) << v2;
// Эквивалентно этому (вычисляется слева направо):
std::cout << v1 << v2;
// По факту делает то же самое, что и это.
std::cout << v1;
std::cout << v2;
return 0;
}

С friend-функциями выходит небольшая странность, когда требуется разделить объявление и определение:

struct Vector
{
// ...
// Объявление
friend std::ostream& operator<<(std::ostream& outputStream, Vector vector);
};
// Определение.
// Оператор разрешения области видимости `::` не нужен.
// Более того, использовать его здесь нельзя.
std::ostream& operator<<(std::ostream& outputStream, Vector vector)
{
// ...
}

friend-функции позволяют поместить логику вывода вашего объекта в ваш класс, а не в класс std::ostream. Это полезно, когда нельзя или есть причина не изменять тип, являющийся первым параметром, то есть когда нельзя или не хочется добавлять этот оператор как обычный метод в исходный код класса первого параметра.

В данном случае, конечно, раз std::ostream взят из стандартной библиотеки, изменить его исходный код нельзя. Но даже если бы было можно, вы, вероятно, хотели бы, чтобы он умел лишь общие вещи — печатать текст, и чтобы не приходилось обременять его деталями вывода вашего конкретного типа. Тогда ваш тип сможет сам определить логику своего вывода, опираясь на базовые операции, которые предоставляет std::ostream, такие как вывод строки или int.

Эта идея тесно связана с инкапсуляцией и обычно называется разделением ответственности (separation of concerns).

Стоит отметить, что std::ostream перегружает оператор << для некоторых базовых типов, таких как const char[N], int, float и т.д., — именно поэтому << вообще можно использовать для вывода. А применять его «по цепочке» можно потому, что он возвращает ссылку на тот же объект потока.