Salta al contenuto
Note per Studenti Esercizio - Numeri complessi (quarto appello 2023-24, programmazione)

Esercizio - Numeri complessi (quarto appello 2023-24, programmazione)

Esame
In questa pagina 3

Testo (60 minuti, su carta). Implementare la classe Complex che rappresenta i numeri complessi. Tale classe deve fornire: (1) un solo costruttore che permette di inizializzare un nuovo oggetto a 0 (se chiamato senza argomenti), oppure a un numero reale (se chiamato con un solo argomento int), oppure a un numero complesso se chiamato con due argomenti int (il primo è la parte reale, il secondo la parte immaginaria); (2) l'overloading degli operatori matematici +, -, *; (3) l'overloading dell'operatore << che stampa in modo opportuno il numero complesso; (4) ogni altra funzione necessaria alla gestione della classe.

Teoria: 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 →, 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++ - buona interfaccia e progetti su più fileUna classe C++ ben progettata decide esplicitamente le sette operazioni essenziali (costruttore con argomenti, di default, di copia, assegnamento di copia, costruttore e assegnamento di spostamento, distruttore): se serve il distruttore perché si possiede una risorsa, servono quasi sempre anche copia e spostamento; un costruttore a un argomento definisce una conversione implicita, che si blocca con explicit; per capire quando i costruttori e il distruttore vengono chiamati si traccia una struct che stampa ogni chiamata. Un progetto su più file separa interfaccia (header .h con definizioni di classe e dichiarazioni, protetto da include guard) e implementazione (.cpp, compilati uno per uno, mai inclusi); al compilatore si passano solo i .cpp, il main sta in un file a sé; gli errori di compilazione (sintassi, tipi, dichiarazioni mancanti) vengono dal compilatore, quelli di linking (definizione mancante, due main, nessun main) dal linker.C++ - buona interfaccia e progetti su più file →.


1. Lettura del testo

frase del testo scelta
"un solo costruttore" con 0, 1 o 2 argomenti int Complex(int re = 0, int im = 0): gli argomenti di default coprono i tre casi (Complex() = 0, Complex(3) = 3, Complex(3, 4) = 3+4i)
operatori +, -, * funzioni libere operator+(Complex, const Complex&), ricavate dagli operatori composti +=, -=, *= che sono membri (modificano l'oggetto)
<< "in modo opportuno" funzione libera su std::ostream&; formato 3+4i, 3-4i, 5 (immaginaria nulla), -2i (reale nulla)
"ogni altra funzione necessaria" real() e imag() (accesso in lettura, const), == e !=; nessuna copia o distruttore da scrivere: il compilatore genera operazioni corrette per due int (regola dello zero)

Perché le operazioni binarie sono funzioni libere

Con operator+ come funzione membro, a + 5 funzionerebbe (il 5 si converte in Complex grazie al costruttore non explicit) ma 5 + a no: l'operando di sinistra di un membro non subisce conversioni. Come funzione libera operator+(Complex a, const Complex& b) entrambi gli operandi si convertono: a + 5 e 5 + a danno lo stesso risultato. Per lo stesso motivo il costruttore non è explicit: un int è un numero complesso, e la conversione implicita è voluta (a differenza di vector(int), C++ - buona interfaccia e progetti su più fileUna classe C++ ben progettata decide esplicitamente le sette operazioni essenziali (costruttore con argomenti, di default, di copia, assegnamento di copia, costruttore e assegnamento di spostamento, distruttore): se serve il distruttore perché si possiede una risorsa, servono quasi sempre anche copia e spostamento; un costruttore a un argomento definisce una conversione implicita, che si blocca con explicit; per capire quando i costruttori e il distruttore vengono chiamati si traccia una struct che stampa ogni chiamata. Un progetto su più file separa interfaccia (header .h con definizioni di classe e dichiarazioni, protetto da include guard) e implementazione (.cpp, compilati uno per uno, mai inclusi); al compilatore si passano solo i .cpp, il main sta in un file a sé; gli errori di compilazione (sintassi, tipi, dichiarazioni mancanti) vengono dal compilatore, quelli di linking (definizione mancante, due main, nessun main) dal linker.C++ - buona interfaccia e progetti su più file →).

Il primo operando è passato per valore: è già una copia modificabile, quindi return a += b; non richiede una variabile temporanea (idioma "binario in termini del composto"). Il secondo per const& per evitare la copia (qui sarebbero solo due int, ma lo schema resta valido per tipi più grandi).

Il prodotto

(a+bi)(c+di)=(ac−bd)+(ad+bc)i(a + bi)(c + di) = (ac - bd) + (ad + bc)i, perché i2=−1i^2 = -1. Nel operator*= le due parti nuove si calcolano prima di assegnare: re_ = re_ * b.re_ - im_ * b.im_; seguita da im_ = re_ * b.im_ + ... userebbe il re_ già modificato e darebbe un risultato sbagliato (bug classico, e rende sbagliato anche z *= z). Con le variabili locali re e im l'ordine non conta.

Esempio a mano: (1+2i)(3−4i)=(1⋅3−2⋅(−4))+(1⋅(−4)+2⋅3)i=11+2i(1+2i)(3-4i) = (1 \cdot 3 - 2 \cdot (-4)) + (1 \cdot (-4) + 2 \cdot 3)i = 11 + 2i, come stampa il programma.

2. Il codice

complex/include/complex_number.hcpp
#ifndef COMPLEX_NUMBER_H
#define COMPLEX_NUMBER_H

#include <iosfwd>

// Numero complesso con parte reale e immaginaria intere.
// Un solo costruttore con argomenti di default copre i tre casi del testo:
//   Complex()        -> 0
//   Complex(3)       -> 3        (numero reale)
//   Complex(3, 4)    -> 3 + 4i
// Non e' explicit di proposito: Complex z = 5; e 2 + z devono funzionare (un int E' un numero complesso).
class Complex {
public:
    Complex(int re = 0, int im = 0) : re_{re}, im_{im} {}

    int real() const { return re_; }
    int imag() const { return im_; }

    // operatori composti: modificano l'oggetto, quindi sono membri; quelli binari si ricavano da questi
    Complex& operator+=(const Complex& b) { re_ += b.re_; im_ += b.im_; return *this; }
    Complex& operator-=(const Complex& b) { re_ -= b.re_; im_ -= b.im_; return *this; }
    Complex& operator*=(const Complex& b)
    {
        int re = re_ * b.re_ - im_ * b.im_;               // (a+bi)(c+di) = (ac-bd) + (ad+bc)i
        int im = re_ * b.im_ + im_ * b.re_;
        re_ = re;
        im_ = im;
        return *this;
    }

private:
    int re_;
    int im_;
};

// operatori binari: funzioni libere, cosi' la conversione implicita funziona su entrambi gli operandi
Complex operator+(Complex a, const Complex& b);
Complex operator-(Complex a, const Complex& b);
Complex operator*(Complex a, const Complex& b);
bool operator==(const Complex& a, const Complex& b);
bool operator!=(const Complex& a, const Complex& b);
std::ostream& operator<<(std::ostream& os, const Complex& z);     // 3+4i, 3-4i, 5, -2i

#endif // COMPLEX_NUMBER_H
complex/src/complex_number.cppcpp
#include "complex_number.h"
#include <ostream>

Complex operator+(Complex a, const Complex& b) { return a += b; }    // a e' una copia: si puo' modificare
Complex operator-(Complex a, const Complex& b) { return a -= b; }
Complex operator*(Complex a, const Complex& b) { return a *= b; }

bool operator==(const Complex& a, const Complex& b)
{
    return a.real() == b.real() && a.imag() == b.imag();
}

bool operator!=(const Complex& a, const Complex& b) { return !(a == b); }

std::ostream& operator<<(std::ostream& os, const Complex& z)
{
    if (z.imag() == 0) return os << z.real();                         // solo parte reale
    if (z.real() == 0) return os << z.imag() << 'i';                  // solo parte immaginaria
    return os << z.real() << (z.imag() < 0 ? '-' : '+') << (z.imag() < 0 ? -z.imag() : z.imag()) << 'i';
}
complex/src/main.cppcpp
#include <iostream>
#include "complex_number.h"

int main()
{
    Complex zero;                         // 0
    Complex real{3};                      // 3
    Complex a{1, 2};                      // 1+2i
    Complex b{3, -4};                     // 3-4i
    std::cout << "zero = " << zero << ", real = " << real << ", a = " << a << ", b = " << b << '\n';

    std::cout << "a + b = " << a + b << '\n';        // 4-2i
    std::cout << "a - b = " << a - b << '\n';        // -2+6i
    std::cout << "a * b = " << a * b << '\n';        // (1*3 - 2*(-4)) + (1*(-4) + 2*3)i = 11+2i
    std::cout << "i * i = " << Complex{0, 1} * Complex{0, 1} << '\n';   // -1
    std::cout << "(1+2i)(1-2i) = " << a * Complex{1, -2} << '\n';       // 5: il prodotto per il coniugato e' reale

    std::cout << "a + 5 = " << a + 5 << ", 5 + a = " << 5 + a << '\n';  // conversione implicita int -> Complex
    Complex z = 7;                        // 7
    z += a;                               // 8+2i
    z *= 2;                               // 16+4i
    std::cout << "z = " << z << '\n';
    std::cout << "a == Complex{1,2}: " << (a == Complex{1, 2}) << ", a != b: " << (a != b) << '\n';
    std::cout << "solo immaginario: " << Complex{0, -2} << '\n';
}

(Il file si chiama complex_number.h per non confonderlo con l'header complex.h della libreria C.)

Uscita eseguita (compilato anche per Linux con -Wall -Wextra senza avvisi):

zero = 0, real = 3, a = 1+2i, b = 3-4i
a + b = 4-2i
a - b = -2+6i
a * b = 11+2i
i * i = -1
(1+2i)(1-2i) = 5
a + 5 = 6+2i, 5 + a = 6+2i
z = 16+4i
a == Complex{1,2}: 1, a != b: 1
solo immaginario: -2i

Controlli a mano: (1+2i)+(3−4i)=4−2i(1+2i)+(3-4i) = 4-2i; (1+2i)−(3−4i)=−2+6i(1+2i)-(3-4i) = -2+6i; i⋅i=−1i \cdot i = -1; il prodotto per il coniugato è reale: (1+2i)(1−2i)=1+4=5(1+2i)(1-2i) = 1 + 4 = 5; z = 7, z += a dà 8+2i8+2i, z *= 2 dà 16+4i16+4i.

Limiti: con int il prodotto può andare in overflow (comportamento indefinito) per parti oltre circa ±46000\pm 46000; il testo impone int.

3. Errori da evitare

  • Tre costruttori (Complex(), Complex(int), Complex(int, int)) quando il testo vuole un solo costruttore: si usano gli argomenti di default.
  • operator+ membro (5 + a non compila) o con la conversione bloccata da explicit.
  • operator*= che usa re_ già aggiornato per calcolare im_.
  • operator<< membro, o che non restituisce lo stream, o che stampa con std::cout; parte immaginaria negativa stampata come 1+-2i.
  • Funzioni add(), sub() al posto degli operatori (le consegne dicono di fare l'overloading).
  • Dati membro pubblici per far funzionare operator<< o friend.

Versione ripasso

complex/include/complex_number.hcpp
#ifndef COMPLEX_NUMBER_H
#define COMPLEX_NUMBER_H

#include <iosfwd>

// Numero complesso con parte reale e immaginaria intere.
// Un solo costruttore con argomenti di default copre i tre casi del testo:
//   Complex()        -> 0
//   Complex(3)       -> 3        (numero reale)
//   Complex(3, 4)    -> 3 + 4i
// Non e' explicit di proposito: Complex z = 5; e 2 + z devono funzionare (un int E' un numero complesso).
class Complex {
public:
    Complex(int re = 0, int im = 0) : re_{re}, im_{im} {}

    int real() const { return re_; }
    int imag() const { return im_; }

    // operatori composti: modificano l'oggetto, quindi sono membri; quelli binari si ricavano da questi
    Complex& operator+=(const Complex& b) { re_ += b.re_; im_ += b.im_; return *this; }
    Complex& operator-=(const Complex& b) { re_ -= b.re_; im_ -= b.im_; return *this; }
    Complex& operator*=(const Complex& b)
    {
        int re = re_ * b.re_ - im_ * b.im_;               // (a+bi)(c+di) = (ac-bd) + (ad+bc)i
        int im = re_ * b.im_ + im_ * b.re_;
        re_ = re;
        im_ = im;
        return *this;
    }

private:
    int re_;
    int im_;
};

// operatori binari: funzioni libere, cosi' la conversione implicita funziona su entrambi gli operandi
Complex operator+(Complex a, const Complex& b);
Complex operator-(Complex a, const Complex& b);
Complex operator*(Complex a, const Complex& b);
bool operator==(const Complex& a, const Complex& b);
bool operator!=(const Complex& a, const Complex& b);
std::ostream& operator<<(std::ostream& os, const Complex& z);     // 3+4i, 3-4i, 5, -2i

#endif // COMPLEX_NUMBER_H
  • Uscita: a = 1+2i, b = 3-4i; a + b = 4-2i; a - b = -2+6i; a * b = 11+2i; i * i = -1; (1+2i)(1-2i) = 5; a + 5 = 6+2i = 5 + a; z = 16+4i.
  • Errori tipici: tre costruttori, operator+ membro, *= con re_ già aggiornato, << membro o su cout, add()/sub(), dati pubblici, overflow con int.

Esercizi su questo argomento

Teoria collegata