Привет, Хабр!
Сегодня рассмотрим, как один единственный #[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.