Infognati - fdp 2
Cerca per parola chiave 0/0

Materiale Ingegneria Informatica

Fondamenti di Programmazione

Introduzione al C++

Teoria Fondamenti di Programmazione

Il linguaggio C++ si basa sulla grammatica BNF (grammatica che definisce come deve essere strutturato il linguaggio di programmazione, dalla sua definizione sino alla sua scrittura vera e propria).


Elementi Letterali

Molto importanti in C++ sono gli elementi letterali, abbiamo:

  • literals: gli elementi immutabili scritti nel codice, determinano costanti ma senza nome. Esempio: char a = 'c', int b = 5.
  • identificatori: i nomi che un programmatore assegna a delle "celle di memoria". Esempio: int questo_identificatore = 5.
  • keywords: parole chiave riservate al linguaggio (come if, while) che il programmatore non può usare per gli identificatori. Identificano azioni che il calcolatore dovrà eseguire.
  • operator: i classici simboli "+", "-", "/", "*" che rappresentano le operazioni. Hanno priorità (es. "*" e "/" hanno priorità su "+" e "-"). Esempio: int a = 4*9 + 3/4.
  • separator: elementi che separano stringhe di codice o altri elementi, come ();, {}, []. Esempio: int a = (b+c)/2; int v[5].

Definizione e Dichiarazione

La differenza sostanziale è che con la dichiarazione si dichiara l'esistenza di un oggetto (senza assegnargli spazio in RAM), mentre con la definizione si assegna un vero e proprio spazio in memoria.

Esempio: extern int a; è una dichiarazione. Stiamo dicendo che la variabile a esisterà, ma non avrà una locazione in memoria fino alla sua definizione.

Struttura della memoria:

  • Memoria: insieme di celle.
  • Cella: in genere dimensione di un byte (8 bit).

I Tipi in C++

Categorie di Tipi

Ci sono due diverse categorie di tipi:

  • Tipi fondamentali
    • Tipi predefiniti: int, char, float, bool, double, unsigned
    • Tipi di enumerazione (enum)
  • Tipi derivati: si ottengono a partire dai tipi predefiniti e permettono di creare strutture complesse.

N.B:

  • I tipi fondamentali sono chiamati anche tipi aritmetici.
  • Il tipo intero e il tipo reale sono detti tipi numerici.
  • Intero, booleano, carattere ed enumerati sono detti tipi discreti.
Intero (int)

La rappresentazione degli interi (con segno), dato N il numero di bit, è nell'intervallo:

[ -2(N-1), +2(N-1) - 1 ]

Di solito, per un int si usano 4 byte (N=32 bit).

// tipo short
short int s1 = 1; // letterale
short s2 = 2;

// tipo long int
long int ln1 = 6543; // letterale int
long ln2 = 6543L; // letterale long int (suffisso L)
long ln3 = 6543l; // letterale long int (suffisso l)

// letterale int ottale, prefisso 0 (zero)
int ott = 011; // ott = 9 (decimale)

// letterale int esadecimale, prefisso 0x o 0X
int esad1 = 0xF; // esad1 = 15 (decimale)
int esad2 = 0XF; // esad2 = 15 (decimale)
Intero Senza Segno (unsigned)

La rappresentazione degli interi unsigned, dato N bit, è nell'intervallo: [0, 2N - 1].

// tipo unsigned int
unsigned int u1 = 1U; // letterale unsigned, suffisso U
unsigned u2 = 2u; // letterale unsigned, suffisso u

// tipo unsigned short int
unsigned short int u3 = 3;
unsigned short u4 = 4;

// tipo unsigned long int
unsigned long int u5 = 5555;
unsigned long u6 = 6666UL;
unsigned long u7 = 7777LU; // letterale unsigned long, suffisso UL (o lu)

// Errore comune: overflow
unsigned short int u8 = -0x0001; // Warning: OVERFLOW (avrà valore 65535)
Reali (float, double)

Letterale reale (forma estesa):

[Parte Intera] . [Parte Frazionaria] E [+/- Esponente]
Esempio: 10.56 E-3 (Componente in virgola fissa)

  • La parte intera o la parte frazionaria, se valgono zero, possono essere omesse.
// tipo double (precisione doppia)
double d1 = 3.3;
double d2 = -12.14e-3; // Notazione scientifica (-12.14 * 10^-3)
double d3 = 1.51;

// tipo float (precisione singola)
float f = -2.2f; // letterale float, suffisso F (o f)
float g = f - 12.12F;

// tipo long double (precisione estesa)
long double h = +0.1;
long double k = 1.23e+12L; // letterale long double, suffisso L (o l)
// 1.23e+12L significa 1.23 * 10^12
// 1.23e-12L significa 1.23 * 10^-12
Operazioni tra Reali e Interi

Le operazioni tra reali e interi, sebbene usino gli stessi simboli (+, /, ...), sono operazioni diverse (overloading).

int i = 1, j = 2;
int z = i / j; 
// z = 0 (divisione INTERA, la parte decimale viene troncata)

double d1 = 1.0 / 2.0;
// d1 = 0.5 (divisione REALE)

double d2 = 1 / 2;
// d2 = 0.0 (ERRORE! 1 e 2 sono interi -> divisione intera -> 0. Poi 0 viene convertito in double 0.0)

double d3 = (double)i / j; 
// d3 = 0.5 (Cast: i viene convertito in double -> divisione reale)
Booleani (bool)

I tipi booleani possono assumere solo due valori: true e false.

Quando convertiti da/in interi:

  • 0 (zero) corrisponde a false.
  • Qualsiasi numero diverso da zero (es. 6, -5, 1000) corrisponde a true.

Operazioni sui bool (logiche):

  • || : OR logico (disgiunzione)
  • && : AND logico (congiunzione)
  • ! : NOT logico (negazione)

Tabella di verità:

p q p || q (OR) p && q (AND) !p (NOT)
false false false false true
false true true false true
true false true false false
true true true true false
bool b1 = true, b2 = false;
bool b3 = b1 && b2; // b3 = false
bool b4 = b1 || b2; // b4 = true

// Precedenza: NOT (!) > AND (&&) > OR (||)
bool b5 = b1 || b2 && false; // b5 = true || (false && false) -> true
bool b6 = !b2 || b2 && false; // b6 = (!false) || (false && false) -> true || false -> true

N.B: Quando si stampa un'espressione logica con cout, usare sempre le parentesi!

bool b1 = true, b2 = false;
// L'operatore << ha precedenza su &&
cout << endl << b1 && b2; // STAMPA 1 (true)!
cout << endl << (b1 && b2); // STAMPA 0 (false) (corretto)
L'Implicazione (Logica)

L'implicazione (p → q, "se p allora q") non è un operatore C++ nativo per i bool.

  • IMPLICAZIONE FALSA: solo quando p è vero e q è falso.
  • IMPLICAZIONE VERA: In tutti gli altri casi (in particolare, se p è falso, l'implicazione è sempre vera).

Dalla tabella di verità si evince che p → q è logicamente equivalente a !p || q.

p q p → q !p !p || q
false false true true true
false true true true true
true false false false false
true true true false true
Tipi di Carattere (char)

Un char è un tipo intero (solitamente 1 byte) usato per memorizzare caratteri (es. in codifica ASCII).

Ordinamento:

  • Le codifiche rispettano l'ordine alfabetico ('a' < 'b') e numerico ('1' < '2').
  • La relazione tra maiuscole e minuscole dipende dalla codifica (es. in ASCII 'A' < 'a').

Valore:

  • Un carattere può essere rappresentato dal suo valore decimale, ottale o esadecimale.
  • Ottale: preceduto da \ (es. '\143').
  • Esadecimale: preceduto da \x (es. '\x63').
  • Nota: I letterali carattere vanno racchiusi fra apici (es. '\n', '\15').
Sequenze di escape

Combinazioni speciali per caratteri non direttamente rappresentabili:

  • Nuova riga (LF): \n
  • Tabulazione orizzontale: \t
  • Ritorno carrello (CR): \r (permette la sovrascrittura della riga corrente)
  • Barra invertita: \\
  • Apice: \'
  • Virgolette: \"

Esempi (char):

char c1 = 'c', t = '\t', d = '\n';
char c2 = '\x63'; // 'c' (esadecimale)
char c3 = '\143'; // 'c' (ottale)
char c4 = 99;     // 'c' (decimale, codice ASCII)
cout << c1 << t << c2 << t << c3 << t << c4 << d; // Stampa: c   c   c   c

// Aritmetica sui char (basata sui loro codici ASCII)
char c5 = c1 + 1; // 'd' (99 + 1 = 100)
char c6 = c1 - 2; // 'a' (99 - 2 = 97)
char c7 = 4 * d + 3; // '+' (basato sul valore ASCII di '\n')
int i = c1 - 'a'; // 2 (99 - 97)
cout << c5 << t << c6 << t << c7 << t << i << d;

bool m = 'a' < 'b'; // m = true
bool n = 'a' > 'c'; // n = false
Tipo Enumerazione (o enumerati)

Sono tipi definiti dal programmatore, costituiti da un set di costanti intere (enumeratori).

Vengono usati per variabili che possono assumere un numero limitato di valori e per rappresentare informazioni non numeriche. Si possono usare operatori di confronto e operazioni intere.

int main() {
    // Di default, LUN=0, MAR=1, MER=2...
    enum Giorni { LUN, MAR, MER, GIO, VEN, SAB, DOM };
    
    Giorni oggi = MAR;
    oggi = MER;
    int i = oggi; // OK: conversione implicita enum -> int (i = 2)

    // ERRORE: non si può convertire implicitamente int -> enum
    // oggi = 3;         // ERRORE! 3 è una costante intera
    // oggi = i;         // ERRORE! i è un intero, non un Giorni
    // oggi = MER - MAR; // ERRORE! MER-MAR è un intero

    cout << int(oggi) << endl; // 2
    cout << oggi << endl; // 2 (conversione implicita in int per cout)

    enum { ROSSO, GIALLO, VERDE } semaforo; // ROSSO=0, GIALLO=1, ...
    semaforo = GIALLO;
    cout << semaforo << endl; // 1

    // Si possono assegnare valori specifici (i successivi seguono)
    enum { INIZ1 = 10, INIZ2, INIZ3 = 9, INIZ4 }; // INIZ2=11, INIZ4=10
    cout << INIZ1 << '\t' << INIZ2 << '\t'; // 10 11
    cout << INIZ3 << '\t' << INIZ4 << endl; // 9 10
}

Operatori in C++

Operatori Bitwise (Logici)

Operano sui singoli bit dei tipi interi:

  • | : OR bit a bit
  • & : AND bit a bit
  • ^ : XOR (OR esclusivo) bit a bit
  • ~ : Complemento (NOT) bit a bit
  • << : Shift (traslazione) a sinistra
  • >> : Shift (traslazione) a destra

Tabelle di verità (Bit a Bit):

aba | ba & ba ^ b
00000
01101
10101
11110
unsigned short a = 0xFFF9; // 1111 1111 1111 1001 (65529)
unsigned short b = ~a;     // 0000 0000 0000 0110 (6)
unsigned short c = 0x0013; // 0000 0000 0001 0011 (19)
unsigned short d = 021;    // in ottale (17)
unsigned short e = 0b0000000000010010; // in binario (18)
unsigned short c1, c2, c3;

c1 = b | c; // 0000 0000 0001 0111 (23)
c2 = b & c; // 0000 0000 0000 0010 (2)
c3 = b ^ c; // 0000 0000 0001 0101 (21)

unsigned short b1, b2;
b1 = b << 2; // 0000 0000 0001 1000 (24)
b2 = b >> 1; // 0000 0000 0000 0011 (3)

Operatori di Confronto

Restituiscono false se la relazione è falsa, true altrimenti. I tipi aritmetici possono usare:

  • == (uguale)
  • != (diverso)
  • > (maggiore)
  • >= (maggiore o uguale)
  • < (minore)
  • <= (minore o uguale)
int a = -1;
bool b1 = a > 0; // false (0 > -1)
bool b2 = a < 0; // true (-1 < 0)

Operatore sizeof()

Ritorna la grandezza (in byte) di un tipo o di una variabile.

int main() {
    cout << "char \t" << sizeof(char) << endl; // 1
    cout << "short \t" << sizeof(short) << endl; // 2
    cout << "int \t" << sizeof(int) << endl; // 4
    cout << "long \t" << sizeof(long) << endl; // 4 (o 8)
    cout << "unsigned char \t" << sizeof(unsigned char) << endl; // 1
    cout << "unsigned short \t" << sizeof(unsigned short) << endl; // 2
    cout << "unsigned int \t" << sizeof(unsigned int) << endl; // 4
    cout << "unsigned long \t" << sizeof(unsigned long) << endl; // 4 (o 8)
    cout << "float \t" << sizeof(float) << endl; // 4
    cout << "double \t" << sizeof(double) << endl; // 8
    cout << "long double \t" << sizeof(long double) << endl; // 12
    
    cout << "costante carattere " << sizeof 'c' << endl; // 1
    cout << "costante carattere " << sizeof('c') << endl; // 1
    char c = 0;
    cout << "variabile carattere " << sizeof c << endl; // 1
    // cout << "char" << sizeof char << endl; // ERRORE!
}

Operatore Assegnamento (=)

Assegna a una variabile un valore o il valore di un'altra variabile.

int i = 0, j = 1, k;
i = 3;
cout << i << endl; // 3
j = i;
cout << j << endl; // 3

// L'assegnamento è ASSOCIATIVO A DESTRA
k = j = i = 5; // Equivale a: k = (j = (i = 5))
cout << i << '\t' << j << '\t' << k << endl; // 5 5 5

// L'espressione (i = 3) vale 3
k = j = 2 * (i = 3); // Equivale a: k = (j = (2 * 3))
cout << i << '\t' << j << '\t' << k << endl; // 3 6 6

// k = j + 1 = 2 * (i = 100); // ERRORE!

// L'assegnamento restituisce un l-value (la variabile stessa)
(j = i) = 10; // (j=i) assegna i (3) a j. (j) = 10 assegna 10 a j.
cout << j << endl; // 10

Altri Operatori di assegnamento (+=, -=, *=, /=):

int i = 0, j = 5;
i += 5; // i = i + 5
cout << i << endl; // 5

i *= j + 1; // i = i * (j + 1) -> i = 5 * (6)
cout << i << endl; // 30

// Associativo a DESTRA
i -= j -= 1; // j = j - 1 (j=4). Poi i = i - j (i=30-4)
cout << i << endl; // 26

(i += 12) = 2; // Associativo a destra; restituisce l-value
cout << i << endl; // 2

Operatori Incremento e Decremento (++ e --)

Permettono di scrivere somme/sottrazioni in forma abbreviata.

int i, j;
i = 0; j = 0;
++i; // PRE-incremento (i ora è 1)
--j; // PRE-decremento (j ora è -1)
cout << i << '\t' << j << endl; // 1 -1

i = 0;
// PRE-incremento: 1. i diventa 1. 2. j = i
j = ++i;
cout << i << '\t' << j << endl; // 1 1

i = 0;
i++;
cout << i << endl; // 1

i = 0;
// POST-incremento: 1. j = i (j=0). 2. i diventa 1.
j = i++;
cout << i << '\t' << j << endl; // 1 0

// j = ++i++; // ERRORE!
j = (++i)++; // CORRETTO
// j = i++++; // ERRORE!
int k = ++++i; // (Se i era 1, i diventa 3, k diventa 3)
cout << i << '\t' << j << '\t' << k << endl; // Es: 5 2 5 (dipende dallo stato prec.)

Perché ++++i è valido e i++++ no?

  • i++ (post-incremento) restituisce un valore temporaneo (r-value), cioè una copia del vecchio valore di i.
  • ++i (pre-incremento) restituisce la variabile stessa (l-value), già incrementata.
  • Gli operatori ++ e -- richiedono un l-value (una variabile) su cui operare.
  • i++ ++ fallisce perché tenta di incrementare (il secondo ++) un valore temporaneo (il risultato di i++).
  • ++++i (cioè ++(++i)) è valido perché ++i restituisce i (un l-value), che può essere incrementato di nuovo.
Operatore Condizionale (Ternario)

Usato per scrivere in modo compatto le strutture if-else. La sintassi è:

(<condizione>) ? (<condizione_vera>) : (<condizione_falsa>)

Operatore Virgola (,)

Permette di inserire più espressioni dove ne sarebbe prevista solo una. È un operatore binario, associativo a sinistra.

Sintassi: esp1, esp2

Viene prima valutata esp1, poi esp2. Il risultato dell'intera espressione è il risultato di esp2 (il risultato di esp1 viene ignorato).

int main() {
    int a = 2;
    int b = 3;
    a = (b++, 5); // b++ viene eseguito (b diventa 4), poi a = 5
    cout << b << endl; // stampa 4
    cout << a << endl; // stampa 5
}

N.B: L'operatore virgola ha la priorità più bassa, inferiore anche all'assegnamento =.

int main() {
    int a = 2;
    int b = 3;
    // Questo è come scrivere (a = b++), 5;
    a = b++, 5; 
    cout << b << endl; // stampa 4 (b++ eseguito)
    cout << a << endl; // stampa 3 (a = vecchio valore di b)
}

Espressioni e Conversioni

Espressioni Aritmetiche e Logiche

Nelle espressioni aritmetiche e logiche vengono rispettate la precedenza e le associatività tra operatori.

Precedenza
  1. Fattori: Funzioni, operatori unari (++ postfisso, -- postfisso, poi ++ prefisso, -- prefisso, !, - unario, + unario)
  2. Termini (operatori binari):
    1. Moltiplicativi (*, /, %)
    2. Additivi (+, -)
    3. Relazione (<, >=, ...)
    4. Uguaglianza (==, !=)
    5. Logici (&&, poi ||)
    6. Assegnamento (=, +=, ...)
  3. Parentesi (): usate per forzare un'espressione a essere valutata per prima (come "fattore").
int i = 2, j;
j = 3 * i + 1; // * ha precedenza: (3 * 2) + 1
cout << j << endl; // 7

j = 3 * (i + 1); // () hanno precedenza: 3 * (2 + 1)
cout << j << endl; // 9
Associatività

Indica la direzione (a sinistra o a destra) in cui vengono valutati operatori dello stesso livello di precedenza.

  • Operatori aritmetici binari (+, -, *, /): a sinistra.
  • Operatori unari (!, ++): a destra.
  • Operatori di assegnamento (=, +=): a destra.

Conversioni in C++ (Cast)

Ci sono due tipi di conversioni in C++:

Conversioni Implicite

Fatte automaticamente dal linguaggio.

Attenzione: la conversione da double a int causa perdita di informazione (troncamento della parte decimale).

int main() {
    int i = 10, j;
    float f = 2.5f, h;
    double d = 1.2e+1; // 12.0
    char c = 'd'; // Codice ASCII 100
    
    h = f + 1; // 1 (int) viene promosso a float -> 2.5f + 1.0f = 3.5f
    cout << h << '\t'; // 3.5

    j = f + 3.1f; // f + 3.1f = 5.6f. Assegnato a int 'j', viene troncato
    cout << j << endl; // 5

    d = i + 1; // 10 + 1 = 11. 11 (int) promosso a double 11.0
    cout << d << '\t'; // 11

    d = f + d; // f (2.5) promosso a double -> 2.5 + 11.0 = 13.5
    cout << d << endl; // 13.5

    j = c - 'a'; // 'd'(100) - 'a'(97) = 3
    cout << j << endl; // 3
}

Regole (semplificate):

  • Operatori binari: Se gli operandi hanno tipi diversi, quello "inferiore" viene promosso al tipo "superiore" (shortint, intdouble).
  • Assegnamento: Il valore a destra viene convertito al tipo della variabile a sinistra (anche con perdita di dati).
  • A una variabile enum si può assegnare solo un valore dello stesso tipo enum (non un int) senza un cast esplicito.
  • In sequenza: char può essere assegnato a double (charintdouble).
Conversioni Esplicite (Cast)

Forzate volontariamente dal programmatore.

Operatore static_cast:

  • Effettua una conversione di tipo quando esiste la conversione implicita inversa.
  • Può essere usato per effettuare conversioni di tipo previste dalla conversione implicita.
int main() {
    enum Giorni { LUN, MAR, MER, GIO, VEN, SAB, DOM };
    int i;
    Giorni g1 = MAR, g2, g3;
    
    i = g1; // OK: conversione implicita enum -> int
    
    // Stile C++ moderno (preferito e sicuro)
    g1 = static_cast<Giorni>(i);
    
    // Stile C (sconsigliato in C++)
    g2 = (Giorni)i;
    
    // Notazione funzionale
    g3 = Giorni(i);
    
    cout << g1 << '\t' << g2 << '\t' << g3 << endl; // 1 1 1

    // Cast per troncare (double -> int)
    int j = (int)1.1; // j = 1
    float f = float(2); // f = 2.0
    cout << j << '\t' << f << endl;
}

N.B: Si utilizza la sintassi (int)a o int(a), ma static_cast<> è il metodo più sicuro.


Strutture di Controllo

Istruzione IF

Le istruzioni if (o istruzioni strutturate) sono fondamentali. Per il Teorema di Bohm-Jacobini, per ogni problema complesso sono necessarie al più tre istruzioni strutturate (sequenza, selezione e iterazione).

La sintassi è:

if (<condizione>) {
    <istruzioni per la condizione vera>
} else {
    <istruzioni per la condizione falsa>
}

// Forma compatta (operatore condizionale)
<condizione> ? <vera> : <falsa>;

Istruzione SWITCH-BREAK

L'istruzione switch permette una selezione multipla basata sul valore di un'espressione.

  • Espressione: Solitamente una variabile a valori discreti (int, char, enum).
  • Etichette (case): Contengono espressioni costanti. I valori devono essere distinti.
  • default: Eseguita se nessun case corrisponde. Deve essere unica. Se manca e nessun case corrisponde, lo switch termina.
  • break: Istruzione di salto che fa terminare l'istruzione switch.

Attenzione: Se un'alternativa non termina con break, l'esecuzione "cade" (fall-through) ed esegue anche le istruzioni dell'alternativa successiva.

Esempi di switch:

char c;
// ... cin >> c; ...
switch (c) {
    case 'a':
    case 'A':
        cout << "Prima alternativa" << endl;
        break;
    case 'b':
    case 'B':
        cout << "Seconda alternativa" << endl;
        break;
    case 'c':
    case 'C':
        cout << "Terza alternativa" << endl;
        break;
    // Manca il caso di default
    // Se non è una delle alternative, non scrive niente
}

int i;
cout << "Quanti asterischi? " << endl;
cin >> i;
switch (i) {
    case 3: // in cascata (fall-through)
        cout << "*";
    case 2: // in cascata
        cout << "*";
    case 1:
        cout << "*";
        break;
    default:
        cout << "!"; // Se i non è 1, 2, o 3
}
cout << endl;

Istruzioni per i Cicli

Ci sono 3 diversi tipi di cicli:

WHILE FOR DO-WHILE
Usato quando non sappiamo quante volte il ciclo dovrà ripetersi. Usato quando sappiamo quante volte il ciclo dovrà ripetersi. Usato quando non sappiamo quante volte, ma siamo sicuri che verrà eseguito almeno una volta.
while (<condizione>) {
    <istruzioni>
}
for (<variabile>; <condizione>; <incremento>) {
    <istruzioni>
}
do {
    <istruzioni>
} while (<condizione>);
  • break: Istruzione che permette di saltare all'istruzione immediatamente successiva al corpo del ciclo/switch in cui è collocato.
  • continue: Istruzione che provoca la terminazione dell'iterazione corrente e salta all'iterazione successiva del ciclo, valutando di nuovo la condizione.

N.B: continue "ignora" l'eventuale presenza di uno switch al suo interno e agisce solo sul ciclo.

Esempio break:

int main() {
    int j;
    // Ciclo infinito; altra forma: while(true)
    for (;;) {
        cout << "Inserisci un numero intero " << endl;
        cin >> j;
        if (j < 0)
            break; // Esce dal ciclo se j è negativo
        cout << j << endl;
    }
    return 0;
}
/* Output:
Inserisci un numero intero
3
3
Inserisci un numero intero
5
5
Inserisci un numero intero
-1
(Process finished)
*/

Esempio continue:

int main() {
    const int N = 5;
    for (int i = 0, j; i < N; i++) {
        cout << "Inserisci un numero intero" << endl;
        cin >> j;
        if (j < 0)
            continue; // Salta il resto del blocco e passa a i++
        cout << j << endl;
    }
    return 0;
}
/* Output:
Inserisci un numero intero
1
1
Inserisci un numero intero
2
2
Inserisci un numero intero
-2
Inserisci un numero intero
-3
Inserisci un numero intero
4
4
*/

Stream (I/O) e File

Concetto di Stream

Uno stream (flusso) in C++ è un'astrazione che rappresenta un canale di dati attraverso il quale i dati vengono trasferiti in sequenza da una sorgente (source) a una destinazione (sink).

Immagina uno stream come una tubatura: i dati scorrono in un'unica direzione.

  • Sorgente: Da dove provengono i dati (es. tastiera, file, variabile).
  • Destinazione: Dove i dati sono diretti (es. schermo, file, variabile).

Questa astrazione permette di usare le stesse funzioni (>>, <<) indipendentemente dal dispositivo.

In parole semplici, lo stream è un buffer (memoria temporanea) che permette al programma di "parlare" con l'esterno.

Struttura dello Stream

Ogni stream è costituito da una sequenza di celle (byte) e termina con una marca di fine stream.

Nel momento in cui il programma incontra cin >> <variabile>, andrà a leggere nel buffer. Il buffer è gestito con politica FIFO (First In First Out), quindi il valore assegnato sarà il primo inserito.

I Principali Stream

I principali stream sono 3 (dalla libreria <iostream>):

  • cout: Stream di output standard (di solito, lo schermo).
  • cerr: Stream di errore standard (non bufferizzato, per messaggi urgenti).
  • cin: Stream di input standard (di solito, la tastiera).
CIN (Operatore di Lettura >>)

cin viene utilizzato per prelevare input ed esegue due azioni:

  1. Preleva dallo stream una serie di caratteri (ignorando spazi, tabulazioni e "a capo" iniziali).
  2. Interpreta i dati in base al tipo della variabile (se int, cerca un numero; se char, prende un carattere, ecc.).

I caratteri digitati riempiono lo stream solo quando si preme INVIO. È definito per interi, reali, caratteri e costanti stringa.

1) Lettura Caratteri (cin)
char c;
cin >> c;

Questo codice: salta tutti gli spazi bianchi finché non trova un carattere "visibile", lo preleva e lo assegna a c. Il puntatore avanza.

Per leggere qualsiasi carattere (incluso lo spazio bianco):

char c;
cin.get(c);

cin.get(c) è una funzione membro che legge il prossimo byte dallo stream, qualunque esso sia.

Esempi cin >> c vs cin.get(c)
// Esempio con cin >> c (salta gli spazi)
int main() {
    char c;
    while (true) {
        cout << "Inserisci un carattere " << endl;
        cin >> c;
        if (c != 'q')
            cout << c << endl;
        else
            break;
    }
    return 0;
}
/* Output se inserisco "a b q":
Inserisci un carattere
a b q
a
Inserisci un carattere
b
Inserisci un carattere
(termina)
*/
// Esempio con cin.get(c) (legge tutto)
int main() {
    char c;
    while (true) {
        cout << "Inserisci un carattere " << endl;
        cin.get(c); // Legge il prossimo carattere, INCLUSI gli spazi
        if (c != 'q')
            cout << c << endl;
        else
            break;
    }
    return 0;
}
/* Output se inserisco "a bq":
Inserisci un carattere
a bq
a
Inserisci un carattere

(legge lo spazio)
Inserisci un carattere
b
Inserisci un carattere
(termina)
*/
2) Lettura Interi e Reali (cin)
int i;
cin >> i;
float f;
cin >> f;

cin salta gli spazi bianchi, poi legge caratteri finché sono consistenti con il tipo (cifre e segno per int; cifre, segno, punto decimale e 'e' per float). Si ferma al primo carattere non valido, che rimane nel buffer.

COUT (Operatore di Uscita <<)

cout calcola il valore dell'espressione a destra, la converte in una sequenza di caratteri e la inserisce nello stream di output.

Si possono scrivere più valori in sequenza (concatenazione): cout << b << c;

cout scrive anche i booleani (convertiti in intero: 0=false, 1=true).

Per formattare l'output (richiede <iomanip>):

  • setw(n): Imposta l'ampiezza del campo (numero di caratteri) *solo per la prossima stampa*.
  • setprecision(n): Imposta il numero di cifre significative (o decimali se usato con fixed).
  • fixed: Forza la notazione decimale (non scientifica) per i float.
  • hex, oct, dec: Cambia la base numerica (esadecimale, ottale, decimale) per i numeri interi da quel punto in poi.
#include <iostream>
#include <iomanip>
using namespace std;

int main() {
    double d = 1.564e-2; // 0.01564
    double f = 1.2345;
    
    cout << d << endl; // 0.01564
    
    // setprecision(n) = n cifre SIGNIFICATIVE
    cout << setprecision(2) << d << '\t' << f << endl; // 0.016   1.2
    
    // setprecision rimane attivo!
    cout << d << endl; // 0.016
    
    // setw(n) riserva n caratteri totali (8 spazi + "43")
    cout << setw(10) << 43 << endl; // "        43"
    
    cout << hex << 10 << '\t' << 11 << endl; // a    b
    cout << oct << 10 << '\t' << 11 << endl; // 12   13
    cout << dec << 10 << '\t' << 11 << endl; // 10   11
    
    // fixed + setprecision(n) = n cifre DOPO la virgola
    cout << fixed << setprecision(2) << 345.3262 << endl; // 345.33 (arrotonda)
    return 0;
}

Errori degli Stream (cin)

Lo stream cin va in uno stato di errore (cin.fail() restituisce true) quando i dati nel buffer non sono consistenti con il tipo della variabile che deve leggerli.

Esempio: Si cerca di leggere un int ma nel buffer c'è "ciao".

int a;
double b;
cin >> a >> b;
// Input utente: 10 ciao
// Risultato: 'a' viene letto (10). 'b' fallisce (ciao non è double).
// Lo stream è ora in stato di errore.

Uno stream in errore blocca tutte le successive operazioni di input. Per ripristinarlo:

  • cin.clear(): Resetta lo stato di errore (cin.fail() torna false).
  • cin.ignore(N, '\n'): Pulisce il buffer, ignorando i prossimi N caratteri (es. 1000) o fino a che non trova un "a capo" (\n).

È possibile usare cin stesso come condizione:

int i;
cout << "Inserisci un numero intero (carattere per terminare):" << endl;
while (cin >> i) { // Questa condizione è VERA se la lettura va a buon fine
    cout << "Numero letto: " << i << endl;
    cout << "Inserisci un numero intero:" << endl;
}
// Se inserisco 'a', cin >> i fallisce, la condizione è FALSA, il ciclo termina.
cout << boolalpha << cin.fail() << endl; // Stampa "true"
cin.clear(); // Ripristina lo stato
cin.ignore(1000, '\n'); // Pulisce il buffer da 'a'
cout << cin.fail() << endl; // Stampa "false"

L'utente può forzare la fine dello stream (EOF - End Of File) da tastiera con Ctrl+D (Linux/Mac) o Ctrl+Z (Windows).

Manipolazione File (<fstream>)

Per leggere e scrivere su file si usa la libreria <fstream>, che fornisce ifstream (input), ofstream (output) e fstream (entrambi).

  • open(nome_file, modalità): Associa lo stream a un file.
  • close(): Chiude lo stream.

Modalità di apertura:

  • ios::in: (Lettura) Il file deve esistere. Puntatore all'inizio.
  • ios::out: (Scrittura) Se il file esiste, lo cancella (sovrascrive). Se non esiste, lo crea. Puntatore all'inizio.
  • ios::app: (Append) Se il file esiste, si posiziona alla fine per aggiungere dati (i dati non vengono persi). Se non esiste, lo crea. (Si usa con ios::out: ios::out | ios::app).

Una volta aperto, si usano >> e << come con cin e cout. Gli errori funzionano allo stesso modo.

Esempio 1: Scrittura e Lettura

#include <fstream>
#include <iostream>
#include <cstdlib> // per exit()
using namespace std;

int main() {
    fstream fs;
    int num;

    // Apertura in scrittura
    fs.open("esempio.txt", ios::out);
    if (!fs) { // Controlla se l'apertura è fallita
        cerr << "Il file non puo' essere aperto" << endl;
        exit(1); // Termina il programma
    }

    // Scrive i numeri da 0 a 3 nel file
    for (int i = 0; i < 4; i++)
        fs << i << endl; // ATT: separa i numeri con newline
    fs.close();

    // Apertura in lettura
    fs.open("esempio.txt", ios::in);
    if (!fs) {
        cerr << "Il file non puo' essere aperto" << endl;
        exit(1);
    }
    
    // Legge i numeri fino alla fine del file e li stampa
    while (fs >> num) { // Stessa logica di cin!
        cout << num << '\t';
    }
    cout << endl;
    fs.close();
    return 0;
}
/* Output a schermo:
0   1   2   3
*/

Esempio 2: Controllo Errori Lettura

int main() {
    fstream fs;
    int i, num;
    const int N = 6; // Il file "esempio.txt" ne ha solo 4
    
    fs.open("esempio.txt", ios::in);
    if (!fs) {
        cerr << "Il file non puo' essere aperto" << endl;
        exit(1);
    }
    
    // Legge finché i < N E finché la lettura (fs >> num) va a buon fine
    for (i = 0; i < N && fs >> num; i++)
        cout << num << '\t';
    cout << endl;
    
    if (i != N) { // Il ciclo si è interrotto prima di arrivare a N
        cerr << "Errore nella lettura del file" << endl; // (o EOF)
    }
    return 0;
}
/* Output a schermo:
0   1   2   3
Errore nella lettura del file
*/

Esempio 3: Append (Aggiungere)

int main() {
    fstream fs;
    char c;
    
    // Apertura in scrittura (sovrascrive)
    fs.open("esempio.txt", ios::out);
    for (int i = 0; i < 4; i++)
        fs << i << '\t';
    fs.close();

    // Apertura in append (aggiunge)
    fs.open("esempio.txt", ios::out | ios::app);
    fs << 5 << '\t' << 6 << endl;
    fs.close();

    // Lettura (con fs.get per leggere anche \t e \n)
    fs.open("esempio.txt", ios::in);
    while (fs.get(c)) // Legge ogni singolo carattere
        cout << c;
    fs.close();
    return 0;
}
/* Output a schermo:
0   1   2   3   5   6
*/

Sintassi abbreviata:

ifstream ifs("file.in");
Equivale a fstream fs; fs.open("file.in", ios::in);

ofstream ofs("file.out");
Equivale a fstream fs; fs.open("file.out", ios::out);

ofstream ofs("file.out", ios::app);
Equivale a fstream fs; fs.open("file.out", ios::out | ios::app);


Librerie

Sono un insieme di funzioni (sottoprogrammi) precompilate.

  • Formate da coppie di file: un file compilato e un file di intestazione (header, es. .h) che contiene le dichiarazioni.
  • Si usano includendo il file header (es. #include <cmath>) e, in fase di collegamento (linking), specificando la libreria da usare.

Librerie C++ standard principali:

  • <iostream>: Per cin, cout, cerr.
  • <fstream>: Per ifstream, ofstream.
  • <iomanip>: Per setw, setprecision.
  • <cmath>: Funzioni matematiche (sqrt, pow, sin...).
  • <cstdlib>: Funzioni di utilità generale (exit, rand...).
  • <cctype>: Funzioni sui caratteri (isdigit, tolower...).
  • <vector>: Per il tipo di dato vector.

Tipi Derivati

Dai tipi fondamentali si possono derivare altri tipi. I meccanismi di derivazione possono essere composti (es. array di puntatori a interi, puntatori ad array di interi, ecc.).

  • Dal tipo int si deriva il tipo puntatore a int (int*). Una variabile di questo tipo contiene l'indirizzo di un intero.
  • Dal tipo int si deriva il tipo array di int (int[]). Una variabile di questo tipo contiene n interi.

Principali tipi derivati:

  • Riferimenti (&)
  • Puntatori (*)
  • Array ([])
  • Strutture (struct)
  • Unioni (union)
  • Classi (class)

Riferimenti (&)

Un riferimento è un alias (un sinonimo) per una variabile esistente. Non occupa una nuova cella di memoria, ma "punta" alla stessa cella della variabile originale.

int a = 5;
int &a2 = a; // a2 è ora un alias per a
  • Modificare a2 modifica a, e viceversa.
  • Il tipo del riferimento deve essere congruo (int &a2 = a; OK; char &c2 = a; ERRORE).
  • Non si possono creare riferimenti a riferimenti.
  • Un riferimento deve essere inizializzato al momento della dichiarazione.
  • Non si può riferire a un valore costante (letterale), a meno che il riferimento non sia const.
int main() {
    int i = 10;
    int &r = i;
    int &s = r; // s è un altro alias per i
    
    // int &err1; // ERRORE: deve essere inizializzato
    // int &err2 = 10; // ERRORE: non si può riferire a un letterale

    cout << i << '\t' << r << '\t' << s << endl; // 10 10 10
    r++; // Modifica r, ma in realtà modifica i
    cout << i << '\t' << r << '\t' << s << endl; // 11 11 11

    int h = 0, k = 1;
    int &r1 = h, &r2 = k; // due riferimenti
    int &r3 = h, j = 3;  // un riferimento e un intero
    return 0;
}
Riferimenti Const

Un riferimento const è un alias che non può essere usato per modificare il valore della variabile originale.

int main() {
    int i = 1;
    const int &r = i; // r è un riferimento "read-only" a i

    cout << r << endl; // 1
    // r = 2; // ERRORE! r è costante, non possiamo cambiare i tramite r.
    
    i = 2; // OK: i non è costante, possiamo cambiarla direttamente
    cout << r << endl; // 2 (r "vede" la modifica)

    int j = r; // OK: j = 2
    cout << j << endl; // 2
    
    const int k = 10;
    const int &t = k; // OK: riferimento const a variabile const
    // int &err3 = k; // ERRORE! non si può creare un rif. non-const a una var. const
    return 0;
}
Riferimenti come Argomenti (Passaggio per Riferimento)
  • Passaggio per Valore: (es. void Scambia(int a, int b)). La funzione riceve una copia della variabile del main. Le modifiche fatte su a e b nella funzione non si riflettono su i e j nel main. Vengono create nuove celle di memoria.
  • Passaggio per Riferimento: (es. void Scambia(int &a, int &b)). La funzione riceve un alias della variabile del main. a diventa un sinonimo di i, b un sinonimo di j. Le modifiche fatte su a e b agiscono direttamente sulle variabili i e j del main. Non viene creata nuova memoria per a e b.

Esempio Passaggio per Valore (NON FUNZIONA):

void Scambia (int a, int b) { // a e b sono copie
    int c = a;
    a = b;
    b = c;
}
int main() {
    int i = 2, j = 3;
    cout << "Inserisci due interi: " << i << " " << j << endl;
    Scambia (i,j);
    cout << i << '\t' << j << endl; // Stampa ancora 2   3
}

Esempio Passaggio per Riferimento (FUNZIONA):

void Scambia(int &a, int &b) { // a e b sono alias di i e j
    // scambio i valori degli oggetti riferiti
    int c = a;
    a = b;
    b = c;
}
int main() {
    int i, j;
    cout << "Inserisci due interi: ";
    cin >> i >> j; // Es: i=2, j=3
    Scambia(i, j);
    cout << i << '\t' << j << endl; // Stampa: 3   2
}

Riferimenti const come Argomenti:

Si usa quando non si vuole modificare la variabile originale, ma si vuole evitare il costo di copiarla (specialmente se è un oggetto grande). Dà alla funzione l'accesso in "sola lettura".

float Interesse (int importo, const float& rateo) {
    float inter = rateo * importo;
    // rateo += 0.5; // ERRORE: rateo è const!
    return inter;
}
Riferimenti come Risultato di Funzione

Una funzione può restituire un riferimento a una variabile. Questo permette alla funzione di essere usata a sinistra di un assegnamento.

// Funzione che restituisce un RIFERIMENTO al maggiore tra a e b
int& Massimo (int &a, int &b) {
    return a > b ? a : b;
}

int main() {
    int i, j;
    cout << "Inserisci due interi: "; // Es: 1 3
    cin >> i >> j;
    
    cout << "Valore massimo: ";
    cout << Massimo(i,j) << endl; // Stampa 3

    // Posso assegnare un valore al risultato della funzione!
    Massimo(i,j) = 5; // 'j' (il massimo) diventa 5
    cout << i << '\t' << j << endl; // Stampa 1   5

    Massimo(i,j)++; // 'j' (il massimo) viene incrementato
    cout << i << '\t' << j << endl; // Stampa 1   6
}

Attenzione: Per restituire un riferimento, gli argomenti devono essere anch'essi riferimenti (o variabili globali/statiche). Restituire un riferimento a una variabile locale (passata per valore) è un errore gravissimo (non segnalato dal compilatore!) perché la variabile locale "muore" alla fine della funzione.

Operatore const_cast

Questa operazione converte un riferimento const in un riferimento non const. È un'operazione pericolosa e generalmente sconsigliata, da usare solo se si sa esattamente cosa si sta facendo.

// Funzione che accetta const ma restituisce non-const
int& Massimo (const int &a, const int &b) {
    return const_cast<int>(a > b ? a : b); // Rimuove il const
}

Puntatori (*)

Un puntatore è una variabile che contiene l'indirizzo di memoria di un'altra variabile. A differenza di un riferimento, un puntatore ha una sua cella di memoria.

  • &var: Operatore "indirizzo di". Prende l'indirizzo di var.
  • *puntatore: Operatore di "deferenzazione" o "indirezione". Accede al valore contenuto nella cella di memoria a cui puntatore punta.
int var;
int* puntatore = &var;

Un puntatore deve essere associato a una variabile o inizializzato a nullptr (dalla versione C++11, sostituisce NULL o 0). Un puntatore non inizializzato punta a una cella di memoria casuale, il che è pericolosissimo.

Differenza con i Riferimenti:

  • int* p = &n;
  • Se stampo &p (indirizzo del puntatore) e &n (indirizzo della variabile), sono diversi.
  • Se stampo p (valore del puntatore) e &n, sono uguali.
  • *p = 10; assegna il valore 10 alla variabile puntata (n).
  • La forza dei puntatori è che possono cambiare la cella di memoria a cui puntano (un riferimento è "bloccato" all'inizializzazione).
int n = 0, k = 0;
int* p = &n; // p punta a n
*p = 10;      // n ora vale 10
p = &k;      // p ora punta a k (questo è impossibile con un riferimento)
*p = 5;       // k ora vale 5
// Risultato: n = 10, k = 5
Operazioni sui Puntatori
  • Assegnamento: Si può assegnare solo un valore che rappresenta un indirizzo (int* p = &x;).
  • Confronto: == e != verificano se due puntatori puntano allo stesso indirizzo.
  • Stampa: cout << p; stampa l'indirizzo (es. 0x7FFF...); cout << *p; stampa il valore (es. 10).
Puntatore come argomento di funzione

Passare un puntatore (f(&x)) è un modo per ottenere il "passaggio per riferimento". La funzione riceve l'indirizzo e può modificare la variabile originale tramite *.

// Esempio di errore: passare un puntatore a 'const int' a una funzione
// che si aspetta un puntatore a 'int'

int massimo (int* a, int* b) {
    return *a > *b ? *a : *b;
}

int main() {
    int i = 2;
    const int N = 3;
    
    // ERRORE: invalid conversion from 'const int*' to 'int*'
    // cout << *massimo(&i, &N) << endl; 
    
    // La funzione 'massimo' NON promette di non modificare *b,
    // quindi non le si può passare l'indirizzo di una 'const'.
    return 0;
}

Puntatori a Funzione

In C++, un puntatore a funzione è una variabile che memorizza l'indirizzo di memoria di una funzione anziché di un dato. Questo permette di "parametrizzare" il comportamento del codice, decidendo quale operazione eseguire solo a runtime.

Sintassi ed Esempi Pratici

1. Dichiarazione
Bisogna specificare il tipo di ritorno e i parametri della funzione che si vuole puntare:
double (*puntoOperazione)(double, double);
2. Assegnazione
Il nome di una funzione senza parentesi rappresenta il suo indirizzo:
puntoOperazione = somma; // 'somma' è una funzione definita altrove
3. Invocazione
Si usa il puntatore come se fosse il nome della funzione:
double risultato = puntoOperazione(5.5, 10.2);

Esempio di Applicazione: Il "Callback"

I puntatori a funzione sono fondamentali per scrivere funzioni generiche. Considera una funzione che esegue un calcolo tra due numeri, ma lascia decidere a chi la chiama quale operazione usare:

// Definiamo due funzioni con la stessa firma
int Somma(int a, int b) { return a + b; }
int Moltiplica(int a, int b) { return a * b; }

// Funzione che usa un puntatore a funzione come parametro (Callback)
void EseguiOperazione(int x, int y, int (*f_ptr)(int, int)) {
    // Invochiamo la funzione puntata da f_ptr
    cout << "Risultato: " << f_ptr(x, y) << endl;
}

int main() {
    // Dichiarazione di un puntatore a funzione
    int (*ptr)(int, int);

    // Puntiamo alla funzione Somma e la eseguiamo
    ptr = Somma;
    EseguiOperazione(10, 5, ptr); // Output: 15

    // Puntiamo alla funzione Moltiplica
    ptr = Moltiplica;
    EseguiOperazione(10, 5, ptr); // Output: 50

    return 0;
}
Attenzione alla firma:
Un puntatore a funzione è "schizzinoso": può puntare solo a funzioni che hanno esattamente lo stesso tipo di ritorno e lo stesso numero/tipo di parametri dichiarati. Se provi ad assegnare una funzione con parametri diversi, il compilatore genererà un errore di tipo.

Puntatori a Puntatori (Indirezione Multipla)

Un puntatore può puntare a un altro puntatore. Questo è indicato con il doppio asterisco **.

È utile per modificare l'indirizzo contenuto in un puntatore all'interno di una funzione, o per gestire matrici dinamiche.

int main() {
    int i = 2, j = 7;
    int* pi = &i;
    int* pj = &j;

    int** q1 = &pi; // q1 punta a pi

    cout << **q1 << endl; // Stampa 2 (valore di i)

    *q1 = pj; // Modifica pi! Ora pi punta a j
    
    cout << **q1 << endl; // Stampa 7
    cout << *pi << endl;  // Stampa 7 (pi è cambiato)
}

Funzioni

Concetto e Chiamata

  • Visibilità (Scope): Nomi di variabili locali e argomenti formali sono "visibili" solo all'interno della funzione in cui sono definiti.
  • Chiamata:
    1. Argomenti formali e variabili locali vengono allocati in memoria.
    2. Argomenti formali vengono inizializzati (copiati) con i valori degli argomenti attuali (passaggio per valore).
    3. La funzione esegue le sue elaborazioni.
    4. Al termine (return), argomenti e variabili locali vengono deallocati.
  • Istanza di funzione: Ogni chiamata crea una nuova "istanza" con una nuova copia delle variabili. I valori non vengono conservati tra chiamate diverse.
int fatt (int n) {
    int ris = 1;
    for(int i = 2; i <= n; i++)
        ris *= i;
    return ris;
}
int main() {
    cout << fatt(4); // Stampa 24
}

N.B: Una funzione deve essere dichiarata (o definita) prima di essere usata (chiamata). È prassi comune mettere la "firma" (prototipo) della funzione sopra il main e il corpo (definizione) sotto.

Argomenti e Variabili Locali

//funzione che somma gli interi compresi fra due interi dati (con m>=n)
int SommaInteri (int n, int m) {
    int s = n;
    for(int i = n + 1; i <= m; i++) {
        s += i;
    }
    return s;
}

int main() {
    int a, b;
    cout << "Inserisci interi: ";
    cin >> a >> b;
    cout << SommaInteri(a, b); // passaggio di variabili locali per valore
}

In questo caso, n e m sono copie di a e b. Vengono create celle differenti in memoria.

Funzioni Void

Una funzione void è una funzione che non restituisce alcun valore con return. Esegue solo azioni (es. stampa).

void ScriviAsterischi (int n) {
    for(int i = 0; i < n; i++) {
        cout << "*";
    }
}
int main() {
    int i;
    cout << "Quanti asterischi? ";
    cin >> i;
    ScriviAsterischi(i);
}
Funzioni senza Argomenti

Una funzione può essere definita senza argomenti (void Funzione(void)). Questo è utile se la funzione usa variabili globali o non necessita di input.

using namespace std;
const int N = 20; // Variabile globale

void ScriviAsterischi (void) {
    for(int i = 0; i < N; i++) {
        cout << "*";
    }
    cout << endl;
}

int main() {
    ScriviAsterischi(); // Non servono parametri
}

Funzioni Ricorsive

Una funzione ricorsiva è una funzione che, al suo interno, richiama sé stessa. È essenziale che ci sia un caso base che termina la ricorsione.

Esempio: Fattoriale

int Fattoriale (int n) {
    if (n == 0) {
        return 1; // Caso Base
    } else {
        return n * Fattoriale(n - 1); // Passo Ricorsivo
    }
}

int main() {
    int n;
    cout << "Inserisci un valore per calcolarne il fattoriale: ";
    cin >> n;
    cout << "Il fattoriale di " << n << " e': " << Fattoriale(n);
}

Modalità di Esecuzione (per Fattoriale(3)):

  1. Fattoriale(3) chiama return 3 * Fattoriale(2)
  2. Fattoriale(2) chiama return 2 * Fattoriale(1)
  3. Fattoriale(1) chiama return 1 * Fattoriale(0)
  4. Fattoriale(0) (Caso Base) restituisce 1
  5. Fattoriale(1) calcola return 1 * 1 (restituisce 1)
  6. Fattoriale(2) calcola return 2 * 1 (restituisce 2)
  7. Fattoriale(3) calcola return 3 * 2 (restituisce 6)

Ogni funzione può essere formulata in maniera iterativa (con un ciclo for o while), che è spesso più efficiente. Tuttavia, la ricorsione è più chiara per certi problemi.


Array

Definizione di Array

Un array è un vettore composto di celle contigue in memoria (una attaccata all'altra). Il tipo di un array dipende dai sui elementi. La dichiarazione e definizione sono contestuali (non si può dichiarare un array senza definirne la dimensione).

int main() {
    const int N = 6;
    int a[4] = {0, 1, 2, 3}; // array di 4 elementi
    int b[N] = {0, 1, 2, 3}; // array di 6 elementi (gli ultimi 2 sono 0)
    
    //b[0]++; // OK: restitusce un l-value
    
    cout << "Dimensioni array (bytes): ";
    cout << sizeof a << '\t' << sizeof b << endl; // 16   24 (4*4 e 6*4)

    cout << "Numero di elementi: ";
    cout << sizeof a / sizeof(int) << '\t'; // 4
    cout << sizeof b / sizeof(int) << endl; // 6
    
    // Stampa array b (elementi 0, 1, 2, 3, 0, 0)
    for (int i = 0; i < N; i++)
        cout << b[i] << '\t';
    cout << endl;

    // ERRORE! Accesso fuori dai limiti (Out of Bounds)
    // Stampa array a (elementi 0, 1, 2, 3, e poi spazzatura)
    // Nessun controllo sul valore degli indici in C++!
    for (int i = 0; i < N; i++) // ERRORE: i < N (6) invece di i < 4
        cout << a[i] << '\t';
    cout << endl;
    
    return 0;
}

Operatori su Array

Gli operatori di assegnamento (=), confronto (==, >) e aritmetici (+) non sono definiti per gli array.

Quando si usa il nome di un array (es. v) senza indici, questo "decade" a un puntatore alla sua prima cella (&v[0]).

Di conseguenza, i confronti (v == u) confrontano gli indirizzi di memoria, non il contenuto dell'array.

int main() {
    const int N = 5;
    int u[N] = {0, 1, 2, 3, 4};
    int v[N] = {5, 6, 7, 8, 9};

    // v = u; // ERRORE: assegnamento non permesso tra array

    cout << "Ind. v: " << v << "\t Ind. u: " << u << endl;
    
    if (v == u) // Confronta gli indirizzi di v[0] e u[0]
        cout << "Array uguali" << endl;
    else
        cout << "Array diversi" << endl; // Stampa questo
    
    return 0;
}

Puntatori e Aritmetica degli Array

Si può accedere alle celle di un array usando un puntatore e l'aritmetica dei puntatori.

Se p è un puntatore a T (T*), p+1 rappresenta l'indirizzo dell'oggetto T consecutivo in memoria. Nei sistemi a 32 bit (int = 4 byte), p+1 equivale all'indirizzo di p + 4 byte.

Le seguenti notazioni sono equivalenti:

int main() {
    int v[4];
    int* p = v; // p punta a v[0] (v decade a &v[0])

    *p = 1;       // v[0] = 1
    *(p + 1) = 10;   // v[1] = 10
    p += 3;        // p ora punta a v[3]
    *(p - 1) = 100;  // v[2] = 100
    *p = 1000;      // v[3] = 1000

    p = v; // p torna a puntare a v[0]
    
    // Stampa: v[4] = [1   10  100 1000]
    cout << "v[4] = [" << *p;
    for (int i = 1; i < 4; i++)
        cout << '\t' << *(p + i);
    cout << "]" << endl;

    int* p1 = &v[1];
    int* p2 = &v[2];
    cout << p2 - p1 << endl; // 1 (differenza di puntatori in elementi)
    cout << (long)p2 - (long)p1 << endl; // 4 (differenza in byte, su 32bit)
    return 0;
}

Array come Argomenti di Funzioni

Quando un array viene passato a una funzione, "decade" automaticamente in un puntatore al suo primo elemento. La funzione non conosce la dimensione dell'array.

Per questo motivo, è necessario passare la dimensione dell'array come argomento separato.

Le seguenti firme di funzione sono equivalenti:

// Funzione che calcola la somma degli elementi
int somma (int v[], int dim) { // o int* v
    int s = 0;
    for (int i = 0; i < dim; i++)
        s += v[i]; // v[i] è *(v+i)
    return s;
}

int main() {
    int vett[] = {10, 11, 12, 13};
    int dim = sizeof(vett) / sizeof(int); // Calcola la dimensione (4)
    
    cout << "La somma degli elementi e': ";
    cout << somma(vett, dim) << endl; // 46
    return 0;
}

Per evitare modifiche accidentali all'array originale, si può dichiarare l'argomento come const:

// Gli elementi sono const: non possono essere modificati
int somma (const int v[], int n) {
    int s = 0;
    for (int i = 0; i < n; i++)
        s += v[i];
    return s;
}

void incrementa (const int v[], int n) {
    for (int i = 0; i < n; i++)
        // v[i]++; // ERRORE: v[i] è costante!
}

Array Multidimensionali (Matrici)

Sono array di array, identificati da Righe (R) e Colonne (C). int matr[R][C];

In memoria, la matrice non è bidimensionale. È memorizzata come un unico blocco contiguo di R * C celle, per righe (prima tutti gli elementi della riga 0, poi tutti quelli della riga 1, ecc.).

m[i][j] è equivalente a *(p + i*C + j), dove p è il puntatore a m[0][0].

Array Multidimensionali come Argomenti

Quando si passa una matrice a una funzione, la dimensione della prima dimensione (righe) può essere omessa, ma tutte le altre (colonne) devono essere specificate.

void stampa(const int m[][3], int r)

Questo è necessario perché il compilatore deve sapere la dimensione delle colonne (C) per calcolare la posizione di m[i][j] (come i*C + j).

using namespace std;
const int C = 3; // La dimensione delle colonne DEVE essere nota

// Inizializza la matrice m con valori i + j
void inizializza (int m[][C], int r) {
    for (int i = 0; i < r; i++)
        for (int j = 0; j < C; j++)
            m[i][j] = i + j;
}

// Stampa la matrice
void stampa (const int m[][C], int r) {
    for (int i = 0; i < r; i++) {
        for (int j = 0; j < C; j++)
            cout << m[i][j] << '\t';
        cout << endl;
    }
}

int main() {
    const int R = 2;
    int mat1[R][C];
    int mat2[2][5]; // ha 5 colonne, non 3
    
    inizializza(mat1, R);
    stampa(mat1, R);
    
    // inizializza(mat2, 2); // ERRORE: tipo argomento diverso (int[][5] vs int[][3])
    return 0;
}



Algoritmi Fondamentali

Ordinamento (Sorting)

Gli algoritmi di ordinamento servono a organizzare gli elementi di un vettore in ordine (crescente o decrescente). Vediamo i due principali trattati.

Selection Sort

L'ordinamento per selezione si basa sulla ricerca iterativa dell'elemento più piccolo all'interno della parte non ordinata del vettore.

Logica dell'algoritmo:

  1. Si considera l'intero vettore. Si cerca il valore minimo.
  2. Si scambia il minimo trovato con l'elemento nella posizione corrente (inizialmente la posizione 0).
  3. Si restringe il campo di ricerca escludendo l'elemento appena ordinato.
  4. Si ripete il procedimento fino alla fine del vettore.
void selectionSort(T v[], int n) {
    int min;
    // Scorre tutto il vettore tranne l'ultimo elemento
    for (int i = 0; i < n - 1; i++) {
        min = i; // Ipotizzo che il primo sia il minimo
        
        // Cerco il vero minimo nella parte restante
        for (int j = i + 1; j < n; j++) {
            if (v[j] < v[min]) 
                min = j;
        }
        // Scambio l'elemento corrente con il minimo trovato
        scambia(v, i, min); 
    }
}
Bubble Sort

Il Bubble Sort (ordinamento a bolle) consiste nello scorrere il vettore più volte. A ogni passaggio, confronta coppie di elementi adiacenti e li scambia se non sono nell'ordine corretto.

Ottimizzazione: Se durante una passata completa non avvengono scambi, significa che il vettore è già ordinato ed è inutile continuare. Per questo si usa una variabile booleana (ordinato) per interrompere il ciclo anticipatamente.

void bubbleSort(T v[], int n) {
    bool ordinato = false;
    
    // Continua finché non è ordinato O finché non finiscono i passaggi
    for (int i = 0; i < n - 1 && !ordinato; i++) {
        ordinato = true; // Ipotizzo che sia ordinato
        
        // Scorre dal fondo verso l'inizio della parte non ordinata
        for (int j = n - 1; j >= i + 1; j--) {
            if (v[j] < v[j - 1]) {
                scambia(v, j, j - 1);
                ordinato = false; // Ho fatto uno scambio, non era ordinato
            }
        }
    }
}

Ricerca (Searching)

Ricerca Lineare (Sequenziale)

È il metodo più semplice: si scorre il vettore elemento per elemento partendo dall'inizio.

Ricerca Binaria (Dicotomica)

Molto più efficiente della ricerca lineare, ma ha un prerequisito fondamentale: il vettore deve essere già ordinato.

Logica (Divide et Impera):

  1. Si confronta l'elemento cercato con quello al centro del vettore.
  2. Se sono uguali, fine (trovato).
  3. Se l'elemento cercato è minore del centro, si ripete la ricerca solo nella metà sinistra.
  4. Se l'elemento cercato è maggiore del centro, si ripete la ricerca solo nella metà destra.
  5. Si ripete finché l'intervallo non si annulla.
bool ricbin(T v[], int inf, int sup, T k, int& pos) {
    // Calcola l'indice centrale
    int medio = (inf + sup) / 2;
    
    while (inf <= sup) {
        if (k > v[medio])
            inf = medio + 1; // Cerca nella metà destra
        else if (k < v[medio])
            sup = medio - 1; // Cerca nella metà sinistra
        else {
            pos = medio;     // Trovato
            return true;
        }
        medio = (inf + sup) / 2; // Ricalcola il centro
    }
    return false; // Non trovato
}

Stringhe

Concetto di Stringa (C-String)

In C++ (storicamente), le stringhe sono gestite in due modi: la libreria <string> (moderna) o gli array di caratteri (stile C).

Una C-String è un array di char che termina con il carattere nullo ('\0').

Operatori di I/O per C-String
int main () {
    const int LETTERE = 26;
    char str[100];
    int conta[LETTERE];

    for (int i = 0; i < LETTERE; i++)
        conta[i] = 0;

    cout << "Inserisci una stringa: ";
    cin >> str; // Legge solo fino al primo spazio

    for (int i = 0; str[i] != '\0'; i++) {
        if (str[i] >= 'a' && str[i] <= 'z')
            ++conta[str[i] - 'a'];
        else if (str[i] >= 'A' && str[i] <= 'Z')
            ++conta[str[i] - 'A'];
    }
    
    for (int i = 0; i < LETTERE; i++)
        cout << (char)('a' + i) << ": " << conta[i] << '\t';
    
    cout << endl;
    return 0;
}
/* Output:
Inserisci una stringa: prova
a: 1  b: 0  c: 0  d: 0  e: 0  f: 0  g: 0  h: 0  i: 0  j: 0  k: 0  l: 0  m: 0  
n: 0  o: 1  p: 1  q: 0  r: 1  s: 0  t: 0  u: 0  v: 1  w: 0  x: 0  y: 0  z: 0
*/

Libreria <cstring>

Fornisce funzioni per manipolare le C-String.

1. strcpy(char* dest, const char* sorg)

2. strcat(char* dest, const char* sorg)

3. strlen(const char* string)

4. strcmp(const char* s1, const char* s2)

5. strchr(const char* string, char c)

#include <iostream>
#include <cstring>
using namespace std;

int main() {
    const int N = 30;
    char s1[] = "Corso";
    char s2[] = "di";
    char s3[] = "Informatica\n";
    char s4[N] = "Corso";
    
    cout << "Dimensione array s1 e s4" << endl;
    cout << sizeof(s1) << " " << sizeof(s4) << endl; // 6 30 (sizeof include '\0')
    
    cout << "Lunghezza stringhe s1 e s4" << endl;
    cout << strlen(s1) << " " << strlen(s4) << endl; // 5 5 (strlen NON include '\0')
    
    if (strcmp(s1, s4) == 0)
        cout << "Stringhe uguali" << endl;
    else
        cout << "Stringhe diverse" << endl;
        
    if (strcmp(s1, s2) == 0)
        cout << "Stringhe uguali" << endl;
    else
        cout << "Stringhe diverse" << endl << endl;

    char s5[N];
    strcpy(s5, s1); // s5 = "Corso"
    strcat(s5, s2); // s5 = "Corso di"
    strcat(s5, s3); // s5 = "Corso di Informatica\n"
    
    cout << "Concatenazione di s1, s2 e s3" << endl;
    cout << s5 << endl;
    
    char* s = strchr(s5, 'I'); // Trova la 'I' maiuscola
    cout << "Stringa dalla prima istanza di I" << endl;
    cout << s << endl;
    
    return 0;
}


Strutture e Unioni

Strutture (struct)

Una struttura è un'aggregazione di elementi (detti membri) anche di tipo diverso, identificati da un nome. Rappresenta un oggetto complesso (es. un punto, una data, un record).

Ogni membro ha il proprio spazio di memoria; i membri esistono contemporaneamente.

struct punto {
    double x; // campo membro
    double y; // campo membro
};

int main() {
    punto r;
    r.x = 3.0;       // Accesso con selettore punto (.)
    r.y = 10.5;
    
    punto* p = &r;
    cout << p->x;    // Accesso tramite puntatore con freccia (->)
}

Allineamento e Padding nelle Strutture

L'occupazione di memoria di una struct non è sempre la semplice somma delle dimensioni dei suoi membri. Per ottimizzare l'accesso ai dati (allineamento), il compilatore inserisce dei byte "vuoti" chiamati padding.

Le CPU leggono la memoria in "parole" (word) di 4 o 8 byte. Se un membro inizia a un indirizzo non allineato, la CPU dovrebbe eseguire due letture invece di una. Il padding garantisce che ogni membro inizi a un indirizzo di memoria multiplo della sua dimensione, migliorando drasticamente le prestazioni.

struct Esempio {
    char a;     // 1 byte
    // --- 3 byte di padding inseriti qui ---
    int b;      // 4 byte (deve iniziare su un indirizzo multiplo di 4)
    char c;     // 1 byte
    // --- 3 byte di padding finale ---
};

int main() {
    // Risultato: 12 byte, non 6! (1+3+4+1+3)
    cout << "Dimensione struct: " << sizeof(Esempio);
}
Consiglio di ottimizzazione: Per ridurre lo spreco di memoria dovuto al padding, è buona norma ordinare i membri della struttura dal tipo più grande al più piccolo (es. prima i double, poi gli int, infine i char).

Operazioni su Strutture

struct vettore { 
    int vv[3]; 
};

// Passaggio per Riferimento (modifica l'originale)
void incrementa(vettore* v) {
    v->vv[0]++;
}

// Passaggio per Valore (copia, non modifica l'originale)
void stampa(vettore v) {
    cout << v.vv[0] << endl;
}

Unioni (union)

Le Unioni sono simili alle strutture, ma con una differenza fondamentale nella gestione della memoria.

union Dato {
    int i;    // 4 byte
    double d; // 8 byte
}; // Dimensione totale: 8 byte (condivisi)

int main() {
    Dato val;
    val.i = 10;
    cout << val.i << endl; // Stampa 10
    
    val.d = 20.5; // Sovrascrive i!
    cout << val.d << endl; // Stampa 20.5
    cout << val.i << endl; // Stampa valore "sporco" (bit di double letti come int)
}

Strutture Dati

Pila (Stack)

La Pila è una struttura dati che segue la logica LIFO (Last In First Out). Come una pila di libri, il primo libro messo (push) sarà l'ultimo a essere tolto (pop).

Può essere implementata sia con un Vettore (array) sia con una Lista.

Coda (Queue)

La Coda è una struttura dati che segue la logica FIFO (First In First Out). Come la coda al supermercato, il primo elemento inserito è il primo a uscire.


Liste Collegate

Concetto e Struttura

Le Liste sono strutture dati dinamiche molto versatili. Dobbiamo immaginarle come dei “vagoni” carichi di informazioni che si agganciano l’uno all’altro tramite puntatori.

Creazione e Operazioni in Testa

Creazione di una lista:

  1. Leggere l’informazione.
  2. Allocare un nuovo elemento con l’informazione da inserire.
  3. Collegare il nuovo elemento al primo elemento della lista.
  4. Aggiornare il puntatore di testa della lista a puntare al nuovo elemento.
typedef elem* lista; // Il tipo lista è un puntatore al primo nodo

lista crealista(int n) {
    lista p0 = 0; 
    elem* p;
    for (int i = 0; i < n; i++) {
        p = new elem;
        cin >> p->inf;
        p->pun = p0;    // Collega al vecchio primo elemento
        p0 = p;         // Aggiorna la testa
    }
    return p0;
}

Inserimento ed Estrazione in Testa:

// INSERIMENTO IN TESTA
void instesta(lista& p0, T a) {
    elem* p = new elem;
    p->inf = a;
    p->pun = p0;
    p0 = p;
}

// ESTRAZIONE DALLA TESTA
bool esttesta(lista& p0, T& a) {
    elem* p = p0;
    if (p0 == 0) 
        return false;
    
    a = p0->inf;
    p0 = p0->pun;
    delete p;
    return true;
}

Inserimento ed Estrazione in Coda

Operare in coda richiede la scansione della lista (se non si usa un puntatore ausiliario).

// INSERIMENTO IN CODA
void insfondo(lista& p0, T a) {
    elem* p;
    elem* q;
    
    // Scorre fino alla fine
    for (q = p0; q != 0; q = q->pun)
        p = q;

    q = new elem;
    q->inf = a;
    q->pun = 0;

    if (p0 == 0) 
        p0 = q;          // Lista vuota
    else 
        p->pun = q;      // Collega all'ultimo
}

// ESTRAZIONE DALLA CODA
bool estfondo(lista& p0, T& a) {
    elem* p = 0;
    elem* q;
    
    if (p0 == 0) 
        return false;

    // q scorre fino all'ultimo, p lo segue
    for (q = p0; q->pun != 0; q = q->pun)
        p = q;

    a = q->inf;
    
    // Controlla se si estrae il primo elemento
    if (q == p0) 
        p0 = 0;
    else 
        p->pun = 0;
    
    delete q;
    return true;
}

Estrazione da Lista Ordinata

bool estrordinata(lista& p0, T a) {
    elem* p = 0; 
    elem* q;
    
    // Scorre finché non trova l'elemento o supera il valore
    for (q = p0; q != 0 && q->inf < a; q = q->pun)
        p = q;

    if ((q == 0) || (q->inf > a)) 
        return false;

    if (q == p0) 
        p0 = q->pun;     // Estrazione in testa
    else 
        p->pun = q->pun; // Estrazione intermedia
    
    delete q;
    return true;
}

Liste con Puntatore Ausiliario e Complesse

Per ottimizzare l'inserimento in coda (rendendolo O(1)), si mantiene un puntatore all'ultimo elemento.

struct lista_n {
    elem* p0; // Testa
    elem* p1; // Coda
};

// Creazione lista con puntatore ausiliario
lista_n crealista1(int n) {
    elem* p;
    lista_n li = {0, 0};
    
    if (n >= 1) {
        p = new elem;
        cin >> p->inf;
        p->pun = 0;
        li.p0 = p;
        li.p1 = p; // p1 punta all'unico elemento
        
        for (int i = 2; i <= n; i++) {
            p = new elem;
            cin >> p->inf;
            p->pun = li.p0; 
            li.p0 = p; // Inserimento in testa, p1 non cambia
        }
    }
    return li;
}

// Inserimento in fondo O(1)
void insfondo1(lista_n& li, T a) {
    elem* p = new elem;
    p->inf = a;
    p->pun = 0;
    
    if (li.p0 == 0) {
        li.p0 = p;
        li.p1 = p;
    } else {
        li.p1->pun = p; // Collega l'attuale ultimo al nuovo
        li.p1 = p;      // Aggiorna p1
    }
}

Liste Complesse (Doppiamente Collegate):

Sono liste con due puntatori per nodo: uno all'elemento precedente (prec) e uno a quello successivo (succ).

struct nu_elem {
    T inf;
    elem* prec;
    elem* succ;
};

struct lista_c {
    nu_elem* p0;
    nu_elem* p1;
};

Argomenti di Default

Un parametro con valore di default è un parametro formale a cui viene assegnato un valore nell’intestazione della funzione. Se alla chiamata non si fornisce quell’argomento, il compilatore inserisce automaticamente il valore di default nella chiamata al momento della compilazione.

double peso(double lung, double diam = 10, double dens = 15);

// Chiamate:
peso(125) // diventa peso(125, 10, 15)
peso(35, 5) // diventa peso(35, 5, 15)

Regole Importanti (Riassunto)

Ambiguità e Correttezza Formale

L'ambiguità si verifica quando non tutti i parametri hanno un valore di default:

// 1) Dichiarazione con un parametro obbligatorio (nLati):
perimetro(int nLati, double lunghLato = 1.0);

void f() {
    double p = perimetro(); // ERRORE: manca nLati e nLati non ha default
}

// 2) Dichiarazione con tutti i parametri di default:
double perimetro(int nLati = 3, double lunghLato = 1.0);

void g() {
    double p = perimetro(); // OK: entrambi i parametri hanno default
}

Nella prima dichiarazione, nLati è obbligatorio, rendendo la chiamata perimetro() senza argomenti errata. Nella seconda, il compilatore sostituisce la chiamata con perimetro(3, 1.0).

Esempio pratico con calcolo:

double peso(double lung, double diam = 10, double dens = 15) {
    diam /= 2;
    return (diam * diam * 3.14 * lung * dens);
}
// double p1 = peso(125); // peso(125,10,15)
// double p2 = peso(35, 5); // peso(35,5,15)
// double p3 = peso(20, 4, 8); // peso(20,4,8)

Overloading (Sovraccarico)

L’overloading è una funzionalità del linguaggio C++ che permette di definire più funzioni o metodi con lo stesso nome ma con parametri diversi (firma diversa).

Una funzione è overloadata se differisce per:

  1. Numero argomenti (es. somma(int a, int b) vs somma(int a, int b, int c)).
  2. Tipo argomenti (es. stampa(int i) vs stampa(std::string s)).
  3. Ordine argomenti (es. funz(int a, double b) vs funz(double a, int b)).

NB: Il tipo di ritorno non conta; non è possibile avere due funzioni chiamate nello stesso modo (stessa firma) ma con un tipo di ritorno diverso.

È possibile applicare l’overloading anche ad operatori (come +, -, *, / e altri simboli) utilizzando come argomenti oggetti di classi o strutture per rendere il codice più leggibile ed intuitivo.

Argomenti Differenti (Esempio Tipo)

int massimo(int a, int b) {
    cout << "Massimo per interi " << endl;
    return a > b ? a : b;
}

double massimo(double a, double b) {
    cout << "Massimo per double" << endl;
    return a > b ? a : b;
}

int main() {
    cout << massimo(10, 15) << endl; // Chiama int massimo(int, int)
    cout << massimo(12.3, 13.5) << endl; // Chiama double massimo(double, double)
    // cout << massimo(12.3, 13) << endl; // ERRORE: ambiguo
    return 0;
}

Const e non Const

Si può overloadare una funzione se una ha tipo const e una no, sono differenti.

int massimo(const int v[], int n) {
    cout << "Array const ";
    int m = v[0];
    for (int i = 1; i < n; i++)
    m = (m >= v[i]) ? m : v[i];
    return m;
}

int massimo(int v[], int n) {
    cout << "Array non const ";
    int m = v[0];
    for (int i = 1; i < n; i++)
    m = (m >= v[i]) ? m : v[i];
    return m;
}

int main() {
    const int N = 5;
    const int cv[N] = {1, 10, 100, 10, 1};
    cout << massimo(cv, N) << endl;
    int v[N] = {1, 10, 100, 10, 1};
    cout << massimo(v, N) << endl;
    return 0;
}

Overloading e Argomenti di Default

L’aggiunta di un argomento di default può impedire la compilazione per ambiguità nella chiamata.

Scenario 1: Overload senza ambiguità

double perimetro(int nLati, double lunghLato) {
    return nLati * lunghLato;
}
int perimetro(int lato) {
    return 4 * lato; // perimetro del quadrato
}

int main() {
    cout << perimetro(5); // OK: stampa 20, chiama perimetro(int lato)
    return 0;
}

Scenario 2: Overload con Ambiguita (Aggiunta del Default)

double perimetro(int nLati, double lunghLato = 2.5) { // Aggiunto default
    return nLati * lunghLato;
}
int perimetro(int lato) {
    return 4 * lato; // perimetro del quadrato
}

int main() {
    cout << perimetro(5); // ERRORE! Chiamata ambigua.
    // Ci sono due funzioni che possono prendere un int come valore!
    return 0;
}

Scenario 3: Overload con Tipi Distinti (Risoluzione Ambiguita)

double perimetro(int nLati, double lunghLato = 2.5) {
    return nLati * lunghLato;
}
int perimetro(double lato) { // Firma diversa (double)
    return 4 * lato; // perimetro del quadrato
}

int main() {
    cout << perimetro(5.7); // OK: stampa 22 (22.8 troncato a 22), chiama perimetro(double lato)
    return 0;
}

Questa versione del codice funziona perché possono coesistere due funzioni con singolo argomento, una avente argomento intero (la prima, dopo aver usato il default per il secondo) e l'altra argomento double (overloading delle funzioni).


Dichiarazioni Typedef

Le dichiarazioni typedef sono uno strumento per creare degli identificatori rispetto ai tipi in C++ (chiamati nomi typedef) e che possiamo usare per riferirsi ai tipi durante le dichiarazioni.

NB: typedef non introduce un nuovo tipo!

int main() {
    int i = 1;
    typedef int* intP;
    intP p = &i; // p è un puntatore a int
    cout << *p << endl; // 1
    
    typedef int vett[5]; // vett è un alias per un array di 5 interi
    vett v = {1, 10, 100, 10, 1};
    cout << "v = [" << v[0];
    for (int j = 1; j < 5; j++)
        cout << ' ' << v[j];
    cout << ']' << endl;

    typedef int intero;
    int a = 4;
    intero b = a; // OK, typedef non introduce un nuovo tipo
    cout << a << '\t' << b << endl; // 4 4
    return 0;
}

Effetti Collaterali (Side Effects)

Definizione e Modalità di Generazione

Una funzione in C++ produce due tipi di risultati:

Una funzione void senza argomenti e senza effetti collaterali è considerata inutile. Tuttavia, funzioni come quelle di stampa (cout) sono utili proprio perché il loro effetto collaterale è modificare lo stream di output (una variabile globale).

Gli effetti collaterali si generano principalmente attraverso:

  1. Modifica di variabili Globali: La funzione modifica una variabile dichiarata all'esterno di essa.
    int n = 5; // Variabile globale void incrementaGlobale() { ++n; // n passa da 5 a 6 }
  2. Passaggio per Riferimento (&): La funzione riceve un riferimento alla variabile originale del chiamante, non una copia.
    void incrementaRef(int &n) { ++n; // Modifica la variabile originale ('a' nel main) }
  3. Passaggio tramite Puntatore (*): La funzione riceve l'indirizzo di memoria della variabile e ne modifica il contenuto tramite deferenziazione.
    void incrementaPtr(int *p) { (*p)++; // Modifica il valore puntato }

Rischi e Ordine di Valutazione

L'uso di funzioni con effetti collaterali all'interno di espressioni complesse può portare a errori logici gravi e risultati imprevedibili. Questo accade perché il compilatore potrebbe non valutare gli operandi nell'ordine che ci si aspetta.

Considerando una funzione "pericolosa" che incrementa il maggiore tra due puntatori ma restituisce il valore NON ancora incrementato (post-incremento):

int incremMag(int *pa, int *pb) { if (*pa > *pb) return (*pa)++; // Restituisce *pa, poi lo incrementa else return (*pb)++; // Restituisce *pb, poi lo incrementa }

Dati iniziali: i=10, j=100.

Gli effetti collaterali sono potenti (permettono I/O e modifica di strutture dati), ma non dovrebbero mai essere usati all'interno di espressioni matematiche complesse, poiché l'ottimizzazione del compilatore può alterare il risultato finale.


Gestione della Memoria

Classi di Memorizzazione

La classe di memorizzazione risponde a due domande fondamentali sulla vita di una variabile:

  1. Durata (Lifetime): Quando viene creata e quando viene distrutta?
  2. Visibilità (Scope): Chi può vederla e usarla?

1. Classe Automatica (La "Variabile Usa e Getta")

È il comportamento di default per le variabili dichiarate dentro una funzione o un blocco { ... }.

int contaChiamateErrata() {
    int n = 0; // Classe AUTOMATICA
    return ++n;
}

Risultato: Viene creato un nuovo spazio per n, impostato a 0, incrementato a 1 e restituito. La variabile viene distrutta. Restituirà sempre 1. Non sa quante volte è stata chiamata prima.

2. Classe Statica (La "Variabile con Memoria")

Una variabile statica ha una proprietà speciale: sopravvive per tutta la durata del programma, anche se la sua visibilità è limitata.

Possiamo avere due tipi di statica (variabile persistente):

A. Statica Locale (Dentro una funzione):
È visibile solo dentro la funzione, ma mantiene il suo valore tra una chiamata e l’altra.

int contaChiamate() {
    static int n = 0; // Eseguita SOLO la prima volta
    ++m; // Modifica la variabile globale statica
    return ++n; // Ricorda il valore precedente!
}

Risultato: Conta effettivamente le chiamate (1, 2, 3...).

B. Statica Globale (Fuori dalle funzioni):
Essendo dichiarata static fuori dalle funzioni, questa variabile è visibile in tutto il file corrente, ma è nascosta agli altri file del progetto (collegamento interno). Vive per sempre.

3. Classe Dinamica (La "Variabile Manuale")

Qui il programmatore ha il controllo totale. La variabile non nasce e muore automaticamente; sei tu a decidere.

int main() {
    // 'p' è una variabile AUTOMATICA (sullo stack)
    // che contiene l'indirizzo di un intero.
    int *p;
    
    // L'intero creato con 'new' è DINAMICO (sullo heap).
    // Non ha nome, ci arriviamo solo tramite *p.
    p = new int;
    
    *p = 10; // Uso la variabile dinamica
    delete p; // Distruggo la variabile dinamica
    
    // Se non lo faccio, quell'int resta in memoria per sempre!
    return 0;
}

Segmento RODATA (Read-Only Data)

Il segmento .rodata è un'area di memoria dedicata esclusivamente ai dati immutabili. Durante la fase di caricamento del programma, il Sistema Operativo marca queste pagine di memoria come "sola lettura". Qualsiasi tentativo di scrittura in quest'area da parte del software provocherà un'eccezione hardware immediata, solitamente un Access Violation o Segmentation Fault.

In questo spazio risiedono le costanti globali e, soprattutto, le stringhe letterali. Quando scrivi un testo tra virgolette nel codice, quel testo non "nasce" sullo stack, ma viene cablato in questa zona protetta per essere condiviso in tutto il programma senza rischio di corruzione.

// La stringa letterale risiede fisicamente in .RODATA
const char* etichetta = "SISTEMA_OPERATIVO"; 

int main() {
    // Costante dichiarata a livello locale
    const int SOGLIA_CRITICA = 1024; 

    // Errore Semantico: etichetta[0] = 'A'; 
    // Crash a runtime perché si tenta di scrivere in .RODATA

    return 0;
}

Segmento BSS (Block Started by Symbol)

Il segmento .bss rappresenta una sezione di memoria statica ottimizzata per l'efficienza. Qui vengono allocate le variabili globali e le variabili static che non sono state inizializzate esplicitamente dal programmatore o che sono state inizializzate a zero.

Una caratteristica fondamentale del BSS è che non occupa spazio fisico all'interno dell'eseguibile salvato su disco. Il file binario contiene solo un'istruzione che indica la dimensione totale richiesta. All'avvio del programma, il Sistema Operativo riserva questa memoria in RAM e garantisce la pulizia automatica, impostando ogni singolo bit a zero. Questo assicura che variabili non inizializzate abbiano un valore prevedibile (0, 0.0 o NULL).

// Allocata in .BSS (non occupa spazio sul disco, azzerata a runtime)
int contatoreGlobale; 
static double bufferDati[5000]; 

int main() {
    // Anche senza assegnazione, il valore è garantito essere 0
    if (contatoreGlobale == 0) {
        // Eseguito sempre grazie alle proprietà del segmento BSS
    }
    return 0;
}
Confronto Tecnico: A differenza del segmento .data, dove ogni valore deve essere memorizzato nel file eseguibile (es. int x = 123;), il segmento .bss permette di avere eseguibili molto piccoli anche se dichiarano array globali di enormi dimensioni.

Memoria Dinamica (Heap)

Le tecniche di programmazione standard (lavorando con lo Stack) richiedono che il valore di una variabile (come la dimensione di un array) sia conosciuto a tempo di compilazione.

Per superare questo limite e poter dimensionare un array in base al volere dell'utente (a tempo di esecuzione), si utilizza la Memoria Dinamica (o Memoria Libera).

Il meccanismo di memoria dinamica permette di allocare variabili nell'area Heap (una parte della RAM) e di accedervi tramite puntatori. Queste variabili sono chiamate variabili dinamiche.

Allocazione con new

Per allocare una variabile nell'Heap si utilizza la keyword new:

int main() { int* q; q = new int; // Alloca un singolo intero *q = 10; cout << *q << endl; // Stampa 10

int* p;
int n;
cin >> n;
p = new int[n]; // Alloca un array di n interi
// Utilizzo dell'array:
for (int i = 0; i < n; i++)
    p[i] = i; // o *(p + i) = i;
}

Gestione Errori con set_new_handler()

Per verificare il buon esito dell’operatore new si può utilizzare la funzione set_new_handler() (dichiarata in <new>), che prende come argomento una funzione void (definita dall'utente) da lanciare nel caso in cui l'allocazione nella memoria dinamica fallisca.

void myhandler() { cerr << "Memoria libera non disponibile" << endl; exit(1); }

int main() { int n; set_new_handler(myhandler); // Imposta la funzione di gestione errori cout << "Inserisci la dimensione" << endl; cin >> n; // La chiamata a new qui può lanciare myhandler() in caso di esaurimento memoria. int** m = new int*[n]; }

Deallocazione con delete

Le variabili allocate nell'Heap esisteranno fino a quando non verranno deallocate con il comando delete o fino a quando il programma non terminerà.

Deallocazione e Errori Comuni:

int main() { int n = 12; int* p = new int(10);

delete p; // Deallocazione del singolo intero
// cout << *p << endl; // SBAGLIATO, ma non segnala errore (comportamento indefinito)

p = 0; // o nullptr, per prevenire il dangling pointer
// cout << *p << endl; // SEGNALA ERRORE A TEMPO DI ESECUZIONE (dereferenziazione di nullptr)

int* m = new int[n];
delete[] m; // Deallocazione corretta di un array (uso delle parentesi [])

// delete m; // SBAGLIATO per un array allocato con new int[n]
// delete &n; // ERRORE – n non è allocato dinamicamente con new
}

Gestione unità di codice

Unità di Compilazione

L’unità di compilazione è una parte del compilatore che si occupa di prendere il file di codice e trasformarlo nel file oggetto intermedio, interpretabile (o quasi) dal processore.

In C++ i file di compilazione costituiscono tutti i file sorgente ed i file inclusi tramite #include.

NOTA: Se il file non fa parte delle librerie deve essere incluso tramite l’utilizzo delle virgolette (e non con <..> come per i file facenti parte delle librerie).

#include <iostream>
using namespace std;

int i; // visibilità a livello di unità di compilazione

void leggi() // visibilità a livello di unità di compilazione
{
    cout << "Inserisci un numero intero " << endl;
    cin >> i;
}

void scrivi() // visibilità a livello di unità di compilazione
{
    cout << i << endl;
}

int main()
{
    leggi();
    scrivi();
}

Output:

Inserisci un numero intero
2
2

In pratica gli elementi visibili a livello di unità di compilazione possono essere accessibili da altri file, ma soprattutto possono accedere ad ogni variabile definita a livello di file (quindi globale, visibile ad unità di compilazione), in questo caso la i è sempre accessibile per tutte le funzioni.

Ovviamente nel caso in cui una funzione sia definita ad unità di compilazione non potrà accedere a variabile non definite al suo stesso livello.

#include <iostream>
using namespace std;

int i = 1; // visibilità a livello di unità di compilazione

int main()
{
    cout << i << endl; // 1
    {
        int i = 5; // visibilità locale
        cout << ::i << '\t' << i << endl; // 1    5
        {
            int i = 10; // visibilità locale
            cout << ::i << '\t' << i << endl; // 1    10
        }
    }
    cout << ::i << endl; // 1
    return 0;
}

Spazio di Nomi (Namespace)

I namespace in C++ servono a organizzare il codice e evitare conflitti di nomi. Sono come "cartelle logiche" in cui puoi mettere variabili, funzioni, classi, strutture, ecc.

Con i namespace posso dichiarare variabili, funzioni o strutture con lo stesso nome e distinguerli per namespace.

namespace LibreriaA {
    void print() { /*...*/ }
}
namespace LibreriaB {
    void print() { /*...*/ }
}

// < ---Main--- >
LibreriaA::print();
LibreriaB::print();

using namespace LibreriaA; // direttiva
print(); // usa la funzione della ‘LibreriaA’

L’uso della direttiva using namespace può generare delle ambiguità.

int main() {
    uno::st ss1;
    using namespace uno;
    using namespace due;
    
    // st ss2; // ERRORE: Ambiguità se st esiste in entrambi
    due::st ss2; // using due::st; -> per ‘st’ uso il due
}

I namespace sono aperti: puoi dichiarare lo stesso namespace più volte in file diversi o nello stesso file, e aggiungere nuovi elementi ogni volta. Anche se appare in due blocchi separati, è lo stesso namespace; tutto si unisce.

namespace esempio {
    int a = 10;
}

// più avanti nel codice...
namespace esempio {
    void stampa() {
        std::cout << a << std::endl;
    }
}

Tutto ciò che viene dichiarato fuori da qualsiasi funzione, classe o namespace sta nel namespace globale. Puoi fare riferimento al namespace globale usando l’operatore :: senza nome davanti.


Collegamento (Linkage)

Partiamo dal presupposto che un programma può essere composto da più unità di compilazione, trattate prima singolarmente, e poi unite assieme per formare il file eseguibile. Per sviluppare un programma su più file abbiamo bisogno che siano collegati tra di loro; da qui possiamo definire due tipi di collegamento:

Regole di base:

Gli oggetti con visibilità a livello di unità di compilazione possono essere utilizzati in più unità di compilazione.

Utilizzo di extern

Per definire e dichiarare un identificatore/variabile/funzione in un file esterno rispetto a quello in cui abbiamo dichiarato questi ultimi usiamo la keyword extern.

Osservazione: Analogamente agli oggetti con visibilità a livello di unità di collegamento (oggetti condivisi), anche gli oggetti con collegamento esterno (oggetti globali) permettono la condivisione di informazioni fra funzioni.

/*------ FILE 1 ------*/
int a = 1;          // collegamento esterno
const int N = 0;
static int b = 10;  // static, collegamento interno

void f1(int a) {
    int k; /* ... */
}

static void f2() {  // static, collegamento interno
    /* ... */
}

struct punto {
    double x;
    double y;
};

punto p1; // collegamento esterno


/*------ FILE 2 ------*/
#include <iostream>
using namespace std;

extern int a; // solo dichiarazione
void f1(int); // solo dichiarazione
void f2();    // solo dichiarazione
void f3();    // solo dichiarazione
double f4(double, double); // definizione mancante (OK se non utilizzata)

int main() {
    cout << a << endl; // OK, 1
    
    extern int b; // dichiarazione
    // cout << b << endl; // ERRORE! b in FILE 1 ha collegamento interno

    f1(a); // OK
    
    // f2(); // ERRORE! f2 in FILE 1 è static
    // f3(); // ERRORE! f3 non definita
    // punto p2; // ERRORE! punto non dichiarato in FILE 2
    // p1.x = 10; // ERRORE! p1 non dichiarato in FILE 2
    
    return 0;
}

Stesso tipo in più unità di compilazione: Viene verificata solo l’uguaglianza tra gli identificatori del tipo; se l’organizzazione interna non è la stessa, si hanno errori logici a tempo di esecuzione.


Moduli e astrazioni

Regola del Cortocircuito

La regola del cortocircuito si basa sul valutare l’espressione di una condizione ed in base al funzionamento degli operatori utilizzati (OR o AND) determinare il risultato dell’espressione senza però valutarla tutta. In pratica, secondo la regola del cortocircuito, il compilatore può interrompere la valutazione dell’espressione logica non appena conosce il risultato finale.

Cortocircuito applicato all'AND (&&)

L’AND (&&) è un operatore logico dove se anche solo un’espressione è falsa, allora restituirà falso, mentre se entrambe le espressioni sono vere restituirà vero.

Supponendo di avere una condizione AND con due espressioni logiche del tipo: expr1 && expr2. Secondo il cortocircuito valuterò sicuramente la prima espressione a sinistra; se questa risulta false, tuttavia, per il comportamento dell’operatore AND, il risultato sarà sicuramente falso, perciò la valutazione dell’espressione si fermerà alla prima espressione.

Esempio 1: Valutazione ed effetti collaterali

int main() {
    int a = -1, b = 2;

    // La condizione è falsa perché expr1 (++a) diventa 0 (false)
    if (++a && --b) 
        cout << b << endl; // NON viene eseguito

    cout << a << ' ' << b << endl; // stampa: 0 2 (b non è stato decrementato)

    // Questa volta expr1 (++a) diventa 1 (true), quindi valuta anche --b
    if (++a && --b) 
        cout << a << ' ' << b << endl; // stampa: 1 1
    return 0;
}

Esempio 2: Sicurezza (Puntatori e Divisioni)

int main() {
    int num, den;
    cin >> num >> den;
    
    // AND logico: esempio 2 (evita divisione per zero)
    if (den != 0 && num / den > 5)
        cout << "Il rapporto è maggiore di cinque" << endl;

    // AND logico: esempio 3 (utile con liste/array dinamici)
    int* p = nullptr;
    allocaVettoreMemDin(p, 10);
    
    // Evita dereferenziazione di puntatore nullo
    if (p != nullptr && *p < 0)
        cout << "Il primo elemento è negativo" << endl;
    return 0;
}

Cortocircuito applicato all'OR (||)

L’OR (||) è un operatore logico dove se anche solo una espressione è vera allora il risultato sarà vero; l’unico caso in cui ritornerà falso sarà nel caso in cui entrambe le espressioni logiche ritorneranno falso.

Supponendo di avere una condizione OR con due espressioni logiche del tipo: expr1 || expr2. Secondo il cortocircuito valuterò sicuramente la prima espressione a sinistra; se questa risulta true, tuttavia, per il comportamento dell’operatore OR, il risultato sarà sicuramente vero, perciò la valutazione dell’espressione si fermerà alla prima espressione.

Esempio 1: Valutazione ed effetti collaterali

int main() {
    int a = -1, b = 2;

    // OR logico: expr1 (++a) è 0 (false) -> valuta expr2 (--b)
    if (++a || --b) 
        cout << b << endl; // stampa 1
    
    cout << a << ' ' << b << endl; // stampa 0 1

    // OR logico: expr1 (++a) è 1 (true) -> cortocircuito, expr2 non valutata
    if (++a || --b) 
        cout << a << ' ' << b << endl; // stampa 1 1
    return 0;
}

Esempio 2: Ottimizzazione e Controlli

int main() {
    int* p = nullptr;
    
    // Esegue l'allocazione SOLO se p non è già allocato (se vettoreAllocato ritorna false)
    if (vettoreAllocato(p) || allocaVettoreMemDin(p, 10))
        cout << "Il vettore risulta allocato" << endl;

    // OR logico: Esempio 3
    // Controlla se l'indice è invalido PRIMA di accedere all'array
    if (indiceInvalido(indice) || vett[indice] <= 0)
        cout << "Operazione di decremento fallita";
    else
        vett[indice]--; // decremento solo se l’indice è valido e vett[indice] > 0
        
    return 0;
}

Moduli

Un programma complesso, per facilitare la manutenzione del codice, può essere diviso in moduli, cioè parti di programma adibite a determinati compiti; a sua volta un modulo può essere composto da uno o più file.

Esistono due categorie di moduli:

ESEMPIO (Modulo Pila):

// ESEMPIO PILA
// MODULO SERVER
// file pila.h
#ifndef PILA_H
#define PILA_H

typedef int T;
const int DIM = 5;

struct pila {
    int top;
    T stack[DIM];
};

void inip(pila& pp);
bool empty(const pila& pp);
bool full(const pila& pp);
bool push(pila& pp, T s);
bool pop(pila& pp, T& s);
void stampa(const pila& pp);
#endif

// ESEMPIO PILA
// MODULO CLIENT
// file pilaMain.cpp
#include <iostream>
#include "pila.h"
using namespace std;

int main()
{
    pila st;
    inip(st);
    T num;
    
    if (empty(st))
        cout << "Pila vuota" << endl;

    for (int i = 0; i < DIM; i++)
        if (push(st, DIM - i))
            cout << "Inserito " << DIM - i << ". Valore di top: " << st.top << endl;
        else
            cerr << "Inserimento di " << i << " fallito" << endl;

    if (full(st))
        cout << "Pila piena" << endl;
}

Comandi di Compilazione per Moduli

Per compilare programmi distribuiti su più file si usano comandi separati. Ecco una tabella riassuntiva per l'uso di g++:

Scenario Azione Sistema Comando g++ File Generati Esecuzione
File Singolo Compilazione (solo oggetto) Win/Linux g++ -c main.cpp -o main.obj main.obj N/A
File Singolo Linking (genera eseguibile) Windows g++ main.obj -o main.exe main.exe main
File Multipli Compilazione Modulo 1 Win/Linux g++ -c main.cpp -o main.obj main.obj N/A
File Multipli Compilazione Modulo 2 Win/Linux g++ -c pila.cpp -o pila.obj pila.obj N/A
File Multipli Linking dei Moduli Windows g++ main.obj pila.obj -o main.exe main.exe main
Completo Compilazione e Linking Windows g++ main.cpp pila.cpp -o main.exe main.exe main
Completo Compilazione e Linking Linux/macOS g++ main.cpp pila.cpp -o main main ./main

Astrazioni Procedurali

L'astrazione procedurale è un concetto fondamentale della programmazione che serve a gestire la complessità del codice nascondendo i dettagli "tecnici" di come un compito viene svolto.

In breve, funziona secondo il principio della "Scatola Nera" (Black Box): sai cosa fa la scatola (input/output), ma non hai bisogno di sapere cosa succede al suo interno.

Ecco i punti chiave spiegati in modo semplice: