Programmazione Scientifica++: Lezione 6

Costanti, Aritmetica dei Puntatori e Introduzione alle Classi

Docente

Argomenti di Oggi

  • Costanti in C++
    • La Regola Destra-Sinistra
  • Ancora su Puntatori e Array
    • Aritmetica dei puntatori e salti in memoria
  • Interfaccia delle Funzioni
    • Passaggio per Valore vs Reference vs Puntatore
    • Il Santo Graal: Costanti per Reference
  • Introduzione alle Classi in C++
    • Oggetti, Costruttori e Access Modifiers (public/private)

Costanti in C++

Il C++ permette di assicurarsi che il valore di una variabile non cambi mai all’interno del suo scope tramite la keyword const. È fondamentale come contratto di sicurezza (es. il learning_rate di una Rete Neurale).

int main() {
    const double learning_rate = 0.01;
1    // learning_rate = 0.05;
    
2    // const int epochs;
    
    return 0;
}
1
ERRORE DI COMPILAZIONE! La variabile è in sola lettura (read-only).
2
ERRORE! Le costanti devono SEMPRE essere inizializzate quando dichiarate. Non si possono riempire dopo.

Leggere il C++: Regola da Destra a Sinistra

Leggere i puntatori costanti in C++ crea molta confusione. Il trucco è leggere sempre la dichiarazione da destra verso sinistra.

Puntatore Costante:

int * const b = &a;

Lettura (da destra a sinistra): - b - const: è costante - *: puntatore - int: a un intero

Puntatore a Costante:

const int * b = &a;

Lettura (da destra a sinistra): - b - *: è un puntatore - int: a un intero - const: costante

Note

Imparare questo trucco ti salva ore di debug e di errori incomprensibili del compilatore.

1. Puntatore Costante (int * const)

Il puntatore stesso non può cambiare bersaglio, ma puoi usarlo per modificare il valore a cui punta!

int main() {
    int a = 1;
    int c = 3;
    
1    int * const b = &a;
    
2    *b = 5;
    
3    // b = &c;
    
    return 0;
}
1
b è un puntatore COSTANTE ad a. Il “tubo” è fisso.
2
OK! Posso usare il tubo per cambiare l’acqua (il valore puntato).
3
ERRORE! Non posso staccare il tubo e collegarlo a c.

2. Puntatore a una Costante (const int *)

Il bersaglio è considerato intoccabile, ma il puntatore può muoversi altrove.

int main() {
    int a = 1; 
    int c = 3;
    
1    const int * b = &a;
    
2    b = &c;
    
3    // *b = 5;
    
    return 0;
}
1
L’intero è considerato costante attraverso il puntatore b.
2
OK! Posso spostare lo sguardo del puntatore e farlo puntare a c.
3
ERRORE! Non posso modificare i dati usando b.

3. L’Accesso più Restrittivo (const float* const)

Unisce entrambe le restrizioni: Non puoi cambiare né il puntatore né il valore puntato! È l’accesso in sola lettura più sicuro del C++.

int main() {
    float a = 1.0f;
    float c = 3.0f;
    
1    const float * const b = &a;
    
2    // *b = 5.0f;
3    // b = &c;
    
    return 0;
}
1
Puntatore costante a float costante.
2
ERRORE! Non si tocca il valore.
3
ERRORE! Non si sposta il puntatore.

Array e Puntatori

In C++ il nome di un array decade (decay) a un puntatore al suo primo elemento appena lo si usa in un’espressione. Non è un puntatore — la slide sul sizeof(), fra due, mostra la differenza — ma quasi sempre si comporta come tale.

#include <iostream>

int main() {
    int v3[] = { 10, 20, 30, 40, 50 }; 
    
1    int* d = v3;
    
2    std::cout << "d punta a: " << *d << "\n";
    
    return 0;
}
1
Assegniamo l’array v3 al puntatore d. Non serve usare & perché v3 è GIÀ l’indirizzo del primo elemento!
2
Stampa 10.

Aritmetica dei Puntatori

Cosa succede se aggiungiamo o sottraiamo numeri a un puntatore? Il puntatore si sposta lungo la memoria RAM!

#include <iostream>

int main() {
    int v3[] = { 10, 20, 30, 40, 50 }; 
    int* d = v3;
    
1    d++;
2    std::cout << "*d: " << *d << "\n";
    
3    d = d + 2;
4    std::cout << "*d: " << *d << "\n";
    
    return 0;
}
1
Avanza di 1 “posizione”.
2
Stampa 20.
3
Salta in avanti di altre 2 posizioni.
4
Stampa 40.

Salti in Memoria e sizeof()

Se avanzo un puntatore a int di 1 (d++), di quanti byte si sposta fisicamente nella RAM? Dipende dal tipo di dato!

Tipo Memoria (Tipica) Salto in RAM per ptr++
char 1 byte Salta 1 byte
short 2 bytes Salta 2 bytes
int / float 4 bytes Salta 4 bytes
double 8 bytes Salta 8 bytes

Se d contiene l’indirizzo 0x7ffc3a5b1e40, fare d++ lo farà puntare a 0x7ffc3a5b1e44 (perché un int pesa 4 byte). L’aritmetica dei puntatori gestisce questa matematica automaticamente!

Note

La funzione sizeof(tipo) restituisce esattamente quanti byte occupa un tipo sulla tua macchina!

Il Pericolo dell’Aritmetica

L’aritmetica non ha confini. Il C++ ti permette allegramente di superare la fine dell’array ed esplorare l’ignoto!

#include <iostream>

int main() {
    int v3[] = { 10, 20, 30 }; // Array di 3 elementi
    int* d = v3;
    
1    d = d + 100;
    
2    std::cout << "Dato ignoto: " << *d << "\n";
    
    return 0;
}
1
Saltiamo di 100 posizioni oltre il limite dell’array. Al compilatore non interessa!
2
Risultato? O stampa spazzatura presa da un’altra zona della memoria del vostro stesso processo (di un altro programma no: il sistema operativo non ve lo permette), oppure, se l’indirizzo esce dalle pagine assegnate, il programma esplode in un Segmentation Fault (Core Dumped). È undefined behaviour: non è detto che ve ne accorgiate subito.

Operazioni tra Puntatori

Puoi anche sottrarre due puntatori che guardano allo stesso array per calcolare la loro “distanza” in termini di elementi.

#include <iostream>

int main() {
    int v[] = { 1, 2, 3, 4, 5, 6, 7 };
    
    int* inizio = &v[0]; // Oppure solo: inizio = v;
    int* fine   = &v[4]; // Punta al quinto elemento (5)
    
1    int distanza = fine - inizio;
    
    std::cout << "Distanza: " << distanza << " elementi\n";
    
    return 0;
}
1
Sottraendo i puntatori, il risultato è un intero che rappresenta quanti “elementi” ci sono di mezzo (in questo caso 4).

Interfacce delle Funzioni: Ripasso

Il meccanismo è quello della Lezione 5; qui lo rivediamo su un caso che vi servirà davvero. Uno scheduler dimezza il learning rate a fine epoca:

#include <iostream>

1void decay(double eta) {
    eta = eta * 0.5;
}

int main() {
    double learning_rate = 0.2;
    decay(learning_rate);
2    std::cout << "eta = " << learning_rate << "\n";
    return 0;
}
1
Passaggio per valore: decay riceve una copia indipendente in RAM.
2
Stampa ancora 0.2. Lo scheduler ha dimezzato la sua copia, e l’ha buttata uscendo dalla funzione: il learning rate vero non si è mosso di un ε.

Passaggio per Reference

Basta una & perché eta sia un alias del learning rate vero, senza copie.

#include <iostream>

1void decay(double& eta) {
    eta = eta * 0.5;
}

int main() {
    double learning_rate = 0.2;
    decay(learning_rate);
2    std::cout << "eta = " << learning_rate << "\n";
    return 0;
}
1
Una sola &, e cambia tutto.
2
Stampa 0.1: adesso lo scheduler ha davvero schedulato.

Note

Nel Laboratorio 10 scriverete l’interfaccia Scheduler per davvero. Il fixedScheduler del framework tiene un puntatore all’Optimizer proprio per questo motivo: per poter mettere le mani sul learning rate di qualcun altro.

Gestire Array nelle Funzioni

Come si passa un Array a una funzione per elaborarne i dati?

// Usiamo const double* per leggere l'array in sicurezza
// Usiamo double& per salvare il risultato
1void computeLoss(const double* predictions, int nData, double& loss) {
    loss = 0.;
    for(int i=0; i<nData; ++i) {
        loss += predictions[i]; 
    }
    loss /= nData; 
}

int main() {
    double batch_preds[] = { 1.2, 0.9, 1.34, 1.67 };
    double error_finale;
2    computeLoss( batch_preds, 4, error_finale );
    return 0;
}
1
La funzione non ritorna nulla (void). L’output è scritto nella variabile passata per reference loss.
2
Dobbiamo dichiarare error_finale vuoto prima di poterlo riempire chiamando la funzione.

Un problema di Intuizione

Il metodo appena visto è Anti-intuitivo. Leggendo computeLoss(a, b, c), chiunque non abbia scritto la funzione non sa capire quale sia l’input e quale l’output!

Facciamo restituire alla funzione direttamente il valore calcolato:

1double computeLoss(const double* predictions, int nData) {
    double loss = 0.;
    for(int i=0; i<nData; ++i) {
        loss += predictions[i]; 
    }
2    return loss / nData;
}
1
Firma chiara: due input in entrata, un double in uscita.
2
Restituisce il valore pulito!

Important

Ma cosa succede se volevamo calcolare sia la Loss che l’Accuratezza in un colpo solo? Una funzione in C++ può restituire return di un solo valore alla volta!

E se avessimo un tipo Custom?

Immagina se potessimo dire al C++ di creare una nostra “Scatola” contenente due valori, e potessimo restituire l’intera scatola con il return!

1Result computeMetrics(const double* predictions, int nData) {
    Result res; 
    
    // ... finti calcoli ...
    res.loss = 0.5;
    res.accuracy = 95.0;
    
2    return res;
}
1
Il tipo di ritorno è un fantomatico tipo Result creato da noi.
2
Restituiamo due pezzi di informazione all’interno di un unico pacchetto!

Questo ci porta a uno dei pilastri del C++…

Le Classi in C++

La Programmazione Orientata agli Oggetti (OOP) ci permette di unire dati e funzioni all’interno di “Oggetti”. Una classe è il progetto (il “blueprint”) per creare questi oggetti.

  • Attributi (Dati): Le caratteristiche dell’oggetto.
  • Metodi (Comportamento): Cosa l’oggetto sa fare con i propri dati.

Esempio (il nostro): Classe: DenseLayer Attributi: la matrice dei pesi, il numero di neuroni. Metodi: forward(), backward().

Sintassi di una Classe

#include <iostream>

class Result {
1public:
    double loss;
    double accuracy;
    
2    void print() {
        std::cout << "Loss: " << loss << " Acc: " << accuracy << "%\n";
    }
3};

int main() {
4    Result r1;
    r1.loss = 1.2;
    r1.accuracy = 85.5;
    r1.print();
    
    return 0;
}
1
public: significa che i membri successivi sono accessibili dal main.
2
Una funzione che vive dentro l’oggetto! Ha accesso automatico agli attributi.
3
IMPORTANTISSIMO: Ricordati sempre il ; alla fine della definizione della classe!
4
Stiamo creando un’istanza (oggetto) della classe.

Incapsulamento: public vs private

In C++, la buona norma è “nascondere” i dati all’utente (private) e consentire l’accesso solo tramite metodi controllati (public).

class ContoBancario {
1private:
    double saldo;

public:
2    void deposita(double soldi) {
        if (soldi > 0) saldo += soldi;
    }
    
3    double leggi_saldo() { return saldo; }
};
1
Nessuno da fuori può fare conto.saldo = 1000000; e arricchirsi illecitamente!
2
Metodo per alterare lo stato interno in modo sicuro (con controlli).
3
Getter: Metodo per leggere i dati protetti senza poterli alterare.

Costruttori (Constructors)

Come facciamo ad assicurarci che la variabile saldo inizi esattamente a zero alla nascita dell’oggetto, senza “spazzatura” di memoria? Con i Costruttori!

  • Il costruttore è una funzione speciale con lo stesso nome della classe.
  • Non ha tipo di ritorno (neanche void).
  • Viene eseguito automaticamente all’istante in cui l’oggetto viene creato.
class ContoBancario {
private:
    double saldo;

public:
1    ContoBancario() {
        saldo = 0.0;
        std::cout << "Conto creato con successo!\n";
    }
};
1
Costruttore di default: viene chiamato quando si scrive ContoBancario c; e mette saldo a zero. Senza, saldo conterrebbe spazzatura.

Costruttori con Parametri

Possiamo passare argomenti al costruttore nel momento in cui creiamo l’oggetto.

class Result {
private:
1    double loss_;
    double accuracy_;

public:
    // Costruttore con parametri
2    Result(double l, double a) {
        loss_ = l;
        accuracy_ = a;
    }
    
    double getLoss() { return loss_; }
};

int main() {
3    // Result r1;
    
4    Result r2(1.1, 0.95);
    return 0;
}
1
Convenzione molto usata: aggiungere un underscore _ alla fine dei nomi delle variabili private.
2
Inizializziamo l’oggetto con dati forniti dall’esterno. Nota i double passati per valore: il const-reference della Lezione 5 serve per gli oggetti pesanti, su un double costerebbe più di quanto risparmia.
3
ERRORE! Non abbiamo fornito i parametri previsti.
4
Ok! Chiamiamo il costruttore passando i dati necessari per la “nascita” dell’oggetto.

Class vs Struct

Una domanda frequentissima: qual è la differenza in C++ tra struct e class?

Nessuna (quasi)! Fanno esattamente la stessa cosa e supportano le stesse identiche feature (metodi, costruttori, ecc.). L’unica, minuscola differenza è la visibilità di default:

  • Se usi struct: Tutto ciò che scrivi è public di default (fino a quando non scrivi private:).
  • Se usi class: Tutto ciò che scrivi è private di default (fino a quando non scrivi public:).

Note

Si usa struct quando si vuole creare solo un contenitore per trasportare pacchetti di variabili grezze senza molta logica (“Plain Old Data”). Si usa class quando ci sono comportamenti complessi e si vuole proteggere la memoria.

Esercizio in Classe (15 min)

La classe Particella!

  1. Scrivi un programma C++ che definisca una classe Particella.
  2. La classe deve avere due attributi privati: massa_ e velocita_.
  3. Crea un costruttore che accetti in input due double e li assegni agli attributi privati.
  4. Crea un metodo pubblico chiamato calcola_energia() che restituisca l’energia cinetica \(\frac{1}{2}mv^2\).
  5. Nel main(), istanzia un oggetto di tipo Particella passandogli massa=2.0 e velocità=10.0.
  6. Richiama il metodo calcola_energia() sull’oggetto appena creato e stampa il risultato!

(Provate a compilarlo dal terminale!)

Soluzione dell’Esercizio: La Classe

Ecco la nostra definizione:

#include <iostream>

class Particella {
1private:
    double massa_;
    double velocita_;

2public:
    // Costruttore
3    Particella(double m, double v) {
        massa_ = m;
        velocita_ = v;
    }
    
    // Metodo (Comportamento)
4    double calcola_energia() {
        return 0.5 * massa_ * velocita_ * velocita_;
    }
};
1
Incapsulamento: proteggiamo le variabili dalle alterazioni esterne.
2
Interfaccia: mostriamo al mondo solo gli strumenti permessi.
3
Inizializziamo i dati.
4
I metodi della classe hanno accesso diretto ai dati privati.

Soluzione dell’Esercizio: Il Main

E ora mettiamola all’opera!

int main() {
    // 1. Creazione (Istanziazione) dell'Oggetto
1    Particella protone(2.0, 10.0);
    
2    // std::cout << protone.massa_;
    
    // 2. Chiamata al metodo tramite l'operatore punto (.)
3    double e = protone.calcola_energia();
    
    std::cout << "Energia della Particella: " << e << " Joule\n";
    
    return 0;
}
1
L’oggetto “nasce” sano ed integro.
2
ERRORE DI COMPILAZIONE! massa_ è privata, il main non può sbirciarci dentro.
3
Usiamo la “dot notation” (operatore .) per far agire l’oggetto. Restituisce 100.0.

Esercizi lampo: cosa stampa?

Due minuti, senza portatile. In entrambi la risposta istintiva è sbagliata.

1.

#include <iostream>

void f(int v[]) { std::cout << sizeof(v) << "\n"; }

int main() {
    int v[5];
    std::cout << sizeof(v) << "\n";
    f(v);
}

2.

int v[] = {10, 20, 30, 40, 50};
int* p = v + 3;
std::cout << p - v << " " << *(v + 1) << " " << 2[v] << "\n";

Esercizi lampo: le risposte

1. Stampa 20 e poi 8. Nel main v è un array di 5 interi e sizeof lo sa: 20 byte. Passato a f, l’array decade a int* e sizeof misura il puntatore: 8 byte su una macchina a 64 bit. È la dimostrazione che un array non è un puntatore — lo diventa attraversando la chiamata, e la lunghezza si perde per strada. Per questo computeLoss riceve anche nData. Buona notizia: g++ -Wall in questo caso vi avvisa, con 'sizeof' on array function parameter 'v' will return size of 'int*'.

2. Stampa 3 20 30. I primi due sono aritmetica dei puntatori normale. Il terzo è 2[v]: sembra un errore di battitura e invece compila, perché a[i] in C++ è definito come *(a + i) — e la somma è commutativa, quindi 2[v] è *(2 + v), cioè v[2]. Non scrivetelo mai, ma sapere perché funziona vuol dire aver capito cosa sono davvero le parentesi quadre.