Teoria Fondamenti di Programmazione
Introduzione alla OOP
La Programmazione Orientata agli Oggetti (Object-Oriented Programming, OOP) è un paradigma di programmazione che permette di definire oggetti software in grado di interagire gli uni con gli altri attraverso lo scambio di messaggi.
A differenza della programmazione procedurale (come il C puro), dove i dati e le funzioni sono separati, nella OOP i dati (attributi) e le funzioni che operano su di essi (metodi) sono raggruppati in un'unica entità: la Classe.
I 4 Pilastri Fondamentali
La potenza della OOP risiede in quattro principi cardine che permettono di scrivere codice modulare, riutilizzabile e manutenibile:
-
1. Incapsulamento e Information Hiding
È il meccanismo che racchiude dati e metodi all'interno di una classe, nascondendo i dettagli di implementazione all'esterno.
L'Information Hiding (occultamento delle informazioni) è la pratica di rendere inaccessibili i dati interni (usandoprivate), obbligando l'utente a interagire con l'oggetto solo attraverso un'interfaccia pubblica ben definita (metodipublic). Questo protegge l'integrità dei dati e disaccoppia l'interfaccia dall'implementazione. -
2. Astrazione
Consiste nel semplificare la realtà complessa, concentrandosi solo sulle qualità essenziali di un'entità per il contesto specifico, ignorando i dettagli superflui. In C++, l'astrazione si realizza definendo classi che rappresentano concetti generali (spesso tramite Classi Astratte). -
3. Ereditarietà
Permette di creare nuove classi (classi derivate o figlie) partendo da classi esistenti (classi base o padri). La classe figlia eredita attributi e metodi della classe padre, potendo aggiungerne di nuovi o modificarne il comportamento. Questo favorisce il riutilizzo del codice. -
4. Polimorfismo
Dal greco "molte forme", è la capacità di un oggetto di assumere forme diverse. In C++ si manifesta principalmente in due modi:- Compile-time: Overloading di funzioni e operatori.
- Run-time: Tramite l'uso di puntatori alla classe base che puntano a oggetti di classi derivate, permettendo di invocare il metodo corretto (spesso
virtual) durante l'esecuzione del programma.
Concetto Chiave: L'Information Hiding è fondamentale per l'ingegneria del software. Se cambiamo la struttura interna (privata) di una classe, il codice esterno che usa quella classe non deve essere modificato, purché l'interfaccia pubblica rimanga la stessa.
Introduzione alle Classi
Il dividere un programma in singoli moduli in cui ogni modulo ha il suo compito non ci permette di realizzare compiutamente l’astrazione di un determinato tipo, e per questo il C++ ci mette a disposizione le classi.
Tipi di Classe
La classe è uno strumento che ci permette di astrarre determinati tipi di dato. Si definisce nel seguente modo:
class NomeClasse {
// Parte privata (se non ci scriviamo nulla di default è private)
// Qui vanno i dati nascosti all'esterno (Information Hiding)
protected:
// Parte protetta (accessibile dalle sottoclassi)
public:
// Parte pubblica (interfaccia visibile a tutti)
};
I Membri di una Classe
I membri che possono far parte di una classe possono essere:
- Un tipo: Enumerazioni (
enum) o strutture definite internamente. - Un campo dati: Oggetto non inizializzato, anche di un’altra classe (attributi).
- Una funzione: Si chiama funzione membro (o metodo) e potrà essere solo dichiarata o anche definita all'interno della classe.
- Una classe: Una classe diversa definita dentro la classe a cui appartiene (Classi Annidate).
Esempio Pratico
Di seguito l'implementazione di una classe per la gestione dei Numeri Complessi (composti da parte reale e parte immaginaria).
#include <iostream>
using namespace std;
class complesso {
// Dati privati (default)
double re, im;
public:
// Funzione per inizializzare i valori
void iniz_compl(double r, double i) {
re = r;
im = i;
}
// Getter per leggere i dati
double reale() { return re; }
double immag() { return im; }
// Funzione per stampare a video
void scrivi() {
cout << '(' << re << ", " << im << ')';
}
};
int main() {
complesso c1, c2;
// Utilizzo dell'oggetto c1
c1.iniz_compl(1.0, -1.0); // Inizializzazione
c1.scrivi();
cout << endl; // Output: (1, -1)
// Utilizzo dell'oggetto c2
c2.iniz_compl(10.0, -10.0);
c2.scrivi();
cout << endl; // Output: (10, -10)
// Utilizzo tramite puntatore
complesso* cp = &c1;
cp->scrivi(); // Si usa la freccia (->) con i puntatori
cout << endl; // Output: (1, -1)
return 0;
}
Funzioni Inline
Tutte le funzioni membro di una classe sono definite come funzioni inline.
Ma cos'è una funzione Inline?
Definizione: Quando una funzione è definita come "inline", in fase di traduzione del programma, se possibile, il compilatore sostituisce la chiamata della funzione con il corpo della funzione stessa.
inline double square(double x) {
return x * x;
}
Perché "Se Possibile"?
Il compilatore non rispetta questo suggerimento quando la funzione è troppo dispendiosa a livello di memoria, "troppo grossa", o quando viene invocata molto frequentemente nel corso del programma.
Sostituire il corpo della funzione ad ogni chiamata infatti aumenterebbe di troppo le dimensioni del codice (code bloat)
Problemi
- Calo delle prestazioni.
- Aumento delle dimensioni sul disco/RAM.
Vantaggi
Evita il costo della chiamata di funzione, in particolare:
- Evita il salvataggio dell’indirizzo dell’istruzione seguente.
- Evita il passaggio dati alla funzione.
- Evita il salto all’indirizzo della funzione chiamata.
- Evita di creare lo stack frame.
- Evita il ritorno al chiamante.
Funzioni piccole con poche istruzioni, assenza di cicli, perciò le dichiariamo inline in quanto sono leggere.
Inline e Classi
Tutte le funzioni membro definite all’interno di una classe sono quindi inline di default, perciò:
- Funzioni piccole: conviene scriverle all’interno di una classe, in quanto verranno sostituite con facilità dal compilatore.
- Funzioni grandi: è meglio definirle fuori dalla classe, perché renderle inline renderebbe l’eseguibile più pesante e inefficiente (anche se comunque il compilatore potrebbe ignorare l'inline).
Ricordo che definire una funzione inline è un "consiglio" che diamo al compilatore, non un obbligo, quindi il compilatore può benissimo ignorare l’inline.
Possiamo perciò definire funzioni membro (secondo la logica descritta precedentemente) all’esterno del corpo della classe:
#include <iostream>
using namespace std;
class complesso {
public:
void iniz_compl(double r, double i); // Solo dichiarazione
double reale();
double immag();
/* ... */
void scrivi();
private:
double re, im;
};
// Definizione esterna con operatore di risoluzione di scope (::)
void complesso::iniz_compl(double r, double i) {
re = r; im = i;
}
double complesso::reale() { return re; }
double complesso::immag() { return im; }
void complesso::scrivi() {
cout << '(' << re << ", " << im << ')';
}
Bonus: Separazione File .h e .cpp
Posso dichiarare la firma della funzione membro nei file .h, e implementarla nei file .cpp
// File ......... .h
class Prova_This {
int a;
public:
void prova(int val);
};
// File ......... .cpp
int Prova_This::prova(int val) {
cout << "ID_GIOCATORE: prova1" << val;
}
Operazioni su Oggetti Classe
Un oggetto appartenente ad una certa classe viene chiamato oggetto classe o istanza della classe.
Tramite questi oggetti è possibile richiamare le funzioni membro definite per quel tipo astratto (per quella classe) e tutti i vari attributi (se pubblici).
class Complesso { ... }
int main() {
Complesso c1;
// chiamo la funzione membro che lo inizializza
c1.init_compl(1.0, 1.0);
// chiamo la funzione membro che esegue il cout
c1.scrivi();
// funzione membro che restituisce la parte immaginaria di un oggetto della classe Complesso
double imm = c1.getImmaginaria();
}
In generale le funzioni membro si comportano come le funzioni normali in C++ (sono la stessa cosa).
Costanti e Riferimenti nelle Classi
Se un campo dati di una classe è dichiarato come const (costante) oppure è un riferimento (T&), allora deve essere inizializzato nel momento esatto in cui l'oggetto viene creato.
Regola Ferrea: NON puoi assegnare un valore a questi campi dentro il corpo del costruttore (tra le graffe {}). L'unico modo è usare la Lista di Inizializzazione.
Perché?
- Un campo
constpuò essere scritto una sola volta (alla nascita). Un assegnamento successivo sarebbe illegale. - Un riferimento (
&) deve per forza riferirsi a qualcosa fin dalla nascita; non può esistere un riferimento "vuoto" o nullo. - Il corpo del costruttore viene eseguito dopo che i campi sono già stati allocati (e quindi "creati"). Arrivare al corpo è "troppo tardi".
class complesso {
const double re; // campo costante
double im; // campo normale
public:
complesso(double r = 0, double i = 0);
complesso(const complesso& c);
};
// IMPLEMENTAZIONE
complesso::complesso(double r, double i)
: re(r) // OK: Inizializzazione del campo const
{
// re = r; // ERRORE! Sarebbe un assegnamento a costante
im = i; // OK: im non è const, può essere assegnato qui
}
N.B: È possibile dichiarare riferimenti come membri di una classe, purché vengano legati a una variabile esistente tramite la lista di inizializzazione.
class A {
int& ref; // Riferimento (non inizializzato qui)
public:
// Il costruttore DEVE inizializzare 'ref'
A(int& x) : ref(x) {
// Corpo vuoto
}
};
Puntatore This
class Prova_This {
int a;
public:
// Funzione per inizializzare il campo 'a'
void init(int val) { a = val; }
};
int main() {
Prova_This p1;
p1.init(5);
}
NB: la funzione init va ad operare specificamente sul campo a dell’oggetto p1.
Definizione e Funzionamento Interno
Il puntatore THIS è un puntatore costante predefinito che contiene l’indirizzo dell’oggetto classe su cui il metodo è stato invocato.
this è quindi passato come primo argomento implicito ad una funzione membro!
NB: Quando l’oggetto viene dichiarato static il puntatore this non viene passato (inizializzato), poiché i metodi statici non sono legati a una specifica istanza.
Dietro le quinte:
La definizione base viene trasformata dal compilatore in qualcosa di simile a:
void init(Prova_this* const this, int val) { ... }
Alla chiamata, invece di p1.init(5), il compilatore esegue:
init(&p1, 5);
Questo serve per permettere al compilatore di sapere a che variabile di quale oggetto ci stiamo riferendo. Le seguenti istruzioni sono equivalenti all'interno della classe:
a = val;
this->a = val;
(*this).a = val;
Utilizzo Esplicito di This
L'uso esplicito è necessario quando la funzione deve utilizzare l’indirizzo dell’oggetto, ad esempio per restituirlo.
1. Restituzione per Riferimento (L'oggetto stesso)
Prova_This& eleva() { // Ritorna reference (&)
a *= a;
return *this; // Restituisce l'oggetto stesso
}
Restituisce l’oggetto stesso in quanto restituisce una referenza, cioè la stessa cella di memoria dell’oggetto su cui è chiamato questo metodo, ma con un nome diverso.
2. Restituzione di Puntatore
Prova_This* eleva() { // Ritorna puntatore (*)
a *= a;
return this; // Restituisce il puntatore
}
Restituisce un puntatore alla stessa cella di memoria, quindi non crea un oggetto copia.
3. Restituzione per Valore (Copia)
Prova_This eleva() { // Ritorna valore
a *= a;
return *this; // Restituisce oggetto copia
}
Restituisce un nuovo oggetto allocato in una nuova cella di memoria (nello stack o temporanea); restituisce quindi una copia dell’oggetto chiamante.
4. Disambiguazione (Shadowing)
Necessario quando la variabile passata ha lo stesso nome della variabile dichiarata nella classe.
void init(int a) {
this->a = a; // 'this->a' è il membro, 'a' è il parametro
}
Esempio di Concatenazione e Differenze di Memoria
void stampa() {
cout << a << endl;
}
int main() {
Prova_this p1;
p1.init(2);
// Il comportamento cambia se eleva() restituisce per valore o riferimento
p1.eleva().eleva();
p1.stampa();
return 0;
}
Caso 1: RIFERIMENTO (Prova_This&)
p1viene creato. Chiamoinit→p1.a = 2.- Chiamo
eleva()→p1.adiventa 4. Ritorna l'oggetto stessop1. - Richiamo di nuovo
eleva()sul risultato (che èp1) →p1.adiventa 16. - Stampa: 16.
Visto che .eleva() ritorna lo stesso p1, se richiamo .eleva() di nuovo lo sto richiamando sempre rispetto all’oggetto p1.
Caso 2: VALORE o COPIA (Prova_This)
p1viene creato. Chiamoinit→p1.a = 2.- Chiamo
eleva()→p1.adiventa 4. La funzione restituisce una copia dip1(chiamiamolatmp) che haa = 4. - Richiamo di nuovo
eleva()sul risultato (tmp). Visto che.eleva()è applicata all'oggetto copia, il campoadip1non viene variato ulteriormente. Viene variato quello ditmp(che diventa 16). - Quando si stampa
p1, stamperà: 4.
Visibilità all'interno di Classi
Regole di Visibilità
Una classe individua un campo di visibilità. Gli identificatori dichiarati all'interno di una classe sono visibili dal punto della loro dichiarazione fino alla fine della classe stessa.
class A {
int x; // visibile da qui in poi
void f() {
x = 10; // ok
}
};
Particolarità: Nel corpo delle funzioni membro sono visibili tutti gli identificatori presenti nella classe (anche quelli non ancora dichiarati fisicamente nel codice prima della funzione).
class B {
void f() {
y = 5; // anche se y è definita dopo, qui è visibile!
}
int y;
};
Shadowing (Oscuramento)
Se nella classe viene riutilizzato un identificatore dichiarato all'esterno della classe (globale), la dichiarazione fatta nella classe nasconde quella più esterna.
int x = 100; // Globale
class C {
int x = 5; // questo x nasconde quello globale
void f() {
cout << x; // stampa 5 (il membro)
// cout << ::x; // stamperebbe 100
}
};
Operatore di Risoluzione di Visibilità (::)
All'esterno della classe, i membri possono essere resi visibili mediante l'operatore :: applicato al nome della classe.
class D {
public:
void f();
};
void D::f() { } // definizione fuori dalla classe
Questo operatore si usa per:
- Le funzioni membro, quando vengono definite fuori dalla classe.
- Un tipo o un enumeratore, se dichiarati nella parte pubblica.
- Membri statici.
Attenzione: L'operatore di visibilità non può essere utilizzato per i campi dati non statici.
class G {
public:
int v;
};
// int G::v = 5; // ❌ ERRORE! v non è statico
G obj;
obj.v = 5; // ✔️ corretto: si accede tramite istanza
Classi Annidate (Nested Classes)
In C++ è possibile dichiarare una classe all'interno di un'altra: si parla di classe annidata.
La classe interna diventa membro della classe esterna, ma per riferirsi ai suoi elementi dall'esterno è necessario utilizzare due operatori di risoluzione di visibilità (::), indicando prima la classe contenitrice e poi la classe annidata (es. Esterna::Interna::funzione).
Regola Importante: La presenza di una classe annidata non modifica le normali regole di accesso: la classe annidata non ha privilegi speciali sulla classe esterna e viceversa. Pertanto, entrambe non possono accedere ai membri privati dell'altra, salvo espliciti meccanismi come friend.
class Esterna {
private:
int x;
public:
class Interna {
void g() {
// x = 10; // ❌ ERRORE: x è privata in Esterna
}
};
void h() {
Interna i;
// i.y = 20; // ❌ ERRORE: y è privata in Interna
}
};
// Definizione esterna di membri della classe annidata
class Esterna::Interna {
private:
int y;
};
Modularità e Ricompilazione
L'utilizzo delle classi ci permette di scrivere programmi modulari, separando nettamente l'interfaccia (cosa fa la classe) dalla sua realizzazione (come lo fa).
Regole di Ricompilazione
Quando si modifica la parte dati di una classe (gli attributi privati nel file .h), si rende necessaria la ricompilazione di tutti i moduli che includono quel file header.
Perché? Il compilatore deve conoscere la dimensione esatta dell'oggetto (layout di memoria) per allocarlo nello stack. Se aggiungi un membro privato, la dimensione cambia. Per questo motivo, in C++ non è possibile separare al 100% la struttura interna (nascosta) dall'interfaccia, poiché i membri privati devono essere visibili al compilatore nel file .h .
Performance di Build: La modifica di un file .h è costosa (ricompila tutto). La modifica di un file .cpp è economica (ricompila solo quel file) .
Esempio Completo Multi-file
Di seguito l'implementazione della classe complesso suddivisa in tre file, che mostra anche l'uso del Method Chaining tramite la funzione scala.
// file complesso.h (INTERFACCIA)
class complesso {
double re, im; // Dati privati
public:
void iniz_compl(double r, double i);
double reale();
double immag();
void scrivi();
// Restituisce un riferimento per permettere il chaining
complesso& scala(double s);
};
// file complesso.cpp (REALIZZAZIONE)
#include <iostream>
#include "complesso.h"
using namespace std;
void complesso::iniz_compl(double r, double i) {
re = r;
im = i;
}
double complesso::reale() { return re; }
double complesso::immag() { return im; }
void complesso::scrivi() {
cout << '(' << re << ", " << im << ')';
}
// Funzione che modifica l'oggetto e lo restituisce
complesso& complesso::scala(double s) {
re *= s;
im *= s;
return *this; // Restituisce l'oggetto stesso per riferimento
}
// file main.cpp (CLIENTE)
#include <iostream>
#include "complesso.h"
using namespace std;
int main() {
complesso c1;
c1.iniz_compl(1.0, -1.0);
// Chiamata standard
c1.scala(2);
c1.scrivi();
cout << endl; // Output: (2, -2)
complesso c2;
c2.iniz_compl(1.0, -1.0);
// METHOD CHAINING:
// 1. c2.scala(2) modifica c2 e restituisce un riferimento a c2
// 2. .scala(2) viene chiamato sul risultato (cioè su c2 stesso)
c2.scala(2).scala(2);
c2.scrivi();
cout << endl; // Output: (4, -4)
return 0;
}
Funzioni Globali
Sono funzioni che non sono membro di nessuna classe, non possono accedere alla zona privata delle classi e non possono sfruttare il puntatore this.
Sono accessibili da qualsiasi punto del programma. Solitamente vengono dichiarate in un file header e definite in un file sorgente .
#include <iostream>
#include "complesso.h"
// include il file header contenente la dichiarazione della classe complesso
// metodi disponibili: iniz_compl, reale, immag, scrivi
using namespace std;
// Funzione globale somma
complesso somma(const complesso& a, const complesso& b) {
complesso s;
// Deve usare i metodi pubblici (getter) perché non ha accesso ai membri privati
s.iniz_compl(a.reale() + b.reale(), a.immag() + b.immag());
return s;
}
int main() {
complesso c1, c2, c3; // dichiarazione oggetti complessi
// definizione dei primi due oggetti
c1.iniz_compl(1.0, -1.0);
c2.iniz_compl(2.0, -2.0);
// richiamo della funzione globale
c3 = somma(c1, c2);
c3.scrivi();
cout << endl; // Output: (3, -3)
return 0;
}
Costruttori
Definizione e Chiamata
Il costruttore è una funzione membro speciale che prende lo stesso nome della classe. Se definita, viene invocata automaticamente ogni volta che viene creata un’istanza di quella determinata classe.
// FILE HEADER --> COMPLESSO.h
class complesso {
double re, im;
public:
complesso(double r, double i); // costruttore
// ... altri metodi
};
// FILE DEFINIZIONI --> COMPLESSO.cpp
complesso::complesso(double r, double i) {
re = r; im = i;
}
// FILE MAIN --> main.cpp
int main() {
complesso c1(1.0, -1.0); // Chiamata implicita
// ERRORI: necessario inserire i valori per reale e immaginario
// complesso c2;
// complesso c3(3);
}
Costruttore di Default
È il costruttore utilizzato per inizializzare oggetti senza dover definire da subito gli argomenti (es. complesso c;). Esistono 2 meccanismi per ottenerlo:
1. Overloading del Costruttore
Definisco esplicitamente un costruttore senza parametri.
// HEADER
class complesso {
public:
complesso(); // costruttore default
complesso(double r, double i);
};
// IMPLEMENTAZIONE
complesso::complesso() { // default
re = 0; im = 0;
}
complesso::complesso(double r, double i) {
re = r; im = i;
}
// MAIN
complesso c1(1.0, -1.0); // chiama costruttore parametrico
complesso c2; // chiama costruttore default
// complesso c3(3); // ERRORE: nessun costruttore con 1 argomento
2. Argomenti di Default
Assegno valori predefiniti ai parametri del costruttore unico.
// HEADER
class complesso {
public:
// Funge sia da default (0,0), sia da 1 param (r,0), sia da 2 param (r,i)
complesso(double r = 0, double i = 0);
};
// IMPLEMENTAZIONE
complesso::complesso(double r, double i) {
re = r; im = i;
}
// MAIN
complesso c1(1.0, -1.0);
complesso c2; // (0, 0)
complesso c3(3.0); // (3.0, 0) - Qui funziona!
ATTENZIONE! Questi due meccanismi non possono essere usati contemporaneamente nella stessa classe, altrimenti il compilatore segnalerà un'ambiguità sulla chiamata complesso c;.
Costruttori e Allocazione di Memoria
Spesso un costruttore richiede l’allocazione di memoria libera (Heap) per alcuni membri dell’oggetto da creare (es. stringhe dinamiche).
class stringa {
char* str;
public:
stringa(const char s[]); // costruttore
};
// IMPLEMENTAZIONE
Stringa::stringa(const char s[]) {
// 1. Allocazione dinamica (lunghezza + terminatore)
str = new char[strlen(s) + 1];
// 2. Copia del dato
strcpy(str, s);
}
// MAIN
stringa inf("Fondamenti di programmazione");
Non appena viene eseguita la riga nel main:
- Viene allocato lo spazio nello stack per l’oggetto
inf(il puntatorestr). - Viene chiamato il costruttore passando il literal di stringa come argomento.
- Il costruttore alloca spazio sull’heap e copia il testo in quello spazio (includendo
\0). - Il puntatore membro
strpunta a questa nuova area di memoria.
Poiché il costruttore alloca esplicitamente memoria, la classe è responsabile anche della deallocazione. Ciò significa che la classe deve avere un distruttore che chiami la funzione delete[].
Costruttore per Oggetti Dinamici
I costruttori definiti per una classe vengono chiamati implicitamente anche quando si usa new. Il puntatore creato nel main riceve l’indirizzo del nuovo oggetto e l’accesso ai membri avviene tramite l’operatore freccia ->.
#include <iostream>
#include "complesso.h"
int main() {
complesso* pc1 = new complesso(3.0, 4.0);
complesso* pc2 = new complesso(3.0);
complesso* pc3 = new complesso; // Chiama costruttore di default
}
Costruttore di Copia (Copy Constructor)
Definizione e Meccanismo
Il costruttore di copia è un costruttore speciale che ha il compito di creare un nuovo oggetto inizializzandolo con i dati di un altro oggetto già esistente della stessa classe.
// Sintassi Obbligatoria
Classe(const Classe& altro);
Perché il passaggio per riferimento (&)?
È fondamentale passare l'oggetto sorgente per riferimento. Se lo passassimo per valore, il compilatore dovrebbe fare una copia dell'oggetto per passarlo alla funzione... richiamando il costruttore di copia... che richiederebbe un'altra copia... innescando una ricorsione infinita (Stack Overflow).
Quando viene invocato?
Il costruttore di copia scatta automaticamente in tre scenari precisi:
-
Inizializzazione Esplicita:
Complesso c2 = c1;oppureComplesso c2(c1); -
Passaggio di parametri per Valore:
Quando si passa un oggetto a una funzione:void func(Complesso c) { ... }. Viene creata una copia locale nello stack della funzione. -
Restituzione per Valore:
Quando una funzione restituisce un oggetto locale:return c;. Viene creata una copia temporanea per il chiamante.
Il Problema della Memoria: Shallow vs Deep Copy
La differenza critica emerge quando la classe gestisce risorse dinamiche (es. puntatori a memoria Heap).
1. La Copia Superficiale (Shallow Copy) - DEFAULT
Se non definisci esplicitamente un costruttore di copia, il compilatore ne genera uno di default che copia i membri "bit a bit". Se l'oggetto ha un puntatore char* str, verrà copiato l'indirizzo di memoria (il numero esadecimale), non la stringa puntata .
☠️ Pericolo: Shallow Copy
str: 0x500
str: 0x500
['c', 'i', 'a', 'o', '\0']
La Catastrofe (Double Free):
1. L'oggetto A esce dallo scope -> Chiama distruttore -> delete[] 0x500 (Memoria liberata).
2. L'oggetto B esce dallo scope -> Chiama distruttore -> delete[] 0x500.
3. CRASH: Si tenta di cancellare memoria che non appartiene più al programma.
2. La Copia Profonda (Deep Copy) - SOLUZIONE
Per risolvere il problema, bisogna scrivere manualmente il costruttore di copia per allocare nuova memoria e copiare i dati reali .
// Implementazione Deep Copy nella classe Stringa
Stringa::Stringa(const Stringa& sorgente) {
// 1. Calcolo quanto spazio serve guardando la sorgente
int lunghezza = strlen(sorgente.str);
// 2. Alloco un NUOVO blocco di memoria distinto
str = new char[lunghezza + 1];
// 3. Copio il contenuto (i caratteri) dalla sorgente alla destinazione
strcpy(str, sorgente.str);
cout << "Copia profonda eseguita!" << endl;
}
✅ Soluzione: Deep Copy
str: 0x500"ciao"str: 0x888"ciao"Le aree di memoria sono distinte. I distruttori operano su indirizzi diversi in sicurezza.
Strategia: Disabilitare la Copia
In alcuni casi (es. classi che gestiscono file, socket o risorse hardware uniche), la copia non ha senso o è pericolosa. Per impedire che il compilatore generi il costruttore di copia di default, si può dichiarare il costruttore di copia nella sezione private senza implementarlo .
class FileHandler {
// ...
private:
// Dichiarato privato: blocca la copia esterna
FileHandler(const FileHandler&);
public:
FileHandler();
// ...
};
void usaFile(FileHandler f); // ERRORE COMPILAZIONE: Richiede accesso al costruttore di copia
void usaFile(FileHandler& f); // OK: Passaggio per riferimento
Distruttori
Definizione e Scopo
Il distruttore è una funzione membro speciale che viene eseguita automaticamente quando un oggetto termina il suo ciclo di vita.
- Ha lo stesso nome della classe preceduto dal simbolo tilde (
~). - Non ha argomenti né valore di ritorno (non può essere sovraccaricato).
- Serve principalmente a liberare memoria (Heap) o rilasciare risorse (file, socket) acquisite dal costruttore.
Il comportamento varia in base a come l'oggetto è stato creato:
- Oggetti Automatici (Stack): Il distruttore scatta automaticamente all'uscita dal blocco
{}. - Oggetti Dinamici (Heap): Il distruttore scatta solo se si invoca esplicitamente l'operatore
delete. Se si dimentica ildelete, il distruttore non parte e si ha un memory leak.
// Esempio base: Distruttore per array dinamico
stringa::~stringa() {
// Dealloca l'array di char creato nel costruttore
delete[] str;
}
// Utilizzo nel main
int main() {
stringa* ps = new stringa("Dinamica");
// 1. Libera la memoria heap puntata da ps
// 2. Invoca automaticamente ~stringa() su quell'oggetto
delete ps;
}
Deallocazione Matrici Dinamiche
Quando una classe gestisce strutture multidimensionali allocate dinamicamente (come una matrice realizzata tramite un array di puntatori), il distruttore deve essere scritto con molta attenzione.
Non basta un singolo delete[]. Bisogna deallocare le risorse in ordine esattamente inverso all'allocazione: prima le singole righe, poi l'array dei puntatori alle righe.
class Matrice {
int** data; // Puntatore a puntatore (array di array)
int righe;
public:
Matrice(int r, int c) : righe(r) {
// 1. Allocazione array di puntatori (righe)
data = new int*[r];
// 2. Allocazione delle colonne per ogni riga
for(int i=0; i<r; i++)
data[i] = new int[c];
}
// Distruttore Complesso
~Matrice() {
// DEALLOCAZIONE (Ordine inverso)
// 1. Deallocare ogni singola riga (array di int)
for(int i=0; i<righe; i++) {
delete[] data[i];
}
// 2. Deallocare l'array principale dei puntatori
delete[] data;
cout << "Matrice deallocata correttamente." << endl;
}
};
Regole di Chiamata e Ordine di Distruzione
Esistono regole precise su quando vengono chiamati costruttori e distruttori. In generale, la distruzione avviene in ordine inverso rispetto alla costruzione (LIFO - Last In First Out).
| Tipo Oggetto | Chiamata Costruttore | Chiamata Distruttore |
|---|---|---|
| Statici | All'inizio del programma (prima del main). | Al termine del programma. |
| Automatici (Stack) | Quando si incontra la definizione. | All'uscita dal blocco {} in cui sono definiti. |
| Dinamici (Heap) | Quando si usa new. |
Solo quando si usa delete. |
| Membri | Prima del corpo del costruttore contenitore. | Dopo il corpo del distruttore contenitore. |
Esempio Traccia di Esecuzione
Vediamo l'ordine esatto di chiamate in un programma misto.
// Classe con messaggi di debug
stringa::stringa(const char s[]) {
str = new char[strlen(s) + 1];
strcpy(str, s);
cout << "C " << str << endl; // C = Costruttore
}
stringa::~stringa() {
cout << "D " << str << endl; // D = Distruttore
delete[] str;
}
int main() {
// 1. Costruzione Oggetto Dinamico (Heap)
stringa* ps = new stringa("Fondamenti di Progr.");
// 2. Costruzione Oggetto Automatico (Stack)
stringa s("Reti Logiche");
// 3. Distruzione Manuale Dinamico
delete ps;
// 4. Fine main -> Distruzione Automatica di 's'
return 0;
}
/* OUTPUT DEL PROGRAMMA:
C Fondamenti di Progr. (allocazione new)
C Reti Logiche (allocazione stack)
D Fondamenti di Progr. (chiamata delete ps)
D Reti Logiche (uscita dal main, LIFO)
*/
Funzioni Friend
Definizione e Accesso
Una funzione è definita friend (amica) di una classe quando la sua dichiarazione compare all'interno della classe preceduta dalla parola chiave friend.
Questa dichiarazione speciale permette alla funzione di accedere direttamente ai membri privati e protetti della classe, anche se la funzione non è un metodo membro (non ha il puntatore this) .
Scenario 1: Senza Friend (Accesso Limitato)
Normalmente, una funzione globale non può toccare i dati privati. Deve obbligatoriamente passare attraverso i metodi pubblici (getter).
class complesso {
double re, im; // Privati
public:
double reale() { return re; }
double immag() { return im; }
};
complesso somma(const complesso& a, const complesso& b) {
// Lento e verboso: accesso solo tramite metodi pubblici
complesso s(a.reale() + b.reale(),
a.immag() + b.immag());
return s;
}
Scenario 2: Con Friend (Accesso Privilegiato)
Dichiarando la funzione come amica, abbattiamo la barriera dell'incapsulamento per quella specifica funzione.
class complesso {
double re, im;
public:
// Dichiarazione di amicizia (può stare in private o public indifferentemente)
friend complesso somma(const complesso& a, const complesso& b);
};
// Definizione della funzione (NON serve 'complesso::' perché è globale)
complesso somma(const complesso& a, const complesso& b) {
// Ora posso accedere direttamente a .re e .im!
complesso s(a.re + b.re,
a.im + b.im);
return s;
}
Nota Tecnica: La funzione somma funziona perché, grazie a friend, vede i campi privati re e im come se fossero pubblici.
Perché e quando utilizzarle?
L'uso di friend va limitato perché rompe l'incapsulamento, ma è fondamentale in questi casi:
- Operatori: Per definire operatori binari simmetrici (es.
operator+(double, complesso)) o operatori di I/O (operator<<). - Efficienza: Per evitare l'overhead della chiamata ai metodi getter/setter quando le prestazioni sono critiche.
- Leggibilità: Per rendere il codice più pulito evitando catene di chiamate a funzioni di accesso.
Overloading degli Operatori
Concetto e Motivazione
In C++ è possibile ridefinire (overloadare) il comportamento della maggior parte degli operatori standard (+, -, *, ==, ecc.) quando vengono applicati a oggetti di classi definite dall'utente.
L'obiettivo è permettere ai nuovi tipi di dato astratti di comportarsi in modo naturale e intuitivo, simile ai tipi fondamentali.
Sintassi: La ridefinizione avviene tramite una funzione speciale il cui nome è composto dalla parola chiave operator seguita dal simbolo dell'operatore (es. operator+).
Forme diverse: Un operatore può essere definito come Funzione Membro o come Funzione Globale. Questo cambia il numero di argomenti espliciti:
- Operatore Unario (es.
!x,-x):- Membro:
operator@()(0 argomenti, agisce suthis) - Globale:
operator@(x)(1 argomento)
- Membro:
- Operatore Binario (es.
x + y):- Membro:
operator@(y)(1 argomento, il sinistro èthis) - Globale:
operator@(x, y)(2 argomenti espliciti)
- Membro:
Esempio 1: Operatore Somma come Funzione Membro
Quando l'operatore + è un membro, l'espressione c1 + c2 viene tradotta dal compilatore in c1.operator+(c2). L'operando di sinistra è l'oggetto invocante (this).
// FILE HEADER: complesso.h
class complesso {
double re, im;
public:
complesso(double r = 0, double i = 0);
// Dichiarazione Operatore Membro (un solo parametro)
complesso operator+(const complesso& altro);
void scrivi();
};
// FILE IMPLEMENTAZIONE: complesso.cpp
complesso complesso::operator+(const complesso& x) {
// re e im si riferiscono a 'this' (operando sinistro)
// x.re e x.im si riferiscono all'operando destro
complesso z(re + x.re, im + x.im);
return z;
}
// FILE MAIN: main.cpp
int main() {
complesso c1(3.2, 4);
complesso c2(c1);
// Chiamata implicita a c1.operator+(c2)
complesso c3 = c1 + c2;
c3.scrivi();
cout << endl; // Output: (6.4, 8)
return 0;
}
Esempio 2: Operatore Somma come Funzione Globale
Quando l'operatore è globale, c1 + c2 diventa operator+(c1, c2). Spesso queste funzioni devono essere dichiarate friend per accedere ai dati privati.
// FILE HEADER: complesso.h
class complesso {
double re, im;
public:
complesso(double r = 0, double i = 0);
// Dichiarazione Friend (Globale)
friend complesso operator+(const complesso& x, const complesso& y);
void scrivi();
};
// FILE IMPLEMENTAZIONE: complesso.cpp
// Non serve 'complesso::' perché non è un metodo della classe
complesso operator+(const complesso& x, const complesso& y) {
// Accesso diretto ai privati consentito grazie a friend
complesso z(x.re + y.re, x.im + y.im);
return z;
}
// FILE MAIN: main.cpp
int main() {
complesso c1(3.2, 4);
complesso c2(c1);
// Chiamata a operator+(c1, c2)
complesso c3 = c1 + c2;
c3.scrivi();
cout << endl; // Output: (6.4, 8)
return 0;
}
Note Importanti e Vincoli
Vincoli Tecnici
- Operatori Esistenti: Si possono sovraccaricare solo gli operatori già definiti nel linguaggio (non puoi inventare
**o<>). - Proprietà Fisse: Non si possono cambiare la precedenza, l'associatività o il numero di operandi dell'operatore.
- Tipo Utente: Almeno uno degli operandi deve essere un tipo definito dall'utente (classe o enum). Non puoi ridefinire
int + int.
Assegnamento (=)
L'operatore di assegnamento operator= è speciale: deve essere sempre una funzione membro (non globale) e deve restituire un riferimento all'oggetto (*this) per permettere assegnamenti multipli (a = b = c).
ATTENZIONE ALLE EQUIVALENZE PERSE:
Le versioni predefinite degli operatori garantiscono relazioni logiche (es. ++x equivale a x+=1). Quando ridefinisci gli operatori, queste equivalenze non valgono automaticamente. È compito del programmatore implementare sia operator++ che operator+= se vuole mantenere questa coerenza logica.
Risoluzione Ambiguità
Se esistono diverse versioni di un operatore o possibili conversioni di tipo (cast impliciti), il compilatore cerca la corrispondenza migliore. Se ne trova più di una valida senza un chiaro vincitore, segnala un errore di ambiguità.
Simmetria tra gli Operatori
🔹 Il Problema
Se l’operatore + è definito come metodo della classe (funzione membro):
complesso operator+(const complesso&);
Allora funziona solo quando il primo operando (quello a sinistra) è un oggetto della classe:
c1 = c1 + 3.0; // ✔️ OK: c1.operator+(3.0)
// c2 = 3.0 + c1; // ❌ ERRORE: 3.0 è un double e non ha il metodo operator+
🔹 La Soluzione
Per ottenere la simmetria (cioè far sì che a + b funzioni qualunque sia l’ordine), bisogna definire gli operatori come funzioni globali friend.
friend complesso operator+(const complesso&, const complesso&);
friend complesso operator+(const complesso&, double);
friend complesso operator+(double, const complesso&);
In questo modo, il compilatore può applicare le conversioni implicite (tramite costruttore) anche al primo operando:
c1 + 4.0; // ✔️ OK
3.0 + c1; // ✔️ OK (chiama la versione globale)
4.0 + 3.0; // ✔️ OK (aritmetica standard dei double)
Operatori Ridefinibili e Vincoli
In C++ si possono ridefinire quasi tutti gli operatori, con alcune importanti eccezioni.
NON si possono ridefinire:
- L’operatore di risoluzione di visibilità (
::). - L’operatore di selezione di membro (
.). - L’operatore di selezione di membro attraverso un puntatore a membro (
.*). - L'operatore ternario (
?:). - L'operatore
sizeof.
ATTENZIONE - Regole Obbligatorie:
-
Gli operatori seguenti devono essere ridefiniti sempre come funzioni membro (non globali):
- Assegnamento (
=) - Indicizzazione (
[]) - Chiamata di funzione (
()) - Selezione di membro tramite puntatore (
->*o->)
- Assegnamento (
-
Oltre all’operatore di assegnamento, sono predefiniti dal compilatore (se non li ridefinisci tu) anche:
- Operatore di indirizzo (
&) - Operatore di sequenza (
,)
- Operatore di indirizzo (
Operatore di Assegnamento e Aliasing
L'operatore di assegnamento (=) permette di copiare lo stato di un oggetto in un altro oggetto già esistente. A differenza del costruttore di copia (che crea oggetti da zero), qui l'oggetto di destinazione ha già delle risorse allocate che devono essere gestite.
Il Pericolo della Shallow Copy (Default)
Se la classe gestisce memoria dinamica e non ridefiniamo l'operatore =, il compilatore ne fornisce uno di default che esegue una copia membro a membro (copia dei puntatori). Questo causa due problemi gravi:
- Memory Leak: La memoria precedentemente puntata dall'oggetto di sinistra viene persa (nessuno la dealloca).
- Double Free: Entrambi gli oggetti puntano alla memoria dell'oggetto di destra. Alla distruzione, si tenterà di liberare due volte la stessa area.
void fun() {
stringa s1("oggi");
stringa s2("ieri");
// ASSEGNAMENTO DI DEFAULT (Shallow Copy)
s1 = s2;
// 1. "oggi" è perso in memoria (Leak).
// 2. s1 e s2 puntano entrambi a "ieri".
// 3. ~s2() libera "ieri".
// 4. ~s1() tenta di liberare "ieri" -> CRASH!
}
Regole per la Ridefinizione:
Per implementare correttamente l'assegnamento in classi con memoria dinamica, bisogna seguire 3 step obbligatori:
- Deallocare la memoria attuale dell'operando di sinistra.
- Allocare nuova memoria della dimensione corretta.
- Copiare i dati dall'operando di destra.
Il Problema dell'Aliasing (Auto-assegnamento)
L'Aliasing si verifica quando l'argomento implicito (this) e l'argomento esplicito (dx) sono lo stesso oggetto (es. s1 = s1;).
Cosa succede senza controllo?
Se scrivo s1 = s1; e non controllo l'aliasing:
- Eseguo
delete[] str;(distruggo i dati di s1). - Cerco di copiare da
dx.str... madxès1! - Sto leggendo memoria appena cancellata o spazzatura.
Implementazione Corretta (Safe Assignment):
// FILE .cpp
stringa& stringa::operator=(const stringa& dx) {
// 1. CONTROLLO ALIASING: Verifico gli indirizzi
if (this != &dx) {
// 2. Deallocazione memoria vecchia
delete[] str;
// 3. Nuova Allocazione
str = new char[strlen(dx.str) + 1];
// 4. Copia Profonda
strcpy(str, dx.str);
}
// 5. Ritorno per riferimento (permette concatenazione a = b = c)
return *this;
}
Ottimizzazione
Se la memoria già allocata è sufficiente per contenere la nuova stringa (o se le dimensioni coincidono), possiamo evitare la costosa operazione di delete e new, riutilizzando il buffer esistente.
stringa& stringa::operator=(const stringa& dx) {
if (this != &dx) {
// Se le lunghezze sono diverse, rialloco
if (strlen(str) != strlen(dx.str)) {
delete[] str;
str = new char[strlen(dx.str) + 1];
}
// Se la lunghezza è uguale, sovrascrivo direttamente (molto più veloce)
strcpy(str, dx.str);
}
return *this;
}
Composizione: Oggetti all'interno di Classi
Una classe (detta Principale) può contenere come membri campi dati che sono a loro volta istanze di altre classi (dette Secondarie). Questo meccanismo è noto come Composizione.
Ciclo di Vita e Ordine di Chiamata
C++ segue regole rigorose per l'ordine di costruzione e distruzione degli oggetti composti:
| Fase | Ordine di Esecuzione |
|---|---|
| Costruzione |
1. Costruttori dei membri (classi secondarie), nell'ordine esatto in cui sono dichiarati nel file .h.2. Costruttore della classe principale. |
| Distruzione |
1. Distruttore della classe principale. 2. Distruttori dei membri, in ordine inverso rispetto alla dichiarazione. |
Inizializzazione dei Membri Oggetto
Se la classe secondaria (membro) non ha un costruttore di default (ma solo costruttori con parametri), la classe principale DEVE chiamare esplicitamente il costruttore del membro utilizzando la Lista di Inizializzazione.
// FILE: record.h
#include "stringa.h"
class record { // Classe Principale
stringa nome; // Classe Secondaria (Membro 1)
stringa cognome; // Classe Secondaria (Membro 2)
// Supponiamo che 'stringa' richieda per forza un parametro char[]
public:
record(const char n[], const char c[]);
};
// FILE: record.cpp
record::record(const char n[], const char c[])
: nome(n), cognome(c) // <-- LISTA DI INIZIALIZZAZIONE OBBLIGATORIA
{
// Qui 'nome' e 'cognome' sono già stati costruiti.
// Non potrei fare: nome = n; (sarebbe un assegnamento, non costruzione)
}
// FILE: main.cpp
int main() {
record pers("Mario", "Rossi");
}
Composizione con Costruttori di Default
Se le classi secondarie possiedono un costruttore di default (o argomenti di default che lo simulano), non è obbligatorio menzionarle nella lista di inizializzazione della classe principale. Il compilatore chiamerà implicitamente il loro costruttore di default.
// FILE: stringa.h
class stringa {
char* str;
public:
// Costruttore con default: può essere chiamato come stringa()
stringa(const char s[] = "");
};
// FILE: record.h
class record {
stringa nome;
stringa cognome;
public:
// Nessun costruttore definito esplicitamente.
// Il compilatore genera un costruttore di default implicito per 'record'.
};
// FILE: main.cpp
int main() {
record pers;
// SEQUENZA AUTOMATICA:
// 1. Chiama stringa() per 'nome' (inizializzato a "")
// 2. Chiama stringa() per 'cognome' (inizializzato a "")
// 3. Esegue il corpo del costruttore di record (vuoto)
}
Array di Oggetti
Quando si crea un array di oggetti in C++, il compilatore deve inizializzare ogni singola cella. Per ogni elemento vengono automaticamente chiamati:
- Il costruttore al momento della creazione dell'array.
- Il distruttore quando l'array esce dallo scope (o viene deallocato).
La Necessità del Costruttore di Default
Se dichiari un array semplice come complesso vc[3];, il compilatore proverà a inizializzare i 3 oggetti usando il costruttore senza argomenti.
Regola Importante: Se nella classe hai definito un costruttore con parametri (es. complesso(double r, double i)), il compilatore non genera più quello di default automatico. Devi quindi fornire esplicitamente un costruttore di default (senza parametri o con argomenti di default), altrimenti la creazione dell'array fallirà .
class complesso {
public:
complesso(double r, double i); // Costruttore parametrico
complesso(); // Costruttore di default (NECESSARIO per l'array)
};
int main() {
complesso vc[3]; // Chiama 3 volte complesso()
}
Inizializzazione Esplicita
È possibile inizializzare specifici elementi dell'array usando le parentesi graffe. Gli elementi non specificati verranno costruiti usando il costruttore di default .
complesso vc[5] = {
complesso(1.1, 2.2), // vc[0]
complesso(1.5, 1.5), // vc[1]
complesso(4.1, 2.5) // vc[2]
// vc[3] e vc[4] usano il costruttore di default (0,0)
};
C++14 e Array Dinamici: Dallo standard C++14 è possibile usare l'inizializzazione a lista anche per array allocati dinamicamente con new :
stringa* v = new stringa[3] {
stringa("Fondamenti "),
stringa("di "),
stringa("Programmazione")
};
Gestione Avanzata: Membri Statici
Membri Statici
I membri dichiarati static hanno una caratteristica fondamentale: modellano informazioni globali per la classe e sono propri della classe stessa, non della singola istanza.
Ciò significa che esiste una sola copia del membro statico, condivisa da tutti gli oggetti. Se un oggetto modifica un attributo statico, la modifica è visibile a tutte le altre istanze.
1. Attributi Statici
Vanno dichiarati all'interno della classe (nel file .h), ma devono essere definiti e inizializzati nel file sorgente globale (.cpp) per allocare la memoria.
// FILE HEADER: entity.h
class entity {
int dato;
public:
entity(int n);
// Dichiarazione della variabile statica (condivisa)
static int conto;
};
// FILE SORGENTE: entity.cpp
#include "entity.h"
// Definizione e inizializzazione globale (obbligatoria)
int entity::conto = 0;
entity::entity(int n) {
conto++; // Incrementa la variabile condivisa
dato = n; // Assegna la variabile d'istanza
}
// FILE MAIN: main.cpp
int main() {
entity e1(1), e2(2);
// Accesso tramite nome della classe (preferibile)
cout << "Numero istanze: " << entity::conto << endl;
return 0;
}
// OUTPUT: Numero istanze: 2
2. Funzioni Statiche
Una funzione membro dichiarata static è una funzione che agisce sui membri statici della classe e non sulle singole istanze .
Può essere invocata senza avere un oggetto specifico, usando la sintassi NomeClasse::Metodo().
// FILE HEADER: entity1.h
class entity1 {
int dato;
static int conto;
public:
entity1(int n);
static int numero(); // Metodo statico
};
// FILE SORGENTE: entity1.cpp
int entity1::conto = 0;
entity1::entity1(int n) {
dato = n;
conto++;
}
// Definizione metodo statico
int entity1::numero() {
return conto; // OK: accede a membro statico
// return dato; // ERRORE: non può accedere a membri d'istanza
}
// FILE MAIN: main.cpp
int main() {
entity1 e1(1), e2(2);
// Chiamata tramite Classe (Standard)
cout << "Num. istanze: " << entity1::numero() << endl;
// Chiamata tramite Oggetto (Funziona, ma sconsigliato)
cout << "Num. istanze: " << e1.numero() << endl;
return 0;
}
// OUTPUT:
// Num. istanze: 2
// Num. istanze: 2
Regola Fondamentale: Una funzione membro statica non può accedere implicitamente a nessun membro non statico né usare il puntatore this. Tentare di farlo genererà un errore di compilazione del tipo: "ERROR: invalid use of member `entity2::ident' in static member function" .
Funzioni Membro Costanti (Const)
Definizione e Contratto di Immutabilità
In C++, una funzione membro const è una funzione che dichiara esplicitamente di non modificare l'oggetto su cui viene chiamata. Si dichiara aggiungendo la parola chiave const alla fine della firma della funzione (dopo le parentesi dei parametri).
La Regola Fondamentale: Gli oggetti dichiarati come costanti (const) possono invocare soltanto metodi marcati come const.
// Esempio di Errore Comune
const complesso c1(3.2, 4); // Oggetto costante
// Se il metodo reale() NON è dichiarato const:
// c1.reale(); // ERRORE DI COMPILAZIONE!
// Il compilatore non sa se reale() modificherà c1, quindi lo vieta per sicurezza.
Sintassi Corretta:
// FILE HEADER
class complesso {
double re, im;
public:
// Dichiarazione const
double reale() const;
double immag() const;
};
// FILE .CPP
double complesso::reale() const {
return re; // OK: lettura consentita
// re = 5; // ERRORE: scrittura vietata in metodo const
}
Overloading delle Funzioni Const
Il C++ permette di avere due versioni della stessa funzione che differiscono solo per il qualificatore const. Il compilatore sceglie automaticamente la versione corretta in base al tipo dell'oggetto chiamante.
- Versione
const: Chiamata da oggetti costanti (sola lettura). - Versione non-
const: Chiamata da oggetti modificabili (può restituire riferimenti modificabili).
class Vettore {
int dati[100];
public:
// 1. Versione per oggetti modificabili (R/W)
int& operator[](int i);
// 2. Versione per oggetti costanti (Read-Only)
int operator[](int i) const;
};
void func(Vettore& v, const Vettore& cv) {
v[0] = 10; // Chiama versione 1 (OK)
int x = cv[0]; // Chiama versione 2 (OK)
// cv[0] = 10; // ERRORE: versione 2 restituisce int per valore (non assegnabile)
}
Conversioni di Tipo
In C++, è possibile definire come gli oggetti della nostra classe interagiscono con altri tipi di dato, permettendo conversioni automatiche (implicite) o esplicite (casting).
1. Conversione mediante Costruttori (T → Classe)
Un costruttore che può essere chiamato con un singolo argomento funge automaticamente da convertitore implicito dal tipo dell'argomento al tipo della classe.
// FILE HEADER: complesso.h
class complesso {
double re, im;
public:
// Costruttore con argomenti di default:
// Può essere chiamato con 1 solo double (re)
complesso(double r = 0, double i = 0);
// Operatore somma (membro o friend)
complesso operator+(const complesso& x);
void scrivi();
};
// FILE MAIN: main.cpp
int main() {
complesso c1(3.2, 4), c2;
// CONVERSIONE IMPLICITA:
// 1. Il compilatore vede '2.5' (double) ma l'operatore + vuole un 'complesso'.
// 2. Cerca un costruttore complesso(double). Lo trova!
// 3. Crea un oggetto temporaneo complesso(2.5, 0).
// 4. Esegue la somma: c1 + [temporaneo].
c2 = c1 + 2.5;
c2.scrivi(); // Output: (5.7, 4)
cout << endl;
}
Parola chiave explicit: Se si vuole evitare che il compilatore esegua queste conversioni "di nascosto" (che possono causare bug se non intenzionali), si deve anteporre la keyword explicit alla dichiarazione del costruttore.
2. Operatori di Conversione (Classe → T)
È possibile definire come un oggetto della nostra classe possa essere convertito in un altro tipo (es. trasformare un Complesso in un double o int).
Sintassi: operator Tipo().
Non ha tipo di ritorno (è dedotto dal nome dell'operatore) e non prende argomenti.
// FILE HEADER: complesso.h
class complesso {
double re, im;
public:
complesso(double r, double i) : re(r), im(i) {}
// Conversione a double (es. restituisce la somma delle parti)
operator double();
// Conversione a int
operator int();
};
// FILE IMPLEMENTAZIONE: complesso.cpp
complesso::operator double() {
return re + im;
}
complesso::operator int() {
// Uso static_cast per convertire il risultato double in int
return static_cast<int>(re + im);
}
// FILE MAIN: main.cpp
int main() {
complesso c1(3.2, 4);
// Cast esplicito (stile C o C++)
cout << (double)c1 << endl; // Output: 7.2 (3.2 + 4)
cout << int(c1) << endl; // Output: 7
// Cast implicito (se non dichiarato explicit)
double d = c1; // d = 7.2
}
Literals come Oggetti (User-Defined Literals)
Tradizionalmente, i literals in C++ sono valori primitivi "puri": 10 è un int, 3.14 è un double, "ciao" è un array di char.
A partire dal C++11, è stato introdotto un meccanismo potente chiamato User-Defined Literals (UDL). Questo permette di definire suffissi personalizzati per trasformare un literal direttamente in un Oggetto di una Classe.
Sintassi e Funzionamento
Un UDL è essenzialmente una funzione speciale che il compilatore chiama quando incontra un valore seguito da un suffisso definito dall'utente (es. 12_kg).
TipoRitorno operator"" _suffisso(unsigned long long n); // Per interi
TipoRitorno operator"" _suffisso(long double n); // Per reali
Regola Importante: I suffissi definiti dall'utente devono obbligatoriamente iniziare con un underscore (_), ad esempio _km, _rad. I suffissi senza underscore sono riservati alla libreria standard (come s per i secondi o le stringhe).
Esempio: Gestione Unità Fisiche
Immaginiamo di voler gestire delle distanze evitando di confondere metri e chilometri. Possiamo creare una classe Distanza e usare i literals per istanziarla in modo intuitivo.
class Distanza {
double metri;
public:
// Costruttore explicit per evitare conversioni accidentali
explicit Distanza(double m) : metri(m) {}
double getMetri() const { return metri; }
// Operatore somma per sommare due oggetti Distanza
Distanza operator+(const Distanza& d) const {
return Distanza(metri + d.metri);
}
};
// 1. Literal per i METRI (es. 10.5_m)
Distanza operator"" _m(long double val) {
return Distanza(static_cast<double>(val));
}
// 2. Literal per i CHILOMETRI (es. 2.0_km)
// Converte automaticamente in metri!
Distanza operator"" _km(long double val) {
return Distanza(static_cast<double>(val * 1000.0));
}
int main() {
// Creazione di oggetti tramite Literals
Distanza d1 = 500.0_m; // Oggetto con 500 metri
Distanza d2 = 1.5_km; // Oggetto con 1500 metri
// Somma tra oggetti (polimorfismo operatori)
Distanza tot = d1 + d2; // Totale: 2000 metri
// Distanza d3 = 100; // ERRORE! Costruttore explicit (Type Safety garantita)
}
Literals nella Libreria Standard
Anche la libreria standard C++ fa largo uso di questo meccanismo (senza l'underscore iniziale). Per attivarli è spesso necessario usare namespace specifici.
- Stringhe:
using namespace std::string_literals;permette di usare"ciao"sper creare direttamente un oggettostd::stringinvece di unconst char*. - Numeri Complessi:
using namespace std::complex_literals;permette di scrivere3.0iper creare un oggettostd::complex. - Tempo:
using namespace std::chrono_literals;permette di scrivere10s,500ms,2h.
Input/Output Avanzato: Approfondimento Metodi
1. Metodi della classe istream (Input)
La classe istream (di cui cin è istanza) offre diversi modi per leggere i dati, a seconda se si vuole una lettura formattata o grezza.
A. Input Formattato (operator>>)
L'operatore di estrazione >> è "intelligente":
- Salta automaticamente gli spazi bianchi iniziali (spazi, tab, newlines).
- Legge finché trova caratteri validi per il tipo di destinazione.
- È type-safe (converte testo in int, float, ecc.).
B. Input Non Formattato (Carattere per Carattere)
Questi metodi non saltano gli spazi bianchi. Sono essenziali per leggere file binari o testi completi.
| Metodo | Descrizione e Differenze |
|---|---|
int get() |
Legge un solo carattere (inclusi spazi/invio). Restituisce il valore intero del carattere o EOF (-1) se lo stream è finito. Utile nei cicli while((c = cin.get()) != EOF). |
istream& get(char& c) |
Legge un carattere e lo mette in c. Restituisce lo stream stesso (per concatenare o controllare errori: if(cin.get(c))). |
istream& ignore(n, delim) |
Salta fino a n caratteri o finché non trova il delimitatore (es. '\n'). Fondamentale per pulire il buffer dopo un cin >> x prima di leggere una stringa. |
int gcount() |
Restituisce il numero di caratteri letti dall'ultima operazione di input non formattato. |
C. Input di Stringhe: get vs getline
C'è una differenza critica nel modo in cui gestiscono il carattere delimitatore (solitamente '\n').
char buffer[100];
// 1. cin.get(buffer, dim, delim)
// Legge fino al delimitatore, ma LO LASCIA nel buffer di input.
cin.get(buffer, 100, '\n');
// 2. cin.getline(buffer, dim, delim)
// Legge fino al delimitatore e LO RIMUOVE dal buffer (lo "mangia").
cin.getline(buffer, 100, '\n');
// 3. istream& read(char* s, int n)
// Legge ESATTAMENTE n byte (o fino a EOF).
// NON aggiunge il terminatore '\0' alla fine! (Per dati binari).
cin.read(buffer, 50);
Trappola comune: Usare cin >> var seguito da cin.getline(...). L'operatore >> lascia il carattere "invio" nel buffer. La successiva getline vedrà subito quell'invio e leggerà una stringa vuota. Soluzione: usare cin.ignore() in mezzo.
2. Metodi della classe ostream (Output)
Oltre all'operatore <<, la classe ostream (di cui cout è istanza) offre metodi per l'output di basso livello.
put(char c): Scrive un singolo carattere nello stream. Restituisce lo stream, permettendo la concatenazione:cout.put('A').put('B');write(const char* s, int n): Scrive esattamentenbyte dal buffers. Ignora il terminatore'\0'(quindi può scrivere anche zeri binari). È l'opposto diread.flush(): Forza la scrittura dei dati dal buffer alla periferica (es. schermo o file).endlè equivalente a'\n' + flush().
char dati[] = {'A', 'B', '\0', 'C'};
cout << dati; // Stampa "AB" (si ferma a \0)
cout.write(dati, 4); // Stampa "AB C" (include il null e C)
3. Stato dello Stream e Gestione Errori
Ogni stream mantiene un registro di stato interno (flag) per segnalare l'esito delle operazioni.
| Metodo | Significato |
|---|---|
good() |
Tutto OK. Nessun errore. |
eof() |
End Of File: raggiunta la fine dello stream (input). |
fail() |
Errore logico/formattazione (es. cercavo un intero, ho trovato lettere). Lo stream è intatto ma l'operazione è fallita. |
bad() |
Errore grave (es. disco rotto, perdita dati). Stream corrotto. |
clear() |
Resetta i flag di errore (riporta lo stream a good). Necessario per continuare a leggere dopo un errore. |
int x;
cout << "Inserisci numero: ";
cin >> x;
if (cin.fail()) {
cout << "Non hai inserito un numero!" << endl;
cin.clear(); // 1. Ripristina lo stato
cin.ignore(1000, '\n'); // 2. Svuota il buffer "sporco"
}
Controllo del Formato di Output
Il controllo del formato di input/output in C++ è gestito tramite i manipolatori. Tecnicamente, i manipolatori sono puntatori a funzioni che modificano lo stato interno dello stream.
Gli operatori di inserimento (<<) ed estrazione (>>) sono ridefiniti tramite overloading per accettare tali puntatori:
ostream& operator<<(ostream& (*pf)(ostream&));
1. Manipolatori in <iostream>
Questi manipolatori non richiedono librerie aggiuntive oltre a iostream.
| Manipolatore | Descrizione |
|---|---|
endl |
Inserisce un carattere di nuova riga ('\n') e svuota il buffer (flush).Esempio: cout << x << endl; |
dec, hex, oct |
Selezionano la base numerica per interi (Decimale, Esadecimale, Ottale). Esempio: cout << hex << 16; // Stampa "10" |
boolalpha |
Forza la stampa dei booleani come parole ("true"/"false") invece che numeri (1/0). Esempio: cout << boolalpha << true; // Stampa "true" |
noboolalpha |
Ripristina la stampa numerica dei booleani. |
2. Manipolatori in <iomanip>
Per la formattazione avanzata (spaziature, precisione), è necessario includere #include <iomanip>. Questi manipolatori prendono argomenti.
setw(int n): Imposta la larghezza del campo (width) ancaratteri per il prossimo output. Se il dato è più corto, viene riempito con spazi (o altro carattere di riempimento). Se è più lungo,setwviene ignorato (non tronca il dato).setfill(char c): Specifica il carattere da usare per il riempimento (padding) quando si usasetw. Il default è lo spazio bianco.setprecision(int n): Imposta il numero di cifre significative (totali: parte intera + decimale) per i numeri reali. Se combinato con il flagfixed, indica il numero di cifre dopo la virgola.
Regole di Persistenza (Sticky vs Non-Sticky)
Importante: Quasi tutti i manipolatori sono persistenti (Sticky): una volta applicati, modificano lo stato dello stream e valgono per tutte le operazioni successive finché non vengono cambiati.
L'Eccezione: setw() è l'unico manipolatore che ha effetto solo sulla singola istruzione di scrittura immediatamente successiva. Dopo la stampa, la larghezza del campo torna a 0 (default).
Esempio Completo
#include <iostream>
#include <iomanip>
using namespace std;
int main() {
double d = 1.564e-2; // 0.01564
int i;
bool b;
// --- TEST NUMERI REALI ---
cout << d << endl; // Default: 0.01564
cout << setprecision(2) << d << endl; // 2 cifre significative: 0.016
// setw vale solo per il prossimo cout
cout << setw(10) << d << endl; // " 0.016" (allineato a dx)
// Cambio carattere di riempimento
cout << setfill('0') << setw(10) << d << endl; // "000000.016"
// --- TEST BASI NUMERICHE ---
// Input: supponiamo di inserire 'b' (esadecimale per 11)
cin >> hex >> i;
// hex è persistente, quindi 'i' viene stampato in hex (b)
// poi switch a dec per stamparlo in base 10 (11)
cout << hex << i << '\t' << dec << i << endl;
// Input: supponiamo di inserire "12" (ottale per 10 decimale)
cin >> oct >> i;
cout << oct << i << '\t' << dec << i << endl;
// --- TEST BOOLEANI ---
// Input: supponiamo di inserire "false"
cin >> boolalpha >> b;
// boolalpha è persistente
cout << b << boolalpha << ' ' << b << endl; // Stampa "false false"
// noboolalpha ripristina 0/1, ma qui boolalpha è ancora attivo
cout << true << endl; // Stampa "true"
return 0;
}
/* OUTPUT PREVISTO:
0.01564
0.016
0.016
000000.016
b 11
12 10
false false
true
*/
Controlli sugli Stream e Stati di Errore
Ogni stream mantiene internamente una configurazione di bit, detti bit di stato, che segnalano l'esito delle operazioni di I/O. Lo stato è considerato corretto (good) solo se tutti i bit di stato valgono 0.
I Bit di Stato
| Bit | Significato | Enumeratore (ios::) |
|---|---|---|
| failbit | Posto a 1 se si verifica un errore recuperabile (es. errore di formato: cercavo un intero, l'utente ha scritto "ciao"). | ios::failbit |
| badbit | Posto a 1 se si verifica un errore grave e non recuperabile (es. perdita di dati, errore hardware). | ios::badbit |
| eofbit | Posto a 1 quando viene raggiunta la fine dello stream (End Of File) durante un tentativo di lettura. | ios::eofbit |
Funzioni di Controllo (Ispezione)
I bit possono essere esaminati tramite funzioni membro che restituiscono valori booleani:
fail(): Restituiscetruese c'è un errore (recuperabile o grave). Verifica se failbit o badbit sono a 1.bad(): Restituiscetruesolo se il badbit è a 1 (errore grave).eof(): Restituiscetruese il eofbit è a 1.good(): Restituiscetruese tutti i bit sono a 0 (nessun errore).
Nota Bene (Conversione Booleana): Quando uno stream viene valutato in un contesto booleano (es. if(cin)), viene restituito il complemento del risultato di fail().
Quindi if(cin) equivale a if(!cin.fail()).
Manipolazione dello Stato (clear)
I bit di stato possono essere modificati manualmente via software tramite la funzione clear().
void clear(iostate state = ios::goodbit);
- L'argomento è un intero che rappresenta il nuovo stato.
- Il valore di default è
0(che corrisponde aios::goodbit), quindicin.clear()azzera tutti gli errori.
È possibile impostare stati specifici combinando gli enumeratori con l'operatore OR bit a bit (|):
// Imposta sia il bit di fail che quello di eof
stream.clear(ios::failbit | ios::eofbit);
Esempio Tipico: Recupero da Errore di Input
int x;
cout << "Inserisci un intero: ";
cin >> x;
if (cin.fail()) { // Controllo se l'input era sbagliato (es. lettere)
// 1. Resetto lo stato di errore per poter usare ancora cin
cin.clear();
// 2. Ignoro i caratteri rimasti nel buffer che hanno causato l'errore
cin.ignore(100, '\n');
cout << "Errore di formato!" << endl;
}
Ridefinizione Operatori di Ingresso e Uscita
Per permettere di stampare (cout << obj) o leggere (cin >> obj) direttamente i nostri oggetti personalizzati, dobbiamo ridefinire gli operatori di shift.
// Dichiarazioni (solitamente Friend)
ostream& operator<<(ostream& os, const complesso& z);
istream& operator>>(istream& is, complesso& z);
Perché gli operatori devono essere globali?
N.B: Non possiamo definire questi operatori come membri della nostra classe (es. complesso::operator<<).
Il motivo è che l'operando di sinistra è lo stream (cout di tipo ostream o cin di tipo istream). Queste classi appartengono alla libreria standard e non possono essere modificate da noi.
Serve quindi una funzione esterna (globale) che accetti lo stream come primo parametro. Spesso si usa la keyword friend dentro la classe per permettere a queste funzioni di accedere ai dati privati in modo efficiente.
Operatore di Uscita (<<)
Serve a formattare l'oggetto come stringa sullo stream. Deve ritornare lo stream stesso per permettere il concatenamento (chaining).
ostream& operator<<(ostream& os, const complesso& z) {
// Stampa nel formato (re,im)
os << '(' << z.reale() << ',' << z.immag() << ')';
// Restituisce os per permettere: cout << z1 << " " << z2;
return os;
}
Operatore di Ingresso (>>) e Parsing
L'operatore di input è più complesso perché deve gestire la lettura formattata e il controllo degli errori. Supponiamo di voler leggere un input nel formato esatto (3.5,2).
istream& operator>>(istream& is, complesso& z) {
double re = 0, im = 0;
char c = 0;
// 1. Legge il primo carattere -> deve essere '('
is >> c;
if (c != '(') {
is.clear(ios::failbit); // Errore: formato non valido
}
else {
// 2. Legge la parte reale (double) e il separatore (char)
is >> re >> c;
// 3. Controllo separatore -> deve essere ','
if (c != ',') {
is.clear(ios::failbit);
}
else {
// 4. Legge la parte immaginaria (double) e la chiusura (char)
is >> im >> c;
// 5. Controllo chiusura -> deve essere ')'
if (c != ')') {
is.clear(ios::failbit);
}
}
}
// Se tutto è andato bene (nessun failbit settato), aggiorno l'oggetto
if (is) {
z = complesso(re, im);
}
return is;
}
Logica di Funzionamento:
- Il sistema tenta di leggere la sequenza attesa:
(→Numero→,→Numero→). - Se in qualsiasi punto trova un carattere diverso da quello atteso (es. trova 'A' invece di ','), imposta lo stato dello stream su failbit.
- Se lo stream è in stato di errore (fail), le successive letture vengono ignorate fino al ripristino (clear).
Il Preprocessore
Il preprocessore è una parte del compilatore che elabora il testo del programma prima dell’analisi del codice vero e proprio (compilazione). Non conosce la sintassi del C++, manipola solo il testo sorgente.
Il preprocessore può:
- Includere file esterni (
#include). - Espandere simboli definiti dall’utente (
#define). - Includere o escludere parti di codice in base a condizioni (compilazione condizionale:
#ifdef,#ifndef,#if,#endif).
Nota: Tutte le direttive del preprocessore iniziano con il simbolo cancelletto (#) e non richiedono il punto e virgola finale.
1. Inclusione di File Header (#include)
Questa direttiva dice al preprocessore di prendere il contenuto di un altro file e copiarlo nel punto esatto dove si trova la direttiva. Esistono due sintassi:
| Sintassi | Comportamento |
|---|---|
#include <file.h> |
Cerca il file nelle cartelle standard del sistema (librerie predefinite come iostream, vector). |
#include "file.h" |
Cerca il file prima nella cartella corrente (quella del progetto), e solo se non lo trova cerca nelle cartelle di sistema. Usato per i propri file header. |
2. Definizione di Simboli e Macro (#define)
La direttiva #define istruisce il preprocessore a eseguire una sostituzione di testo automatica.
A. Definizione di Costanti (Simboli)
Il preprocessore vede il nome definito e lo rimpiazza brutalmente con il valore specificato prima che il compilatore veda il codice.
#define KONST 42
int main() {
int x = KONST;
// Il preprocessore lo trasforma in: int x = 42;
}
B. Macro con Parametri
Le macro agiscono come "funzioni" puramente testuali. Sono molto veloci (nessun overhead di chiamata) ma pericolose se non scritte correttamente.
#define MAX(A, B) ((A) > (B) ? (A) : (B))
Perché servono tutte quelle parentesi?
Poiché è una sostituzione testuale, l'ordine delle operazioni potrebbe essere alterato dagli operatori vicini.
Esempio senza parentesi: #define MUL(A,B) A * B.
Chiamata: MUL(2+3, 4) → diventa 2+3 * 4 → 2+12 = 14 (Errato!).
Con parentesi: (2+3) * 4 = 20 (Corretto).
Compilazione Condizionale
È una tecnica che permette di includere o escludere blocchi di codice dal processo di compilazione in base a condizioni predefinite. Questo processo non è gestito dal compilatore, ma dal Preprocessore prima ancora che la compilazione inizi.
Direttive Principali
#if: Inizia un blocco condizionale. Il codice segue solo se l'espressione è vera (diversa da zero).#elif: (Else If) Controlla un'altra condizione se la precedente era falsa.#else: Alternativa se nessuna condizione precedente è vera.#endif: Termina il blocco condizionale.#ifdef: Compila se la macro è definita.#ifndef: Compila se la macro NON è definita.
Esempio 1: Versioning API
#define VERSIONE_API 2
int main() {
#if VERSIONE_API == 1
void carica_dati_v1(); // Escluso
#elif VERSIONE_API == 2
void carica_dati_v2(); // COMPILATO
#else
#error "Versione API non supportata"
#endif
return 0;
}
Esempio 2: Cross-Platform (Linux/Windows)
È possibile definire i simboli direttamente alla chiamata del compilatore (es. g++ -DLINUX main.cpp) invece che nel codice.
#ifdef LINUX
system("CLEAR");
#elif defined WINDOWS
system("CLS");
#else
cout << "OS non supportato" << endl;
exit(1);
#endif
Include Guards (Anti-Doppia Inclusione)
L'uso più importante della compilazione condizionale è evitare che un file header venga incluso più volte, causando errori di ridefinizione.
// FILE: complesso.h
#ifndef COMPLESSO_H
#define COMPLESSO_H
class complesso {
// ... contenuto ...
};
#endif // Fine del blocco
Appendice A: Classi di Memoria dei Vettori
Analisi delle diverse tipologie di allocazione per i vettori in C++.
| Caso | Tipologia | Descrizione |
|---|---|---|
| 1 | Statico | Allocato in memoria statica/globale. Lunghezza nota a tempo di compilazione.
int vett[10]; (se globale) |
| 2 | Automatico | Allocato nello Stack. Lunghezza nota a tempo di compilazione (usando costanti).
const int DIM=10; int v[DIM]; |
| 3 | Dinamico | Allocato nello Heap (new). Lunghezza nota a tempo di esecuzione.
int* v = new int[lun]; |
| 4 | Semi-dinamico (VLA) | Allocato nello Stack ma con lunghezza definita a runtime.
int v[lun]; (Estensione GCC, non standard C++ puro). |
ATTENZIONE - Vettori nelle Classi:
All'interno di una class non è possibile definire vettori semi-dinamici (Caso 4). La dimensione dei membri array deve essere nota a tempo di compilazione (costante), oppure si deve usare un puntatore e allocazione dinamica nel costruttore.
class Esempio {
int* v_dinamico; // OK: allocato nel costruttore (heap)
int v_statico[10];// OK: dimensione fissa
// int n;
// int v_errore[n]; // ERRORE DI COMPILAZIONE!
};
Appendice B: Priorità degli Operatori
Ordine di valutazione decrescente (dalla priorità più alta alla più bassa).
| Priorità | Operatore | Descrizione | Associatività |
|---|---|---|---|
| 1 (Alta) | :: | Scope Resolution | Sx → Dx |
| 2 | () [] . -> | Chiamata, Indice, Membro | Sx → Dx |
| 3 | ++ -- ! ~ * & (type) sizeof new delete | Unari, Dereferenza, Cast, Allocazione | Dx → Sx |
| 4 | .* ->* | Puntatore a membro | Sx → Dx |
| 5 | * / % | Moltiplicativi | Sx → Dx |
| 6 | + - | Additivi | Sx → Dx |
| 7 | << >> | Shift bitwise / IOStream | Sx → Dx |
| 8 | < <= > >= | Relazionali | Sx → Dx |
| 9 | == != | Uguaglianza | Sx → Dx |
| 10-12 | & ^ | | Bitwise AND, XOR, OR | Sx → Dx |
| 13-14 | && || | Logici AND, OR | Sx → Dx |
| 15 | ?: | Ternario | Dx → Sx |
| 16 | = += -= ... | Assegnamento | Dx → Sx |
| 17 (Bassa) | , | Virgola | Sx → Dx |
Gli Errori in C++
In C++, un errore non è quasi mai un evento isolato, ma la manifestazione di una violazione dei vincoli imposti dal linguaggio o dall'architettura hardware. Comprendere la genesi di un fallimento significa saper navigare tra i diversi strati di astrazione, dal codice sorgente fino alla gestione della memoria fisica.
Analisi Statica e Violazioni Semantiche
Prima ancora che il programma prenda vita, il compilatore agisce come un analizzatore formale. Gli errori in questa fase non riguardano l'esecuzione, ma la coerenza del "progetto".
Mentre l'Errore Sintattico è un semplice fallimento grammaticale, l'Errore Semantico rappresenta una contraddizione logica: il codice rispetta la forma ma viola il significato (es. tentare di assegnare il ritorno di una funzione void a una variabile, o eseguire operazioni aritmetiche su tipi non compatibili senza operatori sovraccaricati).
Violazione Semantica: Restituzione di Variabili Locali
Un errore semantico particolarmente insidioso riguarda la gestione dello Stack. Le variabili dichiarate all'interno di una funzione hanno una "durata di memorizzazione automatica": vengono create all'inizio della funzione e distrutte alla fine. Tentare di restituire un riferimento o un puntatore a una di queste variabili è un errore logico grave, poiché l'indirizzo restituito punterà a una zona di memoria già rilasciata.
// Errore Semantico: Restituzione di riferimento a variabile locale
int& funzionePericolosa() {
int n = 42;
return n; // ERRORE: n viene distrutta qui, il riferimento è "appeso"
}
int main() {
int& ref = funzionePericolosa();
cout << ref; // Undefined Behavior: la memoria è stata deallocata
return 0;
}
In questo scenario, il compilatore moderno solitamente emette un Warning (avviso), ma tecnicamente il codice potrebbe essere compilato. Tuttavia, a tempo di esecuzione, il programma accederà a una locazione di memoria non più valida, portando a risultati imprevedibili o a un crash improvviso.
Violazioni di Segmentazione (SIGSEGV)
Il Segmentation Fault è il segnale con cui il Sistema Operativo abbatte un processo che tenta di accedere a una zona di memoria non autorizzata. È la protezione fondamentale della MMU (Memory Management Unit) contro la corruzione del sistema.
Le cause principali risiedono nell'uso improprio dei puntatori: la dereferenziazione di un puntatore NULL, l'accesso a un indirizzo "spazzatura" (wild pointer) o il tentativo di scrivere in aree di sola lettura (come il segmento di codice).
int* ptr = nullptr;
*ptr = 100; // SIGSEGV: Accesso all'indirizzo 0x0
int arr[5];
for(int i = 0; i < 10000; i++) {
arr[i] = i; // SIGSEGV: Accesso fuori dai limiti del segmento stack
}
Esaurimento delle Risorse: Stack Overflow
Lo Stack è una struttura LIFO (Last-In-First-Out) di dimensioni limitate che gestisce le chiamate a funzione e le variabili locali. Quando una funzione richiama se stessa senza un caso base adeguato (ricorsione infinita) o alloca array locali troppo grandi, lo Stack invade aree di memoria non concesse, causando un crash istantaneo.
void funzioneRicorsiva() {
double buffer[1000]; // Occupa spazio sullo stack ad ogni chiamata
funzioneRicorsiva(); // STACK OVERFLOW: esaurimento memoria stack
}
Dinamismo Pericoloso: Errori nello Heap
Lo Heap è la memoria gestita manualmente dal programmatore. Qui gli errori sono spesso "silenziosi" o differiti, rendendo il debugging estremamente complesso.
Il Memory Leak si verifica quando si perde l'unico puntatore a un blocco di memoria allocata, rendendola irrecuperabile. Il Double Free, invece, avviene quando si tenta di deallocare due volte lo stesso indirizzo: questo corrompe i metadati del gestore di memoria (malloc/free) e porta a fallimenti critici imprevedibili.
int* p = new int(10);
delete p;
// ... altre operazioni ...
delete p; // DOUBLE FREE: il gestore di memoria rileva la corruzione
int* q = new int[50];
q = nullptr; // MEMORY LEAK: l'indirizzo originale è perso, memoria bloccata
L'Incognita dell'Undefined Behavior (UB)
Esiste un territorio in cui il C++ non garantisce alcun comportamento: l'Undefined Behavior. Accedere a un array fuori dai limiti o utilizzare una variabile non inizializzata può produrre risultati che variano a seconda del compilatore, del livello di ottimizzazione o persino della temperatura della CPU. Un programma affetto da UB è intrinsecamente non affidabile, anche se sembra funzionare durante i test.
unique_ptr, shared_ptr) e container della libreria standard (std::vector), che automatizzano la gestione della memoria riducendo drasticamente l'insorgenza di leak e segfault.