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

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

Перегрузка операторов в C++ — это возможность задать смысл встроенных операторов (+, ==, <<, [] и других) для своих типов: вы пишете функцию с именем operator+, и выражение a + b с объектами вашего класса вызывает ее. Новых операторов так не создать, а приоритет и число операндов остаются как у встроенных.

Ниже — полный рабочий пример, правило выбора «метод класса или свободная функция», список того, что перегружать нельзя, и сравнения через operator<=> из C++20. Примеры проверены компилятором Apple clang 21 с флагом -std=c++20 24.09.2026.

Три близких термина, которые часто путают:

Термин Что это Пример
Перегрузка функций несколько функций с одним именем и разными параметрами print(int) и print(double)
Перегрузка операторов частный случай перегрузки функций: имя функции — operator@ operator+(Vec2, Vec2)
Переопределение (override) замена виртуального метода базового класса в наследнике void draw() override

Минимальный пример: вектор на плоскости

Сначала весь код целиком, потом разбор. Сохраните как vec.cpp, соберите g++ -std=c++20 vec.cpp или clang++ -std=c++20 vec.cpp и запустите.

#include <iostream>

struct Vec2 {
    double x = 0, y = 0;

    // составное присваивание - член класса, меняет *this
    Vec2& operator+=(const Vec2& rhs) {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }

    // унарный минус - член, возвращает новый объект
    Vec2 operator-() const { return {-x, -y}; }

    // бинарный + через += : свободная функция (hidden friend)
    friend Vec2 operator+(Vec2 lhs, const Vec2& rhs) {
        lhs += rhs;
        return lhs;
    }

    // умножение на число в обе стороны
    friend Vec2 operator*(Vec2 v, double k) { return {v.x * k, v.y * k}; }
    friend Vec2 operator*(double k, Vec2 v) { return v * k; }

    // C++20: == по всем полям генерирует компилятор, != получаем бесплатно
    bool operator==(const Vec2&) const = default;

    // вывод в поток - только свободная функция
    friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
        return os << '(' << v.x << ", " << v.y << ')';
    }
};

int main() {
    Vec2 a{1, 2}, b{3, 4};
    Vec2 c = a + b;
    std::cout << c << '\n';          // (4, 6)
    std::cout << 2 * a << '\n';      // (2, 4)
    std::cout << -b << '\n';         // (-3, -4)
    c += a;
    std::cout << c << '\n';          // (5, 8)
    std::cout << std::boolalpha << (a != b) << '\n'; // true
}

В консоли появятся пять строк:

(4, 6)
(2, 4)
(-3, -4)
(5, 8)
true

Что здесь происходит:

  • a + b компилятор превращает в вызов operator+(a, b). Если бы + был методом, вызов выглядел бы как a.operator+(b): левый операнд становится неявным this.
  • += меняет сам объект и возвращает ссылку Vec2&, как встроенный +=. На его основе + написан в три строки — так логика сложения живет в одном месте.
  • Унарный минус помечен const: он не меняет исходный вектор, а возвращает новый.
  • operator== с = default сравнивает поля по порядку. Начиная с C++20 выражение a != b компилятор сам переписывает в !(a == b), отдельный != не нужен.

Синтаксис: как компилятор читает выражение

Функция-оператор — обычная функция со специальным именем. Число параметров зависит от того, где она объявлена:

Вид оператора Метод класса Свободная функция
Бинарный (a @ b) 1 параметр (правый), левый — this 2 параметра
Унарный (@a) 0 параметров 1 параметр
Постфиксный a++ 1 фиктивный int 2 параметра: объект и фиктивный int

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

Метод класса или свободная функция

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

Неверно — умножение на число только методом:

struct Vec2 {
    double x = 0, y = 0;
    Vec2 operator*(double k) const { return {x * k, y * k}; }
};

int main() {
    Vec2 a{1, 2};
    Vec2 b = a * 2;   // ok: a.operator*(2)
    Vec2 c = 2 * a;   // ошибка
}

Результат — ошибка компиляции (текст clang 21; у GCC формулировка другая, например no match for 'operator*'):

err1.cpp:9:16: error: invalid operands to binary expression ('int' and 'Vec2')

Исправление — свободная функция для порядка «число слева», как friend Vec2 operator*(double k, Vec2 v) в первом примере. Сводное правило:

Оператор Как объявлять Почему
=, (), [], ->, операторы преобразования только метод так требует язык
+=, -=, ++, --, унарные -, ! обычно метод меняют объект или относятся к одному объекту
+, -, *, ==, <=> обычно свободная функция (часто hidden friend) операнды равноправны, преобразования работают для обеих сторон
<<, >> с потоком только свободная функция левый операнд — std::ostream/std::istream, его класс вы не меняете

Вторая и третья строки — соглашение, а не требование: == методом тоже скомпилируется, но в C++17 и раньше 2 == v при неявном преобразовании из числа работал только со свободной функцией.

Что нельзя перегружать и какие правила действуют

Нельзя перегрузить: ::, ., .*, ?:, sizeof, typeid, alignof, noexcept, а также static_cast и другие именованные приведения. Преобразование своего класса в другой тип задается отдельно — функцией-членом operator T(), лучше с explicit.

Остальные ограничения:

  • новый оператор придумать нельзя: operator** не существует;
  • приоритет, ассоциативность и число операндов не меняются: a + b * c для ваших типов по-прежнему a + (b * c);
  • хотя бы один операнд должен быть классом или перечислением — поменять смысл int + int нельзя;
  • перегруженные && и || теряют короткое замыкание: оба операнда вычисляются всегда (порядок слева направо гарантирован с C++17), поэтому их почти не перегружают.

Что говорит компилятор (clang 21), если нарушить правила:

struct A { int v; };
A& operator=(A& a, int x) { a.v = x; return a; }
int operator+(int a, int b) { return a - b; }
bad.cpp:2:4: error: overloaded 'operator=' must be a non-static member function
bad.cpp:3:5: error: overloaded 'operator+' must have at least one parameter of class or enumeration type

Инкремент, индексация и вызов как функции

У ++ и -- две формы, и различает их только фиктивный параметр int:

#include <iostream>
#include <vector>

class Counter {
    int value_ = 0;
public:
    Counter& operator++() {          // префикс: ++c
        ++value_;
        return *this;
    }
    Counter operator++(int) {        // постфикс: c++ (int - фиктивный)
        Counter old = *this;
        ++value_;
        return old;                  // копия старого значения
    }
    int get() const { return value_; }
};

class Row {
    std::vector<int> data_;
public:
    explicit Row(std::size_t n) : data_(n) {}
    int& operator[](std::size_t i) { return data_[i]; }             // запись
    const int& operator[](std::size_t i) const { return data_[i]; } // чтение у const
};

struct Scale {
    double k;
    double operator()(double x) const { return x * k; }  // функциональный объект
};

int main() {
    Counter c;
    Counter before = c++;
    ++c;
    std::cout << before.get() << ' ' << c.get() << '\n';   // 0 2

    Row r(3);
    r[1] = 42;
    const Row& cr = r;
    std::cout << cr[1] << '\n';                             // 42

    Scale twice{2.0};
    std::cout << twice(21) << '\n';                         // 42
}

Вывод: 0 2, затем 42 и 42. Постфиксная форма возвращает копию по значению: ссылку на локальный old вернуть нельзя, он уничтожится при выходе из функции. Поэтому в циклах по своим итераторам принято писать ++it.

У operator[] две версии: неконстантная отдает ссылку для записи, константная работает у const-объектов. Как и у std::vector, [] здесь индекс не проверяет — выход за границы дает неопределенное поведение; для проверки добавляют метод вроде at() с исключением. В C++23 operator[] может принимать несколько индексов: m[i, j].

Сравнения в C++20: operator<=>

До C++20 для полного набора сравнений писали шесть операторов. Теперь достаточно одного трехстороннего сравнения operator<=> («космический корабль»): компилятор переписывает a < b в (a <=> b) < 0, а >, <=, >= — аналогично.

#include <algorithm>
#include <compare>
#include <iostream>
#include <vector>

struct Version {
    int major = 0, minor = 0, patch = 0;
    // сравнение по полям в порядке объявления; == тоже генерируется
    auto operator<=>(const Version&) const = default;
};

int main() {
    Version v1{1, 4, 2}, v2{1, 10, 0};
    std::cout << std::boolalpha
              << (v1 < v2) << ' '      // true: 4 < 10
              << (v1 == v2) << ' '     // false
              << (v1 >= v2) << '\n';   // false
    std::vector<Version> vs{{2, 0, 0}, {1, 10, 0}, {1, 4, 2}};
    std::sort(vs.begin(), vs.end());
    for (const auto& v : vs)
        std::cout << v.major << '.' << v.minor << '.' << v.patch << ' ';
    std::cout << '\n';
}

Вывод: true false false, затем 1.4.2 1.10.0 2.0.0. Версии сравниваются как числа, а не как строки, где "1.10" оказалась бы меньше "1.4".

Тип результата <=> говорит о смысле сравнения:

Тип Когда Пример
std::strong_ordering равные значения неотличимы целые числа, Version
std::weak_ordering эквивалентные значения могут различаться строки без учета регистра: "Cpp" и "CPP"
std::partial_ordering часть значений несравнима double с NaN

Ловушка: == default генерируется только вместе с <=> = default. Если вы пишете <=> вручную, == сам не появится. Неверно — тег с ручным сравнением без учета регистра и без ==:

#include <algorithm>
#include <cctype>
#include <compare>
#include <iostream>
#include <string>

struct Tag {
    std::string name;

    // "Cpp" и "CPP" эквивалентны, но не одинаковы -> weak_ordering
    std::weak_ordering operator<=>(const Tag& rhs) const {
        std::size_t n = std::min(name.size(), rhs.name.size());
        for (std::size_t i = 0; i < n; ++i) {
            int a = std::tolower(static_cast<unsigned char>(name[i]));
            int b = std::tolower(static_cast<unsigned char>(rhs.name[i]));
            if (a != b) return a <=> b;
        }
        return name.size() <=> rhs.name.size();
    }
};

int main() {
    Tag a{"Cpp"}, b{"CPP"}, c{"rust"};
    std::cout << std::boolalpha << (a < c) << ' ' << (c > b) << '\n';
    std::cout << (a == b) << ' ' << (a != c) << '\n';
}

Результат — < и > компилируются, а == и != нет:

tag.cpp:25:21: error: invalid operands to binary expression ('Tag' and 'Tag')
tag.cpp:25:40: error: invalid operands to binary expression ('Tag' and 'Tag')

Исправление — добавить в класс ==, согласованный с <=>:

bool operator==(const Tag& rhs) const { return (*this <=> rhs) == 0; }

После этого программа печатает true true и true true. Упрощение в примере: tolower корректен только для ASCII, для кириллицы в UTF-8 нужна библиотека работы с Unicode.

Когда перегрузка вредит

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

Исключение, ставшее соглашением, — << и >> у потоков: к битовому сдвигу они отношения не имеют, но так устроена стандартная библиотека.

Если не получилось — типовые симптомы:

Симптом Причина Что сделать
invalid operands to binary expression / no match for operator нет подходящей перегрузки для этих типов или порядка операндов добавить свободную функцию; проверить const у параметров
must be a non-static member function =, [], () или -> объявлены вне класса перенести внутрь класса
must have at least one parameter of class or enumeration type попытка перегрузить оператор для встроенных типов ввести свой тип-обертку
== не компилируется при готовом <=> <=> написан вручную добавить operator==

Выводы

  • Перегрузка операторов — это функция с именем operator@; приоритет, число операндов и ассоциативность остаются как у встроенного оператора.
  • =, (), [], -> и преобразования объявляются только методом; << для потоков — только свободной функцией.
  • Симметричные операторы (+, *, ==) удобнее делать свободными функциями, часто hidden friend, а + выражать через +=.
  • В C++20 operator<=> с = default дает все шесть сравнений; ручной <=> требует отдельного ==.
  • Перегружать стоит только там, где смысл оператора очевиден.

Где применяется / связь с практикой

Освойте тему на практике

Перегрузка операторов встречается в любом «математическом» коде — векторы, матрицы, деньги, — а также в итераторах, умных указателях и функциональных объектах для алгоритмов STL. Понимать ее нужно, даже если свои операторы вы пишете редко: так устроены std::string, std::chrono и потоки ввода-вывода. Если хотите системно пройти классы, шаблоны и стандартную библиотеку с практикой и ревью кода, посмотрите курс «C++-разработчик. Базовый уровень». Разобрать отдельные темы языка с преподавателями можно на открытых уроках Otus.

FAQ

Можно ли перегрузить оператор для enum?
Да: перечисление, включая enum class, считается пользовательским типом. Так часто делают operator| для флагов.

Что такое пользовательские литералы и при чем тут перегрузка?
С C++11 можно объявить operator""_km, и запись 5.0_km вызовет эту функцию. Это отдельный вид операторной функции; имена без подчеркивания зарезервированы за стандартной библиотекой.

Нужно ли писать operator= самому?
Обычно нет: компилятор генерирует копирующее присваивание, а перемещающее — только если в классе нет объявленных пользователем деструктора, копирующих или перемещающих операций (иначе вместо перемещения молча выполнится копирование). Свое нужно, когда класс напрямую владеет ресурсом (сырой указатель, дескриптор), — это правило трех/пяти; чаще проще хранить ресурс в std::vector или std::unique_ptr.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)