Salta al contenuto
Note per Studenti C++ - enumerazioni e overloading degli operatori

C++ - enumerazioni e overloading degli operatori

In questa pagina 3

Altri due strumenti per costruire tipi: le enumerazioni, per scegliere fra pochi valori con un nome, e l'overloading degli operatori, per far funzionare +, ==, << sui tipi nuovi. Completano la classe Date di C++ - classi, membri e invariantiuna classe è un tipo definito dal programmatore che rappresenta un concetto con una rappresentazione (dati membro) e delle operazioni (funzioni membro); l'interfaccia pubblica separa chi usa la classe da chi la implementa e i dati privati sono modificabili solo tramite l'interfaccia; struct è una class con membri pubblici di default; l'invariante è la regola che definisce gli stati validi, e il costruttore deve stabilirlo e ogni funzione membro conservarlo; il costruttore (stesso nome della classe) inizializza con una lista di inizializzazione e, se i dati non sono validi, lancia un'eccezione, l'unico modo per far fallire un costruttore; le funzioni membro si definiscono fuori dalla classe (solo le brevissime dentro, dove sono inline); le funzioni che non modificano l'oggetto sono dichiarate const; le funzioni che si possono scrivere con la sola interfaccia pubblica (operator==, next_sunday) sono helper function esterne, spesso nello stesso namespace.C++ - classi, membri e invarianti → (il mese diventa un Month); un esempio completo di overloading, con le scelte di progetto, è il laboratorio Esercizio - Numeri razionali con classe e overloading (laboratorio 2). In Python l'analogo sono i metodi speciali (__add__, __eq__): Classi ed ereditarietà in PythonAttributi di istanza e di classe, metodi di istanza, di classe e statici; confronto e ordinamento con eq e lt; proprietà; ereditarietà, super(), override e polimorfismo; classi astratte come interfacce; eccezioni personalizzate; overloading e shadowing in Python.Classi ed ereditarietà in Python →.

Enumerazioni

In molte situazioni si vuole esprimere una scelta fra un numero predefinito di opzioni (un mese, un giorno della settimana, un semaforo). Si potrebbe usare un codice numerico, ma un insieme di etichette è più descrittivo per il programmatore (Month::feb contro 2) e il compilatore verifica che non si usi un valore a caso.

Definizione. Un'enumerazione (enum) è un tipo definito dall'utente molto semplice: specifica un insieme di valori rappresentati da costanti simboliche (gli enumeratori); una variabile di quel tipo è una scelta in questo insieme.

cpp
enum class Month {
    jan = 1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec
};
enum class Day {
    monday, tuesday, wednesday, thursday, friday, saturday, sunday
};
  • Si possono scegliere valori specifici (jan = 1); altrimenti ogni enumeratore vale il precedente più uno, partendo da 0. Quindi Month::feb vale 2 e Day::monday vale 0.
  • enum class crea uno scope: l'enumeratore si scrive Month::jan, il nome jan da solo non esiste. Due enumerazioni possono avere lo stesso enumeratore senza conflitti.
enum_base.cppcpp
#include <iostream>
using namespace std;

enum class Month {                 // enumerazione "con classe": crea uno scope
    jan = 1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec
};

enum class Day {                   // senza valori espliciti: parte da 0
    monday, tuesday, wednesday, thursday, friday, saturday, sunday
};

enum Colore { rosso, verde = 5, blu };   // enum "semplice": i nomi stanno nello scope esterno

int main()
{
    Month m = Month::feb;          // il nome va qualificato: Month::feb
    // Month m2 = feb;             // errore: feb non e' in scope
    // m = 7;                      // errore: non si assegna un int a un Month
    // int n = m;                  // errore: nemmeno un Month a un int
    Month mm = Month(7);           // conversione ESPLICITA da int: jul
    int n = int(m);                // conversione esplicita a int: 2
    cout << n << ' ' << int(mm) << '\n';

    Day d = Day::wednesday;
    cout << int(d) << ' ' << int(Day::sunday) << '\n';   // 2 e 6

    Colore c = blu;                // ok senza prefisso
    int k = c;                     // un enum semplice si converte a int da solo
    cout << rosso << ' ' << verde << ' ' << blu << ' ' << k << '\n';
    return 0;
}
2 7
2 6
0 5 6 6

Regole di assegnamento di un enum class:

istruzione esito
Month m = Month::feb; ok
Month m2 = feb; errore: feb non è in scope
m = 7; errore: nessuna conversione implicita da int
int n = m; errore: nessuna conversione implicita a int
Month mm = Month(7); ok: conversione esplicita da int (non controlla l'intervallo!)
int n = int(m); ok: conversione esplicita a int

Si può anche scrivere un enum senza class: i nomi stanno nello scope esterno (Colore c = blu;) e si convertono a int da soli. È più comodo ma più soggetto a conflitti: nel codice nuovo si preferisce enum class.

Overloading degli operatori

Gli operatori sono elementi fondamentali delle espressioni (C++ - tipi, variabili, espressioni e dichiarazioniun tipo definisce contenuto, intervallo di valori e operazioni; un oggetto è una regione di memoria con un tipo, il valore è la configurazione di bit letta in base al tipo, una variabile è un oggetto con un nome; inizializzazione (dare il primo valore) e assegnamento (cambiare il valore) sono diversi; le conversioni verso un tipo più capiente sono sicure, quelle verso un tipo meno capiente (narrowing, double->int, int->char) non lo sono e il compilatore le segnala di rado, ma la notazione con graffe {} le vieta; un'espressione calcola un valore da operandi e operatori, un lvalue è ciò che può stare a sinistra di =, constexpr vuole un valore noto in compilazione mentre const solo un valore che non cambia; gli operatori con side effect (++, --, +=) non vanno mischiati con altri usi della stessa variabile nella stessa espressione perché l'ordine di valutazione non è definito; una dichiarazione introduce un nome e un tipo, una definizione fornisce anche l'entità (memoria per una variabile, corpo per una funzione).C++ - tipi, variabili, espressioni e dichiarazioni →). Per i tipi built-in sono già definiti. Per un UDT? Sommare due date non ha un significato ovvio, ma può essere sensato definirlo.

Definizione. L'overloading degli operatori (operator overloading) è la possibilità di definire il comportamento di quasi tutti gli operatori per operandi di tipo definito dall'utente (class o enum), scrivendo una funzione di nome speciale: operator+, operator++, operator*, operator[], ...

Il C++ riconosce questo schema e traduce:

cpp
Date d {2010, Month::feb, 21};
++d;     // equivale a operator++(d)   se è una helper function
         //        oppure a d.operator++()  se è una funzione membro

Regole

  • Solo operatori già esistenti, con lo stesso numero di operandi e la stessa sintassi. Non si inventano operatori nuovi.
  • Deve esserci almeno un argomento UDT: int operator+(int, int); è un errore, perché + fra int è predefinito.
  • Un potente strumento, ma: non definire operatori con significato contro-intuitivo, e definirli solo se hanno senso concettualmente per quel tipo.
  • Gli operatori più utili: =, ==, !=, <, [], ().
  • Ogni operatore ha il proprio schema, diverso se è funzione membro o funzione esterna:
cpp
int operator+(int, int);                           // errore: + built-in
Vector operator+(const Vector&, const Vector&);    // OK
Vector operator+=(const Vector&, int);             // OK come schema (ma += di solito è un membro che modifica *this)

Helper function o funzione membro

helper function funzione membro
argomenti per un operatore binario due (almeno uno UDT, l'altro anche built-in) uno: il primo operando è l'oggetto stesso (this)
UDT a sinistra obbligatorio? no: int * Vec e Vec * int si scrivono entrambi sì: T + int sì, int + T no
accesso ai dati privati solo se amica o con l'interfaccia pubblica diretto
flessibilità maggiore minore
op_membro_helper.cppcpp
#include <iostream>
using namespace std;

struct Vec {
    int x, y;
    Vec(int xx = 0, int yy = 0) : x{xx}, y{yy} {}

    // FUNZIONE MEMBRO: l'operando di sinistra e' l'oggetto stesso (this), un solo argomento esplicito
    Vec operator+(const Vec& b) const { return Vec{x + b.x, y + b.y}; }
    Vec operator*(int k) const { return Vec{x * k, y * k}; }       // Vec * int
};

// HELPER FUNCTION: due argomenti espliciti, il primo puo' essere di qualunque tipo
Vec operator*(int k, const Vec& v) { return v * k; }               // int * Vec
bool operator==(const Vec& a, const Vec& b) { return a.x == b.x && a.y == b.y; }
ostream& operator<<(ostream& os, const Vec& v) { return os << '(' << v.x << ", " << v.y << ')'; }

int main()
{
    Vec a {1, 2}, b {3, 4};
    cout << a + b << '\n';          // a.operator+(b)
    cout << a * 3 << '\n';          // a.operator*(3)
    cout << 3 * a << '\n';          // operator*(3, a): possibile solo con l'helper function
    cout << (a + b == Vec{4, 6}) << '\n';
    // cout << 3 + a;               // errore: nessun operator+(int, Vec) e 3 non puo' essere "this"
    return 0;
}
(4, 6)
(3, 6)
(3, 6)
1

a + b e a * 3 sono chiamate a funzioni membro (a.operator+(b), a.operator*(3)). 3 * a richiede l'helper function operator*(int, const Vec&): con una funzione membro il 3 dovrebbe essere this, che non può essere un int. Per questo il codice 3 + a, se esiste solo il membro Vec::operator+, non compila:

error: invalid operands to binary expression ('int' and 'Vec')

Per i binari che non modificano l'operando conviene la funzione esterna (così le conversioni implicite valgono per entrambi gli argomenti e a < b compila se compila b < a).

Pattern degli operatori più comuni

operatore forma tipica note
+ - * / helper, due argomenti, restituisce un nuovo oggetto per valore almeno uno UDT
+= -= membro (modifica *this), restituisce T&
== != < helper, due const T&, restituisce bool != come !(a == b)
<< helper: ostream& operator<<(ostream&, const T&) deve restituire lo stream ricevuto, per concatenare
>> helper: istream& operator>>(istream&, T&) idem, con istream&
[] membro; due versioni (const e non) vedi C++ - allocazione dinamica della memoria con new e deletela memoria dinamica si prende dal free store (heap) con new, che restituisce un puntatore all'oggetto o al primo elemento dell'array (new T, new T[n], con inizializzatori {}), e si restituisce con delete (per new) e delete[] (per new[]); il puntatore non sa quanti elementi ci sono, la memoria non ha nome e non ha controllo dei limiti; in C++ non c'è garbage collector (scelta per efficienza e controllo), quindi un'area che perde ogni riferimento diventa garbage e produce un memory leak, e gli errori tipici sono la doppia cancellazione e il dangling pointer (si mette il puntatore a nullptr dopo delete); il distruttore ~T() è chiamato dal compilatore quando l'oggetto esce dallo scope o con delete e libera le risorse, per cui una classe come vector acquisisce la memoria nel costruttore e la rilascia nel distruttore; new alloca e chiama il costruttore, delete chiama il distruttore e libera; operator[] con due versioni (T& e const) dà accesso in lettura e scrittura al buffer; si alloca dinamicamente solo se serve davvero (memoria grande, dimensione nota a run-time, durata controllata).C++ - allocazione dinamica della memoria con new e delete →
= sempre membro copia o spostamento: C++ - costruttori, inizializzazione, copia e spostamentouna buona interfaccia è completa e minimale, ha i costruttori giusti, supporta o proibisce la copia, usa tipi adeguati, dichiara const ciò che non modifica e libera le risorse nel distruttore; il costruttore di default (senza argomenti) esiste quando c'è un valore iniziale sensato, si ottiene con valori di default nei membri (in-class initializer) o argomenti di default, e explicit vieta la conversione implicita da un argomento; initializer_list permette la lista {a, b, c} e con le graffe ha la precedenza sul costruttore a un intero (vector{3} è un elemento, vector(3) sono tre); la copia di default è membro a membro (shallow copy) e con un puntatore a un buffer duplica il puntatore invece del contenuto (due oggetti che condividono i dati e una doppia delete), quindi servono il costruttore di copia e l'assegnamento di copia che fanno la deep copy (l'assegnamento prima alloca e copia, poi libera i vecchi dati, e deve tollerare l'auto-assegnamento); this è il puntatore all'oggetto corrente, parametro implicito delle funzioni membro; il costruttore e l'assegnamento di spostamento (T&&, rvalue reference) rubano il buffer della sorgente lasciandola vuota ma valida, il compilatore li sceglie da solo (con la copy elision a volte non servono nemmeno); i cinque (distruttore, copia, spostamento) vanno definiti insieme.C++ - costruttori, inizializzazione, copia e spostamento →
++, -- due forme, vedi sotto

L'operatore << mostra perché deve restituire lo stream: cout << "Il valore " << i << " e' stato scritto" è una catena di chiamate: (cout << "Il valore ") << i << .... Ogni << prende lo stream a sinistra e restituisce lo stesso stream, così la chiamata dopo ha di nuovo uno stream a sinistra. Si traduce in operator<<(ostream&, string), poi operator<<(ostream&, int), ecc.

Pre e post incremento

operator++ ha due implementazioni:

cpp
T& operator++(T& t);        // preincremento:  ++x
T  operator++(T& t, int);   // postincremento: x++   (int: argomento fittizio, solo per distinguere)

Il post-incremento restituisce una copia del valore precedente (non è un lvalue): ritorna T, di solito per valore. Il pre-incremento può ritornare T o T&, con comportamenti diversi: con T& il risultato è l'oggetto stesso, quindi si può concatenare ++(++c) (incrementa due volte); con T per valore il risultato è un temporaneo e la seconda ++ non compila (cannot increment value of type).

inc_ritorno.cppcpp
#include <iostream>
using namespace std;

struct Contatore { int v = 0; };

Contatore& operator++(Contatore& c)      // restituisce un riferimento all'oggetto stesso
{
    ++c.v;
    return c;
}

int main()
{
    Contatore c;
    ++(++c);                             // il risultato di ++c e' c stesso: si incrementa due volte
    cout << c.v << '\n';
    return 0;
}
2

Il mese: operator++ e operator<<

Per un enum class conviene definire gli operatori che servono. L'incremento deve ricominciare da gennaio dopo dicembre; il ternario cond ? se_vero : se_falso lo scrive in una riga (int max = a > b ? a : b;, utile se le espressioni sono corte). La stampa usa una tabella dei nomi.

month_ops.cppcpp
#include <iostream>
#include <string>
#include <vector>
using namespace std;

enum class Month { jan = 1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec };

// pre-incremento: passa al mese successivo (dopo dec torna a jan)
Month operator++(Month& m)
{
    m = (m == Month::dec) ? Month::jan : Month(int(m) + 1);   // operatore ternario
    return m;
}

// post-incremento: l'int e' un argomento fittizio che distingue le due forme
Month operator++(Month& m, int)
{
    Month vecchio = m;             // il post-incremento restituisce il valore PRIMA dell'incremento
    ++m;
    return vecchio;
}

const vector<string> month_tbl {"", "jan", "feb", "mar", "apr", "may", "jun",
                                "jul", "aug", "sep", "oct", "nov", "dec"};

// l'operatore << deve restituire lo stream ricevuto, cosi' si puo' concatenare
ostream& operator<<(ostream& os, const Month& m)
{
    return os << month_tbl[int(m)];
}

int main()
{
    Month m = Month::nov;
    cout << m << '\n';             // nov
    ++m;
    cout << m << '\n';             // dec
    ++m;
    cout << m << '\n';             // jan: ricomincia
    Month a = m++;                 // a vale il vecchio valore (jan), m diventa feb
    cout << a << ' ' << m << '\n';
    cout << "Il mese e' " << m << '\n';
    ++m;
    cout << "e il successivo " << m << '\n';
    return 0;
}
nov
dec
jan
jan feb
Il mese e' feb
e il successivo mar

Osservazioni: a = m++ assegna il valore prima dell'incremento (jan) e poi m diventa feb. L'operator<< delle slide è dichiarato con Month& m non costante; qui è const Month& m perché stampare non modifica, e così funziona anche con temporanei e oggetti const. Il vettore month_tbl ha una stringa vuota in posizione 0 perché i mesi partono da 1.

Domande d'esame

1. Che cos'è un'enumerazione e che cosa cambia con enum class? Può rendere il programma più efficiente? Traccia. Tipo con un insieme di valori simbolici; enum class crea uno scope (Month::jan), niente conversioni implicite da e verso int. Rende il codice più espressivo e controllabile, non più efficiente. Non è una classe con membri pubblici: non ha funzioni né dati.

2. Quali vincoli ha l'overloading di un operatore? Posso cambiarne il numero di operandi? Il compilatore lo genera se non lo scrivo? Traccia. Solo operatori esistenti, stessa arità e sintassi, almeno un argomento UDT; non si applica ai soli tipi built-in. Il numero di argomenti della funzione cambia (uno in meno se membro, perché il primo è this) ma non il numero di operandi. Se non lo definisco non c'è (tranne = per copia e spostamento che il compilatore genera).

3. Differenza fra Rational + int e int + Rational con operator membro e con helper function. Traccia. Con il membro Rational::operator+(int) vale solo Rational + int (l'UDT è il primo operando, this); int + Rational richiede una helper function operator+(int, const Rational&).

4. Perché operator<< restituisce ostream&? Traccia. Per concatenare più inserimenti nella stessa istruzione: ogni chiamata riceve lo stream e lo restituisce.

Versione ripasso

  • Enumerazione = tipo con insieme di valori simbolici (enumeratori): enum class Month { jan = 1, feb, ... };. Valori: quelli indicati, altrimenti il precedente +1+1 (da 0). enum class crea uno scope: Month::feb.
istruzione esito
Month m = Month::feb; ok
Month m2 = feb; errore: non in scope
m = 7; / int n = m; errore: nessuna conversione implicita
Month(7) / int(m) ok, conversione esplicita (nessun controllo dell'intervallo)

Un enum senza class mette i nomi nello scope esterno e si converte a int. Rende il codice più espressivo, non più veloce.

  • Overloading degli operatori = definire quasi tutti gli operatori per class/enum con una funzione operator+, operator++, operator[], ... Regole: solo operatori già esistenti, stesso numero di operandi e stessa sintassi, almeno un argomento UDT (int operator+(int,int) è un errore); non rendere gli operatori contro-intuitivi; i più utili sono =, ==, !=, <, [], ().
  • ++d; →\to operator++(d) (helper) oppure d.operator++() (membro).
  • Helper function: due argomenti per i binari, l'UDT può stare a sinistra o a destra (Vec * int e int * Vec). Funzione membro: un argomento in meno (il primo operando è this), l'UDT deve stare a sinistra: T + int sì, int + T no →\to meno flessibile. 3 + a con solo il membro: invalid operands to binary expression ('int' and 'Vec').
cpp
struct Vec { int x, y;
  Vec operator+(const Vec& b) const { return Vec{x+b.x, y+b.y}; }   // membro
  Vec operator*(int k) const { return Vec{x*k, y*k}; } };
Vec operator*(int k, const Vec& v) { return v * k; }               // helper: 3 * a
// a{1,2}, b{3,4}: a+b = (4, 6);  a*3 = (3, 6);  3*a = (3, 6)
  • << / >>: helper ostream& operator<<(ostream&, const T&) / istream& operator>>(istream&, T&); restituiscono lo stream per concatenare.
  • Pre e post incremento: T& operator++(T&) (pre) e T operator++(T&, int) (post, int fittizio); il post restituisce la copia del vecchio valore (non è un lvalue). Con T& si può fare ++(++c) (2 incrementi); con T per valore la seconda ++ non compila.
  • Ternario: cond ? se_vero : se_falso; per Month: m = (m == Month::dec) ? Month::jan : Month(int(m)+1);. Programma: da nov: ++ →\to dec, ++ →\to jan; a = m++ →\to a = jan, m = feb.
  • operator<< per Month: tabella month_tbl con stringa vuota in posizione 0; parametro const Month& (non Month&, che non accetta temporanei).
  • Traccia di risposta: Rational + int va con membro o helper; int + Rational solo con helper; = sempre membro; le + - * / restituiscono un nuovo oggetto per valore; += restituisce T&.
  • Codice chiave del mese:
cpp
Month operator++(Month& m)
{ m = (m == Month::dec) ? Month::jan : Month(int(m) + 1); return m; }
ostream& operator<<(ostream& os, const Month& m) { return os << month_tbl[int(m)]; }
  • Domande tipiche: enum class = scope + niente conversioni implicite, più espressivo, non più veloce; operatori = solo esistenti, arità uguale, almeno un UDT, se non li scrivi non esistono (eccetto copia e spostamento); Rational + int con membro o helper, int + Rational solo con helper; << restituisce ostream& per concatenare.
  • Errori tipici: operator<< che non restituisce lo stream; Month m = 7;; definire operator+(int,int); operator<< come funzione membro; post-incremento che restituisce l'oggetto modificato; dimenticare const sui parametri degli operatori.
  • Esercizi svolti: la classe Rational completa con tutti gli operatori e il confronto fra membro e helper: Esercizio - Numeri razionali con classe e overloading (laboratorio 2); la classe Complex: Esercizio - La classe Complex (primo appello 2022-23, programmazione).
  • Esempi di codice enum: enum class Day { monday, ..., sunday }; →\to int(Day::wednesday) = 2, int(Day::sunday) = 6; enum Colore { rosso, verde = 5, blu }; →\to rosso = 0, verde = 5, blu = 6; un enum semplice si converte a int da solo.

Esercizi su questo argomento

Teoria collegata