Esercizio - Numeri razionali con classe e overloading (laboratorio 2)
In questa pagina 4
Secondo laboratorio del corso di Laboratorio di Programmazione, Ing. Informatica UniPD: progettare la classe Rational e darle gli operatori. Teoria: 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 →, C++ - enumerazioni e overloading degli operatoriun'enumerazione è un tipo definito dall'utente che specifica un insieme di valori simbolici (enumeratori); enum class crea uno scope (Month::jan), non si converte implicitamente in int, e i valori partono da 0 o dal valore del precedente più uno; l'overloading degli operatori permette di definire gli operatori esistenti per tipi definiti dall'utente (almeno un operando UDT, stesso numero di operandi e stessa sintassi) scrivendo una funzione di nome operator+, operator++, operator[], ...; si può fare con una helper function (due argomenti per un operatore binario, il primo può essere di qualunque tipo) o con una funzione membro (un argomento in meno, perché il primo operando è l'oggetto, quindi l'UDT deve stare a sinistra: T + int sì, int + T no); << e >> sono helper function che prendono e restituiscono ostream& e istream&; ++ ha due forme (pre e post, quest'ultima con un int fittizio); gli operatori più utili sono =, ==, !=, <, [], ().C++ - enumerazioni e overloading degli operatori →, per la copia 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
Testo. Dal libro di testo (cap. 9, esercizio 13). Progettare e realizzare la classe
Rational: un numero razionale ha un numeratore e un denominatore, per esempio (circa ). Fornire: A. un costruttore di default che inizializza il numero a 0; B. un costruttore con un argomentoint(un intero da rappresentare come razionale); C. un costruttore con dueint, numeratore e denominatore; D. l'assegnamento; E. addizione; F. sottrazione; G. moltiplicazione; H. divisione; I. uguaglianza (==), maggiore (>), minore (<) e l'operatore di uscita su stream (<<). Fornire anche una conversione adoublecon una funzioneto_double(). Provare tutti gli operatori inmain.Per l'addizione provare l'overloading di: A. somma di due
Rational; B. somma di unRationale di unint. Entrambi vanno scritti in due modi: come funzione membro e come helper function (non contemporaneamente). Quali sono le differenze fraint + RationaleRational + int? Prestare molta attenzione al tipo restituito, e a se debba essere una variabile o una reference.
Domande di discussione
1. Quali sono le due parti di una classe? L'interfaccia (membri public: ciò che l'utente può usare) e l'implementazione (membri private: dati e funzioni di servizio).
2. Differenza fra interfaccia e implementazione. L'interfaccia è il punto di vista di chi usa la classe e cambia raramente; l'implementazione è il punto di vista di chi la scrive, nascosta e libera di cambiare. Qui: l'interfaccia di Rational sono i costruttori, numerator(), denominator(), gli operatori; num_ e den_ e normalize() sono l'implementazione.
3. Perché un costruttore invece di una funzione init_day()? Il costruttore è chiamato sempre alla creazione, non si può bypassare e può rifiutare un oggetto non valido (un denominatore nullo); una funzione di inizializzazione si può dimenticare, e nel frattempo l'oggetto esiste in uno stato inutilizzabile.
4. Cos'è un invariante? Esempi. Una regola sullo stato valido che il costruttore stabilisce e ogni funzione membro conserva. Per Rational: e frazione ridotta ai minimi termini (lo zero è ). Con questi invarianti ogni razionale ha una rappresentazione unica, e l'uguaglianza si riduce al confronto dei due interi.
5. Dentro o fuori dalla classe? Dentro solo funzioni brevissime (qui numerator(), denominator()), perché diventano inline; il resto fuori, per tenere pulita l'interfaccia e per non forzare la ricompilazione dei client a ogni modifica.
Progetto della classe
Decisioni, in ordine:
| decisione | scelta | motivo |
|---|---|---|
| dati | int num_, den_ |
la convenzione del suffisso _ distingue i dati membro dagli argomenti |
| invarianti | , MCD , zero | rappresentazione unica; == banale |
| costruttori A, B, C | uno solo: Rational(int num = 0, int den = 1) |
gli argomenti di default coprono zero, uno e due argomenti |
| denominatore nullo | throw DivisionByZero() nel costruttore |
un costruttore può fallire solo così |
| conversione | double to_double() const e non operator double() |
vedi la sezione sugli errori della soluzione del corso |
| assegnamento (D) | nessuno: quello generato | i membri sono due int: la copia membro a membro è corretta |
+ - * / |
helper function, const Rational&, restituiscono Rational per valore |
non servono i dati privati; il risultato è un oggetto nuovo |
== != < > |
helper function che restituiscono bool |
!= è la negazione di ==, > è < a parti scambiate |
<< |
helper ostream& operator<<(ostream&, const Rational&) |
restituisce lo stream, per concatenare |
Gli accessori numerator() e denominator() sono const, in modo che le helper function possano leggere anche da const Rational&. Il costruttore riporta sempre alla forma normale con normalize(): sposta il segno al numeratore e divide per il MCD del valore assoluto (std::gcd(abs(num_), den_); gcd(0, d) = d fa diventare uguale a ).
Le formule: , , (con ): il costruttore pensa a ridurre e a sistemare il segno, anche se è negativo. Per : , valido perché i denominatori sono positivi (invariante!); il prodotto si calcola in long long per evitare l'overflow.
I file
#ifndef RATIONAL_H
#define RATIONAL_H
#include <iosfwd>
// Numero razionale num/den.
// Invarianti: den_ > 0 e la frazione e' ridotta ai minimi termini (MCD = 1); lo zero e' 0/1.
class Rational {
public:
class DivisionByZero {}; // segnala un denominatore o un divisore nullo
Rational(int num = 0, int den = 1); // tre costruttori in uno: 0, n/1, num/den
// Copia, assegnamento e distruttore NON si scrivono: i membri sono due int e quelli generati
// dal compilatore (membro a membro) sono gia' corretti.
int numerator() const { return num_; }
int denominator() const { return den_; }
double to_double() const; // conversione esplicita a double
private:
void normalize(); // ripristina gli invarianti
int num_;
int den_;
};
// Tutti gli operatori binari sono helper function: non servono i dati privati
// e le conversioni implicite valgono per ENTRAMBI gli operandi (r + 1 e 1 + r).
Rational operator+(const Rational& a, const Rational& b);
Rational operator-(const Rational& a, const Rational& b);
Rational operator*(const Rational& a, const Rational& b);
Rational operator/(const Rational& a, const Rational& b); // lancia DivisionByZero se b == 0
bool operator==(const Rational& a, const Rational& b);
bool operator!=(const Rational& a, const Rational& b);
bool operator<(const 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#include "rational.h"
#include <cstdlib>
#include <numeric>
#include <ostream>
Rational::Rational(int num, int den) : num_{num}, den_{den}
{
if (den_ == 0) throw DivisionByZero(); // il denominatore nullo violerebbe l'invariante
normalize();
}
void Rational::normalize()
{
if (den_ < 0) { // il segno sta sempre al numeratore
num_ = -num_;
den_ = -den_;
}
int g = std::gcd(std::abs(num_), den_); // gcd(0, d) = d: lo zero diventa 0/1
num_ /= g;
den_ /= g;
}
double Rational::to_double() const
{
// il cast sul numeratore serve: senza, num_ / den_ sarebbe una divisione tra interi
return static_cast<double>(num_) / den_;
}
Rational operator+(const Rational& a, const Rational& b)
{
// a/b + c/d = (a*d + c*b) / (b*d); il costruttore riduce e sistema il segno
return Rational(a.numerator() * b.denominator() + b.numerator() * a.denominator(),
a.denominator() * b.denominator());
}
Rational operator-(const Rational& a, const Rational& b)
{
return Rational(a.numerator() * b.denominator() - b.numerator() * a.denominator(),
a.denominator() * b.denominator());
}
Rational operator*(const Rational& a, const Rational& b)
{
return Rational(a.numerator() * b.numerator(), a.denominator() * b.denominator());
}
Rational operator/(const Rational& a, const Rational& b)
{
if (b.numerator() == 0) throw Rational::DivisionByZero();
// a/b : c/d = a*d / (b*c); se c e' negativo il costruttore sposta il segno
return Rational(a.numerator() * b.denominator(), a.denominator() * b.numerator());
}
bool operator==(const Rational& a, const Rational& b)
{
// grazie agli invarianti (ridotta, den > 0) la rappresentazione e' unica: basta confrontare i membri
return a.numerator() == b.numerator() && a.denominator() == b.denominator();
}
bool operator!=(const Rational& a, const Rational& b) { return !(a == b); }
bool operator<(const Rational& a, const Rational& b)
{
// a/b < c/d <=> a*d < c*b, valido perche' i denominatori sono positivi; long long contro l'overflow
return static_cast<long long>(a.numerator()) * b.denominator()
< static_cast<long long>(b.numerator()) * a.denominator();
}
bool operator>(const Rational& a, const Rational& b) { return b < a; }
std::ostream& operator<<(std::ostream& os, const Rational& r)
{
return os << r.numerator() << '/' << r.denominator();
}#include <iostream>
#include "rational.h"
using namespace std;
int main()
{
Rational a; // default: 0/1
Rational b(5); // intero: 5/1
Rational c(3, 4);
Rational d(-6, 8); // ridotta: -3/4
Rational e(2, -4); // il segno passa al numeratore: -1/2
cout << "a = " << a << ", b = " << b << ", c = " << c << ", d = " << d << ", e = " << e << '\n';
cout << "c + d = " << c + d << '\n';
cout << "c - d = " << c - d << '\n';
cout << "c * d = " << c * d << '\n';
cout << "c / d = " << c / d << '\n';
cout << "c / e = " << c / e << '\n'; // il divisore e' negativo
cout << "c + 1 = " << c + 1 << " e 1 + c = " << 1 + c << '\n'; // entrambi gli ordini
Rational f = c; // costruttore di copia (generato)
f = e; // assegnamento
cout << "f = " << f << '\n';
cout << "c == Rational(6, 8): " << (c == Rational(6, 8)) << '\n';
cout << "c != d: " << (c != d) << '\n';
cout << "c > d: " << (c > d) << ", c < d: " << (c < d) << ", c < b: " << (c < b) << '\n';
cout << "c.to_double() = " << c.to_double() << '\n';
cout << "1/3 come double: " << Rational(1, 3).to_double() << '\n';
try {
Rational x(1, 0);
cout << x << '\n';
}
catch (Rational::DivisionByZero) {
cout << "Rational(1, 0): denominatore nullo\n";
}
try {
cout << c / a << '\n';
}
catch (Rational::DivisionByZero) {
cout << "c / a: divisione per zero\n";
}
return 0;
}Si compila con g++ -o prog main.cpp rational.cpp. Uscita (il programma usa try/catch, che in WebAssembly non funzionano: l'uscita è ricavata a mano e confermata eseguendo una copia in cui throw stampa e termina):
a = 0/1, b = 5/1, c = 3/4, d = -3/4, e = -1/2
c + d = 0/1
c - d = 3/2
c * d = -9/16
c / d = -1/1
c / e = -3/2
c + 1 = 7/4 e 1 + c = 7/4
f = -1/2
c == Rational(6, 8): 1
c != d: 1
c > d: 1, c < d: 0, c < b: 1
c.to_double() = 0.75
1/3 come double: 0.333333
Rational(1, 0): denominatore nullo
c / a: divisione per zeroControlli dalla tabella delle uscite: diventa e diventa (invarianti rispettati); ; ; ; ; (divisore negativo gestito); c + 1 e 1 + c danno entrambi .
Rational + int contro int + Rational
Con la funzione membro, l'operando di sinistra è l'oggetto stesso (this): Rational::operator+(const Rational& b) const permette r + s e, grazie alla conversione implicita del costruttore Rat(int), anche r + 1 (diventa r.operator+(Rat(1))). Ma 1 + r non compila: a sinistra c'è un int, e un int non ha membri.
#include <iostream>
using namespace std;
// Versione minima di Rational, con le somme come FUNZIONI MEMBRO
class Rat {
public:
Rat(int n = 0, int d = 1) : num{n}, den{d} {} // il costruttore a un argomento e' implicito
Rat operator+(const Rat& b) const { return Rat(num * b.den + b.num * den, den * b.den); }
int num, den;
};
int main()
{
Rat r(1, 2), s(1, 3);
Rat t = r + s; // r.operator+(s): ok
Rat u = r + 1; // r.operator+(Rat(1)): ok, l'argomento 1 e' convertito
cout << t.num << '/' << t.den << ' ' << u.num << '/' << u.den << '\n';
// Rat v = 1 + r; // ERRORE: a sinistra c'e' un int, non esiste (int).operator+
return 0;
}5/6 3/2error: invalid operands to binary expression ('int' and 'Rat') (con Rat v = 1 + r;)Con una helper function Rat operator+(const Rat& a, const Rat& b) il compilatore può convertire entrambi gli argomenti, quindi funzionano r + s, r + 1 e 1 + r.
#include <iostream>
using namespace std;
// Stessa classe, con la somma come HELPER FUNCTION
struct Rat {
Rat(int n = 0, int d = 1) : num{n}, den{d} {}
int num, den;
};
Rat operator+(const Rat& a, const Rat& b) { return Rat(a.num * b.den + b.num * a.den, a.den * b.den); }
int main()
{
Rat r(1, 2), s(1, 3);
Rat t = r + s;
Rat u = r + 1; // operator+(r, Rat(1))
Rat v = 1 + r; // operator+(Rat(1), r): ora funziona
cout << t.num << '/' << t.den << ' ' << u.num << '/' << u.den << ' ' << v.num << '/' << v.den << '\n';
return 0;
}5/6 3/2 3/2Quindi: con un unico overload (const Rational&, const Rational&) e il costruttore Rational(int) non esplicito non servono overload separati per gli int. Scrivendo anche operator+(const Rational&, int) in versione membro (Rational::operator+(int)) si ottiene Rational + int ma non int + Rational; per l'altro ordine serve per forza una helper function operator+(int, const Rational&). Per questo la guida di stile Google raccomanda: prefer to define non-modifying binary operators as non-member functions, per non avere un a < b che compila mentre b < a no.
Tipi restituiti.
| funzione | restituisce | perché |
|---|---|---|
operator+, -, *, / |
Rational per valore |
è un oggetto nuovo: una reference a un temporaneo locale sarebbe dangling |
operator= (se scritto a mano) |
Rational& (*this) |
permette a = b = c |
operator<< |
ostream& |
per concatenare |
==, <, > |
bool |
|
numerator(), denominator() |
int per valore, funzioni const |
tipi piccoli |
La soluzione del corso: errori trovati
Il corso pubblica una soluzione del laboratorio 2 (cartella lab2 con include/rational.h, src/rational.cpp, src/main.cpp, CMakeLists.txt). Compilata e provata, ha tre problemi.
1. La normalizzazione non sposta il segno al numeratore. Il commento in normalize() dice che sposta il negativo al numeratore. In realtà divide numeratore e denominatore per Gcd(numerator_, denominator_), e Gcd è l'algoritmo di Euclide con %, che in C++ ha il segno del dividendo: il risultato può essere negativo. Dividere per un MCD negativo inverte entrambi i segni, quindi un denominatore può rimanere negativo:
| costruzione | risultato della soluzione del corso | corretto |
|---|---|---|
Rational(3, -6) |
1/-2 |
-1/2 |
Rational(-3, 6) |
1/-2 |
-1/2 |
Rational(2, -8) |
1/-4 |
-1/4 |
Rational(6, -3) |
-2/1 |
-2/1 |
Rational(-3, -6) |
1/2 |
1/2 |
Rational(1, 2) / Rational(-1, 3) |
3/-2 |
-3/2 |
I valori numerici sono giusti, ma la forma non è coerente: -1/2 esce come 1/-2 mentre -5/8 esce come -5/8 (dipende dal segno che l'algoritmo di Euclide dà all'MCD), e il denominatore dichiarato positivo non lo è. Qualunque codice che assuma sbaglia: per esempio il confronto < per prodotto incrociato dà il risultato opposto con un denominatore negativo. La correzione è quella di questa soluzione: prima si sistema il segno (if (den_ < 0) { num_ = -num_; den_ = -den_; }), poi si divide per l'MCD del valore assoluto.
2. Lcm può essere negativo. Dipende dallo stesso Gcd: Lcm(-7, 11) dà e Lcm(-4, 6) dà , mentre Lcm(7, -11) dà (il segno dipende dall'ordine degli argomenti).
3. L'operatore di conversione operator double() rende ambigue Rational + int e Rational == int. La classe ha sia il costruttore implicito Rational(int) sia operator double() implicito. Per r + 1 il compilatore ha due strade: convertire 1 in Rational (e usare operator+(Rational, Rational)) oppure convertire r in double (e usare la somma built-in). Nessuna è migliore: errore.
error: use of overloaded operator '+' is ambiguous (with operand types 'Rational' and 'int')
error: use of overloaded operator '==' is ambiguous (with operand types 'Rational' and 'int')Per questo qui la conversione è solo to_double(); se si vuole la conversione per cast, explicit operator double() const.
Aspetti buoni della soluzione ufficiale, ripresi qui: un solo costruttore con argomenti di default; le helper function per i binari; static constexpr per i default; accessori numerator() e denominator() const. Il passaggio per copia dei Rational negli operatori (due int) è lecito; qui si preferisce const Rational& per uniformità con le classi più grandi, senza differenze pratiche.
Limiti comuni. Le operazioni su int possono andare in overflow con numeratori e denominatori grandi: la versione robusta ridurrebbe i fattori comuni prima di moltiplicare, o userebbe long long.
Versione ripasso
- Domande: classe = interfaccia (
public) + implementazione (private); costruttore perché obbligatorio e può rifiutare un oggetto non valido; invarianti diRational: , frazione ridotta, zero = (rappresentazione unica==confronta i dueint); dentro la classe solo funzioni brevissime. - Classe:
Rational(int num = 0, int den = 1)(default, intero e due argomenti in uno),throw DivisionByZero()seden == 0; assegnamento e copia generati (dueint);numerator(),denominator()const;double to_double() const(cast sul numeratore, altrimenti divisione fra interi).
void Rational::normalize()
{
if (den_ < 0) { num_ = -num_; den_ = -den_; } // segno al numeratore
int g = std::gcd(std::abs(num_), den_); // MCD del valore assoluto
num_ /= g; den_ /= g;
}
Rational operator+(const Rational& a, const Rational& b)
{ return Rational(a.numerator() * b.denominator() + b.numerator() * a.denominator(),
a.denominator() * b.denominator()); }- Operatori: helper function con
const Rational&:+ - * /restituisconoRationalper valore;/lanciaDivisionByZerose il divisore è 0 ();==confronta i membri;<con inlong long;>è<scambiato;!=è!(==);<<restituisceostream&. - Uscita del test:
d = -3/4,e = -1/2; , , , , ,c + 1 = 7/4e1 + c = 7/4;c == Rational(6, 8)vero;to_double() = 0.75. - Membro o helper: il membro
operator+(const Rat&) constammetter + ser + 1(il costruttore implicito converte) ma non1 + r(invalid operands to binary expression ('int' and 'Rat')); l'helper converte entrambi gli argomenti e ammette tutti e tre. Google: i binari che non modificano vanno non membri. - Tipi restituiti:
+ - * /per valore (oggetto nuovo, una reference sarebbe dangling);=Rational&;<<ostream&; confrontibool. - Errori della soluzione del corso: (1)
normalize()divide per un MCD che può essere negativo:Rational(-3, 6)eRational(3, -6)danno1/-2,Rational(1,2)/Rational(-1,3)dà3/-2(invariante violato, forma incoerente); (2)Lcm(-7, 11) = -77; (3)operator double()implicito insieme aRational(int)rende ambiguer + 1er == 1. - Header della classe (forma finale):
class Rational {
public:
class DivisionByZero {};
Rational(int num = 0, int den = 1); // 0, intero, num/den in un solo costruttore
int numerator() const { return num_; }
int denominator() const { return den_; }
double to_double() const;
private:
void normalize();
int num_; int den_;
};
Rational operator+(const Rational& a, const Rational& b); // - * / analoghi; / lancia DivisionByZero
bool operator==(const Rational& a, const Rational& b); // != < > analoghi
std::ostream& operator<<(std::ostream& os, const Rational& r);- Altri operatori:
operator-: ;operator*: ;operator/: seb.numerator() == 0lancia, altrimenti ;operator==: confronto dei dueint;operator<:static_cast<long long>(a.numerator()) * b.denominator() < static_cast<long long>(b.numerator()) * a.denominator();operator>:b < a;operator!=:!(a == b);operator<<:os << r.numerator() << '/' << r.denominator();to_double:static_cast<double>(num_) / den_. - Tre prove di invariante:
Rational(2, -4)-1/2;Rational(-6, 8)-3/4;Rational(0, 5)0/1(gcd(0, 5) = 5). - Le due versioni dell'addizione con
int: membro (Rational::operator+(const Rational&) const):r + 1ok,1 + rno; helperoperator+(const Rational&, const Rational&): tutte e due. Con un costruttore nonexplicitbastano le versioni a dueRational. - Errori tipici: denominatore negativo non normalizzato; dimenticare di ridurre; accessori non
const; operatore+membro e pretendere1 + r; restituire reference a un locale;num_ / den_fra interi into_double; overflow su numeri grandi. - Il test
main, righe chiave:Rational a; Rational b(5); Rational c(3, 4); Rational d(-6, 8); Rational e(2, -4);a = 0/1, b = 5/1, c = 3/4, d = -3/4, e = -1/2;Rational f = c; f = e;(copia e assegnamento generati)f = -1/2;c > d: 1, c < d: 0, c < b: 1;Rational(1, 3).to_double() = 0.333333; duetry:Rational x(1, 0)ec / alancianoDivisionByZero(uscita ricavata a mano,try/catchnon eseguibili in WebAssembly). - Perché l'invariante : rende unica la rappresentazione (così
==confronta solo i dueint) e corretto<per prodotto incrociato; senza di essaRational(1, -2)eRational(-1, 2)sarebbero due oggetti diversi per==.