C++ - buona interfaccia e progetti su più file
In questa pagina 5
Questa nota raccoglie il materiale di progettazione che serve per consegnare progetti veri: quali funzioni speciali definire in una classe, come si comportano costruttori e distruttori, come si divide un programma in più file e quali sono gli errori che i compilatori e i correttori puniscono più spesso. La teoria dei singoli meccanismi è in 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
Le operazioni essenziali di una classe
Per ogni classe si decide, una per una, se serve e come è fatta ciascuna di queste sette operazioni:
| operazione | firma tipica per una classe C |
quando viene chiamata |
|---|---|---|
| costruttore con argomenti | C(int n) |
C a(3); C a{3}; |
| costruttore di default | C() |
C a; vector<C> v(10); |
| costruttore di copia | C(const C&) |
C b = a; passaggio per valore, ritorno per valore |
| assegnamento di copia | C& operator=(const C&) |
b = a; |
| costruttore di spostamento | C(C&&) |
C b = move(a); ritorno di un temporaneo |
| assegnamento di spostamento | C& operator=(C&&) |
b = move(a); |
| distruttore | ~C() |
uscita dallo scope, delete |
Il costruttore di default non è un lusso: vector<double> v(10), vector<string> vs(10) e vector<vector<int>> vv(10) funzionano solo perché double, string e vector<int> hanno un costruttore di default che riempie i 10 elementi. Una classe senza costruttore di default non si può usare in questo modo.
Quando serve il distruttore, e cosa si trascina dietro
Il distruttore serve quando l'oggetto acquisisce una risorsa che qualcuno deve restituire: memoria nel free store (new), file, lock, thread, socket. Il segnale tipico è un membro puntatore che il costruttore inizializza con new.
Regola. Se una classe ha bisogno di un distruttore perché gestisce una risorsa, quasi sempre servono anche costruttore di copia, assegnamento di copia, costruttore di spostamento e assegnamento di spostamento.
Il perché: le copie generate dal compilatore (shallow) copiano il puntatore e non il contenuto. Due oggetti finiscono col puntare alla stessa memoria e i due distruttori la liberano due volte (doppia deallocazione); inoltre una modifica attraverso uno dei due si vede nell'altro. I dettagli della copia profonda (deep copy) e dello spostamento sono in 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_listvector, string, gli smart pointer di C++ - smart pointerGli smart pointer (classi template di
Costruttori e conversioni: explicit
Un costruttore che si può chiamare con un solo argomento definisce anche una conversione implicita da quel tipo alla classe. È utile per complex z = 3.14;, ma pericoloso quando la conversione non ha senso. Esempio: un Vec con un costruttore da int (numero di elementi) accetterebbe Vec v = 10; e f(10) come se 10 fosse un vettore.
#include <iostream>
class Vec {
public:
explicit Vec(int n) : size_{n} {}
int size() const { return size_; }
private:
int size_;
};
class Vec2 { // senza explicit: conversione implicita da int
public:
Vec2(int n) : size_{n} {}
int size() const { return size_; }
private:
int size_;
};
void f(const Vec& v) { std::cout << "f: " << v.size() << '\n'; }
void g(const Vec2& v) { std::cout << "g: " << v.size() << '\n'; }
int main()
{
Vec v0(10); // ok: chiamata esplicita
f(v0);
f(Vec(20)); // ok
// Vec v1 = 10; // errore: copy-initialization con costruttore explicit
// f(30); // errore: nessuna conversione implicita da int
g(30); // ok ma sospetto: un int diventa un Vec2
Vec2 w = 40; // ok: conversione implicita
w = 50; // ok: crea un Vec2 temporaneo e lo assegna
std::cout << w.size() << '\n';
}Uscita: f: 10, f: 20, g: 30, 50. Se si toglie il commento alle due righe sbagliate, il compilatore segnala no viable conversion from 'int' to 'Vec' e no matching function for call to 'f'. La regola pratica: ogni costruttore a un argomento è explicit, salvo che la conversione sia davvero voluta.
Quando vengono chiamati costruttori e distruttori
Un costruttore viene chiamato quando si crea un oggetto: dichiarazione di una variabile, new, copia (passaggio per valore, ritorno per valore, inizializzazione da un altro oggetto). Il distruttore quando un nome esce dallo scope o si fa delete. Per vederlo si costruisce una struct che stampa ogni cosa: costruttore di default, costruttore con un argomento, costruttore di copia, assegnamento di copia e distruttore.
#include <iostream>
#include <string>
#include <vector>
using namespace std;
struct X {
int val;
void out(const string& s, int nv)
{ cout << s << ": val=" << val << " (arg " << nv << ")\n"; }
X() { val = 0; out("X()", 0); }
X(int v) { val = v; out("X(int)", v); }
X(const X& x) { val = x.val; out("X(const X&)", x.val); }
X& operator=(const X& a) { out("operator=", a.val); val = a.val; return *this; }
~X() { out("~X()", 0); }
};
X glob(2); // variabile globale
X copy(X a) { return a; }
X copy2(X a) { X aa = a; return aa; }
X& ref_to(X& a) { return a; }
X* make(int i) { X a(i); return new X(a); }
struct XX { X a; X b; };
int main()
{
cout << "--- 1 loc{4}\n"; X loc {4};
cout << "--- 2 loc2{loc}\n"; X loc2 {loc};
cout << "--- 3 loc = X{5}\n"; loc = X{5};
cout << "--- 4 loc2 = copy(loc)\n"; loc2 = copy(loc);
cout << "--- 5 loc2 = copy2(loc)\n"; loc2 = copy2(loc);
cout << "--- 6 loc3{6}\n"; X loc3 {6};
cout << "--- 7 ref_to\n"; X& r = ref_to(loc); (void)r;
cout << "--- 8 delete make(7)\n"; delete make(7);
cout << "--- 9 vector<X> v(4)\n"; vector<X> v(4);
cout << "--- 10 XX loc4\n"; XX loc4;
cout << "--- 11 new X{9}\n"; X* p = new X{9};
cout << "--- 12 delete p\n"; delete p;
cout << "--- 13 new X[3]\n"; X* pp = new X[3];
cout << "--- 14 delete[] pp\n"; delete[] pp;
cout << "--- fine main\n";
}Il programma, compilato e lanciato, produce questa sequenza. Ogni riga X(...) o ~X() è una chiamata; val è il valore dell'oggetto su cui la funzione agisce (nell'assegnamento è il valore vecchio, prima di essere sovrascritto), arg il valore dell'argomento.
| passo | istruzione | chiamate, in ordine | perché |
|---|---|---|---|
prima di main |
X glob(2) |
X(int) con 2 |
le variabili globali nascono prima di main |
| 1 | X loc{4} |
X(int) 4 |
costruzione diretta |
| 2 | X loc2{loc} |
X(const X&) |
costruzione per copia |
| 3 | loc = X{5} |
X(int) 5 (temporaneo), operator= (da 5, su 4), ~X() (temporaneo, 5) |
il temporaneo nasce, si copia in loc, muore a fine istruzione |
| 4 | loc2 = copy(loc) |
2 volte X(const X&), operator=, 2 volte ~X() |
copia nel parametro a, copia nel valore di ritorno, assegnamento a loc2, distruzione del ritorno e del parametro |
| 5 | loc2 = copy2(loc) |
2 volte X(const X&), operator=, 2 volte ~X() |
copia nel parametro, aa = a (copia); return aa non copia (elisione, vedi sotto) |
| 6 | X loc3{6} |
X(int) |
|
| 7 | ref_to(loc) |
nessuna | parametro e ritorno per riferimento: nessuna copia |
| 8 | delete make(7) |
X(int) 7 (locale a), X(const X&) (il new X(a)), ~X() (locale a), ~X() (la delete) |
|
| 9 | vector<X> v(4) |
4 volte X() |
il costruttore di default riempie 4 elementi |
| 10 | XX loc4 |
2 volte X() |
un oggetto composto costruisce i membri |
| 11, 12 | new X{9} e delete p |
X(int) 9, poi ~X() |
|
| 13, 14 | new X[3] e delete[] pp |
3 volte X(), poi 3 volte ~X() |
|
fine main |
6 volte ~X() con 0 (loc4 e v), poi 6, 5, 5 (loc3, loc2, loc), e dopo main il 2 di glob |
i distruttori vanno in ordine inverso alla costruzione |
Dal passo 5 si capisce che il compilatore può non eseguire alcune copie: è autorizzato a ritenere che un costruttore di copia faccia solo una copia e nient'altro, quindi può eliminarla (copy elision). Per questo il numero esatto di righe X(const X&) dipende dal compilatore e dallo standard (in C++17 e successivi la copia nel return di un temporaneo è obbligatoriamente eliminata). Questo non cambia le regole che interessano: le copie di parametri passati per valore avvengono, quelle per riferimento no.
Progetti su più file
Un programma vero non sta in un file: si divide per leggibilità, per ricompilare solo ciò che cambia e per permettere a più persone di lavorare insieme. Esistono due tipi di file.
| tipo | contiene | come si usa |
|---|---|---|
header (.h) |
interfaccia: definizioni di classe, dichiarazioni di funzione, costanti | si include con #include "...", non si compila da solo |
sorgente (.cpp) |
implementazione: definizioni di funzione e di funzioni membro; il main (uno solo in tutto il progetto) |
si compila, non si include mai |
Il perché della divisione: dichiarazioni e definizioni di classe servono al compilatore per controllare i .cpp che le usano, quindi vanno copiate in ogni file che le nomina (è proprio ciò che fa #include). Le definizioni delle funzioni invece devono esistere una sola volta nel programma: se un .cpp fosse incluso in due file, la funzione sarebbe definita due volte e il linker protesterebbe. Ogni .cpp diventa una unità di traduzione (un file oggetto .o); il linker le salda in un solo eseguibile.
Il punto di partenza è la funzione vista in C++ - introduzione, compilazione e linkingC++ è un linguaggio compilato e standard che dà controllo totale sulla macchina (basso livello) e strumenti di astrazione (alto livello); un programma parte da main, usa gli stream (std::cout << ...) e le direttive del preprocessore (#include); il passaggio da sorgente a eseguibile ha tre fasi, preprocessore (espande #include e macro), compilatore (controlla sintassi e tipi e produce un file oggetto in codice macchina) e linker (unisce i file oggetto e le librerie risolvendo i nomi); gli errori di sintassi e di tipo sono del compilatore, i simboli dichiarati ma non definiti (undefined reference) sono del linker; si compila da riga di comando con g++ -o eseguibile sorgente.cpp -lLibreria.C++ - introduzione, compilazione e linking →: dichiarazione, definizione e chiamata. Spostando la dichiarazione in un header e la definizione in un .cpp si ottiene la struttura di un progetto:
Rational/
├── include/Rational.h // interfaccia
└── src/
├── Rational.cpp // implementazione
└── main.cpp // main, in un file a séUn esempio completo: la classe Rational
L'header contiene la classe e le dichiarazioni delle funzioni libere. Nell'interfaccia si scrivono inline solo le funzioni di una o due righe; il resto va nel .cpp.
#ifndef RATIONAL_H // include guard: il corpo viene letto una volta sola
#define RATIONAL_H
#include <iosfwd> // basta la dichiarazione di std::ostream
class Rational {
public:
Rational(int num = 0, int den = 1); // costruttore (default se senza argomenti)
int numerator() const { return numerator_; } // 1 riga: inline
int denominator() const { return denominator_; }
Rational& operator+=(const Rational& b);
private:
void simplify(); // helper privato
int numerator_;
int denominator_;
};
Rational operator+(Rational a, const Rational& b);
bool operator==(const Rational& a, const Rational& b);
std::ostream& operator<<(std::ostream& os, const Rational& r);
#endif // RATIONAL_HL'implementazione include il proprio header per prima cosa, e definisce le funzioni membro con l'operatore di scope Rational::. Dentro una funzione membro i dati privati sono accessibili anche se la classe è definita altrove.
#include "Rational.h"
#include <ostream>
#include <stdexcept>
namespace {
int gcd(int a, int b) { return b == 0 ? (a < 0 ? -a : a) : gcd(b, a % b); }
}
Rational::Rational(int num, int den) : numerator_{num}, denominator_{den}
{
if (den == 0) throw std::invalid_argument("denominatore nullo");
simplify();
}
void Rational::simplify()
{
int g = gcd(numerator_, denominator_);
numerator_ /= g;
denominator_ /= g;
if (denominator_ < 0) { numerator_ = -numerator_; denominator_ = -denominator_; }
}
Rational& Rational::operator+=(const Rational& b)
{
numerator_ = numerator_ * b.denominator_ + b.numerator_ * denominator_;
denominator_ *= b.denominator_;
simplify();
return *this;
}
Rational operator+(Rational a, const Rational& b) { return a += b; }
bool operator==(const Rational& a, const Rational& b)
{
return a.numerator() == b.numerator() && a.denominator() == b.denominator();
}
std::ostream& operator<<(std::ostream& os, const Rational& r)
{
return os << r.numerator() << '/' << r.denominator();
}Il main sta in un file a parte e include solo l'header:
#include <iostream>
#include "Rational.h"
int main()
{
Rational a{1, 2}, b{1, 3};
std::cout << a << " + " << b << " = " << a + b << '\n';
std::cout << (Rational{2, 4} == a) << '\n';
}Compilato e lanciato (la throw sostituita da un abort, perché la verifica è stata fatta in WebAssembly) stampa 1/2 + 1/3 = 5/6 e 1: 2/4 viene semplificato a 1/2, quindi è uguale ad a. Il comando completo con GNU:
g++ Rational.cpp main.cpp -o mainSi passano al compilatore solo i .cpp. L'header viene letto da #include; passarlo sulla riga di comando è inutile (e g++ -o x a.cpp a.h non è un'abitudine da prendere). Il compilatore produce un .o per ogni .cpp, poi il linker li unisce.
Include guard
Un .cpp può includere più header, e gli header possono includere altri header: book.h include date.h, e il .cpp include entrambi. Senza protezione date.h sarebbe letto due volte e le sue classi ridefinite; con inclusioni cicliche (a.h include b.h che include a.h) si andrebbe all'infinito. Ogni header ha quindi la sua include guard:
#ifndef RATIONAL_H
#define RATIONAL_H
// ... contenuto ...
#endif // RATIONAL_HLa prima volta RATIONAL_H non è definito: il contenuto viene letto e la macro si definisce; le volte successive #ifndef è falso e tutto fino a #endif è saltato. Provando a includere due volte lo stesso header senza guard, il compilatore dice redefinition of 'Rational'. #pragma once fa la stessa cosa ma non è standard: nei progetti del corso si usa #ifndef.
Errori di compilazione e di linking
Con più file è essenziale riconoscere chi ha segnalato l'errore. La prima domanda davanti a un errore è: compilazione o linking?
| errore di compilazione | errore di linking | |
|---|---|---|
| chi lo segnala | il compilatore (g++) |
il linker (ld) |
| quando | durante la traduzione di un singolo .cpp |
dopo, quando si uniscono i .o |
| cause tipiche | errori di sintassi, conversioni di tipo non valide, nome non dichiarato, header non incluso | definizione mancante di una funzione dichiarata e chiamata; due main; nessun main; due definizioni della stessa funzione; libreria non linkata |
| esempio di messaggio | no matching function for call to ... |
undefined reference to 'area(double, double)' |
Un programma può compilare e non linkare. Esempio del corso: double area(double w, double h); dichiarata, chiamata nel main, mai definita. Anche il caso più semplice dà errore di linking: compilare Rational.cpp da solo con g++ Rational.cpp -o Rational fallisce con undefined reference to 'main', perché ogni eseguibile ha bisogno di un punto di ingresso, la funzione main. Compilando invece solo main.cpp del progetto sopra, il linker dice che manca la definizione di Rational::Rational(int, int), di operator+ e degli altri: sono dichiarate nell'header, ma nessuna unità di traduzione le definisce.
Il compilatore non segnala invece i memory leak, gli accessi fuori dai limiti di un array né i puntatori non inizializzati: sono errori di esecuzione (o di logica), che si trovano con test e con revisione del codice.
Errori comuni nelle consegne
Elenco di ciò che nei progetti e negli esami viene considerato errore (non semplice stile):
- Interfaccia e implementazione non divise. Il
mainin un file a sé; le classi in.h(interfaccia) e.cpp(implementazione). Nell'interfaccia solo le funzioni inline di 1-2 righe. - Includere un
.cpp(#include "Rational.cpp"): si includono solo gli header. - Mancare le include guard negli header delle proprie classi.
- Passare gli header al compilatore invece dei soli
.cpp. - Dati e funzioni membro: se una funzione membro è definita fuori dalla classe si usa
Classe::; la funzione che non modifica l'oggetto èconst; i dati membro sonoprivate. Variabili necessarie alla classe mai globali: sono membri privati. Una funzione di servizio comereserve(...)non va resa pubblica se l'utente non deve usarla. - Passaggio dei parametri:
void print(std::string label, std::vector<int> v)copia tutto a ogni chiamata; meglioconst std::string&econst std::vector<int>&(vedi C++ - funzioni, reference e passaggio dei parametriuna funzione ha parametri formali (0 o più, input) e un valore di ritorno (0 o 1, output); in C++ gli argomenti si passano per valore, cioè il parametro è una variabile locale inizializzata con una copia dell'argomento, e modificarla non cambia l'originale; per oggetti grandi (vector, immagini) la copia costa, e si passa per reference (T&), un alias dell'oggetto del chiamante che permette lettura e scrittura, oppure per const reference (const T&), alias in sola lettura verificata dal compilatore, che accetta anche literal (oggetto temporaneo); regola: valore per oggetti piccoli, const T& per grandi da non modificare, T& solo se la funzione deve davvero modificare o produrre più di un output; il passaggio converte l'argomento nel tipo del parametro come in un'inizializzazione e un cast esplicito (static_cast) rende visibile l'intenzione.C++ - funzioni, reference e passaggio dei parametri →). #includeridondanti o funzioni, dati, costruttori non richiesti: "less is better", più codice è più rischio di errori. Una classeDateche nella sua interfaccia non usaiostreamnon deve includerlo.using namespace std;nei header e nelle librerie: si scrivestd::string. Dimenticare#include <vector>(e il namespacestd::) è un errore di compilazione;std::intnon esiste,int,char,bool,doublesono tipi built-in.friend: da evitare, perché rompe l'incapsulamento.- Leggere le consegne. Se è richiesto l'overload di
+,-,/,=, non si scrivono funzioniadd(),sub(). Se è richiesto un file per ilmain, ilmainnon sta inRational.h. - Concatenare stringhe e numeri:
s = s + a + "\n"conaintero non compila; si manda tutto astd::coutoppure si usastd::to_string. - Dimensione dei tipi: un
long intpuò avere la stessa dimensione di unint: attenzione all'overflow (vedi 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 →). - Identificatori: il primo carattere è una lettera latina o il trattino basso, i successivi lettere, cifre o trattino basso.
- Ordine nelle cartelle: una cartella (workspace) per progetto, per non compilare un sorgente e modificarne un altro con lo stesso nome; niente file spazzatura nella consegna; indentazione corretta.
- Editor: vanno bene tutti (CodeLite, gedit, vim, nano): conta che non rallenti.
Stile
Dopo aver scritto un po' di codice conviene rileggere una guida di stile come la Google C++ Style Guide, nelle sezioni utili e comprensibili: header (autosufficienti, #define guard, include solo ciò che serve, funzioni inline), scoping (namespace, variabili locali), classi (poco lavoro nei costruttori, conversioni implicite, tipi copiabili e spostabili, struct contro class, ordine delle dichiarazioni), funzioni (input e output, funzioni brevi, overload, argomenti di default), nomi (tipi, variabili, costanti, funzioni, namespace, enumeratori) e commenti (di file, di classe, di funzione).
Domande d'esame
1. Quando serve il distruttore? Quali altre funzioni speciali bisogna definire in quel caso, e perché?
Traccia. Il distruttore serve se la classe possiede una risorsa (memoria nel free store, file, socket, lock), tipicamente un membro puntatore inizializzato con new. Le copie generate dal compilatore copiano il puntatore (shallow copy): due oggetti condividono la stessa risorsa e il secondo distruttore la libera di nuovo. Servono quindi costruttore e assegnamento di copia (deep copy) e di spostamento (che ruba il puntatore e lascia la sorgente in stato valido).
2. A cosa serve explicit su un costruttore? Fare un esempio.
Traccia. Un costruttore a un argomento definisce una conversione implicita: con vector(int) sarebbero lecite vector v = 10; e f(10) per una f(const vector&). explicit vector(int) le vieta; restano vector v(10) e f(vector(10)).
3. Perché si includono gli header ma non i .cpp, e perché al compilatore si passano solo i .cpp? Che differenza c'è tra errore di compilazione e di linking?
Traccia. Le dichiarazioni e le definizioni di classe servono a ogni file che le usa, le definizioni delle funzioni devono comparire una sola volta (altrimenti il linker segnala doppia definizione). Ogni .cpp è un'unità di traduzione; il linker le unisce. Errore di compilazione: sintassi, tipi, nomi non dichiarati (compilatore). Errore di linking: definizione mancante o doppia, main assente (linker ld).
Versione ripasso
Sette operazioni essenziali: costruttore con argomenti, di default, di copia, assegnamento di copia, costruttore di spostamento, assegnamento di spostamento, distruttore. Il default serve a
vector<T> v(10).Distruttore = c'è una risorsa (memoria nel free store, file, lock, socket, di solito un membro puntatore). Se serve, servono quasi sempre anche copia e spostamento: la copia di default è shallow e causa doppia
delete. Meglio non possedere risorse grezze: C++ - smart pointerGli smart pointer (classi template di) sono oggetti che detengono un puntatore e lo deallocano automaticamente all'uscita dallo scope (RAII), eliminando memory leak e dangling pointer con un overhead minimo. unique_ptr ha un solo proprietario: non si copia (costruttore e assegnamento di copia cancellati) ma si sposta (std::move, ritorno da funzione), ha reset(), release(), get(), * e ->; due unique_ptr costruiti con lo stesso puntatore grezzo sono un errore (doppia delete) che il compilatore non vede, e si evita con make_unique. shared_ptr ha più proprietari: si copia, tiene un contatore di riferimenti (use_count) e libera l'oggetto quando l'ultimo shared_ptr viene distrutto, a un costo maggiore; weak_ptr osserva senza possedere, per rompere i cicli. auto_ptr è deprecato (rimosso in C++17).C++ - smart pointer → ,vector,string. Dettagli in 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_listpermette 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 → .explicitsu ogni costruttore a un argomento:Vec v = 10;ef(10)non compilano, restanoVec v(10)ef(Vec(10)). Senzaexplicitunintdiventa unVec.Traccia con una
struct Xche stampa: costruttore alla creazione (variabile,new, copia, parametro per valore, ritorno per valore), distruttore a fine scope odelete, distruttori in ordine inverso; parametri e ritorni per riferimento non copiano;vector<X> v(4)chiama 4 volteX(); la copy elision può togliere copie (obbligatoria in C++17 sui temporanei).Due tipi di file: header
.h(definizioni di classe, dichiarazioni, si include) e sorgente.cpp(definizioni,mainunico, si compila, mai incluso). Ogni.cppè un'unità di traduzione; il linker le unisce. Si passano al compilatore solo i.cpp:g++ Rational.cpp main.cpp -o main.Include guard in ogni header:
#ifndef X_H,#define X_H,#endif; evita ridefinizioni e inclusioni cicliche.#pragma oncenon è standard.Compilazione o linking? Compilatore: sintassi, tipi, nomi mancanti. Linker (
ld):undefined reference(dichiarata, chiamata, mai definita),undefined reference to main, doppia definizione.Struttura del progetto:
include/*.h,src/*.cpp,main.cppa parte,README. Nell'interfaccia inline solo funzioni di 1-2 righe; funzioni fuori classe conClasse::.Errori da evitare:
#include "X.cpp", header senza guard, variabili globali al posto di membri privati,using namespace std;nei header,#includeinutili,friend,add()al posto dioperator+, passare oggetti grandi per copia,s + aconaintero.Stile: Google C++ Style Guide (header, scoping, classi, funzioni, nomi, commenti).
Codice (rational/Rational.h):
#ifndef RATIONAL_H // include guard: il corpo viene letto una volta sola
#define RATIONAL_H
#include <iosfwd> // basta la dichiarazione di std::ostream
class Rational {
public:
Rational(int num = 0, int den = 1); // costruttore (default se senza argomenti)
int numerator() const { return numerator_; } // 1 riga: inline
int denominator() const { return denominator_; }
Rational& operator+=(const Rational& b);
private:
void simplify(); // helper privato
int numerator_;
int denominator_;
};
Rational operator+(Rational a, const Rational& b);
bool operator==(const Rational& a, const Rational& b);
std::ostream& operator<<(std::ostream& os, const Rational& r);
#endif // RATIONAL_H- Codice (rational/Rational.cpp):
#include "Rational.h"
#include <ostream>
#include <stdexcept>
namespace {
int gcd(int a, int b) { return b == 0 ? (a < 0 ? -a : a) : gcd(b, a % b); }
}
Rational::Rational(int num, int den) : numerator_{num}, denominator_{den}
{
if (den == 0) throw std::invalid_argument("denominatore nullo");
simplify();
}
void Rational::simplify()
{
int g = gcd(numerator_, denominator_);
numerator_ /= g;
denominator_ /= g;
if (denominator_ < 0) { numerator_ = -numerator_; denominator_ = -denominator_; }
}
Rational& Rational::operator+=(const Rational& b)
{
numerator_ = numerator_ * b.denominator_ + b.numerator_ * denominator_;
denominator_ *= b.denominator_;
simplify();
return *this;
}
Rational operator+(Rational a, const Rational& b) { return a += b; }
bool operator==(const Rational& a, const Rational& b)
{
return a.numerator() == b.numerator() && a.denominator() == b.denominator();
}
std::ostream& operator<<(std::ostream& os, const Rational& r)
{
return os << r.numerator() << '/' << r.denominator();
}- Codice (rational/main.cpp):
#include <iostream>
#include "Rational.h"
int main()
{
Rational a{1, 2}, b{1, 3};
std::cout << a << " + " << b << " = " << a + b << '\n';
std::cout << (Rational{2, 4} == a) << '\n';
}- Codice (explicit/expl.cpp):
#include <iostream>
class Vec {
public:
explicit Vec(int n) : size_{n} {}
int size() const { return size_; }
private:
int size_;
};
class Vec2 { // senza explicit: conversione implicita da int
public:
Vec2(int n) : size_{n} {}
int size() const { return size_; }
private:
int size_;
};
void f(const Vec& v) { std::cout << "f: " << v.size() << '\n'; }
void g(const Vec2& v) { std::cout << "g: " << v.size() << '\n'; }
int main()
{
Vec v0(10); // ok: chiamata esplicita
f(v0);
f(Vec(20)); // ok
// Vec v1 = 10; // errore: copy-initialization con costruttore explicit
// f(30); // errore: nessuna conversione implicita da int
g(30); // ok ma sospetto: un int diventa un Vec2
Vec2 w = 40; // ok: conversione implicita
w = 50; // ok: crea un Vec2 temporaneo e lo assegna
std::cout << w.size() << '\n';
}- Codice (traccia/traccia.cpp):
#include <iostream>
#include <string>
#include <vector>
using namespace std;
struct X {
int val;
void out(const string& s, int nv)
{ cout << s << ": val=" << val << " (arg " << nv << ")\n"; }
X() { val = 0; out("X()", 0); }
X(int v) { val = v; out("X(int)", v); }
X(const X& x) { val = x.val; out("X(const X&)", x.val); }
X& operator=(const X& a) { out("operator=", a.val); val = a.val; return *this; }
~X() { out("~X()", 0); }
};
X glob(2); // variabile globale
X copy(X a) { return a; }
X copy2(X a) { X aa = a; return aa; }
X& ref_to(X& a) { return a; }
X* make(int i) { X a(i); return new X(a); }
struct XX { X a; X b; };
int main()
{
cout << "--- 1 loc{4}\n"; X loc {4};
cout << "--- 2 loc2{loc}\n"; X loc2 {loc};
cout << "--- 3 loc = X{5}\n"; loc = X{5};
cout << "--- 4 loc2 = copy(loc)\n"; loc2 = copy(loc);
cout << "--- 5 loc2 = copy2(loc)\n"; loc2 = copy2(loc);
cout << "--- 6 loc3{6}\n"; X loc3 {6};
cout << "--- 7 ref_to\n"; X& r = ref_to(loc); (void)r;
cout << "--- 8 delete make(7)\n"; delete make(7);
cout << "--- 9 vector<X> v(4)\n"; vector<X> v(4);
cout << "--- 10 XX loc4\n"; XX loc4;
cout << "--- 11 new X{9}\n"; X* p = new X{9};
cout << "--- 12 delete p\n"; delete p;
cout << "--- 13 new X[3]\n"; X* pp = new X[3];
cout << "--- 14 delete[] pp\n"; delete[] pp;
cout << "--- fine main\n";
}Esercizi su questo argomento
- Esercizio - Classe Image con buffer dinamico (terzo appello 2023-24, programmazione)
- Esercizio - ConvertToArray da un container STL (secondo appello 2023-24, programmazione)
- Esercizio - InertialDriver con buffer circolare (prova intermedia 2025-26)
- Esercizio - Navigazione di un robot in un labirinto (laboratorio 8)
- Esercizio - Numeri complessi (quarto appello 2023-24, programmazione)
- Esercizio - Quiz e domande su spostamento, passaggio dei parametri e contenitori STL (primo appello 2023-24, teoria)
- Esercizio - Sistema tutor autostradale (progetto finale 2025-26)
- Esercizio - Un unique_ptr minimo e make_unique (laboratorio 10)