Salta al contenuto
Note per Studenti C++ - buona interfaccia e progetti su più file

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 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 → e 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 →; qui si usano insieme. Il passo successivo, la descrizione del progetto con un file di build, è in CMake - build di progetti C++Un progetto C++ non è fatto solo di sorgenti: bisogna dire quali file formano ogni eseguibile e ogni libreria (target), quali librerie linkare, dove stanno gli header e con quale standard compilare. CMake è un meta-sistema di build indipendente da compilatore e piattaforma: si descrive il progetto in un file di testo CMakeLists.txt (cmake_minimum_required, project, add_library, add_executable, target_link_libraries, include directories) e CMake genera i file del sistema di build nativo (Makefile, progetto di IDE). Lato utente si fa una configurazione (mkdir build, cd build, cmake ..) e poi la compilazione (make o cmake --build .) in una cartella di build separata dai sorgenti; le librerie di soli template (header-only) si dichiarano INTERFACE perché non producono codice precompilato.CMake - build di progetti C++ →.

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_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 →; la memoria dinamica in 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 →. L'alternativa moderna è non possedere affatto risorse grezze, ma usare tipi che le gestiscono (vector, string, gli smart pointer di 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 →): allora il compilatore genera da solo operazioni corrette e non si scrive niente di tutto questo.

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.

explicit/expl.cppcpp
#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.

traccia/traccia.cppcpp
#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.

rational/Rational.hcpp
#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

L'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.

rational/Rational.cppcpp
#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:

rational/main.cppcpp
#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 main

Si 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:

cpp
#ifndef RATIONAL_H
#define RATIONAL_H
// ... contenuto ...
#endif // RATIONAL_H

La 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):

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

rational/Rational.hcpp
#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):
rational/Rational.cppcpp
#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):
rational/main.cppcpp
#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):
explicit/expl.cppcpp
#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):
traccia/traccia.cppcpp
#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

Teoria collegata