EduBrick

Свои структуры

Почему пара пар — плохая идея, как научить структуру сравниваться и вводиться, и что делает const в объявлении оператора.

5 мин

В задаче есть объект с несколькими полями: у школьника имя, класс и балл; у отрезка левая и правая границы; у события время и тип. Хранить это можно по-разному, и разница огромна.

Пара пар — так не надо

Для двух полей pair уместен. Для трёх начинается вот это:

vector<pair<pair<string, string>, int>> people;
cin >> item.first.first >> item.first.second >> item.second;

Через день вы уже не помните, что такое first.second. Через два поля добавится ещё уровень, и код станет нечитаемым окончательно. tuple не лучше: доставать поля через get<2>(t) ничем не понятнее.

Правильно так:

struct Person {
    string name;
    string surname;
    int grade = 0;
    long long score = 0;
};   // точка с запятой обязательна

Теперь person.grade вместо item.first.second. Точка с запятой после закрывающей скобки — самая частая ошибка компиляции у тех, кто пишет структуру впервые.

Значения по умолчанию (= 0) стоит указывать всегда. Без них поля неинициализированной структуры содержат мусор, и это неопределённое поведение.

Сравнение

sort по вектору структур не скомпилируется: язык не знает, какая структура «меньше». Ошибка при этом будет на четыре сотни строк, и полезной информации в ней почти нет — ищите первую строчку со словом error.

Учим сравниваться:

struct Person {
    string name;
    int grade = 0;
    long long score = 0;

    bool operator<(const Person& other) const {
        return score < other.score;
    }
};

Два const здесь не украшение.

Первый, в const Person& other, означает две вещи: аргумент передаётся по ссылке (не копируется — а копирование структуры со строками стоит дорого) и изменять его нельзя. Без ссылки при каждом сравнении копировались бы все строки; сортировка мгновенно стала бы вдвое медленнее.

Второй, после скобок, запрещает менять текущий объект. Без него можно случайно написать score = 0 внутри сравнения и очень долго искать, почему массив сортируется в мусор.

Альтернатива — определить оператор снаружи структуры:

bool operator<(const Person& left, const Person& right) {
    return left.score < right.score;
}

Работает одинаково. Внутри короче, снаружи нагляднее — дело вкуса.

Сравнение должно быть строгим

Оператор обязан возвращать строгое «меньше». return score <= other.score — не оптимизация, а неопределённое поведение: sort на массиве из равных элементов с таким компаратором падает с ошибкой сегментации.

Для сравнения по нескольким полям удобно сравнивать кортежи — они уже умеют лексикографический порядок:

bool operator<(const Person& other) const {
    return tie(grade, score, name) < tie(other.grade, other.score, other.name);
}

Одна строка вместо цепочки if, и ошибиться негде.

С C++20 есть ещё короче — auto operator<=>(const Person&) const = default; сравнивает все поля по порядку объявления. Но проверьте, что судья поддерживает стандарт.

Ввод и вывод

Чтобы cin >> person работало, нужен оператор ввода:

istream& operator>>(istream& in, Person& p) {
    return in >> p.name >> p.grade >> p.score;
}

ostream& operator<<(ostream& out, const Person& p) {
    return out << p.name << ' ' << p.grade << ' ' << p.score;
}

Почему всё по ссылке и почему возвращается поток: чтобы работали цепочки cin >> a >> b. Каждый оператор возвращает тот же поток, и следующий читает из него дальше. Возьмёте поток по значению — не скомпилируется.

Обратите внимание на асимметрию: в operator>> структура не const (её меняют), в operator<<const (только читают).

Разбор на переменные

С C++17 структуру можно разложить на именованные части прямо в объявлении:

for (auto& [name, grade, score] : people)
    cin >> name >> grade >> score;

Поля привязываются по порядку объявления, а не по имени. Поменяете местами поля в структуре — переменные тихо поменяются смыслом, и компилятор не скажет ни слова. Приём удобен в коротких циклах и опасен в длинных.

Так же разбираются пары, что особенно приятно при обходе словаря:

for (const auto& [key, value] : counts)
    cout << key << ": " << value << '\n';

Конструкторы

Пока полей мало, структура создаётся списком в фигурных скобках: Person p{"Аня", 9, 100}. Когда полей много, а заполнять нужно два, пишут конструктор:

struct Segment {
    long long left = 0, right = 0, length = 0;

    Segment() = default;
    Segment(long long l, long long r) : left(l), right(r), length(r - l) {}
};

Строка после двоеточия — список инициализации: поля заполняются до входа в тело. Это и быстрее, и единственный способ инициализировать ссылки и константы.

Важная деталь: как только вы написали хоть один конструктор, конструктор без аргументов исчезает. Строка Segment() = default; возвращает его. Забудете — и vector<Segment> v(n) перестанет компилироваться.

emplace_back

При конструкторе появляется смысл у emplace_back:

v.push_back(Segment(1, 5));   // создали, потом скопировали в вектор
v.emplace_back(1, 5);         // сразу построили внутри вектора

Разница ощутима, когда в структуре лежит что-то тяжёлое — строка или вложенный вектор. Для структур из чисел разницы почти нет.

Структуры в контейнерах

С определённым operator< структура кладётся в set и работает ключом map без дополнительных усилий. Без него — не скомпилируется: упорядоченным контейнерам нужен порядок.

Для unordered_set и unordered_map operator< не нужен, зато нужны operator== и своя хеш-функция. Проще не связываться и взять обычный set, а если нужна скорость — свести структуру к числу или к строке и хешировать его.