This lesson is not translated yet – the Russian original is below.
Оператор в C++ – это функция с необычным именем. a + b для своего типа компилятор превращает в
вызов operator+(a, b) или a.operator+(b), и никакой другой магии здесь нет.
Зачем это нужно
Rational a(1, 2);
Rational b(1, 3);
Rational c = a.Add(b).Mul(Rational(2, 1)); // без операторов
Rational d = (a + b) * 2; // с операторами
Смысл – в читаемости для типов, у которых арифметика или сравнение существуют в предметной области: числа, векторы, даты, деньги, матрицы. Для всего остального осмысленное имя метода лучше хитро перегруженного знака.
Метод или свободная функция
class Rational {
public:
Rational& operator+=(const Rational& other); // метод
};
Rational operator+(const Rational& a, const Rational& b); // свободная функция
Правило выбора простое:
- Метод – если оператор меняет левый операнд или тесно с ним связан:
=,+=,[],(),->, приведение типа. - Свободная функция – симметричные операторы:
+,-,==,<,<<.
У симметричных есть практическая причина. Метод получает левый операнд как *this, и
преобразование к нему не применяется:
class Rational {
public:
Rational(int value); // неявное преобразование из int
Rational operator+(const Rational&) const; // метод
};
Rational r(1, 2);
Rational x = r + 1; // работает: 1 преобразуется в Rational
Rational y = 1 + r; // ошибка: у int нет метода operator+(Rational)
Со свободной функцией работают оба варианта – преобразование применяется к обоим аргументам.
Что можно и чего нельзя
Перегружаются почти все операторы. Не перегружаются: ::, ., .*, ?: и sizeof.
Ограничения, которые не обойти:
- нельзя придумать новый знак:
**оператором не станет; - нельзя изменить приоритет и ассоциативность:
a + b * cвсегда сначала умножит; - нельзя изменить арность: бинарный
+не станет тернарным; - хотя бы один операнд должен быть пользовательским типом – переопределить
int + intне выйдет.
Канонические формы
Операторы принято писать парами: составной делает работу, обычный выражается через него.
class Rational {
public:
Rational& operator+=(const Rational& other) {
// вся арифметика здесь
return *this;
}
};
Rational operator+(Rational a, const Rational& b) { // левый – по значению: это и есть копия
a += b;
return a;
}
Сравнения – тоже парами: == и !=, < и остальные три через него.
bool operator==(const Rational& a, const Rational& b);
bool operator!=(const Rational& a, const Rational& b) { return !(a == b); }
bool operator<(const Rational& a, const Rational& b);
bool operator>(const Rational& a, const Rational& b) { return b < a; }
bool operator<=(const Rational& a, const Rational& b) { return !(b < a); }
bool operator>=(const Rational& a, const Rational& b) { return !(a < b); }
В C++20 появился оператор <=> («космический корабль»): объявив auto operator<=>(const Rational&) const = default;, вы получаете все шесть сравнений сразу. Но большая часть живого кода написана до C++20, и шесть операторов в классе – привычная картина.
Инкремент
Префиксный и постфиксный различают фиктивным параметром:
class Iterator {
public:
Iterator& operator++() { // префиксный: ++it
++pos_;
return *this;
}
Iterator operator++(int) { // постфиксный: it++
Iterator copy = *this;
++pos_;
return copy;
}
private:
size_t pos_ = 0;
};
Постфиксный обязан вернуть прежнее значение, то есть создать копию. Отсюда правило: в цикле, где
результат не используется, пишите ++i. Для int разницы нет, для итератора она есть.
Оператор вывода
class Point {
public:
Point(double x, double y) : x_(x), y_(y) {}
friend std::ostream& operator<<(std::ostream& out, const Point& p);
private:
double x_;
double y_;
};
std::ostream& operator<<(std::ostream& out, const Point& p) {
return out << '(' << p.x_ << ", " << p.y_ << ')';
}
std::cout << Point(1, 2) << '\n';
Три обязательных детали: поток принимается и возвращается по ссылке (копировать потоки нельзя,
а возврат нужен для цепочек cout << a << b), объект – по константной ссылке, и friend – чтобы
добраться до приватных полей. Без friend можно обойтись, если у класса есть публичные геттеры.
Оператор индексирования
class Array {
public:
int& operator[](size_t i) { return data_[i]; }
int operator[](size_t i) const { return data_[i]; }
private:
int* data_;
};
Две версии – по той же причине, что и у обычных методов: константный объект должен позволять чтение и запрещать запись.
operator[] принимает ровно один аргумент, поэтому m[i][j] для матрицы напрямую не запишешь.
Обходят это двумя способами: возвращают прокси-объект, у которого тоже есть operator[], или
берут operator() – он принимает сколько угодно аргументов:
class Matrix {
public:
double& operator()(size_t row, size_t col) { return data_[row * cols_ + col]; }
};
Matrix m(3, 3);
m(1, 2) = 5;
Класс с перегруженным operator() называют функциональным объектом или функтором: его экземпляр вызывается как функция. Это основной способ передавать поведение в алгоритмы стандартной библиотеки – std::sort(v.begin(), v.end(), Greater()). К нему мы вернёмся в модуле про шаблоны.
Оператор приведения типа
class Rational {
public:
explicit operator double() const { return static_cast<double>(num_) / den_; }
private:
int num_;
int den_;
};
Rational r(1, 2);
double d = static_cast<double>(r); // явно
// double bad = r; // без explicit прошло бы молча
explicit здесь так же важен, как у конструктора с одним аргументом. Неявное преобразование к
double означало бы, что Rational начнёт участвовать в любой арифметике с потерей точности и
без единого предупреждения.
Чего делать не стоит
Перегрузка операторов – место, где легко написать компилирующийся, но нечитаемый код.
Оператор обязан вести себя так, как ожидает читатель. + не должен изменять операнды, == обязан быть симметричным и транзитивным, < – задавать строгий порядок (иначе std::sort с ним даст неопределённое поведение, а не просто неверный порядок). operator&& и operator|| перегружать технически можно, но при этом теряется ленивое вычисление – правый операнд начнёт вычисляться всегда. Это ровно тот случай, когда «работает» и «правильно» расходятся.
И главный критерий: перегружайте оператор, только если операция в предметной области
действительно так и называется. date1 - date2 как «разница в днях» – понятно. logger << data
как «записать» – спорно, но прижилось. config + patch как «применить настройки» – уже ребус.