Overload delle Funzioni in C++
- L'overloading delle funzioni permette di definire più funzioni con lo stesso nome ma con liste di parametri diverse.
- Il compilatore determina quale versione della funzione chiamare in base ai tipi e al numero degli argomenti passati.
- Le funzioni sovraccaricate devono differire nel numero o nel tipo dei loro parametri; non possono differire solo nel tipo di ritorno.
- Il qualificatore
constdi primo livello non influisce sulla firma della funzione, ma i riferimenti o puntatori a versioniconste nonconstpossono essere usati per sovraccaricare le funzioni. - La risoluzione del sovraccarico è il processo mediante il quale il compilatore associa una chiamata di funzione a una funzione specifica da un insieme di funzioni sovraccaricate.
- La ricerca del nome avviene prima del controllo del tipo, il che significa che una dichiarazione locale di una funzione può nascondere altre dichiarazioni con lo stesso nome in ambiti esterni.
- È importante usare l'overloading delle funzioni in modo appropriato, evitando di sovraccaricare funzioni che eseguono operazioni significativamente diverse per mantenere la chiarezza del codice.
Overload delle Funzioni
L'overloading o sovraccarico delle funzioni è una caratteristica del C++ che ci permette di definire più funzioni con lo stesso nome all'interno dello stesso ambito. Quando chiamiamo una funzione sovraccaricata, il compilatore determina quale versione della funzione chiamare in base ai tipi (e al numero) degli argomenti passati nella chiamata.
Le funzioni che hanno lo stesso nome ma liste di parametri diverse e che appaiono nello stesso ambito sono sovraccaricate. Ad esempio, potremmo definire tre funzioni diverse chiamate stampa, ognuna con una diversa lista di parametri:
void stampa(const char *cp);
void stampa(const int *inizio, const int *fine);
void stampa(const int ia[], size_t dimensione);
Queste funzioni eseguono la stessa azione generale ma si applicano a tipi di parametri diversi:
- Nel primo caso, stampa una stringa di caratteri.
- Nel secondo caso, stampa una sequenza di interi definita da un intervallo di puntatori.
- Nel terzo caso, stampa una sequenza di interi definita da un array e la sua dimensione.
Quando chiamiamo queste funzioni, il compilatore può dedurre quale funzione vogliamo in base al tipo di argomento che passiamo:
int j[2] = {0,1};
// chiama stampa(const char*)
stampa("Ciao Mondo");
// chiama stampa(const int*, size_t)
stampa(j, end(j) - begin(j));
// chiama stampa(const int*, const int*)
stampa(begin(j), end(j));
Il sovraccarico delle funzioni elimina la necessità di inventare e ricordare nomi che esistono solo per aiutare il compilatore a capire quale funzione chiamare.
Esiste un'eccezione alla regola però: la funzione main non può essere sovraccaricata.
Definire Funzioni Sovraccaricate
Consideriamo un'applicazione di database con diverse funzioni per trovare un record basato su nome, numero di telefono, numero di conto, e così via. Il sovraccarico delle funzioni ci permette di definire una collezione di funzioni, ognuna chiamata cerca, che differiscono in termini di come eseguono la ricerca. Possiamo chiamare cerca passando un valore di diversi tipi:
Record cerca(const Account&); // trova per Account
Record cerca(const Phone&); // trova per Phone
Record cerca(const Name&); // trova per Name
/* ... */
Account conto;
Phone telefono;
Record r1 = cerca(conto); // chiama la versione che prende un Account
Record r2 = cerca(telefono); // chiama la versione che prende un Phone
Qui, tutte e tre le funzioni condividono lo stesso nome, eppure sono tre funzioni distinte. Il compilatore usa il tipo (o i tipi) degli argomenti per capire quale funzione chiamare.
Le funzioni sovraccaricate devono differire nel numero o nel tipo (o nei tipi) dei loro parametri. Ognuna delle funzioni sopra prende un singolo parametro, ma i parametri hanno tipi diversi.
È un errore per due funzioni differire solo in termini dei loro tipi di ritorno. Se le liste di parametri di due funzioni corrispondono ma i tipi di ritorno differiscono, allora la seconda dichiarazione è un errore:
Record cerca(const Account&);
// ERRORE: solo il tipo di ritorno è diverso
bool cerca(const Account&);
Determinare se Due Tipi di Parametri Differiscono
Due liste di parametri possono essere identiche, anche se non sembrano uguali:
// ogni coppia dichiara la stessa funzione
Record cerca(const Account &conto);
Record cerca(const Account&); // i nomi dei parametri sono ignorati
typedef Phone Telno;
Record cerca(const Phone&);
Record cerca(const Telno&); // Telno e Phone sono lo stesso tipo
Nella prima coppia, la prima dichiarazione nomina il suo parametro. I nomi dei parametri sono solo un aiuto per la documentazione. Non cambiano la lista dei parametri.
Nella seconda coppia, sembra che i tipi siano diversi, ma Telno non è un nuovo tipo; è un sinonimo per Phone. Un alias di tipo fornisce un nome alternativo per un tipo esistente; non crea un nuovo tipo. Pertanto, due parametri che differiscono solo in quanto uno usa un alias e l'altro usa il tipo a cui l'alias corrisponde non sono diversi.
Sovraccarico e Parametri const
Come abbiamo visto nella lezione sul qualificatore const, il const di primo livello non ha effetto sugli oggetti che possono essere passati alla funzione. Un parametro che ha un const di primo livello è indistinguibile da uno senza const di primo livello:
Record cerca(Phone);
Record cerca(const Phone); // ridichiarazione di Record cerca(Phone)
Record cerca(Phone*);
Record cerca(Phone* const); // ridichiarazione di Record cerca(Phone*)
In queste dichiarazioni, la seconda dichiarazione dichiara la stessa funzione della prima.
D'altra parte, possiamo sovraccaricare basandoci sul fatto se il parametro è un riferimento (o puntatore) alla versione const o non const di un dato tipo; tali const sono di basso livello:
// funzioni che prendono riferimenti o puntatori const e nonconst hanno parametri diversi
// dichiarazioni per quattro funzioni indipendenti e sovraccaricate
Record cerca(Account&); // funzione che prende un riferimento ad Account
Record cerca(const Account&); // nuova funzione che prende un riferimento const
Record cerca(Account*); // nuova funzione, prende un puntatore ad Account
Record cerca(const Account*); // nuova funzione, prende un puntatore a const
In questi casi, il compilatore può usare la costanza dell'argomento per distinguere quale funzione chiamare. Poiché non c'è conversione da const, possiamo passare un oggetto const (o un puntatore a const) solo alla versione con un parametro const. Poiché c'è una conversione a const, possiamo chiamare entrambe le funzioni su un oggetto non const o un puntatore a non const. Tuttavia, come vedremo nelle prossime lezioni, il compilatore preferirà le versioni non const quando passiamo un oggetto non const o puntatore a non const.
const_cast e Overloading
Nelle lezioni precedenti abbiamo notato che i const_cast sono più utili nel contesto delle funzioni sovraccaricate. Come esempio, consideriamo una funzione che restituisce un riferimento alla stringa più corta tra due stringhe passate come argomenti. Potremmo definire questa funzione come segue:
// restituisce un riferimento alla più corta delle due stringhe
const string &stringaPiuCorta(const string &s1, const string &s2)
{
return s1.size() <= s2.size() ? s1 : s2;
}
Questa funzione prende e restituisce riferimenti a string const. Possiamo chiamare la funzione su una coppia di argomenti string non const, ma otterremo un riferimento a una string const come risultato. Potremmo voler avere una versione di stringaPiuCorta che, quando riceve argomenti non const, produrrebbe un riferimento normale. Possiamo scrivere questa versione della nostra funzione usando un const_cast:
string &stringaPiuCorta(string &s1, string &s2)
{
auto &r = stringaPiuCorta(const_cast<const string&>(s1),
const_cast<const string&>(s2));
return const_cast<string&>(r);
}
Questa versione chiama la versione const di stringaPiuCorta convertendo i suoi argomenti a riferimenti a const. Quella funzione restituisce un riferimento a una string const, che sappiamo essere legato a uno dei nostri argomenti originali non const. Pertanto, sappiamo che è sicuro convertire quella string di nuovo a un normale riferimento string & nel ritorno.
Quando NON usare l'overloading delle funzioni
Anche se il sovraccarico ci permette di evitare di inventare (e ricordare) nomi per operazioni comuni, dovremmo sovraccaricare solo operazioni che effettivamente fanno cose simili. Ci sono alcuni casi dove fornire nomi di funzioni diversi aggiunge informazioni che rendono il programma più facile da capire. Consideriamo un insieme di funzioni che muovono il cursore su uno schermo rappresentato da un oggetto ipotetico di tipo Screen.
Screen& spostaInizio();
Screen& spostaAssoluto(int, int);
Screen& spostaRelativo(int, int, string direzione);
Potrebbe inizialmente sembrare meglio sovraccaricare questo insieme di funzioni sotto il nome unico di sposta:
Screen& sposta();
Screen& sposta(int, int);
Screen& sposta(int, int, string direzione);
Tuttavia, sovraccaricando queste funzioni, abbiamo perso informazioni che erano inerenti nei nomi delle funzioni. Sebbene il movimento del cursore sia un'operazione generale condivisa da tutte queste funzioni, la natura specifica di quel movimento è unica per ognuna di queste funzioni. spostaInizio, ad esempio, rappresenta un'istanza speciale di movimento del cursore in cui spostiamo il cursore all'inizio dello schermo (l'angolo in alto a sinistra).
Analogamente, spostaAssoluto e spostaRelativo rappresentano due modi distinti di spostare il cursore: uno in base a coordinate assolute e l'altro in base a un offset relativo alla posizione corrente del cursore. Usando nomi distinti per queste funzioni, forniamo informazioni utili che aiutano a chiarire le differenze tra queste operazioni correlate ma distinte.
Chiamare una Funzione Sovraccaricata
Una volta definito un insieme di funzioni sovraccaricate, dobbiamo essere in grado di chiamarle con argomenti appropriati. La corrispondenza delle funzioni (nota anche come risoluzione del sovraccarico) è il processo mediante il quale una particolare chiamata di funzione è associata a una funzione specifica da un insieme di funzioni sovraccaricate. Il compilatore determina quale funzione chiamare confrontando gli argomenti nella chiamata con i parametri offerti da ogni funzione nell'insieme sovraccaricato.
In molti casi, è semplice per un programmatore determinare se una particolare chiamata è legale e, se sì, quale funzione sarà chiamata. Spesso le funzioni nell'insieme sovraccaricato differiscono in termini del numero di argomenti, o i tipi degli argomenti non sono correlati. In tali casi, è facile determinare quale funzione è chiamata. Determinare quale funzione è chiamata quando le funzioni sovraccaricate hanno lo stesso numero di parametri e quei parametri sono correlati da conversioni può essere meno ovvio. Vedremo come il compilatore risolve le chiamate che coinvolgono conversioni nelle prossime lezioni.
Per ora, quello che è importante realizzare è che per ogni data chiamata a una funzione sovraccaricata, ci sono tre possibili risultati:
- Il compilatore trova esattamente una funzione che rappresenta la migliore corrispondenza per gli argomenti reali e genera codice per chiamare quella funzione.
- Non c'è funzione con parametri che corrispondono agli argomenti nella chiamata, nel qual caso il compilatore emette un messaggio di errore che non c'era nessuna corrispondenza.
- C'è più di una funzione che corrisponde e nessuna delle corrispondenze è chiaramente la migliore. Questo caso è anche un errore; è una chiamata ambigua.
Esercizio
Spiegate l'effetto della seconda dichiarazione in ognuno dei seguenti insiemi di dichiarazioni. Indicate quali, se ce ne sono, sono illegali.
int calcola(int, int);
int calcola(const int, const int);
int ottieni();
double ottieni();
int *azzera(int *);
double *azzera(double *);
Risposta:
- La seconda dichiarazione è una ridichiarazione della prima. Entrambe dichiarano la stessa funzione
calcola(int, int), poiché i qualificatoriconstdi primo livello sui parametri non influenzano la firma della funzione. Quindi, questa è legale ma ridondante. - La seconda dichiarazione è illegale perché le due funzioni
ottieni()differiscono solo nel tipo di ritorno (intvsdouble). In C++, le funzioni non possono essere sovraccaricate solo in base al tipo di ritorno. - La seconda dichiarazione è legale e rappresenta una funzione sovraccaricata. Le due funzioni
azzera(int *)eazzera(double *)hanno liste di parametri diverse (puntatore aintvs puntatore adouble), quindi sono considerate funzioni distinte.
Overloading e Scope
Normalmente, è una cattiva idea dichiarare una funzione localmente. Tuttavia, per spiegare come l'ambito interagisce con il sovraccarico, violeremo questa pratica e useremo dichiarazioni di funzioni locali.
I programmatori nuovi al C++ sono spesso confusi riguardo l'interazione tra scope e sovraccarico. Tuttavia, il sovraccarico non ha proprietà speciali rispetto all'ambito: Come al solito, se dichiariamo un nome in un ambito interno, quel nome nasconde gli usi di quel nome dichiarato in un ambito esterno. I nomi non si sovraccaricano attraverso gli ambiti:
string leggi();
void stampa(const string &);
void stampa(double); // sovraccarica la funzione stampa
void fooBar(int ival)
{
bool leggi = false; // nuovo ambito: nasconde la dichiarazione esterna di leggi
string s = leggi(); // errore: leggi è una variabile bool, non una funzione
// CATTIVA PRATICA:
// di solito è una cattiva idea dichiarare funzioni nell'ambito locale
void stampa(int); // nuovo ambito: nasconde le istanze precedenti di stampa
stampa("Valore: "); // errore: stampa(const string &) è nascosta
stampa(ival); // ok: stampa(int) è visibile
stampa(3.14); // ok: chiama stampa(int); stampa(double) è nascosta
}
La maggior parte dei lettori non sarà sorpresa che la chiamata a leggi sia in errore. Quando il compilatore elabora la chiamata a leggi, trova la definizione locale di leggi. Quel nome è una variabile bool, e non possiamo chiamare un bool. Quindi, la chiamata è illegale.
Esattamente lo stesso processo è usato per risolvere le chiamate a stampa. La dichiarazione di stampa(int) in fooBar nasconde le dichiarazioni precedenti di stampa. È come se ci fosse solo una funzione stampa disponibile: quella che prende un singolo parametro int.
Quando chiamiamo stampa, il compilatore prima cerca una dichiarazione di quel nome. Trova la dichiarazione locale per stampa che prende un int. Una volta trovato un nome, il compilatore ignora gli usi di quel nome in qualsiasi ambito esterno. Invece, il compilatore assume che la dichiarazione che ha trovato sia quella per il nome che stiamo usando. Quello che rimane è vedere se l'uso del nome è valido.
La motivazione principale è che in C++, la ricerca del nome avviene prima del controllo del tipo. Quindi, una volta che il compilatore trova la dichiarazione locale di stampa, ignora le altre dichiarazioni di stampa che sono fuori dall'ambito. Ora, il compilatore deve solo vedere se la chiamata a stampa è valida per la funzione stampa(int).
La prima chiamata passa un letterale stringa, ma l'unica dichiarazione per stampa che è nell'ambito ha un parametro che è un int. Un letterale stringa non può essere convertito a un int, quindi questa chiamata è un errore. La funzione stampa(const string&), che avrebbe corrisposto a questa chiamata, è nascosta e non è considerata.
Quando chiamiamo stampa passando un double, il processo è ripetuto. Il compilatore trova la definizione locale di stampa(int). L'argomento double può essere convertito a un int, quindi la chiamata è legale.
Se avessimo dichiarato stampa(int) nello stesso ambito delle altre funzioni stampa, allora sarebbe un'altra versione sovraccaricata di stampa. In quel caso, queste chiamate sarebbero risolte diversamente, perché il compilatore vedrebbe tutte e tre le funzioni:
void stampa(const string &);
void stampa(double); // sovraccarica la funzione stampa
void stampa(int); // un'altra istanza sovraccaricata
void fooBar2(int ival)
{
stampa("Valore: "); // chiama stampa(const string &)
stampa(ival); // chiama stampa(int)
stampa(3.14); // chiama stampa(double)
}