Как стать автором
Обновить
619.2
OTUS
Развиваем технологии, обучая их создателей

Сломанный PartialEq и Ord: как один лишний derive ломает сортировку

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров857

Привет, Хабр!

Сегодня рассмотрим, как один единственный #[derive(Ord)], казалось бы безобидный, может сломать сортировку, нарушить контракт PartialEq, и вызвать странные баги в BTreeMap, .sort(), или даже в логике dedup.

Что делает #[derive(Ord)]

Первое, что нужно помнить: когда вы пишете

#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
struct Point {
    x: i32,
    y: i32,
}

компилятор автоматически генерирует:

  1. PartialEq: сравнивает все поля по очереди.

  2. Eq: маркер, что PartialEq строгий (нет NaN‑подобных ситуаций).

  3. PartialOrd: лексикографическое сравнение кортежа (x, y).

  4. Ord: полное сравнение, соответствующее PartialOrd.

В результате Point { x:1, y:2 } < Point { x:2, y:0 } и при этом a == b оба поля равны.

Но допустим, хочется считать два Point равными, если совпадает только x, а y игнорировать. Например, для группировки на основе x‑координаты. Многие идут по ленивому пути:

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

// Я сравниваю только x, мне не важно y!
impl PartialEq for Point {
    fn eq(&self, other: &Self) -> bool {
        self.x == other.x
    }
}
impl Eq for Point {}

Думают, все будет без проблем. Но нет, мы забыли, что сортировка через .sort() использует Ord, которого у нас нет, а derive мы не добавляли — значит нам нужно имплементить PartialOrd, Ord тоже вручную или убрать их вовсе. А что делает тот лишний derive, о котором речь?

Когда Ord и PartialOrd расходятся

Если вы derive«ите только часть:

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Point {
    x: i32,
    y: i32,
}

// Но PartialEq мы переопределили выше.

Здесь получается неявная неконсистентность: PartialEq::eq сравнивает только x, а Ord::cmp (от derive!) сравнивает (x, y).

Правило трейт‑бездны: для любых a, b должно выполняться

if a == b { cmp(a, b) == Ordering::Equal }

Но в нашем случае:

let a = Point { x: 5, y: 1 };
let b = Point { x: 5, y: 9 };
assert!(a == b);                // true, потому что x совпало
assert!(a.cmp(&b) == Ordering::Equal); // false, тут Ordering::Less

— мы сломали контракт Ord. Результат: сортировка .sort() (или любые коллекции с BTreeSet) начнёт весело себя вести: дубликаты могут появиться дважды, элементы «прыгают» в неожиданные места.

Баг в .sort() из-за неполного Eq

Допустим, есть лог событий, и нужно отсортировать их по user_id. Порядок внутри одной группы не важен — хоть по timestamp, хоть по алфавиту payload, хоть по фазе луны.

#[derive(Debug)]
struct Event {
    user_id: u64,
    timestamp: u64,
    payload: String,
}

// Мы явно определяем равенство только по user_id
impl PartialEq for Event {
    fn eq(&self, other: &Self) -> bool {
        self.user_id == other.user_id
    }
}
impl Eq for Event {}

// Но вот дальше начинаются проблемы:
#[derive(PartialOrd, Ord)] // <-- derive'им Ord и PartialOrd, не перепроверив логику
struct Event; // ошибка компиляции, потому что тип уже определён выше

В более реальном случае код выглядел примерно так:

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct Event {
    user_id: u64,
    timestamp: u64,
    payload: String,
}

Но мы забыли, что derive(Ord) не знает, что PartialEq уже переопределён — и продолжает сравнивать все поля по порядку, включая timestamp, payload и прочее.

Последствия:

let mut events = vec![
    Event { user_id: 1, timestamp: 10, payload: "login".into() },
    Event { user_id: 1, timestamp: 5, payload: "logout".into() },
    Event { user_id: 2, timestamp: 3, payload: "purchase".into() },
];

events.sort();

// Ожидаем: [user_id 1, user_id 1, user_id 2]
// Порядок между первыми двумя не имеет значения.
// Но получаем: [1@5, 1@10, 2@3] — потому что cmp сравнивает timestamp.

С точки зрения алгоритма сортировки всё работает корректно — Ord говорит сравнивать user_id, потом timestamp, потом payload, и .sort() делает именно это. Но это вступает в противоречие с PartialEq, где мы сказали: «равны, если user_id одинаковый».

Теперь представьте, что вы делаете что‑то вроде:

let grouped: Vec<_> = events.into_iter().dedup().collect();
assert_eq!(grouped.len(), expected_user_count); // падение

Почему? Потому что .dedup() использует ==, а .sort() — cmp(). А они у нас про разное.

Результат: дубликаты не выкинулись, а вы сидите и гадаете, откуда взялись лишние элементы. И вот уже баг, тайминги слетели, дедлайны поехали, кто‑то уже сыплет println!, а кто‑то идёт искать виноватого. Но проблема — в одном #[derive(Ord)].

Как правильно реализовать сравнение

Когда вы осознанно переопределяете PartialEq, то должны взять на себя всю ответственность за консистентность. И если вы нарушаете равенство между == и cmp() == Ordering::Equal, последствия будут не абстрактными, а вполне конкретными: сортировки, BTreeMap, .dedup(), бинарные поиски — всё начнёт себя вести непредсказуемо.

Полный ручной имплемент всех трейтов

Допустим, нужно, чтобы два события считались равными, если у них совпадает user_id. Остальные поля нас не интересуют. Тогда пишем так:

use std::cmp::Ordering;

#[derive(Debug)]
struct Event {
    user_id: u64,
    timestamp: u64,
    payload: String,
}

// Равенство: сравниваем только user_id
impl PartialEq for Event {
    fn eq(&self, other: &Self) -> bool {
        self.user_id == other.user_id
    }
}
impl Eq for Event {}

// Частичное сравнение (для сортировок с опцией)
impl PartialOrd for Event {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.user_id.cmp(&other.user_id))
    }
}

// Полное сравнение — должно строго соответствовать PartialEq
impl Ord for Event {
    fn cmp(&self, other: &Self) -> Ordering {
        self.user_id.cmp(&other.user_id)
    }
}

Здесь зафиксировали, что всё вращается вокруг user_id. Это дает нам то, что:

  • .sort() работает корректно и стабильно по user_id;

  • .dedup() не пропустит дубликаты;

  • BTreeSet<Event> не будет держать «одинаковых» с разными payload.

Если хоть один из этих трейтов остался с derive, вы можете получить рассинхрон. Частая ошибка — оставить derive(PartialOrd) или derive(Ord), и не заметить, что он тянет timestamp, payload и прочее в сравнение.

Не забываем, что в Rust'е PartialOrd должен быть согласован с PartialEq, а Ord — с Eq.

Макросы и внешние крейты

Если вы хотите удобства и меньше ручного бойлерплейта, но при этом оставить избирательное сравнение — можно подключить сторонние макросы. Один из популярных подходов — использовать ord_subset или cmp_derive (оба на crates.io).

Допустим, есть сложная структура с кучей полей:

use cmp_derive::CmpOrd;

#[derive(Debug, PartialEq, Eq, CmpOrd)]
#[cmp_ord(ignore = "payload, timestamp")]
struct Event {
    user_id: u64,
    timestamp: u64,
    payload: String,
}

Макрос сгенерирует корректную реализацию PartialOrd и Ord, в которой будет сравниваться только user_id. Поля, перечисленные в #[cmp_ord(ignore = "...")], будут проигнорированы.

Итоги

Один лишний #[derive(Ord)], не согласованный с PartialEq, может вызвать баги, которые точно заденут ваши нервы. Поэтому: либо имплементируйте сравнение вручную и строго, либо используйте макросы, которые чётко фиксируют намерения. Важно одно — не оставляйте случайное поведение в проде.

А вы сталкивались с подобными проблемами из‑за derive?


Если вы хотите углубиться в ключевые аспекты работы с Rust и понять, как эффективно строить системы с его использованием, открытые уроки ниже точно вам подойдут. Присоединяйтесь, чтобы разобраться в тонкостях ABI, безопасности и создании веб-сервисов, которые соответствуют современным требованиям:

Теги:
Хабы:
Всего голосов 11: ↑6 и ↓5+2
Комментарии4

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS