Far sembrare predefiniti i tuoi tipi
Sai già che std::string ti permette di scrivere a + b per concatenare e cout << s per stampare. Non sono trucchi speciali del compilatore: sono normali funzioni con nomi strani. L'overloading degli operatori è la funzionalità che permette alle tue classi di agganciarsi alla stessa sintassi, così un tipo Vector2 o Money si può sommare, confrontare e stampare esattamente come un int.
Il meccanismo è semplice, una volta visto: un'espressione come a + b è una scorciatoia. Il compilatore la riscrive come chiamata a una funzione chiamata operator+ e ne cerca una che corrisponda ai tipi degli operandi. Definisci quella funzione per la tua classe e a + b all'improvviso funziona. È in realtà una forma specializzata di overloading delle funzioni: valgono le stesse regole di risoluzione dei nomi, solo con nomi a forma di operatore.
Nota che la funzione riceve entrambi gli operandi per const&: l'aritmetica non dovrebbe modificare i suoi input, e i riferimenti evitano le copie. Restituisce un nuovo Vector2 per valore: p + q deve produrre un risultato nuovo senza toccare p o q, proprio come 2 + 3 non cambia 2.
Membro vs non membro
Ci sono due posti in cui definire un operatore: come membro della classe o come funzione libera (non membro). Come membro, l'operando sinistro è l'implicito this, quindi un operatore binario riceve un solo parametro esplicito:
Il const dopo la lista dei parametri conta: a + b non dovrebbe modificare a, quindi il membro è marcato const. Usa la forma membro per gli operatori legati per natura all'operando sinistro che non richiedono conversioni su di esso: +=, [], (), -> e gli operatori unari come -x o ++x.
Il limite dei membri: l'operando sinistro non può essere convertito. Con il membro operator+ qui sopra, a + 50 funziona (50 si converte in Money per il lato destro), ma 50 + a non compila: l'operando sinistro 50 è un int, e non puoi aggiungere una funzione membro a int. Un operatore non membro risolve il problema perché entrambi gli operandi sono parametri espliciti ed entrambi possono essere convertiti:
Regola pratica: rendi non membri gli operatori binari simmetrici (+, ==, *) così le conversioni funzionano su entrambi i lati; rendi membri gli operatori che devono modificare l'operando sinistro o sono legati a esso (+=, [], =).
Overloading dell'operatore di stream
L'operatore ridefinito più spesso in assoluto è << per la stampa. Non puoi renderlo membro della tua classe, perché l'operando sinistro è uno std::ostream (come cout), non il tuo tipo, e ostream non è tuo. Quindi è sempre un non membro che riceve lo stream per riferimento non const e lo restituisce:
Due dettagli fanno funzionare tutto. Lo stream viene passato e restituito per riferimento (ostream&): gli stream non si possono copiare, e restituire lo stesso stream è ciò che ti permette di concatenare cout << "p = " << p << "\n". Ogni << restituisce lo stream così il << successivo ha qualcosa a cui legarsi. Dimentica il return os; e la concatenazione si rompe.
Operatori di confronto
Per confrontare i tuoi oggetti con ==, < e simili, fai l'overloading degli operatori di confronto. Prima di C++20 li scrivevi tutti a mano; la trappola principale è che operator< deve restituire un bool e definire un ordinamento coerente:
Scrivere a mano tutti e sei i confronti (==, !=, <, <=, >, >=) è noioso e facile da sbagliare. C++20 ha aggiunto l'operatore di confronto a tre vie <=> (lo "spaceship"). Impostarlo a default insieme a == genera per te tutti i confronti:
= default dice al compilatore di confrontare i membri nell'ordine di dichiarazione, che è esattamente l'ordinamento lessicografico che scriveresti a mano. Preferiscilo con i compilatori moderni.
L'operatore di assegnazione e le sue trappole
operator= (assegnazione per copia) è speciale: il compilatore ne genera uno per te, e per le classi semplici quel default è corretto. Devi scriverne uno tuo solo quando la classe gestisce una risorsa, come la memoria grezza o un handle di file, dove una copia membro per membro sarebbe sbagliata. La firma canonica restituisce *this per riferimento così le assegnazioni si possono concatenare (a = b = c):
In questa breve funzione si nascondono due trappole. Prima, il controllo di autoassegnazione if (this == &other): senza di esso, a = a eseguirebbe delete[] data e poi leggerebbe dal other.data appena liberato, cioè undefined behavior. Seconda, l'ordine conta: in una versione scritta a mano non devi cancellare il vecchio buffer prima di aver copiato al sicuro quello nuovo (un'implementazione reale spesso alloca prima, o usa l'idioma copy-and-swap, così un'allocazione fallita lascia intatto l'oggetto).
Una trappola più generale: non ridefinire gli operatori in modi sorprendenti. Un operator+ che modifica di nascosto l'operando sinistro, o un operator== non simmetrico, confonderà chiunque legga e romperà il codice della libreria standard che presuppone i significati abituali. Fai l'overloading degli operatori solo quando l'operazione è davvero "simile all'addizione" o "simile all'uguaglianza" per il tuo tipo.
Prossimo passo: gli specificatori di accesso
Nota come ogni esempio abbia tenuto private i dati membro, esponendo il comportamento tramite una piccola superficie pubblica: costruttori, operatori e pochi metodi. Quel confine tra ciò che è visibile all'esterno e ciò che è nascosto dentro la classe è controllato dagli specificatori di accesso: public, private e protected. Nella prossima pagina vedremo esattamente cosa permette ciascuno, perché dati private con metodi pubblici sono il default di un buon incapsulamento e come protected si inserisce nell'ereditarietà.
Domande frequenti
Cos'è l'overloading degli operatori in C++?
L'overloading degli operatori ti permette di definire cosa significano operatori predefiniti come +, == o << per i tuoi tipi. Scrivi una funzione con un nome speciale, operator+, operator== e così via, e il compilatore la chiama ogni volta che l'operatore compare con operandi della tua classe. È così che string + string concatena e cout << obj stampa un oggetto personalizzato.
Gli operatori in C++ vanno definiti come funzioni membro o non membro (friend)?
Usa una funzione membro quando l'operando sinistro è la tua classe e non avrà bisogno di conversioni (per esempio +=, [], ()). Usa una funzione non membro (spesso friend) quando l'operando sinistro potrebbe essere un tipo predefinito o quando vuoi conversioni simmetriche su entrambi i lati: è obbligatorio per operator<<, perché l'operando sinistro è uno std::ostream, non la tua classe.
Quali operatori C++ non si possono ridefinire?
Non puoi fare l'overloading di :: (risoluzione dello scope), . (accesso ai membri), .* (accesso tramite puntatore a membro), ?: (ternario) e sizeof. Non puoi nemmeno inventare operatori nuovi o cambiare il numero di operandi o la precedenza di un operatore: + è sempre binario con la stessa precedenza, che sommi degli int o il tuo Vector2.