Three-way comparison operator in C++. E in C#?

di Andrea Zani, in cplusplus,

Capita (dal verbo capitare, non da capire), non è una regola fissa ma capita. In C# ho la mia bella classe e devo fare un Compare. Se uso l'operatore di uguaglianza (tralasciando Record che è possibile as it is), è facilmente implementabile anche con una class o struct facendo l'override due due operatori di uguaglianza (==) e diseguaglianza (!=):

public class Test1 //: IEquatable<Test1>
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public override int GetHashCode()
    {
        return Id.ToString().GetHashCode() ^ Name.GetHashCode();
    }

    public override bool Equals(object? obj)
    {
        if (obj is null)
        {
            return false;
        }

        if (Object.ReferenceEquals(this, obj))
        {
            return true;
        }

        if (obj is Test1 temp)
        {
            return temp.Id == Id && temp.Name == Name;
        }

        return false;
    }

    public int CompareTo(Test1? other)
    {
        if (other is null)
        {
            return 1;
        }

        if (other.Id != Id)
        {
            return other.Id.CompareTo(Id); // <- inverted
        }

        return Name.CompareTo(other.Name);
    }

    public static bool operator ==(Test1 e1, Test1 e2)
    {
        return e1.CompareTo(e2) == 0;
    }

    public static bool operator !=(Test1 e1, Test1 e2)
    {
        return e1.CompareTo(e2) != 0;
    }
}

In questo caso la classe ha due property - Id un intero, e Name una stringa - che utilizzo per il Compare. Senza l'uso dell'override di questi operatori una comparazione di uguaglianza di due oggetti è fatta sulla reference all'oggetto e non sul suo contenuto.

Andando nel caso più particolare può nascere la necessità di fare il Compare tra due oggetti dello stesso tipo anche con altri operatori di confronto come "minore di" (<), "minore o uguale a" (<=), "maggiore di" (>) e "maggiore o uguale a" (>=). Nel caso della mia semplice classe voglio che la property Id (numerica) abbia un ordinamento inverso, in modo che un oggetto che Id uguale a uno sia maggiore di una classe con Id uguale a due, tre, quattro... L'ordinamento di Name che è stringa deve seguire il classico ordinamento alfabetico.

Aggiungo quindi l'override di questi operatori:

public static bool operator <(Test1 e1, Test1 e2)
{
    return e1.CompareTo(e2) < 0;
}

public static bool operator <=(Test1 e1, Test1 e2)
{
    return e1.CompareTo(e2) <= 0;
}

public static bool operator >(Test1 e1, Test1 e2)
{
    return e1.CompareTo(e2) > 0;
}

public static bool operator >=(Test1 e1, Test1 e2)
{
    return e1.CompareTo(e2) >= 0;
}

Ora posso scrivere da codice:

var t21 = new Test1 { Id = 1, Name = "a" };
var t22 = new Test1 { Id = 1, Name = "a" };
var t23 = new Test1 { Id = 2, Name = "b" };
var t24 = new Test1 { Id = 2, Name = "a" };

Console.WriteLine($"t21 == t22  = {t21 == t22}");
Console.WriteLine($"t22 >  t23  = {t22 > t23}");
Console.WriteLine($"t23 >  t24  = {t23 > t24}");

Che darà il risultato:

t21 == t22  = True
t22 >  t23  = True
t23 >  t24  = True

Piccola critica: anche se non utilizzati, è necessario scrivere sempre tutti gli operatori. Se si scrive il primo operatore di uguaglianza (==) è necessario scrivere anche quello di diseguaglianza (!==) previo errore di compilatore. Così come scritto l'operatore "minore di" (<) è poi obbligatorio scrivere il suo inverso...

Excursus in C++

Ecco la stessa classe Test1 scritta in C++. All'inizio solo con gli operatori di uguaglianza:

class Test1 final {
    int Id;
    std::string Name;
public:
    Test1(const int& id, const std::string& name)
        : Id{ id }, Name{ name } {}

    bool operator==(const Test1& other) const {
        return Id == other.Id && Name == other.Name;
    }
};

Almeno in C++ (dalla versione 20) capisce che non è necessario scrivere l'operatore di diseguaglianza ma sarà possibile utilizzare lo stesso da codice:

Test1 t1{1, "B"};
Test1 t2{2, "A"};
if (t1 != t2) {
  std::cout << "t1 != t2\n";
}

Per fare il Compare con gli altri operatori devo scrivere tutti gli override come in C#:

bool operator<(const Test1& other) const {
    if (Id != other.Id) return Id < other.Id;
    return Name < other.Name;
}

bool operator<=(const Test1& other) const{
    if (Id != other.Id) return Id <= other.Id;
    return Name <= other.Name;
}

bool operator>(const Test1& other) const{
    return !operator<=(other);
}

bool operator>=(const Test1& other) const{
    return !operator<(other);
}

E potrò fare come sopra:

Test1 t1{1, "B"};
Test1 t2{2, "A"};
if (t1 < t2) {
  std::cout << "t1 < t2\n";
}

Dunque?

Three-way comparison, spaceship operator

Ricordo quando presentarono la versione 20 del linguaggio C++ che tra le novità la presenza di questo operatore: <=>

La prima reazione fu: Ma che fa? Ritorna true se a è minore, uguale e maggiore di b?

Ovviamente no. In verità diventa utile anche per l'override degli operatori che ho mostrato prima. Innanzitutto un po' di teoria: il confronto tra due variabili non ritorna un valore booleano come gli operatori che tutti conoscono, ma tre possibili oggetti:

  • std::strong_ordering che può ritornare i valori: less, equal, equivalent e greater.
  • std::weak_ordering che può ritornare i valori: less, equal e greater.
  • std::partial_ordering che può ritornare i valori: less, equivalent, greater, unordered.

Due parole su equal e equivalent. In linea teorica sono la stessa cosa ma ci possono essere casistiche in cui non lo sono. Per esempio, come da documentazione, due stringhe contenenti lo stesso valore sono equal (contengono lo stesso valore e sono intercambiabili). Due oggetti si dicono equivalent quando non sono intercambiabili. Come da documentazione, due utenti con le stesse Role di autenticazione per l'accesso sono sì per il sistema equivalenti (hanno le stesse autorizzazioni) ma non sono uguali e intercambiabili.

La scelta dell'uso di strong_ordering o weak_ordering si basa solo su questa differenza, mentre l'uso del partial_ordering è da utilizzare nelle routine di comparazione per l'ordinamento di oggetti (tra i valori restituiti è presente anche unordered per avvisare che i due oggetti non sono confrontabili).

Parlando del mero codice si può scrivere:

if (a <=> b == std::strong_ordering::equal) { /* equal */}
if (a <=> b == std::strong_ordering::greater) { /* greater */}
...

Oppure più semplicemente:

if (a <=> b == 0) { /* equal */}
if (a <=> b == 1) { /* greater */}
...

Possibile perché se si controlla il codice di quegli oggetti si scopre che sono degli enum:

enum class _Compare_eq : _Compare_t { equal = 0, equivalent = equal };
enum class _Compare_ord : _Compare_t { less = -1, greater = 1 };
enum class _Compare_ncmp : _Compare_t { unordered = -128 };

L'utilizzo diretto del tipo di oggetto restituito dal Three-way operator è utile quando viene usato come parametro in funzioni. Si può scrivere:

auto cmp = a <=> b;
ShowResult(cmp);
...
void ShowResult(std::strong_ordering result) {
  if (result == 0) std::cout << "Equal\n";
  if (result == -1) std::cout << "Less\n";
  if (result == 1) std::cout << "Greater\n";
}

Quando, con gli operatori standard, si ha solo come risultato un valore booleano. Arrivati a questo punto posso riscrivere la versione in C++ nel seguente modo molto più semplice:

#include <iostream>

class Test1 final {
    friend std::ostream& operator<<(std::ostream& os, const Test1& p);
public:
    int Id;
    std::string Name;
    Test1(const int& id, const std::string& name)
        : Id{ id }, Name{ name } {}

    bool operator==(const Test1& other) const {
        return Id == other.Id && Name == other.Name;
    }

    std::strong_ordering operator<=>(const Test1& other) const {

        if (auto cmp = other.Id <=> Id; cmp != 0) return cmp;
        if (auto cmp = Name <=> other.Name; cmp != 0) return cmp;
        return std::strong_ordering::equal;
    }
};

std::ostream& operator<<(std::ostream& os, const Test1& dt) {
    os << "{ _id='" << dt.Id << "', _name='" << dt.Name << "' }";
    return os;
}
int main() {
    Test1 t21{ 1, "A" };
    Test1 t22{ 1, "A" };
    Test1 t23{ 1, "B" };

    std::cout << "t21 = " << t21 << "\n";
    std::cout << "t22 = " << t22 << "\n";
    std::cout << "t23 = " << t23 << "\n";

    std::cout << "t21 == t22 = " << (t21 == t22) << "\n";
    std::cout << "t21 != t22 = " << (t21 != t22) << "\n";
    std::cout << "t21 < t22  = " << (t21 < t22) << "\n";
    std::cout << "t21 <= t22 = " << (t21 <= t22) << "\n";
    std::cout << "t21 > t22  = " << (t21 > t22) << "\n";
    std::cout << "t21 >= t22 = " << (t21 >= t22) << "\n";
    std::cout << "t21 >= t22 = " << (t21 >= t22) << "\n";
    std::cout << "t21 > t23  = " << (t21 > t23) << "\n";
}

E avere questo output:

t21 = { _id='1', _name='A' }
t22 = { _id='1', _name='A' }
t23 = { _id='1', _name='B' }
t21 == t22 = 1
t21 != t22 = 0
t21 < t22  = 0
t21 <= t22 = 1
t21 > t22  = 0
t21 >= t22 = 1
t21 >= t22 = 1
t21 > t23  = 0

Il Three-way comparison NON esclude l'uso dell'equal operator che dev'essere sempre aggiunto come nel codice qui sopra:

bool operator==(const Test1& other) const {
    return Id == other.Id && Name == other.Name;
}

Che è inutile visto che in automatico questo linguaggio può creare il Compare tra tutti gli oggetti al suo interno. Se avessi scritto:

bool operator==(const Test1& other) const = default;

Il tutto avrebbe funzionato correttamente. Anche con il Three-way comparison è possibile fare la stessa cosa:

auto operator<=>(const Test1& other) const = default;

Ma nel mio caso mi avrebbe dato un risultato errato perché l'ordinamento di Id io lo volevo inverso. Se non avessi avuto necessità particolari avrei potuto scrivere:

#include <iostream>

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

int main() {
    S s1{1,2};
    S s2{1,3};
    S s3{2,0};
    S s4{1,2};

    std::cout << "s1 < s2  = " << (s1 < s2) << "\n";
    std::cout << "s1 < s3  = " << (s1 < s3) << "\n";
    std::cout << "s3 > s2  = " << (s3 > s2) << "\n";
    std::cout << "s1 == s4 = " << (s1 == s4) << "\n";
}

E avere questo output:

s1 < s2  = 1
s1 < s3  = 1
s3 > s2  = 1
s1 == s4 = 1

E il Three-way comparison in C#?

La risposta è semplice: non c'è. Peccato, è presente anche in altri linguaggi ma in C# purtroppo non c'è. Spero in una prossima versione di C# che gli ingegneri Microsoft prendano in considerazione questo operatore che, in coppia con Record, permetterebbe di creare in modo veloce tutti gli operatori di confronto.

E visto che sono in modalità richiesta, perché non aggiungere anche l'if statement initializer come è stato introdotto in C++ dalla versione 17? Come mostrato sopra nell'esempio ma esteso:

if (auto cmp = other.Id <=> Id; cmp != 0) {
  return cmp;
}
else {
  std::cout << (cmp == 0) << "\n";
  return cmp;
}
    

cmp è un variabile creata dentro lo scope dell'if e sarà visibile sia nel blocco che sarà eseguito sia se la condizione sia vera, sia nel blocco dove la condizione è falsa. Quindi cmp sarà distrutta all'uscita della condizione. Pure questa aggiunta sarebbe comoda, no?

Ok, non ho speranze.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
Nessuna risorsa collegata
I più letti del mese