Привет, Хабр!
Сегодня рассмотрим, как один единственный #[derive(Ord)], казалось бы безобидный, может сломать сортировку, нарушить контракт PartialEq, и вызвать странные баги в BTreeMap, .sort(), или даже в логике dedup.
Что делает #[derive(Ord)]
Первое, что нужно помнить: когда вы пишете
#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)] struct Point { x: i32, y: i32, }
компилятор автоматически генерирует:
PartialEq: сравнивает все поля по очереди.Eq: маркер, чтоPartialEqстрогий (нет NaN‑подобных ситуаций).PartialOrd: лексикографическое сравнение кортежа(x, y).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, безопасности и создании веб-сервисов, которые соответствуют современным требованиям:
24 апреля в 20:00 — Rust, Си ABI и линковка — способы сборки и линковки библиотек на Rust, а также реализация Си ABI.
14 мая в 20:00 — Описание контрактов и инвариантов в Rust — как описывать инварианты и обеспечивать безопасность в Rust.
19 мая в 20:00 — Веб-сервис на Rust с HTTP и gRPC API — реализация бизнес-логики и создание HTTP и gRPC API.
