Introduzione alle Espressioni in C++
C++ fornisce un ricco insieme di operatori e definisce cosa fanno questi operatori quando applicati a operandi di tipo built-in. Ci permette anche di definire il significato della maggior parte degli operatori quando applicati a operandi di tipi classe.
A partire da questa lezione ci concentreremo sugli operatori così come sono definiti nel linguaggio e applicati a operandi di tipo built-in. Esamineremo anche alcuni degli operatori definiti dalla libreria. Nelle prossime lezioni vedremo poi come possiamo definire operatori per i nostri tipi.
Un'espressione è composta da uno o più operandi e produce un risultato quando viene valutata. La forma più semplice di un'espressione è un singolo letterale o variabile. Il risultato di tale espressione è il valore della variabile o del letterale. Espressioni più complicate sono formate da un operatore e uno o più operandi.
Ci sono alcuni concetti fondamentali che influenzano il modo in cui le espressioni vengono valutate. Iniziamo discutendo brevemente i concetti che si applicano alla maggior parte (se non a tutte) le espressioni. Le lezioni successive tratteranno questi argomenti in maggior dettaglio.
- Le espressioni in C++ sono costituite da operandi e operatori e producono un risultato quando vengono valutate.
- Gli operatori possono essere unari, binari o ternari, a seconda del numero di operandi su cui agiscono.
- La precedenza e l'associatività degli operatori determinano come gli operandi sono raggruppati in espressioni complesse.
- L'ordine di valutazione degli operandi non è sempre specificato, il che può portare a comportamenti indefiniti se un operando modifica un oggetto che un altro operando utilizza.
- Gli operatori possono essere sovraccaricati per tipi definiti dall'utente, ma il numero di operandi, la precedenza e l'associatività rimangono invariati.
- Le espressioni in C++ sono classificate come l-value o r-value, influenzando come possono essere utilizzate.
Concetti di Base
Esistono sia operatori unari che operatori binari. Gli operatori unari, come address-of (&
) e dereference (*
), agiscono su un operando. Gli operatori binari, come l'uguaglianza (==
) e la moltiplicazione (*
), agiscono su due operandi. C'è anche un operatore ternario che prende tre operandi, e un operatore, la chiamata di funzione, che prende un numero illimitato di operandi.
Alcuni simboli, come *
, sono usati sia come operatore unario (dereference) che come operatore binario (moltiplicazione). Il contesto in cui viene usato un simbolo determina se il simbolo rappresenta un operatore unario o binario. Gli usi di tali simboli sono indipendenti; può essere utile pensarli come due simboli diversi.
Raggruppamento di Operatori e Operandi
Comprendere le espressioni con più operatori richiede la comprensione dei concetti di precedenza e associatività degli operatori e può dipendere dall'ordine di valutazione degli operandi. Ad esempio, il risultato della seguente espressione dipende da come gli operandi sono raggruppati con gli operatori:
5 + 10 * 20/2;
Gli operandi dell'operatore *
potrebbero essere 10
e 20
, oppure 10
e 20/2
, oppure 15
e 20
, oppure 15
e 20/2
. Comprendere tali espressioni è l'argomento della prossima sezione.
Conversioni degli Operandi
Come parte della valutazione di un'espressione, gli operandi sono spesso convertiti da un tipo all'altro. Ad esempio, gli operatori binari di solito si aspettano operandi con lo stesso tipo. Questi operatori possono essere usati su operandi con tipi differenti purché gli operandi possano essere convertiti a un tipo comune.
Sebbene le regole siano alquanto complicate, per la maggior parte le conversioni avvengono in modi non sorprendenti. Ad esempio, possiamo convertire un intero in virgola mobile, e viceversa, ma non possiamo convertire un tipo puntatore in virgola mobile. Ciò che può essere un po' sorprendente è che gli operandi di tipo intero di piccola dimensione (ad es., bool
, char
, short
, ecc.) sono generalmente promossi a un tipo intero più grande, tipicamente int
. Esamineremo in dettaglio le conversioni nelle prossime lezioni.
Operatori Sovraccaricati (Operator Overloading)
Il linguaggio definisce cosa significano gli operatori quando applicati a tipi built-in e composti. Possiamo anche definire cosa significano la maggior parte degli operatori quando applicati a tipi classe. Poiché tali definizioni danno un significato alternativo a un simbolo di operatore esistente, ci riferiamo ad esse come operatori sovraccaricati (in gergo tecnico si parla di overload degli operatori). Gli operatori >>
e <<
della libreria I/O e gli operatori che abbiamo usato con string
, vector
e iteratori
sono tutti operatori sovraccaricati.
Quando usiamo un operatore sovraccaricato, il significato dell'operatore, incluso il tipo dei suoi operandi e il risultato, dipende dal modo in cui l'operatore è definito. Tuttavia, il numero di operandi e la precedenza e l'associatività dell'operatore non possono essere modificati.
L-Value e R-Value
Ogni espressione in C++ è o un r-value (pronunciato "ar-value") o un l-value (pronunciato "el-value"). Questi nomi sono ereditati dal linguaggio C e originariamente avevano uno scopo mnemonico semplice: gli l-value potevano stare sul lato sinistro di un'assegnazione mentre gli r-value non potevano. Questo perché gli l-value rappresentavano posizioni in memoria (identità) mentre gli r-value rappresentavano valori.
In C++, la distinzione è meno semplice. In C++, un'espressione l-value produce un oggetto o una funzione. Tuttavia, alcuni l-value, come gli oggetti const
, potrebbero non essere l'operando sinistro di un'assegnazione. Inoltre, alcune espressioni producono oggetti ma li restituiscono come r-value, non l-value. In parole povere, quando usiamo un oggetto come r-value, usiamo il valore dell'oggetto (il suo contenuto). Quando usiamo un oggetto come l-value, usiamo l'identità dell'oggetto (la sua posizione in memoria).
Gli operatori differiscono nel fatto che richiedano operandi l-value o r-value e nel fatto che restituiscano l-value o r-value. Il punto importante è che (con un'eccezione che tratteremo nelle prossime lezioni) possiamo usare un l-value quando è richiesto un r-value, ma non possiamo usare un r-value quando è richiesto un l-value (cioè, una posizione). Quando usiamo un l-value al posto di un r-value, viene usato il contenuto dell'oggetto (il suo valore). Abbiamo già usato diversi operatori che coinvolgono l-value.
- L'assegnazione richiede un l-value (non
const
) come operando sinistro e produce il suo operando sinistro come l-value. - L'operatore address-of richiede un operando l-value e restituisce un puntatore al suo operando come r-value.
- Gli operatori built-in di dereference (
*
) e indice ([]
) e gli operatori di dereference degli iteratori e indice distring
evector
producono tutti l-value. - Gli operatori built-in e degli iteratori di incremento e decremento richiedono operandi l-value e le versioni prefisse (che sono quelle che abbiamo usato finora) producono anche l-value.
Man mano che presentiamo gli operatori, noteremo se un operando deve essere un l-value e se l'operatore restituisce un l-value.
Gli l-value e gli r-value differiscono anche quando usati con decltype
. Quando applichiamo decltype
a un'espressione (diversa da una variabile), il risultato è un tipo riferimento se l'espressione produce un l-value.
Come esempio, assumiamo che p
sia un int *
. Poiché il dereference produce un l-value, decltype(*p)
è int&
. D'altra parte, poiché l'operatore address-of produce un r-value, decltype(&p)
è int**
, cioè, un puntatore a un puntatore a tipo int.
Precedenza e Associatività
Un'espressione con due o più operatori è un'espressione composta. Valutare un'espressione composta comporta raggruppare gli operandi con gli operatori. La precedenza e l'associatività determinano come gli operandi sono raggruppati. Cioè, determinano quali parti dell'espressione sono gli operandi per ciascuno degli operatori nell'espressione. I programmatori possono sovrascrivere queste regole mettendo tra parentesi le espressioni composte per forzare un particolare raggruppamento.
In generale, il valore di un'espressione dipende da come le sottoespressioni sono raggruppate. Gli operandi degli operatori con precedenza più alta si raggruppano più strettamente degli operandi degli operatori con precedenza più bassa. L'associatività determina come raggruppare gli operandi con la stessa precedenza. Ad esempio, la moltiplicazione e la divisione hanno la stessa precedenza l'una dell'altra, ma hanno precedenza più alta dell'addizione. Pertanto, gli operandi della moltiplicazione e della divisione si raggruppano prima degli operandi dell'addizione e della sottrazione. Gli operatori aritmetici sono associativi a sinistra, il che significa che gli operatori con la stessa precedenza si raggruppano da sinistra a destra:
- A causa della precedenza, l'espressione
3+4*5
è23
, non35
. - A causa dell'associatività, l'espressione
20-15-3
è2
, non8
.
Come esempio più complicato, una valutazione da sinistra a destra della seguente espressione produce 20
:
6+3 * 4/2+2
Altri risultati immaginabili includono 9, 14 e 36. In C++, il risultato è 14, perché questa espressione è equivalente a
// le parentesi in questa espressione corrispondono
alla precedenza e associatività predefinite
((6 + ((3 * 4) / 2)) + 2)
In generale il C++, come il linguaggio C da cui deriva, segue le normali regole di precedenza e associatività usate in matematica che vengono ricordate con l'acronimo PEMDAS:
- Parentesi
- Esponenti (non esiste un operatore di esponenziazione in C++)
- Moltiplicazione e Divisione (da sinistra a destra)
- Addizione e Sottrazione (da sinistra a destra)
Le Parentesi Forzano Precedenza e Associatività
Possiamo forzare il raggruppamento normale con le parentesi. Le espressioni tra parentesi sono valutate trattando ogni sottoespressione tra parentesi come un'unità e altrimenti applicando le normali regole di precedenza. Ad esempio, possiamo mettere tra parentesi l'espressione sopra per forzare il risultato ad essere uno qualsiasi dei quattro valori possibili:
cout << (6 + 3) * 4 / 2 + 2 << endl; // stampa 36
cout << ((6 + 3) * 4) / 2 + 2 << endl; // stampa 20
cout << 6 + 3 * 4 / 2 + 2 << endl; // stampa 14
Quando Precedenza e Associatività Contano
Abbiamo già visto esempi in cui la precedenza influenza la correttezza dei nostri programmi. Ad esempio, riprendiamo la discussione fatta nelle lezioni precedenti riguardo al dereference e all'aritmetica dei puntatori. Consideriamo il seguente codice:
// array con cinque elementi di tipo int
int ia[] = {0,2,4,6,8};
// inizializza last a 8, il valore di ia[4]
int last = *(ia + 4);
// last = 4, equivalente a ia[0] + 4
last = *ia + 4;
Se vogliamo accedere all'elemento nella posizione ia + 4
, allora le parentesi intorno all'addizione sono essenziali. Senza parentesi, *ia
viene raggruppato prima e 4 viene aggiunto al valore in *ia
.
Il caso più comune che abbiamo visto in cui l'associatività conta è nelle espressioni di input e output. Come vedremo nelle prossime lezioni, gli operatori usati per l'I/O sono associativi a sinistra. Questa associatività significa che possiamo combinare diverse operazioni I/O in una singola espressione:
// Legge prima l'input e lo memorizza in v1
// Poi legge l'input successivo e lo memorizza in v2
cin >> v1 >> v2;
La Tabella degli Operatori C++ con loro priorità e associatività elenca tutti gli operatori a cui è associato un valore di precedenza ed è indicata l'associatività. Gli operatori con lo stesso valore di precedenza vengono valutati in base alla loro associatività, e hanno precedenza più alta degli operatori con priorità indicata da un numero più basso. Ad esempio, gli operatori prefissi di incremento e dereference condividono la stessa precedenza, che è più alta di quella degli operatori aritmetici. Abbiamo già visto alcuni di questi operatori e tratteremo la maggior parte dei rimanenti nelle prossime lezioni. Tuttavia, ci sono alcuni operatori che non tratteremo fino a lezioni più avanzate.
Esercizi
-
Qual è il valore restituito da
5 + 10 * 20/2
?Soluzione:
105
. La moltiplicazione e la divisione hanno precedenza sull'addizione, quindi l'espressione è equivalente a5 + ((10 * 20) / 2)
. -
Usando la tabella degli operatori e della loro precedenza, metti tra parentesi le seguenti espressioni per indicare l'ordine in cui gli operandi sono raggruppati:
-
* vec.begin()
Soluzione:
*(vec.begin())
-
* vec.begin() + 1
Soluzione:
*(vec.begin()) + 1
-
Ordine di Valutazione
La precedenza specifica come gli operandi sono raggruppati. Non dice nulla sull'ordine in cui gli operandi sono valutati. Nella maggior parte dei casi, l'ordine è in gran parte non specificato. Nella seguente espressione
int i = f1() * f2();
sappiamo che f1
e f2
devono essere chiamate prima che la moltiplicazione possa essere eseguita. Dopotutto, sono i loro risultati che vengono moltiplicati. Tuttavia, non abbiamo modo di sapere se f1
verrà chiamata prima di f2
o viceversa.
Per gli operatori che non specificano l'ordine di valutazione, è un errore che un'espressione si riferisca a e modifichi lo stesso oggetto. Le espressioni che lo fanno hanno comportamento indefinito. Come semplice esempio, l'operatore <<
non garantisce quando o come i suoi operandi sono valutati. Di conseguenza, la seguente espressione di output è indefinita:
int i = 0;
// Indefinito: l'ordine di valutazione di i e ++i non è specificato
cout << i << " " << ++i << endl;
Poiché questo programma è indefinito, non possiamo trarre conclusioni su come potrebbe comportarsi. Il compilatore potrebbe valutare ++i
prima di valutare i
, nel qual caso l'output sarà 1 1
. Oppure il compilatore potrebbe valutare i
prima, nel qual caso l'output sarà 0 1
. O il compilatore potrebbe fare qualcos'altro completamente diverso. Poiché questa espressione ha comportamento indefinito, il programma contiene un errore, indipendentemente dal codice che il compilatore genera.
Ci sono quattro operatori che garantiscono l'ordine in cui gli operandi sono valutati. Abbiamo visto che l'operatore AND logico (&&
) garantisce che il suo operando sinistro sia valutato per primo. Inoltre, ci è anche garantito che l'operando destro sia valutato solo se l'operando sinistro è vero. Gli unici altri operatori che garantiscono l'ordine in cui gli operandi sono valutati sono l'operatore OR logico (||
), l'operatore condizionale (?:
) e l'operatore virgola (,
).
Ordine di Valutazione, Precedenza e Associatività
L'ordine di valutazione degli operandi è indipendente dalla precedenza e dall'associatività. In un'espressione come f() + g() * h() + j()
:
- La precedenza garantisce che i risultati di
g()
eh()
siano moltiplicati. - L'associatività garantisce che il risultato di
f()
sia aggiunto al prodotto dig()
eh()
e che il risultato di quell'addizione sia aggiunto al valore dij()
. - Non ci sono garanzie sull'ordine in cui queste funzioni sono chiamate.
Se f
, g
, h
e j
sono funzioni indipendenti che non influenzano lo stato degli stessi oggetti o eseguono I/O, allora l'ordine in cui le funzioni sono chiamate è irrilevante. Se una qualsiasi di queste funzioni influenza lo stesso oggetto, allora l'espressione è in errore e ha comportamento indefinito.
Gestire Espressioni Composte
Quando scrivi espressioni composte, due regole empiriche possono essere utili:
- In caso di dubbio, metti tra parentesi le espressioni per forzare il raggruppamento che la logica del tuo programma richiede.
- Se cambi il valore di un operando, non usare quell'operando altrove nella stessa espressione.
Un'importante eccezione alla seconda regola si verifica quando la sottoespressione che cambia l'operando è essa stessa l'operando di un'altra sottoespressione. Ad esempio, in *++iter
, l'incremento cambia il valore di iter
. Il valore (ora cambiato) di iter
è l'operando dell'operatore di dereferenziamento. In questa (e simili) espressione, l'ordine di valutazione non è un problema. L'incremento (cioè, la sottoespressione che cambia l'operando) deve essere valutato prima che il dereferenziamento possa essere valutato. Tale uso non pone problemi ed è abbastanza comune.