CodeGym /Corsi /C++ SELF /operator<=> — l'idea del confronto ternario in C++23

operator<=> — l'idea del confronto ternario in C++23

C++ SELF
Livello 50, Lezione 0
Disponibile

1. I tipi hanno bisogno di un ordine

Quando sentiamo per la prima volta "confronto di oggetti", di solito pensiamo a ==: uguali o no. Ma nei programmi reali (e specialmente nella STL) emerge presto la questione dell'ordine: quale oggetto è "minore", quale "maggiore", come ordinare una lista. Senza ordine std::sort si arrende, e lo sviluppatore comincia a scrivere operatori fatti a mano operator<, operator<=, operator> e operator>= — e da qualche parte tra il terzo e il quarto operatore nasce di solito un bug che vivrà più a lungo di molte piante domestiche.

Immaginiamo che stiamo facendo una piccola applicazione console TaskBox (una semplice lista di task). Già sappiamo come memorizzare i task in std::vector e stamparli tramite operator<<. Ora vogliamo mostrare i task ordinati: per esempio per priorità, e in caso di parità di priorità — per id.

Prima dell'introduzione di <=>, molti iniziano così:

  • scrivere operator< (sembra ok);
  • poi "per sicurezza" scrivere operator> come return other < *this;
  • in seguito aggiungere <= e >=;
  • e poi imbattersi in strani comportamenti se uno degli operatori non è coerente con gli altri.

Il confronto ternario operator<=> cerca proprio di rendere questa storia più ordinata: un operatore — un'unica fonte di verità per l'ordine.

2. operator<=>: idea di base e cosa sostituisce

Un operatore al posto di quattro

In termini semplici, operator<=> è l'operatore che risponde non alla domanda "è minore?", ma alla domanda "in che modo sono comparati?". Il risultato ha dunque tre "stati": minore, uguale, maggiore. Per la forma del simbolo viene spesso chiamato spaceship operator ("operatore-astronave").

È importante non confondere: operator< restituisce bool, mentre operator<=> restituisce il risultato del confronto, che si può controllare confrontandolo con zero (nel senso "uguale/non uguale a zero"). I tipi di risultato possono essere diversi (ci sono varie categorie di confronto), ma per oggi ci basta il modello pratico: il risultato si può confrontare con 0 e questo funziona.

Spesso operator<=> viene dichiarato così:

#include <compare> // spesso necessario per <=>  

struct X {
    auto operator<=>(const X& other) const = default;
};

Nota l'header <compare>: è collegato al meccanismo del confronto ternario, e negli esempi didattici è meglio includerlo esplicitamente, per non chiedersi "perché non compila con questo compilatore/IDE".

Cosa cambia nel codice quotidiano

Quando aggiungi al tuo tipo operator<=>, stai dicendo al compilatore: "Ecco il mio modo ufficiale di confrontare due oggetti per ordine. Se ti servono <, <=, >, >= — usalo". Di conseguenza il tipo diventa più "amichevole con la STL": ordinamenti, ricerche binarie (su dati ordinati), strutture ordinate e codice leggibile diventano più semplici.

È utile tenere a mente una piccola tabella riassuntiva:

Cosa vogliamo nel codice Com'era normalmente senza <=> Cosa cambia con <=>
Ordinamento con std::sort serve < < spesso viene "derivato" da <=>
Controllare "non è minore" si scrive <= o lo si esprime via < <= può essere anch'esso dedotto
Un "centro del confronto" facile disallineare 3–4 operatori un operatore — meno probabilità di disallineamento
"Sono uguali?" serve == in alcuni casi == può essere = default, e l'ordine tramite <=>

Resta comunque una regola importante di design: gli operatori devono essere prevedibili. Se definisci un "ordine naturale" per un tipo — deve essere tale che il lettore del codice non si perda in riflessioni filosofiche sul senso della vita e dell'ordinamento.

3. TaskBox: ordiniamo i task tramite <=>

Modello Task

Ora aggiungeremo al modello di task un ordine chiaro, in modo da poter ordinare i task in std::vector. Supponiamo che un task abbia id, title e priority. Per semplicità rendiamo la priorità un int: 0 — basso, 1 — normale, 2 — alto. Sì, non è il modello ideale del mondo, ma almeno non richiede nuovi argomenti.

Cominciamo con la struttura:

#include <string>

struct Task {
    int id{};
    std::string title;
    int priority{}; // 0..2
};

Per stampare i task in modo carino, aggiungiamo operator<<:

#include <iostream>
#include <string>

std::ostream& operator<<(std::ostream& os, const Task& t) {
    return os << "Task{id=" << t.id
              << ", pr=" << t.priority
              << ", title=\"" << t.title << "\"}";
}

Ora vogliamo ordinare. E qui abbiamo due scenari: o ci basta l'"ordine per campi così come dichiarati", oppure servono criteri diversi (per esempio prima la priorità, poi l'id). Con operator<=> entrambi gli scenari si esprimono in modo compatto.

= default e ordine per campi

A volte va bene l'ordine "strutturale": confrontare il primo campo, se uguali — il secondo, se uguali — il terzo. Questo si chiama confronto lessicografico (in pratica "come sul dizionario, ma per campi"). In tal caso si può chiedere al compilatore di farlo per noi.

Aggiungiamo a Task il confronto ternario di default:

#include <compare>
#include <string>

struct Task {
    int id{};
    std::string title;
    int priority{};

    auto operator<=>(const Task&) const = default;
};

Qui è importante capire una cosa: l'ordine dei campi diventa l'ordine dei criteri. Cioè con = default il compilatore confronterà id, poi title, poi priority (nell'ordine di dichiarazione). Se ti aspettavi "prima priority", per default otterrai qualcosa di diverso.

Questo non è un "bug del compilatore" né un "capriccio dello standard". È l'esecuzione onesta del tuo contratto: hai detto "confronta per campi così come sono" — ed è quello che fa.

Verifichiamo che l'ordinamento ora sia possibile:

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks = { {2, "Refactor", 1}, {1, "Fix bug", 2} };
    std::sort(tasks.begin(), tasks.end());

    std::cout << tasks[0] << '\n'; // Task{id=1, pr=2, title="Fix bug"}
}

Sì, l'ordinamento funziona. Ma ordina per id (perché id è il primo campo). Questo può andare bene, oppure essere non ideale se volevi vedere prima i task urgenti.

<=> manuale: priorità, poi id

Quando vuoi controllare esplicitamente l'ordine, operator<=> si può scrivere manualmente. Qui appare un pattern comodo: confronti un campo — se non uguale, restituisci immediatamente il risultato; se è uguale — confronti il successivo.

Definiamo l'ordine: prima priority in ordine decrescente, poi id in ordine crescente. Decrescente significa che "priority=2" deve venire prima di "priority=1". Per ottenere la decrescita si può confrontare "al contrario": other.priority <=> priority.

#include <compare>
#include <string>

struct Task {
    int id{};
    std::string title;
    int priority{};

    auto operator<=>(const Task& other) const {
        if (auto r = other.priority <=> priority; r != 0) return r;
        return id <=> other.id;
    }
};

Nota lo stile: usiamo il controllo del risultato tramite != 0. Si legge quasi come "se troviamo una differenza — restituisci". Ed è esattamente quello che vogliamo: l'ordine è definito in modo sequenziale e trasparente.

Ora l'ordinamento restituirà i task prima per priorità (2, poi 1, poi 0), e all'interno della stessa priorità — per id.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks = { {2, "Refactor", 1}, {1, "Fix bug", 2} };
    std::sort(tasks.begin(), tasks.end());

    std::cout << tasks[0] << '\n'; // Task{id=1, pr=2, title="Fix bug"}
    std::cout << tasks[1] << '\n'; // Task{id=2, pr=1, title="Refactor"}
}

Sì: ora i task urgenti sono davvero in cima.

Perché il risultato viene confrontato con zero

Dopo l'esperienza con operator< lo sviluppatore inesperto si aspetta naturalmente: "l'operatore di confronto restituisce true/false". Ma <=> funziona diversamente, perché risponde a una domanda più ricca: in che modo gli oggetti sono stati comparati.

Nel codice quotidiano ti bastano di solito due fatti.

Primo fatto: il risultato si può verificare con "diverso da zero" per capire se gli oggetti differiscono secondo quel criterio. Ecco perché il pattern if (auto r = a <=> b; r != 0) return r; è così popolare.

Secondo fatto: se usi std::sort, per default usa <. E <, in presenza di <=> per il tuo tipo, di norma viene generato automaticamente dal compilatore (il compilatore "capisce" come esprimere < tramite <=>). Quindi continui a scrivere il codice algoritmico abituale, mentre <=> risiede nel tipo come "motore dell'ordine".

Se vuoi un'immagine visiva, si può rappresentare così:

flowchart TD
    A["Il tuo tipo: operator<=>"] --> B["Il compilatore ricava <, <=, >, >="]
    B --> C["std::sort usa <"]
    C --> D["La lista di task viene ordinata"]

Questo è il senso: una volta definito con cura l'ordine dentro il tipo — si usa poi gli strumenti standard della STL senza montare manualmente quattro operatori.

Coerenza con operator==

Quando in un tipo ci sono sia l'uguaglianza che l'ordine, devono andare d'accordo. Se a == b, allora il confronto ternario dovrebbe indicare "uguale". Altrimenti otterrai comportamenti strani: per esempio std::sort potrebbe comportarsi in modo imprevedibile, e la ricerca in un contenitore ordinato darà sorprese tipo "sembra che l'elemento ci sia, ma in realtà non c'è".

Nel nostro Task confrontiamo per priority e id. Questo definisce automaticamente l'idea di uguaglianza: "uguali se priorità e id sono identici". Ma che succede con title? Se title non partecipa all'ordine, è logico che non debba partecipare nemmeno all'uguaglianza — altrimenti avresti una contraddizione: due oggetti possono essere "uguali secondo l'ordine" (confronto dà uguale), ma "non uguali secondo ==".

Quindi, se scrivi un <=> manuale, è utile pensare: serve anche definire esplicitamente operator== sugli stessi campi? Lo facciamo in modo onesto:

#include <string>

struct Task {
    int id{};
    std::string title;
    int priority{};

    bool operator==(const Task& other) const {
        return id == other.id && priority == other.priority;
    }
};

E lasciamo operator<=> come l'avevamo scritto. Ora il contratto è più chiaro: il titolo del task è una "descrizione", mentre l'identità e l'ordine sono dettati da id e priority.

Questo potrebbe essere discutibile in un progetto reale (per esempio id spesso è di per sé unico, quindi confrontare solo per id può avere senso). Ma per l'app didattica è più importante mostrare il principio: uguaglianza e ordine devono essere coerenti.

4. Un altro esempio e limiti d'uso

Version: ordine naturale

Per consolidare l'idea "per campi" e "per componenti", è comodo considerare il tipo Version: major.minor.patch. Qui l'ordine è normalmente ovvio: prima major, poi minor, poi patch. È quasi il candidato perfetto per = default.

#include <compare>

struct Version {
    int major{};
    int minor{};
    int patch{};

    auto operator<=>(const Version&) const = default;
};

L'uso risulta molto "umano":

#include <iostream>

int main() {
    Version a{1, 2, 0};
    Version b{1, 10, 0};

    std::cout << (a < b) << '\n'; // 1
}

Qui <=> ti dà l'ordine per <, e std::sort (se ordinassimo versioni) sarebbe felice.

Dove <=> è utile e dove è meglio usare un comparatore

<=> è particolarmente comodo quando per un tipo esiste davvero un ordine naturale: versione, data-ora (in forma semplificata), coordinate (se questo è il significato voluto), chiave di ordinamento, qualche "rank".

Se invece per un tipo esistono molti ordinamenti possibili ("per nome", "per prezzo", "per valutazione", "per data di creazione") — qui bisogna fare attenzione: cercare di "infilare" tutti i criteri in un solo <=> può rendere l'API del tipo imprevedibile. Allora il lettore del codice si chiederà: "a< b — è per nome o per età?".

Regola pratica: se decidi di dare un ordine al tipo, operator<=> permette di farlo in modo compatto e coerente. Se invece non esiste un ordine "naturale", spesso si preferisce definire l'ordinamento localmente (per esempio in std::sort tramite un comparatore) e lasciare il tipo senza una "filosofia di confronto" incorporata.

5. Errori tipici nell'uso di operator<=>

Errore №1: aspettarsi che <=> restituisca bool.
Dopo l'abitudine a operator< è naturale voler scrivere qualcosa come bool operator<=>(...). Non va bene: il senso di <=> è proprio quello di restituire il "risultato del confronto", non vero/falso. Un segnale pratico dell'errore è che il compilatore si lamenta sulla firma o sull'uso del risultato. Soluzione semplice: usa auto e il pattern con controllo tramite != 0.

Errore №2: dimenticare di includere <compare> e ottenere strani errori "non trovato".
In alcuni ambienti alcune parti dei confronti vengono portate indirettamente, e sembra che l'header non sia necessario. Poi sposti il codice in un'altra IDE/compilatore e tutto rompe. Nel codice didattico e applicativo è meglio includere esplicitamente <compare>: rende la dipendenza chiara e riduce problemi "magici".

Errore №3: usare = default senza pensare all'ordine dei campi.
operator<=> = default confronta i campi nell'ordine di dichiarazione. Se nel tuo tipo i campi sono disposti "in base a come è comodo memorizzarli" e non "in base a come è comodo confrontarli", otterrai un ordine inatteso. Non è un bug del compilatore — è il tuo contratto. La correzione è di solito una delle due: o riordini i campi (se non compromette il modello), oppure scrivi <=> a mano "per parti".

Errore №4: rendere l'ordine incoerente con l'uguaglianza.
Se == considera alcuni campi e <=> ne considera altri, si può arrivare a una situazione in cui a == b è falso, ma (a <=> b) == 0 (cioè "uguali per ordine"). Tali contraddizioni spezzano l'intuito, l'ordinamento e la ricerca. Buona regola per chi inizia: i campi che partecipano all'identità (==) dovrebbero essere gli stessi (o compatibili) con quelli che partecipano a <=>.

Errore №5: dimenticare il const in operator<=>.
Il confronto di solito non deve modificare l'oggetto. Se dimentichi const, il tipo potrebbe non essere confrontabile se si trova in un contesto const (per esempio negli algoritmi o quando confronti due oggetti const). È frustrante e ci si chiede "perché non posso confrontare due task?". La forma corretta è quasi sempre auto operator<=>(const T& other) const.

1
Compito
C++ SELF, livello 50, lezione 0
Bloccato
Versioni del rilascio
Versioni del rilascio
1
Compito
C++ SELF, livello 50, lezione 0
Bloccato
Ordinamento dei libri
Ordinamento dei libri
1
Compito
C++ SELF, livello 50, lezione 0
Bloccato
Coda delle attività
Coda delle attività
1
Compito
C++ SELF, livello 50, lezione 0
Bloccato
Classifica frazionaria
Classifica frazionaria
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION