JavaRush /Курси /C++ SELF /Оператори чи компаратори

Оператори чи компаратори

C++ SELF
Рівень 50 , Лекція 1
Відкрита

1. Чому виникає вибір

Коли ви починаєте писати operator== і <=>, зʼявляється приємне відчуття: «мій тип майже як int». Але доволі швидко життя повертає до реальності: той самий обʼєкт у різних завданнях можна порівнювати по-різному. В одному місці вам важливий id, в іншому — «спочатку пріоритет, потім назва», а в третьому — «виконані мають бути внизу списку». І тут важливо не перетворити оператори на «швейцарський ніж, що ще й суп варить».

Перевантажені оператори — це правило за замовчуванням для типу. Якщо ви додали a < b, ви ніби кажете: «у більшості випадків, коли люди бачать Task і хочуть його впорядкувати, вони очікують саме такого порядку». А компаратор, найчастіше у вигляді лямбди, — це локальне правило: саме зараз, у цьому конкретному std::sort, ми сортуємо так, бо цього потребує завдання.

Щоб не обговорювати це надто абстрактно, продовжимо розвивати наш навчальний консольний застосунок: мінітрекер завдань, умовний TODO-список, де ми зберігаємо завдання у std::vector і вміємо їх виводити та сортувати.

2. Міні-модель: TaskId, Priority, Task

Перш ніж сперечатися про «оператори чи компаратори», нам потрібен обʼєкт, який узагалі можна порівнювати. Зробімо невелику модель завдання так, щоб вона була зрозумілою новачкові й водночас достатньо «багатою», аби зʼявилися різні критерії сортування та пошуку. Важливо: модель не зобовʼязана бути ідеальною — вона має бути навчальною та передбачуваною, щоб на ній добре було видно наслідки ваших рішень.

Почнімо з невеликого TaskId (щоб не плутати «число взагалі» та «ідентифікатор завдання») і пріоритету:

#include <compare>

class TaskId {
public:
    explicit TaskId(int v) : v_(v) {}

    int value() const { return v_; }

    auto operator<=>(const TaskId&) const = default; // порядок за числом
    bool operator==(const TaskId&) const = default;

private:
    int v_{};
};

enum class Priority {
    Low = 0,
    Normal = 1,
    High = 2
};

Тепер саме завдання. Нехай у нього є id, title, priority і прапорець done. Виведення в потік і порівняння ми вже вміємо робити з матеріалу попередніх лекцій.

#include <string>
#include <utility>

struct Task {
    TaskId id;
    std::string title;
    Priority priority{Priority::Normal};
    bool done{false};

    bool operator==(const Task&) const = default;
};

Зверніть увагу: тут operator== = default означає «рівні, якщо рівні всі поля». Це не завжди правильне розуміння рівності, але для початку це дуже зручна точка відліку. За кілька розділів ми побачимо, коли це перетворюється на помилку дизайну.

Зробімо ще й виведення в потік, щоб у прикладах сортування було що показувати:

#include <iostream>

std::ostream& operator<<(std::ostream& os, const Task& t) {
    os << "Завдання{id=" << t.id.value()
       << ", title=\\"" << t.title << "\\""
       << ", done=" << (t.done ? "true" : "false")
       << "}";
    return os;
}

3. Коли порівняння має бути частиною типу

Коли ви визначаєте оператор усередині типу або поруч із ним як вільну функцію, ви не просто додаєте синтаксичний цукор. Ви задаєте правило, яким почнуть користуватися інші частини програми: алгоритми, контейнери, а також ваші майбутні функції. Це приблизно як назвати поле price і сподіватися, що всі зрозуміють «ціна в доларах», а потім раптом зберігати там «копійки». Формально так можна. Користі з цього мало.

Найчесніший критерій такий: оператор доречний тоді, коли тип має природний, очевидний для більшості читачів зміст порівняння. Для TaskId все просто: два TaskId рівні, якщо число однакове. Для Task усе вже не так очевидно: завдання може бути «тим самим» за id, навіть якщо title змінили. Або навпаки: у вас може бути система без id, і тоді «рівність за всіма полями» — цілком нормальний варіант.

Є дуже практичне правило, яке добре береже нерви: оператор == має відповідати на запитання «це той самий обʼєкт у сенсі моєї предметної області?». Якщо так — чудово. Якщо ж ви відчуваєте, що відповідь залежить від ситуації, тоді, найімовірніше, == як «єдине розуміння рівності» вам іще рано визначати, або його варто будувати за стабільнішим ключем.

Наприклад, якщо id — це ідентичність, то рівність «за всіма полями» може виявитися неправильною. Тоді краще так:

struct Task {
    TaskId id;
    std::string title;
    Priority priority{Priority::Normal};
    bool done{false};

    bool operator==(const Task& other) const {
        return id == other.id; // ідентичність за id
    }
};

Такий operator== вважає завдання «тим самим», навіть якщо ви змінили заголовок або пріоритет. І це часто ближче до реальності: завдання — це сутність, а поля — її стан.

4. Порядок за замовчуванням: корисно, але небезпечно

Порядок (<, >, <=>) — ще слизькіша доріжка, ніж рівність. Рівність зазвичай ще можна якось пояснити. А от порядок часто видає себе за «природний», хоча насправді він просто зручний для програміста.

Якщо ви додаєте <=> для Task, ви визначаєте сортування за замовчуванням. І тут варто чесно запитати себе: «а чи є в завдання один природний порядок, якого очікують усюди?». Якщо у вас є чітка відповідь, наприклад «усюди сортуємо за id», тоді можна:

#include <compare>

struct Task {
    TaskId id;
    std::string title;
    Priority priority{Priority::Normal};
    bool done{false};

    auto operator<=>(const Task& other) const {
        return id <=> other.id; // порядок за ідентифікатором
    }

    bool operator==(const Task& other) const {
        return id == other.id; // і рівність узгоджена з порядком
    }
};

Тут є важливий момент: ми спеціально робимо == узгодженим із порядком, щоб не виникало «когнітивного дисонансу». Якщо a == b, зазвичай очікується, що a < b і b < a будуть хибними.

Але якщо ви не впевнені, що існує «природний» порядок, краще не додавати його в сам тип. І це не слабкість, а обережність. Тип без < не стає гіршим; він просто чесно каже: «порядок залежить від завдання, задавайте його компаратором».

5. Компаратор: сортування під конкретне завдання

Коли ми сортуємо std::vector<Task>, ми майже завжди робимо це для екрана, для звіту, для вибору користувачем, а не тому, що в завдань є якийсь філософський «природний порядок». Тому std::sort із компаратором — цілком нормальна й доросла практика.

Уявімо, що в нашому застосунку є команда «показати завдання: спочатку невиконані, потім виконані; усередині — за пріоритетом; і лише потім — за id». Це не «порядок завдання як сутності», а «порядок подання».

Ось який вигляд це має з компаратором-лямбдою:

#include <algorithm>
#include <vector>

int priority_rank(Priority p) {
    return static_cast<int>(p);
}

void sort_for_ui(std::vector<Task>& tasks) {
    std::sort(tasks.begin(), tasks.end(),
              [](const Task& a, const Task& b) {
                  if (a.done != b.done) return a.done < b.done; // false раніше true
                  if (a.priority != b.priority) {
                      return priority_rank(a.priority) > priority_rank(b.priority); // High вище
                  }
                  return a.id < b.id;
              });
}

Зверніть увагу на стиль: ми явно проговорюємо «якщо значення тут різняться — вирішуємо за цим правилом, інакше йдемо далі». Такий код читається як набір правил сортування, а не як загадка. Так, це більше рядків, ніж = default, зате зміст видно прямо там, де ви його використовуєте.

І тепер головне: вам не довелося змінювати зміст Task в усій програмі. Ви просто відсортували дані для потрібного екрана.

6. Пошук: оператори та предикати

Слово «пошук» часто плутають із «упорядковуванням». На початку навчання це зазвичай зливається в одне: «я шукаю — отже, порівнюю». Але в STL пошук частіше виглядає як «знайти елемент, що задовольняє умову». Ця умова — звичайна функція bool(const T&), і найчастіше її пишуть як лямбду. Тут нам не потрібен порядок, і часто нам навіть не потрібен operator==.

Почнімо з класичного std::find. Він шукає «точно рівний елемент». Тому йому потрібен operator==:

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

int main() {
    std::vector<Task> tasks = {
        {TaskId{1}, "Купити молоко", Priority::Normal, false},
        {TaskId{2}, "Виправити баг", Priority::High, false}
    };

    auto it = std::find(tasks.begin(), tasks.end(),
                        Task{TaskId{2}, "", Priority::Low, true});

    std::cout << (it != tasks.end() ? it->title : "не знайдено") << '\n';
    // Виправити баг
}

Цей приклад виглядає трохи дивно: ми створили «шаблонне завдання» з порожньою назвою. Але він чудово демонструє, що std::find порівнює через ==. Якщо ваш == порівнює тільки id, то все працює. Якщо ж він порівнює всі поля, то шукати «за id» так уже не вийде — доведеться або створювати точну копію завдання, або змінювати підхід.

І ось тут на сцену виходить std::find_if: пошук за умовою. Йому не потрібен operator==, йому потрібен предикат.

#include <algorithm>

Task* find_by_id(std::vector<Task>& tasks, TaskId id) {
    auto it = std::find_if(tasks.begin(), tasks.end(),
                           [&](const Task& t) { return t.id == id; });
    return (it == tasks.end()) ? nullptr : &*it;
}

Такий стиль часто кращий навіть за наявності ==, бо умову пошуку видно прямо в коді: ми шукаємо за id, і крапка. Жодних здогадок на кшталт «а що саме там порівнюється в operator==?».

7. Корисні нюанси

Шпаргалка: що куди класти

Коли голова кипить, корисно тримати перед очима просту карту рішень. Тут немає «єдино правильної відповіді», але є робочі евристики, які рятують від випадкових API-помилок і дивних операторів.

Ситуація Що краще Чому
Тип має один очевидний зміст «однаковості» (наприклад, TaskId) operator== усередині типу Це частина сутності типу, і саме цього очікують усюди
Тип має один очевидний «природний порядок» (рідко, але буває) operator<=> або operator< усередині типу Зручно для std::sort за замовчуванням і для контейнерів, яким потрібен порядок
Тип має багато осмислених варіантів сортування (за імʼям, за датою, за пріоритетом) Компаратор у std::sort Правило сортування локальне й явне, тип не «перепрошивається»
Пошук за складною умовою (підрядок, прапорець, діапазон) std::find_if з лямбдою Так простіше виразити умову, ніж намагатися втиснути її в operator==
Пошук за ключем (наприклад, id -> Task), і це часта операція Окремий індекс (наприклад, std::unordered_map<TaskId, ...>) Пошук стає прямим, а не зводиться до перебору всього вектора

Зверніть увагу: останній рядок — не про оператори напряму, але він дуже практичний. Іноді ми сперечаємося про те, «як порівнювати», хоча правильна відповідь — «давайте зберігати дані так, щоб не шукати їх перебором».

Компаратор і коректне порівняння

Дуже легко написати компаратор, який виглядає правдоподібно, але порушує очікування std::sort. У цьому курсі ми не будемо заглиблюватися в теорію, але базову інтуїцію варто вловити: компаратор має задавати розумний порядок без суперечностей. Якщо в одному випадку компаратор каже «a менше b», а потім під час іншої перевірки раптом починає поводитися інакше, або залежить від зовнішнього змінного стану, сортування може дати дивний результат.

Наприклад, погана ідея — компаратор, який використовує випадковість або глобальний лічильник викликів. Так, це звучить як жарт, але інколи люди випадково роблять майже те саме, коли захоплюють у лямбду посилання на змінну, яка змінюється під час сортування.

Хороші компаратори мають важливу рису: вони «чисті». Вони отримують на вхід два обʼєкти, а на виході дають true/false, і це рішення залежить лише від цих обʼєктів та, максимум, від сталих правил на кшталт таблиці пріоритетів.

8. Кілька сортувань без ламання моделі

Дуже типова ситуація в реальному проєкті: користувачеві потрібен перемикач сортування. Сьогодні — «за пріоритетом», завтра — «за назвою». І саме тут особливо важливо не намагатися «переписувати operator<=> на льоту» (що, на щастя, неможливо) і не робити оператор залежним від глобальної змінної currentSortMode. Звучить ніби зручно, але на практиці це майже гарантований баг.

Зробімо все акуратно: окрема функція, яка приймає режим і викликає std::sort із потрібним компаратором. Нехай режим поки що буде звичайним enum class.

#include <algorithm>

enum class SortMode {
    ById,
    ByTitle,
    ByPriority
};

void sort_tasks(std::vector<Task>& tasks, SortMode mode) {
    if (mode == SortMode::ById) {
        std::sort(tasks.begin(), tasks.end(),
                  [](const Task& a, const Task& b) { return a.id < b.id; });
        return;
    }

    if (mode == SortMode::ByTitle) {
        std::sort(tasks.begin(), tasks.end(),
                  [](const Task& a, const Task& b) { return a.title < b.title; });
        return;
    }

    std::sort(tasks.begin(), tasks.end(),
              [](const Task& a, const Task& b) {
                  return static_cast<int>(a.priority) > static_cast<int>(b.priority);
              });
}

Зверніть увагу на цей «технічний трюк»: ми не ліпимо складних шаблонів, не використовуємо тем із майбутніх лекцій, а просто чесно вибираємо компаратор. Так, тут є if. І це нормально. Іноді найчитабельніший дизайн — той, що не намагається бути «надто розумним».

9. Типові помилки

Помилка № 1: намагатися запхати всі варіанти сортування в operator<=>.
Новачки часто думають: «ну я ж можу визначити порядок, отже сортування всюди буде зручним». А потім виявляється, що «зручний порядок» один, а сценаріїв сортування — пʼять. У результаті operator<=> стає або сумнівним, або просто шкідливим, бо навʼязує порядок там, де він не потрібен.

Помилка № 2: робити operator== = default, не подумавши про зміст ідентичності.
= default порівнює всі поля, і це часто виявляється несподіванкою. Якщо завдання «те саме» за id, але ви змінили title, то раптом a == b стане хибним, і std::find перестане знаходити «те саме» завдання. Перш ніж писати == через = default, проговоріть словами: «що означає “рівні” для мого типу?».

Помилка № 3: використовувати std::find там, де потрібен пошук за умовою.
Якщо вам треба знайти завдання за підрядком у назві або за статусом done, не мучте operator== і не створюйте «шаблонний обʼєкт для порівняння». std::find_if з лямбдою зазвичай простіший, зрозуміліший і чесніший — умову пошуку видно прямо в місці виклику.

Помилка № 4: компаратор залежить від зовнішнього змінного стану.
Сортування викликає компаратор багато разів і в неочевидному порядку. Якщо ваш компаратор читає змінну, яка змінюється паралельно, або змінюється всередині самого компаратора, ви отримаєте «плаваюче» порівняння. Підсумок — дивний порядок або, в гіршому разі, поведінка, яку дуже важко пояснити.

Помилка № 5: змішувати «рівність» і «схожість».
Іноді хочеться перевантажити operator== так, щоб завдання вважалися рівними, якщо в них схожі заголовки або однаковий пріоритет. Але це вже не рівність, а «схожість». Для «схожості» краще мати окрему функцію або предикат у find_if, інакше ви зламаєте очікування від == в усій програмі.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ