Laboratorio di TNDS – Lezione 6

Maurizio Tomasi

Martedì 29 Ottobre 2024

Esercizi per oggi

Metodi virtuali

Metodi virtuali

  • L’unico modo in cui il C++ implementa il polimorfismo durante l’esecuzione è attraverso i metodi virtual.

  • (I template permettono polimorfismo in fase di compilazione, e sono quindi usati in ambiti diversi).

  • Una classe derivata può ridefinire uno dei metodi della classe genitore se questo è indicato come virtual, usando la parola chiave override.

Uso di metodi virtuali

#include <iostream>

struct Animal {
  // Dichiaro `greet` come un metodo che può essere sovrascritto
  virtual void greet() const { std::cout << "?\n"; }
};

struct Dog : public Animal {
  // Chiedo al C++ di sovrascrivere `greet` tramite `override`
  void greet() const override { std::cout << "Woof!\n"; }
};

struct Cat : public Animal {
  // Chiedo al C++ di sovrascrivere `greet` tramite `override`
  void greet() const override { std::cout << "Meow!\n"; }
};

Funzionamento di virtual

#include <string>

int main(int argc, char *argv[]) {
  Animal *animal{}; // Qui abbiamo proprio bisogno di *

  // Legge il nome dell'animale dalla linea di comando
  std::string name{argc > 1 ? argv[1] : ""};
  if (name == "cat") {
    animal = new Cat();
  } else if (name == "dog") {
    animal = new Dog();
  }

  if (animal)
    animal->greet();
}

Funzionamento di virtual

  • Nel main, la chiamata ad animal->greet() non permette di capire quale metodo greet verrà usato guardando solo il codice: la scelta viene fatta a runtime, a seconda del valore di argv:

    $ ./test cat
    Meow!
    $ ./test dog
    Woof!
    $ ./test bird
    $
  • Questo meccanismo si chiama dynamic dispatch (indirizzamento dinamico). Nel caso del C++ è un dynamic single dispatch, perché la chiamata viene decisa solo in funzione del tipo dell’oggetto puntato da animal. (Altri linguaggi come Julia usano tutti i tipi dei parametri della funzione, e sono quindi più versatili).

virtual e override

La parola virtual è obbligatoria solo all’interno della classe base:

struct Animal {
  virtual void greet() const { std::cout << "?\n"; }
};

struct Dog : public Animal {
  // I can repeat "virtual" here, but it's optional
  virtual void greet() const override { std::cout << "Woof!\n"; }
};

struct Cat : public Animal {
  // You usually avoid repeating "virtual", if there is "override"
  void greet() const override { std::cout << "Meow!\n"; }
};

virtual e override

  • Per compatibilità con le vecchie versioni del C++, anche override può essere tralasciato:

    struct Dog : public Animal {
      // Ok, compila e funziona come atteso, ma NON FATELO!
      virtual void greet() const { std::cout << "Woof!\n"; }
    };
    
    struct Cat : public Animal {
      // Anche questo è ok e compila senza errori, ma NON FATELO!
      void greet() const { std::cout << "Meow!\n"; }
    };
  • Questo è comune in codici C++ particolarmente vecchi, ma oggigiorno è considerata una pessima pratica.

Uso di puntatori

Quando usare puntatori

  • Nell’Esercizio 6.1 di oggi si usano i puntatori nel seguente codice:

    Particella * p{new Particella{1., 2.}};
    Elettrone * b{new Elettrone{}};
    Particella * c{new Elettrone{}};
  • Si usano puntatori (o reference) quando avviene questo:

    1. Si crea una variabile di un tipo base (Particella)…
    2. …ma poi le si vuole assegnare una variabile di un tipo derivato (Elettrone), che non è noto in fase di compilazione.

Quando usare puntatori

  • Usando un puntatore e new, si possono specificare i due tipi Particella ed Elettrone separatamente:

    //   Type 1            Type 2
       Particella * c{new Elettrone{}};
  • Senza puntatore, non ci sarebbe questa possibilità:

    Particella c{};  // How can I specify that I want "Elettrone"?

Quando usare i puntatori

Se non ci si trova nella situazione descritta, è meglio evitare di usare i puntatori: il codice è più semplice da leggere, e non c’è possibilità di avere segmentation fault causati dall’accesso a puntatori nulli.

// Far easier and safer!
Particella a{1., 2.};
Elettrone b{};
Elettrone c{};

Esercizio 6.2 (bisezione)

Verifica dell’algoritmo

L’esercizio richiede di trovare gli zeri della funzione f(x) = 3x^2 + 5x - 2, che è un problema risolubile analiticamente: x_{1/2} = \frac{-5 \pm \sqrt{25 + 24}}6 = \begin{cases} -2,\\ \frac13. \end{cases}

Verifica dell’algoritmo

// You might even pass a reference to `Solutore & s` and call in `main`
//     test_zeroes(Bisezione{});
//     test_zeroes(Secante{});
//     test_zeroes(Newton{});
void test_zeroes() {
  Bisezione s{};
  Parabola f{3, 5, -2}; // Zeroes for this function are known: x₁ = −2, x₂ = 1/3
  s.SetPrecisione(1e-8);

  assert(are_close(s.CercaZeri(-3.0, -1.0, f), -2.0)); // Zero is within (a, b)
  assert(are_close(s.CercaZeri(-2.0, 0.0, f), -2.0));  // Zero is at a
  assert(are_close(s.CercaZeri(-4.0, -2.0, f), -2.0)); // Zero is at b

  assert(are_close(s.CercaZeri(0.0, 1.0, f), 1.0 / 3)); // Do NOT write 1 / 3 !

  cerr << "Root finding works correctly! 🥳\n";
}

Determinazione del segno

Determinazione del segno

  • Nell’algoritmo di bisezione occorre verificare quando f(a) e f(b) sono di segno concorde.

  • La scrittura

    if (f(a) * f(b) < 0) {}

    non è consigliabile, perché se f(a) o f(b) sono molto piccoli, il risultato potrebbe essere nullo. Meglio usare una funzione sign

    if (sign(f(a)) * sign(f(b)) < 0) {}

Gestione di errori

Condizioni d’errore

  • Il metodo di bisezione fallisce se le ipotesi del teorema degli zeri non valgono. Cosa fare in questo caso?

  • Ricordiamo che esistono due tipi di errori:

    • Errori dell’utente: è l’utente che ha sbagliato a usare il programma, e deve rimediare facendolo ripartire con gli input giusti;

    • Errori del programmatore: è il programmatore che deve rimediare modificando il programma e ricompilandolo.

  • Oggi implementiamo due esercizi in cui il metodo di bisezione può fallire per colpa dell’utente (esercizio 6.2) o del programmatore (esercizio 6.3). Come facciamo?

Condizioni di errore

$ ./esercizio-6.2 4 6
Errore, il teorema degli zeri non è valido nell'intervallo [4, 6]. Usa un altro intervallo

$ ./esercizio-6.3
fish: Job 1, './esercizio-6.3' terminated by signal SIGABRT (Abort)

(Se un programma termina invocando abort(), eseguendolo all’interno di QtCreator in modalità debugging si può ispezionare il valore delle variabili al momento in cui è andato in crash: molto utile per correggere il bug!)

Approcci possibili

  1. Scrivere un messaggio di errore e invocare abort(): bene se l’errore è del programmatore, male se l’errore è dell’utente! 😠

  2. Restituire un valore fissato (es., zero): molto ambiguo, come fa l’utente a sapere se la funzione si annulla veramente per x = 0 o se c’è stato un errore? 😠

  3. Accettare un parametro aggiuntivo bool &found (reference!) per CercaZeri:

    double CercaZeri(double xmin, double xmax, const FunzioneBase * f, bool &found);

    (In alternativa si può dichiarare found variabile membro di Solutore).

Segnalare l’errore

Con il terzo approccio è possibile differenziare la “reazione” all’errore nel main dei due esercizi

// main() dell'esercizio 6.2 (errore dell'utente):

double xmin{stod(argv[1])}, xmax{stod(argv[2])};
bool found;
double x{bisezione.CercaZeri(xmin, xmax, f, found)};
if (! found) {
    fmt::println("Errore, il teorema degli zeri non è valido nell'intervallo "
                 "[{}, {}]. Usa un altro intervallo", xmin, xmax);
    return 1;
}

// main() dell'esercizio 6.3 (errore dell'utente):

double xmin{};  // Formula matematica implementata dal programmatore;
double xmax{};  // Idem
bool found;
double x{bisezione.CercaZeri(xmin, xmax, f, found)};
if (! found) {
    abort();
}

Uso di std::expected (1/5)

  • Con il C++23 è stato introdotto il tipo std::expected, che è stato mutuato da linguaggi come Rust e OCaml. (Nella compilazione bisogna quindi usare -std=c++23, non -std=c++20!)

  • Il template std::expected<T, U> rappresenta il concetto “di solito questa variabile è del tipo T, ma se c’è un errore allora è del tipo U”:

    #include <expected>
    
    // If everything is ok, `val` is a `double`; if there is an error,
    // `val` is a string (presumably, an error message).
    std::expected<double, string> val;

Uso di std::expected (2/5)

  • Una variabile std::expected può essere inizializzata usando un valore del primo tipo (T) senza bisogno di sintassi particolari:

    std::expected<double, string> val{3.5};  // Initialized with a double
  • Per inizializzarla al valore “erroneo”, bisogna usare la funzione std::unexpected:

    std::expected<double, string> val{std::unexpected("Errore!")};

Uso di std::expected (3/5)

  • Nel main() è laborioso definire il tipo della variabile che riceve il risultato:

    std::expected<double, string> result{bisezione.CercaZeri()};
  • Il C++ fornisce (a partire dal C++11) la keyword auto, che dice al compilatore: “sai benissimo tu qual è il tipo di ritorno, quindi non farmelo specificare!”. Quindi possiamo scrivere:

    // Much easier!
    auto result{bisezione.CercaZeri()};

Uso di std::expected (4/5)

  • Si controlla se la variabile è “giusta” o “sbagliata” col metodo has_value():

    auto result{bisezione.CercaZeri()};
    if (result.has_value()) {
       // Everything is ok!
    } else {
       // ERROR!
    }
  • C’è anche la comoda scorciatoia di usare result come una variabile booleana:

    if (result) {
       // Everything is ok!
    }

Uso di std::expected (5/5)

  • Il metodo value() estrae il valore giusto, il metodo error() il valore erroneo:

    if (result) {
       double true_value{result.value()};
       // …
    } else {
       fmt::println(stderr, "Error: {}", result.error());
    }
  • Invece di value() si può usare l’operatore di deferenziazione *result:

    double true_value{*result};

std::expected nella bisezione

std::expected<double, string>  // Ideally we return a `double`, unless there are problems
Bisezione::CercaZeri(double xmin,
                     double xmax,
                     const FunzioneBase * f) {
    double signfa{sign(f->Eval(xmin))}, signfb{sign(f->Eval(xmax))};
    if(signfa == 0) return xmin;
    if(signfb == 0) return xmax;
    if(signfa * signfb > 0) {
        return std::unexpected(fmt::format("Invalid range [{}, {}]", xmin, xmax));
    }

    // ...
    return x;  // `x` is a boring `double`
}

std::expected nel main

  • Nel main dell’esercizio 6.2 stampiamo l’errore in maniera user-frendly:

    auto result{bisezione.CercaZeri(xmin, xmax, f)};
    if(! result) {
        fmt::println(stderr, "Error: {}", result.error());
        return 1;
    }
    double x{result.value()};
  • Nell’esercizio 6.3 invece chiamiamo abort() (in <cstdlib>):

    auto result{bisezione.CercaZeri(xmin, xmax, f)};
    if(! result) { abort(); }
    double x{result.value()};

Esercizio 6.4

  • Nell’esercizio 6.4 si illustra un modo alternativo di implementare la bisezione, che non usa il polimorfismo.

  • Questo approccio è importante da imparare:

    1. È applicabile al 99% dei casi della vita reale;
    2. Ha preso il sopravvento rispetto alla programmazione OOP usata in ROOT, ed è usato in molte librerie di calcolo numerico moderne (Armadillo, Eigen, …);
    3. Il codice è più veloce da scrivere e semplice da leggere;
    4. È anche più veloce da eseguire.