Course program← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

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

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 как «применить настройки» – уже ребус.