El operador <=> [C++20], conocido como spaceship operator, es la solución definitiva para simplificar la lógica de comparación en C++. Antes de esta adición, si querías que tu clase fuera totalmente comparable, tenías que implementar manualmente seis operadores (==, !=, <, <=, >, >=). Esto no solo era tedioso, sino que era una fuente constante de errores de lógica donde, por ejemplo, a < b y b > a podían devolver resultados inconsistentes.
El operador <=> resuelve esto devolviendo un objeto de tipo orden en lugar de un booleano. Este objeto indica si un elemento es menor, igual, mayor o si la comparación ni siquiera es posible. La elegancia de este diseño reside en que, una vez que defines el operador de tres vías, el compilador puede deducir (sintetizar) automáticamente todos los demás operadores relacionales.
Sin embargo, no todas las comparaciones son iguales. El tipo de orden que devuelves define la semántica de tu clase:
1. std::strong_ordering: La forma más estricta. Si a <=> b es igual a b <=> a, entonces a == b. Es el caso de los int o std::string.
2. std::weak_ordering: La igualdad es una equivalencia, pero no necesariamente una identidad. Por ejemplo, si comparamos strings ignorando mayúsculas, "A" y "a" son “iguales” para nuestro operador, pero no son el mismo objeto.
3. std::partial_ordering: Para casos donde la comparación puede ser indeterminada. El ejemplo clásico es float con NaN (Not a Number); NaN no es mayor, ni menor, ni igual a nada.
Para usarlo correctamente, fíjate en la distinción entre el uso de = default y la implementación manual. Si usas auto operator<=>(const T&) const = default;, el compilador genera una comparación miembro a miembro (lexicográfica) y, lo más importante, también genera automáticamente el operator==. Si implementas la lógica de <=> a mano, el compilador no generará el == por ti, por lo que deberás definirlo explícitamente para evitar errores de compilación.
#include <iostream>
#include <compare> // Necesario para los tipos de orden
#include <string>
#include <vector>
#include <limits>
// Un tipo con ordenación fuerte (strong_ordering).
// Los enteros siempre son comparables y la igualdad es absoluta.
struct Version {
int major;
int minor;
// Al usar = default, el compilador genera <=> y ==
// comparando primero 'major' y luego 'minor'.
auto operator<=>(const Version&) const = default;
};
// Un tipo con ordenación parcial (partial_ordering).
// Debido a que 'value' puede ser NaN, la comparación no es total.
struct SensorReading {
double value;
// Debemos devolver std::partial_ordering porque el tipo base (double)
// admite estados donde la comparación no tiene sentido (NaN).
std::partial_ordering operator<=>(const SensorReading& other) const {
return value <=> other.value;
}
// Al no ser 'default', debemos implementar el equality operator.
bool operator==(const SensorReading& other) const {
return value == other.value;
}
};
int main() {
// Caso 1: Version (Sintetizado por el compilador)
Version v1{1, 2};
Version v2{1, 3};
Version v3{1, 2};
if (v1 < v2) std::cout << "v1 es menor que v2\n";
if (v1 == v3) std::cout << "v1 es igual a v3\n";
// Caso 2: SensorReading (Lógica personalizada y parcial)
SensorReading s1{25.5};
SensorReading s2{30.0};
SensorReading s3{std::numeric_limits<double>::quiet_NaN()};
if (s1 < s2) std::cout << "s1 es menor que s2\n";
// La comparación con NaN devuelve std::partial_ordering::unordered
auto res = s1 <=> s3;
if (res == std::partial_ordering::unordered) {
std::cout << "s1 y s3 son incomparables debido a NaN\n";
}
// El operador <= se sintetiza a partir de <=> y ==
if (s1 <= s2) std::cout << "s1 es menor o igual a s2\n";
return 0;
}
Desglose del código
En el ejemplo, Version es un ejemplo de optimización de desarrollo. Al usar auto operator<=>(const Version&) const = default;, el compilador analiza los miembros de la estructura. Como int soporta std::strong_ordering, el operador resultante también lo hace. Fíjate que no hemos escrito operator< ni operator==; el compilador los ha inyectado en la tabla de símbolos basándose en la lógica de Version.
En SensorReading, la situación es distinta. Como trabajamos con double, tenemos que lidiar con NaN. Si intentáramos usar = default aquí, el compilador intentaría derivar un strong_ordering que sería semánticamente incorrecto para un tipo que puede ser unordered. Por ello, implementamos el operator<=> devolviendo std::partial_ordering. Al hacerlo manualmente, el compilador ya no nos regala el operator==, así que lo definimos para que coincida con la semántica de igualdad de punto flotante.
En el main, la magia de la síntesis se ve cuando usamos v1 < v2. El compilador ve que v1 y v2 tienen un operator<=>, así que lo utiliza para evaluar la comparación. De igual forma, la comparación s1 <= s2 es posible porque el operador <=> de SensorReading proporciona la información necesaria para determinar la desigualdad y la igualdad.
El error frecuente
Un error muy común es definir el operator<=> de forma personalizada y olvidar implementar operator==. Aunque parezca que el operador de tres vías debería ser suficiente, si no usas = default, el compilador no genera el operador de igualdad. Esto resultará en un error de compilación cuando intentes usar tipos como SensorReading en un std::set o con std::find, ya que estas plantillas dependen de operator==.
Otro error crítico es devolver std::strong_ordering cuando tu clase contiene miembros que no garantizan una ordenación total. Si tu objeto puede representar un estado “inválido” o “desconocido” (como un NaN en un double o un std::optional sin valor que tratas de forma especial), y devuelves strong_ordering, romperás los requisitos de los algoritmos de la STL como std::sort, provocando comportamiento indefinido o crashes debido a que el algoritmo asumirá que la relación de orden es consistente cuando no lo es.
N° 45