Синтаксис си/с++

Перегрузка операторов: принципы, какие операторы можно перегружать

Введение

Многие предыдущие примеры использовали стандартные операторы (+, ==, <<) для встроенных типов — C++ позволяет определить, что значит применение этих же операторов к объектам пользовательских классов, через перегрузку операторов, разбираемую в этой статье вместе с её ограничениями и общими принципами хорошего стиля.

Концепция

Перегрузка оператора — определение функции (либо как метод класса, либо как свободная функция, в зависимости от конкретного оператора и желаемой семантики), вызываемой при применении соответствующего символа оператора к объектам пользовательского типа — это позволяет объектам пользовательских классов вести себя естественно, «как встроенные типы» в выражениях с этими операторами (например, складывать два объекта Vector2D через +, как если бы это были числа). Не все операторы C++ можно перегрузить (в частности, нельзя перегрузить оператор . доступа к члену, оператор :: разрешения области видимости, тернарный ?:, и нельзя создавать совершенно новые, не предусмотренные стандартом символы операторов), и хорошая практика рекомендует перегружать операторы только тогда, когда их семантика для конкретного пользовательского типа действительно интуитивно соответствует ожидаемому, «естественному» смыслу этого оператора (принцип наименьшего удивления), а не использовать перегрузку произвольным, неинтуитивным образом просто потому, что это технически возможно.

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

#include <iostream>

class Vector2D {
public:
    Vector2D(double x, double y) : m_x(x), m_y(y) {}

    Vector2D operator+(const Vector2D &other) const // перегрузка + как метод класса
    {
        return Vector2D(m_x + other.m_x, m_y + other.m_y);
    }

    bool operator==(const Vector2D &other) const
    {
        return m_x == other.m_x && m_y == other.m_y;
    }

    // friend (статья 536) — для операторов, где левый операнд не является объектом этого класса
    friend std::ostream &operator<<(std::ostream &os, const Vector2D &vec)
    {
        os << "(" << vec.m_x << ", " << vec.m_y << ")";
        return os;
    }

private:
    double m_x, m_y;
};

int main()
{
    Vector2D a(1.0, 2.0);
    Vector2D b(3.0, 4.0);

    Vector2D sum = a + b; // вызывает a.operator+(b) — естественный, интуитивный синтаксис
    std::cout << sum << std::endl; // вызывает operator<<(cout, sum) — "(4, 6)"

    std::cout << (a == b) << std::endl; // вызывает a.operator==(b) — 0 (false)

    return 0;
}
// Перегрузка оператора [] для класса-обёртки, имитирующего поведение массива/контейнера
class SafeArray {
public:
    SafeArray(size_t size) : m_data(new int[size]), m_size(size) {}
    ~SafeArray() { delete[] m_data; }

    int &operator[](size_t index) // возвращает ССЫЛКУ — позволяет и читать, и ИЗМЕНЯТЬ через []
    {
        if (index >= m_size) {
            throw std::out_of_range("Индекс за пределами массива"); // статья 540 — исключения
        }
        return m_data[index];
    }

private:
    int *m_data;
    size_t m_size;
};

Пояснения к коду

Vector2D::operator+ показывает типичную перегрузку как метод класса — a + b синтаксически разворачивается в a.operator+(b), что естественно для операторов, где левый операнд логично является объектом класса, для которого определена перегрузка. friend std::ostream &operator<< показывает случай, требующий объявления как friend-функции (статья 536) — поскольку левый операнд этого оператора (std::ostream, то есть cout) не является объектом класса Vector2D, оператор не может быть естественно определён как метод Vector2D, и стандартная практика для перегрузки << (и аналогично >> для ввода) — свободная функция, объявленная как friend для доступа к приватным полям класса. SafeArray::operator[], возвращающий ссылку int& (а не просто int), показывает важную деталь — возврат именно ссылки позволяет использовать результат [] не только для чтения (int value = arr[2];), но и для записи (arr[2] = 100;), что было бы невозможно, если бы оператор возвращал обычное значение, а не ссылку на реальный элемент внутреннего хранилища.

Подводные камни

  • Перегрузка оператора с неинтуитивной, не соответствующей «естественному» смыслу семантикой (например, перегрузка + для класса, где сложение двух объектов не имеет очевидного, ожидаемого смысла, или, что хуже, выполняет совершенно не связанную с интуитивным пониманием «сложения» операцию) — это прямо нарушает принцип наименьшего удивления, и читатель кода, видящий a + b для объектов пользовательского класса, разумно ожидает поведения, аналогичного сложению чисел/векторов в математическом смысле, а не произвольной, неочевидной операции, для которой более явно названный обычный метод был бы значительно понятнее.
  • Перегрузка оператора == без соответствующей, согласованной перегрузки != (или, в современном C++20, без использования автоматической генерации через <=>, оператор трёхстороннего сравнения, не разбираемый подробно в этой статье, но решающий именно эту проблему) — пользователи класса разумно ожидают, что если == определён, то и != должен быть доступен с логически согласованной, противоположной семантикой, и в более старых стандартах C++ (до C++20) оба оператора нужно было перегружать отдельно, явно обеспечивая их согласованность вручную.
  • Возврат значения (а не ссылки) из перегруженного operator[], когда ожидается возможность изменения элемента через этот оператор — как явно показано в примере SafeArray, возврат именно ссылки критичен для поддержки как чтения, так и записи через один и тот же синтаксис [], и забытое возвращение ссылки (случайное возвращение копии значения) сделало бы код вида arr[2] = 100; либо ошибкой компиляции, либо (что хуже) бессмысленной операцией, изменяющей лишь временную копию, не влияющую на реальное состояние объекта.
  • Перегрузка операторов для классов, где простой, явно названный метод был бы значительно понятнее и однозначнее — перегрузка операторов уместна именно для типов, моделирующих математические или контейнероподобные абстракции, где соответствующий оператор имеет действительно интуитивный, широко понятный смысл (векторы, комплексные числа, контейнеры), но для произвольных, не имеющих такой естественной математической/контейнерной аналогии классов попытка «изобрести» смысл для оператора часто менее читаема, чем простой, явно названный метод (combineWith() вместо неоднозначного +), и решение о перегрузке оператора должно явно учитывать, действительно ли это улучшает или, наоборот, ухудшает понятность кода для конкретного случая.