CORSO C++ STANDARD - Antonio...

296
CORSO C++ STANDARD Indice degli argomenti trattati Introduzione Obiettivi e Prerequisiti Contenuto generale del Corso Nota storica Caratteristiche generali del linguaggio "Case sensitivity" Moduli funzione Entry-point del programma: la funzione main Le tre parti di una funzione Aree di commento Primo programma di esempio (con tabella esplicativa di ogni simbolo usato) Cominciamo dalla funzione printf Perché una funzione di I/O del C ? Operazioni della funzione printf Argomenti della funzione printf Scrittura della control string sullo schermo Definizione di sequenza di escape Principali sequenze di escape La funzione printf con più argomenti Definizione di specificatore di formato Principali specificatori di formato in free-format Specificatori di formato con ampiezza di campo e precisione Altri campi degli specificatori di formato Tipi, Variabili, Costanti Tipi delle variabili Tipi intrinseci del linguaggio Dichiarazione e definizione degli identificatori Qualificatori e specificatori di tipo Tabella di occupazione della memoria dei vari tipi di dati L'operatore sizeof Il tipo "booleano" Definizione con Inizializzazione Le Costanti in C++ Specificatore const Visibilità e tempo di vita Visibilità di una variabile Tempo di vita di una variabile Visibilità globale Operatori e operandi Definizione di operatore e regole generali Operatore di assegnazione Operatori matematici Operatori a livello del bit Operatori binari in notazione compatta Operatori relazionali

Transcript of CORSO C++ STANDARD - Antonio...

Page 1: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

CORSO C++ STANDARD

Indice degli argomenti trattati

• Introduzione Obiettivi e Prerequisiti Contenuto generale del Corso Nota storica

• Caratteristiche generali del linguaggio "Case sensitivity" Moduli funzione Entry-point del programma: la funzione main Le tre parti di una funzione Aree di commento Primo programma di esempio (con tabella esplicativa di ogni simbolo usato)

• Cominciamo dalla funzione printf Perché una funzione di I/O del C ? Operazioni della funzione printf Argomenti della funzione printf Scrittura della control string sullo schermo Definizione di sequenza di escape Principali sequenze di escape La funzione printf con più argomenti Definizione di specificatore di formato Principali specificatori di formato in free-format Specificatori di formato con ampiezza di campo e precisione Altri campi degli specificatori di formato

• Tipi, Variabili, Costanti Tipi delle variabili Tipi intrinseci del linguaggio Dichiarazione e definizione degli identificatori Qualificatori e specificatori di tipo Tabella di occupazione della memoria dei vari tipi di dati L'operatore sizeof Il tipo "booleano" Definizione con Inizializzazione Le Costanti in C++ Specificatore const

• Visibilità e tempo di vita Visibilità di una variabile Tempo di vita di una variabile Visibilità globale

• Operatori e operandi Definizione di operatore e regole generali Operatore di assegnazione Operatori matematici Operatori a livello del bit Operatori binari in notazione compatta Operatori relazionali

Page 2: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Operatori logici Operatori di incremento e decremento Operatore condizionale Conversioni di tipo Precedenza fra operatori (tabella) Ordine di valutazione

• Introduzione all'I/O sui dispositivi standard Dispositivi standard di I/O Oggetti globali di I/O Operatori di flusso di I/O Output tramite l'operatore di inserimento Input tramite l'operatore di estrazione Memorizzazione dei dati introdotti da tastiera Comportamento in caso di errore in lettura

• Il Compilatore GNU gcc in ambiente Linux Un compilatore integrato C/C++ Il progetto GNU Quale versione di gcc sto usando? I passi della compilazione Estensioni L'input/output di gcc Il valore restituito al sistema Passaggi intermedi di compilazione I messaggi del compilatore Controlliamo i livelli di warning Compilare per effetture il debug Autopsia di un programma defunto Ottimizzazione Compilazione di un programma modulare Inclusione di librerie in fase di compilazione

• Il Comando 'make' in ambiente Linux Perche' utilizzare il comando make? Il Makefile ed i target del make Dipendenze Macro e variabili ambiente Compiliamo con make Alcuni target standard

• Istruzioni di controllo Istruzione di controllo if Istruzione di controllo while Istruzione di controllo do ... while Istruzione di controllo for Istruzioni continue, break e goto Istruzione di controllo switch ... case

• Array Cos'è un array ? Definizione e inizializzazione di un array L'operatore [ ] Array multidimensionali L'operatore sizeof e gli array Gli array in C++

• Stringhe di caratteri

Page 3: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Le stringhe come particolari array di caratteri Definizione di variabili stringa Inizializzazione di variabili stringa Funzioni di libreria gets e puts Conversioni fra stringhe e numeri Le stringhe in C++

• Funzioni Definizione di una funzione Dichiarazione di una funzione Istruzione return Comunicazioni fra programma chiamante e funzione Argomenti di default Funzioni con overload Funzioni inline Trasmissione dei parametri tramite l'area stack Ricorsività delle funzioni Funzioni con numero variabile di argomenti Cenni sulla Run Time Library

• Riferimenti Costruzione di una variabile mediante copia Cosa sono i riferimenti ? Comunicazione per "riferimento" fra programma e funzione

• Direttive al Preprocessore Cos'é il preprocessore ? Direttiva #include Direttiva #define di una costante Confronto fra la direttiva #define e lo specificatore const Direttiva #define di una macro Confronto fra la direttiva #define e lo specificatore inline Direttive condizionali Direttiva #undef

• Sviluppo delle applicazioni in ambiente Windows Definizioni di IDE e di "progetto" Gestione di files e progetti Editor di testo Gestione delle finestre Costruzione dell'applicazione eseguibile Debug del programma Utilizzo dell'help in linea

• Indirizzi e Puntatori Operatore di indirizzo & Cosa sono i puntatori ? Dichiarazione di una variabile di tipo puntatore Assegnazione di un valore a un puntatore Aritmetica dei puntatori Operatore di dereferenziazione * Puntatori a void Errori di dangling references Funzioni con argomenti puntatori

• Puntatori ed Array Analogia fra puntatori ed array Combinazione fra operazioni di deref. e di incremento

Page 4: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Confronto fra operatore [ ] e deref. del puntatore "offsettato" Funzioni con argomenti array Funzioni con argomenti puntatori passati by reference Array di puntatori

• Elaborazione della riga di comando Esecuzione di un programma tramite riga di comando Argomenti passati alla funzione main

• Puntatori e Funzioni Funzioni che restituiscono puntatori Puntatori a Funzione Array di puntatori a funzione Funzioni con argomenti puntatori a funzione

• Puntatori e Costanti Puntatori a costante Puntatori costanti Puntatori costanti a costante Funzioni con argomenti costanti trasmessi by value Funzioni con argomenti costanti trasmessi by reference

• Tipi definiti dall'utente Concetti di oggetto e istanza Typedef Strutture Operatore . Puntatori a strutture - Operatore -> Unioni Dichiarazione di strutture e membri di tipo struttura Strutture di tipo bit field Tipi enumerati

• Allocazione dinamica della memoria Memoria stack e memoria heap Operatore new Operatore delete

• Namespace Programmazione modulare e compilazione separata Definizione di namespace Risoluzione della visibilità Membri di un namespace definiti esternamente Namespace annidati Namespace sinonimi Namespace anonimi Estendibilità della definizione di un namespace Parola-chiave using Precedenze e conflitti fra i nomi Collegamento fra namespace definiti in files diversi

• Eccezioni Segnalazione e gestione degli errori Il costrutto try L'istruzione throw Il gestore delle eccezioni: costrutto catch Riconoscimento di un'eccezione fra diverse alternative Blocchi innestati Eccezioni che non sono errori

Page 5: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• Classi e data hiding Analogia fra classi e strutture Specificatori di accesso Data hiding Funzioni membro Risoluzione della visibilità Funzioni-membro di sola lettura Classi membro Polimorfismo Puntatore nascosto this

• Membri a livello di classe e accesso "friend" Membri di tipo enumerato Dati-membro statici Funzioni-membro statiche Funzioni friend Classi friend

• Costruttori e distruttori degli oggetti Costruzione e distruzione di un oggetto Costruttori Costruttori e conversione implicita Distruttori Oggetti allocati dinamicamente Membri puntatori Costruttori di copia Liste di inizializzazione Membri oggetto Array di oggetti Oggetti non locali Oggetti temporanei Utilità dei costruttori e distruttori

• Overload degli operatori Estendibilità del C++ Ridefinizione degli operatori Metodi della classe o funzioni esterne ? Il ruolo del puntatore nascosto this Overload degli operatori di flusso di I/O Operatori binari e conversioni Operatori unari e casting a tipo nativo Operatori in namespace Oggetti-array e array associativi Oggetti-funzione Puntatori intelligenti Operatore di assegnazione Ottimizzazione delle copie Espressioni-operazione

• Eredita' L'eredità in C++ Classi base e derivata Accesso ai membri della classe base Conversioni fra classi base e derivata Costruzione della classe base Regola della dominanza

Page 6: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Eredità e overload La dichiarazione using Eredità multipla e classi basi virtuali

• Polimorfismo Late binding e polimorfismo Ambiguità dei puntatori alla classe base Funzioni virtuali Tabelle delle funzioni virtuali Costruttori e distruttori virtuali Scelta fra velocità e polimorfismo Classi astratte Un rudimentale sistema di figure geometriche Un rudimentale sistema di visualizzazione delle figure

• Template Programmazione generica Definizione di una classe template Istanza di un template Parametri di default Funzioni template Differenze fra funzioni e classi template Template e modularità

• Generalità sulla Libreria Standard del C++ Campi di applicazione Header files Il namespace std La Standard Template Library

• La Standard Template Library Generalità Iteratori Contenitori Standard Algoritmi e oggetti-funzione

• Una classe C++ per le stringhe La classe string Confronto fra string e vector<char> Il membro statico npos Costruttori e operazioni di copia Gestione degli errori Conversioni fra oggetti string e stringhe del C Confronti fra stringhe Concatenazioni e inserimenti Ricerca di sotto-stringhe Estrazione e sostituzione di sotto-stringhe Operazioni di input-output

• Librerie statiche e dinamiche in Linux Introduzione Librerie in ambiente Linux Un programma di prova Librerie statiche Come costruire una libreria statica Link con una libreria statica I limiti del meccanismo del link statico Librerie condivise

Page 7: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Come costruire una libreria condivisa Link con una libreria condivisa La variabile ambiente LD_LIBRARY_PATH La flag -rpath Che tipo di libreria sto usando? Un aspetto positivo dell'utilizzo delle librerie condivise Librerie statiche vs librerie condivise

• Le operazioni di input-ouput in C++ La gerarchia di classi stream Operazioni di output Operazioni di input Stato dell'oggetto stream e gestione degli errori Formattazione e manipolatori di formato Cenni sulla bufferizzazione

• Conclusioni

Page 8: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

INTRODUZIONE

Obiettivi e Prerequisiti

Obiettivi

Acquisire le conoscenze necessarie per lo sviluppo di applicazioni in linguaggio C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP (Object Oriented Programming").

Un linguaggio di programmazione ha due scopi principali:

1. Fornire i mezzi perchè il programmatore possa specificare le azioni da eseguire;

2. Fornire un insieme di concetti per pensare a quello che può essere fatto.

Il primo scopo richiede che il linguaggio sia vicino alla macchina (il C fu progettato con questo scopo); il secondo richiede che il linguaggio sia vicino al problema da risolvere, in modo che i concetti necessari per la soluzione siano esprimibili direttamente e in forma concisa. La OOP è stata appositamente pensata per questo scopo e le potenzialità aggiunte al C per creare il C++ ne costituiscono l'aspetto principale e caratterizzante.

Prerequisiti

Conoscenza dei concetti base e della terminologia informatica (es. : linguaggio, programma, istruzione di programma, costante, variabile, funzione, operatore, locazione di memoria, codice sorgente, codice oggetto, compilatore, linker ecc…)

Non è necessaria la conoscenza del C ! Infatti il C++ è anche (ma non solo) un'estensione del C, che mantiene nel suo ambito come sottoinsieme. E quindi un corso, base ed essenziale ma completo, di C++, è anche un corso di C.

Contenuto generale del Corso

Livello di partenza

Concetti fondamentali di programmazione in C e C++, tenendo presente gli obiettivi, e quindi:

Page 9: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• il C verrà trattato solo negli aspetti che si mantengono inalterati in C++ Esempi: le istruzioni di controllo if ….. else, i costrutti while, do...while, for ecc...

• il C non verrà trattato dove è stato sostituito dal C++ Esempio : le funzioni C di allocazione di memoria malloc e free, sostituiti in C++ dagli operatori new e delete

• il Corso riguarderà soltanto il C++ standard, indipendentemente dalla piattaforma hardware o dal sistema operativo, e quindi non verranno trattati tutti quegli argomenti legati all'ambiente specifico

Livello avanzato

La Programmazione a Oggetti: concetti di: tipo astratto, classe, istanza, incapsulamento, overload di funzioni e operatori, costruttore e distruttore, ereditarietà, polimorfismo, funzione virtuale, template ecc...

La libreria standard del C++ : classi iostream (per l'input-output) e string, classi contenitore (vector, list, queue, stack, map ecc. ), algoritmi e iteratori.

Nota Storica

Il C++ fu "inventato" nel 1980 dal ricercatore informatico danese Bjarne Stroustrup, che ricavò concetti già presenti in precedenti linguaggi (come il Simula67) per produrre una verisone modificata del C, che chiamò: "C con le classi". Il nuovo linguaggio univa la potenza e l'efficienza del C con la novità concettuale della programmazione a oggetti, allora ancora in stato "embrionale" (c'erano già le classi e l'eredità, ma mancavano l'overload, le funzioni virtuali, i riferimenti, i template, la libreria e moltre altre cose).

Il nome C++ fu introdotto per la prima volta nel 1983, per suggerire la sua natura evolutiva dal C, nel quale ++ è l'operatore di incremento (taluni volevano chiamarlo D, ma C++ prevalse, per i motivi detti).

All'inizio, comunque, e per vari anni, il C++ restò un esercizio quasi "privato" dell'Autore e dei suoi collaboratori, progettato e portato avanti, come egli stesso disse, "per rendere più facile e piacevole la scrittura di buoni programmi".

Tuttavia, alla fine degli anni 80, risultò chiaro che sempre più persone apprezzavano ed utilizzavano il linguaggio e che la sua standardizzazione formale era un obiettivo da perseguire. Nel 1990 si formò un comitato per la standardizzazione del C++, cui ovviamente partecipò lo stesso Autore. Da allora in poi, il comitato, nelle sue varie articolazioni, divenne il luogo deputato all'evoluzione e al raffinamento del linguaggio.

Finalmente l'approvazione formale dello standard si ebbe alla fine del 1997. In questi ultimi anni il C++ si è ulteriormente evoluto, soprattutto per quello che riguarda l'implementazione di nuove classi nella libreria standard.

Page 10: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Caratteristiche generali del linguaggio

"Case sensitivity"

Il linguaggio C++ (come il C) distingue i caratteri maiuscoli da quelli minuscoli.

Esempio: i nomi MiaVariabile e miavariabile indicano due variabili diverse

Moduli funzione

In C++ (come in C) ogni modulo di programma è una funzione.

Non esistono subroutines o altri tipi di sottoprogramma.

Ogni funzione è identificata da un nome

Entry point del programma: la funzione main

Quando si manda in esecuzione un programma, questo inizia sempre dalla funzione identificata dalla parola chiave main Il main è chiamato dal sistema operativo, che gli può passare dei parametri; a sua volta il main può restituire al sistema un numero intero (di solito analizzato come possibile codice di errore).

Le tre parti di una funzione

• lista degli argomenti passati dal programma chiamante: vanno indicati fra parentesi tonde dopo il nome della funzione; void indica che non vi sono argomenti (si può

Page 11: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

omettere)

• blocco (ambito di azione, ambito di visibilità, scope) delle istruzioni della funzione: va racchiuso fra parentesi graffe; ogni istruzione deve terminare con ";" (può estendersi su più righe o vi possono essere più istruzioni sulla stessa riga); un'istruzione è costituita da una successione di "tokens": un "token" è il più piccolo elemento di codice individualmente riconosciuto dal compilatore; sono "tokens" : gli identificatori, le parole-chiave, le costanti letterali o numeriche, gli operatori e alcuni caratteri di punteggiatura; i blanks e gli altri caratteri "separatori" (horizontal or vertical tabs, new lines, formfeeds) fra un token e l'altro o fra un'istruzione e l'altra, sono ignorati; in assenza di "separatori" il compilatore analizza l'istruzione da sinistra a destra e tende, nei casi di ambiguità, a separare il token più lungo possibile. Es. l'istruzione a = i+++j; può essere interpretata come: a = i + ++j; oppure come: a = i++ + j; il compilatore sceglie la seconda interpretazione.

• tipo del valore di ritorno al programma chiamante: va indicato prima del nome della funzione ed è obbligatorio; se è void indica che non c'è valore di ritorno

Commenti

I commenti sono brani di programma (che il compilatore ignora) inseriti al solo scopo di documentazione, cioè per spiegare il significato delle istruzioni e così migliorare la leggibilità del programma. Sono molto utili anche allo stesso autore, per ricordargli quello che ha fatto, quando ha necessità di rivisitare il programma per esigenze di manutenzione o di aggiornamento. Un buon programma si caratterizza anche per il fatto che fa abbondante uso di commenti. In C++ ci sono due modi possibili di inserire i commenti:

• l'area di commento è introdotta dal doppio carattere /* e termina con il doppio carattere */ (può anche estendersi su più righe)

• l'area di commento inizia con il doppio carattere // e termina alla fine della riga

Esempio di programma

Page 12: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP
Page 13: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Cominciamo dalla funzione "printf"

Perché una funzione di input-output del C ?

La funzione printf è importante perché utilizza gli specificatori di formato, che definiscono il modo di scrivere i dati (formattazione). Tali specificatori sono usati, con le stesse regole della printf, da tutte le funzioni (anche non di input-output), che eseguono conversioni di formato sui dati

Operazioni della funzione printf

La funzione printf formatta e scrive una serie di caratteri e valori sul dispositivo standard di output (stdout), associato di default allo schermo del video, e restituisce al programma chiamante il numero di caratteri effettivamente scritti (oppure un numero negativo in caso di errore). Quando si usa la funzione printf bisogna prima includere il file header <stdio.h>

Argomenti della funzione printf

La funzione printf riceve dal programma chiamante uno o più argomenti. Solo il primo è obbligatorio e deve essere una stringa, che si chiama control string (stringa di controllo)

Scrittura della control string sullo schermo

Page 14: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Quando printf è chiamata con un solo argomento, la control string viene trasferita sullo schermo, carattere per carattere (compresi gli spazi bianchi), salvo quando sono incontrati i seguenti caratteri particolari:

" (termina la control string)

% (introduce uno specificatore di formato - da non usare in questo caso)

\ (introduce una sequenza di escape)

Sequenze di escape

Il carattere \ (backslash) non viene trasferito sullo schermo, ma utilizzato in combinazione con i caratteri successivi (un solo carattere se si tratta di una lettera, oppure una sequenza di cifre numeriche); l'insieme viene detto: escape sequence, e viene interpretato come un unico carattere. Le sequenze di escape sono usate tipicamente per specificare caratteri speciali che non hanno il loro equivalente stampabile (come newline, carriage return, tabulazioni, suoni ecc...), oppure caratteri, che da soli, hanno una funzione speciale, come le virgolette o lo stesso backslash

Principali sequenze di escape

\a suona il campanello (bell) \b carattere backspace

\f salta pagina (form-feed) \n va a capo (newline)

\r ritorno carrello (carriage-return) \t tabulazione orizzontale

\\ carattere backslash \" carattere virgolette

\nnn carattere con codice ascii nnn (tre cifre in ottale)

\nn carattere con codice ascii nn (due cifre in esadecimale)

%% carattere "%" - atipico: è introdotto da % anziché da \

\ da solo alla fine della riga = continua la control string nella riga successiva

Page 15: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

La funzione printf con più argomenti

Eventuali altri argomenti successivi alla control string, nella chiamata a printf, rappresentano i dati da formattare e scrivere, e possono essere costituiti da costanti, variabili, espressioni, o altre funzioni (in questo caso in realtà l'argomento è il valore di ritorno della funzione, la quale viene eseguita prima della printf). Per il momento, dato che le variabili non sono state ancora introdotte, supponiamo che i dati siano costituiti da costanti o da espressioni fra costanti

Specificatori di formato

Ad ogni argomento successivo alla control string, deve corrispondere, all'interno della stessa control string e nello stesso ordine, uno specificatore di formato, costituito da un gruppo di caratteri introdotto dal carattere "%". Nella sua forma generale uno specificatore di formato ha la seguente sintassi:

%[flags][width][.precision]type

dove i termini indicati con il colore fuchsia costituiscono i campi dello specificatore (senza spazi in mezzo), e sono tutti opzionali salvo l'ultimo (type), che determina come deve essere interpretato il corrispondente argomento della printf (numero intero, numero floating, carattere, stringa ecc...). I campi opzionali controllano invece il formato di scrittura. Se sono omessi, cioè lo specificatore assume la forma minima %type, i dati sono scritti in free-format (cioè in modo da occupare lo spazio strettamente necessario). Per esempio, se a un certo punto della control string compare lo specificatore %d, significa che in quella posizione deve essere scritto, in free-format, il valore del corrispondente argomento della printf, espresso come numero intero decimale, come nel caso della seguente istruzione: printf("Ci sono %d iscritti a questo corso!\nTemevo fossero solo %d!",3215+1,2); che scrive su video la frase: Ci sono 3216 iscritti a questo corso! Temevo fossero solo 2!

Page 16: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Principali specificatori di formato in free-format

In uno specificatore di formato il campo obbligatorio type può assumere uno dei seguenti valori:

u, o, x valori interi assoluti, basi: decimale, ottale, esadecimale

X come x ma con le cifre letterali maiuscole

d, i valori interi relativi, base decimale

f, e valori floating, notazione normale o esponenziale

g come f o e (sceglie il più comodo)

E, G come e e g (scrive "E" al posto di "e")

c carattere

s stringa di caratteri

p indirizzo di memoria (in esadecimale)

[p02]

Specificatori di formato con ampiezza di campo e precisione

Anziché in free-format, si possono scrivere i dati in formato definito, tramite gli specificatori numerici di ampiezza di campo e precisione.

In uno specificatore di formato il campo opzionale width, costituito da un numero intero positivo, determina l'ampiezza di campo, cioè il numero di caratteri minimo con cui deve essere scritto il dato corrispondente. Se il numero di caratteri effettivo è inferiore, il campo viene riempito (normalmente) a sinistra con spazi bianchi; se invece il numero è superiore, il campo viene espanso fino a raggiungere la lunghezza effettiva (in altre parole il dato viene sempre scritto per intero, anche se il valore specificato in width è insufficiente). Se al posto di un numero si specifica nel campo width un asterisco, il valore viene desunto in esecuzione dalla lista degli argomenti della printf; in questo caso il valore dell'ampiezza di campo deve precedere immediatamente il dato a cui lo specificatore in esame si riferisce.

In uno specificatore di formato il campo opzionale precision, se presente, deve essere sempre preceduto da un punto (che lo separa dal campo width), ed

Page 17: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

è costituito da un numero intero non negativo, con significato che dipende dal contenuto del campo obbligatorio type, come si evince dalla seguente tabella:

contenuto campo type

significato campo precision

default

d,i,u,o,x,X (valori interi)

La precisione specifica il minimo numero di cifre che devono essere scritte. Se il numero di cifre effettive del dato corrispondente è minore della precisione, vengono scritti degli zeri sulla sinistra fino a completare il campo. Se invece il numero di cifre effettive è superiore, il dato è comunque scritto per intero senza nessun troncamento. Infine, se la precisione è .0 (oppure semplicemente .) e il dato è zero, non viene scritto nulla.

1

f,e,E (valori floating)

La precisione specifica il numero di cifre che devono essere scritte dopo il punto decimale. L'ultima cifra è arrotondata. Se la precisione è .0 (oppure semplicemente .), non è scritto neppure il punto decimale (in questo caso è arrotondata la cifra intera delle unità).

6 cifre decimali

g,G (valori floating)

La precisione specifica il massimo numero di cifre significative che devono essere scritte. L'ultima cifra è arrotondata. Gli zeri non significativi a destra non vengono scritti.

6 cifre significative

c (carattere)

La precisione non ha effetto.

s (stringa)

La precisione specifica il massimo numero di caratteri che devono essere scritti. I caratteri in eccesso non vengono scritti.

La stringa è scritta per intero

Come per l'ampiezza di campo, anche per la precisione, se al posto di un numero si specifica un asterisco, il valore viene desunto in esecuzione dalla lista degli argomenti della printf; anche in questo caso il valore della precisione deve precedere immediatamente il dato a cui lo specificatore in esame si riferisce.

Altri campi degli specificatori di formato

Page 18: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

In uno specificatore di formato il campo opzionale flags è costituito da uno o più caratteri, ciascuno dei quali svolge una funzione particolare, come descritto dalla seguente tabella:

flag significato

default

-

Allinea la scrittura a sinistra in un campo con ampiezza specificata da width.

Allineamento a destra

+

Mette il segno (+ o –) davanti al numero.

Il segno appare solo se il numero è negativo

spazio

Mette uno spazio bianco davanti al numero, se questo è positivo.

Nessuno spazio davanti ai numeri positivi

0

Aggiunge zeri sulla sinistra fino a raggiungere l'ampiezza specificata da width. Se appaiono insieme 0 e -, 0 è ignorato.

Il campo specificato da width è riempito da spazi bianchi

# (usato con o,x,X)

Mette davanti a ogni valore diverso da zero i prefissi 0, 0x, o 0X, rispettivamente.

Nessun prefisso davanti ai numeri

# (usato con e,E,f,g,G)

Scrive sempre il punto decimale.

Il punto decimale è scritto solo se è seguito da altre cifre

# (usato con g,G)

Riempie tutto il campo con ampiezza specificata da width, scrivendo anche gli zeri non significativi.

Gli zeri non significativi non vengono scritti

Page 19: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Tipi, Variabili, Costanti

Tipi delle variabili

Classificazione delle variabili in tipi

Si dice che il C++ (come il C) è un linguaggio "tipato", per il fatto che pretende che di ogni variabile venga dichiarato il tipo di appartenenza.

Definizione di tipo di una variabile

Il tipo è un termine di classificazione che raggruppa tutte quelle variabili che sono memorizzate nello stesso modo e a cui si applica lo stesso insieme di operazioni.

Controllo forte sui tipi

Il C++ esercita un forte controllo sui tipi (strong type checking), nel senso che regola e limita la conversione da un tipo all'altro (casting) e controlla l'interazione fra variabili di tipo diverso.

Tipi intrinseci del linguaggio

In C++ esistono solo 5 tipi, detti "intrinseci o "nativi" del linguaggio :

int numero intero di 2 o 4 byte

char

numero intero di 1 byte (interpretabile come codice ascii di un carattere)

float numero in virgola mobile con 6-7 cifre significative (4 byte )

double numero in virgola mobile con 15-16 cifre significative (8 byte )

bool valore booleano: true o false (1 byte )

In realtà il numero di tipi possibili è molto più grande, sia perché ogni tipo nativo può essere specializzato mediante i qualificatori di tipo, sia perché il programma stesso può creare propri tipi personalizzati (detti "tipi astratti")

Page 20: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Dichiarazione e definizione degli identificatori

Cos'è un identificatore ?

Un identificatore è un nome simbolico che il programma assegna a un'entità del linguaggio, per modo che il compilatore sia in grado di riconoscere quell'entità ogni volta che incontra il nome che le è stato assegnato. Sono pertanto identificatori i nomi delle variabili, delle funzioni, degli array, dei tipi astratti, delle strutture, delle classi ecc... Ogni identificatore consiste di una sequenza di lettere (maiuscole o minuscole) e di cifre numeriche, senza caratteri di altro tipo o spazi bianchi (a parte l'underscore "_", che è considerato una lettera). Il primo carattere deve essere una lettera. Non sono validi gli identificatori che coincidono con le parole-chiave del linguaggio (come da Tabella sotto riportata). Esempi di identificatori validi: hello deep_space9 a123 _7bello Esempi di identificatori non validi:

un amico (contiene uno spazio)

un'amica (contiene un apostrofo)

7bello (il primo carattere non è una lettera)

for (è una parola-chiave del C++)

Tabella delle parole-chiave del C++

auto bool break case

catch char class const

const_class continue default delete

do double dynamic_cast else

enum explicit extern false

float for friend goto

if inline int long

main mutable namespace new

operator private protected public

register reinterpret_class return short

signed sizeof static static_cast

struct switch template this

throw true try typedef

Page 21: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

typeid typename union unsigned

using virtual void volatile

wmain while

Dichiarazione obbligatoria degli identificatori

In C++ tutti gli identificatori di un programma devono essere dichiarati prima di essere utilizzati (non necessariamente all'inizio del programma), cioè deve essere specificato il loro tipo. Per dichiarare un identificatore bisogna scrivere un'istruzione apposita in cui l'identificatore è preceduto dal tipo di appartenenza. Es.

int Variabile_Intera;

Più identificatori dello stesso tipo possono essere dichiarati nella stessa istruzione e separati l'uno dall'altro da una virgola. Es.

int ore, giorni, mesi;

Definizione obbligatoria degli identificatori

Un'istruzione di dichiarazione si limita ad informare il compilatore del C++ che un certo identificatore appartiene a un certo tipo, ma può non essere considerata in fase di esecuzione del programma. Quando una dichiarazione comporta anche un'operazione eseguibile, allora si dice che è anche una definizione. Per esempio, l'istruzione: extern int error_number; è soltanto una dichiarazione, in quanto (come vedremo più avanti) con lo specificatore extern informa il compilatore (o meglio il linker) che la variabile error_number è definita in un altro file del programma (e quindi l'istruzione serve solo ad identificare il tipo della variabile e a permetterne l'utilizzo); mentre l'istruzione: int error_number; è anche una definizione, in quanto non si limita ad informare il compilatore che la variabile error_number è di tipo int, ma crea la variabile stessa, allocando un'apposita area di memoria. Per meglio comprendere la differenza fra dichiarazione e definizione, si considerino le seguenti regole:

• tutte le definizioni sono anche dichiarazioni (ma non è vero il contrario);

• deve esserci una ed una sola definizione per ogni identificatore che appare nel programma (o meglio, per ogni identificatore che appare in uno stesso ambito, altrimenti si tratta di identificatori diversi, pur avendo lo stesso nome), mentre possono esserci più dichiarazioni (purchè non in contraddizione fra loro);

Page 22: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• un identificatore deve essere dichiarato prima del suo utilizzo, ma può essere definito dopo (o altrove, come abbiamo visto nell'esempio precedente);

• la semplice dichiarazione (cioè senza specificatore) di una variabile di tipo nativo è sempre anche una definizione, in quanto comporta l'allocazione di un'area di memoria;

Qualificatori e specificatori di tipo

Definizione di "qualificatore" e "specificatore"

Un qualificatore di tipo è una parola-chiave che, in una istruzione di dichiarazione, si premette a un tipo nativo, per indicare il modo in cui la variabile dichiarata deve essere immagazzinata in memoria. Se il tipo è omesso, è sottointeso int. Esistono 4 qualificatori: short, long, signed, unsigned.

Uno specificatore è una parola-chiave che, in una istruzione di dichiarazione, si premette al tipo (che può essere qualsiasi, anche non nativo) e all'eventuale qualificatore, per definire ulteriori caratteristiche dell'entità dichiarata. Esistono svariati tipi di specificatori, con funzioni diverse: li introdurremo via via durante il corso, quando sarà necessario.

Qualificatori short e long

I qualificatori short e long si applicano al tipo int. Essi definiscono la dimensione della memoria occupata dalle rispettive variabili di appartenenza. Purtroppo lo standard non garantisce che tale dimensione rimanga inalterata trasportando il programma da una piattaforma all'altra, in quanto essa dipende esclusivamente dalla piattaforma utilizzata. Possiamo solo dire così: a tutt'oggi, nelle implementazioni più diffuse del C++ , il qualificatore short definisce variabili di 16 bit (2 byte) e il qualificatore long definisce variabili di 32 bit (4 byte), mentre il tipo int "puro" definisce variabili di 32 bit (cioè long e int sono equivalenti). Vedremo fra poco che esiste un operatore che permette di conoscere la effettiva occupazione di memoria dei diversi tipi di variabili. Per completezza aggiungiamo che il qualificatore long si può applicare anche al tipo double (la cosidetta "precisione estesa"), ma, da prove fatte sulle macchine che generalmente usiamo, è risultato che conviene non applicarlo!

Qualificatori signed e unsigned

I qualificatori signed e unsigned si applicano ai tipi "interi" int e char. Essi determinano se le rispettive variabili di appartenenza possono assumere o meno valori negativi.

Page 23: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

E' noto che i numeri interi negativi sono rappresentati in memoria mediante l'algoritmo del "complemento a 2" (dato un numero N rappresentato da una sequenza di bit, -N si rappresenta invertendo tutti i bit e aggiungendo 1). E' pure noto che, in un'area di memoria di m bit, esistono 2m diverse possibili configurazioni (cioè un numero intero può assumere 2m valori). Pertanto un numero con segno ha un range (intervallo) di variabilità da -2m-1 a +2m-1-1, mentre un numero assoluto va da 0 a +2m-1. Se il tipo è int, i qualificatori signed e unsigned possono essere combinati con short e long, dando luogo, insieme a signed char e unsigned char, a 6 diversi tipi interi possibili. E i tipi int e char "puri" ? Il tipo int è sempre con segno (e quindi signed int e int sono equivalenti), mentre, per quello che riguarda il tipo char, ancora una volta dipende dall'implementazione: "in generale" (ma non sempre) coincide con signed char.

Page 24: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'operatore sizeof

L'operatore sizeof(operando) restituisce la lunghezza in byte di identificatori appartenenti a un dato tipo; operando specifica il tipo in esame o un qualunque identificatore dichiarato di quel tipo. Per esempio, sizeof(int) può essere usato per sapere se il tipo int è di 2 o di 4 byte.

[p04]

Confronto dei risultati fra diverse architetture

Lunghezza della voce di memoria in byte

tipo PC (32 bit)

con Windows

PC (32 bit)

con Linux

DEC ALPHA (64 bit)

con Unix

char 1 1 1

Page 25: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

short 2 2 2

int 4 4 4

long 4 4 8

float 4 4 4

double 8 8 8

long double 8 12 16

bool 1 1 1

Definizione con Inizializzazione

Abbiamo visto finora che ogni dichiarazione o definizione di un identificatore consiste di tre parti:

• uno o più specificatori (opzionali); • il tipo (eventualmente preceduto da uno o più qualificatori); • l'identificatore.

NOTA Per completezza aggiungiamo che a sua volta l'identificatore può essere preceduto (e/o seguito) da un "operatore di dichiarazione". I più comuni operatori di dichiarazione sono:

* puntatore prefisso

*const puntatore costante prefisso

& riferimento prefisso

[] array suffisso

( ) funzione suffisso

Ne parleremo al momento opportuno.

Esiste una quarta parte, opzionale, che si chiama inizializzatore (e che si può aggiungere solo nel caso della definizione di una variabile): un inizializzatore è un'espressione che definisce il valore iniziale assunto dalla variabile, ed è separato dal resto della definizione dall'operatore "=". Quindi, ricapitolando (nel caso che l'identificatore sia il nome di una variabile):

• la semplice dichiarazione assegna un tipo alla variabile;

Page 26: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• la definizione crea la variabile in memoria, ma non il suo contenuto, che rimane, per il momento, indefinito (forse resta quello che c'era prima nella stessa locazione fisica di memoria);

• la definizione con inizializzazione attribuisce un valore iniziale alla variabile definita.

Es. unsigned peso = 57;

n.b. un'inizializzazione è concettualmente diversa da un'assegnazione

In C++ i valori di inizializzazione possono essere dati non solo da costanti, ma anche da espressioni che includono variabili definite precedentemente.

Es. int lordo = 45; int tara = 23; int netto = lordo-tara;

Il tipo "booleano"

Il tipo bool non faceva parte inizialmente dei tipi nativi del C e solo recentemente è stato introdotto nello standard del C++. Una variabile "booleana" (cioè dichiarata bool) può assumere solo due valori: true e false. Tuttavia, dal punto di vista dell'occupazione di memoria, il tipo bool è identico al tipo char, cioè occupa un intero byte (anche se in pratica utilizza un solo bit). Nelle espressioni aritmetiche e logiche valori booleani e interi possono essere mescolati insieme: se un booleano è convertito in un intero, per definizione true corrisponde al valore 1 e false corrisponde al valore 0; viceversa, se un intero è convertito in un booleano, tutti i valori diversi da zero diventano true e zero diventa false. Esempi:

bool b = 7; ( b è inizializzata con true )

int i = true; ( i è inizializzata con 1 )

int i = 7 < 2; ( espressione falsa: i è inizializzata con 0 )

Le Costanti in C++

Costanti intere

Page 27: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Una costante intera è un numero decimale (base 10), ottale (base 8) o esadecimale (base 16) che rappresenta un valore intero positivo o negativo. Un numero senza prefissi o suffissi è interpretato in base decimale e di tipo int (o unsigned int se la costante specificata è maggiore del massimo numero positivo signed int). La prima cifra del numero non deve essere 0. Un numero con prefisso 0 è interpretato in base ottale e di tipo int (o unsigned int). Es. a = 0100; (in a è memorizzato il numero 64) Un numero con prefisso 0x o 0X è interpretato in base esadecimale e di tipo tipo int (o unsigned int). Le "cifre" a,b,c,d,e,f possono essere scritte sia in maiuscolo che in minuscolo. Es. a = 0x1B; (in a è memorizzato il numero 27)

In qualunque caso, la presenza del suffisso L indica che il numero deve essere di tipo long int, mentre la presenza del suffisso U indica che il numero deve essere di tipo unsigned int. Es. a = 0x1BL; a = 0x1BU;

Costanti in virgola mobile

Una costante in virgola mobile è un numero decimale (base 10), che rappresenta un valore reale positivo o negativo. Può essere specificato in 2 modi:

• parte_intera.parte_decimale (il punto è obbligatorio) • notazione esponenziale (il punto non è obbligatorio)

Esempi: 15.75 -1.5e2 25E-4 10.

In qualunque notazione, se il numero è scritto senza suffissi, è assunto di tipo double. Per forzare il tipo float bisogna apporre il suffisso f (o F) Es. 10.3 è di tipo double 1.4e-5f è di tipo float

Costanti carattere

Una costante carattere è rappresentata inserendo fra singoli apici un carattere stampabile oppure una sequenza di escape.

Esempi: 'A' carattere A

'\n' carattere newline

'\003' carattere cuoricino

In memoria un carattere è rappresentato da un numero intero di 1 byte (il suo codice ascii). Le conversioni fra tipo char e tipo int sono automatiche (purché il valore intero da convertire sia compreso nel range del tipo char) e quindi i due tipi possono essere mescolati insieme nelle espressioni aritmetiche. Per esempio, l'operazione: MiaVar = 'A' + 1; è ammessa, se la variabile MiaVar è stata dichiarata int oppure char.

Il carattere NULL ha codice ascii 0 e si rappresenta con '\0' (da non confondere con il carattere 0 decimale che ha codice ascii 48).

Page 28: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Costanti stringa

Una costante stringa è rappresentata inserendo un insieme di caratteri (fra cui anche sequenze di escape) fra doppi apici (virgolette). Es. "Ciao Universo\n"

In C++ (come in C) non esistono le stringhe come tipo intrinseco. Infatti esse sono definite come sequenze (array) di caratteri, con una differenza rispetto ai normali array: il compilatore, nel creare una costante stringa, aggiunge automaticamente un NULL dopo l'ultimo carattere (si dice che le stringhe sono "array di caratteri null terminated"). E quindi, per esempio, 'A' e "A" sono due costanti diverse :

'A' è un carattere e occupa 1 byte (con il numero 65)

"A" è una stringa e occupa 2 byte (con i numeri 65 e 0)

Per inizializzare una stringa bisogna definirla di tipo char e aggiungere al nome della variabile l'operatore di dichiarazione []. Es. char MiaStr[] = "Sono una stringa";

Specificatore const

Se nella definizione di una variabile, si premette al tipo (e ai suoi eventuali qualificatori), lo specificatore const, il contenuto della variabile non può più essere modificato. Ovviamente una variabile definita const deve sempre essere inizializzata.

Es. const double pigreco = 3.14159265385;

L'uso di const è fortemente consigliato rispetto all'alternativa di scrivere più volte la stessa costante nelle istruzioni del programma; infatti se il programmatore decide di cambiarne il valore, e ha usato const, è sufficiente che modifichi la sola istruzione di definizione.

D'ora in poi, quando parleremo di "costanti", intenderemo riferirci a "variabili definite const" (distinguendole dalle costanti "dirette" che saranno invece chiamate "costanti letterali" o "literals").

Page 29: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Visibilità e tempo di vita

Visibilità di una variabile

Ambito di azione

Abbiamo visto che, in via del tutto generale, si definisce ambito di azione (o ambito di visibilità o scope) l'insieme di istruzioni di programma comprese fra due parentesi graffe: {....}.

Le istruzioni di una funzione devono essere comprese tutte nello stesso ambito; ciò non esclude che si possano definire più ambiti innestati l'uno dentro l'altro (ovviamente il numero di parentesi chiuse deve bilanciare quello di parentesi aperte, e ogni parentesi chiusa termina l'ambito iniziato con la parentesi aperta più interna).

Variabili locali

In ogni caso una variabile è visibile al programma e utilizzabile solo nello stesso ambito in cui è definita (variabili locali). Se si tenta di utilizzare una variabile in ambiti diversi da quello in cui è definita (o in ambiti superiori in caso di più ambiti innestati), il compilatore non la riconosce.

Il C++ ammette che si ridefinisca più volte la stessa variabile, purché in ambiti diversi; in questo caso riconosce la variabile definita nel proprio ambito o in quello superiore più vicino.

Variabili globali

Una variabile è globale, cioè è visibile in tutto il programma, solo se è definita al di fuori di qualunque ambito (che viene per questo definito: ambito globale). Le definizioni (con eventuali inizializzazioni) sono le uniche istruzioni del linguaggio che possono anche risiedere esternamente all'ambito delle funzioni.

In caso di concorrenza fra una variabile globale e una locale viene riconosciuta la variabile locale; tuttavia la variabile globale prevale se è specificata con prefisso :: (operatore di riferimento globale).

Tempo di vita di una variabile

Page 30: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Variabili automatiche

Una variabile è detta automatica (o dinamica), se cessa di esistere non appena il flusso del programma esce dalla funzione in cui la variabile è definita. Se il flusso del programma torna nella funzione, la variabile viene ricreata ex-novo e, in particolare, viene reinizializzata sempre con lo stesso valore. Tutte le variabili locali sono, per default, automatiche ("tempo di vita" limitato all'esecuzione della funzione).

Variabili statiche

Una variabile è detta statica se il suo "tempo di vita" coincide con l'intera durata del programma: quando il flusso del programma torna nella funzione in cui è definita una variabile statica, ritrova la variabile come l'aveva lasciata (cioè con lo stesso valore); ciò significa in particolare che l'istruzione di definizione (con eventuale annessa inizializzazione) viene eseguita solo la prima volta. Per ottenere che una variabile sia statica, bisogna preporre lo specificatore static nella definizione della variabile.

Esiste anche, per le variabili automatiche, lo specificatore auto, ma è inutile in quanto di default (può essere usato per migliorare la leggibilità del programma).

A differenza dalle variabili automatiche, (in cui, in assenza di inizializzatore, il contenuto iniziale è indefinito), le variabile statiche sono inizializzate di default a zero (in modo appropriato al tipo).

Visibilità globale

Variabili globali statiche

Una variabile locale può essere automatica o statica; una variabile globale è sempre statica (se è visibile dall'esterno deve essere anche viva!) e quindi lo specificatore static non avrebbe significato.

In realtà, nella definizione di una variabile globale, lo specificatore static ha un significato differente: quello di limitare la visibilità della variabile al solo file in cui è definita (file scope). Senza lo specificatore static, la variabile è visibile anche negli altri files, purché in essi venga dichiarata con lo specificatore extern.

Page 31: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Visibilità di variabili globali

Se una variabile globale, visibile in tutti i files sorgente del programma (cioè definita senza lo specificatore static), non é inizializzata, deve esistere una e una sola dichiarazione senza lo specificatore extern, altrimenti il linker darebbe errore, con messaggio "unresolved symbol" (se tutte le dichiarazioni hanno extern), oppure "one or more multiply defined symbols" (se ci sono due dichiarazioni senza extern); se invece la variabile é inizializzata, l'inizializzazione deve essere presente in un solo file (in questo caso lo specificatore extern é opzionale), mentre negli altri files la variabile deve essere dichiarata con extern e non deve essere inizializzata.

Visibilità di costanti globali

In C++ le costanti globali (cioè le variabili globali definite const, con inizializzazione obbligatoria), obbediscono a regole differenti e precisamente:

• di default le costanti globali hanno file scope; • affinché una costante globale sia visibile dappertutto, è necessaria la

presenza dello specificatore extern anche nella dichiarazione in cui la costante è inizializzata (ovviamente, come per le variabili, l'inizializzazione deve essere presente una sola volta).

Tabella riassuntiva

Visibilità globale specificatore extern

nel file di definizionespecificatore extern

negli altri files

File scope

Variabile globale senza inizializzazione vietato obbligatorio specificatore static

Variabile globale con inizializzazione opzionale obbligatorio

senza inizializzazione specificatore static

Costante globale obbligatorio obbligatorio senza inizializzazione default

Page 32: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Operatori e operandi

Definizione di operatore e regole generali

Un operatore è un token che agisce su una coppia di dati (o su un singolo dato), detti operandi, ottenendo un nuovo dato (risultato dell'operazione).

Ogni operatore è identificato da un particolare simbolo grafico, costituito di solito da un solo carattere (ma talvolta anche da due o più caratteri). Non tutti gli operatori possono applicarsi ad ogni tipo di operando, ma, per ogni operatore esiste un ben definito insieme di tipi di operandi a cui l'operatore è applicabile.

Un operatore è detto binario se agisce su due operandi, unario se agisce su un solo operando. Se l'operatore è binario, i due operandi sono detti left-operand e right-operand.

Un'espressione è una successione di operazioni in cui il risultato di ogni operazione diviene operando per le operazioni successive, fino a giungere ad un unico risultato. L'ordine in cui le operazioni sono eseguite è regolato secondo precisi criteri di precedenza e associatività fra gli operatori.

Es. a op1 b op2 c (a, b, c sono operandi, op1 e op2 sono operatori)

1. op1 ha la precedenza: il risultato di a op1 b diventa left-operand di op2 c

2. op2 ha la precedenza: il risultato di b op2 c diventa right-operand di a op1

3. op1 e op2 hanno la stessa precedenza, ma l'associatività procede da sinistra a destra: come nel caso 1.

4. op1 e op2 hanno la stessa precedenza, ma l'associatività procede da destra a sinistra: come nel caso 2.

Per ottenere che un'operazione venga comunque eseguita con precedenza rispetto alle altre, bisogna racchiudere operatore e operandi fra parentesi tonde. Es. a op1 ( b op2 c ) (la seconda operazione viene eseguita per prima)

Operatore di assegnazione

Page 33: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'operatore binario di assegnazione = copia il contenuto del right-operand (detto nello specifico r-value) nel left-operand (detto l-value).

a = b

b (r-value) può essere una qualunque espressione che restituisce un valore di tipo nativo;

a (l-value) ha un ambito di scelta molto più ristretto (tutti gli l-values possono essere r-values, ma non viceversa); in pratica, salvo poche eccezioni, a deve essere una variabile.

I tipi di a e b devono coincidere, oppure il tipo di b deve essere convertibile implicitamente nel tipo di a.

Operatori matematici

Come in tutti i linguaggi di programmazione, le operazioni matematiche fondamentali (addizione, sottrazione, moltiplicazione, divisione) sono eseguite rispettivamente dai seguenti operatori binari:

+ - * /

Se la divisione è fra due numeri interi, il risultato dell'operazione è ancora un numero intero (troncamento). Es. 27 / 4 da' come risultato 6 (anziché 6.75).

Il resto di una divisione fra numeri interi si calcola con l'operatore binario % Es. 27 % 4 da' come risultato 3

Operatori a livello del bit

Il C++ può, a differenza da altri linguaggi, operare sulle variabili intere a livello del bit.

Page 34: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'operatore binario >> produce lo scorrimento a destra (right-shift) dei bit del left-operand, in quantità pari al right-operand. In pratica esegue una divisione intera (con divisore uguale a una potenza di 2) Es. a >> n equivale a a / 2n Dalla sinistra entrano cifre binarie 0 se il numero a è positivo, oppure cifre binarie 1 se il numero a è negativo (a causa della notazione a complemento a 2 dei numeri negativi).

L'operatore binario << produce lo scorrimento a sinistra (left-shift) dei bit del left-operand, in quantità pari al right-operand. In pratica esegue una moltiplicazione per una potenza di 2 Es. a << n equivale a a * 2n Dalla destra entrano sempre cifre binarie 0.

Gli operatori binari &, |, e ^ eseguono operazioni logiche bit a bit fra i due operandi, e precisamente:

& esegue l'AND fra i corrispondenti bit dei due operandi

| esegue l'OR inclusivo fra i corrispondenti bit dei due operandi

^ esegue l'OR esclusivo (XOR) fra i corrispondenti bit dei due operandi

Es., date due variabili char a e b i cui valori sono, in notazione binaria:

a = 0 1 0 0 1 1 0 1 (77)

b = 0 0 1 1 1 0 1 0 (58)

i risultati delle tre operazioni sono rispettivamente:

a & b = 0 0 0 0 1 0 0 0 (8)

a | b = 0 1 1 1 1 1 1 1 (127)

a ^ b = 0 1 1 1 0 1 1 1 (119)

L'operatore unario ~ inverte i bit dell'operando, cioè calcola il suo complemento a uno (in pratica, se il numero é signed, lo inverte di segno e sottrae 1).

Operatori binari in notazione compatta

Page 35: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Data l'espressione:

a = a op b

dove op è un'operatore matematico o a livello del bit, b è un'espressione qualsiasi e a è una variabile, le due operazioni possono essere sintetizzate in una tramite l'operatore binario op=

Es. MiaVariabile *= 4 equivale a MiaVariabile = MiaVariabile*4

La notazione compatta è conveniente soprattutto quando il nome delle variabili è lungo!

Operatori relazionali

Gli operatori binari relazionali sono:

> >= < <= == !=

Questi operatori eseguono il confronto fra i valori dei due operandi (che possono essere di qualsiasi tipo nativo) e restituiscono un valore booleano:

• a > b restituisce true se a é maggiore di b • a >= b restituisce true se a é maggiore o uguale a b • a < b restituisce true se a é minore di b • a <= b restituisce true se a é minore o uguale a b • a == b restituisce true se a é uguale a b • a != b restituisce true se a é diverso da b

Esempi:

• bool bvar = 7 > 3; (in bvar viene memorizzato true) • bool bvar = 7 < 3; (in bvar viene memorizzato false)

Operatori logici

Page 36: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Gli operatori logici sono:

&& || !

Questi operatori agiscono su operandi booleani e restituiscono un valore booleano:

L'operatore binario && esegue l'AND logico fra gli operandi: a && b risultato: true se entrambi a e b sono true; altrimenti: false

L'operatore binario || esegue l'OR logico fra gli operandi: a || b risultato: false se entrambi a e b sono false; altrimenti: true

L'operatore unario ! esegue il NOT logico dell'operando: !a risultato: true se a é false o viceversa

Notare la differenza fra gli operatori logici && e || e gli operatori di confronto bit a bit & e |

Es: 5 && 2

restituisce true in quanto entrambi gli operandi sono true (ogni intero diverso da zero è convertito in true)

5 & 2

restituisce 0 (e quindi false se convertito in booleano) in quanto i bit corrispondenti sono tutti diversi

Operatori di incremento e decremento

Gli operatori unari di incremento ++ o decremento -- fanno aumentare o diminuire di un'unità il valore dell'operando (che deve essere un l-value di qualunque tipo nativo). Equivalgono alla sintesi di un operatore binario di addizione o sottrazione, in cui il right-operand è 1, con un operatore di assegnazione, in cui il left-operand coincide con il left-operand dell'addizione o sottrazione.

Es. MiaVariabile++; equivale a MiaVariabile = MiaVariabile+1;

la prima forma è più rapida e compatta (specialmente se il nome della variabile è lungo!) .

Page 37: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'operatore può seguire (suffisso) o precedere (prefisso) l'operando. Nella forma prefisso l'incremento (o il decremento) viene eseguito prima che la variabile sia utilizzata nell'espressione, nella forma suffisso avviene il contrario.

Es: int a, b, c=5 ;

a = c++;

b = ++c;

alla fine di queste operazioni si trovano, nella variabili a, b e c, rispettivamente i valori 5, 7 e 7. Da questo esempio si capisce anche che la forma incremento (o decremento) conviene non solo perché è più compatta, ma soprattutto perché consente di ridurre il numero di istruzioni.

Operatore condizionale

L'operatore condizionale é l'unico operatore ternario (tre operandi):

condizione ? espressioneA : espressioneB

(dove condizione é un'espressione logica) l'operazione restituisce il valore dell'espressioneA se la condizione e' true o il valore dell'espressioneB se la condizione e' false

Es: minimo = a < b ? a : b ;

L'operatore condizionale gode della rara proprietà di restituire un ammissibile l-value (non in tutti i compilatori, però!) Es. (m < n ? a : b) = c ; (memorizza il valore di c in a se m é minore di n, altrimenti memorizza il valore di c in b) in questo caso però a e b non possono essere né espressioni né costanti, ma soltanto l-values.

Conversioni di tipo

Page 38: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Conversioni di tipo implicite

Il C++ esercita un forte controllo sui tipi e da' messaggio di errore quando si tenta di eseguire operazioni fra operandi di tipo non ammesso. Es. l'operatore % richiede che entrambi gli operandi siano interi.

I quattro operatori matematici si applicano a qualsiasi tipo intrinseco, ma i tipi dei due operandi devono essere uguali. Tuttavia, nel caso di due tipi diversi, il compilatore esegue una conversione di tipo implicita su uno dei due operandi, seguendo la regola di adeguare il tipo più semplice a quello più complesso, secondo la seguente gerarchia (in ordine crescente di complessità):

bool - char - unsigned char - short - unsigned short - long - unsigned long - float - double - long double

Es: nell'operazione 3.4 / 2 il secondo operando è trasformato in 2.0 e il risultato è correttamente 1.7

Nelle assegnazioni, il tipo del right-operand viene sempre trasformato implicitamente nel tipo del left-operand (con un messaggio warning se la conversione potrebbe implicare loss of data (perdita di dati), trasformando un tipo più complesso in un tipo più semplice).

Es: date le variabili char c e double d, l'assegnazione c = d è ammessa, ma genera un messaggio warning in fase di compilazione.

Nelle operazioni fra tipi interi, se il valore ottenuto esce dal range (overflow), l'errore non viene segnalato. La stessa cosa dicasi se l'overflow si verifica a seguito di una conversione di tipo.

Es: short n = 32767 ;

n++ ; (l'errore non viene segnalato, ma in n si ritrova il numero -32768)

Conversioni di tipo esplicite (casting)

Quando si vuole ottenere una conversione di tipo che non verrebbe eseguita implicitamente, bisogna usare l'operatore binario di casting (o conversione esplicita), che consiste nell'indicazione del nuovo tipo fra parentesi davanti al nome della variabile da trasformare.

Es. se la variabile n é di tipo int, l'espressione (float)n trasforma il contenuto di n da int in float.

In C++ si può usare anche il formato funzione (function-style casting):

Page 39: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

float(n) é equivalente a (float)n

va detto che il function-style casting non è sempre possibile (per esempio con i puntatori non si può fare).

Tutti i tipi nativi consentono il casting, fermo restando il fatto che, se la variabile da trasformare è operando di una certa operazione, il tipo risultante deve essere fra quelli ammissibili (altrimenti viene generato un errore in compilazione). Per esempio: float(n) % 3 é errato in quanto l'operatore % ammette solo operandi interi.

Vediamo ora un esempio in cui si evidenzia la necessità del casting:

int m=10, n=4;

float r, a=2.7F;

r = m/n+a;

nell'ultima istruzione la divisione è fra due numeri interi e quindi, essendo i due operandi dello stesso tipo, la conversione implicita non viene eseguita e il risultato della divisione è il numero intero 2; solo successivamente questo numero viene convertito in modo implicito in 2.0 per essere sommato ad a. Se vogliamo che la conversione a float avvenga prima della divisione, e che questa fornisca il risultato esatto (cioè 2.5), dobbiamo convertire esplicitamente almeno uno dei due operandi e quindi riscrivere così la terza istruzione:

r = (float)m/n+a; (non servono altre parentesi perchè il casting ha la precedenza sulla divisione)

Il casting che abbiamo esaminato finora è quello del C (C-style casting). Il C++ ha aggiunto altri quattro operatori di casting, suddividendo le conversioni di tipo in altrettante categorie e riservando un operatore per ciascuna di esse (per fornire al compilatore strumenti di controllo più raffinati). D'altra parte il C-style casting (che li comprende tutti) è ammesso anche in C++, e pertanto non tratteremo in questo corso degli altri operatori di casting, limitandoci a fornirne l'elenco: static_cast<T>(E) dynamic_cast<T>(E) reinterpret_cast<T>(E) const_cast<T>(E) dove è E un'espressione qualsiasi il cui tipo è convertito nel tipo T.

Precedenza fra operatori

Page 40: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Nella seguente tabella gli operatori sono in ordine di precedenza decrescente (nello stesso blocco di righe hanno uguale precedenza):

[Legenda degli operandi: id=identificatore; pid=puntatore a identificatore; expr=espressione; lv=l-value; ...=operandi opzionali]

DESCRIZIONE OPERATORE CATEGORIA SIMBOLO E OPERANDI ASSOCIATIVITA'

risoluzione di visibilità riferimento globale

binario unario

id::id ::id

da sinistra a destra ----

selezione di un membro selezione di un membro puntato indicizzazione array chiamata di funzione incremento suffisso decremento suffisso identificazione di tipo

binario binario binario binario unario unario unario

id.id pid->id id[expr] id(expr)

lv++ lv--

typeid(expr)

da sinistra a destra da sinistra a destra da sinistra a destra ---- ---- ---- ----

dimensione di un oggetto complemento a 1 NOT logico incremento prefisso decremento prefisso segno - algebrico segno + algebrico indirizzo di memoria dereferenziazione allocazione di memoria deallocazione di memoria casting (conversione di tipo)

unario unario unario unario unario unario unario unario unario

ternario binario binario

sizeof(expr) ~ expr ! expr ++lv --lv

- expr + expr

&lv *pid

new tipo ... delete ... pid (tipo)expr

---- da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra ---- ---- da destra a sinistra

moltiplicazione divisione resto di divisione intera

binario binario binario

expr * expr expr / expr

expr % expr

da sinistra a destra da sinistra a destra da sinistra a destra

addizione sottrazione

binario binario

expr + expr expr - expr

da sinistra a destra

Page 41: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

da sinistra a destra

scorrimento a destra scorrimento a sinistra

binario binario

expr >> expr expr << expr

da sinistra a destra da sinistra a destra

minore minore o uguale maggiore maggiore o uguale

binario binario binario binario

expr < expr expr <= expr expr > expr

expr >= expr

da sinistra a destra da sinistra a destra da sinistra a destra da sinistra a destra

uguale diverso

binario binario

expr == expr expr != expr

da sinistra a destra da sinistra a destra

AND bit a bit binario expr & expr da sinistra a destra

XOR bit a bit binario expr ^ expr da sinistra a destra

OR bit a bit binario expr | expr da sinistra a destra

AND logico binario expr && expr da sinistra a destra

OR logico binario expr || expr da sinistra a destra

espressione condizionale ternario expr ? expr : expr

da destra a sinistra

assegnazione moltiplicazione e assegnazione divisione e assegnazione resto e assegnazione addizione e assegnazione sottrazione e assegnazione scorrimento a destra e assegnazione scorrimento a sinistra e assegnazione AND bit a bit e assegnazione OR bit a bit e assegnazione XOR bit a bit e assegnazione

binario binario binario binario binario binario binario binario binario binario binario

lv = expr lv *= expr lv /= expr

lv %= expr lv += expr lv -= expr

lv >>= expr lv <<= expr lv &= expr lv |= expr lv ^= expr

da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra da destra a sinistra

Page 42: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

da destra a sinistra da destra a sinistra

serializzazione delle espressioni binario expr , expr da sinistra a destra

Ordine di valutazione

Le regole di precedenza e associatività fra gli operatori non garantiscono che l'ordine di valutazione delle sotto-espressioni all'interno di un espressione sia sempre definito. Per esempio, si consideri l'espressione: a = fun1(5) + fun2(3); (dove fun1 e fun2 sono funzioni) le regole di precedenza assicurano che prima vengano eseguite le chiamate delle funzioni, poi l'addizione e infine l'assegnazione, ma non è definito quale delle due funzioni venga chiamata per prima.

Un altro caso di ordine di valutazione indefinito si ha fra gli argomenti di chiamata di una funzione: Es. funz(expr1,expr2) valuta prima expr1 o expr2 ?

All'opposto, in molti casi, l'ordine di valutazione è univocamente definito, come per esempio nelle operazioni logiche:

1. expr1 && expr2 2. expr1 || expr2

in entrambi i casi expr1 è sempre valutata prima di expr2; in più, expr2 è valutata (e quindi eseguita) solo se è necessario. In altre parole:

• nel caso 1. expr2 non viene eseguita se expr1 è false • nel caso 2. expr2 non viene eseguita se expr1 è true

questo tipo di azione si chiama valutazione cortocircuitata ed è molto utile perché consente di ridurre il numero di istruzioni.

Page 43: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Introduzione all'I/O sui dispositivi standard

In questa lezione introdurremo le caratteristiche principali dell'I/O in C++, limitandoci per il momento all'I/O in free-format sui dispositivi standard di input e di output.

Precisiamo che useremo una libreria (dichiarata nell'header-file: <iostream.h>) che è ormai "superata" dalla Libreria Standard (alcuni compilatori danno un messaggio di warning, avvisando che si sta usando una "deprecated" (?!) library). Tuttavia questa libreria è ancora integrata nello standard e ci sembra un buon approccio per introdurre l'argomento.

Dispositivi standard di I/O

In C++ (come in C) sono definiti i seguenti dispositivi standard di I/O (elenchiamo i tre principali):

• stdout standard output (di default associato al video) • stderr standard output per i messaggi (associato al video) • stdin standard input (di default associato alla tastiera)

stdin e stdout sono reindirizzabili a files nella linea di comando quando si lancia il programma eseguibile.

Oggetti globali di I/O

In C++ i dispositivi standard di I/O stdout, stderr e stdin sono "collegati" rispettivamente agli oggetti globali cout, cerr e cin.

Oggetto (definizione temporanea): variabile appartenente a un tipo astratto, non nativo del linguaggio.

Globale: visibile sempre e dappertutto.

Un oggetto globale é creato appena si lancia il programma, prima che venga eseguita la prima istruzione del main.

Page 44: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Per definire gli oggetti globali di I/O bisogna includere l'header-file: <iostream.h>.

Operatori di flusso di I/O

In C++ sono definiti gli operatori di flusso di I/O

<< (inserimento)

e

>> (estrazione)

i cui left-operand sono rispettivamente cout (oppure cerr, che non menzioneremo più, in quanto le sue proprietà sono identiche a quelle di cout) e cin.

Il compilatore distingue gli operatori di flusso da quelli di shift dei bit (identificati dagli stessi simboli) in base al contesto, cioè in base al tipo degli operandi.

Output tramite l'operatore di inserimento

In C++ un'operazione di output si identifica con un'operazione di inserimento nell'oggetto cout:

cout << dato;

dove dato è una qualsiasi variabile o espressione di tipo nativo (oppure una stringa). L'istruzione significa: il "dato" viene "inserito" nell'oggetto cout (e da questo automaticamente trasferito su stdout).

A differenza dalla funzione printf non è necessario usare specificatori di formato, in quanto il tipo delle variabili è riconosciuto automaticamente (in realtà, come vedremo più avanti, esistono anche qui degli specificatori, detti "manipolatori di formato", ma servono soltanto quando la scrittura deve essere non in free-format).

Page 45: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Esempi: cout << "Scrive una stringa\n";

cout << Variabile_intera;

cout << Variabile_float;

ecc.....

In ogni operazione viene trasferito un solo dato per volta; per cui, se si devono scrivere più dati (specie se di tipo diverso), vanno fatte altrettante operazioni di inserimento, con istruzioni separate. Alternativamente, in una stessa istruzione si possono "impilare" più operazioni di inserimento una di seguito all'altra.

Esempio: cout << dato1 << dato2 << dato3;

equivale a: cout << dato1; cout << dato2; cout << dato3;

questo è possibile grazie al fatto che l'operatore << restituisce lo stesso oggetto del left-operand (cioè cout) e che l'associatività dell'operazione procede da sinistra a destra.

Una variabile di tipo char è scritta come carattere; per scriverla come numero occorre fare il casting.

Per esempio, l'istruzione:

cout << 'A' << " ha codice ascii: " << (int)'A' << "\n";

visualizza la frase: A ha codice ascii 65

Input tramite l'operatore di estrazione

In C++ un'operazione di input si identifica con un'operazione di estrazione dall'oggetto cin:

cin >> dato;

dove dato è un l-value di qualsiasi tipo nativo (oppure una variabile stringa). L'istruzione significa: il valore immesso da stdin (automaticamente trasferito in cin) viene "estratto" dall'oggetto cin e memorizzato nella variabile "dato".

Come le operazioni di inserimento, anche quelle di estrazione possono essere "impilate" una di seguito all'altra in un'unica istruzione. Esempio: cin >> dato1 >> dato2 >> dato3; (i dati dato1, dato2, dato3 devono essere forniti nello stesso ordine).

Il programma interpreta la lettura di un dato come terminata se incontra un blank, un carattere di tabulazione o un ritorno a capo. Ne consegue che, se

Page 46: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

l'input è una stringa, non deve contenere blanks (né tabs) e non può essere spezzata in due righe. D'altra parte l'esistenza dei terminatori (blank, tab o CR) consente di immettere più dati nella stessa riga.

Casi particolari:

• i terminatori inseriti ripetutamente o prima del dato da leggere sono ignorati

• se il dato da leggere é di tipo numerico, la lettura é terminata quando incontra un carattere non valido (compreso il punto decimale se il numero é intero, cioè non esegue conversioni di tipo)

• se il dato da leggere é di tipo char, legge un solo carattere

Memorizzazione dei dati introdotti da tastiera

Se stdin é associato, come di default, alla tastiera, la memorizzazione dei dati segue delle regole generali, che sono le stesse sia in C++ (lettura tramite l'oggetto cin) che in C (lettura tramite le funzioni di libreria):

• la lettura non avviene direttamente, ma tramite un'area di memoria, detta buffer di input;

• il programma, appena incontra un'istruzione di lettura, si appresta a memorizzare i dati (che distingue l'uno dall'altro riconoscendo i terminatori) trasferendoli dal buffer di input, finché questo non resta vuoto;

• se il buffer di input si svuota prima che la lettura sia terminata (oppure se il buffer é già vuoto all'inizio della lettura, come dovrebbe succedere sempre), il programma si ferma in attesa di input e il controllo passa all'operatore, che viene abilitato a introdurre dati da tastiera fino a quando non invia un enter (indipendentemente dal numero di dati da leggere); l'intera riga digitata dall'operatore viene poi trasferita nel buffer di input, al quale il programma riaccede per completare l'operazione di lettura;

• se nel buffer di input restano ancora dati dopo che l'operazione di lettura é finita, questi verranno memorizzati durante la lettura successiva.

Come si può notare, la presenza del buffer di input (molto utile peraltro per migliorare l'efficienza del programma) crea una specie di "asincronismo" fra operatore e programma, che può essere facilmente causa di errore: bisogna fare attenzione a fornire ogni volta esattamente il numero di dati richiesti.

Page 47: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Comportamento in caso di errore in lettura

Le operazioni di estrazione non restituiscono mai espliciti messaggi di errore, tuttavia,

• se il primo carattere letto non é valido (per esempio una lettera se vuole leggere un numero), il programma non memorizza il dato e imposta una condizione di errore interna che inibisce anche le successive operazioni di lettura (nel senso che tutte le istruzioni di lettura, dal punto dell'errore in poi, vengono "saltate");

• se invece il carattere non valido non è il primo, il programma accetta il dato letto fino a quel momento, ma il carattere invalido resta nel buffer, disponibile per le operazioni di lettura successive.

Per accorgersi di un errore (e per porvi rimedio) bisogna utilizzare alcune proprietà dell'oggetto cin (di cui parleremo più avanti).

Page 48: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Il Compilatore GNU gcc in ambiente Linux

Un compilatore integrato C/C++

Per Linux e' disponibile un compilatore integrato C/C++: si tratta dei comandi GNU gcc e g++, rispettivamente. In realta g++ e' uno script che chiama gcc con opzioni specifiche per riconoscere il C++.

Il progetto GNU

Il comando gcc, GNU Compiler Collection, fa parte del progetto GNU (web server www.gnu.org). Il progetto GNU fu lanciato nel 1984 da Richard Stallman con lo scopo di sviluppare un sistema operativo di tipo Unix che fosse completamente "free" software.

Cosa è GNU/Linux? Gnu Non è Unix!

"GNU, che sta per "Gnu's Not Unix" (Gnu Non è Unix), è il nome del sistema software completo e Unix-compatibile che sto scrivendo per distribuirlo liberamente a chiunque lo possa utilizzare. Molti altri volontari mi stanno aiutando. Abbiamo gran necessità di contributi in tempo, denaro, programmi e macchine."

[Richard Stallman, Dal manifesto GNU, http://www.gnu.org/gnu/manifesto.html

Quale versione di gcc sto usando?

Si puo' determinare la versione del compilatore invocando:

gcc -v gcc version 2.96 20000731 (Red Hat Linux 7.1 2.96-98)

I passi della compilazione

Sia gcc che g++ processano file di input attraverso uno o piu' dei seguenti passi:

1) preprocessing -rimozione dei commenti -interpretazioni di speciali direttive per il preprocessore denotate da "#"

Page 49: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

come: #include - include il contenuto di un determinato file, Es. #include<math.h> #define -definisce un nome simbolico o una variabile, Es. #define MAX_ARRAY_SIZE 100

2) compilation -traduzione del codice sorgente ricevuto dal preprocessore in codice assembly

3) assembly -creazione del codice oggetto

4) linking -combinazione delle funzioni definite in altri file sorgenti o definite in librerie con la funzione main() per creare il file eseguibile.

Estensioni

Alcuni suffissi di moduli implicati nel processo di compilazione:

.c modulo sorgente C; da preprocessare, compilare e assemblare .cc modulo sorgente C++; da preprocessare, compilare e assemblare .cpp modulo sorgente C++; da preprocessare, compilare e assemblare .h modulo per il preprocessore; di solito non nominato nella riga di commando .o modulo oggetto; da passare linker .a sono librerie statiche .so sono librerie dinamiche

L' input/output di gcc

gcc accetta in input ed effettua la compilazione di codice C o C++ in un solo colpo. Consideriamo il seguente codice sorgente C:

/* il codice C pippo.c */ #include <stdio.h> int main() { puts("ciao pippo!"); return 0; }

Per effettuare la compilazione

Page 50: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

gcc pippo.c

In questo caso l' output di default e' direttamente l'eseguibile a.out.

Di solito si specifica il nome del file di output utilizzando l' opzione -o :

gcc -o prova pippo.c

L'eseguibile puo' essere lanciato usando semplicemente

./prova ciao pippo!

Nota. Usare "./" puo' sembrare superfluo. In realta' si dimostra molto utile per evitare di lanciare involontariamente un programma omonimo, per esempio il comando "test"!

Consideriamo ora un codice sorgente C++ analogo:

// Il codice C++ pippo.cpp #include<iostream> int main() { cout<<"ciao pippo!"<<'\n'; return 0; }

Questa volta compiliamo usando

g++ -o prova pippo.cpp

Il valore restituito al sistema

Per verificare il valore restituito dal programma al sistema tramite l'istruzione di return usiamo

./prova ciao pippo! echo $? 0

Page 51: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Passaggi intermedi di compilazione

Per compilare senza effettuare il link usare

g++ -c pippo.cpp

In questo caso viene creato il file oggetto pippo.o . Per effettuare il link usiamo

g++ -o prova pippo.o

I messaggi del compilatore

Il compilatore invia spesso dei messaggi all'utente. Questi messaggi si possono classificare in due famiglie: messaggi di avvertimento (warning messagges) e messaggi di errore (error messagges). I messaggi di avvertimento indicano la presenza di parti di codice presumibilmente mal scritte o di problemi che potrebbero avvenire in seguito, durante l'esecuzione del programma. I messaggi di avvertimento non interrompono comunque la compilazione.I messaggi di errore invece indicano qualcosa che deve essere necessariamente corretto e causano l'interruzione della compilazione.

Esempio di un codice C++ che genera un warning:

// example1.cpp #include<iostream> float multi(int a, int b) { return a*b; }; int main() { float a=2.5; int b=1; cout<<"a="<<a<<", b="<<b<<'\n'; cout<<"a*b="<<multi(a,b)<<'\n'; return 0; }

In fase di compilazione apparira' il seguente warning:

Page 52: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

example1.cpp: In function `int main ()': example1.cpp:12: warning: passing `float' for argument passing 1 of `multi (int, int)' example1.cpp:12: warning: argument to `int' from `float'

Il messaggio ci avvisa che alla linea 12 del main() e' stato passato alla funzione multi un float invece che un int.

Esempio di un codice che genera un messaggio di errore:

// example1.cpp #include<iostream> float multi(int a, int b) { return a*b }; int main() { int a=2; int b=1; cout<<"a="<<a<<", b="<<b<<'\n'; cout<<"a/b="<<multi(a,b)<<'\n'; return 0; }

Si noti che l' instruzione di return all' interno della funzione multi non termina con il ; . A causa di questo grave errore la compilazione non puo' essere portata a termine:

example1.cpp: In function `float multi (int, int)': example1.cpp:5: parse error before `}' example1.cpp:5: warning: no return statement in function returning non-void

Controlliamo i livelli di warning

Per inibire tutti i messaggi di warinig usare l' opzione -w

Page 53: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

g++ -w -o prova example1.cpp

Per usare il massimo livello di warning usare l' opzione -Wall

g++ -Wall -o prova example1.cpp

Compilare per effetture il debug

Se siete intenzionati ad effettuare il debug di un programma, utilizzate sempre l'opzione -g:

g++ -Wall -g -o pippo example1.cpp

L' opzione -g fa in modo che il programma eseguibile contenga informazioni supplementari che permettono al debugger di collegare le istruzioni in linguaggio macchina che si trovano nell'eseguibile alle righe del codice corrispondenti nei sorgenti C/C++.

Autopsia di un programma defunto

Il seguente codice C++, wrong.cpp, genera un errore (nella fattispecie una divisione per 0) in fase di esecuzione che porta alla terminazione innaturale del programma

#include<iostream> int div(int a, int b) { return a/b; }; int main() { int a=2; int b=0; cout<<"a="<<a<<", b="<<b<<'\n'; cout<<"a/b="<<div(a,b)<<'\n'; return 0; }

Page 54: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Compiliamo il file wrong_code.cpp

g++ -Wall -g -o wrong_program wrong_code.cpp

Il codice e' sintatticamente ineccepibile e verra' compilato senza problemi. In fase di esecuzione si verifica tuttavia una divisione per zero che causa la morte del programma.

./wrong_program a=2, b=0 Floating exception (core dumped)

Linux genera nella directory corrente un file in cui scarica la memoria memoria assocciata al programma (core dump):

ls -sh total 132k 100k core 4.0k wrong_code.cpp 28k wrong_program*

Il file core contiene l 'immagine della memoria (riferita al nostro programma) al momento dell'errore. Possiamo effettuare l' autopsia del programma utilizzando il debugger GNU gdb

gdb wrong_program core ... Core was generated by `wrong_prog'. ... #0 0x080486a5 in div (a=2, b=0) at wrong_code.cpp:4 4 return a/b; (gdb) where #0 0x080486a5 in div (a=2, b=0) at wrong_code.cpp:4 #1 0x08048737 in main () at wrong_code.cpp:13 #2 0x400b1647 in libc_start_main __

Page 55: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

(main=0x80486b0 <main>, argc=1, ubp_av=0xbfffe614, init=0x80484e8 <_init>, fini=0x80487b0 <_fini>, rtld_fini=0x4000dcd4 <_dl_fini>, stack_end=0xbfffe60c) at ../sysdeps/generic/libc-start.c:129 (gdb) quit

Il comando where di gdb ci informa che l' errore si e' verificato alla riga 4 del modulo wrong_code.cpp. Esiste una versione con interfaccia grafica di gdb : kdbg

Ottimizzazione

Il compilatore gcc consente di utilizzare diverse opzioni per ottenere un risultato più o meno ottimizzato. L'ottimizzazione richiede una potenza elaborativa maggiore, al crescere del livello di ottimizzazione richiesto. L' opzione -On ottimizza il codice, dove n é il livello di ottimizzazione. Il massimo livello di ottimizzazione allo stato attuale é il 3, quello generalmente più usato é 2. Quando non si deve eseguire il debug é consigliato ottimizzare il codice.

Opzione Descrizione

-O, -O1 Ottimizzazione minima

-O2 Ottimizzazione media

-O3 Ottimizzazione massima

-O0 Nessuna ottimizzazione

Esempio di un codice chiaramente inefficiente

int main() { int a=10; int b=1; int c; for (int i=0; i<1e9; i++) { c=i+a*b-a/b; } return 0; }

Page 56: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Confronto dei tempi di esecuzione in funzione di livelli di ottimizzazione crescente

Livello Tempo di esecuzione (secondi)

O0 32.2

O1 5.4

O2 5.2

O3 5.2

Compilazione di un programma modulare

Un programma modulare e' un programma spezzettato in componenti piu' piccole con funzioni specifiche. La programmazione modulare e' piu' facile da comprendere e da correggere.

Nel seguito abbiamo un programma C++ composto da tre moduli: main.cpp, myfunc.cpp e myfunc.h .

// main.cpp #include<iostream> #include"myfunc.h" int main() { int a=6; int b=3; cout<<"a="<<a<<", b="<<b<<'\n'; cout<<"a/b="<<div(a,b)<<'\n'; cout<<"a*b="<<mul(a,b)<<'\n'; return 0; }

// myfunc.h int div(int a, int b); int mul(int a, int b);

// myfunc.cpp int div(int a, int b) { return a/b; };

Page 57: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

int mul(int a, int b) { return a*b; };

1. Per compilare usiamo

g++ -Wall -g -o prova main.cpp myfunc.cpp

Si noti che il file myfunc.h non appare nella riga di comando, verra' incluso dal gcc in fase di precompilazione.

Inclusione di librerie in fase di compilazione

L'opzione -lnome_libreria compila utilizzando la libreria indicata, tenendo presente che, per questo, verrà cercato un file che inizia per lib, continua con il nome indicato e termina con .a oppure .so.

Modifichiamo i moduli myfunc.h e myfunc.cpp aggiungendo la funzione pot:

// myfunc.h int div(int a, int b); int mul(int a, int b); float pot(float a, float b);

// myfunc.cpp int div(int a, int b) { return a/b; }; int mul(int a, int b) { return a*b; }; float pot(float a, float b) { return pow(a,b); }

La compilazione pero' si interrompe

g++ -Wall -g -o prova main.cpp myfunc.cpp myfunc.cpp: In function `float pot (float,

Page 58: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

float)': myfunc.cpp:11: `pow' undeclared (first use this function) myfunc.cpp:11: (Each undeclared identifier is reported only once for each function it appears in.)

La funzione pow e' contenuta nella libreria matematica math, dobbiamo allora aggiungere l'istruzione include nel modulo :

// myfunc.cpp #include<math.h> int div(int a, int b) { return a/b; }; int mul(int a, int b) { return a*b; }; float pot(float a, float b) { return pow(a,b); }

e compilare con un link alla libreria libm.so

g++ -Wall -g -o prova main.cpp myfunc.cpp -lm

Di default il compilatore esegue la ricerca della libreria nel direttorio standard /usr/lib/. Tramite l' opzione -L/nome_dir, e' possibile aggiunge la directory /nome_dir alla lista di direttori in cui gcc cerca le librerie in fase di linking.

Page 59: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Il Comando 'make' in ambiente Linux

Perche' utilizzare il comando make?

Immaginate un progetto molto esteso, formato da decine e decine di moduli, e di voler cambiare solo una piccola parte di codice e di voler poi testare il programma. Ovviamente, per evitare di ricompilare tutto il codice ad ogni modifica, e' conveniente compilare solo i moduli appena modificati e poi effettuare il link con la parte di codice rimasta immutata. Potrebbe pero' essere difficile, o quanto meno noioso, controllare ripetutamente quali moduli devono essere per forza ricompilati e quali no. Il comando make fa questo per voi!

Il Makefile ed i target del make

Per funzionare make ha bisogno che voi scriviate un file chiamato Makefile in cui siano descritte le relazioni fra i vostri files ed i comandi per aggiornarli. Quando il make viene invocato esegue le istruzioni contenute nel Makefile.

Una idea base che bisogna capire del make e' il concetto di target . Il primo target in assoluto e' il Makefile stesso. se si lancia il make senza aver preparato un Makefile si ottiene il seguente risultato

make make: *** No targets specified and no makefile found. Stop.

Quello che segue e' un semplice Makefile in cui sono stati definiti tre target e tre azioni corrispondenti:

# Un esempio di Makefile one: @echo UNO! two: @echo DUE! three: @echo E TRE!

La definizione di un target inizia sempre all'inizio della riga ed seguito da : . Le azioni (in questo caso degli output su schermo) seguono le definizioni di ogni

target e, anche se in questo esempio sono singole, possono essere molteplici. La prima riga, che inizia con #, e' un commento.

Page 60: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Per utilizare i target invochiamoli sulla riga di comando del make:

make one UNO! make one two three UNO! DUE! E TRE!

Se non si invoca nessun target nella linea di comando, make assume come default il primo che trova nel Makefile:

make UNO!

IMPORTANTE: le linee in cui si specificano le azioni corrispondenti ad ogni target (Es. @echo UNO!) devono iniziare con un separatore <TAB>!

Il seguente Makefile non e' valido perche' la riga seguente la definizione del target non inizia con un separatore <TAB>:

# Un esempio di Makefile mal scritto one: @echo UNO!

make one Makefile:4: *** missing separator. Stop.

Le righe di azione devo iniziare invariabilmente con un separatore <TAB>, NON POSSONO ESSERE UITLIZZATI DEGLI SPAZI!

Dipendenze

E' possibile definire delle dipendenze fra i target all' interno del Makefile

# Un esempio di Makefile con dipendenze one: @echo UNO! two: one

Page 61: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

@echo DUE! three: one two @echo E TRE! all: one two three @echo TUTTI E TRE!

Si noti come i target vengono elaborati in sequenza:

make three UNO! DUE! E TRE! make all UNO! DUE! E TRE! TUTTI E TRE!

Macro e variabili ambiente

E' possibile definere delle Macro all' interno del Makefile

#Definiamo la Macro OBJECT OBJECT=PIPPO one: @echo CIAO $(OBJECT)!

make CIAO PIPPO!

Possiamo ridefinire il valore della macro OBJECT direttamente sulla riga di comando, senza alterare il Makefile!

make OBJECT=pippa CIAO pippa!

Il Makefile puo' accedere alle variabili ambiente:

Page 62: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

# Usiamo una variabile ambiente OBJECT=$(TERM) one: @echo CIAO $(OBJECT)!

make CIAO xterm!

Compiliamo con make (finalmente)

Supponiamo di voler compilare il seguente codice C++ composto da tre moduli (main.cpp, myfunc.cpp e myfunc.h) usando il comando make.

// main.cpp #include<iostream> #include"myfunc.h" int main() { int a=6; int b=3; cout<<"a="<<a<<", b="<<b<<endl; cout<<"a/b="<<div(a,b)<<endl; cout<<"a*b="<<mul(a,b)<<endl; cout<<"a^b="<<pot(a,b)<<endl; return 0; }

// myfunc.cpp #include<math.h> int div(int a, int b) { return a/b; }; int mul(int a, int b) { return a*b; }; float pot(float a, float b) { return pow(a,b); }

// myfunc.h

Page 63: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

int div(int a, int b); int mul(int a, int b); float pot(float a, float b);

Un semplice Makefile si presenta cosi':

OBJECTS=main.o myfunc.o CFLAGS=-g -Wall LIBS=-lm CC=g++ PROGRAM_NAME=prova $(PROGRAM_NAME):$(OBJECTS) $(CC) $(CFLAGS) -o $(PROGRAM_NAME) $(OBJECTS) $(LIBS) @echo " " @echo "Compilazione completata!" @echo " "

Il make ricompilera' il target prova se i files da cui questo dipende (gli OBJECTS main.o e myfunc.o) sono stati modificati dopo che prova e' stato modificato l'ultima volta oppure non esistono. Il processo di ricompilazione avverra' secondo la regola descritta nell' azione del target e usando le Macro definite dall' utente (CC, CFLAGS, LIBS).

Per compilare usiamo semplicemente

make g++ -c -o main.o main.cpp g++ -c -o myfunc.o myfunc.cpp g++ -g -Wall -o prova main.o myfunc.o -lm Compilazione completata!

Se modifichiamo solo un modulo, per esempio myfunc.cpp, il make effettuera' la compilazione di questo file solamente.

make g++ -c -o myfunc.o myfunc.cpp g++ -g -Wall -o prova main.o myfunc.o -lm Compilazione completata!

Alcuni target standard

Esistono alcuni target standard usati da programmatori Linux e GNU. Fra questi:

Page 64: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• install, viene utilizzato per installare i file di un progetto e puo' comprendere la creazione di nuove directory e la assegnazione di diritti di accesso ai file.

• clean, viene utilizzato per rimuovere dal sistema i file oggetto (*.o), i file core, e altri file tempornei creati in fase di compilazione

• all, di solito utilizzato per richiamare altri target con lo scopo di costruire l'intero progetto.

Aggiungiamo il target clean al nostro Makefile:

OBJECTS=main.o myfunc.o CC=g++ CFLAGS=-g -Wall LIBS=-lm PROGRAM_NAME=prova $(PROGRAM_NAME):$(OBJECTS) $(CC) $(CFLAGS) -o $(PROGRAM_NAME) $(OBJECTS) $(LIBS) @echo " " @echo "Compilazione completata!" @echo " " clean: rm -f *.o rm -f core

Invocare il target clean comporta la cancellazione di tutti i file oggetto e del file core.

make clean rm -f *.o rm -f core

Page 65: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Istruzioni di controllo

Si chiamano "istruzioni di controllo" in C++ (come in C) quelle istruzioni che modificano l'esecuzione sequenziale di un programma.

Istruzione di controllo if

Sintassi:

if (condizione) istruzione;

(dove condizione é un'espressione logica) se la condizione é true il programma esegue l'istruzione, altrimenti passa direttamente all'istruzione successiva

Nel caso di due scelte alternative, all'istruzione if si può associare l'istruzione else :

if (condizione) istruzioneA; else istruzioneB;

se la condizione é true il programma esegue l'istruzioneA, altrimenti esegue l'istruzioneB

Se le istruzioni da eseguire in base alla condizione sono più di una, bisogna creare un ambito, cioè raggruppare le istruzioni fra parentesi graffe:

if (condizione) { ....... blocco di istruzioni ...... }

e analogamente:

else { ....... blocco di istruzioni ...... }

[p11]

Se l'istruzione controllata da un if consiste a sua volta in un altro if (sono possibili più istruzioni if "innestate"), ogni eventuale else si riferisce sempre all'if immediatamente superiore (in assenza di parentesi graffe).

Page 66: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Es. if (cond1) if (cond2) istr1; else istr2; (istr2 é eseguita se cond1 é true e cond2 é false)

Invece: if (cond1) { if (cond2) istr1;} else istr2; (istr2 é eseguita se cond1 é false, indipendentemente da cond2).

Per essere sicuri di ottenere quello che si vuole, mettere sempre le parentesi graffe, anche se sono ridondanti, e quindi il primo caso è equivalente (ma più chiaro) se si scrive: if (cond1) { if (cond2) istr1; else istr2; }

Istruzione di controllo while

Sintassi:

while (condizione) istruzione;

(dove condizione é un'espressione logica) il programma esegue ripetutamente l'istruzione finchè la condizione é true e passa all'istruzione successiva appena la condizione diventa false. Ovviamente, affinché il loop (ciclo) non si ripeta all'infinito, l'istruzione deve modificare qualche parametro della condizione.

Se le istruzioni da eseguire in base alla condizione sono più di una, bisogna creare un ambito, cioè raggruppare le istruzioni fra parentesi graffe:

while (condizione) { ....... blocco di istruzioni ...... }

La condizione viene verificata all'inizio di ogni iterazione del ciclo: é pertanto possibile, se la condizione é già inizialmente false, che il ciclo non venga eseguito neppure una volta.

E' ammessa anche la forma: while (condizione) ; in questo caso, per evitare un loop infinito, la condizione deve essere in grado di automodificarsi.

Page 67: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Istruzione di controllo do ... while

Sintassi:

do { ... blocco di istruzioni ... } while ( condizione ) ;

(dove condizione é un'espressione logica) funziona come l'istruzione while, con la differenza che la condizione é verificata alla fine di ogni iterazione e pertanto il ciclo é sempre eseguito almeno una volta. Se la condizione é true il programma torna all'inizio del ciclo ed esegue una nuova iterazione, se é false, passa all'istruzione successiva. Le parentesi graffe sono obbligatorie, anche se il blocco è costituito da una sola istruzione.

Istruzione di controllo for

Sintassi:

for (inizializzazione; condizione; modifica) istruzione;

(dove inizializzazione é un'espressione eseguita solo la prima volta, condizione é un'espressione logica, modifica é un'espressione eseguita alla fine di ogni iterazione) il programma esegue ripetutamente l'istruzione finchè la condizione é true e passa all'istruzione successiva appena la condizione diventa false.

Se le istruzioni da eseguire in base alla condizione sono più di una, bisogna creare un ambito, cioè raggruppare le istruzioni fra parentesi graffe:

for (inizializzazione; condizione; modifica) { ....... blocco di istruzioni ...... }

Page 68: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'istruzione for é simile all'istruzione while, con le differenze che in while l'inizializzazione é impostata precedentemente e la modifica é eseguita all'interno del blocco di istruzioni del ciclo. Come in while, anche in for la condizione viene verificata all'inizio di ogni iterazione.

Esempio (confronto fra for e while):

int conta; int conta=0;

for (conta=0; conta<10; conta+=2) while (conta<10)

cout << conta << '\n' ; {

cout << conta << '\n' ;

conta+=2;

}

Tutte le parti di un'istruzione for sono opzionali; al limite anche l'istruzione:

for (;;) ; (loop infinito)

é sintatticamente valida (anche se poco "pratica"!); infatti, se non specificata, la condizione é di default true.

Istruzioni continue, break e goto

Le istruzioni continue e break sono utilizzate all'interno di un blocco di istruzioni controllate da while, do ... while o for.

L'istruzione continue interrompe l'iterazione corrente del ciclo: il programma riprende dall'inizio dell'iterazione successiva, previa esecuzione della modifica (nei cicli for) e verifica della condizione (in tutti i tipi di cicli).

L'istruzione break interrompe completamente un ciclo: il programma riprende dalla prima istruzione successiva a quelle del ciclo.

Nel caso di più cicli innestati, le istruzioni continue e break hanno effetto esclusivamente sul ciclo a cui appartengono e non su quelli più esterni.

Page 69: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Il C++ mantiene la "vecchia" istruzione goto :

goto identificatore; ............................ ............................ identificatore: istruzione;

il flusso del programma "salta" direttamente all'istruzione labellata (etichettata) identificatore. L'istruzione goto ha pochi utilizzi nella normale programmazione ad alto livello. Può essere importante nei rari casi in cui è richiesta la massima efficienza (per esempio in applicazioni in tempo reale), oppure per uscire direttamente dal più interno di diversi cicli innestati.

Istruzione di controllo switch ... case

Sintassi (le parti fra parentesi quadra sono opzionali):

switch( espressione ) { [case costante1 : [ blocco di istruzioni 1]] [case costante2 : [ blocco di istruzioni 2]] .............. [default : [blocco di istruzioni]] }

L'istruzione switch confronta il valore dell'espressione (che deve restituire un risultato intero) con le diverse costanti (dello stesso tipo dell'espressione) e, appena ne trova una uguale, esegue tutte le istruzioni da quel punto in poi (anche se le istruzioni relative allo stesso case sono più d'una, non è necessario inserirle fra parentesi graffe). Se nessuna costante é uguale al valore dell'espressione, esegue, se esistono, le istruzioni dopo default:

Per ottenere che le istruzioni selezionate siano eseguite in alternativa alle altre, bisogna inserire alla fine del corrispondente blocco l'istruzione break

[p15]

Page 70: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Array

Cos'è un array ?

Un array é un insieme di variabili che occupano locazioni consecutive in memoria e sono caratterizzate dall'appartenere tutte allo stesso tipo, detto tipo dell'array (può anche essere un tipo astratto).

Ogni variabile di tale insieme é detta elemento dell'array ed é identificata dalla sua posizione d'ordine nell'array (indice). L'intero array é identificato da un nome (che va specificato secondo le regole generali di specifica degli identificatori).

Il numero di elementi di un array (detto dimensione dell'array ) é predefinito e invariabile. In C++ (come in C) l'indice può assumere valori compresi fra zero e il numero di elementi meno 1.

Definizione e inizializzazione di un array

Per definire un array bisogna specificare prima il tipo e poi il nome dell'array, seguito dalla sua dimensione fra parentesi quadre (la dimensione deve essere espressa da una costante). Es. int valori[30];

In fase di definizione un array può essere anche inizializzato. I valori iniziali dei suoi elementi devono essere specificati fra parentesi graffe e separati l'un l'altro da una virgola; inoltre la dimensione dell'array, essendo determinata automaticamente, può essere omessa (non però le parentesi quadre, che costituiscono l'operatore di dichiarazione dell'array). Es. int valori[] = {32, 53, 28, 85, 21}; nel caso dell'esempio la dimensione 5 é automaticamente calcolata.

L'operatore [ ]

Page 71: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'operatore binario [ ] richiede come left-operand il nome di un array e come secondo operando (racchiuso fra le due parentesi quadre) una qualunque espressione con risultato intero (interpretato come indice dell'array).

Il significato dell'operatore [ ] é duplice:

• usato per restituire un l-value, é un operatore di inserimento di dati nell'array. Es. valori[3] = 45; (il numero 45 viene assegnato alla variabile identificata dall'indice 3 dell'array valori)

• usato per restituire un r-value, é un operatore di estrazione di dati dall'array. Es. a = valori[4] ; (il contenuto della variabile identificata dall'indice 4 dell'array valori viene assegnato alla variabile a)

Array multidimensionali

In C++ (come in C) sono possibili array con qualsivoglia numero di dimensioni; tali array vanno definiti come nel seguente esempio (array tridimensionale): float tabella[3][4][2];

NOTA : la formulazione appare un po' "strana", ma chiarisce il fatto che un array multidimensionale è da intendersi come un array di array. Nell'esempio: tabella è un array di 3 elementi, ciascuno dei quali è un array di 4 elementi, ciascuno dei quali è un array di 2 elementi di tipo float.

A differenza dal FORTRAN, in C++ (come in C) gli array multidimensionali sono memorizzati con gli indici meno significativi a destra ("per riga", nel caso di array bidimensionali). Per esempio, dato l'array A[2][3], i suoi elementi sono memorizzati nel seguente ordine: A[0][0] , A[0][1] , A[0][2] , A[1][0] , A[1][1] , A[1][2]

Per inizializzare un array multidimensionale, bisogna innestare tanti gruppi di parentesi graffe quante sono le singole porzioni monodimensionali dell'array, ed elencare gli elementi nello stesso ordine in cui saranno memorizzati.

Esempio, nel caso bidimensionale: int dati[3][2] = { {8, -5} , {4, 0} , {-2, 6 } };

Page 72: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'operatore sizeof e gli array

L'operatore sizeof, se l'operando é il nome di un array, restituisce il numero di bytes complessivi dell'array, che é dato dal numero degli elementi moltiplicato per la lunghezza in byte di ciascun elemento (la quale ovviamente dipende dal tipo dell'array).

[p16]

Gli array in C++

Gli array descritti finora sono quelli "in stile C". Nei programmi in C++ ad alto livello sono scarsamente utilizzati. Al loro posto si preferisce usare alcune classi della Libreria Standard (come vedremo) che offrono flessibilità molto maggiori (per esempio la dimensione è modificabile dinamicamente e inoltre, negli array multidimensionali, si possono definire singole porzioni monodimensionali con dimensioni diverse).

Page 73: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Stringhe di caratteri

Le stringhe come particolari array di caratteri

Abbiamo già visto che le stringhe non costituiscono un tipo intrinseco del C++ e di conseguenza non sono ammesse come operandi dalla maggior parte degli operatori (compreso l'operatore di assegnazione).

Sono tuttavia riconosciute da alcuni operatori (come per esempio gli operatori di flusso di I/O del C++) e da numerose funzioni di libreria del C (come per esempio la printf, insieme a molte altre che hanno il compito specifico di manipolare le stringhe).

In memoria le stringhe sono degli array di tipo char, con una particolarità in più, che le fa riconoscere da operatori e funzioni come stringhe e non come normali array: l'elemento dell'array che segue l'ultimo carattere della stringa deve contenere il carattere NULL (detto in questo caso terminatore); si dice pertanto che una stringa é un "array di tipo char null terminated".

Definizione di variabili stringa

Consideriamo il seguente esempio. L'istruzione:

char MiaVar[30];

definisce la variabile MiaVar come array di tipo char con massimo 30 elementi, ma non ancora come stringa. Affinché MiaVar sia identificata da operatori e funzioni come stringa, dobbiamo non solo definire una variabile array di tipo char, ma anche inserire nell'array una serie di caratteri terminata da un NULL. Per esempio, se vogliamo che MiaVar presenti a operatori e funzioni la stringa "Ciao", dobbiamo scrivere le istruzioni:

MiaVar[0] = 'C'; MiaVar[1] = 'i'; MiaVar[2] = 'a'; MiaVar[3] = 'o'; MiaVar[4] = '\0';

impegnando così 5 elementi dell'array dei 30 disponibili (i rimanenti 25 saranno ignorati).

Page 74: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Inizializzazione di variabili stringa

Benché le stringhe non siano ammesse nelle operazioni di assegnazione, lo sono in quelle di inizializzazione (il che conferma che si tratta di due operazioni diverse!):

Sequenza non valida Sequenza valida

char Saluto[10]; char Saluto[10] = "Ciao";

Saluto = "Ciao";

Nelle inizializzazioni si utilizzano le costanti stringa, i cui caratteri vengono inseriti nei primi elementi dell'array dichiarato; il terminatore viene aggiunto automaticamente nell'elemento successivo a quello in cui é stato inserito l'ultimo carattere. La stringa può essere "allungata" fino a un massimo di caratteri (terminatore compreso) pari alla dimensione dell'array.

E' anche possibile inizializzare una stringa come un normale array; in questo caso, però, il terminatore deve essere inserito esplicitamente: char Saluto[] = { 'C', 'i', 'a', 'o', '\0' }; ovviamente questa seconda forma, inutilmente più "faticosa", non é mai usata!

Se nella inizializzazione si omette la dimensione dell'array, questa viene automaticamente definita dalla lunghezza della costante stringa aumentata di uno, per far posto al terminatore (in questo caso la stringa non può più essere "allungata"!):

char Saluto[] = "Ciao"; (allocato in memoria array con 5 elementi).

In caso che si creino delle stringhe con un numero di caratteri (compreso il terminatore) maggiore di quello dichiarato, il programma non produce direttamente messaggi di errore, ma invade zone di memoria non di sua pertinenza, con conseguenze imprevedibili (spesso si verifica un errore fatale a livello di sistema operativo).

Funzioni di libreria gets e puts

Page 75: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Benché le funzioni gets e puts facciano parte della libreria di I/O del C, il loro uso é abbastanza frequente anche in programmi C++, a causa di alcune peculiarità che le distinguono da tutte le altre funzioni di I/O.

La funzione gets(argomento) trasferisce l'intero buffer di input di stdin nella variabile stringa argomento, riconoscendo come unico terminatore il carattere new-line ('\n') (che é sempre l'ultimo carattere del buffer) e sostituendolo con il carattere NULL ('\0'). Ne consegue che la stringa può contenere anche blanks e tabulazioni (a differenza dalle stringhe lette mediante cin o le altre funzioni di input del C). In pratica, la gets legge da tastiera un'intera riga di testo, compreso il ritorno a capo che trasforma nel terminatore della stringa.

La funzione puts(argomento) trasferisce in stdout il contenuto della variabile stringa argomento, sostituendo il terminatore di stringa NULL con il carattere new-line. In pratica, la puts scrive su video un'intera riga di testo, compreso il ritorno a capo.

In entrambi i casi la variabile argomento deve essere stata definita (nel programma chiamante) come array di tipo char.

Conversioni fra stringhe e numeri

Le conversioni fra stringhe (contenenti caratteri numerici) e numeri (e viceversa) non si possono fare direttamente mediante casting, in quanto le stringhe sono degli array e, in più, ogni elemento che le costituisce è convertibile nel corrispondente codice ascii, non nel valore numerico della cifra rappresentata. Es.: int('1') da' come risultato il numero 49 (codice ascii del carattere 1) e non il numero 1

Bisogna invece ricorrere a opportune funzioni di libreria.

Conversioni da stringhe a numeri - Le funzioni atoi e atof

Per convertire una stringa in un numero, la via più semplice è usare le funzioni di libreria (del C) atoi e atof :

• atoi(argomento), dove argomento è una stringa contenente la rappresentazione decimale di un numero intero, esegue la conversione di argomento e restituisce un valore di tipo int

• atof(argomento), dove argomento è una stringa contenente la rappresentazione decimale di un numero floating (in notazione normale o

Page 76: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

esponenziale), esegue la conversione di argomento e restituisce un valore di tipo double

Entrambe le funzioni vanno utilizzate includendo l'header-file: <stdlib.h>

Il processo di conversione si interrompe (con il numero calcolato fino a quel momento) appena è incontrato un carattere non valido (senza messaggi di errore). Se nessun carattere è convertito atoi e atof ritornano rispettivamente 0 e 0.0

Conversioni da numeri a stringhe - La funzione sprintf

Per convertire numeri in stringhe, è più conveniente (rispetto ad altre possibilità) usare la funzione di libreria (del C) sprintf. Infatti questa funzione, non solo esegue la conversione, ma permette anche di ottenere una stringa formattata nel modo desiderato.

All'inizio di questo corso abbiamo trattato della funzione printf, che utilizza gli specificatori di formato per scrivere dati sul dispositivo standard di output (stdout). La funzione sprintf é identica alla printf salvo il fatto che scrive in una stringa anziché su stdout. Richiede due argomenti fissi, seguiti da un numero qualsiasi di argomenti opzionali:

• il primo argomento é la variabile stringa (definita come array di tipo char) in cui inserire i dati formattati

• il secondo argomento é la control-string (come il primo della printf) • il terzo argomento e i successivi sono i dati da formattare (come il

secondo e i successivi della printf)

Le stringhe in C++

Le stringhe descritte finora sono quelle "in stile C". In C++ si usano ancora, ma si ricorre più spesso alla classe string della Libreria Standard, che offre maggiori flessibilità e incapsula tutte le funzioni di manipolazione delle stringhe.

Page 77: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Funzioni

Definizione di una funzione

Una funzione è così definita:

tipo nome(argomenti)

{

... istruzioni ... (dette: codice di implementazione della funzione)

}

(notare che la prima istruzione è senza punto e virgola, in quanto é completata dall'ambito che segue)

• tipo: il tipo del valore di ritorno della funzione (con eventuali specificatori e/o qualificatori), detto anche tipo della funzione; se la funzione non ha valore di ritorno, bisogna specificare void

• nome: l'identificatore della funzione; segue le regole generali di specifica degli identificatori

• argomenti: lista degli argomenti passati dal programma chiamante; se non vi sono argomenti, si può specificare void (o, più comodamente, non scrivere nulla fra le parentesi)

Gli argomenti vanno specificati insieme al loro tipo (come nelle dichiarazioni delle variabili) e, se più d'uno, separati con delle virgole.

Es. char MiaFunz(int dato, float valore)

la funzione MiaFunz riceve dal programma chiamante gli argomenti: dato (di tipo int), e valore (di tipo float), e ritorna un risultato di tipo char

Dichiarazione di una funzione

Se in un file di codice sorgente una funzione é chiamata prima di essere definita, bisogna dichiararla prima di chiamarla.

La dichiarazione di una funzione (detta anche prototipo) é un'unica istruzione, formalmente identica alla prima riga della sua definizione, salvo il

Page 78: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

fatto che deve terminare con un punto e virgola. Tornando all'esempio precedente la dichiarazione della funzione MiaFunz é:

char MiaFunz(int dato, float valore);

Nella dichiarazione di una funzione i nomi degli argomenti sono fittizi e non é necessario che coincidano con quelli dalla definizione (non é neppure necessario specificarli); invece i tipi sono obbligatori: devono coincidere ed essere nello stesso ordine di quelli della definizione. Es., un'altra dichiarazione valida della funzione MiaFunz é:

char MiaFunz(int, float);

NOTA IMPORTANTE La tendenza dei programmatori in C++ é di separare le dichiarazioni dalle altre istruzioni di programma: le prime, che possono riguardare non solo funzioni, ma anche costanti predefinite o definizioni di tipi astratti, sono sistemate in header-files (con estensione del nome .h), le seconde in implementation-files (con estensione .c, .cpp o .cxx); ogni implementation-file che contiene riferimenti a funzioni (o altro) dichiarate in header-files, deve includere quest'ultimi mediante la direttiva #include.

Istruzione return

Nel codice di implementazione di una funzione l'istruzione di ritorno al programma chiamante é:

return espressione;

il valore calcolato dell'espressione viene restituito al programma chiamante come valore di ritorno della funzione (se il suo tipo non coincide con quello dichiarato della funzione, il compilatore segnala un errore, oppure, quando può, esegue una conversione implicita, con warning se c'é pericolo di loss of data)

Non é necessario che tale istruzione sia fisicamente l'ultima (e non é neppure necessario che ve ne sia una sola: dipende dalla presenza delle istruzioni di controllo, che possono interrompere l'esecuzione della funzione in punti diversi). Se la funzione non ha valore di ritorno (tipo void), bisogna specificare return; (da solo). Questa istruzione può essere omessa quando il punto di ritorno coincide con la fine fisica della funzione.

Page 79: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Comunicazioni fra programma chiamante e funzione

Da programma chiamante a funzione

La chiamata di una funzione non di tipo void può essere inserita come operando in qualsiasi espressione o come argomento nella chiamata di un'altra funzione (in questo caso il compilatore controlla che il tipo della funzione sia ammissibile): la chiamata viene eseguita con precedenza rispetto alle altre operazioni e al suo posto viene sostituito il valore di ritorno restituito dalla funzione.

Il valore di ritorno può non essere utilizzato dal programma chiamante, come se la funzione fosse di tipo void; in questi casi (cioè se la funzione è di tipo void, oppure il valore di ritorno non interessa), la chiamata non può essere inserita in una espressione, ma deve assumere la forma di un'istruzione a se stante.

Quando esegue la chiamata di una funzione, il programma costruisce una copia di ogni argomento, creando delle variabili locali nell'ambito della funzione (passaggio degli argomenti per valore). Ciò significa che tutte le modifiche, fatte dalla funzione al valore di un argomento, hanno effetto soltanto nell'ambito della funzione stessa.

Es. funzione: funz(int a) { ..... a = a+1; .... }

prog. chiamante: int b = 0 ...... funz(b); .....

il programma, prima di chiamare funz, copia il valore della propria variabile b nell'argomento a, che diventa una variabile locale nell'ambito di funz; per cui a "muore" appena il controllo ritorna al programma e il valore di b resta invariato, qualunque modifica abbia subito a durante l'esecuzione di funz.

A questa regola fa eccezione (per motivi che vedremo in seguito) il caso in cui gli argomenti sono nomi di array (e quindi in particolare di stringhe). Per trasmettere un intero array a una funzione (nel caso di singoli elementi non ci sarebbe eccezione alla regola generale) bisogna inserire nella chiamata il nome dell'array (senza parentesi quadre) e, corrispondentemente nella funzione la dichiarazione di una variabile seguita dalla coppia di parentesi quadre. Non serve specificare la dimensione in quanto la stessa é già stata dichiarata nel programma chiamante (tuttavia, se l'array é multidimensionale l'unico indice che si può omettere é quello all'estrema sinistra). In questa situazione, tutte le modifiche fatte ai singoli elementi dell'array vengono riprodotte sull'array del programma chiamante.

Da funzione a programma chiamante

Page 80: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Quando il controllo torna da una funzione al programma chiamante, tramite l'istruzione: return espressione;, il programma costruisce una copia del valore calcolato dell'espressione (che "muore" appena termina la funzione), creando un valore locale nell'ambito del programma chiamante.

Es. nel programma chiamante: ..... int a = funz(); .......

nella funzione: int funz() { ...... return b; .... }

funz restituisce al programma non la variabile b (che, in quanto locale in funz muore appena funz termina), ma una sua copia, che sopravvive a funz e diventa un valore locale (temporaneo, cioè non identificato da un nome) del programma chiamante, assegnato alla variabile a.

[p20]

Argomenti di default

In C++ é consentito "inizializzare" un argomento: come conseguenza, se nella chiamata l'argomento é omesso, il suo valore é assunto, di default, uguale alla costante (o variabile globale) usata per l'inizializzazione. Questa deve essere fatta un'unica volta (e quindi in generale nel prototipo della funzione, ma non nella sua definizione). Es.

prototipo: void scrive(char [ ] = "Messaggio di saluto");

chiamata: scrive(); equivale a: scrive("Messaggio di saluto");

definizione: void scrive(char ave[ ] ) { ............ }

Se una funzione ha diversi argomenti, di cui alcuni required (da specificare) e altri di default, quelli required devono precedere tutti quelli di default.

Funzioni con overload

A differenza dal C, il C++ consente l'esistenza di più funzioni con lo stesso nome, che sono chiamate: "funzioni con overload". Il compilatore distingue

Page 81: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

una funzione dall'altra in base alla lista degli argomenti: due funzioni con overload devono differire per il numero e/o per il tipo dei loro argomenti.

Es. funz(int); e funz(float); verranno chiamate con lo stesso nome funz, ma sono in realtà due funzioni diverse, in quanto la prima ha un argomento int, la seconda un argomento float.

Non sono ammesse funzioni con overload che differiscano solo per il tipo del valore di ritorno ; né sono ammesse funzioni che differiscano solo per argomenti di default.

Es. void funz(int); e int funz(int);

non sono accettate, in quanto generano ambiguità: infatti, in una chiamata tipo funz(n), il programma non saprebbe se trasferirsi alla prima oppure alla seconda funzione (non dimentichiamo che il valore di ritorno può non essere utilizzato).

Es. funz(int); e funz(int, double=0.0);

non sono accettate, in quanto generano ambiguità: infatti, in una chiamata tipo funz(n), il programma non saprebbe se trasferirsi alla prima funzione (che ha un solo argomento), oppure alla seconda (che ha due argomenti, ma il secondo può essere omesso per default).

La tecnica dell'overload, comune sia alle funzioni che agli operatori, é molto usata in C++, perché permette di programmare in modo semplice ed efficiente: funzioni che eseguono operazioni concettualmente simili possono essere chiamate con lo stesso nome, anche se lavorano su dati diversi. Es., per calcolare il valore assoluto di un numero, qualunque sia il suo tipo, si potrebbe usare sempre una funzione con lo stesso nome (per esempio abs).

Funzioni inline

In C++ esiste la possibilità di chiedere al compilatore di espandere ogni chiamata di una funzione con il codice di implementazione della funzione stessa. Questo si ottiene premettendo alla definizione di una funzione lo specificatore inline.

Es. inline double cubo(double x) { return x*x*x ; }

ogni volta che il compilatore trova nel programma la chiamata: cubo(espressione); la trasforma nell'istruzione : (espressione) * (espressione) * (espressione) ;

Page 82: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'uso dello specificatore inline é molto comune, in quanto permette di eliminare il sovraccarico di lavoro dovuto alla gestione della comunicazione fra programma e funzione. Se però il numero di chiamate della funzione é molto elevato ed é in punti diversi del programma, il vantaggio potrebbe essere annullato dall'eccessivo accrescimento della lunghezza del programma (il vantaggio invece é evidente quando vi sono poche chiamate ma inserite in cicli while o for: in questo caso lo specificatore inline fa crescere di poco la dimensione del programma, ma il numero delle chiamate in esecuzione può essere molto elevato).

In ogni caso il compilatore si riserva il diritto di accettare o rifiutare lo specificatore inline: in pratica, una funzione che consista di più di 4 o 5 righe di istruzioni viene compilata come funzione separata, indipendentemente dalla presenza o meno dello specificatore inline.

Trasmissione dei parametri tramite l'area stack

Cenni sulle liste

In qualsiasi linguaggio di programmazione le liste di dati possono essere accessibili in vari modi (per esempio in modo randomatico), ma esistono due particolari categorie di liste caratterizzate da metodi di accesso ben definiti e utilizzate in numerose circostanze:

• le liste di tipo queue (coda), accessibili con il metodo FIFO (first in-first out): il primo dato che entra nella lista è il primo a essere servito; tipiche queues sono le code davanti agli sportelli, le code di stampa (priorità a parte) ecc...

• le liste di tipo stack (pila), accessibili con il metodo LIFO (last in-first out): l'ultimo dato che entra nella lista è il primo a essere servito.

Uso dell'area stack

Nella trasmissione dei parametri fra programma chiamante e funzione vengono utilizzate liste di tipo stack: quando una funzione A chiama una funzione B, sistema in un'area di memoria, detta appunto stack, un pacchetto di dati, comprendenti:

1. l'area di memoria per tutte le variabili automatiche di B; 2. la lista degli argomenti di B in cui copia i valori trasmessi da A; 3. l'indirizzo di rientro in A (cioè il punto di A in cui il programma deve

tornare una volta completata l'esecuzione di B, trasferendovi l'eventuale valore di ritorno).

Page 83: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

La funzione B utilizza tale pacchetto e, se a sua volta chiama un'altra funzione C, sistema nell'area stack un altro pacchetto, "impilato" sopra il precedente, come nel seguente schema (tralasciamo le aree riservate alle variabili automatiche):

Area stack Commenti

Indirizzo di rientro in B Argomento 1 passato a C Argomento 2 passato a C

La funzione B chiama la funzione C con due argomenti

Indirizzo di rientro in A Argomento 1 passato a B Argomento 2 passato a B Argomento 3 passato a B

La funzione A chiama la funzione B con tre argomenti

Quando il controllo deve tornare da C a B, il programma fa riferimento all'ultimo pacchetto entrato nello stack per conoscere l'indirizzo di rientro in B e, eseguita tale operazione, rimuove lo stesso pacchetto dallo stack (cancellando di conseguenza anche le variabili automatiche di C). La stessa cosa succede quando il controllo rientra da B in A; dopodiché lo stack rimane vuoto.

Ricorsività delle funzioni

Tornando all'esempio precedente, la trasmissione dei parametri attraverso lo stack garantisce che il meccanismo funzioni comunque, sia che A, B e C siano funzioni diverse, sia che si tratti della stessa funzione (ogni volta va a cercare nello stack l'indirizzo di rientro nel programma chiamante e quindi non cambia nulla se tale indirizzo si trova all'interno della stessa funzione).

Ne consegue che in C++ (come in C) le funzioni possono chiamare se stesse (ricorsività delle funzioni). Ovviamente tali funzioni devono sempre contenere un'istruzione di controllo che, se si verificano certe condizioni, ha il compito di interrompere la successione delle chiamate.

Esempio tipico di una funzione chiamata ricorsivamente è quello del calcolo del fattoriale di un numero intero:

int fact(int n) {

Page 84: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

if ( n <= 1 ) return 1; <----- istr. di controllo

return n * fact(n-1); }

Fattoriale in pps

alternativamente, cioè senza usare la ricorsività, si produce codice meno compatto:

int fact(int n) {

int i = 2, m = 1;

while ( i <= n ) m *= i++ ;

return m; }

[p22]

Funzioni con numero variabile di argomenti

In C++ (come in C), tramite accesso allo stack, è possibile gestire funzioni con numero variabile di argomenti. Caso tipico é la nota funzione printf, che ha un solo argomento fisso (la control-string), seguito eventualmente dagli argomenti opzionali (i dati da scrivere), il cui numero è determinato in fase di esecuzione, esaminando il contenuto della stessa control-string.

Le funzioni con numero variabile di argomenti vanno dichiarate e definite con tre puntini (ellipsis) al posto della lista degli argomenti opzionali, che devono sempre seguire quelli fissi (deve sempre esistere almeno un argomento fisso).

Es.: int funzvar(int a, float b, ...) gli argomenti fissi della funzione funzvar sono due: a e b; a questi possono seguire altri argomenti (in numero qualsiasi). Normalmente gli argomenti fissi contengono l'informazione (come nella printf) sull'effettivo numero di argomenti usati in una chiamata.

La funzione può accedere al suo pacchetto di chiamata, contenuto nello stack, per mezzo di alcune funzioni di libreria, i cui prototipi si trovano nell'header-file <stdarg.h> ; per memorizzare i valori degli argomenti opzionali trasmessi dal programma chiamante, la funzione deve procedere nel seguente modo:

Page 85: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

1. anzitutto deve definire una variabile, di tipo (astratto) va_list (creato in <stdarg.h>), che serve per accedere alle singole voci dello stack Es. : va_list marker ;

2. poi deve chiamare la funzione di libreria va_start, per posizionarsi nello stack sull'inizio degli argomenti opzionali. Es. : va_start(marker,b) ; dove b é l'ultimo degli argomenti fissi;

3. poi, per ogni argomento opzionale che si aspetta di trovare, deve chiamare la funzione di libreria va_arg Es. : c = va_arg(marker,int) ; (notare che il secondo argomento di va_arg definisce il tipo dell'argomento opzionale, il cui valore sarà trasferito in c).

4. infine deve chiamare la funzione di libreria va_end per chiudere le operazioni Es. : va_end(marker) ;

Cenni sulla Run Time Library

La libreria standard del C

La Run Time Library è la libreria standard del C, usata anche dal C++, e contiene diverse centinaia di funzioni.

Il codice di implementazione delle funzioni di libreria è fornito in forma già compilata e risiede in files binari (.obj o .lib), mentre i prototipi sono disponibili in formato sorgente e si trovano distribuiti in vari header-files (.h).

Il linker, lanciato da un ambiente di sviluppo, accede in genere automaticamente ai codici binari della libreria. Il compilatore, invece, richiede che tutte le funzioni usate in ogni file sorgente di un'applicazione siano espressamente dichiarate, tramite inclusione dei corrispondenti header-files.

Principali categorie di funzioni della Run-time library

Elenchiamo le principali categorie in cui possono essere classificate le funzioni della Run Time Library. Per informazioni sulle funzioni individualmente consultare l'help dell'ambiente di sviluppo disponibile.

Categorie Header-files

Operazioni di Input/Output <io.h> , <stdio.h>

Funzioni matematiche e statistiche <math.h> , <stdlib.h>

Page 86: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Attributi del carattere <ctype.h>

Conversioni numeri-stringhe <stdlib.h>

Gestione e manipolazione stringhe <string.h>

Gestione dell'ambiente <direct.h> , <stdlib.h>

Gestione degli errori <stdio.h> , <stdlib.h>

Ricerca e ordinamento dati <search.h> , <stdlib.h>

Gestione della data e dell'ora <time.h>

Gest. numero variabile di argomenti <stdarg.h>

Page 87: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Riferimenti

Costruzione di una variabile mediante copia

Riassumiamo i casi in cui una variabile (o più in generale un oggetto) viene costruita (creata) mediante copia di una variabile esistente dello stesso tipo:

• una variabile é definita e inizializzata con il valore di una costante o di una variabile esistente;

• l'argomento di una funzione é passato by value (per valore) dal programma chiamante alla funzione;

• il valore di ritorno di una funzione é passato by value dalla funzione al programma chiamante.

Cosa sono i riferimenti ?

In C++ i riferimenti sono variabili introdotte dall'operatore di dichiarazione :

&

Il loro significato è quello di occupare la stessa memoria delle variabili a cui si riferiscono (in altre parole sono degli alias di altre variabili).

Si definiscono come nel seguente esempio: int & ref = var; (dove var è una variabile di tipo int precedentemente definita, oppure una qualunque espressione che restituisce un l-value di tipo int) la variabile ref è un riferimento a var: qualsiasi modifica apportata a var si ritrova in ref (e viceversa). I tipi di ref e var devono coincidere, non è ammesso il casting in nessun caso (anche quando i tipi sono in pratica gli stessi, come int e long). L'insieme int & assume la connotazione di un nuovo tipo: il tipo di riferimento a int. Nota: nelle definizioni multiple & va ripetuto: in altre parole, l'operatore di dichiarazione & va considerato, dal punto di vista sintattico, un prefisso dell'identificatore e non un suffisso del tipo.

Va da sé che i riferimenti vanno sempre inizializzati. L'inizializzazione, tuttavia, non comporta la costruzione di una nuova variabile mediante copia, in quanto, per il programma, si tratta sempre della stessa variabile (la differenza fra i nomi "scompare" dopo la compilazione).

E' anche possibile dichiarare un riferimento con lo specificatore const : const int & ref = var;

Page 88: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

si può sempre modificare var (e di conseguenza resta modificato ref), ma non si può modificare direttamente ref, che per questo viene anche detto alias di riferimento a sola lettura.

I riferimenti dichiarati const si possono anche inizializzare con un non l-value (e non necessariamente dello stesso tipo, purchè convertibile implicitamente). Es.:

int & ref = var+1; non ammesso: var+1 non è un l-value

const int & ref = var+1; ammesso, anche se var non è int

Ciò è possibile perchè in questo caso il programma crea una variabile temporanea di tipo int (chiamiamola temp) che inizializza con var+1 (dopo aver convertito, se necessario, il tipo di var in int) e poi definisce: const int & ref = temp; La variabile temp persiste nello stesso ambito di ref, ma non è accessibile e quindi in questo caso ref non può più cambiare anche se cambia var.

Comunicazione per "riferimento" fra programma e funzione

L'uso più frequente dei riferimenti si ha nelle comunicazioni fra funzione e programma chiamante. Infatti, mentre in C il passaggio degli argomenti e del valore di ritorno avviene sempre e soltanto by value, in C++ può avvenire anche by reference (per riferimento).

Da programma chiamante a funzione

Un argomento passato a una funzione può essere dichiarato come riferimento:

funz(int& num)

in questo caso l'argomento è passato by reference, cioè non ne viene costruita una copia, ma la variabile num é un alias di riferimento della sua corrispondente nel programma chiamante.

Ne consegue che ogni modifica apportata a num in funz viene effettuata anche nel programma chiamante.

Es.: funzione: funz(int& a) { ..... a = a+1; .... }

prog. chiamante: int b = 0; ...... funz(b); .....

Page 89: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

alla fine in b si ritrova il valore 1

In base alle regole enunciate nel paragrafo precedente, il valore passato alla funzione deve essere un l-value ed esattamente dello stesso tipo del corrispondente argomento dichiarato nella funzione (a meno che non venga dichiarato const, nel qual caso non ci sono restrizioni, purchè sia ammessa la conversione di tipo implicita).

Da funzione a programma chiamante

Anche il valore di ritorno restituito da una funzione può essere dichiarato come riferimento.

Es.: nel programma chiamante: ..... funz( ); .......

nella funzione: int& funz( ) { ...... return b; .... }

anche questa volta il valore è passato by reference, cioè non ne viene costruita una copia, ma nel programma chiamante viene utilizzato direttamente il riferimento al valore b restituito da funz (per le note regole b deve essere un l-value, a meno che il valore di ritorno non sia dichiarato const) . In questo caso però, onde evitare errori in esecuzione, é necessario che b sopravviva a funz; ciò é possibile soltanto in uno dei seguenti tre casi:

• b é una variabile globale • b é una variabile locale di funz, ma dichiarata static • b é essa stessa un argomento di funz, a sua volta passato by reference.

Il valore di ritorno è un l-value (se non è dichiarato const). Questo significa che la chiamata di una funzione che ritorna un valore by reference può essere messa a sinistra di un'operazione di assegnazione !!! Ciò è possibile solo in C++ !

Page 90: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Direttive al Preprocessore

Cos'é il preprocessore ?

In C++ (come in C), prima che il compilatore inizi a lavorare, viene attivato un programma, detto preprocessore, che ricerca nel file sorgente speciali istruzioni, chiamate direttive.

Una direttiva inizia sempre con il carattere # (a colonna 1) e occupa una sola riga (non ha un terminatore, in quanto finisce alla fine della riga; riconosce però i commenti, introdotti da // o da /*, e la continuazione alla riga successiva, definita da \).

Il preprocessore crea una copia del file sorgente (da far leggere al compilatore) e, ogni volta che incontra una direttiva, la esegue sostituendola con il risultato dell'operazione. Pertanto il preprocessore, eseguendo le direttive, non produce codice binario, ma codice sorgente per il compilatore.

Ogni file sorgente, dopo la trasformazione operata dal preprocessore, prende il nome di translation unit. Ogni translation unit viene poi compilata separatamente, con la creazione del corrispondente file oggetto, in codice binario. Spetta al linker, infine, collegare tutti i files oggetto, generando un unico programma eseguibile.

Nel linguaggio esistono molte direttive (alcune delle quali dipendono dal sistema operativo). In questo corso tratteremo soltanto delle seguenti: #include , #define , #undef e direttive condizionali.

Direttiva #include

Ci é già noto il significato della direttiva #include:

#include <filename> oppure #include "filename"

che determina l'inserimento, nel punto in cui si trova la direttiva, dell'intero contenuto del file con nome filename.

Se si usano le parentesi angolari, si intende che filename vada cercato nella directory di default del linguaggio; se invece si usano le virgolette, il file si trova nella directory del programma.

Page 91: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

La direttiva #include viene usata quasi esclusivamente per inserire gli header-files (.h) ed é particolarmente utile quando in uno stesso programma ci sono più implementation-files che includono lo stesso header-file.

Direttiva #define di una costante

Quando il preprocessore incontra la seguente direttiva:

#define identificatore valore

dove, identificatore è un nome simbolico (che segue le regole generali di specifica di tutti gli altri identificatori) e valore é un'espressione qualsiasi, delimitata a sinistra da blanks o tabs e a destra da blanks, tabs o new-line (i blanks e tabs interni fanno parte di valore), sostituisce identificatore con valore in tutto il file (da quel punto in poi).

Es. #define bla frase qualsiasi anche con "virgolette"

sostituisce (da quel punto in poi) in tutto il file la parola bla con la frase: frase qualsiasi anche con "virgolette" (la "stranezza" dell'esempio riportato ha lo scopo di dimostrare che la sostituzione é assolutamente fedele e cieca, qualunque sia il contenuto dell'espressione che viene sostituita all'identificatore; il compito di "segnalare gli errori" viene lasciato al compilatore!)

In generale la direttiva #define serve per assegnare un nome a una costante (che viene detta "costante predefinita").

Es. #define ID_START 3457

da questo punto in poi, ogni volta che il programma deve usare il numero 3457, si può specificare in sua vece ID_START

Esistono principalmente due vantaggi nell'uso di #define:

• se il programmatore decide di cambiare valore a una costante, é sufficiente che lo faccia in un solo punto del programma;

• molto spesso i nomi sono più significativi e mnemonici dei numeri (oppure più brevi delle stringhe, se rappresentano costanti stringa) e perciò l'uso delle costanti predefinite permette una maggiore leggibilità del codice e una maggiore efficienza nella programmazione.

In pratica la direttiva #define produce gli stessi risultati dello specificatore di tipo const; al posto della direttiva dell'esempio precedente si sarebbe potuto scrivere la dichiarazione:

Page 92: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

const int ID_START = 3457;

Confronto fra la direttiva #define e lo specificatore const

Vantaggi nell'uso di const:

• il tipo della costante é dichiarato; un eventuale errore di dichiarazione viene segnalato immediatamente;

• la costante é riconosciuta, e quindi analizzabile, nelle operazioni di debug.

Vantaggi nell'uso di #define:

• una costante predefinita a volte è più comoda e immediata (è una questione sostanzialmente "estetica"!) e può essere usata anche per altri scopi (per esempio per sostituire o mascherare nomi).

Direttiva #define di una macro

Quando il preprocessore incontra la seguente direttiva:

#define identificatore(argomenti) espressione

riconosce una macro, che distingue dalla definizione di una costante per la presenza della parentesi tonda subito dopo identificatore (senza blanks in mezzo).

Una macro é molto simile a una funzione. Il suo uso é chiarito dal seguente esempio: #define Max(a,b) a > b ? a : b tutte le volte che il preprocessore trova nel programma una chiamata della macro, per esempio Max(x,y), la espande, sostituendola con: x > y ? x : y

Come nel caso di definizione di una costante, anche per una macro la sostituzione avviene in modo assolutamente fedele: a parte i nomi degli argomenti, che sono ricopiati dalla chiamata e non dalla definizione, tutti gli altri simboli usati nella definizione sono riprodotti senza alcuna modifica (per

Page 93: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

esempio il punto e virgola di fine istruzione viene messo solo se compare anche nella definizione).

Nella chiamata di una macro si possono mettere, al posto degli argomenti, anche delle espressioni (come nelle chiamate di funzioni); sarà compito, come al solito, del compilatore controllare che l'espressione risultante sia accettabile. Riprendendo l'esempio precedente, la seguente chiamata: Max(x+1,y) espansa in x+1 > y ? x+1 : y sarà accettata dal compilatore, in istruzioni del tipo : c = Max(x+1,y); ma rigettata in istruzioni come: Max(x+1,y) = c; in quanto, in questo caso, gli operandi di un operatore condizionale devono essere l-values.

In altri casi, la sostituzione "cieca" può causare errori che lo stesso compilatore non é in grado di riconoscere. Es. #define quadrato(x) x*x la chiamata: quadrato(2+3) viene espansa in 2+3*2+3 con risultato, evidentemente, errato. Per evitare tale errore si sarebbe dovuto scrivere: #define quadrato(x) (x)*(x)

Agli effetti pratici (purché si usino le dovute attenzioni!), la definizione di una macro produce gli stessi risultati dello specificatore inline di una funzione.

Confronto fra la direttiva #define e lo specificatore inline

Vantaggi nell'uso di inline:

• il tipo della funzione é dichiarato e controllato ; • la funzione é riconosciuta, e quindi analizzabile, nelle operazioni di

debug; • l'espansione di una funzione inline é fatta non in modo "cieco", ma in

modo "intelligente" (vantaggio decisivo!).

Vantaggi nell'uso di #define:

• Una macro e più immediata e più semplice da scrivere di una funzione.

Le macro, usatissime in C, sono raramente utilizzate in C++, se non per funzioni molto brevi e adoperate a livello locale (cioè nello stesso modulo in cui sono definite). Un uso più frequente delle macro si ha quando non corrispondono a funzioni ma a espressioni "parametrizzate" molto lunghe che compaiono più volte nel programma.

Page 94: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Direttive condizionali

Il preprocessore dispone di un suo mini-linguaggio di controllo, che consiste nelle seguenti direttive condizionali:

#if espressione1 #if defined(identificatore1) oppure ... oppure

#if !defined(identificatore1)

...... blocco di direttive e/o istruzioni ........

#elif espressione2 oppure #elif defined(identificatore2) oppure ...

#elif !defined(identificatore2)

...... blocco di direttive e/o istruzioni ........

#else

...... blocco di direttive e/o istruzioni ........

#endif

dove:

espressione é un espressione logica che può contenere solo identificatori di costanti predefinite o costanti literals, ma non variabili e neppure variabili dichiarate const, che il preprocessore non riconosce

defined(identificatore) restituisce vero se identificatore é definito (cioè se é stata eseguita la direttiva: #define identificatore); al posto di #if defined(identificatore) si può usare la forma: #ifdef identificatore

!defined(identificatore) restituisce vero se identificatore non é definito; al posto di #if !defined(identificatore) si può usare la forma: #ifndef identificatore

#elif sta per else if ed é opzionale (possono esserci più blocchi consecutivi, ciascuno introdotto da un #elif)

#else é opzionale (se esiste, deve introdurre l'ultimo blocco prima di #endif)

#endif (obbligatorio) termina la sequenza iniziata con un #if

non é necessario racchiudere i blocchi fra parentesi graffe, perché ogni blocco é terminato da #elif, o da #else, o da #endif

Il preprocessore identifica il blocco (se esiste) che corrisponde alla prima condizione risultata vera, oppure il blocco relativo alla direttiva #else (se esiste) nel caso che tutte le condizioni precedenti siano risultate false. Tale blocco può contenere sia istruzioni di programma che altre direttive, comprese direttive condizionali (possono esistere più blocchi #if "innestati"): il preprocessore esegue le direttive e presenta al compilatore le istruzioni che si trovano nel

Page 95: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

blocco selezionato, scartando sia direttive che istruzioni contenute negli altri blocchi della sequenza #if ... #endif.

Direttiva #undef

La direttiva:

#undef identificatore

indica al preprocessore di disattivare l'identificatore specificato, cioè rimuovere la corrispondenza fra l'identificatore e una costante, precedentemente stabilita con la direttiva:

#define identificatore costante

Nelle istruzioni successive alla direttiva #undef, lo stesso nome potrà essere adibito ad altri usi.

Es. #ifdef EOF

#undef EOF

#endif

char EOF[] = "Ente Opere Filantropiche";

Page 96: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Sviluppo delle applicazioni in ambiente Windows

Definizioni di IDE e di "progetto"

Un IDE (Integrated Development Environment) è un programma interattivo che si lancia da sistema operativo e che aiuta lo sviluppatore di software (cioè il programmatore) a costruire un progetto.

Un progetto è un insieme di files, contenenti codice sorgente, che vengono letti ed elaborati dal compilatore separatamente e poi collegati insieme (tramite il linker) per costruire un unico file in codice binario, contenente il programma eseguibile, che può essere a sua volta lanciato dallo stesso IDE o autonomamente da sistema operativo.

Lo sviluppatore interagisce con IDE tramite menù di tipo pop-up (a tendina); in genere le voci di menù più significative sono selezionabili anche tramite toolbars (gruppi di icone) o tramite i cosiddetti acceleratori (tasti della keyboard che eseguono la stessa funzione della corrispondente voce di menù).

Un IDE può aprire sullo schermo e usare parecchie finestre contemporaneamente, contenenti i files sorgente (uno per ogni finestra), l'output del programma, le informazioni acquisite in fase di debug ecc… Possono esistere anche finestre che contengono l'elenco dei files, delle funzioni, o anche delle singole variabili utilizzate; "cliccando" su queste voci si può raggiungere rapidamente la parte di programma che interessa esaminare o modificare.

Nel seguito illustreremo brevemente l'utilizzo del seguente IDE: Microsoft Visual C++, versione 6, che gira nel sistema operativo Windows. Teniamo a precisare che il Visual C++ non è soltanto un IDE, ma un linguaggio vero e proprio, essendo dotato di funzionalità e librerie che vanno ben oltre lo standard C++. Noi ci limiteremo, però, ad illustrare il suo ambiente di sviluppo, nella versione "ridotta" per applicazioni che utilizzano solo codice standard.

Gestione di files e progetti

• creazione nuovo progetto o nuovo file • apertura e chiusura progetto • inserimento di files esistenti nel progetto aperto • apertura, salvataggio (con eventuale cambiamento del nome) e chiusura file • tutte le operazioni di selezione di file o directory sono eseguibili tramite dialog box

oppure direttamente dalla lista dei MRU (Most Recently Used)

Page 97: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Editor di testo

Un IDE è normalmente provvisto di tutte le funzionalità standard di un editor di testo interattivo (cut, copy, paste, delete, find, replace, undo, redo, ecc…). In più, il suo editor è "intelligente", nel senso che è in grado di riconoscere ed interpretare il testo in modo da renderlo di più facile comprensione (per esempio, scrive le parole-chiave con un altro colore, "indenta" automaticamente le istruzioni che continuano nella riga successiva o che appartengono ad un ambito interno ecc...).

Gestione delle finestre

• full screen della finestra attiva • selezione della finestra da porre in primo piano • visione contemporanea di più finestre (allineate orizzontalmente, verticalmente o in

cascade) ecc…

Costruzione dell'applicazione eseguibile

• file make: è creato e aggiornato automaticamente; contiene tutte le relazioni fra i files sorgente e le opzioni di compilazione e link del progetto

• programma make: legge il file make ed esegue: o la compilazione di tutti i files del progetto, creando un file binario .obj per ogni

file sorgente incluso nel progetto; inoltre la compilazione è di tipo incrementale, nel senso che ricompila solo i files sorgente che sono stati modificati dopo la creazione dei rispettivi .obj

o il link di tutti i .obj per la creazione del programma eseguibile, che ha estensione .exe; anche in questo caso l'operazione è di tipo incrementale, cioè viene eseguita solo se almeno un .obj è stato modificato (o se il .exe non esiste).

Page 98: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Debug del programma

Eseguendo il programma in modo debug, è possibile inserire dei breakpoints (punti di interruzione del programma) direttamente nel codice sorgente e poi esaminare il valore corrente delle variabili (con il comando watch, oppure semplicemente posizionando il cursore del mouse sulla variabile da ispezionare: si apre una finestrella gialla (tip) che mostra il contenuto della variabile), oppure eseguire il programma step-by-step (una istruzione alla volta) ecc…

Utilizzo dell'help in linea

Ogni buon IDE è provvisto di un robusto sistema di documentazione che spiega il significato e il modo di utilizzo delle parole-chiave del linguaggio, dei simboli, delle variabili predefinite e, ovviamente, delle funzioni di libreria; di solito è organizzato per topics (argomenti), ma esiste anche la possibilità di eseguire la ricerca di ogni singolo termine presente del sistema accedendo a un elenco generale in ordine alfabetico.

Inoltre è disponibile il "context sensitive help" che permette di accedere direttamente all'informazione desiderata posizionando il cursore del mouse all'interno della finestra di editor del proprio file sorgente, sopra la variabile o funzione da esaminare, e poi spingendo il tasto F1.

Infine il testo della guida in linea è accessibile con la funzionalità copy dell'editor (ovviamente non con cut o paste, essendo in sola lettura): ciò consente di selezionare e trasferire nel proprio programma brani di codice (per esempio nomi di funzioni o variabili predefinite) senza possibilità di errore.

Page 99: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Indirizzi e Puntatori

Operatore di indirizzo &

L'operatore unario di indirizzo :

&

restituisce l'indirizzo della locazione di memoria dell'operando.

L'operando deve essere un ammissibile l-value. Il valore restituito dall'operatore non può essere usato come l-value (in quanto l'indirizzo di memoria di una variabile non può essere assegnato in un'istruzione, ma è predeterminato dal programma).

Esempi (notare l'uso delle parentesi per alterare l'ordine delle precedenze):

&a

ammesso, purché a sia un l-value

&(a+1) non ammesso, in quanto a+1 non é un l-value

&(a>b?a:b) ammesso, in quanto l'operatore condizionale può restituire un l-value, purché a e b siano l-values

&a = b non ammesso, in quanto l'operatore & non può restituire un l-value

Gli indirizzi di memoria sono rappresentati da numeri interi, in byte, e, nelle operazioni di output, sono scritti, di default, in forma esadecimale.

Cosa sono i puntatori ?

I puntatori sono particolari tipi del linguaggio. Una variabile di tipo puntatore é designata a contenere l'indirizzo di memoria di un'altra variabile (detta variabile puntata), la quale a sua volta può essere di qualunque tipo, anche non nativo (persino un altro puntatore!).

Page 100: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Dichiarazione di una variabile di tipo puntatore

Benché gli indirizzi siano numeri interi e quindi una variabile puntatore possa contenere solo valori interi, tuttavia il C++ (come il C) pretende che nella dichiarazione di un puntatore sia specificato anche il tipo della variabile puntata (in altre parole un dato puntatore può puntare solo a un determinato tipo di variabili, quello specificato nella dichiarazione).

Per ottenere ciò, bisogna usare l'operatore di dichiarazione : *

Es. :

int * pointer

dichiara (e definisce) la variabile pointer, puntatore a variabile di tipo int

Nota: nelle definizioni multiple * va ripetuto: in altre parole, l'operatore di dichiarazione * va considerato, dal punto di vista sintattico, un prefisso dell'identificatore e non un suffisso del tipo.

Si può dire pertanto che, a questo punto della nostra conoscenza, il numero dei tipi del C++ é "raddoppiato": esistono tanti tipi di puntatori quanti sono i tipi delle variabili puntate.

Un puntatore accetta quasi sempre il casting, purché il risultato della conversione sia ancora un puntatore. Tornando all'esempio precedente, l'operazione di casting: (double*)pointer restituisce un puntatore a una variabile di tipo double. Nota2: nel casting, invece, l'operatore di dichiarazione * è un suffisso del tipo. (!)

Si può anche dichiarare un puntatore a puntatore.

Es. : double** pointer_to_pointer

dichiara (e definisce) la variabile pointer_to_pointer, puntatore a puntatore a variabile di tipo double

Assegnazione di un valore a un puntatore

Sappiamo che gli indirizzi di memoria non possono essere assegnati da istruzioni di programma, ma sono determinati automaticamente in fase di

Page 101: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

esecuzione; quindi non si possono assegnare valori a un puntatore, salvo che in questi quattro casi:

• a un puntatore é assegnato il valore NULL (non punta a "niente"); • a un puntatore é assegnato l'indirizzo di una variabile esistente,

restituito dall'operatore & ( Es. : int a; int* p; p = &a; );

• é eseguita un'operazione di allocazione dinamica della memoria (di cui tratteremo più avanti);

• a un puntatore é assegnato il valore che deriva da un'operazione di aritmetica dei puntatori (vedere prossima sezione).

Quanto detto per le assegnazioni vale anche per le inizializzazioni.

Va precisato, comunque, che ogni tentativo di assegnare valori a un puntatore in casi diversi da quelli sopraelencati (per esempio l'assegnazione di una costante) costituisce un errore che non viene segnalato dal compilatore, ma che può produrre effetti indesiderabili (o talvolta disastrosi) in fase di esecuzione.

Aritmetica dei puntatori

Abbiamo detto che il valore assunto da un puntatore é un numero intero che rappresenta, in byte, un indirizzo di memoria. Il C++ (come il C) ammette le operazioni di somma fra un puntatore e un valore intero (con risultato puntatore), oppure di sottrazione fra due puntatori (con risultato intero). Tali operazioni vengono però eseguite in modo "intelligente", cioè tenendo conto del tipo della variabile puntata. Per esempio, se si incrementa un puntatore a float di 3 unità, in realtà il suo valore viene incrementato di 12 byte.

Queste regole dell'aritmetica dei puntatori assicurano che il risultato sia sempre corretto, qualsiasi sia la lunghezza in byte della variabile puntata. Per esempio, se p punta a un elemento di un array, p++ punterà all'elemento successivo, qualunque sia il tipo (anche non nativo) dell'array.

Operatore di dereferenziazione *

Page 102: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'operatore unario di dereferenziazione * (che abbrevieremo in deref.) di un puntatore restituisce il valore della variabile puntata dall'operando ed ha un duplice significato:

• usato come r-value, esegue un'operazione di estrazione. Es. a = *p ; (assegna ad a il valore della variabile puntata da p)

• usato come l-value, esegue un'operazione di inserimento. Es. *p = a ; (assegna il valore di a alla variabile puntata da p)

In pratica l'operazione di deref. é inversa a quella di indirizzo. Infatti, se assegniamo a un puntatore p l'indirizzo di una variabile a, p = &a ; allora la relazione logica: *p == a risulta vera, cioè la deref. di p coincide con a.

Ovviamente non é detto il contrario, cioè, se assegniamo alla deref. di p il valore di a, p = &b ; *p = a ; ciò non comporta automaticamente che in p si ritrovi l'indirizzo di a (dove invece resta l'indirizzo di b), ma semplicemente che il valore della variabile puntata da p (cioè b) coinciderà con a.

Puntatori a void

Contrariamente all'apparenza un puntatore dichiarato a void, es.: void* vptr; può puntare a qualsiasi tipo di variabile. Ne consegue che a un puntatore a void si può assegnare il valore di qualunque puntatore, ma non viceversa (é necessario operare il casting).

Es.: definiti: int* iptr; e void* vptr;

é ammessa l'assegnazione: vptr = iptr;

ma non: iptr = vptr;

bensì: iptr = (int*)vptr;

I puntatori a void non possono essere dereferenziati nè possono essere inseriti in operazioni di aritmetica dei puntatori. In generale si usano quando il tipo della variabile puntata non è ancora stabilito al momento della definizione del puntatore, ma è determinato successivamente, in base al flusso di esecuzione del programma.

Page 103: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Errori di dangling references

In C++ (come in C) l'assegnazione dell'indirizzo di una variabile a a un puntatore p : p = &a ; e il successivo accesso ad a tramite deref. di p, possono portare a errori di dangling references (perdita degli agganci) se puntatore e variabile puntata non condividono lo stesso ambito d'azione. Infatti, se l'ambito di p é più esteso di quello di a (per esempio se p é una variabile globale) e a va out of scope mentre p continua ad essere visibile, la deref. di p accede ad un'area della memoria non più allocata al programma, con risultati spesso imprevedibili.

Funzioni con argomenti puntatori

Quando, nella chiamata di una funzione, si passa come argomento un indirizzo (sia che si tratti di una variabile puntatore oppure del risultato di un'operazione di indirizzo), per esempio (essendo, al solito, p un puntatore e a una qualsiasi variabile):

funz(.... p ....) oppure funz(.... &a ....)

nella definizione (e ovviamente anche nella dichiarazione) della funzione il corrispondente argomento va dichiarato come puntatore; continuando l'esempio (se a é di tipo int): void funz(.... int* p ....)

L'argomento é, come sempre, passato by value. In C++ é anche possibile, passarlo by reference, nel qual caso bisogna indicare entrambi gli operatori di dichiarazione * e & : void funz(.... int*& p ....)

Se il puntatore é passato by value, nella funzione viene creata una copia del puntatore e, qualsiasi modifica venga fatta al suo valore, il corrispondente valore nel programma chiamante rimane inalterato. In questo caso, tuttavia, tramite l'operazione di deref., la variabile puntata (che si trova nel programma chiamante), é accessibile e modificabile dall'interno della funzione.

Page 104: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Es.: programma chiamante: int a = 10; ...... funz(&a);

funzione: void funz( int* p) { ....*p = *p+5; .... }

alla fine, nella variabile a si trova il valore 15 (in questo caso non esistono problemi di scope, in quanto la variabile a, pur non essendo direttamente visibile dalla funzione, é ancora in vita e quindi é accessibile tramite un'operazione di deref.).

Per i motivi suddetti, quando l'argomento della chiamata é un indirizzo, si dice impropriamente che la variabile puntata é trasmessa by address e che, per questa ragione, é modificabile. In realtà l'argomento non é la variabile puntata, ma il puntatore, e questo é trasmesso, come ogni altra variabile, by value.

Page 105: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Puntatori ed Array

Analogia fra puntatori ed array

Quando abbiamo trattato gli array, avremmo dovuto fare le seguente riflessione: "Il C++ é un linguaggio tipato (ogni entità del linguaggio deve appartenere a un tipo); e allora, cosa sono gli array ?".

La risposta é: "Gli array sono dei puntatori!".

Quando si dichiara un array, in realtà si dichiara un puntatore, con alcune caratteristiche in più:

• la dichiarazione di un puntatore comporta allocazione di memoria per una variabile puntatore, ma non per la variabile puntata.

Es.: int* lista; alloca memoria per la variabile puntatore lista ma non per la variabile puntata da lista

• la dichiarazione di un array comporta allocazione di memoria non solo per una variabile puntatore (il nome dell'array), ma anche per l'area puntata, di cui viene predefinita la lunghezza; inoltre il puntatore viene dichiarato const e inizializzato con l'indirizzo dell'area puntata (cioè del primo elemento dell'array).

Es.:

int lista[5];

1. alloca memoria per il puntatore costante lista;

2. alloca memoria per 5 valori di tipo int; 3. inizializza lista con &lista[0]

Il fatto che il puntatore venga assunto const comporta che l'indirizzo dell'array non é modificabile e quindi il nome dell'array non può essere usato come l-value (mentre un normale puntatore sì).

Esiste un'altra differenza fra la dichiarazione di un'array e quella di un puntatore: in un array l'area puntata può essere inizializzata tramite la lista degli elementi dell'array, mentre in un puntatore ciò non é ammesso. A questa regola fa eccezione il caso di un puntatore a char quando l'area puntata é inizializzata mediante una stringa literal (per compatibilità con vecchie versioni del linguaggio).

Es.: char saluto[ ] = "Ciao"; ammesso - saluto é const

char saluto[ ] = {'C','i','a','o','\0'}; ammesso - saluto é const

char* saluto = {'C','i','a','o','\0'}; non ammesso

char* saluto = "Ciao"; ammesso !!! - saluto non é const !!!

Page 106: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

nell'ultimo caso, tuttavia, non è concesso modificare la stringa (anche se è concesso modificare il puntatore!): il programma da' errore in fase di esecuzione! Per esempio, se poniamo: saluto[2] = 'c'; la stringa diventa correttamente "Cico" se saluto è stato dichiarato array di char, mentre risulta un errore di "access violation" della memoria (?!) se saluto è stato dichiarato puntatore a char. Conclusioni: non inizializzare mai un puntatore a char con una stringa literal! (oppure farlo solo se si è sicuri che la stringa non verrà mai modificata).

Combinazione fra operazioni di deref. e di incremento

Le operazioni di deref. e di incremento (o decremento) possono applicarsi contemporaneamente allo stesso operando puntatore. Es. : *p++

In questo caso l'incremento opera sul puntatore e non sulla variabile puntata e, al solito, agisce prima della deref. se é prefisso, oppure dopo la deref. se é suffisso. Da notare che l'espressione nel suo complesso può essere un l-value, mentre il semplice incremento (o decremento) di una variabile non lo é. Infatti, un'istruzione del tipo: *p++ = c ; viene espansa in : *p = c ; p = p+1 ; e quindi é accettabile perché l'operazione di deref. può essere un l-value, mentre l'istruzione: a++ = c ; é inaccettabile in quanto l'operazione di incremento non é un l-value.

Confronto fra operatore [ ] e deref. del puntatore "offsettato"

Poiché il nome (usato da solo) di un array ha il significato di puntatore al primo elemento dell'array, ogni altro elemento é accessibile tramite un'operazione di deref. del puntatore-array "offsettato", cioè incrementato di una quantità pari all'indice dell'elemento. Da questo e dalle note regole di aritmetica dei puntatori consegue che le espressioni (dato un array A):

A[i] e *(A+i)

conducono ad identico risultato e quindi sono perfettamente intercambiabili e possono essere entrambe usate sia come r-value che come l-value.

Page 107: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Funzioni con argomenti array

Quando, nella chiamata di una funzione, si passa come argomento un array (senza indici), in realtà si passa un puntatore, cioè l'indirizzo del primo elemento dell'array e pertanto i singoli elementi sono direttamente modificabili dall'interno della funzione. Questo spiega l'apparente anomalia di comportamento degli argomenti array (e in particolare delle stringhe), a cui abbiamo accennato trattando del passaggio degli argomenti by value.

Es. :

nel programma chiamante:

int A[ ] = {0,0,0}; .... funz(.... A,....) ; ....

nella funzione: void funz(....int A[ ] , ....) { ....A[1] = 5; ....}

il secondo elemento dell'array A risulta modificato, perché in realtà nella funzione viene eseguita l'operazione: *(A+1)= 5 (il valore 5 viene inserito nella locazione di memoria il cui indirizzo é A+1).

Nella dichiarazione (e nella definizione) della funzione, un argomento array può essere indifferentemente dichiarato come array o come puntatore (in questo caso non c'è differenza perché la memoria é già allocata nel programma chiamante). Tornando all'esempio, la funzione funz avrebbe potuto essere definita nel seguente modo: void funz(....int* A, ....)

Le due dichiarazioni sono perfettamente identiche; di solito si preferisce la seconda per evidenziare il fatto che il valore dell'argomento é un indirizzo (il puntatore creato per copia non é mai assunto const, anche se l'argomento é dichiarato come array: resta comunque valida la regola che ogni modifica del suo valore fatta sulla copia non si ripercuote sull'originale).

Funzioni con argomenti puntatori passati by reference

Quando un argomento puntatore é dichiarato in una funzione come riferimento, es. void funz(....int*& A, ....), nel programma chiamante il corrispondente argomento non può essere dichiarato come array, in quanto, se così fosse, sarebbe const e quindi non l-

Page 108: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

value (ricordiamo che gli argomenti passati by reference devono essere degli l-value, a meno che non siano essi stessi dichiarati const nella funzione).

Array di puntatori

In C++ (come in C) i puntatori, come qualsiasi altra variabile, possono essere raggruppati in array e definiti come nel seguente esempio:

int* A[10]; (definisce un array di 10 puntatori a int)

Come un array equivale a un puntatore, così un array di puntatori equivale a un puntatore a puntatore (con in più l'allocazione della memoria puntata, come nel caso di array generico). Se questo viene passato come argomento di una funzione, nella stessa può essere dichiarato indifferentemente come array di puntatori o come puntatore a puntatore. Continuando l'esempio precedente:

programma chiamante: funz(.... A,....) ;

dichiarazione di funz: void funz(....int** A, ....);

Il caso più frequente di array di puntatori é quello dell'array di stringhe, che consente anche l'inizializzazione tramite l'elenco, non dei valori dei puntatori, ma (atipicamente) delle stesse stringhe che costituiscono l'array.

Es.: char* colori[3] = {"Blu", "Rosso", "Verde"} ;

Come appare nell'esempio, le stringhe possono anche essere di differente lunghezza; in memoria sono allocate consecutivamente e, per ciascuna di esse, sono riservati tanti bytes quant'é la rispettiva lunghezza (terminatore compreso). Da certi compilatori la memoria allocata per ogni stringa é arrotondata per eccesso a un multiplo di un numero prefissato di bytes.

Page 109: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Elaborazione della riga di comando

Esecuzione di un programma tramite riga di comando

Un caso tipico di utilizzo di array di stringhe si ha quando il sistema operativo passa a un programma una serie di parametri, elencati nella riga di comando.

Es.: copy file1 file2 copy é il programma

file1 e file2 sono i parametri

Anche un programma scritto in C++ (e trasformato dall'ambiente di sviluppo in un modulo eseguibile) può essere lanciato da sistema operativo come se fosse un comando, e può essere accompagnato da parametri. Il C++ (come il C) si incarica di trasformare tali parametri in argomenti trasmessi alla funzione main, per modo che il programma possa elaborarli.

Argomenti passati alla funzione main

Finora abbia supposto che il main fosse una funzione priva di argomenti. In realtà il sistema operativo passa al main un certo numero di argomenti, di cui, in questo caso, ci interessano i primi due:

int argc numero di voci presenti nella riga di comando (compreso lo stesso nome del programma)

char** argv

array di stringhe, in cui ogni elemento corrisponde a una voce della riga di comando (in fondo viene aggiunta una stringa NULL)

Pertanto, se il programma deve utilizzare dei parametri, il main va definito come segue: int main(int argc, char** argv)

Per esempio, se la riga di comando contiene: copy file1 file2

argc contiene il numero 3

argv[0] contiene la stringa "copy"

argv[1] contiene la stringa "file1"

argv[2] contiene la stringa "file2"

Page 110: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

argv[3] contiene NULL

Page 111: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Puntatori e Funzioni

Funzioni che restituiscono puntatori

Il valore di ritorno restituito da una funzione può essere di qualsiasi tipo, compreso il tipo puntatore.

Es.:

int* funz();

dichiara una funzione funz che restituisce un valore puntatore a int

Come in generale, il valore di ritorno, anche se é un puntatore, viene trasmesso by value e quindi ne viene creata una copia nel programma chiamante; ciò garantisce che il puntatore sopravviva alla funzione anche se è stato creato all'interno del suo ambito.

Tuttavia la variabile puntata potrebbe non sopravvivere alla funzione (se é stata creata nel suo ambito e non dichiarata static). Ciò porterebbe a un errore di dangling references. Notare l'analogia con il tipo di errore generato quando un valore di ritorno, trasmesso by reference, corrisponde a una variabile che cessa di esistere: in quel caso tuttavia, il compilatore ha il controllo della situazione e quindi può segnalare l'errore (o almeno un warning); nel caso di un puntatore, invece, il suo contenuto (cioè l'indirizzo della variabile puntata) é determinato in esecuzione e quindi l'errore non può essere segnalato dal compilatore. Spetta al programmatore fare la massima attenzione a che ciò non si verifichi.

Il più frequente uso di funzioni che restituiscono puntatori si ha nel caso di puntatori a char, cioè di stringhe. Nella stessa libreria Run-time ci sono molte funzioni che restituiscono stringhe. Esempio di funzione di libreria: char* strcat(char* str1, char* str2); concatena la stringa str2 alla stringa str1 e restituisce il risultato sia nella stessa str1 che come valore di ritorno. Notare che in questo caso non c'è pericolo di errore, purchè lo spazio di memoria per str1 sia stato adeguatamente allocato nel programma chiamante.

Puntatori a Funzione

In C++ (come in C) esistono anche i puntatori a funzione! Questi servono quando il programma deve scegliere quale funzione chiamare fra diverse possibili, e la scelta non é definita a priori ma dipende dai dati del programma stesso. Questo processo si chiama late binding ("aggancio ritardato"): gli

Page 112: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

indirizzi delle funzioni da chiamare non vengono risolti al momento della compilazione, come avviene normalmente (early binding) ma al momento dell'esecuzione.

I puntatori a funzione non devono essere definiti, ma solo dichiarati, come nel seguente esempio: int* (*pfunz)(double , char* ); dichiara un puntatore a funzione pfunz che restituisce un puntatore a int e ha due argomenti: il primo é di tipo double, il secondo é un puntatore a char. Notare le parentesi intorno al nome della funzione, in assenza delle quali la dichiarazione sarebbe interpretata in modo diverso (una normale funzione pfunz che restituisce un puntatore a puntatore a int).

Nel corso del programma il puntatore a funzione deve essere assegnato (o inizializzato) con il nome di una funzione "vera", che deve essere precedentemente dichiarata con lo stesso tipo del valore di ritorno e gli stessi argomenti del puntatore. Continuando l'esempio precedente:

int* funz1(double , char* ); int* funz2(double , char* ); if ( ......... ) pfunz = funz1 ; else pfunz = funz2;

notare che i nomi delle funzioni e del puntatore vanno indicati da soli, senza i loro argomenti (e senza le parentesi).

In una chiamata della funzione, tutti i testi di C dicono che il puntatore va dereferenziato (in realtà non é necessario): (*pfunz)(12.3,"Ciao"); ... ma va bene anche: pfunz(12.3,"Ciao");

Array di puntatori a funzione

In C++ (come in C) è consentito dichiarare array di puntatori a funzione, nella forma specificata dal seguente esempio: double (*apfunz[5])(int); dichiara l'array apfunz di 5 puntatori a funzione, tutti con valore di ritorno di tipo double e con un argomento di tipo int.

L'array può essere inizializzato con un elenco di nomi di funzioni, già dichiarate e condividenti tutte le stesso tipo di valore di ritorno e gli stessi argomenti:

double (*apfunz[5])(int) = {f1, f2, f3, f4, f5} ;

Page 113: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

dove f1 ecc... sono tutte funzioni dichiarate: double f1(int); ecc...

I singoli elementi dell'array possono anche essere assegnati tramite l'operatore [ ], che funziona come l-value nel modo consueto: apfunz[3]= fn; dove fn é una funzione dichiarata: double fn(int);

Nelle chiamate, si usa ancora l'operatore [ ] per selezionare l'elemento desiderato: apfunz[i](n); (non é necessario dereferenziare il puntatore) dove l'indice i permette di accedere alla funzione precedentemente assegnata all'i-esimo elemento dell'array.

Gli array di puntatori a funzione possono essere utili, per esempio, quando la funzione da eseguire é selezionata da un menù: in questo caso l'indice i , corrispondente a una voce di menù, indirizza direttamente la funzione prescelta, senza bisogno di istruzioni di controllo, come if o switch, per determinarla.

Funzioni con argomenti puntatori a funzione

E' noto che, quando nella chiamata di una funzione compare come argomento un'altra funzione, questa viene eseguita per prima e il suo valore di ritorno é utilizzato come argomento dalla prima funzione. Quindi il vero argomento della prima funzione non é la seconda funzione, ma un normale valore, che può avere qualsiasi origine (variabile, espressione ecc...), e in particolare in questo caso è il risultato dell'esecuzione di un'altra funzione (il cui tipo di valore di ritorno deve coincidere con il tipo dichiarato dell'argomento).

Quando invece una funzione dichiara fra i suoi argomenti un puntatore a funzione, allora sono parametrizzate proprio le funzioni e non i loro valori di ritorno. Nelle chiamate é necessario specificare come argomento il nome di una funzione "vera", precedentemente dichiarata, che viene sostituito a quello del puntatore.

Es.: dichiarazioni: void fsel(int (*)(float)); int funz1(float); int funz2(float);

chiamate: fsel(funz1); fsel(funz2);

definizione fsel: void fsel(int (*pfunz)(float)) { .... n = pfunz(r); .....} (dove n é di tipo int e r é di tipo float)

Page 114: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

l'istruzione n=pfunz(r) viene sostituita la prima volta con n=funz1(r) e la seconda volta con n=funz2(r) . Notare che, nelle chiamate, l'argomento-funzione deve essere a sua volta specificato senza argomenti e senza le parentesi tonde.

Nell'esempio abbiamo supposto che la variabile r, argomento della pfunz, sia creata all'interno della fsel; anche se r fosse passato dal programma chiamante, la forma: fsel(funz1(r)) sarebbe comunque errata: l'unico modo per passare r potrebbe essere quello di dichiararlo come ulteriore argomento della fsel, cioè: void fsel(float, int (*pfunz)(float)); e nelle chiamate specificare: fsel(r, funz1); ...oppure... fsel(r, funz2);

Page 115: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Puntatori e Costanti

Puntatori a costante

Nelle definizioni di una variabile puntatore, lo specificatore di tipo const indica che deve essere considerata costante la variabile puntata (non il puntatore!).

Es.: const float* ptr; definisce il puntatore variabile ptr a costante float.

In realtà a un puntatore a costante si può anche assegnare l'indirizzo di una variabile, ma non é vero il contrario: l'indirizzo di una costante può essere assegnato solo a un puntatore a costante. In altre parole il C++ accetta conversioni da puntatore a variabile a puntatore a costante, ma non viceversa.

L'operazione di deref. di un puntatore a costante non é mai accettata come l-value, anche se la variabile puntata non é const.

Es.: int datov=50; (datov é una variabile int)

const int datoc=50; (datoc é una costante int)

int* ptv; (ptv é un puntatore a variabile int)

const int* ptc; (ptc é un puntatore a costante int)

ptc = &datov; (valida, in quanto le conversioni da int* a const int* sono ammesse)

ptv = &datoc; (non valida, in quanto le conversioni da const int* a int* non sono ammesse)

*ptc = 10; (deref. l-value non valida, anche se ptc punta a una variabile)

datov=10; cout << *ptc;

(deref. r-value valida, scrive 10)

Puntatori costanti

I puntatori costanti si definiscono specificando l'operatore di dichiarazione * const (al posto di *)

Page 116: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Es.: float* const ptr; definisce il puntatore costante ptr a variabile float

Un puntatore costante segue la regola di tutte le costanti: deve essere inizializzato, ma non può più essere modificato (non é un l-value). Resta l-value, invece, la deref. di un puntatore costante che punta a una variabile.

Es.: int dato1,dato2; (dato1 e dato2 sono due variabili int)

int* const ptr = &dato1;

(ptr é un puntatore costante, inizializzato con l'indirizzo di dato1)

*ptr = 10; (valida, in quanto ptr punta a una variabile)

ptr = &dato2; (non valida, in quanto ptr é costante)

Casi tipici di puntatori costanti sono gli array.

Puntatori costanti a costante

Ripetendo due volte const (come specificatore di tipo e come operatore di dichiarazione), si può definire un puntatore costante a costante (di uso piuttosto raro).

Es.: const char dato='A'; (dato é una costante char, inizializzata con 'A')

const char* const ptr = &dato;

(ptr é un puntatore costante a costante , inizializzato con l'indirizzo della costante dato)

Nel caso di un puntatore costante a costante, non sono l-values né il puntatore né la sua deref.

Funzioni con argomenti costanti trasmessi by value

Le regole di ammissibilità degli argomenti di una funzione, dichiarati const (nella funzione e/o nel programma chiamante) e trasmessi by value, sono

Page 117: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

riconducibili alle regole generali applicate a una normale dichiarazione con inizializzazione; come é noto, infatti, la trasmissione by value comporta una creazione per copia, che equivale alla dichiarazione dell'argomento come variabile locale della funzione, inizializzata con il valore passato dal programma chiamante.

Quindi un argomento può essere dichiarato const nel programma chiamante e non nella funzione o viceversa, senza limitazioni (in quanto la creazione per copia "separa i destini" delle due variabili), salvo in un caso: un puntatore a costante non può essere dichiarato tale nel programma chiamante se non lo é anche nella funzione (in quanto non sono ammesse le conversioni da puntatore a costante a puntatore a variabile).

Es.: void funz(int*); void main() { const int* ptr; ... funz(ptr); ... }

(errore : l'argomento é dichiarato puntatore a costante nel main e puntatore a variabile nella funzione)

void funz(const int*); void main() {int*ptr; ... funz(ptr); ... }

( ok! )

Da quest'ultimo esempio si capisce anche qual'é l'uso principale di un puntatore a costante: come argomento passato a una funzione, se non si desidera che la variabile puntata subisca modifiche dall'interno della funzione stessa (tramite operazioni di deref.), anche se ciò é possibile nel programma chiamante.

Funzioni con argomenti costanti trasmessi by reference

Sappiamo che, se un argomento é passato a una funzione by reference, non ne viene costruita una copia, ma il suo nome nella funzione é assunto come alias del nome corrispondente nel programma chiamante (cioè i due nomi si riferiscono alla stessa locazione di memoria). Per questo motivo il controllo sui tipi e più rigoroso rispetto al caso di passaggio by value: in particolare, qualsiasi sia l'argomento (puntatore o no), non é ammesso dichiararlo const nel programma chiamante e non nella funzione.

La dichiarazione inversa (const solo nella funzione) é invece possibile, in quanto corrisponde alla definizione di un alias di sola lettura: l'argomento, pur essendo modificabile nel programma chiamante, non lo é dall'interno della funzione.

Il passaggio by reference di argomenti dichiarati const nella funzione é in uso molto frequente in C++, perché combina insieme due vantaggi: quello di

Page 118: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

proteggere i dati del programma da modifiche indesiderate (come nel passaggio by value), e quello di una migliore efficienza; infatti il passaggio by reference, non comportando la creazione di nuove variabili, é più veloce del passaggio by value.

Quando un argomento é passato by reference ed é dichiarato const nella funzione, non esiste più la condizione che nel programma chiamante il corrispondente argomento sia un l-value (può anche essere il risultato di un'espressione).

Se si vuole dichiarare un argomento: puntatore costante passato by reference, bisogna specificare entrambi gli operatori di dichiarazione *const e & (nell'ordine)

Es.: funz(int* const & ptr);

dichiara che l'argomento ptr é un puntatore costante a variabile int, passato by reference

Page 119: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Tipi definiti dall'utente

Il termine "tipo astratto", usato in contrapposizione ai tipi nativi del linguaggio, non é molto appropriato: il C++ consente al programmatore di definire nuovi tipi, estendendo così le capacità effettive del linguaggio; ma, una volta definiti, questi tipi sono molto "concreti" e sono trattati esattamente come i tipi nativi. Per questo motivo, la tendenza "moderna" è di identificare i tipi non nativi con il termine: "tipi definiti dall'utente" e di confinare l'aggettivo "astratto" a una precisa sottocategoria di questi (di cui parleremo più avanti). Tuttavia noi continueremo, per comodità, a usare la "vecchia" terminologia.

In questo capitolo parleremo dei tipi astratti comuni sia al C che al C++, usando però la nomenclatura (oggetti, istanze ecc...) del C++.

Concetti di oggetto e istanza

Il termine oggetto é sostanzialmente sinonimo del termine variabile. Benché questo termine si usi soprattutto in relazione a tipi astratti (come strutture o classi), noi possiamo generalizzare il concetto, definendo oggetto una variabile di qualunque tipo, non solo formalmente definita, ma anche già creata e operante.

E' noto infatti che l'istruzione di definizione di una variabile non si limita a dichiarare il suo tipo, ma crea fisicamente la variabile stessa, allocando la memoria necessaria (nella terminologia C++ si dice che la variabile viene "costruita"): pertanto la definizione di una variabile comporta la "costruzione" di un oggetto.

Il termine istanza é quasi simile al termine oggetto; se ne differenzia in quanto sottolinea l'appartenenza dell'oggetto a un dato tipo (istanza di ... "qualcosa"). Per esempio, la dichiarazione/definizione: int ivar ; costruisce l'oggetto ivar, istanza del tipo int.

Esiste anche il verbo: istanziare (o instanziare) un certo tipo, che significa creare un'istanza di quel tipo.

Typedef

Page 120: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'istruzione introdotta dalla parola-chiave typedef definisce un sinonimo di un tipo esistente, cioè non crea un nuovo tipo, ma un nuovo identificatore di un tipo (nativo o astratto) precedentemente definito.

Es.: typedef unsigned long int* pul ; definisce il nuovo identificatore di tipo pul, che potrà essere usato, nelle successive dichiarazioni (all'interno dello stesso ambito), per costruire oggetti di tipo puntatore a unsigned long:

unsigned long a; pul ogg1 = &a; pul parray[100]; ecc...

L'uso di typedef permette di semplificare dichiarazioni lunghe di variabili dello stesso tipo. Per esempio, supponiamo di dover dichiarare molti array, tutti dello stesso tipo e della stessa dimensione:

double a1[100]; double a2[100]; double a3[100]; ecc...

usando typedef la semplificazione é evidente:

typedef double a[100]; a a1; a a2; a a3; ecc...

Un caso in cui si evidenzia in modo eclatante l'utilità di typedef è quello in cui si devono dichiarare più funzioni con lo stesso puntatore a funzione come argomento. Es.: typedef bool (*tpfunz)(const int&, int&, const char*, int&, char*&, int&); in questo caso tpfunz è il nome di un tipo puntatore a funzione e può essere sostituito nelle dichiarazioni delle funzioni chiamanti al posto dell'intera stringa di cui sopra: void fsel1(tpfunz); int fsel2(tpfunz); double fsel3(tpfunz); ecc.... infine, nelle definizioni delle funzioni chiamanti bisogna specificare un argomento di "tipo" tpfunz e usare questo per le chiamate. Es: void fsel1(tpfunz pfunz) { ... if(pfunz(4,a,"Ciao",b,pc,m)) .... }

Un altro utilizzo di typedef è quello di confinare in unico luogo i riferimenti diretti a un tipo. Per esempio, se il programma lavora in una macchina in cui il tipo int corrisponde a 32 bit e noi poniamo: typedef int int32; avendo cura poi di attribuire il tipo int32 a tutte le variabili intere che vogliamo a 32 bit, possiamo portare il programma su una macchina a 16 bit ridefinendo solamente int32 : typedef long int32;

Page 121: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Strutture

Come gli array, in C++ (e in C) le strutture sono gruppi di dati; a differenza dagli array, i singoli componenti di una struttura possono essere di tipo diverso.

Esempio di definizione di una struttura:

struct anagrafico

{

char nome[20];

int anni;

char indirizzo[30];

} ;

Dopo la parola-chiave struct segue l'identificatore della struttura, detto anche marcatore o tag, e, fra parentesi graffe, l'elenco dei componenti della struttura, detti membri; ogni membro é dichiarato come una normale variabile (è una semplice dichiarazione, non una definizione, e pertanto non comporta la creazione dell'oggetto corrispondente) e può essere di qualunque tipo (anche array o puntatore o una stessa struttura). Dopo la parentesi graffa di chiusura, è obbligatoria la presenza del punto e virgola (diversamente dai blocchi delle funzioni).

In C++ (e non in C) la definizione di una struttura comporta la creazione di un nuovo tipo, il cui nome coincide con il tag della struttura. Pertanto, riprendendo l'esempio, anagrafico è a pieno titolo un tipo (come int o double), con la sola differenza che si tratta di un tipo astratto, non nativo del linguaggio.

Per questo motivo l'enunciato di una struttura è una definizione e non una semplice dichiarazione: crea un'entità (il nuovo tipo) e ne descrive il contenuto. Ma, diversamente dalle definizioni delle variabili, non alloca memoria, cioè non crea oggetti. Perchè ciò avvenga, il nuovo tipo deve essere istanziato, esattamente come succede per i tipi nativi. Riprendendo l'esempio, l'istruzione di definizione:

anagrafico ana1, ana2, ana3 ;

costruisce gli oggetti ana1, ana2 e ana3, istanze del tipo anagrafico. Solo adesso viene allocata memoria, per ogni oggetto in quantità pari alla somma delle memorie che competono ai singoli membri della struttura (l'operazione sizeof(anagrafico), oppure sizeof(ana1) ecc..., restituisce il numero dei bytes allocati ad ogni istanza di anagrafico).

La collocazione ideale della definizione di una struttura é in un header-file: conviene infatti separarla dalle sue istanze, in quanto la definizione deve essere (di solito) accessibile dappertutto, mentre le istanze sono normalmente locali e

Page 122: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

quindi limitate dal loro ambito di visibilità. Potrebbe però sorgere un problema: se un programma è suddiviso in più files sorgente e tutti includono lo stesso header-file contenente la definizione di una struttura, dopo l'azione del preprocessore risulteranno diverse translation unit con la stessa definizione e quindi sembrerebbe violata la "regola della definizione unica" (o ODR, dall'inglese one-definition-rule). In realtà, per la definizione dei tipi astratti (e di altre entità del linguaggio, come i template, che vedremo più avanti), la ODR si esprime in modo meno restrittivo rispetto al caso della definizione di variabili e funzioni (non inline): in questi casi, due definizioni sono ancora ritenute esemplari della stessa, unica, definizione, se e solo se:

1. appaiono in differenti translation units , 2. sono identiche nei rispettivi elementi lessicali, 3. il significato dei rispettivi elementi lessicali è lo stesso in entrambe le

translation units

e tali condizioni sono senz'altro verificate se due files sorgente includono lo stesso header-file (purchè in uno dei due non si alteri il significato dei nomi con typedef o #define !).

Operatore .

La grande utilità delle strutture consiste nel fatto che i nomi delle sue istanze possono essere usati direttamente come operandi in molte operazioni o come argomenti nelle chiamate di funzioni, consentendo un notevole risparmio, soprattutto quando il numero di membri é elevato.

In alcune operazioni, tuttavia, é necessario accedere a un membro individualmente. Ciò é possibile grazie all'operatore binario . di accesso al singolo membro: questo operatore ha come left-operand il nome dell'oggetto e come right-operand quello del membro. Es.: ana2.indirizzo

Come altri operatori che svolgono compiti analoghi (per esempio l'operatore [ ] di accesso al singolo elemento di un array), anche l'operatore . può restituire sia un r-value (lettura di un dato) che un l-value (inserimento di un dato).

Es.: int a = ana1.anni; inizializza a con il valore del membro anni dell'oggetto ana1

ana3.anni = 27; inserisce 27 nel membro anni dell'oggetto ana3

Page 123: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Puntatori a strutture - Operatore ->

Come tutti i tipi del C++ (e del C), anche i tipi astratti, e in particolare le strutture, hanno i propri puntatori. Per esempio (notare le differenze):

int* p_anni = &ana1.anni; anagrafico* p_anag = &ana1;

nel primo caso definisce un normale puntatore a int, che inizializza con l'indirizzo del membro anni dell'oggetto ana1; nel secondo caso definisce un puntatore al tipo-struttura anagrafico, che inizializza con l'indirizzo dell'oggetto ana1.

Per accedere a un membro di un oggetto (istanza di una struttura) di cui é dato il puntatore, bisogna eseguire un'operazione di deref. . Riprendendo l'esempio precedente, si potrebbe pensare che la forma corretta dell'operazione sia: *p_anag.anni e invece non lo é, in quanto l'operatore . ha la precedenza sull'operatore di deref. e quindi il compilatore darebbe messaggio di errore, interpretando p_anag.anni come un indirizzo da dereferenziare (l'interpretazione sarebbe giusta se esistesse un oggetto di nome p_anag con un membro di nome anni definito puntatore a int, e invece esiste un puntatore di nome p_anag a un oggetto con un membro di nome anni definito int).

Perché il risultato sia corretto bisognerebbe inserire la deref. del puntatore fra parentesi, cioè: (*p_anag).anni il C++ (come il C) consente di evitare questa "fatica" mettendo a disposizione un altro operatore, che restituisce un identico risultato: p_anag->anni

In generale l'operatore -> permette di accedere a un membro (indicato dal right-operand) di un oggetto, istanza di una struttura, il cui indirizzo é dato nel left-operand (ovviamente anche questo operatore può restituire sia un r-value che un l-value).

Unioni

Page 124: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Le unioni sono identiche alle strutture (sono introdotte dalla parola-chiave union al posto di struct), eccetto nel fatto che i membri di ogni loro istanza occupano la stessa area di memoria.

In pratica un'unione consente di utilizzare un solo membro per ogni oggetto (anche se i membri definiti sono più d'uno) e servono quando può essere comodo selezionare ogni volta il membro più appropriato, in base alle necessità.

L'occupazione di memoria di un'unione coincide con quella del membro di dimensioni maggiori.

Array di strutture

Abbiamo visto negli esempi che i membri di una struttura possono essere array. Anche le istanze di una struttura possono essere array.

Es.: definizione: struct tipo_stud { char nome[20]; int voto[50];}

;

costruzione oggetti: tipo_stud studente[40];

accesso: studente[5].voto[10] = 30;

(lo studente n.5 ha preso 30 nella prova n.10 !)

Dichiarazione di strutture e membri di tipo struttura

I membri di una struttura possono essere a loro volta di tipo struttura. Esiste però il problema di fare riconoscere tale struttura al compilatore. Le soluzione più semplice è definire la struttura a cui appartiene il membro prima della struttura che contiene il membro (così il compilatore é in grado di riconoscerne il tipo). Tuttavia capita non di rado che la stessa struttura a cui appartiene il membro contenga informazioni che la collegano alla struttura principale: in questi casi viene a determinarsi la cosidetta "dipendenza circolare", apparentemente senza soluzione.

In realtà il C++ offre una soluzione semplicissima: dichiarare la struttura prima di definirla! La dichiarazione di una struttura consiste in una istruzione

Page 125: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

in cui appaiono esclusivamente la parola-chiave struct e l'identificatore della struttura.

Es.: struct data ;

chiaramente si tratta di una dichiarazione-non-definizione (questo è il terzo caso che incontriamo, dopo le dichiarazioni di variabili con le specificatore extern e le dichiarazioni di funzioni), nel senso che non rende ancora la struttura utilizzabile, ma è sufficiente affinchè il compilatore accetti data come tipo di una struttura definita successivamente.

Allora il problema è risolto ? No ! Perchè no ? Perchè il compilatore ha un'altra esigenza oltre quella di riconoscere i tipi: deve essere anche in grado di calcolare le dimensioni di una struttura e non lo può fare se questa contiene membri di strutture non definite. Solo nel caso che i membri in questione siano puntatori questo problema non sussiste, in quanto le dimensioni di un puntatore sono fisse e indipendenti dal tipo della variabile puntata.

Pertanto, la dipendenza circolare fra membri di strutture diverse può essere spezzata solo se almeno in una struttura i membri coinvolti sono puntatori.

Per esempio, una sequenza corretta potrebbe essere:

struct data ; dichiarazione anticipata della struttura data

struct persona { char nome[20]; data* pnascita;} ;

definizione della struttura principale persona con un membro puntatore a data

struct data { int giorno; int mese; int anno; persona caio; } ;

definizione della struttura data con un membro di tipo persona

in questo modo il membro pnascita della struttura persona è riconosciuto come puntatore al tipo data prima ancora che la struttura data sia definita.

Con lo stesso ragionamento si può dimostrare che è possibile dichiarare dei membri di una struttura come puntatori alla struttura stessa (per esempio, quando si devono costruire delle liste concatenate). In questo caso, poi, la dichiarazione anticipata non serve in quanto il compilatore conosce già il nome della struttura che appare all'inizio della sua definizione.

Nota:

La dipendenza circolare si può avere anche fra le funzioni (una funzione A che chiama una funzione B che chiama una funzione C che a sua volta chiama la funzione A). Ma in questi casi le dichiarazioni contengono già tutte le informazioni necessarie e quindi il problema si risolve semplicemente dichiarando A prima di definire (nell'ordine) C, B e la stessa A.

Per accedere a un membro di una la struttura al cui tipo appartiene il membro di un certo oggetto, é necessario ripetere due volte l'operazione con l'operatore . (e/o con l'operatore -> se il membro è un puntatore). Seguitando con lo stesso esempio :

Page 126: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

costruzione oggetto:

persona tizio; (da qualche altra parte bisogna anche creare un oggetto di tipo data e assegnare il suo indirizzo a tizio.pnascita)

accesso: tizio.pnascita->anno = 1957;

come si può notare dall'esempio, il numero 1957 é stato inserito nel membro anno dell'oggetto il cui indirizzo si trova nel membro puntatore pnascita dell'istanza tizio della struttura persona.

Strutture di tipo bit field

Le strutture di tipo bit field permettono di riservare ad ogni membro un determinato numero di bit di memoria, consentendo notevoli risparmi; il tipo di ogni membro deve essere unsigned int.

Es.: struct bit { unsigned int ma:2; unsigned int mb:1; } ;

la presenza dei due punti, seguita dal numero di bit riservati, identifica la definizione di una struttura di tipo bit field.

Tipi enumerati

Con la parola-chiave enum si definiscono i tipi enumerati, le cui istanze possono assumere solo i valori specificati in un elenco.

Es.: enum feriale { Lun, Mar, Mer, Gio, Ven } ;

dove: feriale è il nome del tipo enumerato e le costanti fra parentesi graffe sono i valori possibili (detti enumeratori).

In realtà agli enumeratori sono assegnati numeri interi, a partire da 0 e con incrementi di 1, come se si usassero le direttive: #define Lun 0 #define Mar 1 ecc...

Volendo assegnare numeri diversi (comunque sempre interi), bisogna specificarlo.

Es.: enum dati { primo, secondo=12, terzo } ;

Page 127: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

in questo caso alla costante primo è assegnato 0, a secondo è assegnato 12 e a terzo è assegnato 13. Comunque l'uso degli enumeratori, anzichè quello diretto delle costanti numeriche corrispondenti, è utile in quanto permette di scrivere codice più chiaro ed più esplicativo di ciò che si vuole fare.

Analogamente al tag di una struttura, il nome di un tipo enumerato é assunto, in C++ come un nuovo tipo del linguaggio. Es.: feriale oggi = Mar ; costruisce l'oggetto oggi, istanza del tipo enumerato feriale e lo inizializza con il valore dell'enumeratore Mar.

Un oggetto di tipo enumerato può assumere valori anche diversi da quelli specificati nella definizione. L'intervallo di validità (detto dominio) di un tipo enumerato contiene tutti i valori dei propri enumeratori arrotondati alla minima potenza di 2 maggiore o uguale al massimo enumeratore meno 1. Il dominio comincia da 0 se il minimio enumeratore non è negativo; altrimenti è il valore maggiore tra le potenze di due negative minori o uguali del minimo enumeratore (si uguagliano poi minimo e massimo scegliendo il più grande in valore assoluto). In ogni caso il dominio non può superare il range del tipo int.

Es.: enum en1 { bello, brutto } ; dominio 0:1

enum en2 { a=3, b=10 } ; dominio 0:15

enum en3 { a=-38, b=850 } ; dominio -1024:1023

come si può notare, il numero complessivo degli enumeratori possibili è sempre una potenza di 2.

Per inizializzare un oggetto di tipo enumerato con un valore intero (anche diverso dalle costanti incluse nella definizione, purchè compreso nel dominio) è obbligatorio il casting.

Es.:

en2 oggetto1 = (en2)14 ; OK, 14 è compreso nel dominio

en2 oggetto2 = (en2)20 ; risultato indefinito, 20 non è compreso nel dominio

en2 oggetto3 = 3 ; errore: conversione implicita non ammessa

Gli enumeratori sono ammessi nelle operazioni fra numeri interi e, in questi casi, sono converititi implicitamente in int.

Page 128: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Allocazione dinamica della memoria

Memoria stack e memoria heap

Abbiamo già sentito parlare dell'area di memoria stack: é quella in cui viene allocato un pacchetto di dati non appena l'esecuzione passa dal programma chiamante a una funzione. Abbiamo detto che questo pacchetto (il quale contiene l'indirizzo di rientro nel programma chiamante, la lista degli argomenti passati alla funzione e tutte le variabili automatiche definite nella funzione) viene "impilato" sopra il pacchetto precedente (quello del programma chiamante) e poi automaticamente rimosso dalla memoria appena l'esecuzione della funzione é terminata. Sappiamo anche che, grazie a questo meccanismo, le funzioni possono essere chiamate ricorsivamente e inoltre si possono gestire funzioni con numero variabile di argomenti. Le variabili automatiche definite nella funzione hanno lifetime limitato all'esecuzione della funzione stessa proprio perché, quando la funzione termina, il corrispondente pacchetto allocato nell'area stack viene rimosso.

Un'altra area di memoria è quella in cui vengono allocate le variabili non locali e le variabili locali statiche. A differenza dalla precedente, quest'area viene mantenuta in vita fino alla fine del programma, anche se ogni variabile è visibile solo all'interno del proprio ambito.

Esiste una terza area di memoria che il programma può utilizzare. Questa area, detta heap, è soggetta a regole di visibilità e tempo di vita diverse da quelle che governano le due aree precedenti, e precisamente:

• l'area heap non é allocata automaticamente, ma può essere allocata o rimossa solo su esplicita richiesta del programma (allocazione dinamica della memoria);

• l'area allocata non é identificata da un nome, ma é accessibile esclusivamente tramite deref. di un puntatore;

• il suo scope coincide con quello del puntatore che contiene il suo indirizzo;

• il suo lifetime coincide con l'intera durata del programma, a meno che non venga esplicitamente deallocata; se il puntatore va out of scope, l'area non é più accessibile, ma continua a occupare memoria inutilmente: si verifica l'errore di memory leak, opposto a quello di dangling references.

Operatore new

Page 129: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

In C++, l'operatore new costruisce uno o più oggetti nell'area heap e ne restituisce l'indirizzo. In caso di errore (memoria non disponibile) restituisce NULL.

Gli operandi di new (tutti alla sua destra) sono tre, di cui solo il primo é obbligatorio (le parentesi quadre nere racchiudono gli operandi opzionali):

new tipo [[dimensione]] [(valore iniziale)]

• tipo é il tipo (anche astratto) dell'oggetto (o degli oggetti) da creare; • dimensione é il numero degli oggetti, che vengono sistemati nella

memoria heap consecutivamente (come gli elementi di un array); se questo operando é omesso, viene costruito un solo oggetto; se é presente, l'indirizzo restituito da new punta al primo oggetto;

• valore iniziale é il valore con cui l'area allocata viene inizializzata (deve essere dello stesso tipo di tipo); se é omesso l'area non é inizializzata.

NOTA: si è potuto riscontrare che a volte i due operandi opzionali sono mutuamente incompatibili (alcuni compilatori più antichi danno errore): in pratica (vedremo perchè parlando dei costruttori), se il tipo è nativo inizializza comunque tutti i valori con zero, se il tipo è astratto funziona bene (a certe condizioni).

Ovviamente l'operatore new non può restituire un l-value; può essere invece un r-value sia nelle inizializzazioni che nelle assegnazioni, e può far parte di operazioni di aritmetica fra puntatori . Esempi: inizializzazione: int* punt = new int (7); assegnazione con operazione aritmetica: struct anagrafico { ....... } ; anagrafico* p_anag ; p_anag = new anagrafico [100] + 9 ;

nel primo esempio alloca un oggetto int (inizializzato con il valore 7) nell'area heap e usa il suo indirizzo per inizializzare il puntatore punt; nel secondo esempio definisce la struttura anagrafico e definisce un puntatore a tale struttura, a cui assegna l'indirizzo del decimo di cento oggetti di tipo anagrafico, allocati nell'area heap.

Operatore delete

In C++, l'operatore binario delete (con un operando opzionale e l'altro obbligatorio) dealloca la memoria dell'area heap puntata dall'operando (obbligatorio). Non restituisce alcun valore e quindi deve essere usato da solo in un'istruzione (non essendo né un l-value né un r-value non può essere usato in un'espressione con altre operazioni).

Page 130: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Es.: allocazione: int* punt = new int ;

deallocazione: delete punt ;

Contrariamente all'apparenza l'operatore delete non cancella il puntatore né altera il suo contenuto: l'unico effetto é di liberare la memoria puntata rendendola disponibile per ulteriori allocazioni (se l'operando non punta a un'area heap alcuni compilatori generano un messaggio di errore (o di warning), altri no, ma in ogni caso l'operatore delete non ha effetto).

Se l'operando punta a un'area in cui è stato allocato un array di oggetti, bisogna inserire dopo delete l'operando opzionale, che consiste in una coppia di parentesi quadre (senza la dimensione dell'array, che il C++ é in grado di riconoscere automaticamente).

Es.: float* punt = new float [100] ; (alloca 100 oggetti float )

delete [ ] punt ; (libera tutta la memoria allocata)

L'operatore delete costituisce l'unico mezzo per deallocare memoria heap, che, altrimenti, sopravvive fino alla fine del programma, anche quando non é più raggiungibile.

Es.: int* punt = new int ;

(alloca un oggetto int nell'area heap e inizializza punt con il suo indirizzo)

int a ; (definisce un oggetto int nell'area stack)

punt = &a ; (assegna a punt un indirizzo dell'area stack; l'oggetto int dell'area heap non é più raggiungibile)

Page 131: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Namespace

Programmazione modulare e compilazione separata

Nel corso degli anni, l'enfasi nella progettazione dei programmi si è spostata dal progetto delle procedure all'organizzazione dei dati, in ragione anche dei problemi di sviluppo e manutenzione del software che sono direttamente correlati all'aumento di dimensione dei programmi. La possibilità di suddividere grossi programmi in porzioni il più possibile ridotte e autosufficienti (detti moduli) è pertanto caratteristica di un modo efficiente di produrre software, in quanto permette di sviluppare programmi più chiari e più facili da mantenere ed aggiornare (specie se i programmatori che lavorano a un stesso progetto sono molti).

Un modulo è costituito da dati logicamente correlati e dalle procedure che li utilizzano. L'idea-base è quella del "data hiding" (occultamento dei dati), in ragione della quale un programmatore "utente" del modulo non ha bisogno di conoscere i nomi delle variabili, dei tipi, delle funzioni e in generale delle caratteristiche di implementazione del modulo stesso, ma è sufficiente che sappia come utilizzarlo, cioè come mandargli le informazioni e ottenere le risposte. Un modulo è pertanto paragonabile a un dispositivo (il cui meccanismo interno è sconosciuto), con il quale comunicare attraverso operazioni di input-output. Tali operazioni sono a loro volta raggruppate in un modulo separato, detto interfaccia che rappresenta l'unico canale di comunicazione fra il modulo e i suoi utenti.

La programmazione modulare offre così un duplice vantaggio: quello di separare l'interfaccia dal codice di implementazione del modulo, dando la possibilità al modulo di essere modificato senza che il codice dell'utente ne sia influenzato; e quello di permettere all'utente di definire i nomi delle variabili, dei tipi, delle funzioni ecc.. senza doversi preoccupare di eventuali conflitti con i nomi usati nel modulo e dell'insorgere di errori dovuti a simboli duplicati.

Parallelo al concetto di programmazione modulare è quello di compilazione separata. Per motivi di efficienza la progettazione di un programma (specie se di grosse dimensioni) dovrebbe prevedere la sistemazione dei moduli in files separati: in questo modo ogni intervento di modifica o di correzione degli errori di un singolo modulo comporterebbe la ricompilazione di un solo file. E' utile che anche l'interfaccia di un modulo risieda in un file separato sia dal codice dell'utente che da quello di implementazione del modulo stesso. Entrambi questi files dovrebbero poi contenere la direttiva #include (file dell'interfaccia) così che il preprocessore possa creare due translation units indipendenti, ma collegate entrambe alla stessa interfaccia (questo approccio è molto più conveniente di quello di creare due soli files entrambi con il codice dell'interfaccia, in quanto permette al progettista del modulo di modificare l'interfaccia senza implicare che la stessa modifica venga eseguita anche nel file dell'utente).

Page 132: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Definizione di namespace

Dal punto di vista sintattico, la definizione di un namespace somiglia molto a quella di una struttura (cambia la parola-chiave e inoltre il punto e virgola in fondo non è obbligatorio). Esempio:

namespace Stack

{

const int max_size = 100;

char v[max_size ];

int top = 0;

void push(char c) {......}

char pop( ) {......}

}

I membri di un namespace sono dichiarazioni o definizioni (con eventuali inizializzazioni) di identificatori di qualunque genere (variabili, funzioni, typedef, strutture, enumeratori, altri tipi astratti qualsiasi ecc...). Anche il nome di un namespace (Stack, nell'esempio) è un identificatore. Pertanto definire un namespace significa dichiarare/definire un gruppo di nomi a sua volta identificato da un nome.

A differenza dalle strutture, Stack non è un tipo (non può essere istanziato da oggetti) ma identifica semplicemente un ambito di visibilità (scope). I membri di Stack sono perciò identificatori locali, visibili soltanto nello scope definito da Stack. Il programmatore è perciò libero di definire gli stessi nomi al di fuori, senza pericolo di conflitti o ambiguità.

Non è ammesso definire un namespace all'interno di un altro scope (per esempio nel block scope di una funzione o una struttura); e quindi il suo nome ha global scope cioè è riconoscibile dappertutto. E' però possibile "annidare" un namespace all'interno di un altro namespace: in questo caso il suo scope coincide con quello degli altri membri del namespace superiore.

In definitiva, il termine namespace si identifica con quello di "ambito dichiarativo con un nome". In questo senso, anche i blocchi delle funzioni e delle strutture sono dei namespace (con molte funzionalità in più) e tutto ciò che è al di fuori (le variabili globali) è detto appartenere al "namespace globale".

Risoluzione della visibilità

Page 133: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Sorge a questo punto spontanea una domanda: come comunicare fra i namespace? In altre parole, se i membri di un namespace non sono accessibili dall'esterno, come si possono usare nel programma ?

Per accedere a un nome definito in un namespace, bisogna "qualificarlo", associandogli il nome del namespace (che invece è visibile, avendo global scope), tramite l'operatore binario di risoluzione di visibilità :: (doppi due punti). Seguitando nell'esempio precedente:

Stack::top (accede al membro top del namespace Stack)

Notare l'analogia di questo operatore con quello unario di riferimento globale (già visto a proposito dell'accesso alle variabili globali). Infatti, se il left-operand manca, vuol dire che il nome dato dal right-operand deve essere cercato nel namespace globale.

Membri di un namespace definiti esternamente

Abbiamo visto che i membri di un namespace possono essere sia dichiarati che definiti. Sappiamo però che alcune dichiarazioni non sono definizioni e che in generale un identificatore è utilizzabile dal programma se è definito (da qualche parte) ed è dichiarato prima del punto in cui lo si vuole utilizzare.

Possiamo perciò separare, dove è possibile, le dichiarazioni dalle definizioni e includere solo le prime fra i membri di un namespace, ponendo le seconde al di fuori. Nelle definizioni esterne però, i nomi devono essere qualificati, altrimenti non sarebbero riconoscibili.

La separazione fra dichiarazioni e definizioni è applicata soprattutto alle funzioni. Seguitando con lo stesso esempio:

namespace Stack

{

const int max_size = 100;

char v[max_size ];

int top = 0;

void push(char);

char pop( );

}

Page 134: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

void Stack::push(char c) {......}

char Stack::pop( ) {......}

Le funzioni push e pop sono soltanto dichiarate nella definizione del namespace Stack, e definite altrove con i nomi qualificati. Non è necessario, invece, qualificare i membri di Stack utilizzati all'interno delle funzioni, in quanto il compilatore, se incontra una variabile locale non definita nell'ambito della funzione, la va a cercare nel namespace a cui la funzione appartiene.

Quando viene chiamata una funzione membro di un namespace, con argomenti di cui almeno uno è di tipo astratto membro dello stesso namespace, la qualificazione del nome della funzione non è necessaria. Esempio:

#include <iostream.h> namespace A { struct AS {int k;}; char ff(AS); } char A::ff(AS m) { return (char)m.k; } int main() { A::AS m; m.k = 65; cout << ff(m) << '\n'; // non importa A:: davanti a ff }

Infatti il nome di una funzione è cercato, non solo nell'ambito della chiamata (o in ambiti superiori), ma anche in quelli dei namespace in cui sono definiti i tipi di ogni argomento. Non sono prefissati criteri di precedenza: in caso di ambiguità il compilatore dà un messaggio di errore. NOTA : si tratta di una funzionalità recente del C++. Infatti il compilatore gcc la accetta, mentre il Visual C++ pretende la qualificazione.

Namespace annidati

Abbiamo detto che i namespace possono essere definiti solo nell'ambito globale (cioè non si possono definire all'interno di altri blocchi, per esempio di funzioni o strutture). E' però possibile definire un namespace all'interno di un altro namespace (namespace "annidati").

Es.: void f( );

namespace A {

void g( );

namespace B {

Page 135: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

void h( );

}

}

la funzione f è dichiarata nel namespace globale; la funzione g è dichiarata nel namespace A; e infine la funzione h è dichiarata nel namespace B definito nel namespace A.

Per accedere (dall'esterno) a un membro del namespace B bisogna ripetere due volte l'operazione di risoluzione di visibilità.

Es.: void A::B::h( ) {......} (definizione esterna della funzione h)

Per i namespace "annidati" valgono le normali regole di visibilità e di qualificazione: all'interno della funzione h non occorre qualificare i membri di B (come sempre), ma neppure quelli di A, in quanto i nomi definiti in ambiti superiori sono ancora visibili negli ambiti sottostanti; viceversa, all'interno della funzione g bisogna qualificare i membri di B (perchè i nomi definiti in ambiti inferiori non solo visibili in quelli superiori), ma non quelli di A, per cui è sufficiente applicare la risoluzione di visibilità a un solo livello.

Es. void A::g( ) {.... B::h ( ) ....} ( funzione h chiamata dalla funzione g )

Infine, dall'interno della funzione globale f bisogna qualificare sia i membri di A (a un livello: A::) che quelli di B (a due livelli: A::B::) in quanto nessun nome definito nei due namespace è visibile nel namespace globale.

Namespace sinonimi

La scelta del nome di un namespace è importante: se è troppo breve, rischia il conflitto con i nomi di altri namespace (per esempio includendo librerie create da altri programmatori); se è molto lungo, può evitare il conflitto con altri nomi, ma diventa scomodo se lo si usa ripetutamente per qualificare esternamente i suoi membri.

Es.: namespace creato_appositamente_da_me_medesimo {... int x ...}

con un nome così lungo (e così "stupido") non c'è pericolo di conflitto, ma è scomodissimo utilizzare in altri ambiti il suo membro x:

creato_appositamente_da_me_medesimo::x = 20;

Page 136: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Entrambi gli inconvenienti possono essere superati, definendo, in un ambito ristretto (e quindi con scarso pericolo di conflitto), un sinonimo breve di un nome "vero" lungo (i sinonimi possono anche essere definiti localmente, a differrenza dei namespace). Per definire un sinonimo si usa la seguente sintassi (seguitando con l'esempio):

namespace STUP = creato_appositamente_da_me_medesimo;

da questo punto in poi (nello stesso ambito in cui è definito il sinonimo STUP) si può ogni volta qualificare un membro del suo namespace utilizzando come left-operand il sinonimo:

STUP::x = 20;

I namespace sinonimi sono utili non solo per abbreviare nomi lunghi, ma anche per localizzare in un unico punto una modifica che altrimenti si dovrebbe ripetere in molti punti del programma (come nelle definizioni con const, #define e typedef). Per esempio, se il nome di un namespace si riferisce alla versione di una libreria usata dal programma, e questa potrebbe essere successivamente aggiornata, è molto conveniente creare un sinonimo da utilizzare nel programma al posto del nome della libreria: in questo modo, in caso di cambiamento di versione della libreria, si può modificare solo l'istruzione di definizione del sinonimo, assegnando allo stesso sinonimo il nuovo nome (altrimenti si dovrebbero modificare tutte le istruzioni che utilizzano quel nome nel programma).

Namespace anonimi

Nella definizione di un namespace, il nome non è obbligatorio. Se lo si omette, si crea un namespace anonimo.

Es.: namespace { int a = 10; int b; void c(double); }

I membri a, b e c del namespace anonimo sono visibili in tutto il file (file scope), non devono essere qualificati, ma non possono essere utilizzati in files differenti da quello in cui sono stati definiti (cioè, diversamente dagli oggetti globali, non possono essere collegati dall'esterno tramite lo specificatore extern).

In altre parole i membri di un namespace anonimo hanno le stesse identiche proprietà degli oggetti globali definiti con lo specificatore static. Per questo motivo, e allo scopo di ridurre le ambiguità nel significato delle parole-chiave del linguaggio, il comitato per la definizione dello standard (pur mantenendo, per compatibiltà con i "vecchi" programmi, il doppio significato di static), suggerisce

Page 137: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

di usare sempre i namespace anonimi per definire oggetti con file scope, e di mantenere l'uso di static esclusivamente per l'allocazione permanente (cioè con lifetime illimitato) di oggetti con visibilità locale (block scope).

Estendibilità della definizione di un namespace

Al contrario delle strutture, i namespace sono costrutti "aperti", nel senso che possono essere definiti più volte con lo stesso nome. Non si tratta però di diverse definizioni, bensì di estensioni della definizione iniziale. E quindi, pur essendovi blocchi diversi di un namespace con lo stesso nome, l'ambito definito dal namespace con quel nome resta unico.

Ne consegue che, per la ODR (one definition rule), i membri complessivamente definiti in un namespace (anche se frammentato in più blocchi) devono essere tutti diversi (cioè nelle estensioni è consentito aggiungere nuovi membri ma non ridefinire membri definiti precedentemente).

Es.: namespace A {

int x ;

}

namespace B {

int x ; OK: A::x e B::x sono definiti in due ambiti diversi

}

void f( ) {... A::y= ...} errore: y non ancora dichiarato in A

namespace A { OK: estensione del namespace A

int x ; errore: x è già definito nell'ambito di A

int y ; OK: y è un nuovo membro di A

}

void f( ) {... A::y= ...} adesso è OK

La possibilità di suddividere un namespace in blocchi separati consente, da un lato, di racchiudere grandi frammenti di programma in un unico namespace e, dall'altro, di presentare diverse interfacce a diverse categorie di utenti, mostrandone parti differenti.

Page 138: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Parola-chiave using

Quando un membro di un namespace viene usato ripetutamente fuori dal suo ambito, esiste la possibilità, aggiungendo una sola istruzione, di evitare il fastidio di qualificarlo ogni volta.

La parola-chiave using serve a questo scopo e può essere usata in due modi diversi:

• con un'istruzione di "using-declaration" si rende accessibile un membro di un namespace nello stesso ambito in cui è inserita l'istruzione. Tornando all'esempio del namespace Stack, l'istruzione: using Stack::top; permette di accedere al membro top di Stack, senza bisogno di qualificarlo, in tutte le istruzioni che seguono all'interno dello stesso ambito. In sostanza, con la using-declaration, si introduce il sinonimo locale top di Stack::top . In una using-declaration va specificato solo il nome del membro interessato, per cui, in particolare, se il membro è una funzione, l'elenco degli argomenti non va indicato (e neppure le parentesi tonde); nel caso di più funzioni in overload con lo stesso nome, la using-declaration le rende accessibili tutte.

• con un'istruzione di "using-directive" si rendono accessibili tutti i membri di un namespace nello stesso ambito in cui è inserita l'istruzione. Tornando all'esempio, l'istruzione: using namespace Stack; permette di accedere a tutti i membri di Stack, senza bisogno di qualificarli, in tutte le istruzioni che seguono all'interno dello stesso ambito.

Entrambe le istruzioni using possono essere inserite in qualunque ambito e in esso mettono a disposizione sinonimi che a loro volta seguono le normali regole di visibilità. In particolare:

• se le istruzioni using sono inserite in un blocco (di una funzione, struttura o altro), i sinonimi hanno block scope;

• se sono inserite nel namespace globale o in un namespace anonimo, i sinonimi hanno file scope;

• infine, se sono inserite in un altro namespace, i sinonimi hanno lo stesso scope del namespace che li ospita.

Spesso la using-directive a livello globale è usata come "strumento di transizione", cioè per trasportare in C++ vecchio codice scritto in C. Esistono infatti centinaia di librerie scritte in C, con centinaia di migliaia di righe di codice, che fanno un uso massiccio ed estensivo di nomi globali. Molte di queste librerie sono ancora utili e costituiscono un "patrimonio" che non va disperso. D'altra parte, "affollare" così pesantemente il namespace globale non fa parte della

Page 139: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

"logica" del C++. Il problema è stato risolto racchiudendo le librerie in tanti namespace e facendo ricorso alle using-directive per renderle accessibili (quando serve). In questo modo si mantiene la compatibilità con i vecchi programmi, ma i nomi utilizzati dalle librerie non occupano il namespace globale e quindi non rischiano di creare conflitti in altri contesti.

Precedenze e conflitti fra i nomi

Abbiamo visto che le istruzioni using forniscono la possibilità di evitare la qualificazione ripetuta dei nomi definiti in un namespace. D'altra parte, rendendo accessibili delle parti di programma che altrimenti sarebbero nascoste, indeboliscono il "data hiding" e aumentano la probabilità di conflitti fra nomi e di errori non sempre riconoscibili. Si tratta pertanto di operare di volta in volta la scelta più opportuna, bilanciando "comodità" e "sicurezza".

A questo scopo il C++ definisce delle regole precise che, in taluni casi, vietano i conflitti di nomi (nel senso che all'occorrenza il compilatore segnala errore) e, in altri, stabiliscono delle precedenze fra nomi uguali (cioè il nome con precedenza superiore "nasconde" quello con precedenza inferiore). Tali regole sono diverse se si usa una using-declaration o una using-directive :

• una using-declaration aggiunge, nello scope in cui è inserita, un nuovo nome, che si comporta esattamente come tutti gli altri nomi (il fatto che sia sinonimo di un membro di un namespace è del tutto ininfluente), e pertanto:

o entra il conflitto con un nome uguale definito nello stesso ambito; o nasconde i nomi uguali (non qualificati) definiti in ambiti superiori.

• una using-directive mette a disposizione, nello scope in cui è inserita, tutti i nomi definiti in un namespace; si sottolinea il fatto che li "mette a disposizione" ma non li aggiunge immediatamente, e pertanto questa istruzione non genera conflitti di per sè. Al momento dell'utilizzo, ogni nome del namespace interessato si comporta nel modo seguente:

o è nascosto da un nome uguale definito nello stesso ambito o in ambiti superiori, esclusi il namespace globale e il namespace anonimo;

o entra il conflitto con un nome uguale (non qualificato) definito nel namespace globale o nel namespace anonimo.

Queste regole (un po' "atipiche") sui nomi resi accessibili dalla using-directive, sono state concepite al duplice scopo di permettere l'inclusione di grandi librerie con molti nomi globali senza segnalare i potenziali conflitti dei nomi che non vengono di fatto utilizzati, e, all'opposto, di non incoraggiare l'utente "pigro" che continua a definire nomi globali piuttosto che fare ricorso ai namespace.

Page 140: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• nel caso di concorrenza fra using-declaration e using-directive, le prime prevalgono sulle seconde e risolvono anche i potenziali conflitti

Es.:

namespace A { ... int x ; ... }

namespace B { ... int x ; ... }

A e B hanno un membro con lo stesso nome

using namespace A;

using namespace B; Achtung ! potenziale conflitto ..........

using A::x; .......... risolto a favore di A !

Collegamento fra namespace definiti in files diversi

Finora abbiamo trattato i namespace intendendo che fossero sempre definiti nello stesso file. Ci chiediamo ora in che modo è possibile il collegamento fra namespace di file diversi. Prima, però, è opportuno ricordare la differenza che intercorre fra file sorgente e translation unit:

• un file sorgente contiene le istruzioni del programma create dal programmatore;

• una translation unit è lo stesso file visto dal compilatore, dopo che il preprocessore ha incluso gli header-files ed eseguito le altre eventuali direttive (#define, direttive condizionali ecc...).

Due namespace con lo stesso nome appartenenti a due diverse translation units non sono in conflitto, ma sono da considerarsi come facenti parte dello stesso unico namespace (per la proprietà di estendibilità dei namespace). Il conflitto, semmai, può sorgere fra i nomi dei membri del namespace, se viene violata la ODR. D'altra parte ogni translation unit viene compilata separatamente e quindi ogni nome utilizzato in una translation unit deve essere, nella stessa, anche dichiarato. Ne consegue che i membri di uno stesso namespace che vengono utilizzati in entrambe le translation units, devono essere, in una delle due, definiti, e nell'altra dichiarati senza essere definiti (questo discorso vale per gli oggetti e le funzioni non inline, mentre le funzioni inline, i tipi astratti e altre entità del linguaggio che vederemo, come i template, possono anche essere ridefiniti, purchè gli elementi lessicali di ogni definizione siano identici).

Diverso è l'approccio, se si considerano i file sorgente: ogni file (cioè ogni modulo del programma) dovrebbe essere progettato in modo da non contenere duplicazioni e da localizzare questo problema soltanto nelle eventuali interfacce incluse da più moduli. Queste interfacce dovrebbero contenere solo dichiarazioni o definizioni "ripetibili".

Page 141: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Quindi il "trucco" consiste sostanzialmente nel progettare al meglio le interfacce comuni: una "buona" interfaccia dovrebbe essere tale da minimizzare le dipendenze fra le varie parti del programma, in quanto interfacce con dipendenze minime conducono a sistemi più facili da comprendere, con dettagli implementativi invisibili (data-hiding), più facili da modificare e più veloci da compilare.

Riprendiamo a questo proposito il nostro esempio iniziale del namespace Stack e mettiamoci "nei panni" sia del progettista che dell'utente.

• Il progettista deve individuare con quale strumenti rappresentare la pila (per esempio con un array, ma ci potrebbero essere anche altre soluzioni), quali siano le informazioni da memorizzare e mantenere (l'indice corrispondente all'ultimo dato inserito e la massima dimensione di accrescimento della pila), quali algoritmi applicare per le operazioni di inserimento ed estrazione dei dati, e infine come dare all'utente la possibilità di operare.

• L'utente ha bisogno di conoscere solo due cose: il nome della funzione per inserire un dato e il nome della funzione per estrarlo. E' quindi opportuno che acceda esclusivamente a tali informazioni (le dichiarazioni delle due funzioni), che costituiranno l'interfaccia comune fra il file sorgente del progettista e quello dell'utente.

Si deduce pertanto che il progettista dovrà spezzare la definizione del namespace Stack in due (per fortuna ciò è possibile!): nella prima parte metterà solo le dichiarazioni delle funzioni push e pop; nella seconda tutto il resto. Creerà poi due files separati: nel primo (l'interfaccia comune) metterà soltanto la prima definizione del namespace Stack , nel secondo metterà l'estensione di Stack e, esternamente al namespace, le definizioni delle due funzioni. A sua volta l'utente non dovrà fare altro che inserire nel suo file sorgente la direttiva di inclusione dell'interfaccia comune. Così, qualsiasi modifica o miglioramento venga fatto al codice di implementazione dello Stack, i programmi degli utenti non ne verranno minimamente influenzati (al massimo dovrano essere ri-linkati).

Page 142: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Eccezioni

Segnalazione e gestione degli errori

Il termine eccezione (dall'inglese exception) deriva dall'ottimistica assunzione che nell'esecuzione di un programma gli errori costituiscano una "circostanza eccezionale". Anche condividendo tale ottimismo, il problema di come individuare gli errori e di come gestirli una volta individuati deve essere sempre affrontato con grande cura nella progettazione di un programma.

Anche in un programma "perfetto" gli errori in fase di esecuzione possono sempre capitare, perchè sono commessi in larga parte da operatori "umani" (quelli che usano il programma), e quindi è lo stesso programma che deve essere in grado di prevederli e di eseguire le azioni di ripristino, quando è possibile.

Quando un programma, specie se di grosse dimensioni, è composto da moduli separati, e soprattutto se i moduli provengono da librerie sviluppate da altri programmatori, anche la gestione degli errori deve essere tale da minimizzare le dipendenze fra un modulo e l'altro. In generale, quando un modulo verifica una condizione di errore, deve limitarsi a segnalare tale condizione, in quanto l'azione di ripristino dipende più spesso dal modulo che ha invocato l'operazione piuttosto che da quello che ha riscontrato l'errore mentre cercava di eseguirla. Separando i due momenti (la rilevazione dell'errore e la sua gestione) si mantiene il massimo di indipendenza fra i moduli: l'interfaccia comune conterrà gli strumenti necessari, attivati dal modulo "rilevatore" e utilizzati dal modulo "gestore"

Il C++ mette a disposizione un meccanismo semplice ma molto efficace di rilevazione e gestione degli errori: l'idea base è che, quando una funzione rileva un errore che non è in grado di affrontare direttamente, l'esecuzione della funzione termina, ma il controllo non ritorna al punto in cui la funzione è stata chiamata, bensì in un altro punto del programma, dove viene eseguita la procedura di gestione dell'errore. In termini tecnici, la funzione che rileva l'errore "solleva" o "lancia" (throw) un'eccezione ("marcandola" in qualche modo, come vedremo) e termina: l'area stack è ripercorsa all'indietro e cancellata (stack unwinding) a vari livelli finchè il flusso del programma non raggiunge il punto (se esiste) in cui l'eccezione può essere riconosciuta e "catturata" (catch); in questo punto viene eseguita la procedura di gestione dell'errore; se il punto non esiste l'intero programma "abortisce".

Il costrutto try

La parola-chiave try introduce un blocco di istruzioni.

Es. : try

Page 143: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

{

m = c / b;

double f = 10.7;

res = fun(f ,m+n);

}

Le istruzioni contenute in un blocco try sono "sotto controllo": in esecuzione, qualcuna di esse potrebbe generare un errore. Nell'esempio, la funzione fun potrebbe chiamare un'altra funzione e questa un'altra ancora ecc... , generando una serie di pacchetti che si accumula sullo stack. L'area dello stack che va da un un blocco try in su è detta: exception stack frame e costituisce l'insieme di tutte le istruzioni controllate.

L'istruzione throw

Dal punto di visto sintattico, l'istruzione throw è identica all'istruzione return di una funzione (e si comporta all'incirca nello stesso modo):

throw espressione;

Un'istruzione throw può essere collocata soltanto in un exception stack frame e segnala il punto in cui si è ricontrato un errore (o, come si dice, è "sollevata" un'eccezione). Il valore calcolato dell'espressione, detto: "valore dell'eccezione" (il cui tipo è detto: "tipo dell'eccezione"), ripercorre "all'indietro" l'exception stack frame (cancellandolo): se a un certo punto del suo "cammino" l'eccezione viene "catturata" (vedremo come), l'errore può essere gestito, altrimenti il programma abortisce (ed è quello che succede in particolare se l'istruzione throw non è inserita all'interno di un exception stack frame).

In pratica throw si comporta come un return "multilivello". Il valore dell'eccezione viene di solito utilizzato per la descrizione dell'errore commesso (non è però obbligatorio utilizzarlo). Il suo tipo è invece di importanza fondamentale in quanto (come vedremo) costituisce la "marca" di riconoscimento dell'eccezione e ne permette la "cattura".

Il gestore delle eccezioni: costrutto catch

La parola-chiave catch introduce un blocco di istruzioni che ha lo stesso formato sintattico della definizione di una funzione, con un solo argomento e senza valore di ritorno.

Page 144: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

catch (tipo argomento ) { .......... blocco di istruzioni .............. }

Fisicamente un blocco catch deve seguire immediatamente un blocco try. Dal punto di vista della successione logica delle operazioni, invece, un blocco catch costituisce il punto terminale di ritorno di un exception stack frame: questo viene costruito (verso l'alto), a partire da un blocco try, fino a un'istruzione throw, da cui l'eccezione "sollevata" ridiscende (stack unwinding) fino al blocco catch corrispondente al blocco try di partenza (oppure passa direttamente dal blocco try al blocco catch se l'istruzione throw si trova già nel blocco try di partenza; in questo caso l'istruzione throw non si comporta come un return, ma piuttosto come un goto). A questo punto l'eccezione può essere "catturata" o meno: se è catturata, vengono eseguite le istruzioni del blocco catch (detto "gestore dell'eccezione") e poi il flusso del programma prosegue normalmente; se invece l'eccezione non è catturata, il programma abortisce. Se infine non vengono sollevate eccezioni, cioè l'exception stack frame non incontra istruzioni throw, il flusso del programma ridiscende per vie normali tornando al blocco try da cui era partito, eseguito il quale prosegue "saltando" il successivo blocco catch.

Un'eccezione viene "catturata" se il suo tipo coincide esattamente con il tipo dell'argomento di catch. Non sono ammesse conversioni di tipo, neppure implicite (neanche se i due tipi sono uguali in pratica, come int e long in una machina a 32 bit). Verificata la coincidenza dei tipi, il valore dell'eccezione viene trasferito nell'argomento di catch (come se l'istruzione throw "chiamasse" la "funzione" catch); il trasferimento avviene normalmente per copia (by value), a meno che l'argomento di catch non sia un riferimento, nel qual caso il passaggio è by reference, che però ha senso solo se l'espressione di throw è un l-value e se "sopravvive" alla distruzione dello stack (cioè è un oggetto globale, o è definito in un namespace, oppure è locale ma dichiarato static). E' possibile anche che l'argomento di catch sia dichiarato const, nel qual caso valgono le stesse regole e limitazioni che ci sono per il passaggio degli argomenti delle funzioni (vedere il capitolo: Puntatori e costanti - Passaggio degli argomenti trasmessi by value e by reference).

Nel costrutto catch la specifica dell'argomento non è obbligatoria (lo è solo se l'argomento viene usato nel blocco di istruzioni). Il tipo, invece, deve essere sempre specificato, perchè serve per la "cattura" dell'eccezione. A questo proposito è utile aggiungere che la scelta del tipo dell'eccezione è libera, ma, per una migliore leggibilità del programma e per evitare confusioni con le altre eccezioni (in special modo con quelle generate dalle librerie del sistema, fuori dal nostro controllo), è vivamente consigliata la creazione di tipi "ad hoc", preferibilmente uno per ogni possibile eccezione e con attinenza mnemonica fra il nome del tipo e il significato dell'errore a cui è associato: quindi, evitare l'uso di tipi nativi (anche se non sarebbe vietato), ma usare solo tipi astratti (per esempio strutture con nomi "ad hoc").

E' bene che il trattamento delle eccezioni venga usato quando la rilevazione e la gestione di un errore devono avvenire in parti diverse del programma. Quando invece un errore può essere trattato localmente è sufficiente servirsi dei normali controlli del linguaggio (come i costrutti if o switch).

NOTA: per completezza precisiamo che un'eccezione può essere "catturata" anche quando il suo tipo è di una classe "derivata" da quella a cui appartiene

Page 145: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

l'argomento di catch, ma di questo parleremo quando tratteremo delle classi e dell'eredità.

Riconoscimento di un'eccezione fra diverse alternative

Finora abbiamo detto che a un blocco try deve sempre seguire blocco catch. In realtà i blocchi catch possono anche essere più di uno, disposti consecutivamente e con tipi di argomento diversi.

Quando un'eccezione, discendendo lungo l'exception stack frame, incontra una serie di blocchi catch, il suo tipo viene confrontato a uno a uno con quelli dei blocchi catch e, se si verifica una coincidenza, l'eccezione viene "catturata" e vengono eseguite le istruzioni del blocco catch in cui la coincidenza è stata trovata. Dopodichè il flusso del programma "salta" gli eventuali blocchi catch successivi e riprende normalmente dalla prima istruzione dopo l'ultimo blocco catch del gruppo. Il programma abortisce se nessun blocco catch cattura l'eccezione. Se invece non vengono sollevate eccezioni, il flusso del programma, eseguite le istruzioni del blocco try, "salta" tutti i blocchi catch del gruppo.

Se un costrutto catch, al posto del tipo e dell'argomento, presenta "tre puntini" (ellipsis), significa che è in grado di catturare qualsiasi eccezione, indipendentemente dal suo tipo.

L'ordine in cui appaiono i diversi blocchi catch associati a un blocco try è importante: infatti il confronto con il tipo dell'eccezione da catturare viene sempre fatto a partire dal primo blocco catch che segue il blocco try e procede nello stesso ordine: da ciò consegue che l'eventuale catch con ellipsis deve essere sempre l'ultimo blocco del gruppo. L'esempio che segue schematizza la situazione di un blocco try seguito da tre blocchi catch, di cui l'ultimo con ellipsis.

try { blocco try } se non solleva eccezioni, esegue blocco try e salta a istruzione

catch (tipo1) { blocco1}

altrimenti, se il tipo dell'eccezione coincide con tipo1, cattura l'eccezione, esegue blocco1 e salta a istruzione

catch (tipo2) { blocco2}

altrimenti, se il tipo dell'eccezione coincide con tipo2, cattura l'eccezione, esegue blocco2 e salta a istruzione

catch (...) {blocco3} altrimenti, cattura comunque l'eccezione ed esegue blocco3

istruzione ......... riprende il flusso normale del programma

Page 146: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Blocchi innestati

Una sequenza di blocchi try....catch può essere a sua volta "innestata" in un blocco try o in un blocco catch (o in una funzione chiamata, direttamente o indirettamente, da un blocco try o da un blocco catch).

Se la nuova sequenza è interna a un blocco try (cioè nella fase "ascendente" dell'exception stack frame) e successivamente viene sollevata un'eccezione, il controllo per la cattura dell'eccezione viene fatto anzitutto sui blocchi catch interni (che sono incontrati prima nella fase di stack unwinding): se l'eccezione è catturata, il problema è risolto e anche tutti i blocchi catch associati al blocco try esterno vengono "saltati"; se invece nessun blocco interno cattura l'eccezione, il programma non abortisce, ma il controllo passa ai blocchi catch associati al blocco try esterno.

Se la nuova sequenza è interna a un blocco catch (cioè se l'eccezione è già stata catturata), si crea un nuovo exception stack frame a partire da quel punto: pertanto, se è sollevata una nuova eccezione e questa viene catturata, il programma esegue il blocco catch interno che ha catturato la nuova eccezione e poi completa l'esecuzione del blocco catch esterno che ha catturato l'eccezione precedente; se invece la nuova eccezione non è catturata, il programma abortisce.

Anche l'istruzione throw può comparire in un blocco catch o in una funzione chiamata, direttamente o indirettamente, da un blocco catch (la sua collocazione "normale" sarebbe invece in un blocco try o in una funzione chiamata, direttamente o indirettamente, da un blocco try). In questo caso si dice che l'eccezione è "ri-sollevata", ma non può essere gestita allo stesso livello del blocco catch da cui parte, in quanto un blocco catch non può essere "chiamato" ricursivamente. Pertanto un'eccezione sollevata dall'interno di un blocco catch non fa abortire il programma solo se lo stesso blocco catch fa parte di una sequenza innestata in un blocco try esterno (e saranno i corrispondenti blocchi catch a occuparsi della sua cattura). Un caso particolare di eccezione "ri-sollevata" si ha quando l'istruzione throw appare da sola, senza essere seguita da un'espressione; in questo caso il valore e il tipo dell'eccezione sono gli stessi del blocco catch in cui l'istruzione throw è inserita (cioè il programma "ri-solleva" la stessa eccezione che sta gestendo).

Eccezioni che non sono errori

Page 147: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Come abbiamo detto all'inizio, il concetto di eccezione è di norma legato a quello di errore. Tuttavia il meccanismo di gestione delle eccezioni altro non è che un particolare algoritmo di "controllo", meno strutturato e meno efficiente rispetto alle strutture di controllo locali (quali if, switch, for ecc...), che però permette operazioni, come i return "multilivello", che con le strutture tradizionali sarebbero più difficili da ottenere o porterebbero a un codice non in grado di mantenere un adeguato livello di indipendenza fra i diversi moduli del programma.

Quindi la convenienza o meno dell'utilizzo delle eccezioni non si basa tanto sulla distinzione fra errori o altre situazioni, quanto piuttosto sul fatto che le due operazioni di "controllo" e "azione conseguente" siano localizzate insieme (nel qual caso conviene usare le strutture tradizionali), oppure siano separate in aree diverse dello stack (e allora è preferibile usare le eccezioni).

Per esempio, l'utilizzo delle eccezioni come strutture di controllo potrebbe essere una tecnica elegante per terminare funzioni di ricerca, soprattutto se la ricerca avviene attraverso chiamate ricorsive, che "impilano" un numero imprecisato di pacchetti sullo stack.

Altre "correnti di pensiero", invece, suggersicono di mantenere strettamente correlato il concetto di eccezione con quello di errore, per evitare la generazione di codice ambiguo e poco comprensibile (e quindi meno portabile e, in sostanza, "più costoso").

Page 148: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Classi e data hiding

Analogia fra classi e strutture

In C++ le classi sono identiche alle strutture, con l'unica differenza formale di essere introdotte dalla parola-chiave class anziché struct.

In realtà la principale differenza fra classi e strutture è di natura "storica": le strutture sono nate in C, con alcune proprietà (descritte nel capitolo: "Tipi definiti dall'utente"); le classi sono nate in C++, con le stesse proprietà delle strutture e molte altre proprietà in più. Successivamente si è pensato di attribuire alle strutture le stesse proprietà delle classi. Pertanto le strutture C++ sono molto diverse dalle strutture C, essendo invece identiche alle classi (a parte una sola differenza sostanziale, di cui parleremo fra poco). Per questo motivo, d'ora in poi tratteremo solo di classi, sottintendendo che, in C++, quanto detto vale anche per le strutture.

Esempio di definizione di una classe:

class point

{ double x; double y; double z; } ;

ogni istanza della classe point rappresenta un punto nello spazio e i suoi membri sono le coordinate cartesiane del punto.

Specificatori di accesso

In C++, nel blocco di definizione di una classe, é possibile utilizzare dei nuovi specificatori, detti specificatori di accesso, che sono i seguenti:

private: protected: public:

gli specificatori private: e protected: hanno significato analogo: la loro differenza riguarda esclusivamente le classi ereditate, di cui parleremo più avanti; per il momento, useremo soltanto lo specificatore private: .

Questi specificatori possono essere inseriti più volte all'interno della definizione di una classe: private: fa sì che tutti i membri dichiarati da quel punto in poi (fino al termine della definizione della classe o fino a un nuovo specificatore) acquisiscano la connotazione di membri privati (in che senso? ... vedremo più

Page 149: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

avanti); public: fa sì che tutti i membri successivamente dichiarati siano pubblici.

L'unica differenza sostanziale fra classe e struttura consiste nel fatto che i membri di una struttura sono, di default, pubblici, mentre quelli di una classe sono, di default, privati.

Data hiding

Il "data hiding" (occultamento dei dati) consiste nel rendere certe aree del programma invisibili ad altre aree del programma. I suoi vantaggi sono evidenti: favorisce la programmazione modulare, rende più agevoli le operazioni di manutenzione del software e, in ultima analisi, permette un modo di programmare più efficiente.

Introducendo i namespace, abbiamo detto che il data hiding si realizza sostanzialmente racchiudendo i nomi all'interno di ambiti di visibilità e definendo dei canali di comunicazione, ben circoscritti e controllati, come uniche vie di accesso ai nomi di ambiti diversi. Se tutto quello che serve è la protezione dei nomi degli oggetti, i namespace sono sufficienti a questo scopo.

D'altra parte, questo livello di protezione, limitato ai soli oggetti, può rivelarsi inadeguato, se gli oggetti sono istanze di strutture o classi, cioè possiedono membri. E' sorto quindi il problema di proteggere, non solo un oggetto, ma anche i suoi membri, facendo in modo che, anche quando l'oggetto é visibile, l'accesso ai suoi membri sia rigorosamente controllato.

Il C++ ha realizzato questo obiettivo, estendendo il data hiding anche ai membri degli oggetti. L'istanza di una classe é regolarmente visibile all'interno del proprio ambito, ma i suoi membri privati non lo sono: non é possibile, da programma, accedere direttamente ai membri privati di una classe.

Es.: class Persona {

int soldi ;

public:

char telefono[20] ;

char indirizzo[30] ;

} ;

Persona Giuseppe ; (istanza della classe Persona)

il programma può accedere a Giuseppe.telefono e Giuseppe.indirizzo, ma non a Giuseppe.soldi!

Page 150: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Funzioni membro

A questo punto, la domanda d'obbligo é: se i membri privati di una classe sono inaccessibili, a che cosa servono ?

In realtà i membri privati sono inaccessibili direttamente, ma possono essere raggiunti indirettamente, tramite le cosiddette funzioni-membro.

Infatti il C++ ammette che i membri di una classe possano essere costituiti non solo da dati, ma anche da funzioni. Queste funzioni possono essere, come ogni altro membro, pubbliche o private, ma, in ogni caso, possono accedere a qualunque altro membro della classe, anche ai membri privati. D'altra parte, mentre una funzione-membro privata può essere chiamata solo da un'altra funzione-membro, una funzione-membro pubblica può anche essere chiamata dall'esterno, e pertanto costituisce l'unico tramite fra il programma e i membri della classe.

Questo tipo di architettura del C++ costituisce la base fondamentale della programmazione a oggetti: ogni istanza di una classe è caratterizzata dalle sue proprietà (dati-membro) e dai suoi comportamenti (funzioni-membro), detti anche metodi della classe. Con proprietà e metodi, un oggetto diviene un'entità attiva e autosufficiente, che comunica con il programma in modo rigorosamente controllato. L'azione di chiamare dall'esterno una funzione-membro pubblica di una classe viene riferita con il termine: "inviare un messaggio a un oggetto", per evidenziare il fatto che il programma si limita a dire all'oggetto cosa vuole, ma in realtà é l'oggetto stesso ad eseguire l'operazione, tramite i suoi metodi e agendo sulle sue proprietà (si dice anche che le funzioni-membro sono incapsulate negli oggetti).

Nella definizione di una funzione-membro, gli altri membri della sua stessa classe vanno indicati esclusivamente con il loro nome (senza operatori . o ->). Il C++, ogni volta che incontra una variabile non dichiarata nella funzione, cerca, prima di segnalare l'errore, di identificare il suo nome con quello di un membro della classe (esattamente come accade per i membri di un namespace, utilizzati in una funzione membro dello stesso namespace).

I metodi possono essere inseriti nella definizione di una classe in due diversi modi: o come funzioni inline, cioè con il loro codice (ma la parola-chiave inline può essere omessa in quanto all'interno della definizione di una classe è di default), oppure con la sola dichiarazione separata dal codice, che viene scritto in altra parte del programma. Riprendendo l'esempio della classe point (che, per semplicità, riduciamo a due dimensioni):

Esempio del primo modo Esempio del secondo modo

Page 151: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

class point { class point {

double x; double x;

double y; double y;

public: public:

void set(double x0, double y0) void set(double, double ) ;

{ x=x0 ; y=y0 ; } } ;

} ;

Se la definizione della funzione-membro set non è inserita nell'ambito della definizione della classe point (secondo modo), il suo nome dovrà essere qualificato con il nome della classe (come vedremo fra poco).

Seguendo l'esempio, definiamo ora l'oggetto p come istanza della classe point: point p; il programma, che non può accedere alle proprietà private p.x e p.y, può però accedere a un metodo pubblico dello stesso oggetto, con l'istruzione: p.set(x0,y0) ; e quindi agire sull'oggetto nel solo modo che gli sia consentito.

Nel caso che una variabile venga definita come puntatore a una classe, valgono le stesse regole, con la differenza che bisogna usare (per le funzioni come per i dati) l'operatore -> Tornando all'esempio:

point * ptr = new point;

ptr->set(1.5, 0.9) ;

Risoluzione della visibilità

Se il codice di un metodo si trova all'esterno della definizione della classe a cui appartiene, bisogna "qualificare" il nome della funzione associandogli il nome classe, tramite l'operatore :: di risoluzione di visibilità. Seguitando nell'esempio precedente, la definizione esterna della funzione-membro set è:

void point::set(double x0, double y0)

{

x = x0 ;

y = y0 ;

Page 152: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

}

notiamo che questa regola è la stessa che abbiamo visto per i namespace; in realtà si tratta di una regola generale che si applica ogni volta che si deve accedere dall'esterno a un nome dichiarato in un certo ambito di visibilità, e lo stesso ambito di visibilità è identificato da un nome (come sono appunto sia i namespace che le classi).

La scelta se un metodo debba essere scritto in forma inline o meno è arbitraria: se è inline, l'esecuzione è più veloce, se non lo è, la definizione della classe appare in una forma più "leggibile". Per esempio, si potrebbero lasciare inline solo i metodi privati. E' anche possibile scrivere il codice esternamente alla definizione della classe, ma specificare esplicitamente che la funzione deve essere trattata come inline, con la seguente istruzione (riprendendo il solito esempio): inline void point::set(double x0, double y0) in ogni caso il compilatore separa automaticamente il codice se la funzione è troppo lunga.

Quando, nella definizione di una classe, si lasciano solo i prototipi dei metodi, si suole dire che viene creata un'intestazione di classe. La consuetudine prevalente dei programmatori in C++ è quella di creare librerie di classi, separando in due gruppi distinti, le intestazioni, distribuite in header-files, dal codice delle funzioni, compilate separatamente e distribuite in librerie in formato binario; infatti ai programmatori che utilizzano le classi non interessa sapere come sono fatte le funzioni di accesso, ma solo come usarle.

Funzioni-membro di sola lettura

Quando un metodo ha il solo compito di riportare informazioni su un oggetto, senza modificarne il contenuto, si può, per evitare errori, imporre tale condizione a priori, inserendo lo specificatore const dopo la lista degli argomenti della funzione (sia nella dichiarazione che nella definizione). Riprendendo l'esempio della classe point, aggiungiamo la funzione-membro get:

void point::get(double& x0, double& y0) const

{

x0 = x ;

y0 = y ;

}

la funzione-membro get non può modificare i membri della sua classe.

Page 153: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Classi membro

Una classe può anche essere definita all'interno di un'altra classe (oppure semplicemente dichiarata, e poi definita esternamente, nel qual caso però il suo nome deve essere qualificato con il nome della classe di appartenenza). Esempio di definizione di un metodo f di una classe B, definita all'interno di un'altra classe A: void A::B::f( ) {......}

Le classi definite all'interno delle altre classi sono dette: classi-membro o classi annidate. A parte i problemi inerenti all'ambito di visibilità e alla conseguente necessità di qualificare i loro nomi, queste classi si comportano esattamente come se fossero indipendenti. Se però sono collocate nella sezione privata della classe di appartenenza, possono essere istanziate solo dai metodi di detta classe. In sostanza, annidare una classe dentro un'altra classe permette di controllare la creazione dei suoi oggetti. L'accesso ai suoi membri, invece, non dipende dalla collocazione nella classe di appartenenza, ma solo da come sono dichiarati gli stessi membri al suo interno (cioè se pubblici o privati).

Polimorfismo

Per una programmazione efficiente, anche la scelta dei nomi delle funzioni ha la sua importanza. In particolare é utile che funzioni che svolgono la stessa azione abbiano lo stesso nome.

Il C++ consente questa possibilità: non solo i metodi di una classe possono agire su istanze diverse della stessa classe, ma sono anche ammessi metodi di classi diverse con lo stesso nome e gli stessi argomenti (non confondere con l'overload, che implica funzioni con lo stesso nome, ma con diverse liste di argomenti). Il C++ é in grado di riconoscere in esecuzione l'oggetto a cui il metodo é applicato e di selezionare ogni volta la funzione che gli compete. Questa attitudine del linguaggio di rispondere in modo diverso allo stesso messaggio si chiama "polimorfismo": risponde all'esigenza del C++ di modellarsi il più possibile sui concetti della vita reale e, in questo modo, rendere la programmazione più facile ed efficiente che in altri linguaggi. L'importanza del polimorfismo si comprenderà a pieno quando parleremo dell'eredità e delle funzioni virtuali.

Page 154: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Puntatore nascosto this

Ci potremmo chiedere, a questo punto, come fa il C++ ad attuare il polimorfismo: in programmi in formato eseguibile, i nomi degli oggetti e delle funzioni sono spariti, e sono rimasti solo indirizzi e istruzioni. In altre parole, come fa il programma a sapere, in esecuzione, su quale oggetto applicare una funzione?

In realtà il compilatore trasforma il codice sorgente, introducendo un puntatore costante "nascosto" (identificato dalla parola-chiave this) ogni volta che incontra la chiamata di una funzione-membro, e inserendo lo stesso puntatore come primo argomento nella funzione.

Chiariamo quanto detto con il seguente esempio, in cui ogg è un'istanza di una certa classe myclass e init() è una funzione-membro che utilizza un dato-membro x, entrambi della stessa classe myclass:

la definizione della funzione:

void myclass::init() {..... x = .....}

viene trasformata in: void init(myclass* const this) {..... this->x = .....}

e quindi .....

l'istruzione di chiamata della funzione: ogg.init( ) ;

viene tradotta in: init(&ogg) ;

Come si può notare dall'esempio, il puntatore nascosto this punta all'oggetto utilizzato dalla funzione. Il programmatore non é tenuto a conoscerlo, tuttavia, se vuole, può utilizzarlo in sola lettura (per esempio, in una funzione che deve restituire l'oggetto stesso, può usare l'istruzione return *this; ).

Nel caso che la funzione abbia degli argomenti, il puntatore this viene inserito per primo, e gli altri argomenti vengono spostati in avanti di una posizione.

Se la funzione è un metodo in sola lettura, il compilatore trasforma la sua definizione nel seguente modo (per esempio): int myclass::get( ) const ----------> int get(const myclass* const this) cioè this diventa un puntatore costante a costante. Questo fa sì che si possano definire due metodi identici, l'uno const e l'altro no, perchè in realtà i tipi del primo argomento sono diversi (e quindi l'overload è ammissibile).

Page 155: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

L'introduzione del puntatore this spiega l'apparente "stranezza" di istruzioni come ogg.init() (in realtà il codice della funzione in memoria é uno solo, cioè non ne esiste uno per ogni oggetto come per i dati-membro). Pertanto, le operazioni di accesso ai membri di un oggetto (con gli operatori . e ->), producono risultati diversi se il right-operand è un dato-membro o una funzione-membro:

• se il right-operand è un dato-membro (per esempio in un'operazione tipo ogg.x) il programma accede effettivamente alla memoria in cui è localizzato il membro x dell'oggetto ogg;

• se il right-operand è una funzione-membro (per esempio in ogg.init()), il programma esegue la funzione init (che è unica per tutta la classe), aggiungendo, come primo argomento della funzione, l'indirizzo dell'oggetto ogg.

Page 156: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Membri a livello di classe e accesso "friend"

Membri di tipo enumerato

Ricordiamo che un oggetto é di tipo enumerato se può assumere solo un definito e limitato insieme di valori interi, detti enumeratori.

Quando un tipo enumerato é definito all'interno di una classe, il tipo stesso é identificato esternamente dal suo nome preceduto dal nome della classe con il solito operatore :: di risoluzione di visibilità. La stessa regola vale quando si accede separatamente a un singolo enumeratore.

Chiariamo quanto detto con un esempio: definiamo una classe A, contenente la definizione del tipo enumerato festivo, con enumeratori Sabato e Domenica, e un membro giorno, di tipo festivo:

class A { public: enum festivo { Sabato, Domenica} giorno; };

vediamo ora vari modi di utilizzo nel programma:

1. A::festivo oggi = A::Sabato ; crea l'oggetto oggi, istanza del tipo enumerato festivo della classe A e lo inizializza con il valore dell'enumeratore Sabato;

2. A a; a.giorno = A::Sabato; ... oppure ... a.giorno = oggi; crea l'oggetto a, istanza della classe A e assegna il valore dell'enumeratore Sabato (oppure dell'oggetto oggi dell'esempio precedente) al membro giorno dell'oggetto a;

3. int domani = A::Domenica ; crea l'intero domani e lo inizializza con il valore dell'enumeratore Domenica (conversione di tipo implicita); questa istruzione é ammessa anche se non sono state create istanze di A o di festivo.

Da questi esempi si può notare, fra l'altro, che gli enumeratori sono identificati dalla classe e non dal tipo enumerato a cui appartengono: ne consegue che non possono esistere due enumeratori con lo stesso nome definiti nella stessa classe (anche se in due tipi enumerati diversi), mentre possono esistere due enumeratori con lo stesso nome definiti in due classi diverse.

Notiamo inoltre, esaminando la definizione della classe A, che:

• il tipo enumerato festivo é stato definito nella sezione pubblica: se così non fosse, sarebbe accessibile, come di regola, solo dai metodi di A;

• le specificazioni del tipo enumerato (festivo) e del membro di A di tipo festivo (giorno) sono opzionali: si possono omettere quando nel programma si usano solo gli enumeratori (come nell'esempio 3): class A { public: enum { Sabato, Domenica} ; }; questo è in realtà l'uso più frequente che si fa dei tipi enumerati all'interno di una classe: si definisce e si utilizza una serie di enumeratori, a livello di classe e non dei singoli oggetti

Page 157: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Dati-membro statici

In C++ la parola-chiave static ha un ulteriore significato: se un dato-membro di una classe è dichiarato static, la variabile è unica per tutta la classe, indipendentemente dal numero di istanze della classe. In altre parole, il C++ riserva un'area di memoria per ogni oggetto, salvo per i membri static, a ciascuno dei quali corrisponde un'unica locazione.

Pertanto i membri static appartengono alla classe e non ai singoli oggetti. Per individuarli si usa il nome della classe con l'operatore :: Esempio: se sm è un membro static di una classe A, la "variabile" sm è individuata dal costrutto: A::sm

I membri static non vengono creati tramite istanze della classe a cui appartengono, ma devono essere definiti direttamente, nello stesso ambito in cui è definita la classe. Nei rari casi, però, in cui la classe è definita in un block scope, i membri static non sono ammessi. Pertanto un membro static può essere definito solo in un namespace (se la classe è definita in quel namespace) o nel namespace globale. Di default un membro static è inizializzato con zero (in modo appropriato al tipo), come tutte le variabili statiche e globali. Esempio (supponiamo che la classe sia definita nel namespace globale):

class A {

..................

static int sm ;

..................

(sm è un membro static della classe A, che può essere privato o pubblico ; se è privato, è gestibile solo da un metodo della classe A, pur essendo una variabile statica)

};

int A::sm = 10 ; (a questo punto definisce e inizializza, con operazione nell'ambito globale, la variabile statica: A::sm)

int main ( ) ecc...

I membri static sono molto utili per gestire informazioni comuni a tutti gli oggetti di una classe (per esempio possono fornire i dati di default per l'inizializzazione degli oggetti), ma nel contempo, essendo essi stessi membri della classe, permettono di evitare il ricorso a variabili esterne, salvaguardando così il data hiding e l'indipendenza del codice di implementazione della classe dalle altre parti del programma.

NOTA: la principale differenza di significato dello specificatore static, se applicato a un membro o a un oggetto di una classe, consiste nel fatto che, nel primo caso, si crea una variabile nell'ambito di una classe (che deve appartenere a sua volta a un namespace o al namespace globale), nel

Page 158: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

secondo si crea una variabile locale nell'ambito di un blocco; in entrambi i casi il lifetime della variabile persiste fino alla fine del programma. Se invece static è applicato a un oggetto non locale (da evitare, meglio ricorrere al namespace anonimo), il suo significato è completamente diverso (visibilità limitata al file scope).

NOTA2: per i motivi anzidetti, l'attributo static di un membro di una classe deve essere specificato soltanto nella dichiarazione e non nella definizione, perchè in quest'ultima assumerebbe il significato di limitare la sua visibilità al file scope.

Funzioni-membro statiche

Anche le funzioni-membro di una classe possono essere dichiarate static.

Es.:

int A::conta( ) { ..... } (definizione)

class A { ..... static int conta( ) ; (prototipo) ..... }; Nel prog. chiamante:

int n = A::conta( );

come si può notare dall'esempio, nella chiamata di una funzione-membro static, bisogna qualificare il suo nome con quello della classe di appartenenza. Notare inoltre che, nella definizione della funzione, lo specificatore static non va messo (per lo stesso motivo per cui non va messo davanti alla definizione di un dato-membro static).

Una funzione-membro static (che, come tutti gli altri membri, può essere privata o pubblica), accede ai membri della classe ma non è collegata a un oggetto in particolare e quindi non ha il puntatore nascosto this. Ne consegue che, se deve operare su oggetti, questi devono essere trasmessi esplicitamente come argomenti.

Normalmente i metodi static vengono usati per trattare dati-membro static o, in generale, quando non si pone la necessità di operare su un singolo oggetto della classe (cioè quando la presenza del puntatore nascosto this sarebbe un sovraccarico inutile). Viceversa, quando un metodo deve operare direttamente su un oggetto (uno e uno solo alla volta), è più conveniente che sia incapsulato nell'oggetto stesso e quindi non venga dichiarato static.

Funzioni friend

Page 159: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Una normale dichiarazione di un metodo specifica tre cose logicamente distinte:

1. la funzione può accedere ai membri privati della classe; 2. la funzione è nell' ambito di visibilità della classe; 3. la funzione è incapsulata negli oggetti (possiede il puntatore this).

Abbiamo visto che, dichiarando un metodo con lo specificatore static, è possibile fornire alla funzione le prime due proprietà, ma non la terza. Se invece dichiariamo una funzione con lo specificatore friend, è possibile fornirle solo la prima proprietà.

Una funzione si dice "friend" di una classe, se è definita in un ambito diverso da quello della classe, ma può accedere ai suoi membri privati. Per ottenere ciò, bisogna inserire il prototipo della funzione nella definizione della classe (non importa se nella sezione privata o pubblica), facendo precedere lo specificatore friend.

Es.: DEFINIZIONE CLASSE DEFINIZIONE FUNZIONE

class A { void amica(A ogg, .....)

int mp ; .......... {

friend void amica(A, .....) ; ........ ogg.mp ........

........ }; }

la funzione amica, che non è un metodo della classe A (nell'esempio è definita nel namespace globale), è tuttavia dichiarata con lo specificatore friend nella definizione della classe A, e quindi può accedere al suo membri privati (nell'esempio, a mp). Notare che la funzione, essendo priva del puntatore this (come i metodi static), può operare sugli oggetti della classe solo se gli oggetti interessati le sono trasmessi come argomenti.

Se una stessa funzione è friend di due o più classi, il suo prototipo preceduto da friend va inserito nelle definizioni di tutte le classi interessate. Sorge allora un problema, come si può vedere dall'esempio seguente:

class A {...friend int fun(A,B, .....);...}; <---- a questo punto C++ non sa ancora che B è una classe

class B {...friend int fun(A,B, .....);...};

Ci sono due possibili soluzioni per far sapere al sistema che B è una classe: o si pone in testa al gruppo di istruzioni la dichiarazione anticipata: class B; oppure si inserisce, nel prototipo che potrebbe generare errore, la parola-chiave class friend int fun(A,class B, .....);

Le funzioni friend sono preferibili ai metodi static proprio quando devono accedere a più classi e quindi non è conveniente farli appartenere a una classe

Page 160: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

piuttosto che a un'altra. In ogni caso, per favorire la programmazione modulare, è consigliabile aggregare in uno stesso ambito (per esempio in un namespace) classi e funzioni friend collegate.

Classi friend

Quando tutte le funzioni-membro di una classe B sono friend di una classe A, è possibile, anziché dichiarare ciascuna funzione individualmente, inserire una sola dichiarazione in A, indicante che l'intera classe B è friend:

class A {..........friend class B;..........};

L'uso di funzioni e classi friend permette al C++ di aggirare il data hiding ogni volta che classi diverse devono interagire strettamente o condividere gli stessi dati, pur restando distinte.

C'è da dire infine che le relazioni di tipo friend non sono simmetriche (se A è friend di B non è detto che B sia friend di A), né transitive (se A è friend di B e B è friend di C, non è detto che A sia friend di C). In sostanza ogni relazione deve essere esplicitamente dichiarata.

Page 161: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Costruttori e distruttori degli oggetti

Costruzione e distruzione di un oggetto

Abbiamo detto più volte che quando un oggetto, istanza di un tipo nativo o astratto, viene creato, si dice che quell'oggetto è costruito. Analogamente, quando l'oggetto cessa di esistere, si dice che quell'oggetto è distrutto.

Vediamo le varie circostanze in cui un oggetto può essere costruito o distrutto:

1. Un oggetto automatico (cioè locale non statico) viene costruito ogni volta che la sua definizione viene incontrata durante l'esecuzione del programma, e distrutto ogni volta che il programma esce dall'ambito in cui tale definizione si trova.

2. Un oggetto locale statico viene costruito la prima volta che la sua definizione viene incontrata durante l'esecuzione del programma, e distrutto una sola volta, quando il programma termina.

3. Un oggetto allocato nella memoria dinamica (area heap ) viene costruito mediante l'operatore new e distrutto mediante l'operatore delete.

4. Un oggetto, membro non statico di una classe, viene costruito ogni volta che (o meglio, immediatamente prima che) viene costruito un oggetto della classe di cui è membro, e distrutto ogni volta che (o meglio, immediatamente dopo che) lo stesso oggetto viene distrutto.

5. Un oggetto, elemento di un array, viene costruito o distrutto ogni volta che l'array di cui fa parte viene costruito o distrutto.

6. Un oggetto globale, un oggetto di un namespace o un membro statico di una classe, viene costruito una sola volta, alla "partenza" del programma e distrutto quando il programma termina.

7. Infine, un oggetto temporaneo viene costruito per memorizzare risultati parziali durante la valutazione di un'espressione, e distrutto alla fine dell'espressione completa in cui compare.

Come si può notare, la costruzione o distruzione di un oggetto può avvenire in momenti diversi, in base alla categoria dell'oggetto che si sta considerando. In ogni caso, sia durante la costruzione che durante la distruzione, potrebbero rendersi necessarie delle operazioni specifiche. Per esempio, se un membro di una classe è un puntatore, potrebbe essere necessario creare l'area puntata (che non viene fatto automaticamente, come nel caso degli array) e allocarla dinamicamente con l'operatore new; quest'area dovrà però essere rilasciata, prima e poi (con l'operatore delete), e capita non di rado che non lo si possa fare prima della distruzione dell'oggetto. Poichè d'altra parte un oggetto può anche essere costruito o distrutto automaticamente, si pone il problema di come "intercettare" il momento della sua costruzione o distruzione.

Nel caso che gli oggetti siano istanze di una classe, il C++ mette a disposizione un mezzo molto potente, che consiste nella possibilità di definire dei particolari metodi della classe, che il programma riconosce come funzioni da eseguire al momento della costruzione o distruzione di un oggetto. Questi

Page 162: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

metodi prendono il nome di costruttori e distruttori degli oggetti. Il loro scopo principale è, per i costruttori, di inizializzare i membri e/o allocare risorse, per i distruttori, di rilasciare le risorse allocate.

Costruttori

I costruttori degli oggetti devono sottostare alle seguenti regole (ci rifaremo al solito esempio della classe point):

1. devono avere lo stesso nome della classe

prototipo: point(......);

definizione esterna: point::point(......) {......}

2. non bisogna specificare il tipo di ritorno (neanche void) NOTA: in realtà la chiamata di un costruttore può anche essere inserita in un'espressione; ciò significa che un costruttore ritorna "qualcosa" e precisamente .... l'oggetto che ha appena creato!

3. ammettono argomenti e defaults; i costruttori senza argomenti (o con tutti argomenti di default) sono detti: "costruttori di default" prototipo di costruttore di default della classe point: point( ); prototipo di costruttore della classe point con un argomento required e uno di default: point(double,double=0.0);

4. possono esistere più costruttori, in overload, in una stessa classe. Il C++ li distingue in base alla lista degli argomenti. Come tutte le funzioni in overload, non sono ammessi costruttori che differiscano solo per gli argomenti di default.

5. devono essere dichiarati come funzioni-membro pubbliche, in quanto sono sempre chiamati dall'esterno della classe a cui appartengono.

I costruttori non sono obbligatori: se una classe non ne possiede, il C++ fornisce un costruttore di default con "corpo nullo" .

Il costruttore di default (dichiarato nella classe oppure fornito dal C++) viene eseguito automaticamente nel momento in cui l'oggetto viene creato nel programma (si vedano i vari casi elencati nella sezione precedente). Esempio :

definizione del costruttore di default di point: point::point( ) {x=3.5;

y=2.1;}

definizione dell'oggetto p, istanza di point: point p ;

Page 163: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

nel momento in cui è l'eseguita l'istruzione di definizione dell'oggetto p, il costruttore di default va in esecuzione automaticamente, inizializzando p con 3.5 nel membro x e 2.1 nel membro y.

Se invece in una classe esiste almeno un costruttore con argomenti, il C++ non mette a disposizione alcun costruttore di default e perciò questo, se necessario, va esplicitamente definito come metodo della classe. In sua assenza, i costruttori con argomenti non vengono invocati automaticamente e pertanto ogni istruzione del programma che determini, direttamente o indirettamente, la creazione di un oggetto, deve contenere la chiamata esplicita di uno dei costruttori disponibili, nel modo che dipende dalla categoria dell'oggetto interessato. Esamineremo i vari casi separatamente, rifacendoci all'elenco illustrato nella sezione precedente.

Per il momento consideriamo il caso più frequente, che è quello di un oggetto singolo creato direttamente mediante la definizione del suo nome (casi 1., 2. e 6.): i modi possibili per invocare un costruttore con argomenti sono due, come è mostrato dal seguente esempio:

definizione del costruttore di point : point::point(double x0, double y0) {x=x0; y=y0;}

definizione dell'oggetto p, istanza di point :

prima forma : point p (3.5, 2.1);

seconda forma : point p = point(3.5, 2.1);

la prima forma è più concisa, ma la seconda è più chiara, in quanto ha proprio l'aspetto di una inizializzazione tramite chiamata esplicita di una funzione. In entrambi i casi viene invocato un costruttore con due argomenti di tipo double, che inizializza p inserendo i valori dei due argomenti rispettivamente nel membro x e nel membro y. Aggiungiamo che la chiamata esplicita può essere utilizzata anche per invocare un costruttore di default (è necessaria, per esempio, quando l'oggetto è creato all'interno di un'espressione), per esempio: throw Error( ) ; (solleva un'eccezione e trasmette un oggetto della classe Error, creato con il costruttore di default).

Terminiamo questa sezione osservando che anche i tipi nativi hanno i loro costruttori di default (sebbene di solito non si usino), che però, quando servono, vanno esplicimente chiamati, come nel seguente esempio: int i = int(); i costruttori di default dei tipi nativi inizializzano le variabili con zero (in modo appropriato al tipo). Sono utili quando si ha a che fare con tipi parametrizzati (come i template, che vedremo più avanti), in cui non è noto a priori se al parametro verrà sostituito un tipo nativo o un tipo astratto.

Page 164: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Costruttori e conversione implicita

Un'attenzione particolare merita il costruttore con un solo argomento. In questo caso, infatti, il costruttore definisce anche una conversione implicita di tipo dal tipo dell'argomento a quello della classe (ovviamente, spetta al codice di implementazione del costruttore assicurare che la conversione venga eseguita in modo corretto). Esempio:

definizione del costruttore di point : point::point(double d){x=d; y=d;}

definizione dell'oggetto p, istanza di point :

point p = 3;

è equivalente a : point p = point(3.0);

Notare che il numero 3 (che è di tipo int) è convertito implicitamente, prima a double, e poi nel tipo point (tramite esecuzione del costruttore, che lo utilizza per inizializzare l'oggetto p). Notare anche (per "chiudere il cerchio") che un'espressione del tipo point(3.0) è formalmente identica a un'operazione di casting in function-style (è persino ammessa la forma in C-style !).

Le conversioni implicite sono molto utili nella definizione degli operatori in overload (come vedremo prossimamente).

La conversione implicita può essere esclusa premettendo, nella dichiarazione (non nella definizione esterna) del costruttore lo specificatore explicit : explicit point(double); il casting continua invece ad essere ammesso (anche nella forma in C-style), in quanto coincide puramente con la chiamata del costruttore.

Distruttori

I distruttori degli oggetti devono sottostare alle seguenti regole (ci rifaremo al solito esempio della classe point):

1. devono avere lo stesso nome della classe preceduto da una tilde (~)

Page 165: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

prototipo: ~point( );

definizione esterna: point::~point( ) {......}

2. non bisogna specificare il tipo di ritorno (neanche void) 3. non ammettono argomenti 4. ciascuna classe può avere al massimo un distruttore 5. devono essere dichiarati come funzioni-membro pubbliche, in quanto

sono sempre chiamati dall'esterno della classe a cui appartengono.

Come i costruttori, i distruttori non sono obbligatori; sono richiesti quando è necessario liberare risorse allocate dagli oggetti o ripristinare le condizioni preestistenti alla loro creazione. Se esiste, un distruttore è sempre chiamato automaticamente ogni volta che l'oggetto di cui fa parte sta per essere distrutto.

Quando più oggetti sono costruiti in sequenza, e poi sono distrutti contemporaneamente (per esempio se sono oggetti automatici che escono dal loro ambito di visibilità), i loro distruttori sono normalmente eseguiti in sequenza inversa a quella di costruzione.

Oggetti allocati dinamicamente

Se il programma non definisce direttamente un oggetto, ma un suo puntatore, il costruttore non entra in azione al momento della definizione del puntatore, bensì quando viene allocata dinamicamente la memoria per l'oggetto (caso 3. dell'elenco). Solito esempio:

point* ptr; costruisce la "variabile" puntatore ma non l'area puntata

ptr = new point; costruisce l'area puntata

la seconda istruzione dell'esempio esegue varie cose in una sola volta:

• alloca memoria dinamica per un oggetto della classe point • assegna l'indirizzo dell'oggetto, restituito dall'operatore new, al

puntatore ptr • inizializza l'oggetto eseguendo il costruttore di default della classe

point

Quando si vuole che nella creazione di un oggetto sia eseguito un costruttore con argomenti, bisogna aggiungere, nell'istruzione di allocazione della memoria, l'elenco dei valori degli argomenti (fra parentesi tonde): ptr = new point (3.5, 2.1);

Page 166: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

questa istruzione cerca, fra i costruttori della classe point, quello con due argomenti di tipo double, e lo esegue al posto del costruttore di default .

Se si alloca dinamicamente un array di oggetti, sappiamo che la dimensione dell'array va specificata fra parentesi quadre dopo il nome della classe. Poichè il costruttore chiamato è unico per tutti gli elementi dell'array, questi vengono tutti inizializzati nello stesso modo. Nessun problema se si usa il costruttore di default (purchè sia disponibile): ptr = new point [10]; ma, quando si vuole usare un costruttore con argomenti: ptr = new point [10] (3.5, 2.1); non sempre l'istruzione viene eseguita correttamente: anzitutto alcuni compilatori più antichi (come il Visual C++, vers. 6) non l'accettano; quelli che l'accettano la eseguono bene se il tipo è astratto (come nell'esempio di cui sopra), ma se il tipo è nativo, per es.: ptr = new int [10] (3); disponendo solo del costruttore di default, tutti gli elementi dell'array vengono comunque inizializzati con 0 (cioè la parte dell'istruzione fra parentesi tonde viene ignorata).

Gli oggetti allocati dinamicamente non sono mai distrutti in modo automatico. Per ottenere che vengano distrutti, bisogna usare l'operatore delete. Es. (al solito ptr punta a oggetti della classe point): delete ptr; (per un singolo oggetto) delete [ ] ptr; (per un array) a questo punto viene eseguito, per ogni oggetto, il distruttore della classe point (se esiste) .

Membri puntatori

Una particolare attenzione va rivolta alla programmazione dei costruttori e del distruttore di un oggetto che contiene membri puntatori.

Infatti, a differenza dal caso degli array, l'area puntata non è definita automaticamente e quindi (a meno che al puntatore non venga successivamente assegnato l'indirizzo di un'area già esistente) capita quasi sempre che l'area debba essere allocata nella memoria heap. e che questa operazione venga eseguita proprio da un costruttore dell'oggetto.

Analogamente, quando l'oggetto è distrutto (per esempio se è un oggetto automatico che va out of scope), sono del pari distrutti tutti i suoi membri, compresi i membri puntatori, ma non le aree puntate, che continuano ad esistere senza essere più raggiungibili (errore di memory leak).

Page 167: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Pertanto è indispensabile che sia lo stesso distruttore dell'oggetto a incaricarsi di distruggere esplicitamente le aree puntate, cosa che può essere fatta solamente usando l'operatore delete. Esempio:

CLASSE COSTRUTTORE DISTRUTTORE

class Persona { Persona::Persona (int n) Persona::~Persona ( )

char* nome; { {

char* cognome; nome = new char [n]; delete [ ] nome;

public: cognome = new char [n]; delete [ ] cognome;

Persona (int); } }

~Persona ( ); DEFINIZIONE DELL'OGGETTO NEL PROGRAMMA

.... altri metodi }; Persona Tizio(25);

l'oggetto Tizio, istanza della classe Persona, viene costruito automaticamente nella memoria stack, e così pure i suoi membri. In aggiunta, il costruttore dell'oggetto alloca nella memoria heap due aree di 25 byte, e sistema i rispettivi indirizzi nei membri puntatori Tizio.nome e Tizio.cognome. Quando l'oggetto Tizio va out of scope, il distruttore entra in azione automaticamente e, con l'operatore delete, libera la memoria heap allocata per le due aree. Senza il distruttore, sarebbe stata liberata soltanto la memoria stack occupata dall'oggetto Tizio e dai suoi membri puntatori , ma non l'area heap indirizzata da questi.

Costruttori di copia

I costruttori di copia sono particolari costruttori che vengono eseguiti quando un oggetto é creato per copia. Ricordiamo brevemente in quali casi ciò si verifica:

• definizione di un oggetto e sua inizializzazione tramite un oggetto esistente dello stesso tipo;

• passaggio by value di un argomento a una funzione; • restituzione by value del valore di ritorno di una funzione; • passaggio di un'eccezione al costrutto catch.

Un costruttore di copia deve avere un solo argomento, dello stesso tipo dell'oggetto da costruire; l'argomento (che rappresenta l'oggetto esistente) deve essere dichiarato const (per sicurezza) e passato by reference (altrimenti si creerebbe una copia della copia!). Riprendendo il solito esempio, il costruttore di copia della classe point é: point::point(const point& q) {......} e viene chiamato automaticamente ogni volta che si verifica una delle quattro circostanze sopraelencate.

Page 168: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Per esempio, se definiamo un oggetto p e lo inizializziamo con un oggetto preesistente q: point p = q ; questa istruzione aziona il costruttore di copia, a cui é trasmesso q come argomento.

I costruttori di copia, come ogni altro costruttore, non sono obbligatori: se una classe non ne possiede, il C++ fornisce un costruttore di copia di default che esegue la copia membro a membro. Questo può essere soddisfacente nella maggioranza dei casi. Tuttavia, se la classe possiede dei membri puntatori, l'azione di default copia i puntatori, ma non le aree puntate: alla fine si ritrovano due oggetti i cui rispettivi membri puntatori puntano alla stessa area. Ciò potrebbe essere pericoloso, perché, se viene chiamato il distruttore di uno dei due oggetti, il membro puntatore dell'altro, che esiste ancora, punta a un'area che non esiste più (errore di dangling references).

Nell'esempio seguente una classe di nome A contiene, fra l'altro, un membro puntatore a int e un costruttore di copia che esegue le operazioni idonee ad evitare l'errore di cui sopra:

CLASSE COSTRUTTORE DI COPIA

class A { A::A(const A& a)

int* pa; {

public: pa = new int ;

A(const A&); *pa = *a.pa ;

........ }; }

in questo modo, a seguito della creazione di un oggetto a2 per copia da un esistente oggetto a1: A a2 = a1; il costruttore di copia fa si che la variabile puntata *a1.pa venga copiata in *a2.pa; senza il costruttore sarebbe copiato il puntatore a1.pa in a2.pa.

Liste di inizializzazione

Quando un costruttore deve, fra l'altro, inizializzare i membri della propria classe, lo può fare tramite una lista di inizializzazione (introdotta dal segno ":" e inserita nella definizione del costruttore dopo la lista degli argomenti), la quale sostituisce le istruzioni di assegnazione (in effetti un costruttore non dovrebbe assegnare bensì solo inizializzare, anche se la distinzione può sembrare solo formale).

La sintassi di una lista di inizializzazione si desume dal seguente esempio:

Page 169: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

CLASSE COSTRUTTORE

class A { A::A(int p, double q) : m1(p), m2(0),

r(q)

int m1, m2; {

double r; .... eventuali altre operazioni....

public: }

A(int,double);

........ };

Notare che alcuni membri possono essere inizializzati con valori costanti, altri con i valori degli argomenti passati al costruttore. L'ordine nella lista è indifferente; in ogni i caso i membri sono costruiti e inizializzati nell'ordine in cui appaiono nella definizione della classe.

E' buona norma utilizzare le liste di inizializzazione ogni volta che é possibile. Il loro uso é indispensabile quando esistono membri della classe dichiarati const o come riferimenti, per i quali l'inizializzazione è obbligatoria.

Membri oggetto

Riprendiamo ora ad esaminare l'elenco presentato all'inizio di questo capitolo e consideriano la costruzione e distruzione degli oggetti, quando sono membri non statici di una classe (caso 4. dell'elenco).

Sappiamo già che una classe può avere anche tipi classe fra i suoi membri; per esempio:

class A { class C {

int aa; ........ }; A ma;

class B { B mb;

int bb; ........ }; int mc; ........ };

La classe C del nostro esempio viene detta classe composta, in quanto contiene, fra i suoi membri, oggetti di altre classi (il membro-oggetto ma della classe A e il membro-oggetto mb della classe B).

Sappiamo inoltre che, creata un'istanza cc di C, le variabili corrispondenti ai singoli membri vanno indicate nel programma con espressioni del tipo: cc.ma.aa oppure cc.mb.bb (diritti di accesso permettendo).

Page 170: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Nel momento in cui un oggetto di una classe composta sta per essere costruito, e prima ancora che il suo costruttore completi l'operazione, sono eseguiti automaticamente i costruttori che inizializzano i membri delle classi componenti. Se esistono e si vogliono utilizzare i costruttori di default, non esiste problema. Ma se deve essere chiamato un costruttore con argomenti, ci si chiede in che modo tali argomenti possano essere passati, visto che il costruttore di un membro-oggetto non è chiamato esplicitamente.

In questi casi, spetta al costruttore della classe composta provvedere a che vengano eseguiti correttamente anche i costruttori delle classi componenti. Per ottenere ciò, deve includere, nella sua lista di inizializzazione, tutti (e soli) i membri-oggetto che non utilizzano il proprio costruttore di default, ciascuno con i valori di inzializzazione che corrispondono esattamente (cioè con gli stessi tipi e nello stesso ordine) alla lista degli argomenti del rispettivo costruttore. Seguitando con il nostro esempio:

costruttore di A : A::A(int x) : aa(x) { ........ }

costruttore di B : B::B(int x) : bb(x) { ........ }

costruttore di C : C::C(int x, int y, int z) : ma(z), mb(x), mc(y) { ........ }

Le classi componenti A e B hanno anche una loro vita autonoma e in particolare possono essere istanziate con oggetti propri. In questo caso il costruttore di C può generare i suoi membri-oggetto copiando oggetti già costruiti delle classi componenti. Riprendendo l'esempio, un'altra forma del costruttore di C potrebbe essere:

C::C(int x, const A& a, const B& b) : ma(a), mb(b), mc(x) { ........ }

dove gli argomenti a e b corrispondono a istanze già create rispettivamente di A e di B; in tale caso viene eseguito il costruttore di copia, se esiste, oppure di default viene fatta la copia membro a membro.

Quando un oggetto di una classe composta viene distrutto, vengono successivamente e automaticamente distrutti tutti i membri delle classi componenti, in ordine inverso a quello della loro costruzione.

Array di oggetti

Gli elementi di un array di oggetti (caso 5. dell'elenco iniziale) vengono inizializzati, tramite il costruttore della classe comune di appartenenza, non appena l'array è definito.

Page 171: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Come al solito, non esiste nessun problema se si utilizza il costruttore di default: point pt[5]; (costruisce 5 oggetti della classe point, invocando, per ciascuno di essi, il costruttore di default).

Se invece si vuole (o si deve, per mancanza del costruttore di default) utilizzare un costruttore con argomenti, bisogna considerare a parte il caso di costruttore con un solo argomento (o con più argomenti di cui uno solo required). Ricordiamo a questo proposito come si inizializza un array di tipo nativo: int valori[] = {32, 53, 28, 85, 21}; nello stesso modo si può inizializzare un array di tipo astratto: point pt[] = {2.3, -1.2, 0.0, 1.4, 0.5}; ma in questo caso ogni valore di inizializzazione , relativo a un elemento dell'array, viene passato come argomento al costruttore. Ciò è possibile in quanto, grazie alla presenza del costruttore con un solo argomento, ogni valore è convertito implicitamente in un oggetto della classe point (chiamiamolo pn) e quindi l'espressione precedente diventa: point pt[] = {p0, p1, p2, p3, p4}; l'inizializzazione in questa forma di un array di un certo tipo, tramite elementi dello stesso tipo precedentemente costruiti, è sempre consentita, anche per i tipi astratti.

Non esiste invece alcuna possibilità di utilizzare costruttori con due o più argomenti.

Oggetti non locali

Abbiamo già considerato i casi degli oggetti globali, degli oggetti nei namespace e dei membri statici delle classi (numero 6. dell'elenco iniziale), come casi particolari di oggetto singolo creato direttamente mediante la definizione del suo nome (vedere sezione: Costruttori). Sappiamo che tali oggetti non locali sono costruiti una sola volta, alla partenza del programma, e distrutti solo quando il programma termina.

Qui vogliamo solo aggiungere alcune considerazioni riguardo all'ordine di costruzione e distruzione di più oggetti:

• due oggetti definiti nella stessa translation unit sono costruiti nello stesso ordine in cui la loro definizione appare nel programma, e distrutti in ordine inverso;

• l'ordine di costruzione (e di distruzione) è invece indeterminato se i due oggetti sono definiti in translation unit diverse.

Ne consegue che è molto "imprudente" inserire, nel codice del costruttore di un oggetto non locale, operazioni che coinvolgano oggetti definiti in altre

Page 172: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

translation unit (in particolare evitare istruzioni con cin e cout, in quanto non si può essere sicuri che gli oggetti globali delle classi di flusso di I/O siano già stati costruiti).

Oggetti temporanei

Abbiamo detto che un oggetto temporaneo (caso 7. dell'elenco iniziale) viene costruito per memorizzare risultati parziali durante la valutazione di un'espressione, e distrutto alla fine dell'espressione completa in cui compare (con il termine "espressione completa" si intende un'espressione che non sia sotto-espressione di un'altra, cioè, in pratica, un'intera istruzione di programma).

Finora abbiamo considerato soltanto operazioni fra tipi nativi, per i quali il problema della costruzione di un oggetto temporaneo non si pone. Ma, come vedremo nel prossimo capitolo, il C++ consente anche operazioni fra tipi astratti, tramite la possibilità di ridefinire, in overload, le funzioni che competono all'azione di molti operatori (overload degli operatori). Per esempio, si potrebbe ridefinire l'operatore di somma (+) in modo che accetti fra i suoi operandi anche oggetti della classe classe point (si tratterebbe in questo caso di una somma "vettoriale", ottenuta mediante somma membro a membro delle coordinate dei punti): point p = p1 + p2; dove p1 e p2 sono istanze già create della stessa classe. In questo caso è costruito l'oggetto temporaneo p1 + p2, che viene distrutto dopo che l'istruzione è stata eseguita. Ci chiediamo però: cosa succede se la classe point non ha un costruttore di default ? La risposta è che spetta al codice di implementazione della funzione, che definisce l'operatore di somma in overload, provvedere a che l'operazione sia eseguita correttamente (per esempio potrebbe definire un'istanza locale di point, con valori di inizalizzazione qualsiasi, usarla per memorizzare la somma di p1 e p2 membro a membro, e infine trasmetterla come valore di ritorno by value, da copiare in p).

In generale, tutte le volte che un'operazione crea un oggetto temporaneo, la funzione che compete a quell'operazione deve creare nel proprio ambito locale un corrispondente oggetto, che, in quanto costruito mediante definizione con un nome (categoria 1. del nostro elenco), non pone problemi, possegga o meno il costruttore di default.

Page 173: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Utilità dei costruttori e distruttori

Poiché in C++ ogni oggetto ha una sua precisa connotazione, caratterizzata da proprietà e metodi, i costruttori e i distruttori hanno in realtà un campo di applicazione molto più vasto della semplice inizializzazione o liberazione di risorse: in senso lato possono servire ogni qual volta un oggetto necessita di ben definite operazioni iniziali e finali, incapsulate nell'oggetto stesso. Per esempio, se l'oggetto consiste in una procedura di help, il costruttore potrebbe servire per creare la "finestra di aiuto", mentre il distruttore avrebbe il compito di ripristinare le condizioni preesistenti dello schermo.

Page 174: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Overload degli operatori

Estendibilità del C++

In tutti i linguaggi, gli operatori sono dei simboli convenzionali che rendono più agevole la presentazione e lo sviluppo di concetti di uso frequente. Per esempio, la notazione: a+b*c risulta più agevole della frase: "moltiplica b per c aggiungi il risultato ad a"

L'utilizzo di una notazione concisa per le operazioni di uso comune è di importanza fondamentale.

Il C++ supporta, come ogni altro linguaggio, un insieme di operazioni per i suoi tipi nativi. Tuttavia la maggior parte dei concetti utilizzati comunemente non sono facilmente rappresentabili per mezzo di tipi nativi, e bisogna spesso fare ricorso ai tipi astratti. Per esempio, i numeri complessi, le matrici, i segnali, le stringhe di caratteri, le aggregazioni di dati, le code, le liste ecc... sono tutte entità che meglio si prestano a essere rappresentate mediante le classi. E' pertanto necessario che anche le operazioni fra queste entità possano essere descritte tramite simboli convenzionali, in alternativa alla chiamata di funzioni specifiche (come avviene negli altri linguaggi), che non permetterebbero quella notazione concisa che, come si è detto, è di importanza fondamentale per una programmazione più semplice e chiara.

Il C++ consente di soddisfare questa esigenza tramite l'overload degli operatori: il programmatore ha la possibilità di creare nuove funzioni che ridefiniscono il significato dei simboli delle operazioni, rendendo queste applicabili anche ai tipi astratti (estendibilità del C++). La caratteristica determinante per il reale vantaggio di questa tecnica, è che, a differenza dalle normali funzioni, quelle che ridefiniscono gli operatori possono essere chiamate mediante il solo simbolo dell'operazione (con gli argomenti della funzione che diventano operandi): in definitiva la chiamata della funzione "scompare" dal codice del programma e al suo posto si può inserire una "semplice e concisa" operazione. Per esempio, se viene creata una funzione che ridefinisce la somma (+) fra due oggetti, a e b, istanze di una certa classe, in luogo della chiamata della funzione si può semplicemente scrivere: a+b. Se si pensa che un'espressione può essere costituita da parecchie operazioni insieme, il vantaggio di questa tecnica per la concisione e la leggibilità del codice risulta evidente (in alternativa a ripetute chiamate di funzioni, "innestate" l'una nell'altra). Per esempio, tornando all'espressione iniziale, costituita da solo due operazioni:

operatori in overload : a+b*c

chiamata di funzioni specifiche : somma(a,moltiplica(b,c))

Page 175: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Ridefinizione degli operatori

Per ottenere l'overload di un operatore bisogna creare una funzione il cui nome (che eccezionalmente non segue le regole generali di specifica degli identificatori) deve essere costituito dalla parola-chiave operator seguita, con o senza blanks in mezzo, dal simbolo dell'operatore (es.: operator+). Gli argomenti della funzione devono corrispondere agli operandi dell'operatore. Ne consegue che per gli operatori unari è necessario un solo argomento, per quelli binari ce ne vogliono due (e nello stesso ordine, cioè il primo argomento deve corrispondere al left-operand e il secondo argomento al right-operand).

Non è concesso "inventare" nuovi simboli, ma si possono solo utilizzare i simboli degli operatori esistenti. In più, le regole di precedenza e associatività restano legate al simbolo e non al suo significato, come pure resta legata al simbolo la categoria dell'operatore (unario o binario). Per esempio, un operatore in overload associato al simbolo della divisione (/) non può mai essere definito unario e ha sempre la precedenza sull'operatore associato al simbolo +, qualunque sia il significato di entrambi.

E' possibile avere overload di quasi tutti gli operatori esistenti, salvo: ?:, sizeof, typeid e pochi altri, fra cui quelli (come :: e .) che hanno come operandi nomi non "parametrizzabili" (come i nomi delle classi o dei membri di una classe).

Come per le funzioni in overload, nel caso dello stesso operatore ridefinito più volte con tipi diversi, il C++ risolve l'ambiguità in base al contesto degli operandi, riconoscendone il tipo e decidendo di conseguenza quale operatore applicare.

Torniamo ora alla classe point e vediamo un esempio di possibile operatore di somma (il nostro intento è di ottenere la somma "vettoriale" fra due punti); supponiamo che la classe sia provvista di un costruttore con due argomenti:

operazione :

p = p1+p2 ;

funzione somma : point operator+(const point& p1, const point&

p2)

{

point ptemp(0.0,0.0);

ptemp.x = p1.x + p2.x ;

ptemp.y = p1.y + p2.y ;

return ptemp ;

}

Page 176: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Notare:

1. la funzione ha un valore di ritorno di tipo point; 2. gli argomenti-operandi sono passati by reference e dichiarati const,

per maggiore sicurezza (const) e rapidità di esecuzione (passaggio by reference);

3. nella funzione è definito un oggetto automatico (ptemp), inizializzato compatibilmente con il costruttore disponibile (vedere il problema della inizializzazione degli oggetti temporanei nel capitolo precedente);

4. in ptemp i due operandi sono sommati membro a membro (la somma è ammessa in quanto fra due tipi double);

5. in uscita ptemp (essendo un oggetto automatico) "muore", ma una sua copia è passata by value al chiamante, dove è successivamente assegnata a p

Nota ulteriore: è ammessa anche la chiamata della funzione nella forma tradizionale: p = operator+(p1, p2) ; ma in questo caso si vanificherebbero i vantaggi offerti dalla notazione simbolica delle operazioni.

Metodi della classe o funzioni esterne?

Finora abbiamo parlato delle funzioni che ridefiniscono gli operatori in overload, senza preoccuparci di dove tali funzioni debbano essere definite. Quando esse accedono a membri privati della classe, possono appartenere soltanto a una delle seguenti tre categorie:

1. sono metodi pubblici non statici della classe; 2. sono metodi pubblici statici della classe; 3. sono funzioni friend della classe.

Escludiamo subito che siano metodi statici, non perchè non sia permesso, ma perchè non sarebbe conveniente, in quanto un metodo statico può essere chiamato solo se il suo nome è qualificato con il nome della classe di appartenenza, es.: p = point::operator+(p1, p2) ; e quindi non esiste il modo di utilizzarlo nella rappresentazione simbolica di un'operazione.

Restano pertanto a disposizione solo i metodi non statici e le funzioni friend (o esterne, se non accedono a membri privati). La scelta più appropriata dipende dal contesto degli operandi e dal tipo di operazione. In generale conviene che sia un metodo quando l'operatore è unario, oppure (e in questo

Page 177: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

caso è obbligatorio) quando il primo operando è oggetto della classe e la funzione lo restituisce come l-value, come accade per esempio per gli overload degli operatori di assegnazione (=) e in notazione compatta (+= ecc...). Viceversa, non ha molto senso che sia un metodo l'overload dell'addizione (che abbiamo visto come esempio nella sezione precedente), il quale opera su due oggetti e restituisce un risultato da memorizzare in un terzo.

La miglior progettazione degli operatori di una classe consiste nell'individuare un insieme ben definito di metodi per le operazioni che si applicano su un unico oggetto o che modificano il loro primo operando, e usare funzioni esterne (o friend) per le altre operazioni; il codice di queste funzioni risulta però facilitato, in quanto può utilizzare gli stessi operatori già definiti come metodi (vedremo più avanti un'alternativa dell'operatore + come funzione esterna, che usa l'operatore += implementato come metodo). NOTA: nei tipi astratti, l'esistenza degli operatori in overload + e = non implica che sia automaticamente definito anche l'operatore in overload +=

Il ruolo del puntatore nascosto this

E' chiaro a tutti perchè un'operazione che si applica su un unico oggetto o che modifica il primo operando è preferibile che sia implementata come metodo della classe? Perchè, in quanto metodo non statico, può sfruttare la presenza del puntatore nascosto this, che, come sappiamo, punta allo stesso oggetto della classe in cui il metodo è incapsulato e viene automaticamente inserito dal C++ come primo argomento della funzione. Ne consegue che:

1. un operatore in overload può essere implementato come metodo di una classe solo se il primo operando è un oggetto della stessa classe; in caso contrario deve essere una funzione esterna (dichiarata friend se accede a membri privati) ;

2. nella definizione del metodo il numero degli argomenti deve essere ridotto di un'unità rispetto al numero di operandi; in pratica, se l'operatore è binario, ci deve essere un solo argomento (quello corrispondente al secondo operando), se l'operatore è unario, la funzione non deve avere argomenti.

3. se il risultato dell'operazione è l'oggetto stesso l'istruzione di ritorno deve essere: return *this;

Vediamo ora, a titolo di esempio, una possibile implementazione di overload dell'operatore in notazione compatta += della nostra classe point:

Page 178: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

operazione : p += p1 ;

definizione metodo : point& point::operator+=(const point& p1)

{

x += p1.x ;

y += p1.y ;

return *this ;

}

Notare:

1. la funzione ha un un solo argomento, che corrisponde al secondo operando p1, in quanto il primo operando p è l'oggetto stesso, trasmesso per mezzo del puntatore nascosto this;

2. la funzione è un metodo della classe, e quindi i membri dell'oggetto p sono indicati solo con il loro nome (il compilatore aggiunge this-> davanti a ognuno di essi);

3. nel codice della funzione l'operatore += è "conosciuto", in quanto agisce sui membri della classe, che sono di tipo double;

4. la funzione ritorna l'oggetto stesso p (deref. di this), by reference (cioè come l-value), modificato dall'operazione (non esistono problemi di lifetime in questo caso, essendo l'oggetto p definito nel chiamante);

5. la chiamata della funzione nella forma tradizionale sarebbe: p.operator+=(p1) ; tradotta dal compilatore in: operator+=(&p,p1) ;

Adesso che abbiamo definito l'operatore += come metodo della classe, l'implementazione dell'operatore +, che invece preferiamo sia una funzione esterna, può essere fatta in modo più semplice (non occorre che sia dichiarata friend in quanto non accede a membri privati):

operazione : p = p1+p2 ;

funzione somma : point operator+(const point& p1, const point& p2)

{

point ptemp = p1; (uso il costruttore di copia)

return ptemp += p2 ;

}

Overload degli operatori di flusso di I/O

Page 179: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Un caso particolare rappresenta l'overload dell'I/O, cioè degli operatori di flusso "<<" (inserimento) e ">>" (estrazione). Notiamo che questi sono già degli operatori in overload, in quanto il significato originario dei simboli << e >> è quello di operatori di scorrimento di bit (se gli operandi sono interi).

Se invece il left-operand non è un intero, ma l'oggetto cout, abbiamo visto che l'operatore << definisce un'operazione di output, che è eseguita "inserendo" in cout il dato da scrivere (costituito dal right-operand), il quale a sua volta può essere di qualunque tipo nativo o del corrispondente tipo puntatore (quest'ultimo è scritto come numero intero in forma esadecimale, salvo il tipo char *, che è interpretato come stringa).

Il nostro scopo è ora quello di creare un ulteriore overload di <<, in modo che anche un tipo astratto possa essere ammesso come right-operand; per esempio potremmo volere che l'operazione: cout << a; (dove a è un'istanza di una classe A) generi su video una tabella dei valori assunti dai membri di a.

Per fare questo dobbiamo anzitutto sapere che cout, oggetto globale generato all'inizio dell'esecuzione del programma, é un'istanza della classe ostream, che viene detta "classe di flusso di output" (e dichiarata in <iostream.h>). Inoltre il primo argomento passato alla funzione dovrà essere lo stesso oggetto cout (in quanto é il left-operand dell'operazione), mentre il secondo argomento, corrispondente al right-operand, dovrà essere l'oggetto a da trasferire in output. Infine la funzione dovrà restituire by-reference lo stesso primo argomento (cioè sempre cout), per permettere l'associazione di ulteriori operazioni nella stessa istruzione.

Pertanto la funzione per l'overload di << dovrà essere così definita:

ostream& operator<<(ostream& out, const A& a)

{

........ out << a.ma; (ma è un membro di A di tipo nativo)

........ return out ;

}

Notare:

1. il primo argomento della funzione appartiene a ostream e non ad A, e quindi la funzione non può essere un metodo di A, ma deve essere dichiarata come funzione friend nella definizione di A; viceversa, gli overload dell'operatore << con tipi nativi (e loro puntatori) sono definiti nella stessa classe ostream, e quindi sono metodi di quella classe;

2. il valore di ritorno della funzione è trasmesso by-reference, in quanto deve essere un l-value di successive operazioni impilate;

3. poichè nel chiamante il primo argomento è l'oggetto cout, il ritorno by-reference dello stesso oggetto non rischia mai di creare problemi di lifetime;

Page 180: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

4. per i motivi suddetti, e per l'associatività dell'operatore <<, che procede da sinistra a destra, si possono impilare più operazioni di output in una stessa istruzione. Esempio: cout << a1 << a2 << a3; dove a1, a2 e a3 sono tutte istanze di A

Analogamente, si può definire un overload dell'operatore di estrazione ">>" per le operazioni di input (per esempio, cin >> a;), tramite la funzione: istream& operator>>(istream& inp, A& a) dove istream è la classe di flusso di input (anch'essa dichiarata in <iostream.h>), a cui appartiene l'oggetto globale cin. Notare che in questo caso il secondo argomento (cioè a), sempre passato by-reference, non è dichiarato const, in quanto l'operazione lo deve modificare.

Operatori binari e conversioni

Analogamente a quanto visto negli esempi finora riportati, si possono definire gli overload dei seguenti operatori binari :

• matematici (+ - * / %); • a livello del bit (<< >> & | ^) • in notazione compatta (+= -= *= / = %= <<= >> = &= | =

^=) • relazionali (== != < <= > >=); • logici (&& ||) • di serializzazione ( , )

e di altri che tratteremo separatamente (per una maggiore leggibilità del programma, si consiglia, anche se non è obbligatorio, che gli overload di questi operatori mantengano comunque qualche "somiglianza" con il loro significato originario).

Tutti gli operatori sopra riportati avranno ovviamente almeno un operando che è oggetto della classe, non importa se left o right (a parte gli operatori in notazione compatta, per i quali l'oggetto della classe deve essere sempre left). L'altro operando può essere un altro oggetto della stessa classe (come nell'esempio della somma che abbiamo visto prima), oppure un oggetto di qualsiasi altro tipo, nativo o astratto. Pertanto possono esistere parecchi overload dello stesso operatore, ciascuno con un operando di tipo diverso. Non solo, ma se si vuole salvaguardare la proprietà "commutativa" di certe operazioni (+ * & | ^ == != && ||), o la "simmetria" di altre (< con >= e > con <=), occorrono, per ognuna di esse, due funzioni, delle quali per giunta una può essere metodo e l'altra no.

Page 181: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Ne consegue che, se gli operatori da applicare in overload a una certa classe non sono progettati attentamente, si rischia di generare una pletora di funzioni, con varianti spesso molto piccole da una all'altra.

Il C++ offre una soluzione a questo problema, che è molto semplice ed efficace: il numero di funzioni può essere minimizzato utilizzando i costruttori con un argomento, che, come abbiamo visto, definiscono anche una conversione implicita di tipo: se "attrezziamo" la classe con un insieme opportuno di costruttori con un argomento, possiamo ottenere che tutti i tipi coinvolti nelle operazioni siano convertiti implicitamente nel tipo della classe e che ogni operazione sia perciò implementata da una sola funzione, quella che opera su due oggetti della stessa classe. Notare che la conversione implicita viene eseguita indipendentemente dalla posizione dell'operando, e ciò permette in particolare che ogni operazione "commutativa" sia definibile con una sola funzione.

Riprendendo la nostra classe point, vogliamo per esempio definire un operazione di somma fra un vettore p, oggetto di point, e un valore s di tipo double (detto: "scalare"), in modo tale che lo scalare venga sommato a ogni componente del vettore. Se definiamo il costruttore: point::point(double d) : x(d), y(d) { } otterremo che entrambe le operazioni: p + s e s + p comportino la conversione implicita di s da tipo double a tipo point, e si trasformino nell'unica operazione di somma fra due oggetti di point (della quale abbiamo già visto un esempio di implementazione).

Operatori unari e casting a tipo nativo

Si possono definire gli overload dei seguenti operatori unari :

• incremento e decremento (suffisso) (++ - - ); • incremento e decremento (prefisso) (++ - - ); • segni algebrici (+ -) • complemento a 1 e NOT (~ !); • indirizzo e deref. (& *) • casting ( (tipo) )

Gli operatori unari devono avere come unico operando un oggetto della classe in cui sono definiti e quindi possono convenientemente essere definiti come metodi della stessa classe, nel qual caso le funzioni che li implementano devono essere senza argomenti.

Tutti gli operatori sopra menzionati sono prefissi dell'operando, salvo gli operatori di incremento e decremento che possono essere sia prefissi che suffissi. Per distinguerli, è applicata la seguente convenzione: se la funzione è senza argomenti, si tratta di un prefisso, se la funzione contiene un

Page 182: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

argomento fittizio di tipo int (che il sistema non usa in quanto l'operatore è unario) si tratta di un suffisso. Inoltre, per i prefissi, il valore di ritorno deve essere passato by reference, mentre per i suffissi deve essere passato by value (questo perché i prefissi possono essere degli l-values mentre i suffissi no). Infine, gli operatori suffissi devono essere progettati con particolare attenzione, se si vuole conservare la loro proprietà di eseguire un'operazione "posticipata", nonostanza la precedenza alta. Per esempio, un operatore di incremento suffisso di una generica classe A, potrebbe essere implementato così (supponiamo che il corrispondente operatore prefisso sia già stato definito):

A A::operator++(int)

{

A temp = *this;

++*this ;

return temp ;

}

come si può notare, l'oggetto è correttamente incrementato, ma al chiamante non torna l'oggetto stesso, bensì una sua copia precedente (temp); in questo modo, non è l'oggetto, ma la sua copia precedente ad essere utilizzata come operando nelle eventuali successive operazioni dell'espressione di cui fa parte; solo dopo che l'intera espressione è stata eseguita, un nuovo accesso al nome dell'oggetto ritroverà l'oggetto incrementato.

Un caso a parte è quello dell'operatore di casting. Come abbiamo visto, la conversione di tipo può essere eseguita usando un costruttore con un argomento: questo consente conversioni, anche implicite, da tipi nativi a tipi astratti (o fra tipi astratti), ma non può essere utilizzato per conversioni da tipi astratti a tipi nativi, in quanto i tipi nativi non hanno costruttori con un argomento. A questo scopo occore invece definire esplicitamente un overload dell'operatore di casting, che deve essere espresso nella seguente forma (esempio di casting da una classe A a double): A::operator double( ) notare che il tipo di ritorno non deve essere specificato in quanto il C++ lo riconosce già dal nome della funzione; notare anche che esiste uno spazio (obbligatorio) fra le parole operator e double.

La conversione può essere eseguita implicitamente o esplicitamente, in C-style o in function-style. Se è eseguita implicitamente, può verificarsi un'ambiguità nel caso sia definita anche la conversione in senso inverso. Esempio:

A a ; double d ;

a + d ; deve convertire un tipo A in double o un double in A ?

Nell'esempio sopra riportato si è supposto che:

Page 183: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

1. la classe A abbia un metodo che definisce un overload dell'operatore di casting da A a double;

2. la classe A abbia un costruttore con un argomento double; 3. esista una funzione esterna che definisce un overload dell'operatore di

somma fra due oggetti di A.

in queste condizioni il compilatore segnala un errore di ambiguità, perchè non sa quale delle due conversioni implicite selezionare. In ogni caso, quando si tratta di operatori in overload, il C++ non fa preferenza fra i metodi della classe e le altre funzioni .

Operatori in namespace

Abbiamo visto che, per una migliore organizzazione degli operatori in overload di una classe, è preferibile utilizzare in maggioranza funzioni non metodi (se si tratta di operatori binari), che si appoggino a un insieme limitato di metodi della classe. Non ci siamo mai chiesti, però, in quale ambito sia conveniente che tali funzioni vengano definite e, per semplicità, negli esempi (ed esercizi) finora riportati abbiamo sempre definito le funzioni nel namespace globale.

Questo non è, tuttavia, il modo più corretto di procedere. Come abbiamo detto più volte, un affollamento eccessivo del namespace globale può essere fonte di confusione e di errori, specialmente in programmi di grosse dimensioni e con diversi programmatori che lavorano ad un unico progetto.

E' pertanto preferibile "racchiudere" la classe e le funzioni esterne che implementano gli operatori della classe in un namespace definito con un nome. In questo modo non si "inquina" il namespace globale e, nel contempo, si può mantenere la notazione simbolica nella chiamata delle operazioni. Infatti, a differenza dai metodi statici, che devono essere sempre qualificati con il nome della classe, una funzione appartenente a un namespace non ha bisogno di essere qualificata con il nome del namespace, se appartiene allo stesso namespace almeno uno dei suoi argomenti.

In generale, data una generica operazione (usiamo l'operatore @, che in realtà non esiste, proprio per indicare un'operazione qualsiasi):

a @ b

(dove a è un'istanza di una classe A e b è un' istanza di una classe B)

il compilatore esegue la ricerca della funzione operator@ nel seguente modo:

• cerca operator@ come metodo della classe A; • cerca una definizione di operator@ nell'ambito della chiamata (o in

ambiti superiori, fino al namespace globale); • se la classe A è definita in un namespace M, cerca una definizione di

operator@ in M;

Page 184: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• se la classe B è definita in un namespace N, cerca una definizione di operator@ in N

Non sono fissati criteri di preferenza: se sono trovate più definizioni di operator@, il compilatore, se può, sceglie la "migliore" (per esempio, quella in cui i tipi degli operandi corrispondono esattamente, rispetto ad altre in cui la corrispondenza è ottenuta dopo una conversione implicita), altrimenti segnala l'ambiguità.

Nel caso che operator@ sia trovata nel namespace in cui è definita una delle due classi, la funzione deve essere comunque dichiarata friend in entrambe le classi (se in entrambe accede a membri privati); ciò potrebbe far sorgere un problema di dipendenza circolare, problema che peraltro si risolve mediante dichiarazione anticipata di una delle classi (per fortuna un namespace si può spezzare in più parti!)

Oggetti-array e array associativi

Tratteremo ora di alcuni overload di operatori binari, da implementare obbligatoriamente come metodi, in quanto il loro primo operando è oggetto della classe e l-value modificabile. Fermo restando il fatto che la ridefinizione del significato di un operatore in overload è assolutamente libera, questi operatori vengono comunemente ridefiniti con significati specifici.

Oggetti-array

Il primo overload che esaminiamo è quello dell'operatore indice [], che potrebbe servire, per esempio, se un membro della classe è un array. In tal caso, rinunciando, per non avere ambiguità, a trattare array di oggetti, ma solo il membro array di ogni oggetto, l'overload dell'operatore indice potrebbe essere definito come nel seguente esempio: data una classe A : class A { int m[10] ; ........ } ; e una sua istanza a, vogliamo che l'operazione: a[i] non indichi l'oggetto di indice i di un array di oggetti a (come sarebbe senza overload di []), ma l'elemento di indice i del membro-array m dell'oggetto a. Per ottenere questo, basta definire in A il seguente metodo:

int& A::operator[] (const int& i) { return m[i]; }

da notare che il valore di ritorno è un riferimento, e questo fa sì che l'operatore [] funzioni come un l-value, rendendo possibili, non solo operazioni di estrazione, come: num = a[i]; ma anche operazioni di inserimento, come: a[i] = num;

Page 185: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Gli oggetti costituiti da un solo membro-array (o in cui il membro-array è predominante) sono talvolta detti: oggetti-array. Rispetto ai normali array, presentano il vantaggio di poter disporre delle funzionalità in più offerte dalla classe di appartenenza; per esempio possono controllare il valore dell'indice, sollevando eccezione in caso di overflow, oppure modificare la dimensione dell'array (se il membro-array è dichiarato come puntatore) ecc...

Array associativi

L' operatore indice ha un campo di applicazione molto più vasto e generalizzato di un normale array. Infatti non esiste nessuna regola che obblighi il secondo operando a essere un intero, come è l'indice di un array; al contrario, lo si può definire di un qualsiasi tipo, anche astratto, e ciò permette di stabilire una corrispondenza (o, come talvolta si dice, un'associazione) fra oggetti di due classi. Un array associativo, spesso chiamato mappa o anche dizionario, memorizza coppie di valori: dato un valore, la chiave, si può accedere all'altro, il valore mappato. La funzione che implementa l'overload dell' operatore indice fornisce l'algoritmo di mappatura, che associa un oggetto della classe (primo operando) a ogni valore della chiave (secondo operando).

Oggetti-funzione

Anche l'operatore di chiamata di una funzione può essere ridefinito. In questo caso il primo operando deve essere un oggetto della classe (nascosto da this) e il secondo operando è una lista di espressioni, che viene valutata e trattata secondo le normali regole di passaggio degli argomenti di una funzione. Il metodo che implementa l'overload di questo operatore deve essere definito nel seguente modo (supponiamo che il nome della classe sia A):

tipo del valore di ritorno A::operator() (lista di argomenti) { ........ }

L'uso più frequente dell'operatore () si ha quando si vuole fornire la normale sintassi della chiamata di una funzione a oggetti che in qualche modo si comportano come funzioni (cioè che utilizzano in modo predominante un loro metodo). Tali oggetti sono spesso chiamati oggetti-funzione. Rispetto a una normale funzione, un oggetto-funzione ha il vantaggio di potersi "appoggiare" a una classe, e quindi di utilizzare le informazioni già memorizzate nei suoi membri, senza bisogno di dover trasmettere ogni volta queste informazioni come argomenti aggiuntivi nella chiamata.

Page 186: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Puntatori intelligenti

Abbiamo detto all'inizio che non tutti gli operatori possono essere ridefiniti in overload e in particolare non è ammesso ridefinire quegli operatori i cui operandi sono nomi non "parametrizzabili"; citiamo, a questo proposito, l'operatore di risoluzione di visibilità (::), in cui il left-operand è il nome di una classe o di un namespace, e gli operatori di selezione di un membro (. e ->), in cui il right-operand è il nome di un membro di una classe.

A questa regola fa eccezione l'operatore ->, che può essere ridefinito; ma, proprio perchè il suo right-operand non può essere trasmesso come argomento di una funzione, l'operatore -> in overload è "declassato" da operatore binario a operatore unario suffisso e mantiene, come unico operando, il suo originario left-operand, cioè l'indirizzo di un oggetto. La funzione che implementa questo (strano) overload deve essere un metodo di una classe, dal che si deduce che gli oggetti di tale classe possono essere usati come puntatori per accedere ai membri di un'altra classe. Per esempio, data una classe Ptr_to_A:

class Ptr_to_A { ........ public: A* operator->( ); ........ } ;

le sue istanze possono essere utilizzate per accedere a istanze della classe A, in una maniera molto simile a quella in cui sono utilizzati i normali puntatori.

Se il metodo viene chiamato come una normale funzione, il suo valore di ritorno può essere usato come puntatore ad un oggetto di A; se invece si adotta la notazione simbolica dell'operazione, le regole di sintassi pretendono che il nome di un membro di A venga comunque aggiunto. Per chiarire, continuiamo nell'esempio precedente:

Ptr_to_A p ;

A* pa = p.operator->( ); OK

A* pa = p->; errore di sintassi

int num = p->ma; OK (ma è un membro di A di tipo int)

p->ma = 7 ; OK (può anche essere un l-value)

L'overload di -> è utile principalmente per creare puntatori "intelligenti", cioè oggetti che si comportano come puntatori, ma con il vantaggio di poter disporre delle funzionalità in più offerte dalla classe di appartenenza (esattamente come gli oggetti-array e gli oggetti-funzione).

C'è da sottolineare infine che, come di regola, la definizione dell' overload di -> non implica che siano automaticamente definite le operazioni equivalenti. Infatti, mentre per i normali puntatori valgono le seguenti uguaglianze: p->ma = = (*p).ma = = p[0].ma le stesse continuano a valere per gli operatori in overload solo se tutti gli operatori sono definiti in modo tale da produrre volutamente tale risultato.

Page 187: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Operatore di assegnazione

Abbiamo lasciato per ultimo di questo gruppo l'overload dell'operatore di assegnazione (=), non perchè fosse il meno importante (anzi ...), ma semplicemente perchè, negli esempi (e negli esercizi) finora riportati, non ne abbiamo avuto bisogno. Infatti, come già per il costruttore senza argomenti e per il costruttore di copia, il C++ fornisce un operatore di assegnazione di default, che copia membro a membro l'oggetto right-operand nell'oggetto left-operand.

Nota

In alcune circostanze si potrebbe non desiderare che un oggetto venga costruito per copia o assegnato. Ma, se non si definiscono overload, il C++ inserirà quelli di default, e se invece li si definiscono, il programma li userà direttamente. Come fare allora? La soluzione è semplice: definire degli overload fittizi e collocarli nella sezione privata della classe; in questo modo gli overload ridefiniti "nasconderanno" quelli di default, ma a loro volta saranno inaccessibili in quanto metodi non pubblici.

L'assegnazione mediante copia membro a membro può essere esattamente ciò che si vuole nella maggioranza dei casi, e quindi non ha senso ridefinire l'operatore. Ma, se la classe possiede membri puntatori, la semplice copia di un puntatore può generare due problemi:

• dopo la copia, l'area precedentemente puntata dal membro puntatore del left-operand resta ancora, cioè occupa spazio, ma non è più accessibile (errore di memory leak);

• il fatto che due oggetti puntino alla stessa area è pericoloso, perché, se viene chiamato il distruttore di uno dei due oggetti, il membro puntatore dell'altro, che esiste ancora, punta a un'area che non esiste più (errore di dangling references).

Come si può notare, il secondo problema è identico a quello che si presenterebbe usando il costruttore di copia di default, mentre il primo è specifico dell'operatore di assegnazione (in quanto la copia viene eseguita su un oggetto già esistente).

Anche in questo caso, è perciò necessario che l'operatore di assegnazione esegua la copia, non del puntatore, ma dell'area puntata. Per evidenziare analogie e differenze, riprendiamo l'esempio del costruttore di copia del capitolo precedente (complicandolo un po', cioè supponendo che l'area puntata sia un array con dimensioni definite in un ulteriore membro della classe), e gli affianchiamo un esempio di corretto metodo di implementazione dell'operatore di assegnazione:

COSTRUTTORE DI COPIA

OPERATORE DI ASSEGNAZIONE

Page 188: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

operazioni : A a1 ; ........ A a2 = a1 ; A a1 , a2 ; ........ a2 = a1 ;

A::A(const A& a) A& A::operator=(const A& a)

{ {

dim = a.dim ; if (this == &a) return *this;

pa = new int [dim] ; if (dim != a.dim)

for(int i=0; i < dim; i++) *(pa+i) = *(a.pa+i) ;

{

} delete [] pa;

dim = a.dim ;

pa = new int [dim] ;

}

CLASSE

class A {

int* pa;

int dim;

public:

A( );

A(const A&);

A& operator= (const A&);

........ }; for(int i=0; i < dim; i++)

*(pa+i) = *(a.pa+i) ;

return *this;

}

Notare:

1. la prima istruzione: if (this == &a) return *this; serve a proteggersi dalla cosidetta auto-assegnazione (a1 = a1); in questo caso la funzione deve restituire l'oggetto stesso senza fare altro;

2. il metodo che implementa l'operatore di assegnazione è un po' più complicato del costruttore di copia, in quanto deve deallocare (con delete) l'area precedentemente puntata dal membro pa di a2 prima di allocare (con new) la nuova area; tuttavia, se le aree puntate dai membri pa di a2 e a1 sono di uguali dimensioni, non è necessario deallocare e riallocare, ma si può semplicemente riutilizzare l'area già esistente di a2 per copiarvi i nuovi dati;

3. entrambi i metodi eseguono la copia (tramite un ciclo for) dell'area puntata e non del puntatore, come avverrebbe se si lasciasse fare ai metodi di default;

4. la classe dovrà contenere altri metodi (o altri costruttori) che si occupano dell'allocazione iniziale dell'area e dell'inserimento dei dati; per semplicità li abbiamo omessi.

Ottimizzazione delle copie

Tanto per ribadire il vecchio detto che "non è saggio chi non si contraddice mai", ci contraddiciamo subito: a volte può essere preferibile copiare i puntatori e non le aree puntate! Anzi, in certi casi può essere utile creare ad-hoc un

Page 189: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

puntatore a un oggetto (apparentemente non necessario), proprio allo scopo di copiare il puntatore al posto dell'oggetto.

Supponiamo, per esempio, che un certo oggetto a1 sia di "grosse dimensioni" e che, a un certo punto del programma, a1 debba essere assegnato a un altro oggetto a2, oppure un altro oggetto a2 debba essere costruito e inizializzato con a1. In entrambi i casi sappiamo che a1 viene copiato in a2. Ma la copia di un "grosso" oggetto può essere particolarmente onerosa, specie se effettuata parecchie volte nel programma. Aggiungasi il fatto che spesso vengono creati e immediatamente distrutti oggetti temporanei, che moltiplicano il numero delle copie, come si evince dal seguente esempio: a2 = f(a1); in questa istruzione vengono eseguite ben 3 copie!

Ci chiediamo a questo punto: ma se, nel corso del programma, a1 e a2 non vengono modificati, che senso ha eseguire materialmente la copia? Solo la modifica di almeno uno dei due creerebbe di fatto due oggetti distinti, ma finchè ciò non avviene, la duplicazione "prematura" sarebbe un'operazione inutilmente costosa. In base a questo ragionamento, se si riuscisse a creare un meccanismo, che, di fronte a una richiesta di copia, si limiti a "prenotarla", ma ne rimandi l'esecuzione al momento dell'eventuale modifica di uno dei due oggetti (copy on write), si otterrebbe lo scopo di ottimizzare il numero di copie, eliminando tutte quelle che, alla fine, sarebbero risultate inutili.

Puntualmente, è il C++ che mette a disposizione questo meccanismo. L'idea base è quella di "svuotare" la classe (che chiamiamo A) di tutti i suoi dati-membro, lasciandovi solo i metodi (compresi gli eventuali metodi che implementano gli operatori in overload) e al loro posto inserire un unico membro, puntatore a un'altra classe (che chiamiamo Arep). Questa seconda classe, che viene preferibilmente definita come struttura, è detta "rappresentazione" della classe A, e in essa vengono inseriti tutti i dati-membro che avrebbero dovuto essere di A. In questa situazione, si dice che A è implementata come handle (aggancio) alla sua rappresentazione, ma è la stessa rappresentazione (cioè la struttura Arep) che contiene realmente i dati.

Più oggetti di A possono "condividere" la stessa rappresentazione (cioè puntare allo stesso di oggetto di Arep). Per tenere memoria di ciò, Arep deve contenere un ulteriore membro, di tipo int, in cui contare il numero di oggetti di A agganciati; questo numero, inizializzato con 1, viene incrementato ogni volta che è "prenotata" una copia, e decrementato ogni volta che uno degli oggetti di A agganciati subisce una modifica: nel primo caso, la copia viene eseguita solo fra i membri puntatori dei due oggetti di A (in modo che puntino allo stesso oggetto di Arep); nel secondo caso, uno speciale metodo di Arep fa sì che l'oggetto di Arep "si cloni", cioè crei un nuovo oggetto copia di se stesso, su questo esegua le modifiche richeste, e infine ne assegni l'indirizzo al membro puntatore dell'oggetto di A da cui è provenuta la richiesta di modifica. Ovviamente spetta ai metodi di A individuare quali operazioni comportino la modifica di un suo oggetto e attivare le azioni conseguenti che abbiamo descritto. Per concludere, il distruttore di un oggetto di A deve decrementare il contatore di agganci nel corrispondente oggetto di Arep, e poi procedere alla distruzione di detto oggetto solo se il contatore è diventato zero.

Page 190: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Da notare che una rappresentazione è sempre creata nella memoria heap e quindi non ha problemi di lifetime, anche se gli oggetti che l'agganciano sono automatici: questo è particolarmente utile, per esempio, nel passaggio by value degli argomenti e del valore di ritorno fra chiamante e funzione (e viceversa): la copia viene eseguita solo apparentemente, in quanto permane la stessa unica rappresentazione, che sopravvive anche in ambiti di visibilità diversi da quello in cui è stata creata. Per esempio, tornando alla nostra istruzione: a2 = f(a1); almeno 2 delle 3 copie previste non vengono eseguite, in quanto l'oggetto a2 si aggancia direttamente alla rappresentazione creata dall'oggetto locale di f, passato come valore di ritorno (prima copia "risparmiata") e successivamente assegnato ad a2 (seconda copia "risparmiata"); per quello che riguarda la terza copia (passaggio di a1 dal chiamante alla funzione), questa è realmente eseguita solo se il valore locale di a1 è modificato in f, altrimenti entrambi gli oggetti continuano a puntare alla stessa rappresentazione creata nel chiamante, fino a quando f termina e quindi l'a1 locale "muore" senza che la copia sia mai stata eseguita.

E' preferibile che Arep sia una struttura perchè così tutti i suoi membri sono pubblici di default. D'altra parte una rappresentazione di una classe deve essere accessibile solo dalla classe stessa. Pertanto Arep deve essere pubblica per A e privata per il "mondo esterno". Per ottenere questo, bisogna definire Arep "dentro" A (struttura-membro o struttura annidata), nella sua sezione privata (in questo modo non può essere istanziata se non da un metodo di A). Più elegantemente si può inserire in A la semplice dichiarazione di Arep e collocare esternamente la sua definizione; in questo caso, però, il suo nome deve essere qualificato: struct A::Arep { ........ };

Nell'esercizio che riportiamo come esempio tentiamo una "rudimentale" implementazione di una classe "stringa", al solo scopo di fornire ulteriori chiarimenti su quanto detto (l'esercizio è eccezionalmente molto commentato). Non va utilizzato nella pratica, in quanto la Libreria Standard fornisce una classe per la gestione delle stringhe molto più completa.

Nel prossimo esercizio consideriamo i tempi delle copie di oggetti del tipo "stringa" implementato come nell'esercizio precedente (cioè come handle a una rappresentazione), e li confrontiamo con i tempi ottenuti copiando le stringhe direttamente.

Espressioni-operazione

Quando si ha a che fare con espressioni che contengono varie operazioni, sappiamo che ogni operazione crea un oggetto temporaneo, che è usato come operando per l'operazione successiva, secondo l'ordine fissato dai criteri

Page 191: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

di precedenza e associatività fra gli operatori. Quando tutte le operazioni di un'espressione sono state eseguite (cioè, come si dice, l'espressione è stata valutata), tutti gli oggetti temporanei creati durante la valutazione dell'espressione vengono distrutti. Pertanto ogni oggetto temporaneo vien costruito, passato come operando, e alla fine, distrutto, senza svolgere altra funzione.

Normalmente ogni operazione viene eseguita mediante chiamata della funzione che implementa l'overload del corrispondente operatore: questa funzione di solito costruisce un oggetto locale, che poi ritorna per copia al chiamante (salvo i casi in cui l'oggetto del valore di ritorno coincida con uno degli operandi, il passaggio non può essere eseguito by reference, perchè l'oggetto locale passato non sopravvive alla funzione). E quindi, in ogni operazione, viene non solo costruito ma anche copiato un oggetto temporaneo!

Se gli oggetti coinvolti nelle operazioni sono di "grosse dimensioni" (e soprattutto se le operazioni sono molte), il costo computazionale per la costruzione e la copia degli oggetti temporanei potrebbe essere troppo elevato, e quindi bisogna trovare il modo di ottimizzare le prestazioni del programma minimizzando tale costo. In pratica bisogna ridurre al minimo:

• il numero degli oggetti temporanei creati; • il numero di copie; • il numero di cicli di operazioni native in cui ogni operazione viene

tradotta.

La tecnica, anche in questo caso, consiste nella semplice "impostazione" di ogni operazione (senza eseguirla), tramite un handle a una struttura, che funge da "rappresentazione" dell'operazione stessa; solo alla fine, l'intera espressione viene eseguita tutta in una volta, senza creazione di oggetti temporanei, con il minimo numero possibile di cicli, e senza copie di passaggio. Questa tecnica sostanzialmente tratta un'espressione come unica operazione, traducendo n operatori binari in un solo operatore con n+1 operandi.

Supponiamo, per esempio, di avere la seguente espressione:

a = b * c + d ;

e supponiamo per semplicità (anche se non è obbligatorio) che gli oggetti: a, b, c e d appartengano tutti alla stessa classe A. Siamo in presenza di tre operazioni binarie (che, nell'ordine di esecuzione sono: moltiplicazione, somma e assegnazione), ma vogliamo, per l'occasione, trasformarle in un'unica operazione "quaternaria" che esegua, in un sol colpo, l'intera espressione. Per ottenere questo, procediamo nel seguente modo:

1. definiamo un overload della moltiplicazione fra due oggetti di A, che, anzichè eseguire l'operazione, si limita a istanziare una struttura di appoggio (che chiamiamo M), la quale non fa altro che memorizzare i riferimenti ai due operandi (in altre parole, il suo costruttore inizializza due suoi membri, dichiarati come riferimenti ad A, come alias di b e c); a sua volta, M contiene un metodo di casting ad A, che esegue materialmente la moltiplicazione, ma che viene chiamato solo

Page 192: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

se l'operazione rientra in un altro contesto (ricordiamo che, nella scelta dell'overload più appropriato, il compilatore cerca prima fra quelli in cui i tipi degli operandi coincidono esattamente, e poi fra quelli in cui la coincidenza si ha tramite una conversione di tipo);

2. definiamo un overload della somma fra un oggetto di M e un oggetto di A, che, anche in questo caso, si limita a istanziare una struttura di appoggio (che chiamiamo MS), la quale, esattamente come M, memorizza i riferimenti ai due operandi e contiene un metodo di casting ad A;

3. infine, definiamo un overload del costruttore e dell'operatore di assegnazione di A, entrambi con un oggetto di MS come argomento, ed entrambi che chiamano un metodo privato di A, il quale è proprio quello deputato ad eseguire, in modo ottimizzato, l'intera operazione.

Page 193: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Eredita'

L'eredità in C++

L'eredità domina e governa tutti gli aspetti della vita. Non solo nel campo della genetica, ma anche nello stesso pensiero umano, i concetti si aggregano e si trasmettono secondo relazioni di tipo "genitore-figlio": ogni concetto complesso non si crea ex-novo, ma deriva da concetti più semplici, che vengono "ereditati" e integrati con ulteriori approfondimenti. Per esempio, alle elementari si impara l'aritmetica usando "mele e arance", alle medie si applicano le nozioni dell'aritmetica per studiare l'algebra, al liceo si descrivono le formule chimiche con espressioni algebriche; ma un professore di chimica non penserebbe mai di insegnare la sua materia ripartendo dalle mele e dalle arance!

E quindi è lo stesso processo conoscitivo che si sviluppa e si evolve attraverso l'eredità. Eppure, esisteva, fino a pochi anni fa, un campo in cui questo principio generale non veniva applicato: quello dello sviluppo del software (!), che, pur utilizzando strumenti tecnologici "nuovi" e "avanzati", era in realtà in "ritardo" rispetto a tutti gli altri aspetti della vita: i programmatori continuavano a scrivere programmi da zero, cioè ripartivano proprio, ogni volta, dalle mele e dalle arance!

In realtà le cose non stanno proprio così: anche i linguaggi di programmazione precedenti al C++ (compreso il C) applicano una "specie" di eredità nel momento in cui mettono a disposizione le loro librerie di funzioni: un programmatore può utilizzarle se soddisfano esattamente le esigenze del suo problema specifico; ma, quando ciò non avviene (come spesso capita), non esiste altro modo che ricopiare le funzioni e modificarle per adattarle alle proprie esigenze; questa operazione comporta il rischio di introdurre errori, che a volte sono ancora più difficili da localizzare di quando si riscrive il programma da zero!

Il C++ consente invece di applicare lo stesso concetto di eredità che è nella vita reale: gli oggetti possono assumere, per eredità, le caratteristiche di altri oggetti e aggiungere caratteristiche proprie, esattamente come avviene nell'evoluzione del processo conoscitivo. Ed è questa capacità di uniformarsi alla vita reale che rende il C++ più potente degli altri linguaggi: il C++ vanta caratteristiche peculiari di estendibilità, riusabilità, modularità, e manutenibilità, proprio grazie ai suoi meccanismi di uniformizzazione alla vita reale, quali il data hiding, il polimorfismo, l'overload e, ora, l'eredità.

Classi base e derivata

In C++ con il termine "eredità" si intende quel meccanismo per cui si può creare una nuova classe, detta classe figlia o derivata, trasferendo in essa tutti i membri di una classe esistente, detta classe genitrice o base.

Page 194: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

La relazione di eredità si specifica nella definizione della classe derivata (supponendo che la classe base sia già stata definita), inserendo, dopo il nome della classe e prima della parentesi graffa di apertura, il simbolo ":" seguito dal nome della classe base, come nel seguente esempio: class B : A { ........ } ; questa scrittura significa che la nuova classe B possiede, oltre ai membri elencati nella propria definizione, anche quelli ereditati dalla classe esistente A.

L'eredità procede con struttura gerarchica, o ad albero (come le subdirectories nell'organizzazione dei files) e quindi una stessa classe può essere derivata da una classe base e contemporaneamente genitrice di una o più classi figlie. Quando ogni classe figlia ha una sola genitrice si dice che l'eredità è "singola", come nel seguente grafico:

Se una classe figlia ha più classi genitrici, si dice che l'eredità è "multipla", come nel seguente grafico, dove la classe AB è figlia delle classi A3 e B4, e la classe B23 è figlia delle classi B2 e B3:

Page 195: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Nella definizione di una classe derivata per eredità multipla, le due classi genitrici vanno indicate entrambe, separate da una virgola: class AB : A3, B4 { ........ } ;

Accesso ai membri della classe base

Introducendo le classi, abbiamo illustrato il significato degli specificatori di accesso private: e public:, e abbiamo soltanto accennato all'esistenza di un terzo specificatore: protected:. Ora, in relazione all'eredità, siamo in grado di descrivere completamente i tre specificatori:

• private: (default) indica che tutti i membri seguenti sono privati, e non possono essere ereditati;

• public: indica che tutti i membri seguenti sono pubblici, e possono essere ereditati;

• protected: indica che tutti i membri seguenti sono protetti, nel senso che sono privati, ma possono essere ereditati;

Quindi, un membro protetto è inaccesibile dall'esterno, come i membri privati, ma può essere ereditato, come i membri pubblici.

In realtà, esiste un'ulteriore restrizione, che ha lo scopo di rendere il data-hiding ancora più profondo: l'accessibilità dei membri ereditati da una classe base dipende anche dallo "specificatore di accesso alla classe base", che deve essere indicato come nel seguente esempio: class B : spec.di accesso A { ........ } ; dove spec.di accesso può essere: private (default), protected o public (notare l'assenza dei due punti). Ogni membro ereditato avrà l'accesso più "restrittivo" fra il proprio originario e quello indicato dallo specificatore di accesso alla classe base, come è chiarito dalla seguente tabella:

Specificatori di accesso alla classe base

private protected public Accesso dei membri nella classe base Accessibilità dei membri ereditati

private: inaccessibili inaccessibili inaccessibili

protected: privati protetti protetti

public: privati protetti pubblici

e quindi un membro ereditato è pubblico solo se è public: nella classe base e l'accesso della classe derivata alla classe base è public.

Page 196: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Se una classe derivata è a sua volta genitrice di una nuova classe, in quest'ultima l'accesso ai membri ereditati è governato dalle stesse regole, che vengono però applicate esclusivamente ai membri della classe "intermedia", indipendentemente da come questi erano nella classe base. In altre parole, ogni classe "vede" la sua diretta genitrice, e non si preoccupa degli altri eventuali "ascendenti".

Normalmente l'accesso alla classe base è public. In alcune circostanze, tuttavia, si può volere che i suoi membri pubblici e protetti, ereditati nella classe derivata, siano accessibili unicamente da funzioni membro e friend della classe derivata stessa: in questo caso, occorre che lo specificatore di accesso alla classe base sia private; analogamente, se si vuole che i membri pubblici e protetti di una classe base siano accessibili unicamente da funzioni membro e friend della classe derivata e di altre eventuali classi derivate da questa, occorre che lo specificatore di accesso alla classe base sia protected.

Conversioni fra classi base e derivata

Si dice che l'eredità è una relazione di tipo "is a" (un cane è un mammifero, con caratteristiche in più che lo specializzano). Quindi, se due classi, A e B, sono rispettivamente base e derivata, gli oggetti di B sono (anche) oggetti di A, ma non viceversa.

Ne consegue che le conversioni implicite di tipo da B ad A (cioè da classe derivata a classe base) sono sempre ammesse (con il mantenimento dei soli i membri comuni), e in particolare ogni puntatore (o riferimento) ad A può essere assegnato o inizializzato con l'indirizzo (o il nome) di un oggetto di B. Questo permette, quando si ha a che fare con una gerarchia di classi, di definire all'inizio un puntatore generico alla classe base "capostipite", e di assegnargli in seguito (in base al flusso del programma) l'indirizzo di un oggetto appartenente a una qualunque classe della gerarchia. Ciò è particolarmente efficace quando si utilizzano le "funzioni virtuali", di cui parleremo nel prossimo capitolo.

La conversione opposta, da A a B, non è ammessa (a meno che B non abbia un costruttore con un argomento, di tipo A); fra puntatori (o fra riferimenti) la conversione è ammessa solo se è esplicita, tramite casting. Non è comunque un'operazione che abbia molto senso, tantopiù che possono insorgere errori che sfuggono al controllo del compilatore. Per esempio, supponiamo che mb sia un membro di B (e non di A):

A a;

B& b = (B&)a; b è un alias di a, convertito a tipo B& - il compilatore lo accetta

b.mb = ....... per il compilatore va bene (mb è membro di B), ma in realtà b è un alias di a e mb non è membro di A - access violation ?

Page 197: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Tornando alle conversioni implicite da classe derivata a classe base, c'è da aggiungere che si tratta di conversioni di "grado" molto alto (altrimenti dette "conversioni banali"), cioè accettate da tutti i costrutti (come le conversioni da variabile a costante). Per esempio, il costrutto catch con tipo di argomento X "cattura" le eccezioni di tipo Y (con Y diverso da X), cioè accetta conversioni da Y a X, solo se:

1. X è const Y (o viceversa, solo se l'argomento è passato by value) 2. Y è una classe derivata da X

mentre, per esempio, non accetta conversioni da int a long (o viceversa).

Costruzione della classe base

Una classe derivata non eredita i costruttori e il distruttore della sua classe base. In altre parole ogni classe deve fornire i propri costruttori e il distruttore (oppure utilizzare quelli di default). Quanto detto vale anche per l'operatore di assegnazione, nel senso che, in sua assenza, la classe derivata usa l'operatore di default anzichè ereditare quello eventualmente presente nella classe base.

Ogni volta che una classe derivata è istanziata, entrano in azione automaticamente i costruttori di tutte le classi gerarchicamente superiori, secondo lo stesso ordine gerarchico (prima la classe base "capostipite", poi tutte le altre, e per ultima la classe che deve creare l'oggetto). Analogamente, quando l'oggetto "muore", entrano in azione automaticamente i distruttori delle stesse classi, ma procedendo in ordine inverso (per primo il distruttore dell'oggetto e per ultimo il distruttore della classe base "capostipite").

Per quello che riguarda i costruttori, il fatto che entrino in azione automaticamente comporta il solito problema (vedere il capitolo sui Costruttori e Distruttori degli oggetti), che insorge ogni volta che un oggetto non è costruito con una chiamata esplicita: se è eseguito il costruttore di default, tutto bene, ma come fare se si vuole (o si deve) eseguire un costruttore con argomenti?

Abbiamo visto che questo problema ha una soluzione diversa per ogni circostanza: in pratica ci deve sempre essere "qualcun altro" che si occupi di chiamare il costruttore e fornigli i valori degli argomenti richiesti. Nel caso delle classi ereditate il "qualcun altro" è rappresentato dai costruttori delle classi derivate, ciascuno dei quali deve provvedere ad attivare il costruttore della propria diretta genitrice (non preoccupandosi invece delle eventuali altre classi gerarchicamente superiori). Come già abbiamo visto nel caso di una classe composta, il cui costruttore deve includere le chiamate dei costruttori dei membri-oggetto nella propria lista di inizializzazione, così vale anche per le classi ereditate: ogni costruttore di una classe derivata deve includere nella

Page 198: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

lista di inizializzazione la chiamata del costruttore della propria genitrice. Questa operazione si chiama: costruzione della classe base.

Per chiarire quanto detto, consideriamo per esempio una classe A che disponga di un costruttore con due argomenti:

class A { DEFINIZIONE DEL COSTRUTTORE DI A

protected: A::A(int p, float q) : m1(q), m2(p)

float m1; { .... eventuali altre operazioni del

int m2; costruttore di A }

public: A(int,float);

.... altri membri .... };

Vediamo ora come si deve comportare il costruttore di una classe B, derivata di A:

class B : public A { DEFINIZIONE DEL COSTRUTTORE DI B

int n; B::B(int a, int b, float c) : n(b), A(a,c)

public: B(int,int,float); { .... eventuali altre operazioni del

.... altri membri .... }; costruttore di B }

Come si può notare, il costruttore di B deve inserire la chiamata di quello di A nella propria lista di inizializzazione (se non lo fa, e il costruttore di A esiste, cioè non è chiamato di default, il C++ dà errore); ovviamente l'ordine originario degli argomenti del costruttore di A va rigorosamente mantenuto.

Nel caso che B sia a sua volta genitrice di un'altra classe C, il costruttore di C deve includere nella propria lista di inizializzazione il termine: B(a,b,c), cioè la chiamata del costruttore di B, ma non il termine A(a,c), chiamata del costruttore di A.

Il costruttore di una classe derivata non può inizializzare direttamente i membri ereditati dalla classe base: rifacendoci all'esempio, il costruttore di B non può inizializzare i membri m1 e m2 ereditati da A, ma lo può fare solo indirettamente, invocando il costruttore di A.

Notiamo infine che il costruttore di A è dichiarato public: ciò significa che la classe A può essere anche istanziata indipendentemente. Se però fosse dichiarato protected, il costruttore di B lo "vedrebbe" ancora e quindi potrebbe invocarlo ugualmente nella propria lista di inizializzazione, ma gli utenti esterni non potrebbero accedervi. Un modo per occultare una classe base (rendendola disponibile solo per le sue classi derivate) è pertanto quello di dichiarare tutti i suoi costruttori nella sezione protetta.

Page 199: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Regola della dominanza

Finora, negli esempi abbiamo attribuito sempre (e deliberatamente) nomi diversi ai membri delle classi. Ci chiediamo adesso: cosa succede nel caso che esista un membro della classe derivata con lo stesso nome di un membro della sua classe base? Può insorgere un conflitto fra i nomi, oppure (nel caso che il membro sia un metodo) si applicano le regole dell'overload? La risposta ad entrambe le domande è: NO. In realtà si applica una regola diversa, detta regola della "dominanza": viene sempre scelto il membro che appartiene alla stessa classe a cui appartiene l'oggetto.

Per esempio, se due classi, A e B, sono rispettivamente base e derivata e possiedono entrambe un membro di nome mem, l'operazione: ogg.mem seleziona il membro mem di A se ogg è istanza di A, oppure il membro mem di B se ogg è istanza di B.

Volendo invece selezionare forzatamente uno dei due, bisogna qualificare il nome del membro comune mediante il solito operatore di risoluzione della visibilità. Per esempio: ogg.A::mem seleziona sempre il membro mem di A, anche se ogg è istanza di B.

La regola della dominanza può essere sfruttata per modificare i membri ereditati (soprattutto per quello che riguarda i metodi): l'unico sistema è quello di ridichiararli con lo stesso nome, garantendosi così che saranno i nuovi membri, e non gli originari, ad essere utilizzati in tutti gli oggetti della classe derivata. Non è comunque possibile diminuire il numero dei membri ereditati: le funzioni "indesiderate" potrebbero essere ridefinite con "corpo nullo", ma non si può fare di più.

Eredità e overload

Se vi sono due metodi con lo stesso nome, uno della classe base e l'altro della classe derivata, abbiamo visto che vale la regola della dominanza e non quella dell'overload. Ciò è vero anche se le due funzioni hanno tipi di argomenti diversi e, in base all'overload, verrebbe selezionata la funzione che appartiene alla classe a cui non appartiene l'oggetto.

Per fare un esempio (riprendendo quello precedente), supponiamo che ogg sia un'istanza della classe derivata B, e che entrambe le classi possiedano un metodo, di nome fun, con un argomento di tipo double nella classe A e di tipo int nella classe B: A::fun(double) B::fun(int) in esecuzione, la chiamata: ogg.fun(10.7)

Page 200: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

non considera l'overload e seleziona comunque la fun di B con argomento int, operando una conversione implicita da 10.7 a 10

Questo comportamento deriva in realtà da una regola più generale: l'overload non si applica mai fra funzioni che appartengono a due diversi ambiti di visibilità, anche se i due ambiti corrispondono a una classe base e alla sua classe derivata e quindi la funzione della classe base è accessibile nella classe derivata per eredità.

La dichiarazione using

Abbiamo già incontrato l'istruzione di "using-declaration", parlando dei namespace, e sappiamo che serve a rendere accessibile un membro di un namespace nello stesso ambito in cui è inserita l'istruzione stessa.

Analogamente, una using-declaration si può inserire nella definizione di una classe derivata per trasferire nel suo ambito un membro della classe base. Riprendendo il solito esempio, supponiamo ora di inserire nella definizione di B l'istruzione: using A::fun; (notare che il nome fun appare da solo, senza argomenti e senza parentesi). Adesso sì che entrambe le funzioni sono nello stesso ambito di visibilità e quindi si può applicare l'overload. Pertanto la chiamata: ogg.fun(10.7) selezionerà correttamente la funzione con argomento double, cioè la fun di A.

Una using-declaration, se non si riferisce a un namespace, può essere inserita esclusivamente nella definizione di una classe derivata e può riferirsi esclusivamente a un membro della classe base. Non sono ammessi altri usi. Una using-directive può essere usata solo con i namespace.

Una using-declaration, inserita nella definizione di una classe derivata, può avere un altro effetto, oltre a quello di rendere possibile l'overload: permette di modificare l'accesso ai membri della classe base. Infatti, se un membro della classe base è protetto (non se è privato), oppure se lo specificatore di accesso alla classe base è protected o private, e la using-declaration è inserita nella sezione pubblica della classe derivata, quel membro diventa pubblico. Questo fatto può essere utilizzato per specificare interfacce che mettono a disposizione degli utenti parti selezionate di una classe.

Page 201: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Eredità multipla e classi basi virtuali

Supponiamo che una certa classe C derivi, per eredità multipla, da due classi genitrici B1 e B2. Nella definizione di C, il nome di ognuna delle due classi base deve essere preceduto dal rispettivo specificatore di accesso (se non è private, che, ricordiamo, è lo specificatore di default). Per esempio: class C : protected B1, public B2 { ........ } ; in questo caso, nella classe C, i membri ereditati da B1 sono tutti protetti, mentre quelli ereditati da B2 rimangono come erano nella classe base (protetti o pubblici).

Il costruttore di C deve costruire entrambe le classi genitrici, cioè deve includere, nella propria lista di inizializzazione, entrambe le chiamate dei costruttori di B1 e di B2, o meglio, deve includere quei costruttori di B1 o di B2 che non sono di default, considerati indipendentemente (e quindi, a secondo delle circostanze, deve includerli entrambi, o uno solo, o nessuno). Anche nel caso che la classe C non abbia costruttori, è obbligatorio definire esplicitamente il costruttore di default di C (anche con "corpo nullo"), con il solo compito di costruire le classi genitrici (questa operazione non è richiesta solo se anche le classi genitrici sono entrambe istanziate mediante i loro rispettivi costruttori di default).

Supponiamo ora che le classi B1 e B2 derivino a loro volta da un'unica classe base A. Siccome ogni classe derivata si deve occupare solo della sua diretta genitrice, il compito di costruire la classe A è delegato sia a B1 che a B2, ma non a C. Per cui, quando viene istanziata C, sono costruite direttamente soltanto le sue dirette genitrici B1 e B2, ma ciascuna di queste costruisce a sua volta (e separatamente) A; in altre parole, ogni volta che è istanziata C, la sua classe "nonna" A viene costruita due volte (classi base "replicate"), come è illustrato dalla seguente figura:

La replicazione di una classe base può causare due generi di problemi:

Page 202: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• occupazione doppia di memoria, che può essere poco "piacevole", soprattutto se gli oggetti di C sono molti e il sizeof(A) è grande;

• errore di ambiguità: se gli oggetti di C non accedono mai direttamente ai membri ereditati da A, tutto bene; ma, se dovesse capitare il contrario, il compilatore darebbe errore, non sapendo se accedere ai membri ereditati tramite B1 o tramite B2.

Il secondo problema può essere risolto (in un modo però poco "brillante") qualificando ogni volta i membri ereditati da A. Per esempio, se ogg è un'istanza di C e ma è un membro ereditato da A:

ogg.B1::ma indica che ma è ereditato tramite B1

ogg.B2::ma indica che ma è ereditato tramite B2

Entrambi i problemi, invece, si possono risolvere definendo A come classe base "virtuale": questo si ottiene inserendo, nelle definizioni di tutte le classi derivate, la parola-chiave virtual accanto allo specificatore di accesso alla classe base. Esempio: class B1 : virtual protected A { ........ } ; class B2 : virtual public A { ........ } ;

La parola-chiave virtual non ha alcun effetto sulle istanze dirette di B1 e di B2: ciascuna di esse costruisce la propria classe base normalmente, come se virtual non fosse specificata. Ma, se viene istanziata la classe C, derivata da B1 e da B2 per eredità multipla, viene creata una sola copia dei membri ereditati da A, della cui inizializzazione deve essere lo stesso costruttore di C ad occuparsene (contravvenendo alla regola generale che vuole che ogni figlia si occupi solo delle sue immediate genitrici); in altre parole, nella lista di inizializzazione del costruttore di C devono essere incluse le chiamate, non solo dei costruttori di B1 e di B2, ma anche del costruttore di A. In sostanza la parola-chiave virtual dice a B1 e B2 di non prendersi cura di A quando viene creato un oggetto di C, perchè sarà la stessa classe "nipote" C ad occuparsi della sua "nonna".

Pertanto, se una classe base è definita virtuale da tutte le sue classi derivate, viene evitata la replicazione e si realizza la cosidetta eredità a diamante, rappresentata dal seguente grafico:

Page 203: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Sulla reale efficacia dell'eredità multipla esistono a tutt'oggi pareri discordanti: qualcuno sostiene che bisognerebbe usarla il meno possibile, perchè raramente può essere utile ed è meno sicura e più restrittiva dell'eredità singola (per esempio non si può convertire un puntatore da classe base virtuale a classe derivata); altri ritengono al contrario che l'eredità multipla possa essere necessaria per la risoluzione di molti problemi progettuali, fornendo la possibilità di associare due classi altrimenti non correlate come parti dell'implementazione di una terza classe. Questo fatto è evidente in modo particolare quando le due classi giocano ruoli logicamente distinti, come vedremo in un esempio riportato nel prossimo capitolo, a proposito delle classi base astratte.

Page 204: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Polimorfismo

Late binding e polimorfismo

Abbiamo già sentito parlare di late binding trattando dei puntatori a funzione: l'aggancio fra il programma chiamante e la funzione chiamata é ritardato dal momento dalla compilazione a quello dell'esecuzione, perché solo in quella fase il C++ può conoscere la funzione selezionata, in base ai dati che condizionano il flusso del programma. La scelta, tuttavia, avviene all'interno di un insieme ben definito di funzioni, diverse l'una dall'altra non solo nel contenuto ma anche nel nome.

Conosciamo anche il significato di polimorfismo: funzioni-membro con lo stesso nome e gli stessi argomenti, ma appartenenti a oggetti di classi diverse. Nella terminologia del C++, polimorfismo significa: mandare agli oggetti lo stesso messaggio ed ottenere da essi comportamenti diversi, sul modello della vita reale, in cui termini simili determinano azioni diverse, in base al contesto in cui vengono utilizzati.

Tuttavia il polimorfismo che abbiamo esaminato finora é solo apparente: il puntatore "nascosto" this, introdotto dal compilatore, differenzia gli argomenti delle funzioni, e quindi non si tratta realmente di polimorfismo, ma soltanto di overload, cioè di un meccanismo che, come sappiamo, permette al C++ di riconoscere e selezionare la funzione già in fase di compilazione (early binding).

Il "vero" polimorfismo, nella pienezza del suo significato "filosofico", deve essere associato al late binding: la differenziazione di comportamento degli oggetti in risposta allo stesso messaggio non deve essere statica e predefinita, ma dinamica, cioè deve essere determinata dal contesto del programma in fase di esecuzione. Vedremo che ciò é realizzabile solo nell'ambito di una stessa famiglia di classi, e quindi il "vero" polimorfismo non può prescindere dall'eredità e si applica a funzioni-membro, con lo stesso nome e gli stessi argomenti, che appartengono sia alla classe base che alle sue derivate.

Ambiguità dei puntatori alla classe base

Prendiamo il caso di due classi, di nome A e B, dove A é la classe base e B una sua derivata. Consideriamo due istanze, a e b, rispettivamente di A e di B. Supponiamo inoltre che entrambe le classi contengano una funzione-membro, di nome display(), non ereditata da A a B, ma ridefinita in B (traducendo letteralmente il termine inglese "overridden", si suole dire, in questi casi, che la

Page 205: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

funzione display() di A é "scavalcata" nella classe B, ma è un termine "orrendo", che non useremo mai).

Sappiamo che, per la regola della dominanza, ogni volta il compilatore seleziona la funzione che appartiene alla stessa classe a cui appartiene l'oggetto (cioè la classe indicata nell'istruzione di definizione dell'oggetto), e quindi:

a.display() seleziona la funzione-membro di A

b.display() seleziona la funzione-membro di B

Supponiamo ora di definire un puntatore ptr alla classe A e di inizializzarlo con l'indirizzo dell'oggetto a: A* ptr = &a; anche in questo caso la funzione può essere selezionata senza ambiguità e quindi l'istruzione: ptr->display() accede alla funzione display() della classe A.

Abbiamo visto, tuttavia, che a un puntatore definito per una classe base, possono essere assegnati indirizzi di oggetti di classi derivate, e quindi il seguente codice é valido: if(.......) ptr = &a; else ptr = &b; in questo caso, dinanzi all'eventuale istruzione: ptr->display() come si regola il compilatore, visto che l'oggetto a cui punta ptr é determinato in fase di esecuzione? Di default, vale ancora la regola della dominanza e quindi, essendo ptr definito come puntatore alla classe A, viene selezionata la funzione display() della classe A, anche se in esecuzione l'oggetto puntato dovesse appartenere alla classe B.

Funzioni virtuali

Negli esempi esaminati finora, la funzione-membro display() é selezionata in fase di compilazione (early binding); ciò avviene anche nell'ultimo caso, sebbene l'oggetto associato alla funzione sia determinato solo in fase di esecuzione.

Se però, nella definizione della classe A, la funzione display() é dichiarata con lo specificatore "virtual", il C++ rinvia la scelta della funzione appropriata alla fase di esecuzione (late binding). In questo modo si realizza il polimorfismo: lo stesso messaggio (display), inviato a oggetti di classi diverse, induce a diversi comportamenti, in funzione dei dati del programma.

Page 206: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Un tipo dotato di funzioni virtuali è detto: tipo polimorfo. Per ottenere un comportamento polimorfo in C++, bisogna esclusivamente operare all'interno di una gerarchia di classi e alle seguenti condizioni:

1. la dichiarazione delle funzioni-membro della classe base (interessate al polimorfismo) deve essere specificata con la parola-chiave virtual; non è obbligatorio (ma neppure vietato) ripetere la stessa parola-chiave nelle dichiarazioni delle funzioni-membro delle classi derivate (di solito lo si fa per migliorare la leggibilità del programma);

2. una funzione dichiarata virtual deve essere sempre anche definita (senza virtual) nella classe base (al contrario delle normali funzioni che possono essere dichiarate senza essere definite, quando non si usano); invece, una classe derivata non ha l'obbligo di ridichiarare (e ridefinire) tutte le funzioni virtuali della classe base, ma solo quelle che le servono (quelle non ridefinite vengono ereditate);

3. gli oggetti devono essere manipolati soltanto attraverso puntatori (o riferimenti); quando invece si accede a un oggetto direttamente, il suo tipo è già noto al compilatore e quindi il polimorfismo in esecuzione non si attua.

Si può anche aggirare la virtualizzazione, qualificando il nome della funzione con il solito operatore di risoluzione della visibilità. Esempio: ptr->A::display(); in questo caso esegue la funzione della classe base A, anche se questa è stata dichiarata virtual e ptr punta a un oggetto di B.

Tabelle delle funzioni virtuali

Riprendiamo l'esempio precedente, aggiungendo una nuova classe derivata da A, che chiamiamo C; questa classe non ridefinisce la funzione display() ma la eredita da A (come appare nella seguente tabella, dove il termine fra parentesi quadre è facoltativo):

class A { class B : public A { class C : public A {

........ public: ...... ......... public: ...... ..............

virtual void display(); [virtual] void display(); };

}; };

Se ora assegniamo a ptr l'indirizzo di un oggetto che, in base al flusso dei dati in esecuzione, può essere indifferentemente di A, di B o di C, dinanzi a istruzioni del tipo:

Page 207: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

ptr->display() il C++ seleziona in esecuzione la funzione giusta, cioè quella di A se l'oggetto appartiene ad A o a C, quella di B se l'oggetto appartiene a B.

Infatti il C++ prepara, in fase di compilazione, delle tabelle, dette "Tabelle virtuali" o vtables, una per la classe base e una per ciascuna classe derivata, in cui sistema gli indirizzi di tutte le funzioni dichiarate virtuali nella classe base; aggiunge inoltre un nuovo membro in ogni classe, detto vptr, che punta alla corrispondente vtable.

Il seguente diagramma chiarisce quanto detto, nel caso del nostro esempio:

In questo modo, in fase di esecuzione il C++ può risalire, dall'indirizzo contenuto nel membro vptr dell'oggetto puntato da ptr (vptr è un dato-membro e quindi è realmente replicato in ogni oggetto), all'indirizzo della corretta funzione da selezionare.

Costruttori e distruttori virtuali

I distruttori possono essere virtualizzati, anzi, in certe condizioni è praticamente indispensabile che lo siano, se si vuole assicurare una corretta ripulitura della memoria. Infatti, proseguendo con il nostro esempio e supponendo stavolta che gli oggetti siano allocati nell'area heap, l'istruzione: delete ptr; assicura che sia invocato il distruttore dell'oggetto realmente puntato da ptr solo se il distruttore della classe base A è stato dichiarato virtual; altrimenti chiamerebbe comunque il distruttore di A, anche quando, in esecuzione, è stato assegnato a ptr l'indirizzo di un oggetto di una classe derivata.

Viceversa i costruttori non possono essere virtualizzati, per il semplice motivo che, quando è invocato un costruttore, l'oggetto non esiste ancora e quindi non può neppure esistere un puntatore con il suo indirizzo. In altre parole, la nozione di "puntatore a costruttore" è una contraddizione in termini.

Page 208: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Tuttavia è possibile aggirare questo ostacolo virtualizzando, non il costruttore, ma un altro metodo della classe, definito in modo che crei un nuovo oggetto della stessa classe (si deve comunque partire da un oggetto già esistente) e si comporti quindi come un "costruttore polimorfo", in cui il tipo dell' oggetto costruito è determinato in fase di esecuzione.

Vediamo ora un'applicazione pratica di quanto detto. Riprendendo il nostro solito esempio, supponiamo che la classe base A sia provvista di un metodo pubblico così definito:

A* A::clone( ) { return new A(*this); }

come si può notare, la funzione-membro clone crea un nuovo oggetto nell'area heap, invocando il costruttore di copia di A (oppure quello di default se la classe ne è sprovvista) con argomento *this, e ne restituisce l'indirizzo. Ogni oggetto può pertanto generare una copia di se stesso chiamando la clone. Analogamente definiamo una funzione-membro clone della classe derivata B:

A* B::clone( ) { return new B(*this); }

Se ora virtualizziamo la funzione clone, inserendo nella definizione della classe base A la dichiarazione: virtual A* clone(); troviamo in B la ridefinizione di una funzione virtuale, in quanto sono coincidenti il nome (clone), la lista degli argomenti (void) e il tipo del valore di ritorno (A*), e quindi possiamo ottenere da tale funzione un comportamento polimorfo. In particolare l'istruzione:

A* pnew = ptr->clone();

crea un nuovo oggetto nell'area heap e inizializza pnew con l'indirizzo di tale oggetto; il tipo di questo nuovo oggetto è però deciso solo in fase di esecuzione (comportamento polimorfo della funzione clone) e coincide con il tipo puntato da ptr.

Scelta fra velocità e polimorfismo

Il processo early binding è più veloce del late binding, in quanto impegna il C++ solo in compilazione e non crea nuove tabelle o nuovi puntatori; per questo motivo la specifica virtual non è di default. Tuttavia è spesso utile rinunciare a un po' di velocità in cambio di altri vantaggi, come il polimorfismo, grazie al quale è il C++ e non il programmatore a doversi preoccupare di selezionare ogni volta il comportamento appropriato in risposta allo stesso messaggio.

Page 209: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Classi astratte

Nel capitolo "Tipi definiti dall'utente" abbiamo ammesso di utilizzare una nomenclatura "vecchia" identificando indiscriminatamente con il termine "tipo astratto" qualunque tipo non nativo del linguaggio. E' giunto il momento di precisare meglio cosa si intenda in C++ per "tipo astratto".

Una classe base, se definita con funzioni virtuali, "spiega" cosa sono in grado di fare gli oggetti delle sue classi derivate. Nel nostro esempio, la classe base A "spiega" che tutti gli oggetti del programma possono essere visualizzati, ognuno attraverso la propria funzione display(). In sostanza la classe base fornisce, oltre alle funzioni, anche uno "schema di comportamento" per le classi derivate.

Estremizzando questo concetto, si può creare una classe base con funzioni virtuali senza codice, dette funzioni virtuali pure. Non avendo codice, queste funzioni servono solo da "schema di comportamento" per le classi derivate e vanno dichiarate nel seguente modo:

virtual void display() = 0;

(nota: questo è l'unico caso in C++ di una dichiarazione con inizializzazione!) in questo esempio, si definisce che ogni classe derivata avrà una sua funzione di visualizzazione, chiamata sempre con lo stesso nome, e selezionata ogni volta correttamente grazie al polimorfismo.

Una classe base con almeno una funzione virtuale pura è detta classe base astratta, perché definisce la struttura di una gerarchia di classi, ma non può essere istanziata direttamente.

A differenza dalle normali funzioni virtuali, le funzioni virtuali pure devono essere ridefinite tutte nelle classi derivate (anche con "corpo nullo", quando non servono). Se una classe derivata non ridefinisce anche una sola funzione virtuale pura della classe base, rimane una classe astratta e non può ancora essere istanziata (a questo punto, una sua eventuale classe derivata, per diventare "concreta", è sufficiente che ridefinisca l'unica funzione virtuale pura rimasta).

Le classi astratte sono di importanza fondamentale nella programmazione in C++ ad alto livello, orientata a oggetti. Esse presentano agli utenti delle interfacce "pure", senza il vincolo degli aspetti implementativi, che sono invece forniti dalle loro classi derivate. Una gerarchia di classi, che deriva da una o più classi astratte, può essere costruita in modo "incrementale", nel senso di permettere il "raffinamento" di un progetto, aggiungendo via via nuove classi senza la necessità di modificare la parte preesistente. Gli utenti non sono coinvolti, se non vogliono, in questo processo di "raffinamento incrementale", in quanto

Page 210: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

vedono sempre la stessa interfaccia e utilizzano sempre le stesse funzioni (che, grazie al polimorfismo, saranno sempre selezionate sull'oggetto appropriato).

Un rudimentale sistema di figure geometriche

A puro titolo esemplificativo dei concetti finora esposti, si è tentato di progettare l'implementazione di un sistema (molto "rudimentale") di figure geometriche piane. Abbiamo scelto 6 figure, a ciascuna delle quali abbiamo fatto corrispondere una classe:

punto classe Dot

linea classe Line

triangolo classe Triangle

rettangolo classe Rect

quadrato classe Square

cerchio classe Circle

Tutte queste classi fanno parte di una gerarchia, al cui vertice si trova un'unica classe base astratta, di nome Shape, che contiene esclusivamente un distruttore virtuale (con "corpo nullo") e alcune funzioni virtuali pure. La classe Shape presenta, quindi, una pura interfaccia, non possedendo dati-membro nè funzioni-membro implementate, e non può essere istanziata (il compilatore darebbe errore).

Dalla classe Shape derivano due classi, anch'esse astratte, di nome Polygon e Regular (per la precisione, Polygon non è astratta, ma il suo costruttore è inserito nella sezione protetta e quindi non può essere istanziata dall'esterno; Regular, invece, è astratta, in quanto non ridefinisce tutte le funzioni virtuali pure di Shape).

Finalmente, le classi "concrete" derivano tutte da Polygon e Regular: Dot, Line, Triangle e Rect derivano da Polygon; Circle deriva da Regular; Square deriva da Polygon e Regular, per eredità multipla. Si configura così il seguente schema:

Page 211: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

A queste classi si aggiungono due strutture di appoggio: Point, che fornisce le coordinate dei punti sul piano, e Shape_Error, per la gestione delle eccezioni. Il tutto è racchiuso in un unico namespace, di nome mini_graphics, che contiene anche alcune costanti e alcune funzioni esterne alle classi, fra cui due operatori in overload per la lettura e scrittura di oggetti di Point. Il fatto che tutte le componenti del sistema appartengano a un namespace permette di evitare i potenziali conflitti di nomi, in verità molto comuni, come Line e Rect, con nomi uguali forniti da altre librerie ed eventualmente messi a disposizione da queste tramite using-directives. Volendo, l'utente provvederà ad inserire, negli ambiti locali del main e delle sue funzioni, le using-declarations necessarie; a questo proposito viene fornito un header-file contenente tutte le using-declarations dei nomi del namespace che possono essere visti dall'utente.

Il sistema è accessibile dall'esterno esclusivamente attraverso le funzioni virtuali pure di Shape, ridefinite nelle classi "concrete"; per cui, definito un puntatore a Shape, è possibile tramite questo sfruttare il polimorfismo e chiamare ogni volta la funzione-membro dell'oggetto "reale" selezionato in fase di esecuzione. Non tutte le funzioni, però, sono compatibili con tutti gli oggetti (per esempio una funzione che fornisce due punti può essere usata per definire una linea o un rettangolo, ma non per definire un triangolo); d'altra parte, in ogni classe "concreta", tutte le funzioni virtuali pure vanno ridefinite, e ciò ha costituito un problema, che poteva essere risolto in due modi:

1. in ogni classe, ridefinire con "corpo nullo" tutte le funzioni incompatibili (ma in questo modo l'utente non sarebbe stato informato del suo errore);

2. oppure ridefinire tali funzioni in modo da sollevare un'eccezione (ed è quello che è stato fatto): le funzioni di questo tipo sono state collocate nelle classi "intermedie" Polygon e Regular, e quindi non hanno avuto bisogno di essere ridefinite nelle classi "concrete" (dove sono ridefinite solo le funzioni "compatibili").

Le funzioni virtuali di Shape sono in tutto 11, divise in 4 gruppi e precisamente:

• funzioni set (con 5 overloads) per impostare i parametri caratteristici di ogni figura (per esempio, le coordinate del bottom-left-corner e del top-right-corner di un rettangolo); all'inizo, i costruttori (di default) delle classi "concrete" generano figure precostituite;

Page 212: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• 4 funzioni get... per estrarre informazioni dalle figure (per esempio, le coordinate di un vertice di un poligono, oppure la lunghezza del diametro di un cerchio ecc...);

• una funzione display per la visualizzazione (non grafica) dei parametri di una figura (per esempio le coordinate dei punti estremi di una linea o dei quattro vertici di un quadrato ecc...);

• una funzione copy_from per copiare una figura da un'altra; se si tenta la copia fra due figure diverse è sollevata un'eccezione, salvo in questi casi:

1. copia da quadrato a rettangolo (ammessa in quanto il quadrato è un caso particolare di rettangolo);

2. copia da quadrato a cerchio (ricava il cerchio iscritto al quadrato);

3. copia da cerchio a quadrato (ricava il quadrato circoscritto al cerchio)

In effetti, si tratta di un sistema assolutamente "minimale". Ma il nostro scopo non era quello di generare un prodotto finito, bensì di mostrare "come si pone il primo mattone di una casa". Infatti (e questa è la caratteristica principale della programmazione a oggetti che sfrutta il polimorfismo) il sistema si presta ad essere agevolmente incrementato in maniera modulare, in tre direzioni:

• si possono aggiungere nuove figure (e cioè nuove classi) che ridefiniscono le stesse funzioni virtuali pure di Shape, e quindi si può ampliare la gerarchia senza modificare nulla dell'esistente;

• si possono aggiungere nuove funzionalità (per esempio, trasformazioni di coordinate, traslazioni, rotazioni, variazioni della scala ecc...); in questo caso bisogna apportare qualche modifica al progetto, ma pur sempre in maniera "incrementale";

• si possono creare infine altre gerarchie di classi, che eseguono operazioni "specializzate", come per esempio la visualizzazione grafica delle figure su un dato dispositivo (come vedremo nell'esercizio della prossima sezione); il fatto importante è che l'introduzione delle nuove gerarchie non comporta alcuna modifica della gerarchia Shape, ma si limita a creare degli "agganci" ad essa, preservando il requisito fondamentale di minimizzare le dipendenze fra i moduli, che è alla base di una corretta programmazione. Infatti, per come è stata progettata, la gerarchia Shape è "device-independent" è può essere visualizzata su qualunque dispositivo grafico.

Particolare cura è stata dedicata alla gestione delle eccezioni. Sono stati individuati quattro tipi di errori possibili:

• errori di input nell'inserimento dei dati (per esempio, digitazione di caratteri diversi quando sono richieste cifre numeriche);

• tentativi di generare figure geometriche "improprie" (per esempio un triangolo con i tre vertici allineati);

• tentativi di eseguire funzioni incompatibili con la figura selezionata; • tentativi di eseguire copie fra figure diverse (salvo nei casi sopraelencati).

Osserviamo, per concludere, che l'introduzione di una classe derivata per eredità multipla (Square) ha generato qualche piccolo problema aggiuntivo e richiesto una particolare attenzione:

Page 213: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• anzitutto, per evitare la replicazione della classe base, si è dovuto inserire virtual nelle specifiche di accesso a Shape di Polygon e Regular (e quindi Shape, oltre a essere astratta è anche virtuale .... più "irreale" di così....!);

• in secondo luogo si sono dovute rifedinire in Square tutte le funzioni virtuali pure di Shape (comprese quelle "incompatibili"); altrimenti, il "doppio percorso" da Square a Shape avrebbe generato messaggi di errore per ambiguità (infatti, se una funzione non è ridefinita è ereditata: ma allora, in questo caso, sarebbe ereditata da Polygon o da Regular?).

Un rudimentale sistema di visualizzazione delle figure

Proseguendo nell'esempio precedente, costruiamo ora una nuova gerarchia di classi, con lo scopo di visualizzare su un dispositivo grafico le figure definite dalla gerarchia Shape. Non avendo niente di meglio a disposizione, abbiamo scelto una ("rudimentalissima") implementazione grafica costituita da caratteri ASCII, nella quale ogni punto del piano immagine è rappresentato da un carattere ("big pixel") e quindi "disegnare" un punto significa collocare nella posizione corrispondente un carattere adeguato (per esempio un asterisco). La bassissima risoluzione di un simile sistema "grafico" produrrà figure sicuramente distorte e poco definite, ma che quello che ci preme sottolineare non è l'efficacia del prodotto, bensì il metodo utilizzato per la sua implementazione. Il lettore potrà immaginarsi, al posto di questo sistema, una libreria grafica dotata delle più svariate funzionalità e atta a lavorare su dispositivi ad alta risoluzione; ma il "metodo" per implementare tale libreria, mettendola in relazione con le figure di Shape, sarebbe esattamente lo stesso.

La classe base della nostra nuova gerarchia si chiama ASC_Screen: è una classe astratta, in quanto possiede una funzione virtuale pura, di nome draw, così dichiarata: virtual void draw() = 0; Tuttavia, a differenza da Shape che presenta una pura interfaccia, ASC_Screen deve fornire gli strumenti per l'implementazione della grafica su un dispositivo "concreto" e quindi è stata dotata di tutte le proprietà e i metodi adeguati allo scopo. Poichè d'altra parte lo schermo è "unico" indipendentemente dal numero degli oggetti (cioè delle figure) presenti, tutti i dati-membro e le funzioni-membro di ASC_Screen (fuorchè draw) sono stati definiti static. Persino il costruttore e il distruttore (che ovviamente non possono essere definiti static) si comportano in realtà come metodi statici: il primo alloca la memoria "grafica" solo in occasione del primo oggetto creato, il secondo libera tale memoria solo quando tutti gli oggetti sono stati distrutti (per riconoscere tali condizioni è usato un membro statico "contatore" degli oggetti, incrementato dal costruttore e decrementato dal distruttore).

Page 214: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Alcuni metodi di ASC_Screen sono accessibili dall'utente e quindi sono pubblici; altri sono protetti, in quanto accessibili solo dalle classi derivate, e altri sono privati, per solo uso interno. Tutti i dati-membro sono privati.

La classe ASC_Screen ha quindi una duplice funzione: quella di essere una base astratta per gli oggetti delle sue classi derivate, che ridefiniscono la funzione virtuale pura draw per eseguire i disegni; e quella di fornire, a livello della classe e non del singolo oggetto, tutte le funzionalità e i dati necessari per l'implementazione del sistema.

Ed è a questo punto che entra in gioco l'eredità multipla, la quale permette una soluzione semplice, pulita ed efficace al tempo stesso: ogni classe derivata da ASC_Screen, che rappresenta una figura da graficare, deriva anche dalla corrispondente classe di Shape: in questo modo, da una parte si ereditano le caratteristiche generali di una figura, che sono "device-independent", e dall'altra le funzionalità necessarie per il disegno della stessa figura su un particolare device.

Le classi derivate da ASC_Screen hanno gli stessi nomi delle corrispondenti di Shape, con il prefisso ASC_ (e quindi: ASC_Dot, ASC_Line, ecc...). Ogni classe possiede un unico membro, che ridefinisce la funzione virtuale pura draw. Non serve nient'altro, in quanto tutto il resto è ereditato dalle rispettive genitrici.

La situazione complessiva è adesso rappresentata dal seguente disegno (la gerarchia ASC_Screen è "a testa in giù", per ragioni di spazio):

Nell'esercizio che segue viene visualizzato il disegno di una casa "in stile infantile", in cui ogni componente (pareti, tetto, porte, finestre ecc...) è costituito da una figura geometrica elementare. In tutto sono definiti 24 oggetti e due array di 24 puntatori, uno a Shape e l'altro a ASC_Screen. L'indirizzo di ogni oggetto è assegnato al corrispondente puntatore (in entrambi gli array), così che è possibile, per ogni figura, chiamare in modo polimorfo sia le funzioni di Shape che la draw di ASC_Screen. Quest'ultima non esegue materialmente la visualizzazione, ma si limita ad inserire degli asterischi (nelle posizioni che

Page 215: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

definiscono il contorno della figura) in una matrice bidimensionale di caratteri (allocata e inizializzata dal costruttore del primo oggetto); poichè ogni riga della matrice è terminata con un null, si vengono così a costituire tante stringhe quante sono le righe. Alla fine, per visualizzare il tutto, il programma può chiamare il metodo pubblico ASC_Screen::OnScreen(), il quale non fa altro che scrivere le stringhe sullo schermo, l'una sotto l'altra.

Il sistema è pure dotato ("sorprendentemente") di alcune funzionalità più "avanzate", quali il clipping (lo schermo funge da "finestra" che visualizza solo una parte dell'immagine, se questa ha un'estensione maggiore), il moving (possibilità di spostare il centro della "finestra" su un qualunque punto dell'immagine) e lo zoomming (possibilità di ingrandire o rimpicciolire l'immagine intorno al centro della "finestra"). Tutte queste operazioni vengono eseguite chiamando degli opportuni metodi pubblici di ASC_Screen.

Page 216: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Template

Programmazione generica

Nello sviluppo di questo corso, siamo "passati" attraverso vari tipi di "programmazione", che in realtà perseguono sempre lo stesso obiettivo (suddivisione di un progetto in porzioni indipendenti, allo scopo di minimizzare il rapporto costi/benefici nella produzione e manutenzione del software), ma che via via tendono a realizzare tale obiettivo a livelli sempre più profondi:

• programmazione procedurale: è la programmazione caratteristica del linguaggio C (e di tutti gli altri linguaggi precedenti al C++). L'interesse principale è focalizzato sull'elaborazione e sulla scelta degli algoritmi più idonei a massimizzarne l'efficienza. Ogni algoritmo lavora in una funzione, a cui si passano argomenti e da cui si ottiene un valore di ritorno. Le funzioni sono implementate con gli strumenti tipici del linguaggio (tipi, variabili, puntatori, costrutti vari ecc...). Dal punto di vista dell'utente ogni funzione è una "scatola nera" e i suoi argomenti e valore di ritorno sono gli unici canali di comunicazione.

• programmazione modulare: l'attenzione si sposta dal progetto delle procedure all'organizzazione dei dati. Ogni gruppo formato da dati logicamente correlati e dalle procedure che li utilizzano costituisce un modulo, in cui i dati sono "occultati" (data hiding). I moduli sono il più possibile indipendenti. Le interfacce costituiscono l'unico canale di comunicazione fra i moduli e i loro utenti. I namespace sono gli strumenti che il C++ mette a disposizione per realizzare questo tipo di programmazione.

• programmazione a oggetti: l'attenzione si sposta ulteriormente dai moduli ai singoli oggetti. Attraverso le classi, esiste la possibilità di definire nuovi tipi. I membri di ogni classe possono essere sia dati che funzioni e solo alcuni di essi possono essere accessibili dall'esterno. Il data hiding si trasferisce dentro gli oggetti, che diventano entità attive e autosufficienti e comunicano con gli utenti solo attraverso i propri membri pubblici. Ogni nuovo tipo può essere corredato di un insieme di operazioni (overload degli operatori) e ulteriormente espanso e specializzato in modo incrementale e indipendente dal codice già scritto, grazie all'eredità e al polimorfismo.

Un ulteriore "salto di qualità" è rappresentato dalla cosidetta "programmazione generica", la quale consente di applicare lo stesso codice a tipi diversi, cioè di definire template (modelli) di classi e funzioni parametrizzando i tipi utilizzati: nelle classi, si possono parametrizzare i tipi dei dati-membro; nelle funzioni (e nelle funzioni-membro delle classi) si possono parametrizzare i tipi degli argomenti e del valore di ritorno. In questo modo si raggiunge il massimo di indipendenza degli algoritmi dai dati a cui si applicano: per esempio, un algoritmo di ordinamento può essere scritto una sola volta, qualunque sia il tipo dei dati da ordinare.

I template sono risolti staticamente (cioè a livello di compilazione) e pertanto non comportano alcun costo aggiuntivo in fase di esecuzione; sono

Page 217: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

invece di enorme utilità per il programmatore, che può scrivere del codice "generico", senza doversi preoccupare di differenziarlo in ragione della varietà dei tipi a cui tale codice va applicato. Ciò è particolarmente vantaggioso quando si possono creare classi strutturate identicamente, ma differenti solo per i tipi dei membri e/o per i tipi degli argomenti delle funzioni-membro.

La stessa Libreria Standard del C++ mette a disposizione strutture precostituite di classi template, dette classi contenitore (liste concatenate, mappe, vettori ecc...) che possono essere utilizzate specificando, nella creazione degli oggetti, i valori reali da sostituire ai tipi parametrizzati.

Definizione di una classe template

Una classe (o struttura) template è identificata dalla presenza, davanti alla definizione della classe, dell'espressione:

template<class T>

dove T (che è un nome e segue le normali regola di specifica degli identificatori) rappresenta il parametro di un tipo generico che verrà utilizzato nella dichiarazione di uno o più membri della classe. In questo contesto la parola-chiave class non ha il solito significato: indica che T è il nome di un tipo (anche nativo), non necessariamente di una classe. L'ambito di visibilità di T coincide con quello della classe. Se però una funzione-membro non è definita inline ma esternamente, bisogna, al solito, qualificare il suo nome: in questo caso la qualificazione completa consiste nel ripetere il prefisso template<class T> ancora prima del tipo di ritorno (che in particolare può anche dipendere da T) e inserire <T> dopo il nome della classe. Esempio:

Definizione della classe template A

template<class T> class A {

T mem ; dato-membro di tipo parametrizzato

public:

A(const T& m) : mem(m) { } costruttore inline con un argomento di tipo parametrizzato

T get( ); dichiarazione di funzione-membro con valore di ritorno di tipo parametrizzato

........ };

Page 218: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Definizione esterna della funzione-membro get( )

template<class par> par A<par>::get( )

notare che il nome del parametro può

{ anche essere diverso da quello usato nella

return mem ; definizione della classe

}

NOTA

Nella definizione della funzione get la ripetizione del parametro par nelle espressioni template<class par> e A<par> potrebbe sembrare ridondante. In realtà le due espressioni hanno significato è diverso:

• template<class par> introduce, nel corrente ambito di visibilità (in questo caso della funzione get), il nome par come parametro di template;

• A<par> indica che la classe A è un template con parametro par.

In generale, ogni volta che una classe template è riferita al di fuori del proprio ambito (per esempio come argomento di una funzione), è obbligatorio specificarla seguita dal proprio parametro fra parentesi angolari.

I parametri di un template possono anche essere più di uno, nel qual caso, nella definizione della classe e nelle definizioni esterne delle sue funzioni-membro, tutti i parametri vanno specificati con il prefisso class e separati da virgole. Esempio:

template<class par1,class par2,class par3>

I template vanno sempre definiti in un namespace, o nel namespace globale o anche nell'ambito di un'altra classe (template o no). Non possono essere definiti nell'ambito di un blocco. Non è inoltre ammesso definire nello stesso ambito due classi con lo stesso nome, anche se hanno diverso numero di parametri oppure se una classe è template e l'altra no (in altre parole l'overload è ammesso fra le funzioni, non fra le classi).

Istanza di un template

Un template è un semplice modello (come dice la parola stessa in inglese) e non può essere usato direttamente. Bisogna prima sostituirne i parametri con tipi già precedentemente definiti (che vengono detti argomenti). Solo dopo che

Page 219: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

è stata fatta questa operazione si crea una nuova classe (cioè un nuovo tipo) che può essere a sua volta istanziata per la creazione di oggetti.

Il processo di generazione di una classe "reale" partendo da una classe template e da un argomento è detto: istanziazione di un template (notare l'analogia: come un oggetto si crea istanziando un tipo, così un tipo si crea istanziando un template). Se una stessa classe template viene istanziata più volte con argomenti diversi, si dice che vengono create diverse specializzazioni dello stesso template. La sintassi per l'istanziazione di un template è la seguente (riprendiamo l'esempio della classe template A):

A<tipo>

dove tipo è il nome di un tipo (nativo o definito dall'utente), da sostituire al parametro della classe template A nelle dichiarazioni (e definizioni) di tutti i membri di A in cui tale parametro compare. Quindi la classe "reale" non è A, ma A<tipo>, cioè la specializzazione di A con argomento tipo. Ciò rende possibili istruzioni, come per esempio la seguente:

A<int> ai(5);

che costruisce (mediante chiamata del costruttore con un argomento, di valore 5) un oggetto ai della classe template A, specializzata con argomento int.

Parametri di default

Come gli argomenti delle funzioni, anche i parametri dei template possono essere impostati di default. Riprendendo l'esempio precedente, modifichiamo il prefisso della definizione della classe A in:

template<class T = double>

ciò comporta che, se nelle istanziazioni di A si omette l'argomento, questo è sottinteso double; per esempio: A<> ad(3.7); equivale a A<double> ad(3.7); (notare che le parentesi angolari vanno specificate comunque).

Se una classe template ha più parametri, quelli di default possono anche essere espressi in funzione di altri parametri. Supponiamo per esempio di definire una classe template B nel seguente modo:

template<class T, class U = A<T> > class B { ........ };

Page 220: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

in questa classe i parametri sono due: T e U; ma, mentre l'argomento corrispondente a T deve essere sempre specificato, quello corrispondente a U può essere omesso, nel qual caso viene sostituito con il tipo generato dalla classe A specializzata con l'argomento corrispondente a T. Così:

B<double,int> crea la specializzazione di B con argomenti double e int, mentre:

B<int> crea la specializzazione di B con argomenti int e A<int>

Funzioni template

Analogamente alle funzioni-membro di una classe, anche le funzioni non appartenenti a una classe possono essere dichiarate (e definite) template. Esempio di dichiarazione di una funzione template:

template<class T> void sort(int n, T* p);

Come si può notare, uno degli argomenti della funzione sort è di tipo parametrizzato. La funzione ha lo scopo di ordinare un array p di n elementi di tipo T, e dovrà essere istanziata con argomenti di tipi "reali" da sostituire al parametro T (vedremo più avanti come si fa). Se un argomento è di tipo definito dall'utente, la classe che corrisponde a T dovrà anche contenere tutti gli overload degli operatori necessari per eseguire i confronti e gli scambi fra gli elementi dell'array.

Seguitando nell'esempio, allo scopo di evidenziare tutta la "potenza" dei template confrontiamo ora la nostra funzione con un'analoga funzione di ordinamento, tratta dalla Run Time Library (che è la libreria standard del C). Il linguaggio C, che ovviamente non conosce i template nè l'overload degli operatori, può rendere applicabile lo stesso algoritmo di ordinamento a diversi tipi facendo ricorso agli "strumenti" che ha, e cioè ai puntatori a void (per generalizzare il tipo dell'array) e ai puntatori a funzione (per dar modo all'utente di fornire la funzione di confronto fra gli elementi dell'array). Inoltre, nel codice della funzione, dovrà eseguire il casting da puntatori a void (che non sono direttamente utilizzabili) a puntatori a byte (cioè a char) e quindi, non potendo usare direttamente l'aritmetica dei puntatori, dovrà anche conoscere il size del tipo utilizzato (come ulteriore argomento della funzione, che si aggiunge al puntatore a funzione da usarsi per i confronti). In definitiva, la funzione "generica" sort del C dovrebbe essere dichiarata nel seguente modo:

typedef int (*CMP)(const void*, const void*); void sort(int n, void* p, int size, CMP cmp);

Page 221: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

l'utente dovrà provvedere a fornire la funzione di confronto "vera" da sostituire a cmp, e dovrà pure preoccuparsi di eseguire, in detta funzione, tutti i necessari casting da puntatore a void a puntatore al tipo utilizzato nella chiamata.

Risulta evidente che la soluzione con i template è di gran lunga preferibile: è molto più semplice e concisa (sia dal punto di vista del programmatore che da quello dell'utente) ed è anche più veloce in esecuzione, in quanto non usa puntatori a funzione, ma solo chiamate dirette (di overload di operatori che, oltretutto, si possono spesso realizzare inline).

Differenze fra funzioni e classi template

Le funzioni template differiscono dalle classi template principalmente sotto tre aspetti:

1. Le funzioni template non ammettono parametri di default . 2. Come le classi, anche le funzioni template sono utilizzabili soltanto dopo

che sono state istanziate; ma, mentre nelle classi le istanze devono essere sempre esplicite (cioè gli argomenti non di default devono essere sempre specificati), nelle funzioni gli argomenti possono essere spesso dedotti implicitamente dal contesto della chiamata. Riprendendo l'esempio della funzione sort, la sequenza:

double a[10] = { .........};

sort(10, a);

3. crea automaticamente un'istanza della funzione template sort, con argomento double dedotto dalla stessa chiamata della funzione. Quando invece un argomento non può essere dedotto dal contesto, deve essere specificato esplicitamente, nello stesso modo in cui lo si fa con le classi. Esempio:

template<class T> T* create( ) { .........}

int* p = create<int>( ) ;

4. In generale un argomento può essere dedotto quando corrisponde al tipo di un argomento della funzione e non può esserlo quando corrisponde al tipo del valore di ritorno. Se una funzione template ha più parametri, dei quali corrispondenti argomenti alcuni possono essere dedotti e altri no, gli argomenti deducibili possono essere omessi solo se sono gli ultimi nella lista

Page 222: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

(esattamente come avviene per gli argomenti di default di una funzione). Esempio (supponiamo che la variabile d sia stata definita double):

FUNZIONE CHIAMATA NOTE

template<class T,class U> T fun1(U);

int m = fun1<int>(d);

Il secondo argomento è dedotto di tipo double

template<class T,class U> U fun2(T);

int m = fun2<double,int>(d);

Il primo argomento non si può omettere, anche se è deducibile

5. Analogamente alle funzioni tradizionali, e a differenza dalle classi, anche le funzioni template ammettono l'overload (compresi overload di tipo "misto", cioè fra una funzione tradizionale e una funzione template). Nel momento della "scelta" (cioè quando una funzione in overload viene chiamata), il compilatore applica le normali regole di risoluzione degli overload, alle quali si aggiungono le regole per la scelta della specializzazione che meglio si adatta agli argomenti di chiamata della funzione. Va precisato, tuttavia, che tali regole dipendono dal tipo di compilatore usato, in quanto i template rappresentano un aspetto dello standard C++ ancora in "evoluzione". Nel seguito, ci riferiremo ai criteri applicati dal compilatore gcc 3.3 (che è il più "moderno" che conosciamo):

a)

fra due funzioni template con lo stesso nome viene scelta quella "più specializzata" (cioè quella che corrisponde più esattamente agli argomenti della chiamata); per esempio, date due funzioni: template<class T> void fun(T); e template<class T> void fun(A<T>); (dove A è la classe del nostro esempio iniziale), la chiamata: fun(5); selezionerà la prima funzione, mentre la chiamata: fun(A<int>(5)); selezionerà la seconda funzione;

b) se un argomento è dedotto, non sono ammesse conversioni implicite di tipo, salvo quelle "banali", cioè le conversioni fra variabile e costante e quelle da classe derivata a classe base; in altre parole, se uno stesso argomento è ripetuto più volte, tutti i tipi dei corrispondenti argomenti nella chiamata devono essere identici (a parte i casi di convertibilità sopra menzionati);

c) come per l'overload fra funzioni tradizionali, le funzioni in cui la corrispondenza fra i tipi è esatta sono preferite a quelle in cui la corrispondenza si ottiene solo dopo una conversione implicita;

d) a parità di tutte le altre condizioni, le funzioni tradizionali sono preferite alle funzioni template;

e) il compilatore segnala errore se, malgrado tutti gli "sforzi", non trova nessuna corrispondenza soddisfacente; come pure segnala errore in caso di ambiguità, cioè se trova due diverse soluzioni allo stesso livello di preferenza.

Page 223: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

6. Per maggior chiarimento, vediamo ora alcuni esempi di chiamate di funzioni e di scelte conseguenti operate dal compilatore, date queste due funzioni in overload, una tradizionale e l'altra template: void fun(double,double); e template<class T> void fun(T,T);

CHIAMATA RISOLUZIONE NOTE

fun(1,2); fun<int>(1,2); argomento dedotto, corrispondenza esatta

fun(1.1,2.3); fun(1.1,2.3); funzione tradizionale, preferita

fun('A',2); fun(double('A'),double(2)); funzione tradizionale, unica possibile

fun<char>(69,71.2); fun<char>(char(69),char(71.2)); argomento esplicito, conversioni ammesse

definite le seguenti variabili: int a = ...; const int c = ...; int* p = ...;

fun(a,c); fun<int>(a,c); argomento dedotto, conversione "banale"

fun(a,p); ERRORE conversione non ammessa da int* a double

Template e modularità

In relazione alla ODR (One-Definition-Rule), le funzioni template (e le funzioni-membro delle classi template) appartengono alla stessa categoria delle funzioni inline e delle classi (vedere capitolo: Tipi definiti dall'utente, sezione: Strutture), cioè in pratica la definizione di una funzione template può essere ripetuta identica in più translation units del programma.

Nè potrebbe essere diversamente. Infatti, come si è detto, i template sono istanziati staticamente, cioè a livello di compilazione, e quindi il codice che

Page 224: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

utilizza un template deve essere nella stessa translation unit del codice che lo definisce. In particolare, se un stesso template è usato in più translation units, la sua definizione, non solo può, ma deve essere inclusa in tutte (in altre parole, non sono ammesse librerie di template già direttamente in codice binario, ma solo header-files che includano anche il codice di implementazione in forma sorgente).

Queste regole, però, contraddicono il principio fondamentale della programmazione modulare, che stabilisce la separazione e l'indipendenza del codice dell'utente da quello delle procedure utilizzate: l'interfaccia comune non dovrebbe contenere le definizioni, ma solo le dichiarazioni delle funzioni (e delle funzioni-membro delle classi) coinvolte, per modo che qualunque modifica venga apportata al codice di implementazione di dette funzioni, quello dell'utente non ne venga influenzato. Con le funzioni template questo non è più possibile.

Per ovviare a tale grave carenza, e far sì che la programmazione generica costituisca realmente "un passo avanti" nella direzione dell'indipendenza fra le varie parti di un programma, mantenendo nel contempo tutte le "posizioni" acquisite dagli altri livelli di programmazione, è stata recentemente introdotta nello standard una nuova parola-chiave: "export", che, usata come prefisso nella definizione di una funzione template, indica che la stessa definizione è accessibile anche da altre translation units. Spetterà poi al linker, e non al compilatore, generare le eventuali istanze richieste dall'utente. In questo modo "tutto si rimette a posto", e in particolare:

• le funzioni template possono essere compilate separatamente; • nell'interfaccia comune si possono includere solo le dichiarazioni, come

per le funzioni tradizionali.

Tutto ciò sarebbe molto "bello", se non fosse che ... putroppo (secondo quello che ci risulta) nessun compilatore a tutt'oggi implementa la parola-chiave export! E quindi, per il momento, bisogna ancora includere le definizioni delle funzioni template nell'interfaccia comune.

Page 225: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Generalità sulla Libreria Standard del C++

Campi di applicazione

La Libreria Standard del C++ è costituita da un vasto numero di classi e funzioni che trattano principalmente di:

• Input-Output; • gestione delle stringhe; • gestione degli oggetti "contenitori" di altri oggetti (detti: elementi),

quali: gli array, le liste, le code, le mappe, gli insiemi ecc...; • utilizzo degli "iteratori", per "navigare" attraverso gli elementi di un

contenitore o i caratteri di una stringa; • utilizzo degli "algoritmi", per eseguire operazioni sui contenitori e sui

loro elementi, quali: ricerca, conteggio, inserimento, sostituzione, ordinamento, merging ecc...; sono previste anche operazioni specifiche, eseguite tramite oggetti-funzione forniti dall'utente o dalla stessa Libreria;

• operazioni numeriche e matematiche su numeri reali o complessi; • informazioni riguardanti aspetti del linguaggio che dipendono

dall'implementazione (per esempio: il massimo valore di un float).

La programmazione generica è largamente applicata nella Libreria: infatti, nella grande maggioranza le sue classi e funzioni sono template (o specializzazioni di template). Questo fa sì che le stesse operazioni siano applicabili a una vasta varietà di tipi, sia nativi che definiti dall'utente.

In aggiunta alla Libreria Standard del C++, la maggior parte delle implementazioni offre librerie di "interfacce grafiche", spesso chiamate anche GUI (graphical user interface), con sistemi a "finestre" per l'interazione fra utente e programma. Inoltre, la maggior parte degli ambienti di sviluppo integrati fornisce librerie dette FL (foundation libraries), che supportano lo sviluppo di applicazioni in accordo con l'ambiente specifico in cui lavorano (per esempio, la MFC del Visual C++). Sia le GUI che (ovviamente) le FL non fanno parte dello standard C++ e quindi non verranno trattate in questo corso. La stessa Libreria Standard sarà considerata perlopiù "dal punto di vista dell'utente", cioè l'attenzione sarà focalizzata sul suo utilizzo, più che sulla descrizione del suo contenuto.

Header files

Page 226: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Le classi e le funzioni della Libreria Standard sono raggruppate in una cinquantina di header files, i cui nomi seguono una particolare convenzione: non hanno estensione (cioè non hanno .h). Per esempio, il principale header file per le operazioni di input-output è <iostream> (al posto di <iostream.h> della "vecchia" libreria).

In ogni header file si trova di solito una classe (con le eventuali classi derivate se è presente una gerarchia di classi), e varie funzioni esterne di appoggio, soprattutto per la definizione di operatori in overload.

A volte un header file include altri header files. Tuttavia, all'inizio di ognuno di essi, sono inserite alcune direttive al preprocessore che, interrogando opportune costanti predefinite, controllano l'effettiva inclusione del file (cioè non lo includono se è già stato incluso precedentemente). Questo permette all'utente di inserire tutte le direttive #include che ritiene necessarie, senza preoccuparsi di generare eventuali duplicazioni di nomi.

La Libreria Standard del C++ ingloba la Run Time Library del C, i cui header files possono essere specificati in due modi:

• con il loro nome tradizionale, per esempio <stdio.h>; • con i nomi della convenzione C++, senza .h, ma con la lettera c davanti,

per esempio <cstdio>

Il namespace std

Tutta la Libreria Standard del C++ è definita in un unico namespace, che si chiama: std.

Pertanto i nomi delle classi, delle funzioni e degli oggetti definiti nella Libreria devono essere qualificati con il prefisso std::. Per esempio, le operazioni di ouput sul dispostitivo standard vanno scritte:

std::cout << ..... invece di : cout << .....

Va precisato, tuttavia, che alcuni compilatori più "vecchi" non accettano la qualificazione, e altri, "intermedi", accettano entrambe le forme.

Un'alternativa, anche se sconsigliabile per motivi già detti più volte, è quella di trasferire l'intera Libreria nel namespace globale, mediante la using directive:

using namespace std;

Page 227: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

la quale rende disponibili tutti i nomi della Libreria senza bisogno di qualificarli.

Per semplicità, visto che i nostri programmi di esempio sono in genere molto brevi, e quindi il pericolo di conflitto fra i nomi è praticamente inesistente, adotteremo questa soluzione.

La Standard Template Library

Un'importante sottinsieme della Libreria Standard del C++ è la cosidetta Standard Template Library (STL), che mette a disposizione degli utenti classi e funzioni template per la gestione dei contenitori e degli associati iteratori e algoritmi.

La principale caratteristica della STL è quella di fornire la massima genericità: i template della STL permettono all'utente di generare la specializzazione che desidera (fatte salve certe premesse), cioè di utilizzare la libreria con dati di qualunque tipo.

Fuori dalla STL, si ritrovano ancora classi e funzioni template, ma in generale la scelta delle possibili specializzazioni si esaurisce in ambiti più ristretti. Per esempio, i template che gestiscono le stringhe e l'input-output limitano la loro genericità alla scelta della codifica dei caratteri utilizzati. Noi abbiamo sempre trattato (e tratteremo) soltanto di caratteri ASCII di un byte (il tipo char), ma è bene sapere che sono possibili anche caratteri con codifiche diverse (per esempio caratteri giapponesi), che occupano più di un byte (i cosidetti wide-characters, o caratteri estesi). Poichè noi "conosciamo" solo il tipo char, quando parleremo di stringhe e di input-output ignoreremo il fatto che siano template, perchè in realtà tratteremo con template già specializzati con argomento <char>. Un altro esempio: la classe dei numeri complessi è un template solo per il fatto che i tipi delle parti reale e immaginaria possono essere specializzati con float, double o long double.

Nelle classi e funzioni della STL, invece, la scelta dei tipi degli argomenti è completamente libera: l'unica condizione, per i tipi definiti dall'utente, è che questi siano forniti di tutti gli operatori in overload necessari per eseguire le operazioni previste.

Nel seguito riportiamo, per completezza, l'elenco (in ordine alfabetico) degli header files che fanno capo alla STL. Tratteremo solo di alcuni.

<algorithm> algoritmi

<deque> contenitore: coda "bifronte"

Page 228: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

<functional> oggetti-funzione

<iterator> iteratori

<list> contenitore: double-linked list

<map> contenitore: array associativo

<memory> allocazione di memoria per contenitori

<numeric> operazioni numeriche

<queue> contenitore: coda (FIFO)

<set> contenitore: insieme

<stack> contenitore: pila (LIFO)

<utility> coppie di dati e operatori relazionali

<vector> contenitore: array monodimensionale

Page 229: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

La Standard Template Library

Generalità

Una classe che memorizza una collezione di oggetti (chiamati elementi), tutti di un certo tipo (parametrizzato), e detta: "contenitore".

I contenitori della STL sono stati progettati in modo da ottenere il massimo dell'efficienza accompagnata al massimo della genericità. L'obiettivo dell'efficienza ha escluso dal progetto l'utilizzo delle funzioni virtuali, che comportano un costo aggiuntivo in fase di esecuzione; e quindi non esiste un'interfaccia standard per i contenitori, nella forma di classe base astratta.

Ogni contenitore non deriva da un altro, né da una base comune, ma ripete l'implementazione di una serie di operazioni standard, ognuna delle quali ha, nei diversi contenitori, lo stesso nome e significato. Qualche contenitore aggiunge operazioni specifiche, altri eliminano operazioni inefficienti per le loro particolari caratteristiche, ma resta un nutrito sottoinsieme di operazioni comuni a tutti i contenitori. Quanto detto vale non solo per le funzioni che sono metodi delle classi, ma anche per quelle (dette "algoritmi") che lavorano sui contenitori dall'esterno.

Gli iteratori permettono di scorrere su un contenitore, accedendo a ogni elemento singolarmente. Un iteratore astrae e generalizza il concetto di puntatore a una sequenza di oggetti e può essere implementato in tanti modi diversi (per esempio, nel caso di un array sarà effettivamente un puntatore, mentre nel caso di una lista sarà un link ecc...). In realtà la particolare implementazione di un iteratore non interessa all'utente, in quanto le definizioni che riguardano gli iteratori sono identiche, nel nome e nel significato, in tutti i contenitori.

Riassumendo, "dal punto di vista dell'utente", sia le operazioni (metodi e algoritmi) che gli iteratori costituiscono, salvo qualche eccezione, un insieme standard, indipendente dai contenitori a cui vengono applicati. In questo modo è possibile scrivere funzioni template con il massimo della genericità (parametrizzando non solo il tipo dei dati, ma anche la stessa scelta del contenitore), senza nulla togliere all'efficienza in fase di esecuzione.

Tutte le classi template dei contenitori hanno almeno due parametri, ma il secondo (che normalmente riguarda l'allocazione della memoria) può essere omesso in quanto il tipo normalmente utilizzato è fornito di default. Non approfondiremo questo argomento e quindi descriveremo sempre le classi template della STL come se avessero solo il parametro che si riferisce al tipo degli elementi. In generale, allo scopo di "semplificare" una trattazione che già così è abbastanza complessa, trascureremo il più delle volte sia i parametri di default dei template che gli argomenti di default delle funzioni.

Page 230: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Iteratori

Abbiamo detto che un iteratore è un'astrazione pura, che generalizza il concetto di puntatore a un elemento di una sequenza.

Sequenze

Anche il concetto di sequenza è un'astrazione, che significa: "qualcosa in cui si può andare dall'inizio alla fine tramite l'operazione prossimo-elemento", come è esemplificato dalla seguente rappresentazione grafica:

Un iteratore "punta" a un elemento e fornisce un'operazione per far sì che l'iteratore stesso possa puntare all'elemento successivo della sequenza. La fine di una sequenza corrisponde a un iteratore che "punta" all'ipotetico elemento che segue immediatamente l'ultimo elemento della sequenza (non esiste un iteratore NULL, come nei normali puntatori).

Operazioni basilari sugli iteratori

Le operazioni basilari sugli iteratori sono 3 e precisamente:

1. "accedi all'elemento puntato" (dereferenziazione, rappresentata dagli operatori * e ->) NOTA: a questo proposito un iteratore viene detto valido se punta realmente a un elemento, cioè se può essere dereferenziato; un iteratore non è valido se non è stato inizializzato, oppure se puntava a un contenitore che è stato ridimensionato (vedere più avanti) o distrutto, oppure se punta alla fine di una sequenza

2. "punta al prossimo elemento" (incremento, prefisso o suffisso, rappresentata dall'operatore ++)

3. "esegui il test di uguaglianza o disuguaglianza" (rappresentate dagli operatori == e !=)

(notare la perfetta coincidenza, simbolica e semantica, con le rispettive operazioni sui normali puntatori)

L'esistenza di queste operazioni basilari ci permette di scrivere codice generico che si può applicare a qualsiasi contenitore, come nell'esempio della seguente

Page 231: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

funzione template, che copia una qualunque sequenza in un'altra (purchè in entrambe siano definiti i rispettivi iteratori):

template <class In, class Out> void copy(In from, In endseq, Out to)

{

while(from != endseq)

// cicla da from a endseq (escluso)

{

*to = *from;

// copia l'elemento puntato da from in quello puntato da to

++from;

// punta all'elemento successivo della sequenza di input

++to;

// punta all'elemento successivo della sequenza di output

}

}

il parametro In corrisponde a un tipo iteratore definito nella sequenza di input; il parametro Out corrisponde a un tipo iteratore definito nella sequenza di output (i parametri sono due anzichè uno per permettere la copia anche fra contenitori diversi). Notare che la nostra copy funziona benissimo anche per i normali puntatori. Per esempio, dati due array di char, così definiti: char vi[100], vo[100]; la funzione copy ottiene il risultato voluto se è chiamata nel modo seguente: copy(vi, vi+100, vo); in questo punto la copy viene istanziata con gli argomenti char* e char*, dedotti implicitamente dal contesto della chiamata, e quindi si crea la specializzazione: copy<char*,char*> cioè una funzione che non è più template ma "reale", e ottiene come risultato la copia dell'array vi nell'array vo.

Gli iteratori sono tipi

Come già anticipato nell'esempio che abbiamo visto, gli iteratori sono tipi. Ogni tipo iteratore è definito nell'ambito della classe contenitore a cui si riferisce. Ci sono perciò molti tipi intrinsecamente diversi di iteratori, dal momento che ogni iteratore deve essere in grado di svolgere la propria funzione per un particolare tipo di contenitore. Tuttavia l'utente quasi mai ha bisogno di conoscere il tipo di uno specifico iteratore: ogni contenitore "conosce" i suoi tipi iteratori e li rende disponibili con nomi convenzionali, uguali in tutti i contenitori.

Page 232: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Il più comune tipo iteratore è: iterator che punta a un elemento modificabile del contenitore a cui si riferisce.

Gli altri tipi iteratori definiti nelle classi contenitore sono:

const_iterator

punta a elementi non modificabili (analogo di puntatorea costante)

reverse_iterator

percorre la sequenza in ordine inverso (gli elementi puntati sono modificabili)

const_reverse_iterator

percorre la sequenza in ordine inverso (gli elementi puntati non sono modificabili)

NOTA: gli iteratori diretti e inversi non si possono mescolare (cioè non sono amesse conversioni di tipo fra iterator e reverse_iterator).

Un oggetto iteratore si ottiene (come sempre succede quando si tratta con i tipi) istanziando un tipo iteratore. Poichè ogni tipo iteratore è definito nell'ambito di una classe, il suo nome può essere rappresentato all'esterno solo se è qualificato con il nome della classe di appartenenza (esattamente come per i membri statici). Per esempio, consideriamo il contenitore vector, specializzato con argomento int; l'istruzione:

vector<int>::iterator it;

definisce l'oggetto iteratore it, istanza del tipo iterator della classe vector<int>.

Inizializzazione degli iteratori e funzioni-membro che restituiscono iteratori

L'oggetto it non è ancora un iteratore valido, in quanto è stato definito ma non inizializzato (è esattamente lo stesso discorso che si fa per i puntatori).

Per permettere l'inizializzazione di un iteratore, ogni contenitore mette a disposizione un certo numero di funzioni-membro, che danno accesso agli estremi della sequenza (come al solito, i nomi di queste funzioni sono gli stessi in tutti i contenitori):

iterator begin(); restituisce un oggetto iteratore che punta all'inizio della sequenza

const_iterator begin() const; come sopra (elementi costanti)

iterator end(); restituisce un oggetto iteratore che punta alla fine della sequenza

const_iterator end() const; come sopra (elementi costanti)

reverse_iterator rbegin(); restituisce un oggetto iteratore che punta all'inizio della sequenza inversa

const_reverse_iterator rbegin() const;

come sopra (elementi costanti)

reverse_iterator rend(); restituisce un oggetto iteratore che punta alla

Page 233: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

fine della sequenza inversa

const_reverse_iterator rend() const;

come sopra (elementi costanti)

Per esempio, dato un array di n elementi, il valore di ritorno ....

di... punta all'elemento di indice ...

begin() 0

end() n (che non esiste)

rbegin() n-1

rend() -1 (che non esiste)

In aggiunta, esiste una funzione-membro (non dei contenitori, ma di reverse_iterator) che fornisce l'unico modo per passare dal tipo reverse_iterator al tipo iterator. Questa funzione si chiama base(): applicata a un oggetto reverse_iterator che punta a un certo elemento, restituisce un oggetto iterator che punta all'elemento successivo.

Infine, un oggetto iteratore può essere inizializzato (o assegnato) per copia da un altro oggetto iteratore dello stesso tipo. Questo permette di scrivere funzioni con argomenti iteratori passati by value (come la copy del nostro esempio precedente).

Dichiarazione esplicita di tipo

Nell'esempio di definizione dell'oggetto iteratore it, l'espressione: vector<int>::iterator rappresenta un tipo; il compilatore lo sa, in quanto riconosce il contenitore vector. Ma se noi volessimo parametrizzare proprio il contenitore, per esempio passandolo come argomento a una funzione template: template <class Cont> void fun(Cont& c)

e poi definendo e inizializzando all'interno della funzione un oggetto iteratore, con l'istruzione: Cont::iterator it = c.begin(); il compilatore non l'accetterebbe, non essendo in grado di riconoscere che l'espressione Cont::iterator rappresenta un tipo. Perché l'espressione sia valida, occorre in questo caso premettere la parola-chiave typename: typename Cont::iterator it = c.begin(); e questo fa sì che il compilatore accetti provvisoriamente Cont::iterator come tipo, rinviando il controllo definitivo al momento dell'istanziazione della funzione.

In generale la parola-chiave typename davanti a un identificatore dichiara esplicitamente che quell'identificatore è un tipo (può anche essere usata al posto di class nella definizione di un template). E' obbligatoria (almeno nelle versioni più avanzate dello standard) ogni volta che un tipo dipende da un parametro di template.

Categorie di iteratori e altre operazioni

Page 234: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Senza entrare nei dettagli sull'argomento, che esula dagli intendimenti di questo corso, vogliamo accennare al fatto che gli iteratori sono classificati in varie categorie, a seconda delle operazioni che si possono eseguire su di essi. Infatti, oltre alle 3 operazioni basilari che abbiamo visto (comuni a tutti gli iteratori), sono possibili altre operazioni, che però si applicano soltanto ad alcune categorie di iteratori. A loro volta le categorie dipendono sostanzialmente dai particolari contenitori in cui gli iteratori sono definiti (per esempio: gli iteratori definiti in vector e in deque appartengono alla categoria: "ad accesso casuale", mentre gli iteratori definiti in list e in altri contenitori appartengono alla categoria: "bidirezionale").

Le categorie sono organizzate gerarchicamente, nel senso che le operazioni ammesse per gli iteratori di una certa categoria lo sono anche per gli iteratori di categoria superiore, ma non viceversa. Gli stessi algoritmi, che (come vedremo) hanno sempre argomenti iteratori, pretendono di operare, ognuno, su una precisa categoria di iteratori (e su quelle gerarchicamente superiori). Al vertice della gerarchia si trovano gli iteratori ad accesso casuale, seguiti dagli iteratori bidirezionali (e da altri che non menzioneremo).

Gli iteratori bidirezionali e ad accesso casuale ammettono l'operazione di decremento (--), che sposta il puntamento sull'elemento precedente della sequenza, mentre soltanto agli iteratori ad accesso casuale sono riservate alcune operazioni aggiuntive, quali:

• indicizzazione [ ], per esempio it[3] : punta al terzo elemento successivo

• operazioni di confronto: < , <= , > , >= • tutte le operazioni con interi che forniscono un'aritmetica analoga a

quella dei puntatori: + , += , - , -= a questo proposito: agli iteratori delle altre categorie, per i quali le suddette operazioni non sono ammesse, la Libreria fornisce due funzioni (supponiamo che Iter denoti un tipo iteratore): void advance(Iter& it, int n) al posto di : it += n e .... difference_type distance(Iter first, Iter last) al posto di : last - first dove difference_type è un tipo (di solito coincidente con int) definito (come iterator) nel contenitore.

Contenitori Standard

Classificazione dei contenitori

I contenitori della STL sono suddivisi in 2 categorie:

• le sequenze (in senso stretto)

Page 235: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• i contenitori associativi

A loro volta le sequenze sono classificate in sequenze principali e adattatori. Questi ultimi sono delle interfacce ridotte di sequenze principali, specializzate per eseguire un insieme molto limitato di operazioni, e non dispongono di iteratori.

Nei contenitori associativi gli elementi sono coppie di valori. Dato un valore, la chiave, si può (rapidamente) accedere all'altro, il valore mappato. Si può pensare a un contenitore associativo come a un array, in cui l'indice (la chiave) non deve necessariamente essere un intero. Tutti i contenitori associativi dispongono di iteratori bidirezionali, che percorrono gli elementi ordinati per chiave (e quindi anche i contenitori associativi possono essere considerati delle sequenze, in senso lato).

Tipi definiti nei contenitori

Tutti i contenitori mettono a disposizione nomi convenzionali di tipi, definiti nel proprio ambito. Abbiamo appena visto i 4 tipi iteratori e il tipo difference_type. Ve ne sono altri, dei quali elenchiamo i più importanti:

value_type tipo degli elementi

size_type tipo degli indici e delle dimensioni (normalmente coincide con unsigned int)

reference equivale a value_type&

const_reference equivale a const value_type&

key_type tipo della chiave nei contenitori associativi

mapped_type tipo del valore mappato nei contenitori associativi

Costo delle operazioni

Nonostante tutti i tipi definiti nei contenitori e molte funzioni-membro abbiano nomi standardizzati, per permettere la creazione di funzioni generiche in cui i contenitori stessi figurino come parametri, non sempre è conveniente sfruttare questa possibilità. In certi casi, infatti, ci sono operazioni che risultano più efficienti usando un contenitore piuttosto che un altro, e quindi tali operazioni, pur essendo disponibili in tutti i contenitori, non dovrebbero essere inserite in funzioni generiche. In altri casi certe operazioni in alcuni contenitori non sono neppure disponibili, talmente sarebbero inefficienti, e quindi un tentativo di inserirle in funzioni generiche produrrebbe un messaggio di errore. Ogni operazione ha un "costo computazionale", che spesso dipende dal contenitore in cui è eseguita, e quindi a volte non conviene parametrizzare il contenitore, ma piuttosto selezionare il contenitore più appropriato. La scelta deve indirizzarsi a operare il più possibile a "costo costante", cioè indipendente dal numero di elementi (per esempio, l'accesso a un elemento, data la sua posizione, è a "costo costante" usando vector, e non lo è usando list, mentre per l'inserimento di un elemento "in mezzo" è esattamente il contrario).

Sommario dei contenitori

Page 236: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

I contenitori della STL sono 10 (3 sequenze principali, 3 adattatori e 4 contenitori associativi) e precisamente:

vector è il contenitore più completo; memorizza un array monodimensionale, ai cui elementi può accedere in modo "randomatico", tramite iteratori ad accesso casuale e indici; può modificare le sue dimensioni, espandendosi in base alle necessità

list rispetto a vector manca dell'accesso tramite indice e di varie operazioni sugli iteratori, che non sono ad accesso casuale ma bidirezionali; è più efficiente di vector nelle operazioni di inserimento e cancellazione di elementi

deque è una "coda bifronte" cioè è una sequenza ottimizzata per rendere le operazioni alle due estremità efficienti come in list, mentre mantiene gli iteratori ad accesso casuale e l'accesso tramite indice come in vector (di cui però non mantiene certe funzioni di gestione delle dimensioni)

stack è un adattatore di deque per operazioni di accesso (top), inserimento (push) e cancellazione (pop) dell'elemento in coda alla sequenza

queue è un adattatore di deque per operazioni di inserimento in coda (push) e cancellazione in testa (pop); l'accesso è consentito sia in coda (back) che in testa (front)

priority_queue è definito nell'header-file <queue>; è un adattatore di vector per operazioni di inserimento (push) "ordinato" (cioè fatto in modo che gli elementi della sequenza siano sempre in ordine decrescente), e per operazioni di cancellazione in testa (pop) e di accesso in testa (top); il mantenimento degli elementi in ordine comporta che le operazioni non siano eseguite "a costo costante" (se l'implementazione è "fatta bene" il costo dovrebbe essere proporzionale al logaritmo del numero di elementi)

map è il più importante dei contenitori associativi; memorizza una sequenza di coppie (chiave e valore mappato, entrambi parametri di map) e fornisce un'accesso rapido a ogni elemento tramite la sua chiave (ogni chiave deve essere unica all'interno di un map); mantiene i propri elementi in ordine crescente di chiave; riguardo al "costo" delle operazioni, valgono le stesse considerazioni fatte per priority_queue; La sua operazione caratteristica è l'accesso tramite indice (chiamiamo m un oggetto di map): valore mappato = m[chiave] oppure m[chiave] = valore mappato che funziona sia in estrazione che in inserimento; in ogni caso cerca l'elemento con quella chiave: se lo trova, estrae (o inserisce) il valore mappato; se non lo trova, lo crea e inizializza il valore mappato con il "valore base" del suo tipo (dato da mapped_type); il valore base è zero (in modo appropriato al tipo), se il tipo è nativo, altrimenti è un oggetto creato dal costruttore di default (che in questo caso è obbligatorio)

multimap è definito nell'header-file <map>; è un contenitore associativo

Page 237: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

analogo a map, con la differenza che la chiave può essere duplicata; non dispone dell'accesso tramite indice

set è un contenitore associativo analogo a map, con la differenza che possiede solo la chiave (e quindi ha un solo parametro); non dispone dell'accesso tramite indice; in pratica è una sequenza ordinata di valori unici e crescenti

multiset è definito nell'header-file <set>; è un contenitore associativo analogo a set, con la differenza che la chiave può essere duplicata; in pratica è una sequenza ordinata di valori non decrescenti;

A queste classi si aggiunge la struttura template pair, definita in <utility> e utilizzata dai contenitori associativi: template <class T, class U> struct pair {........}; un oggetto pair è costituito da una coppia di valori, di cui il primo, di tipo T, è memorizzato nel membro first e il secondo, di tipo U, è memorizzato nel membro second. La struttura possiede un costruttore di default, che inizializza first e second ai valori base dei loro tipi, e un costruttore con un 2 argomenti, per fornire valori iniziali specifici a first e second. Esiste anche la funzione di Libreria make_pair, che restituisce un oggetto pair, data una coppia di valori. Gli elementi di map e multimap sono oggetti di pair

Requisiti degli elementi e relazioni d'ordine

Abbiamo detto che i template della STL possono essere istanziati con qualsiasi tipo di elementi, a libera scelta dell'utente. Se il tipo prescelto è nativo (non puntatore!) non ci sono problemi. Ma se il tipo è definito dall'utente, esistono alcuni requisiti a cui deve soddisfare, se si vuole che le operazioni fornite dalla Libreria funzionino correttamente.

Anzitutto le copie: gli elementi sono inseriti nel contenitore tramite copia di oggetti esistenti, e quindi il nostro tipo deve essere provvisto di un costruttore di copia e di un operatore di assegnazione adeguati (per esempio non devono eseguire le copie dei membri puntatori ma delle aree puntate ecc...). Se necessario, deve essere presente anche un corretto distruttore, poichè, quando un contenitore è distrutto, sono automaticamente distrutti anche i suoi elementi.

In secondo luogo, l'ordinamento: i contenitori associativi e priority_queue ordinano gli elementi (nel momento stesso in cui li inseriscono), e la stessa cosa viene fatta da alcuni algoritmi che operano sui contenitori. E' pertanto indispensabile che il nostro tipo sia provvisto delle funzionalità necessarie per l'ordinamento dei suoi oggetti. A volte queste funzionalità possono essere fornite da oggetti-funzione specifici (di cui parleremo più avanti, anticipiamo solo che questi sono indispensabili nel caso che gli elementi siano puntatori a tipo nativo), ma di default esse vengono cercate fra gli operatori in overload definiti nel tipo stesso. Fortunatamente non è necessario attrezzare il nostro tipo con tutti gli operatori relazionali possibili, ma è sufficiente che ce ne sia solo uno: operator<. Infatti la Libreria usa soltanto questo operatore per ordinare gli elementi. In compenso, pretende che la funzione che implementa operator< sia "fatta bene", cioè applichi un criterio di ordinamento di tipo "strict weak ordering"; il che significa, in formule:

Page 238: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• X < X è falso (ordine stretto) • è ammessa la possibilità che X < Y e Y < X siano entrambi falsi (ordine

debole); in questo caso si dice che X e Y hanno ordine equivalente (cioè in pratica sono uguali, ma non è necessario definire operator==)

• devono vale le proprietà transitive: o se X < Y e Y < Z allora X < Z o se X e Y hanno ordine equivalente e Y e Z hanno ordine

equivalente, allora anche X e Z hanno ordine equivalente

Passiamo ora alla descrizione delle principali funzioni-membro dei contenitori. A parte gli adattatori, che possiedono poche funzioni specifiche, gli altri contenitori hanno molte funzioni in comune, con lo stesso nome e lo stesso significato. Pertanto, nella trattazione che segue, raggruperemo le funzioni non per contenitore, ma per "tematiche", indicando con Cont il nome di una generica classe contenitore e precisando eventualmente in quale contenitore un certa funzione è o non è definita, o è definita ma inefficiente; se non altrimenti specificato, si intende che la funzione è definita nelle sequenze principali e nei contenitori associativi; indicheremo inoltre con Iter il nome di un generico tipo iteratore.

Dimensioni e capacità

Di default lo spazio di memoria per gli elementi di un contenitore è allocato nell'area heap, ma di questo l'utente non deve normalmente preoccuparsi, in quanto ogni contenitore possiede un distruttore che libera automaticamente l'area allocata.

La dimensione di un contenitore (cioè il numero dei suoi elementi) non è prefissata e immodificabile (come negli array del C). Un oggetto contenitore "nasce" con una certa dimensione, ma esistono diversi metodi che possono modificarla (direttamente o implicitamente). La funzione-membro che modifica direttamente una dimensione è: void Cont::resize(size_type n, value_type val=value_type()) dove n è la nuova dimensione: se è minore della dimensione corrente, vengono mantenuti solo i primi n elementi (con i loro valori); se è maggiore, vengono inseriti i nuovi elementi con valori tutti uguali a val, inizializzato di default al valore base del loro tipo (value_type); la specifica dell'argomento opzionale val è obbligatoria nel caso che value_type non abbia un costruttore di default. Il metodo resize è definito soltanto nelle sequenze principali. Altri metodi, che aggiungono, inseriscono o rimuovono elementi in un contenitore, ne modificano la dimensione implicitamente (li vedremo fra poco). In ogni caso, quando la dimensione cambia, gli iteratori precedentemente definiti potrebbero non essere più validi (conviene ridefinirli o, almeno, riinizializzarli).

I seguenti metodi in sola lettura restituiscono informazioni sulla dimensione di un contenitore:

size_type Cont::size() const restituisce la dimensione corrente dell'oggetto *this; è definito anche negli adattatori

bool Cont::empty() const restituisce true se *this è vuoto; è definito anche negli adattatori

Page 239: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

size_type Cont::max_size() const

restituisce la dimensione massima che un oggetto di Cont può raggiungere (è un numero normalmente molto grande, che dipende dalla stessa dimensione di value_type e dall'implementazione)

Se definiamo "capacità" di un oggetto contenitore la quantità di memoria correntemente allocata (in termini di numero di elementi), è valida la seguente diseguaglianza: capacità >= dimensione questo significa che, se la dimensione aumenta, ma resta inferiore alla capacità, non viene allocata nuova memoria; appena la dimensione tende a superare la capacità, si ha una riallocazione della memoria in modo da ripristinare la diseguaglianza di cui sopra. In altri termini, la differenza: capacità - dimensione rappresenta il numero di elementi che si possono inserire senza causare riallocazione di memoria. In realtà, in tutti i contenitori, salvo vector, capacità e dimensione sono coincidenti, cioè ogni operazione che comporta l'aumento della dimensione produce contestualmente anche una nuova allocazione di memoria. Per evitare che ciò avvenga troppo spesso e che il "costo" di tali operazioni diventi troppo elevato, vector mette a disposizione il seguente metodo, che consente di aumentare la capacità senza modificare la dimensione, cioè in pratica di evitare continue riallocazioni, riservando uno spazio di memoria "preventivo", ma senza inserirvi nuovi elementi: void vector::reserve(size_type n) dove n è la nuova capacità: se è minore della capacità corrente, la funzione non ha effetto; se è maggiore, alloca spazio per (n - capacità corrente) "futuri" nuovi elementi. Si deduce che, con reserve, la capacità di un contenitore può soltanto aumentare; e la stessa cosa succede a seguito di resize e delle altre operazioni che modificano la dimensione: la capacità o aumenta (quando tende a essere superata dalla dimensione), o resta invariata, anche se la dimensione diminuisce; pertanto non esiste modo di "restituire" memoria al sistema prima che lo stesso contenitore venga distrutto (in realtà un modo esiste, ma lo vedremo più avanti, quando parleremo della funzione-membro swap). Per ottenere informazioni sulla capacità, è disponibile il seguente metodo: size_type vector::capacity() const che restituisce la quantità di memoria correntemente allocata, in termini di numero di elementi.

Costruttori e operatori di copia

Tutti i contenitori dispongono di un certo numero di costruttori, e di operatori e funzioni per eseguire le copie.

Anzitutto, il costruttore di default, il costruttore di copia e l'operatore di assegnazione sono definiti in tutti i contenitori (adattatori compresi):

Cont::Cont() crea un oggetto di Cont con dimensione nulla

Page 240: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Cont::Cont(const Cont& c) crea un oggetto di Cont copiandolo dall'oggetto esistente c

Cont& Cont::operator=(const Cont& c)

assegna un oggetto esistente c a *this

NOTE:

1. il costruttore di copia e l'operatore di assegnazione non ammettono conversioni implicite, né fra i tipi dei contenitori, né fra i tipi degli elementi (in altre parole, non si può copiare un list in un vector, e neppure un vector<int> in un vector<double>)

2. il nuovo oggetto creato dal costruttore di copia assume la dimensione di c, ma non la sua capacità, che viene invece fatta coincidere con la dimensione (cioé è allocata memoria solo per gli elementi copiati)

3. dopo l'assegnazione, *this assume la dimensione di c (gli elementi preesistenti vengono eliminati), ma non riduce la sua capacità originaria (può solo aumentarla nel caso che venga superata dalla nuova dimensione)

4. come è noto, i costruttori di copia entrano in azione anche nel passaggio by value di argomenti a una funzione. Nel caso che tali argomenti siano oggetti di un contenitore, l'operazione potrebbe essere "costosa", se la dimensione del contenitore è molto grande. Pertanto si consiglia, quando non è necessario altrimenti per motivi particolari, di passare sempre gli argomenti-contenitore by reference.

Nelle sole sequenze principali sono inoltre definite le due seguenti funzioni:

• un costruttore con un 1 argomento (più altri di default, di cui a noi interessa solo il primo): Cont::Cont(size_type n, const_reference val=value_type()) che crea un oggetto di Cont con dimensione n e inizializza gli elementi con val (riguardo all'argomento di default vedere le considerazioni fatte a proposito di resize); nella definizione della classe Cont questa funzione-membro è dichiarata explicit, per evitare "accidentali" conversioni implicite da size_type a Cont;

• il metodo assign, che è una specie di "estensione" dell'operatore di assegnazione (non si può usare un operatore in overload perchè avrebbe "troppi" argomenti): void Cont::assign(size_type n, const_reference val) esegue la stessa operazione del costruttore di cui sopra, ma su un oggetto di Cont già esistente (altra differenza: il secondo argomento non è di default); come in tutte le operazioni di assegnazione, i "vecchi" elementi vengono eliminati, la dimensione diventa n, ma la capacità resta invariata (o aumenta, se era minore di n)

Finora abbiamo esaminato vari casi di operazioni di copia fra contenitori vincolati a essere dello stesso tipo. Esiste però un costruttore che permette la creazione degli oggetti di un contenitore mediante copia da un qualunque altro contenitore, anche di tipo diverso (anche i tipi degli elementi possono essere diversi, purché convertibili implicitamente gli uni negli altri): Cont::Cont(Iter first, Iter last) (dove Iter è un tipo iteratore definito in Cont o in un altro contenitore);

Page 241: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

questo metodo crea un oggetto di Cont, i cui elementi vengono generati mediante copia a partire dall'elemento puntato da first fino all'elemento puntato da last (escluso). Per esempio, se lst è un oggetto di list<int> (già definito e inizializzato), è possibile creare un oggetto vec di vector<double> copiandovi tutti gli elementi di lst (e convertendoli da int a double) con l'operazione: vector<double> vec(lst.begin(),lst.end()); E' anche possibile eseguire un'assegnazione, con operazione analoga su un oggetto di Cont già esistente, mediante un overload del metodo assign (definito solo nelle sequenze principali): void Cont::assign(Iter first, Iter last) Riprendendo l'esempio precedente, l'operazione: vec.assign(lst.begin(),lst.end()); elimina in vec i suoi "vecchi" elementi e li sostituisce con quelli di lst (che converte da int a double)

Infine, nel numero delle funzioni che eseguono copie di contenitori, si può includere anche il metodo swap: void Cont::swap(Cont& c) che scambia gli elementi, la dimensione e la capacità fra *this e c; i tipi, sia dei contenitori che degli elementi, devono essere gli stessi nei due oggetti. Per ogni contenitore è disponibile, oltre al metodo swap, anche una funzione esterna, con lo stesso nome: void swap(Cont& c1,Cont& c2) che scambia c1 con c2 Notare che la peculiarità di swap di scambiare anche le capacità, fornisce un "trucco" che permette di ridurre la memoria allocata a un oggetto contenitore. Infatti, supponiamo per esempio di avere un oggetto vec di un contenitore vector<double>, con dimensione n e capacità m > n; con l'istruzione: vector<double>* ptmp = new vector<double> (vec); costruiamo un oggetto nell'area heap (puntato da ptmp) che, essendo una copia di vec, ha dimensione n e capacità n; quindi, con l'istruzione: vec.swap(*ptmp); otteniamo che l'oggetto vec si "scambia" con *ptmp (ma gli elementi sono gli stessi!) e quindi, in particolare, la sua capacità si riduce a n (mentre quella di *ptmp diventa m); infine, con l'istruzione: delete ptmp; liberiamo la memoria allocata per *ptmp (e per i suoi m elementi). In totale rimane l'oggetto originario vec con tutto come prima, salvo il fatto che la memoria in eccesso è stata deallocata.

Accesso agli elementi

Tutte le operazioni di accesso agli elementi possono funzionare sia in lettura che in scrittura, cioè possono restituire sia un r-value (lettura) che un l-value(scrittura).

La più generale operazione di accesso è la dereferenziazione di un iteratore (che abbiamo già visto nella sezione dedicata agli iteratori).

I contenitori: vector, deque, e map possono accedere ai propri elementi anche tramite operatori di indicizzazione:

Page 242: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• reference Cont::operator[](size_type i) per vector e deque; l'argomento i rappresenta l'indice;

• const_reference Cont::operator[](size_type i) const come il precedente, salvo che accede in sola lettura;

• mapped_type Cont::operator[](const key_type& k) per map (vedere la descrizione nella tabella sommaria dei contenitori); l'argomento k rappresenta la chiave, che funge da indice.

A parte l'ovvia differenza fra i tipi degli indici, c'è un'altra fondamentale differenza fra l'indicizzazione in map e quella in vector e deque: mentre la prima va sempre "a buon fine" (nel senso che, se un elemento con chiave k non esiste, l'elemento viene aggiunto), la seconda può generare un errore (non segnalato) di valore indefinito (se in lettura) o di access violation (se in scrittura), nel caso che l'elemento con indice i non esista. In altri termini, i deve essere sempre compreso nel range fra 0 e size() (escluso). Il fatto che l'accesso via indice non sia controllato è una "scelta" di progetto, che permette di evitare operazioni "costose" quando il controllo non è necessario. Per esempio, consideriamo il seguente codice: vector<int> vec(100000); (crea un oggetto vec con 100000 elementi vuoti) for(size_type i=0; i < vec.size(); i++) ( li riempie ....) { ................. vec[i] = ................. } sarebbe oltremodo "costoso" (oltre che sciocco) controllare 100000 volte che i sia nel range!

A volte invece il controllo è proprio necessario, specie nei casi in cui il valore di i risulta da operazioni precedenti e quindi non è possibile conoscerlo a priori. L'accesso via indice "controllato" è fornito dal metodo at (definito in vector e deque): reference Cont::at(size_type i) const_eference Cont::at(size_type i) const (per la sola lettura) che, in caso di errore, genera un'eccezione di tipo out_of_range.

Ci chiedamo a questo punto quale relazione intercorra fra gli indici e gli iteratori. E' chiaro che (indicando con c un oggetto di vector o di deque e con it un oggetto iteratore (diretto) che inizializziamo con begin()), è sempre vera l'uguaglianza: c[0] == *it e quindi, per analogia con i puntatori, siamo portati a pensare che sia vera anche la seguente: c[i] == *(it+i) in realtà lo è, ma solo perchè abbiamo supposto che c sia un oggetto di vector o di deque, i cui iteratori sono ad accesso casuale e quindi ammettono l'operazione + con valori interi; mentre non è valida la relazione: &c[0] == it in quanto puntatori e iteratori sono tipi differenti.

Le operazioni di accesso in testa e in coda possono anche essere eseguite da particolari metodi (definiti nelle sequenze principali e nell'adattatore queue): reference Cont::front() (accede al primo elemento) const_reference Cont::front() const (come sopra, in sola lettura) reference Cont::back() (accede al l'ultimo elemento)

Page 243: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

const_reference Cont::back() const (come sopra, in sola lettura) Gli adattatori stack e priority_queue possono accedere soltanto al primo elemento (priority_queue) o all'ultimo (stack); entrambe le operazioni vengono eseguite dal metodo top(), il quale non fa altro che chiamare front() (in priority_queue) o back() (in stack). I metodi front, back e top possono generare un errore (incontrollato) se tentano di accedere a un contenitore vuoto.

Inserimento e cancellazione di elementi

Le operazioni di inserimento e cancellazione di elementi sono presenti in tutti i contenitori. Tuttavia, in alcuni di essi sono poco efficienti e quindi è necessario capire in quali contenitori conviene eseguire certe operazioni e in quali no. A questo scopo, presentiamo nella tabella che segue la relazione che intercorre, in termini di efficienza, fra ogni contenitore e le sue operazioni di inserimento e cancellazione, che suddividiamo in tre categorie: operazioni in testa, in "mezzo" e in coda:

inserimento/ cancellazione vector deque list queue priority_queue stack contenit

associat

in testa non definita efficiente efficienteefficiente (solo canc.)

vedere nota non definita non defin

in "mezzo" inefficiente inefficiente efficiente non definita non definita non

definita vedere not

in coda efficiente efficiente efficienteefficiente (solo ins.)

non definita efficiente non defin

NOTA: ricordiamo che nei contenitori associativi gli inserimenti le cancellazioni sono sempre, come l'accesso, a "costo logaritmico"; in priority_queue l'inserimento è a "costo logaritmico" (perchè deve "ordinare"), mentre la cancellazione è a "costo costante".

Ciò premesso, vediamo i metodi disponibili per queste operazioni (ricordiamo che esse modificano implicitamente la dimensione e quindi rendono invalidi gli iteratori definiti precedentemente); indicheremo con val l'elemento da inserire e con it l'iteratore che punta all'elemento da cancellare o all'elemento prima del quale il nuovo elemento deve essere inserito:

inserimento in testa void Cont::push_front(const_reference val) (in priority_queue cambia nome in push)

cancellazione in testa void Cont::pop_front() (in queue e in priority_queue cambia nome in pop)

inserimento in "mezzo" (vedere nota)

iterator Cont::insert(iterator it,const_reference val) (ritorna un iteratore che punta al nuovo elemento) void Cont::insert(iterator it,size_type n,const_reference val) (inserisce n volte val) void Cont::insert(iterator it,Iter first, Iter last)

Page 244: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

(dove Iter è un tipo iteratore definito in Cont o in un altro contenitore; inserisce elementi generati mediante copia a partire dall'elemento puntato da first fino all'elemento puntato da last escluso)

cancellazione in "mezzo" iterator Cont::erase(iterator it) (ritorna un iteratore che punta all'elemento successivo a quello cancellato, oppure ritorna end() se l'elemento cancellato era l'ultimo) iterator Cont::erase(iterator first, iterator last) (cancella una serie di elementi contigui, a partire dall'elemento puntato da first fino all'elemento puntato da last escluso; ritorna come sopra) void Cont::clear() (elimina tutti gli elementi; equivale a erase con argomentibegin() e end(), ma è molto più veloce)

inserimento in coda void Cont::push_back(const_reference val) (in queue e stack cambia nome in push)

cancellazione in coda void Cont::pop_back() (in stack cambia nome in pop)

NOTA: gli overloads del metodo insert elencati nella tabella riguardano solo le sequenze principali; nei contenitori associativi insert è definito con overloads diversi (vedere più avanti).

Tabella riassuntiva delle funzioni comuni

Abbiamo esaurito la trattazione degli adattatori e delle funzioni-membro comuni a più contenitori. Prima di passare alla descrizione dei metodi specifici di singoli contenitori, presentiamo, nella seguente tabella l'elenco delle funzioni esaminate finora. La legenda dei simboli usati è: ogni contenitore è indicato dalla sua iniziale (es.: v = vector) a = contenitore associativo (escluso map) C = "costo costante", L = "costo logaritmico", N = "non definita" I = "inefficiente" (costo proporzionale al numero di elementi)

v d l m a q p s

dereferenziazione di un iteratore C C C C C N N N

begin end rbegin rend C C C C C N N N

resize C C C N N N N N

size empty C C C C C C C C

max_size C C C C C N N N

reserve capacity C N N N N N N N

costruttore di default C C C C C C C C

costruttore di copia operator= I I I I I I I I

costruttore con dimensione assign I I I N N N N N

Page 245: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

costruttore tramite iteratori I I I I I N N N

swap C C C C C N N N

operator[] C C N L N N N N

at C C N N N N N N

front back C C C N N C N N

top N N N N N N C C

push_front pop_front N C C N N N N N

push_back pop_back C C C N N N N N

push N N N N N C L C

pop N N N N N C C C

insert erase I I C L L N N N

clear C C C C C N N N

Metodi specifici di list

Come si desume dalla tabella, il contenitore list possiede tutte le funzionalità di vector, escluse la "riserva" di memoria (reserve e capacity) e l'accesso via indice (operator[] e at); in più, può eseguire, come deque, operazioni di inserimento e cancellazione in testa (push_front e pop_front) ed è più efficiente di vector e deque nelle operazioni di inserimento e cancellazione in "mezzo" (insert e erase).

In aggiunta, sono definiti in list alcuni metodi specifici, che forniscono operazioni particolarmente adatte alla manipolazione delle liste:

• metodo splice, in 3 overloads: void list::splice(iterator it, list& lst) void list::splice(iterator it, list& lst, iterator first) void list::splice(iterator it, list& lst, iterator first, iterator last) il metodo splice "muove" degli elementi (cioè li copia, cancellando gli originari) dall'oggetto lst in *this, inserendoli prima dell'elemento di *this puntato da it; nel primo overload vengono mossi tutti gli elementi di lst (che resta vuoto); nel secondo, viene mosso solo l'elemento di lst puntato da first; nel terzo, vengono mossi gli elementi contigui di lst, puntati a partire da first fino a last escluso; è ammesso che *this e lst coincidano solo a condizione che il range degli elementi da muovere non contenga it (e quindi non è mai ammesso nel primo caso)

• void list::reverse() inverte gli elementi (cioè scambia il primo con l'ultimo, il secondo con il penultimo ecc...)

• void list::sort() ordina gli elementi in senso ascendente (esiste anche un overload in cui si può imporre la condizione d'ordine tramite un oggetto-funzione, ma ne parleremo in generale quando tratteremo degli algoritmi; la stessa considerazione vale anche riguardo ai successivi metodi di questo elenco)

• void list::remove(const_reference val) elimina tutti gli elementi che trova uguali a val

Page 246: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• void list::merge(list& lst) muove in *this tutti gli elementi di lst (che resta vuoto); se in entrambe le liste gli elementi erano in ordine, si mantengono in ordine anche nella lista risultante, altrimenti gli elementi vengono mescolati senza un ordine definito

• void list::unique() elimina tutti gli elementi duplicati contigui (l'operazione ha senso solo se la lista è gia in ordine)

Metodi specifici dei contenitori associativi

Abbiamo visto che le classi template map e multimap hanno (almeno) due parametri: la chiave (tipo key_type) e il valore mappato (tipo mapped_type), definiti in quest'ordine. I loro elementi (tipo value_type) sono invece specializzazioni della struttura template pair, con argomenti: const key_type e value_type.

Le classi template set e multiset possono considerarsi dei contenitori associativi "degeneri" con un solo parametro: la chiave (gli elementi sono costituiti dalla chiave stessa, e quindi i tipi key_type e value_type sono coincidenti, mentre mapped_type non esiste).

Tutti i contenitori associativi possiedono iteratori bidirezionali, che (di default) percorrono gli elementi in ordine crescente di chiave.

Dell'operatore di indicizzazione (definito solo in map) abbiamo già detto; aggiungiamo solo che non può lavorare su mappe costanti, in quanto, se non trova un elemento, lo crea. Per eseguire una ricerca senza modificare la mappa, bisogna usare il metodo find (vedere più avanti).

Per quello che riguarda l'operazione di inserimento di nuovi elementi, fermo restando che in map il modo più semplice e comune è quello di usare l'operatore di indicizzazione come l-value (con un nuovo valore della chiave), in tutti i contenitori associativi si può usare il metodo insert, i cui overloads sono però diversi da quelli elencati nella tabella generale (al solito, indicheremo con val l'elemento da inserire):

• pair<iterator, bool> Cont::insert(const_reference val) è definito solo in map e set; "tenta" di inserire val, cercando se esiste già una chiave uguale a val.first (se è in map), oppure uguale a val (se è in set); se la trova, non esegue l'inserimento; restituisce un oggetto di pair, in cui first è un iteratore che punta all'elemento (vecchio o nuovo) con chiave val.first (o val se è in set), e second è true nel caso che val sia stato effettivamente inserito

• iterator Cont::insert(const_reference val) come il precedente, salvo che inserisce val comunque e restituisce un iteratore che punta al nuovo elemento inserito; è definito solo in multimap e multiset

• iterator Cont::insert(iterator it,const_reference val) è identico nella forma all'overload definito nelle sequenze principali; se ne differisce per il significato dell'argomento it, che non rappresenta più il punto dove inserire val (nei contenitori associativi ogni elemento è sempre inserito nella posizione d'ordine che gli compete), ma piuttosto il

Page 247: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

punto dal quale iniziare la ricerca: se risulta che val deve essere inserito immediatamente dopo it, l'operazione non è più a "costo logaritmico" ma a "costo costante" (questo overload può servire per inserire rapidamente una sequenza di elementi già ordinati, utilizzando in ogni step il valore di ritorno come argomento it per lo step successivo)

• void Cont::insert(Iter first, Iter last) dove Iter è un tipo iteratore definito in Cont o in un altro contenitore; inserisce elementi generati mediante copia a partire dall'elemento puntato da first fino all'elemento puntato da last escluso

Anche il metodo erase è un pò diverso, nel senso che fornisce un overload in più rispetto a quelli già visti: size_type Cont::erase(const key_type& k) esegue la ricerca degli elementi con chiave k e, se li trova, li cancella; restituisce il numero degli elementi cancellati (che può essere 0 se non ne ha trovato nessuno, e può essere maggiore di 1 solo in multimap e multiset)

Infine, esistono alcuni metodi definiti solo nei contenitori associativi (per ognuno di essi esiste anche, ma tralasciamo di indicarla, la versione per gli oggetti const):

• iterator Cont::find(const key_type& k) restituisce un iteratore che punta al primo elemento con chiave k; se non ne trova, restituisce end()

• iterator Cont::lower_bound(const key_type& k) esegue in pratica la stessa operazione di find

• iterator Cont::upper_bound(const key_type& k) restituisce un iteratore che punta al primo elemento con chiave maggiore di k; se non ne trova, restituisce end()

• pair<iterator,iterator> Cont::equal_range(const key_type& k) restituisce una coppia di iteratori in cui first è uguale al valore di ritorno di lower_bound e second è uguale al valore di ritorno di upper_bound

• size_type Cont::count(const key_type& k) restituisce il numero degli elementi con la stessa chiave k

Il metodo find è usato preferibilmente in map e set; gli altri hanno senso solo se usati in contenitori con chiave duplicata (cioè in multimap e multiset)

Funzioni esterne

In tutti gli header-files in cui sono definite le classi dei contenitori, è anche definito un insieme (sempre uguale) di funzioni esterne di "appoggio". Abbiamo già visto la funzione swap. Le altre sono costituite dal set completo degli operatori relazionali, che servono per confrontare fra loro oggetti contenitori. Le regole applicate sono le seguenti:

• due oggetti contenitori sono uguali (operator==) se hanno la stessa dimensione e tutti gli elementi corrispondenti sono uguali (e quindi è necessario che anche nel tipo degli elementi sia definito operator==);

• dati due oggetti contenitori, a e b, si definisce a minore di b (operator<) se a precede b nell'ordinamento "lessicografico", cioè se:

Page 248: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

1. tutti gli elementi corrispondenti sono uguali e la dimensione di a è minore della dimensione di b, oppure

2. indipendentemente dalla dimensione di a e di b, il primo elemento di a non uguale al corrispondente elemento di b è minore del corrispondente elemento di b (e quindi è necessario che anche nel tipo degli elementi sia definito operator<)

notare che l'ordine alfabetico è un tipico ordinamento lessicografico (in cui i contenitori sono le parole e gli elementi sono le lettere di ogni parola)

Gli altri operatori relazionali sono ricavati da operator== e operator< e precisamente:

operator!=(a,b) ritorna ... !(operator==(a,b))

operator>(a,b) ritorna ... operator<(b,a)

operator<=(a,b) ritorna ... !(operator<(b,a))

operator>=(a,b) ritorna ... !(operator<(a,b))

Pertanto, per le operazioni di confronto fra contenitori, è necessario che nel tipo degli elementi siano definiti entrambi operator< e operator== (gli altri non servono); ricordiamo che invece per le operazioni di ordinamento degli elementi è sufficiente che sia definito operator<

Algoritmi e oggetti-funzione

Algoritmi e sequenze

La STL mette a disposizione una sessantina di funzioni template, dette "algoritmi" e definite nell'header-file <algorithm>.

Gli algoritmi operano sui contenitori, o meglio, su sequenze di dati. Fra gli argomenti di ingresso di un algoritmo è sempre presente almeno una coppia di iteratori (di tipo parametrizzato) che definiscono e delimitano una sequenza: il primo iteratore punta al primo elemento della sequenza, il secondo iteratore punta alla posizione che segue immediatamente l'ultimo elemento. Una tale sequenza è detta "semi-aperta", in quanto contiene il primo estremo ma non il secondo; una sequenza semi-aperta permette di utlizzare gli algoritmi senza dover specificare il caso particolare di una sequenza vuota. L'intervallo (range) individuato da una sequenza semi-aperta è spesso riferito nella documentazione con la scritta: [primo iteratore,secondo iteratore) dove la diversità grafica delle parentesi indica appunto che il primo estremo appartiene all'intervallo e il secondo estremo no.

Page 249: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Nella chiamata di un algoritmo (che normalmente coincide con la sua istanziazione, con deduzione implicita degli argomenti del template) gli argomenti che esprimono i due iteratori devono essere dello stesso tipo (diversamente il compilatore produre un messaggio di errore). A parte questa limitazione (peraltro ovvia), gli algoritmi sono perfettamente generici, nel senso che possono operare su qualsiasi tipo di contenitore (e su qualsiasi tipo degli elementi), purché provvisto di iteratori; anzi, proprio perché agiscono attraverso gli iteratori, alcuni algoritmi possono funzionare altrettanto bene su classi di dati, come le stringhe e le classi di input-output, che non sono propriamente contenitori, ma che hanno in comune la proprietà di definire sequenze espresse in termini di iteratori. Inoltre, la maggior parte degli algoritmi funziona anche su normali array (in questo caso, al posto degli iteratori, bisogna mettere i puntatori, mantenendo però la regola della sequenza semi-aperta).

Pertanto, la definizione più comune di un algoritmo (che indichiamo genericamente con fun) è: template <class Iter, ......> (tipo di ritorno) fun(Iter first, Iter last, ......) dove Iter è il tipo dell'iteratore associato alla sequenza di ingresso e first e last rappresentano gli estremi della sequenza. Gli altri parametri del template e gli altri argomenti dell'algoritmo sono costituiti di solito da altri iteratori (di ingresso o di uscita), da valori di dati o da oggetti-funzione. Se un algoritmo coinvolge due sequenze, i cui corrispondenti tipi iteratori sono individuati da due parametri distinti, i tipi delle due sequenze non devono essere necessariamente gli stessi, purchè coincidano i tipi degli elementi (o uno dei due sia convertibile implicitamente nell'altro).

Oggetti-funzione

Abbiamo già introdotto il concetto di oggetto-funzione trattando degli operatori in overload: gli oggetti-funzione appartengono a classi che hanno la particolare caratteristica di utilizzare in modo predominante un loro metodo, definito come operatore di chiamata di una funzione: operator() (lista di argomenti) il che permette di fornire la normale sintassi della chiamata di una funzione a oggetti di una classe.

Consideriamo ora il caso di una funzione (la chiamiamo fun) che preveda di eseguire un certo numero di operazioni, non definite a priori, ma da selezionare fra diverse operazioni possibili. Occorre pertanto che tali operazioni siano trasmesse come argomenti di chiamata di fun. Il C risolve il problema utilizzando i puntatori a funzione: fun definisce fra i suoi argomenti un puntatore a funzione; questo viene sostituito, in ogni chiamata di fun, con la funzione "vera" che esegue le operazioni volute. Ma il C++ "può fare di meglio"! Infatti i puntatori a funzione potrebbero, in certi casi, rivelarsi inadeguati, per i seguenti motivi:

• la risoluzione di un puntatore a funzione è un'operazione "costosa", in quanto il programma deve ogni volta accedere a una tabella di puntatori;

• se una funzione è chiamata più volte, potrebbero esserci informazioni da conservare o aggiornare; per cui, o si includono tutte queste informazioni

Page 250: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

nella lista degli argomenti, o si definiscono allo scopo delle variabili globali ("brutto", in entrambi i casi!);

• la scelta è comunque confinata entro un insieme di funzioni predefinite.

Il C++ consente di evitare questi inconvenienti, se, al posto di un puntatore a funzione, si inserisce, come argomento di fun, un oggetto-funzione di tipo parametrizzato. Infatti:

• la chiamata della funzione (attraverso il metodo operator(), definito nella classe dell'oggetto-funzione) è eseguita più velocemente, in quanto non deve accedere a tabelle (oltretutto operator() può, in certi casi, essere definito inline);

• le informazioni aggiuntive, da conservare o aggiornare, possono essere memorizzate nei membri definiti nella stessa classe dell'oggetto-funzione;

• poichè la suddetta classe è un parametro di template, non esiste nessun vincolo predefinito sulla scelta della funzione da eseguire (purchè il numero e il tipo dei suoi argomenti sia quello previsto).

Molti algoritmi utilizzano gli oggetti-funzione come argomenti (e le corrispondenti classi come parametri). L'utente può chiamare questi algoritmi fornendo una propria classe come argomento del template; tale classe deve contenere il metodo operator() (con al massimo due argomenti), che ha il compito di eseguire le operazioni desiderate sugli elementi di una data sequenza.

In aggiunta a quelli definiti dall'utente, la STL mette a disposizione un nutrito numero di oggetti-funzione, le cui classi sono definite nell'header-file <functional>. Molte di queste classi trasformano sostanzialmente operazioni in funzioni, in modo da renderle utilizzabili come argomenti negli algoritmi (è il processo logico inverso a quello che porta alla definizione degli operatori in overload). Nello stesso header-file sono anche definite alcune classi e funzioni (dette adattatori) che trasformano oggetti-funzione in altri oggetti-funzione, sempre allo scopo di renderli utilizzabili negli algoritmi. Non approfondiremo oltre questo argomento, la cui trattazione, piuttosto complessa, esula dagli intendimenti di questo corso; ci limiteremo a citare alcuni casi particolari, quando se ne presenterà l'occasione.

For_each

Un vantaggio chiave nell'uso degli algoritmi e degli oggetti-funzione consiste nella possibilità offerta al programmatore di "risparmiare codice" (e quindi di "risparmiare errori"!), evitandogli la necessità di scrivere cicli espliciti, che sono invece eseguiti automaticamente con una sola istruzione. Per comprendere bene tale vantaggio, consideriamo l'algoritmo "più generico che esista", for_each, il quale non fa altro che eseguire "qualcosa" su ogni elemento di una sequenza (e il "qualcosa" è deciso dall'utente). Il codice di implementazione di questo algoritmo è il seguente:

template <class Iter, class Op> Op for_each(Iter first, Iter last, Op oggf)

{

Page 251: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

while (first != last) oggf (*first++);

return oggf;

}

notare che for_each non si interessa di sapere cosa sia realmente il suo terzo argomento, ma si limita ad applicargli l'operatore (); spetterà poi al compilatore controllare, in ogni punto di istanziazione di for_each, che:

1. nella classe che sostituisce il parametro Op sia definito il metodo operator();

2. operator() abbia un solo argomento; 3. il tipo dell'argomento di operator() coincida con il tipo dell'elemento

puntato dal tipo iteratore che sostituisce il parametro Iter.

Inoltre, notare che:

• for_each ritorna lo stesso oggetto-funzione, per permettere al chiamante di accedere alle eventuali altre informazioni memorizzate nei suoi membri;

• il terzo argomento può anche essere una normale funzione, nel qual caso il valore di ritorno di for_each non ha significato.

Predicati

Un "predicato" è un oggetto-funzione che ritorna un valore di tipo bool. Gli algoritmi fanno molto uso dei predicati, il cui compito è spesso di definire criteri d'ordine alternativi a operator<, oppure di determinare, in base al valore di ritorno true o false, l'esecuzione o meno di certe operazioni. Per esempio si possono selezionare, tramite un predicato, solo gli elementi di una sequenza maggiori di un certo valore. In sostanza, come abbiamo già visto per for_each, i predicati servono a risparmiare codice, sostituendo la sola chiamata di un algoritmo alla scrittura delle istruzioni di un ciclo, contenente al suo interno costrutti if o altre istruzioni di controllo.

I predicati sono addirittura indispensabili in tutte quelle operazioni che coinvolgono ordinamenti e confronti fra tipi nativi gestiti da puntatori: in questo caso l'applicazione di default degli operatori < e == ai puntatori darebbe luogo a risultati errati.

Algoritmi che non modificano le sequenze

Alcuni algoritmi eseguono operazioni di ricerca, selezione, confronto e conteggio e non possono modificare gli elementi delle sequenze su cui operano (i loro argomenti iteratori sono definiti const).

Per ogni algoritmo, esistono sempre due versioni: quella con predicato e quella senza predicato; di solito la versione senza predicato è una parziale

Page 252: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

specializzazione della prima, dove il predicato è: elemento == valore A volte le due versioni hanno lo stesso nome e a volte no. Hanno lo stesso nome solo quando il numero degli argomenti è diverso e quindi la risoluzione dell'overload non può generare ambiguità (non dimentichiamo che i tipi degli argomenti sono parametri di template e quindi potrebbero esserci delle specializzazioni con i rispettivi tipi coincidenti, generando ambiguità nel caso che il numero degli argomenti sia uguale). Quando le due versioni non hanno lo stesso nome, quella con predicato prende il nome dell'altra seguito dal suffisso _if

Nell'esposizione che segue useremo le seguenti convenzioni:

• siccome tutti gli algoritmi sono funzioni template, ometteremo il prefisso (sempre presente): template <class ......> nella definizione di ogni algoritmo; per capire quali siano i suoi parametri, indicheremo i loro nomi con il colore viola, e in particolare:

o Iter, Iter1, Iter2 saranno parametri di tipi iteratori; o T sarà il parametro del tipo degli elementi; o Pred sarà il parametro di un tipo predicato

• nella descrizione di ogni algoritmo adotteremo la notazione della sequenza semi-aperta: [primo estremo, secondo estremo) e useremo le operazioni aritmetiche + e - sugli iteratori (lo faremo per comodità di esposizione, anche se sappiamo che tali operazioni sono applicabili solo alla categoria degli iteratori ad accesso casuale, che non sono in genere quelli utilizzati dagli algoritmi)

Gli algoritmi della "famiglia" find scorrono una sequenza, o una coppia di sequenze, cercando un valore che verifichi una determinata condizione:

Iter find(Iter first, Iter last, const T& val) Iter find_if(Iter first, Iter last, Pred pr) cerca il primo valore di un iteratore it nel range [first, last) tale che risulti true: *it == val nel primo caso e ... pr(*it) nel secondo caso; ritorna it se lo trova, oppure last se non lo trova.

Iter find_first_of(Iter1 first1, Iter1 last1, Iter2 first2, Iter2 last2) Iter find_first_of(Iter1 first1, Iter1 last1, Iter2 first2, Iter2 last2, Pred pr) cerca il primo valore di un iteratore it1 nel range [first1, last1) tale che risulti true: *it1 == *it2 nel primo caso e ... pr(*it1, *it2) nel secondo caso dove it2 è un qualunque valore di un iteratore nel range [first2, last2); ritorna it1 se lo trova, oppure last1 se non lo trova.

Iter adjacent_find(Iter first, Iter last) Iter adjacent_find(Iter first, Iter last, Pred pr) cerca il primo valore di un iteratore it nel range [first, last-1) tale che risulti true: *it == *(it+1) nel primo caso e ... pr(*it, *(it+1)) nel secondo caso; ritorna it se lo trova, oppure last se non lo trova.

Page 253: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Gli algoritmi count e count_if contano le occorrenze di un valore in una sequenza:

unsigned int count(Iter first, Iter last, const T& val) unsigned int count_if(Iter first, Iter last, Pred pr) incrementa un contatore n (inizialmente zero) per ogni valore di un iteratore it nel range [first, last) tale che risulti true: *it == val nel primo caso e ... pr(*it) nel secondo caso; ritorna n.

Gli algoritmi equal e mismatch confrontano due sequenze:

bool equal(Iter1 first1, Iter1 last1, Iter2 first2) bool equal(Iter1 first1, Iter1 last1, Iter2 first2, Pred pr) ritorna true solo se, per ogni valore dell'intero N nel range [0, last1-first1) risulta true: *(first1+N) == *(first2+N) nel primo caso e ... pr(*(first1+N), *(first2+N)) nel secondo caso.

pair<Iter1,Iter2> mismatch(Iter1 first1, Iter1 last1, Iter2 first2) pair<Iter1,Iter2> mismatch(Iter1 first1, Iter1 last1, Iter2 first2, Pred pr) cerca il più piccolo valore dell'intero N nel range [0, last1-first1) tale che risulti false: *(first1+N) == *(first2+N) nel primo caso e ... pr(*(first1+N), *(first2+N)) nel secondo caso; se non lo trova pone N = last1-first1; ritorna pair(first1+N,first2+N).

NOTA la seconda sequenza è specificata solo dal primo estremo: ciò significa che il numero dei suoi elementi deve essere almeno uguale al numero degli elementi della prima sequenza; questa tecnica è usata in tutti gli algoritmi in cui si utlizzano due sequenzecon operazioni che coinvolgono le coppie degli elementi corrispondenti.

Page 254: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Una classe C++ per le stringhe

La classe string

La Libreria Standard del C++ mette a disposizione una classe per la gestione delle stringhe, non come array di caratteri (come le stringhe del C), ma come normali oggetti (e quindi, per esempio, trasferibili per copia, a differenza delle stringhe del C, nelle chiamate delle funzioni). Questa classe si chiama string ed è definita nell'header file <string>.

Per la verità, il nome string non è altro che un sinonimo (definito con typedef) di:

basic_string<char>

dove basic_string è una classe template con tipo di carattere generico, e quindi string è una specializzazione di basic_string con argomento char. Ma poiché, come abbiamo già detto nel capitolo di introduzione alla Libreria, a noi interessano solo i caratteri di tipo char, ignoreremo la classe template da cui string proviene e tratteremo string come una classe specifica (non template).

Da un altro punto di vista, più vicino agli interessi dell'utente, string può essere considerata come un "contenitore specializzato", e in particolare "somiglia" molto a vector<char>. Possiede quasi tutte le funzionalità di vector, con alcune (poche) caratteristiche in meno e altre (molte) caratteristiche in più; quest'ultime servono soprattutto per eseguire le operazioni specifiche di manipolazione delle stringhe (come per esempio la concatenazione).

In particolare, come gli elementi di vector, anche i caratteri di string possono essere considerati come facenti parte di una sequenza, e quindi string definisce gli stessi iteratori di vector e della stessa categoria (ad accesso casuale). Ciò rende possibile l'applicazione di tutti gli algoritmi generici della STL anche a string, tramite i suoi iteratori. Questo fatto è indubbiamente un vantaggio, ma non così grande come potrebbe sembrare. Infatti gli algoritmi generici sono pensati principalmente per strutture i cui elementi sono significativi anche se presi singolarmente, il che non è generalmente vero per le stringhe. Per esempio, ordinare una stringa non ha senso (e quindi gli algoritmi di ordinamento o di manipolazione di sequenze ordinate sono poco utili se applicati alle stringhe). L'attenzione maggiore va invece concentrata sui metodi di string, alcuni dei quali sono implementati in modo da ottenere un'ottimizzazione più spinta di quanto non sia possibile nel caso generale.

Confronto fra string e vector<char>

Page 255: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

In questa sezione elencheremo le funzionalità comuni a string e vector, e, separatamente, i metodi di vector non presenti in string. Nelle sezioni successive tratteremo esclusivamente delle funzioni-membro e delle funzioni esterne specifiche di string. Per il significato dei nomi, e per la descrizione dei tipi e delle funzioni, vedere il capitolo: La Standard Template Library, sezione: Contenitori Standard.

Tipi definiti in string

Nell'ambito della classe string sono definiti gli stessi tipi definiti in vector e in particolare (citiamo i più importanti): iterator, const_iterator, reverse_iterator, const_reverse_iterator, difference_type, value_type, size_type, reference, const_reference

Funzioni-membro comuni

I seguenti metodi, già descritti nella trattazione dei contenitori, sono definiti sia in vector che in string, hanno la stessa sintassi di chiamata e svolgono le medesime operazioni (se vector è specializzato con con argomento char):

dereferenziazione di un iteratore

begin end rbegin rend

resize

size empty

max_size

reserve capacity

costruttore di default

costruttore di copia operator= assign

costruttore e assign tramite iteratori

swap

operator[] (accesso non controllato)

at (accesso controllato)

insert erase

Note:

• come gli oggetti di vector, anche quelli di string possono utilizzare i metodi operator[] e at per accedere ai propri elementi (i singoli caratteri) tramite indice;

• c'è una piccola differenza fra i due metodi assign di vector e quelli di string: i primi ritornano void, mentre i secondi ritornano string&;

• a proposito del metodo size, è definito in string anche il metodo length, che fa esattamente la stessa cosa.

Funzioni esterne comuni

Page 256: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Tutte le funzioni esterne di "appoggio" definite nell'header file <vector> sono anche definite nell'header file <string>; ricordiamo che queste funzioni sono: swap, operator==, operator!=, operator<, operator<=, operator>, operator>=. Ognuna di esse ha due argomenti, che nelle funzioni definite nell'header file <string> sono ovviamente di tipo string.

Funzioni-membro di vector non presenti in string

Un numero molto ridotto di metodi di vector non è ridefinito in string:

• Costruttore con un 1 argomento Non è ammesso inizializzare una stringa fornendole solo la dimensione. Per esempio: string str(7); è un'istruzione errata; invece è possibile inizializzare una stringa fornendole la dimensione e il carattere di "riempimento". Per esempio: string str(7,'a'); ok, genera: "aaaaaaa"; in pratica il secondo argomento, che in vector è opzionale, in string è obbligatorio

• Operazioni in testa e in coda i seguenti metodi di vector non esistono in string: front, back, push_back, pop_back

• Metodo clear in compenso esiste un ulteriore overload del metodo erase che esegue la stessa operazione

Il membro statico npos

La classe string dichiara il seguente dato-membro "atipico":

static const size_type npos;

che è inizializzato con il valore -1. Poiché d'altra parte il tipo size_type è sempre unsigned, la costante string::npos contiene in realtà il massimo numero positivo possibile. Viene usato come argomento di default di alcune funzioni-membro o come valore di ritorno "speciale" (per esempio per indicare che un certo elemento non è stato trovato). In pratica npos rappresenta un indice che "non può esistere", in quanto è maggiore di tutti gli indici possibili. In un certo senso svolge le stesse funzioni del terminatore nelle stringhe del C, che non esiste negli oggetti di string (il carattere '\0' può essere un elemento di string come tutti gli altri).

Come vedremo, i metodi di string che utilizzano gli indici come argomenti spesso fanno uso di npos per indicare la fine della stringa.

Page 257: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Costruttori e operazioni di copia

Oltre ai 4 costruttori già visti (default, copia da oggetto string, copia tramite iteratori e inizializzazione con carattere di "riempimento"), string definisce i seguenti costruttori specifici:

string::string(const string& str, size_type ind, size_type n=npos) copia da str, a partire dall'elemento con indice ind, per n elementi o fino al termine di str (quello che "arriva prima")

string::string(const char* s) copia caratteri, a partire da quello puntato da s e fino a quando incontra il carattere '\0' (escluso); in pratica copia una stringa del C (che può anche essere una costante literal)

string::string(const char* s, size_type n) come sopra, salvo che copia solo n caratteri (se prima non incontra '\0')

Per quello che riguarda le copie in oggetti di string già esistenti, oltre all'operatore di assegnazione standard e alle due versioni del metodo assign (copia tramite iteratori e copia con carattere di "riempimento") presenti anche in vector, string definisce ulteriori overloads (in tutte le seguenti operazioni l'oggetto esistente viene cancellato e sostituito da quello ottenuto per copia):

string& string::operator=(const char* s) copia una stringa del C

string& string::operator=(char c) copia un singolo carattere; nota: l'assegnazione di un singolo carattere è ammessa, mentre l'inizializzazione non lo è

string& string::assign(const string& str) esegue le stesse operazioni dell'operatore di assegnazione standard

string& string::assign(const string& str, size_type ind, size_type n) esegue le stesse operazioni del costruttore con uguali argomenti

string& string::assign(const char* s) esegue le stesse operazioni dell'operatore di assegnazione con uguale argomento

string& string::assign(const char* s, size_type n) esegue le stesse operazioni del costruttore con uguali argomenti

Page 258: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Gestione degli errori

Abbiamo detto che, come in vector, operator[] non controlla che l'argomento indice sia compreso nel range [0,size()), mentre il metodo at effettua il controllo e genera un'eccezione di tipo out_of_range in caso di errore.

Molti altri metodi di string hanno, fra gli argomenti, due tipi size_type consecutivi, di cui il primo rappresenta un indice (che ha il significato di "posizione iniziale"), mentre il secondo rappresenta il numero di caratteri "da quel punto in poi" (abbiamo già visto così fatti un costruttore e un metodo assign). In tutti i casi il primo argomento è sempre controllato (generando la solita eccezione se l'indice non è nel range), mentre il secondo non lo è mai e quindi un numero di caratteri troppo alto viene semplicemente interpretato come "il resto della stringa" (che in particolare è l'unica interpretazione possibile se il valore del secondo argomento è npos). Notare che, se la "posizione iniziale" e/o il numero di caratteri sono dati come numeri negativi, questi vengono convertiti in valori positivi molto grandi (essendo size_type un tipo unsigned), e quindi, per esempio:

string(str,-2,3); genera out_of_range

string(str,3,-2);

va bene: costruisce un oggetto string per copia da str, a partire dal quarto carattere fino al termine

I metodi per la ricerca di sotto-stringhe (che vedremo più avanti) restituiscono npos in caso di insuccesso, ma non generano eccezioni; se però il programma dell'utente non controlla il valore di ritorno e lo usa direttamente come argomento di "posizione" nella chiamata di un'altra funzione, allora sì che, in caso di insuccesso nella ricerca, si genera un'eccezione out_of_range.

I metodi che usano una coppia di iteratori al posto della coppia "posizione-numero" non effettuano nessun controllo (e lo stesso discorso vale per gli algoritmi, come sappiamo) e quindi spetta al programma dell'utente assicurare che i limiti del range non vengano oltrepassati.

La stessa cosa dicasi quando la coppia di argomenti "posizione-numero" si riferisce a una stringa del C: anche qui non viene eseguito nessun controllo (a parte il controllo sul terminator che viene riconosciuto come fine della stringa) e quindi bisogna porre la massima attenzione sull'argomento che rappresenta la "posizione iniziale" (che in questo caso è un puntatore a char): anzitutto deve essere diverso da NULL (altrimenti il programma abortisce) e in secondo luogo deve realmente puntare a un carattere interno alla stringa.

Un altro tipo di errore (comune anche a vector), molto raro, che genera un'eccezione di tipo length_error, avviene quando si tenta di costruire una stringa più lunga del massimo consentito (dato da max_size). Lo stesso errore è generato se si tenta di superare max_size chiamando un metodo che modifica la dimensione direttamente (resize) o implicitamente (insert, append, replace, operator+=), oppure che modifica la capacità (reserve).

Page 259: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Conversioni fra oggetti string e stringhe del C

La conversione da una stringa del C (che indichiamo con s) a un oggetto string (che indichiamo con str) si ottiene semplicemente assegnando s a str (con operator= o con il metodo assign), oppure costruendo str per copia da s.

Ovviamente, se si vuole eseguire la conversione inversa, da oggetto string a stringa del C, non si può semplicemente invertire gli operandi nell'assegnazione, in quanto il tipo nativo char* non consente assegnazioni da oggetti string. Bisogna invece ricorrere ad alcuni metodi definiti nella stessa classe string. Questi metodi sono 3 e precisamente:

1. const char* string::data() const scrive i caratteri di *this in un array di cui restituisce il puntatore. L'array è gestito internamente a string e perciò non va preallocato nè cancellato. L'oggetto *this non può essere modificato, nel senso che una sua successiva modifica invalida l'array, nè possono essere modificati i caratteri dello stesso array (in pratica il metodo data può operare solo su oggetti costanti). Non viene aggiunto il terminator alla fine dell'array e quindi non è possibile utilizzare l'array come argomento nelle funzioni che operano sulle stringhe (in sostanza è proprio un array di caratteri , non una stringa!)

2. const char* string::c_str() const è identico a data, salvo il fatto che aggiunge il terminator alla fine, creando così un array di caratteri null terminated, cioè una "vera" stringa del C

3. size_type copy(char* s, size_type n, size_type pos = 0) const copia n caratteri di *this, a partire dal carattere con indice pos, nell'array s, preallocato dal chiamante. Restituisce il numero di caratteri effettivamente copiati. Non aggiunge il terminator alla fine dell'array. Per copiare tutti i caratteri di *this si può usare string::npos come secondo argomento e omettere il terzo.

Da un esame critico dei tre metodi sopracitati, si può osservare che:

1. data è "quasi" inutilizzabile (può servire solo quando si trattano array di caratteri e non stringhe)

2. c_str è invece molto utile, perchè permette di inserire il suo valore di ritorno come argomento nelle funzioni di Libreria del C che operano sulle stringhe. Per esempio: int m = atoi(str.c_str()); (nota: non esistono funzioni C++ che convertono stringhe di caratteri decimali in numeri). Tuttavia può operare solo su oggetti costanti

Page 260: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

3. copy ha il vantaggio di permettere la modifica dell'array copiato. Bisogna però ricordarsi di aggiungere un carattere '\0' in fondo (e bisogna anche evitare che lo stesso carattere sia presente all'interno della stringa da copiare)

Confronti fra stringhe

Per confrontare due oggetti string, o un oggetto string e una stringa del C, la classe string fornisce il metodo compare, con vari overloads. Il valore di ritorno è sempre di tipo int ed ha il seguente significato:

• 0, se le due stringhe sono identiche; • un numero negativo se *this precede lessicograficamente la stringa-

argomento; • un numero positvo se *this segue lessicograficamente la stringa-

argomento.

Rispetto agli operatori relazionali, il metodo compare ha quindi il vantaggio di restituire il risultato di <, == o > con una sola chiamata. I suoi overloads sono:

• int compare(const string& str) const confronta *this con l'oggetto string str

• int compare(const char* s) const confronta *this con la stringa del C s

• int compare(size_type ind, size_type n, const string& str) const confronta la sotto-stringa di *this, data dalla coppia "posizione-numero" ind-n, con l'oggetto string str

• int compare(size_type ind, size_type n, const string& str, size_type ind1, size_type n1) const confronta la sotto-stringa di *this, data dalla coppia "posizione-numero" ind-n, con la sotto-stringa dell'oggetto string str, data dalla coppia "posizione-numero" ind1-n1

• int compare(size_type ind, size_type n, const char* s, size_type n1=npos) const confronta la sotto-stringa di *this, data dalla coppia "posizione-numero" ind-n, con i primi n1 caratteri della stringa del C s

L'utente non può fornire un criterio di confronto specifico; se lo vuol fare, non deve usare compare, ma l'algoritmo lexicographical_compare con un predicato. Per esempio: lexicographical_compare(s1.begin(),s1.end(),s2.begin(),s2.end(),nocase);

Page 261: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

restituisce true se la stringa s1 precede la stringa s2 in base al criterio di confronto dato dalla funzione nocase (fornita dall'utente).

Nell'header-file <string> si trovano varie funzioni esterne di "appoggio" che implementano diversi overloads degli operatori relazionali: <, <=, ==, !=, >, >=; per ognuno di essi esistono tre versioni: quella presente anche in <vector> e negli header-files degli altri contenitori, in cui entrambi gli operandi sono della stessa classe contenitore (in questo caso string) e quelle in cui rispettivamente il primo o il secondo operando è di tipo const char* (cioè una stringa del C). Questo permette di confrontare indifferentemente due oggetti string, o un oggetto string e una stringa del C, o una stringa del C e un oggetto string. In particolare la stringa del C può essere una costante literal. Esempio: if (str == "Hello") .....

Concatenazioni e inserimenti

Concatenare due stringhe significa scrivere le due stringhe l'una di seguito all'altra in una terza stringa.

Nell'header-file <string> si trovano varie funzioni esterne di "appoggio" che implementano diversi overloads dell'operatore +, il quale fornisce la stringa concatenata, date due stringhe come operandi; di queste, una è sempre di tipo const string&, mentre l'altra può essere ancora di tipo const string&, oppure di tipo const char* (cioè una stringa del C), oppure di tipo char (cioè un singolo carattere). Mantenendo la convenzione simbolica che abbiamo usato finora, riteniamo a questo punto che la descrizione delle funzioni possa essere omessa (quando è autoesplicativa già in base ai tipi e ai nomi convenzionali degli argomenti):

• string operator+(const string& str1, const string& str2) • string operator+(const string& str, const char* s) • string operator+(const char* s, const string& str) • string operator+(const string& str, char c) • string operator+(char c, const string& str)

Per l'operazione di somma e assegnazione in notazione compatta, sono disponibili tre metodi che implementano altrettanti overloads dell'operatore +=. In questo caso la stringa concatenata è la stessa di partenza (*this) a cui viene aggiunta in coda la stringa-argomento:

• string& string::operator+=(const string& str) • string& string::operator+=(const char* s) • string& string::operator+=(char c)

Page 262: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Il metodo append esegue la stessa operazione di operator+=, con il vantaggio che gli argomenti possono essere più di uno. Ne sono forniti vari overloads:

• string& string::append(const string& str) • string& string::append(const string& str, size_type ind, size_type

n) • string& string::append(const char* s) • string& string::append(const char* s, size_type n) • string& string::append(size_type n, char c)

appende n volte il carattere c • string& string::append(Iter first, Iter last)

Per quello che riguarda l'inserimento di caratteri "in mezzo" a una stringa (operazione di bassa efficienza, come in vector), sono disponibili ulteriori overloads del metodo insert (oltre a quelli comuni con vector); tutti inseriscono caratteri prima dell'elemento di *this con indice pos e restituiscono by reference lo stesso *this:

• string& string::insert(size_type pos, const string& str) • string& string::insert(size_type pos, const string& str, size_type

ind, size_type n) • string& string::insert(size_type pos, const char* s) • string& string::insert(size_type pos, const char* s, size_type n) • string& string::insert(size_type pos, size_type n, char c)

inserisce n volte il carattere c

Ricerca di sotto-stringhe

Nella classe string sono definiti molti metodi che ricercano la stringa-argomento come sotto-stringa di *this. Tutti restituiscono un valore di tipo size_type, che, se la sotto-stringa è trovata, rappresenta l'indice del suo primo carattere; se invece la ricerca fallisce il valore restituito è npos. Tutti i metodi sono definiti const in quanto eseguono la ricerca senza modificare l'oggetto.

Nell'elenco che segue, suddiviso in vari gruppi, l'argomento di nome pos rappresenta l'indice dell'elemento di *this da cui iniziare la ricerca, mentre l'argomento di nome n rappresenta il numero di caratteri della stringa-argomento da utilizzare per la ricerca.

Cerca una sotto-stringa:

• size_type string::find(const string& str, size_type pos=0) const • size_type string::find(const char* s, size_type pos=0) const

Page 263: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• size_type string::find(const char* s, size_type pos, size_type n) const

• size_type string::find(char c, size_type pos=0) const

Come sopra, ma partendo dalla fine di *this e scorrendo all'indietro:

• size_type string::rfind(const string& str, size_type pos=npos) const

• size_type string::rfind(const char* s, size_type pos=npos) const • size_type string::rfind(const char* s, size_type pos, size_type n)

const • size_type string::rfind(char c, size_type pos=npos) const

Cerca il primo carattere di *this che si trova nella stringa-argomento:

• size_type string::find_first_of(const string& str, size_type pos=0) const

• size_type string::find_first_of(const char* s, size_type pos=0) const

• size_type string::find_first_of(const char* s, size_type pos, size_type n) const

• size_type string::find_first_of(char c, size_type pos=0) const

Come sopra, ma partendo dalla fine di *this e scorrendo all'indietro:

• size_type string::find_last_of(const string& str, size_type pos=npos) const

• size_type string::find_last_of(const char* s, size_type pos=npos) const

• size_type string::find_last_of(const char* s, size_type pos, size_type n) const

• size_type string::find_last_of(char c, size_type pos=npos) const

Cerca il primo carattere di *this che non si trova nella stringa-argomento:

• size_type string::find_first_not_of(const string& str, size_type pos=0) const

• size_type string::find_first_not_of(const char* s, size_type pos=0) const

• size_type string::find_first_not_of(const char* s, size_type pos, size_type n) const

• size_type string::find_first_not_of(char c, size_type pos=0) const

Come sopra, ma partendo dalla fine di *this e scorrendo all'indietro:

• size_type string::find_last_not_of(const string& str, size_type pos=npos) const

• size_type string::find_last_not_of(const char* s, size_type pos=npos) const

• size_type string::find_last_not_of(const char* s, size_type pos, size_type n) const

• size_type string::find_last_not_of(char c, size_type pos=npos) const

Page 264: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Estrazione e sostituzione di sotto-stringhe

Il metodo substr crea una stringa estraendola da *this e la restituisce per copia:

string string::substr(size_type pos=0, size_type n=npos) const

la stringa originaria non è modificata; la nuova stringa coincide con la sotto-stringa di *this che parte dall'elemento con indice pos e contiene n caratteri.

Il metodo replace, definito con vari overloads, sotituisce una sotto-stringa di *this con la stringa-argomento (o una sua sotto-stringa) e restituisce by reference lo stesso *this. Il numero dei nuovi caratteri non deve necessariamente coincidere con quello preesistente (la nuova sotto-stringa può essere più lunga o più corta di quella sostituita) e quindi il metodo replace, oltre a modificare l'oggetto, può anche modificarne la dimensione.

Nell'elenco che segue, i nomi degli argomenti hanno il seguente significato:

• pos : "posizione iniziale" in *this • m : "numero di caratteri" in *this • ind : "posizione iniziale" nella stringa-argomento • n : "numero di caratteri" nella stringa-argomento • ib,ie : iteratori che delimitano la sotto-stringa in *this • n,c: carattere c ripetuto n volte

Metodi che definiscono la sotto-stringa da sostituire mediante la coppia "posizione-numero":

• string& string::replace(size_type pos, size_type m, const string& str)

• string& string::replace(size_type pos, size_type m, const string& str, size_type ind, size_type n)

• string& string::replace(size_type pos, size_type m, const char* s) • string& string::replace(size_type pos, size_type m, const char* s,

size_type n) • string& string::replace(size_type pos, size_type m, size_type n,

char c)

Metodi che definiscono la sotto-stringa da sostituire mediante una coppia di iteratori:

• string& string::replace(iterator ib, iterator ie, const string& str) • string& string::replace(iterator ib, iterator ie, const char* s)

Page 265: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• string& string::replace(iterator ib, iterator ie, const char* s, size_type n)

• string& string::replace(iterator ib, iterator ie, size_type n, char c) • string& string::replace(iterator ib, iterator ie, Iter first, Iter last)

Per cancellare una sotto-stringa è disponibile un ulteriore overload del metodo erase (oltre a quelli comuni con vector): string& string::erase(size_type pos=0, size_type n=npos) notare che la chiamata di erase senza argomenti equivale alla chiamata di clear in vector.

Operazioni di input-output

Nell'header-file <string> si trovano due funzioni esterne di "appoggio" che implementano due ulteriori overloads degli operatori di flusso "<<" (inserimento) e ">>" (estrazione), con right-operand di tipo string.

Pertanto, la lettura e la scrittura di un oggetto string si possono eseguire semplicemente utilizzando gli operatori di flusso come per le stringhe del C.

In particolare la lettura "salta" (cioè non inserisce nella stringa) i caratteri bianchi e i caratteri speciali (che anzi usa come separatori fra una stringa e l'altra). I caratteri "buoni" vengono invece immessi nella stringa l'uno dopo l'altro a partire dalla "posizione" 0 e fino all'incontro di un separatore; la stringa letta sostituisce quella memorizzata precedentemente, assumendo (in più o in meno) anche una nuova dimensione.

Per la lettura di una stringa che includa anche i caratteri bianchi e i caratteri speciali, in <string> è definita anche la funzione getline:

istream& getline(istream&, string& str, char eol='\n')

che estrae caratteri dal flusso di input e li memorizza in str; l'estrazione termina quando è incontrato il carattere eol, che viene rimosso dal flusso di input ma non inserito in str. Omettendo il terzo argomento si ottiene effettivamente la lettura di una intera "linea" di testo. Il valore di ritono, di tipo riferimento a istream, permette di utilizzare la chiamata di getline come left-operand di una o più operazioni di estrazione. Esempio: getline(cin,str1,'\t') >> str2 >> str3 ; legge tutti i caratteri fino al primo tabulatore (escluso), memorizzandoli in str1, e poi legge due sequenze di caratteri delimitate da separatori e li memorizza in str2 e str3 .

Page 266: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Librerie statiche e dinamiche in Linux

Introduzione

Un problema che si presenta comunemente nello sviluppo dei programmi è che questi tendono a diventare sempre più complessi, il tempo richiesto per la loro compilazione cresce di conseguenza, e la directory di lavoro è sempre più affollata. E' proprio in questa fase che incominciamo a chiederci se non esista un modo più efficiente per organizzare i nostri progetti. Una possibilità che ci viene offerta dai compilatori sono le librerie.

Librerie in ambiente Linux

Una libreria è semplicemente un file contenente codice compilato che può essere successivamente incorporato come una unica entità in un nostro programma in fase di linking; l'utilizzo delle librerie ci permettere di realizzare programmi più facili da compilare e mantenere. Di norma le librerie sono indicizzate, così risulta più facile localizzare simboli (funzioni, variabili, classi, etc...) al loro interno. Per questa ragione il link ad una libreria è più veloce rispetto al caso in cui i moduli oggetto siano separati nel disco. Inoltre, quando usiamo una libreria abbiamo meno files da aprire e controllare, e questo comporta un ulteriore aumento della velocità del processo di link.

Nell'ambiente Linux (come nella maggior parte dei sistemi moderni) le librerie si suddividono in due famiglie principali:

• librerie statiche (static libraries) • librerie dinamiche o condivise (shared libraries)

Ognuna presenta vantaggi e svantaggi, ma tutte hanno una cosa in comune: costituiscono un catalogo di funzioni, classi, etc..., che ogni programmatore può riutilizzare.

Un programma di prova

Prima di vedere come si costruiscono e si usano questi due tipi di librerie, presentiamo un piccolo programma di prova che ci servirà da esempio.

Il programma comprende una collezione di funzioni matematiche (myfuncs) ed un gestore di errori (la classe ErrMsg):

• main.cpp • myfuncs.h • myfuncs.cpp • errmsg.h • errmsg.cpp

Page 267: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Le funzioni ''div'' e ''log'' in sostanza ridefiniscono le operazioni di divisione e il logaritmo decimale ma in aggiunta permettono una gestione delle eccezioni tramite il meccanismo di throw-catch.

Il programma può essere compilato in maniera ''convenzionale'' tramite l'istruzione:

g++ -o prova main.cpp myfuncs.cpp errmsg.cpp

L'eseguibile prova si aspetta sulla linea di comando due numeri e calcola in sequenza il loro rapporto ed il logaritmo del primo:

./prova 10 3 3.33333 1

Queste operazioni vengono eseguite nel main del programma in un blocco try; se si verifica una eccezione (nella fattispecie una divisione per zero o il logaritmo di un numero negativo) il blocco catch invoca la funzione membro ErrMsg.print_message() ed il programma termina con un messaggio di errore:

./prova -10 3 -3.33333 **Severe Error in "double log(double)":Invalid argument. Quitting now.

Librerie statiche

Le librerie statiche vengono installate nell'eseguibile del programma prima che questo possa essere lanciato. Esse sono semplicemente cataloghi di moduli oggetto che sono stati collezionati in un unico file contenitore. Le librerie statiche ci permettono di effettuare dei link di programmi senza dover ricompilare il loro codice sorgente. Per far girare il nostro programma abbiamo bisogno solo del suo file eseguibile.

Come costruire una libreria statica

Per costruire una libreria statica bisogna partire dai moduli oggetto dei nostri sorgenti.

g++ -c myfuncs.cpp errmsg.gcc

Page 268: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Una volta compilati i moduli myfuncs.o e errmsg.o, costruiamo la libreria statica libmath_util.a con il programma di archiviazione ar:

ar r libmath_util.a myfuncs.o errmsg.o

Il comando ar invocato con la flag ''r'' crea la libreria (se ancora non esiste) e vi inserisce (eventualmente rimpiazzandoli) i moduli oggetto. Nel scegliere il nome di una libreria statica è stata utilizzata la seguente convenzione: il nome del file della libreria inizia con il prefisso ''lib'' e termina con il suffisso ".a".

Per verificare il contenuto della libreria possiamo usare

ar tv libmath_util.a rw-r--r-- 223/100 18256 Dec 10 14:24 2003 errmsg.o rw-r--r-- 223/100 23476 Dec 10 14:23 2003 myfuncs.o

Link con una libreria statica

Una volta creato il nostro archivio, vogliamo utilizzarlo in un programma. Per poter effettuare il link ad una libreria statica, il compilatore g++ deve essere utilizzato in questo modo:

g++ -o prova_s main.cpp -L. -lmath_util

Dove abbiamo chiamato l'eseguibile prova_s per ricordarci che è stato ottenuto tramite il link alla libreria statica. Notate che abbiamo omesso il prefisso ''lib'' e il suffisso ''.a'' quando abbiamo immesso il nome della libreria nella linea di comando con la flag "-l". Ci pensa il linker ad attaccare queste parti alla fine e all'inizio del nome di libreria. Notate inoltre l'uso della flag ''-L.'' che dice al compilatore di cercare la libreria anche nella directory in uso e non solo nelle directory standard dove risiedono le librerie di sistema (per es. /usr/lib/).

Il processo di link inizia con il caricamento del modulo main.o in cui viene definita la funzione main(). A questo punto il linker si accorge della presenza dei nomi di funzioni div e log e della classe ErrMsg, utilizzate dalla funzione main() ma non definite. Siccome viene fornito al linker il nome della libreria libmath_util.a, viene fatta una ricerca nei moduli all'interno di questa libreria per cercare quelli in cui sono definite queste entità. Una volta localizzati, questi moduli vengono estratti dalla libreria ed inclusi nell'eseguibile del programma.

L'eseguibile prova_s contiene così tutto il codice necessario al suo funzionamento ed è pronto per essere lanciato.

I limiti del meccanismo del link statico

Page 269: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Si deve precisare che il linker estrae dalla libreria statica solo i moduli strettamente necessari alla compilazione del programma. Questo dimostra una certa capacità di economizzare le risorse delle librerie. Pensiamo però a più programmi che utilizzano, magari per altri scopi, la stessa libreria statica. I programmi utilizzano la libreria statica distintamente, cioè ognuno ne possiede una copia. Se questi devono essere eseguiti contemporaneamente nello stesso sistema, i requisiti di memoria si moltiplicano di conseguenza solo per ospitare funzioni assolutamente identiche.

Le librerie condivise forniscono un meccanismo che permette a una singola copia di un modulo di codice di essere condivisa tra diversi programmi nello stesso sistema operativo. Ciò permette di tenere solo una copia di una data libreria in memoria ad un certo istante.

Librerie condivise

Le librerie condivise (dette anche dinamiche) vengono collegate ad un programma in due passaggi. In un primo momento, durante la fase di compilazione (Compile Time), il linker verifica che tutti i simboli (funzioni, variabili, classi, e simili ...) richieste dal programma siano effettivamente collegate o al programma o ad una delle sue librerie condivise. In ogni caso i moduli oggetto della libreria dinamica non vengono inseriti direttamente nel file eseguibile. In un secondo momento, quando l'eseguibile viene lanciato (Run Time), un programma di sistema (dynamic loader) controlla quali librerie dinamiche sono state collegate al nostro programma, le carica in memoria, e le attacca alla copia del programma in memoria.

La fase di caricamento dinamico rallenta leggermente il lancio del programma, ma si ottiene il notevole vantaggio che, se un secondo programma collegato alla stessa libreria condivisa viene lanciato, questo può utilizzare la stessa copia della libreria dinamica già in memoria, con un prezioso risparmio delle risorse del sistema. Per esempio, le librerie standard del C e del C++ sono delle librerie condivise utilizzate da tutti i programmi C/C++.

L'uso di librerie condivise ci permette quindi di utilizzare meno memoria per far girare i nostri programmi e di avere eseguibili molto più snelli, risparmiando così spazio disco.

Come costruire una libreria condivisa

La creazione di una libreria condivisa è molto simile alla creazione di una libreria statica. Si compila una lista di oggetti e li si colleziona in un unico file. Ci sono però due differenze importanti:

1. Dobbiamo compilare per "Position Independent Code" (PIC). Visto che al momento della creazione dei moduli oggetto non sappiamo in quale posizione della memoria saranno inseriti nei programmi che li useranno, tutte le chiamate alle funzioni devono usare indirizzi relativi e non assoluti.

Page 270: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Per generare questo tipo di codice si passa al compilatore la flag "-fpic" o "-fPIC" nella fase di compilazione dei moduli oggetto.

2. Contrariamente alle librerie statiche, quelle dinamiche non sono file di archivio. Una libreria condivisa ha un formato specifico che dipende dall'architettura per la quale è stata creata. Per generarla di usa o il compilatore stesso con la flag "-shared" o il suo linker.

Consideriamo ancora una volta il nostro programma di prova. I comandi per la creazione di una libreria condivisa possono presentarsi come segue:

g++ -fPIC -c myfuncs.cpp g++ -fPIC -c errmsg.cpp g++ -shared -o libmath_util.so myfuncs.o errmsg.o

Nel scegliere il nome di una libreria condivisa è stata utilizzata la convenzione secondo cui il nome del file della libreria inizia con il prefisso ''lib'' e termina con il suffisso ".so''.

I primi due comandi compilano i moduli oggetto con l'opzione (fPIC) in maniera tale che essi siano utilizzabili per una libreria condivisa (possiamo comunque utilizzarli in un programma normale anche se sono stati compilati con PIC). L'ultimo comando chiede al compilatore di generare la libreria dinamica.

Link con una libreria condivisa

Come abbiamo già preannunciato l'uso di una libreria condivisa si articola in due momenti: Compile time e Run Time. La parte di compilazione e semplice. Il link ad una libreria condivisa avviene in maniera del tutto simile al caso di una libreria statica

g++ -o prova_d main.cpp -L. -lmath_util

Dove abbiamo chiamato l'eseguibile prova_d per ricordarci che è stato ottenuto tramite il link alla libreria dinamica.

Se però proviamo a lanciare l'eseguibile otteniamo una sgradita sorpresa:

./prova_d -10 3 ./prova_d: error while loading shared libraries: libmath_util.so: cannot open shared object file: No such file or directory

Il dynamic loader non è in grado di localizzare la nostra libreria!

Page 271: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Possiamo infatti usare il comando ldd per verificare le dipendenze delle librerie condivise e scoprire che la nostra libreria non viene localizzata dal loader dinamico:

ldd ./prova_d libmath_util.so => not found libstdc++.so.5 => /usr/lib/libstdc++.so.5 (0x40030000) libm.so.6 => /lib/tls/libm.so.6 (0x400e3000) libgcc_s.so.1 => /lib/libgcc_s.so.1 (0x40106000) libc.so.6 => /lib/tls/libc.so.6 (0x42000000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)

Ciò avviene perché la nostra libreria non risiede in una directory standard.

La variabile ambiente LD_LIBRARY_PATH

Ci sono diversi modi per specificare la posizione delle librerie condivise nell'ambiente linux. Se avete i privilegi di root, una possibilità è quella di aggiungere il path della nostra libreria al file /etc/ld.so.conf per poi lanciare /sbin/ldconfig . Ma se non avete l'accesso all'utente root, potete sfruttare la variabile ambiente LD_LIBRARY_PATH per dire al dynamic loader dove cercare la nostra libreria:

setenv LD_LIBRARY_PATH /home/murgia/C++/ ldd ./prova_d libmath_util.so => /home/murgia/C++/libmath_util.so (0x40017000) libstdc++.so.5 => /usr/lib/libstdc++.so.5 (0x40030000) libm.so.6 => /lib/tls/libm.so.6 (0x400e3000) libgcc_s.so.1 => /lib/libgcc_s.so.1 (0x40106000) libc.so.6 => /lib/tls/libc.so.6

Page 272: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

(0x42000000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)

In questo caso il programma ldd ci informa che ora il dynamic loader è in grado di localizzare libmath_util.so, ed il programma sarà eseguito con successo.

La flag -rpath

Esiste anche la possibilità di passare al linker la locazione della nostra librerie con l'opzione -rpath in questa maniera

g++ -o prova_d main.cpp -Wl,-rpath,/home/murgia/C++/ -L. -lmath_util

in questo caso non sarà necessario preoccuparsi di definire la variabile ambiente LD_LIBRARY_PATH.

Si faccia però attenzione al fatto che il linker da' la precedenza al path specificato con -rpath, se questo non è specificato allora usa il valore di LD_LIBRARY_PATH, e solo infine verifica il contenuto del file /etc/ld.so.conf.

Che tipo di libreria sto usando?

Se nella stessa directory sono presenti sia libmath_util.so che libmath_util.a il linker preferirà la prima. Per forzare il linker ad utilizzare la libreria statica si può usare la flag -static.

Un aspetto positivo dell'utilizzo delle librerie condivise

Diversi programmi che fanno uso di librerie comuni possono essere corretti contemporaneamente intervenendo sulla libreria che è fonte di errore. La sola ricompilazione e sostituzione della libreria risolve un problema comune.

Librerie statiche vs librerie condivise

Per riassumere:

Librerie statiche:

• Ogni processo ha la sua copia della libreria statica che sta usando, caricata in memoria.

• Gli eseguibili collegati con librerie statiche sono più grandi.

Page 273: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Librerie condivise:

• Solo una copia della libreria viene conservata in memoria ad un dato istante (sfruttiamo meno memoria per far girare i nostri programmi e gli eseguibili sono più snelli).

• I programmi partono più lentamente.

Page 274: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Le operazioni di input-ouput in C++

La gerarchia di classi stream

La Libreria Standard del C++ mette a disposizione, per l'esecuzione delle operazioni di input-output, un insieme di classi, funzioni e oggetti globali (tutti definiti, come sempre, nel namespace std). Fra questi, conosciamo già gli oggetti cin, cout e cerr (a cui bisogna aggiungere, per completezza, clog, che differisce cerr da in quanto opera con output bufferizzato), collegati ai dispositivi standard stdin, stdout e stderr; e conosciamo anche l'esistenza di varie funzioni che implementano gli overloads degli operatori di flusso "<<" (inserimento) e ">>" (estrazione), rispettivemente per la scrittura dei dati su cout o cerr, e per la lettura dei dati da cin.

Tutte le funzionalità di I/O del C++ sono definite in una decina di header-files. Il principale è <iostream>, che va sempre incluso. Alcuni altri sono inclusi dallo stesso <iostream>, per cui citeremo di volta in volta solo quelli necessari.

Alcune classi della Libreria gestiscono operazioni di I/O "ad alto livello", cioè indipendenti dal dispositivo, che può essere un'unità esterna (come i dispositivi standard a noi noti), un file, o anche un'area di memoria (in particolare una stringa); queste classi sono strutturate in un'organizzazione gerarchica: da un'unica classe base discendono, per eredità, tutte le altre. Ogni loro istanza è detta genericamente "stream" (flusso). Il concetto di stream è un'astrazione, che rappresenta un "qualcosa" da o verso cui "fluisce" una sequenza di bytes; in sostanza un oggetto stream può essere interpretato come un "file intelligente" (con proprietà e metodi, come tutti gli oggetti), che agisce come "sorgente" da cui estrarre (input), o "destinazione" in cui inserire (output) i dati.

Un altro concetto importante è quello della "posizione corrente" in un oggetto stream (file position indicator), che coincide con l'indice (paragonando lo stream a un array) del prossimo byte che deve essere letto o scritto. Ogni operazione di I/O modifica la posizione corrente, la quale può essere anche ricavata o impostata direttamente usando particolari metodi (come vedremo). A questo proposito precisiamo che la parola "inserimento", usata come sinonimo di operazione di scrittura, ha diverso significato in base al valore della posizione corrente: se questa è interna allo stream, i dati non vengono "inseriti", ma sovrascritti; se invece la posizione corrente è alla fine dello stream (cioè una posizione oltre l'ultimo byte), i nuovi dati vengono effettivamente inseriti.

La gerarchia di classi stream è illustrata dalla seguente figura:

Page 275: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Tutte le classi della gerarchia, salvo ios_base, sono specializzazioni di template: il nome di ognuna è in realtà un sinonimo del nome (con prefisso basic_) di una classe template specializzata con argomento <char>. Per esempio: ifstream è un sinonimo di: basic_ifstream<char> ma, come già detto a proposito della classe string, noi siamo interessati solo al tipo char e quindi tratteremo direttamente delle classi specializzate e non dei template da cui provengono.

Le classi ios_base e ios

La classe base della gerarchia, ios_base, contiene proprietà e metodi che sono comuni sia alle operazioni di input che a quelle di output e non dipendono da parametri di template. Le stesse caratteristiche sono presenti nella sua classe derivata, ios, con la differenza che questa è una specializzazione con argomento char di template <class T> class basic_ios, le cui funzionalità dipendono dal parametro T. Dal nostro punto di vista, però, non ci sono parametri di template (assumendo sempre T=char), e quindi le due classi si possono considerare insieme come se fossero un'unica classe. Entrambe forniscono strumenti di uso generale per le operazioni di I/O, come ad esempio le funzioni di controllo degli errori, i flags per l'impostazione dei formati di lettura e/o scrittura, i modi di apertura dei files ecc... (molti di questi dati-membro sono enumeratori costituiti da un singolo bit in una posizione specifica, e si possono combinare insieme con operazioni logiche bit a bit). Entrambe le classi, inoltre, dichiarano i loro costruttori nella sezione protetta, e quindi non è possibile istanziarle direttamente; si devono invece utilizzare le classi derivate da ios, a partire da istream (per l'input) e ostream (per l'output), che contengono, per eredità, anche i membri definiti in ios e ios_base.

Le classi istream, ostream e iostream

La classe istream, derivata diretta di ios, contiene le funzionalità necessarie per le operazioni di input; in particolare la classe definisce un overload dell'operatore di flusso ">>" (estrazione), che determina il trasferimento di dati da un oggetto istream alla memoria. Sebbene non sia escluso che si

Page 276: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

possano costruire delle sue istanze nel programma, anche la classe istream, come già la sua genitrice ios, serve quasi esclusivamente per fornire proprietà e metodi alle classi derivate. Alla classe istream appartiene, come sappiamo, l'oggetto globale cin.

La classe ostream, derivata diretta di ios, contiene le funzionalità necessarie per le operazioni di output; in particolare la classe definisce un overload dell'operatore di flusso "<<" (inserimento), che determina il trasferimento di dati dalla memoria a un oggetto ostream. Come istream, ostream serve più che altro a fornire proprietà e metodi alle sue classi derivate. Alla classe ostream appartengono, come sappiamo, gli oggetti globali cout, cerr e clog.

La classe iostream, deriva, per eredità multipla, da istream e ostream, e ne riunisce le funzionalità, senza aggiungere nulla.

Le classi ifstream, ofstream e fstream

Le classi ifstream, ofstream e fstream servono per eseguire operazioni di I/O su file e derivano rispettivamente da istream, ostream e iostream, a cui aggiungono poche funzioni-membro (praticamente la open, la close e qualche altra di minore importanza). Per utilizzarle bisogna includere l'header-file <fstream>.

La classe ifstream serve per le operazioni di input. Normalmente i suoi oggetti sono associati a files di sola lettura, che possono essere sia in modo testo che in modo binario, ad accesso generalmente sequenziale.

La classe ofstream serve per le operazioni di output. Normalmente i suoi oggetti sono associati a files di sola scrittura, che possono essere sia in modo testo che in modo binario, ad accesso generalmente sequenziale.

Infine la classe fstream serve per le operazioni sia di input che di output. E' particolarmente indicata per operare su files binari ad accesso casuale.

Qualunque classe si usi, le operazioni di I/O si eseguono utilizzando gli operatori di flusso e ponendo l'oggetto associato al file come left-operand (al posto di cin o cout). In lettura, se il risultato dell'operazione è NULL (e quindi false, se convertito in tipo bool), vuol dire di solito che si è raggiunta la fine del file (eof); questo permette di inserire la lettura di un file in un ciclo while, in cui la stessa operazione di lettura funge da condizione per il proseguimento del ciclo. Sono anche disponibili funzioni-membro (definite nelle classi genitrici istream e ostream) per la lettura e/o scrittura dei dati, il posizionamento nel file, la gestione degli errori, la definizione dei formati ecc..., come vedremo in dettaglio prossimamente.

Le classi istringstream, ostringstream e stringstream

Le classi istringstream, ostringstream e stringstream servono per eseguire pseudo operazioni di I/O su stringa (come la funzione sprintf del C) e derivano rispettivamente da istream, ostream e iostream, a cui aggiungono poche funzioni-membro. Per utilizzarle bisogna includere l'header-file <sstream>.

Page 277: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

La classe istringstream serve per le operazioni di input. Un oggetto istringstream è sostanzialmente una stringa, dalla quale però si possono estrarre dati, come se fosse un dispositivo periferico o un file. Analogamente ai files, se il risultato di un'operazione di estrazione è NULL, significa che si è raggiunta la fine della stringa (eos).

La classe ostringstream serve per le operazioni di output. Un oggetto ostringstream è sostanzialmente una stringa, nella quale però si possono inserire dati, come se fosse un dispositivo periferico o un file. Le operazioni di inserimento possono anche modificare la dimensione della stringa, e quindi non è necessario effettuare controlli sul range. Questo fatto può essere di grande utilità perchè permette di espandere una stringa liberamente, in base alle necessità (per esempio, per preparare un output "formattato").

Infine la classe stringstream serve sia per le operazioni di input che di output.

Tipi definiti nella Libreria

La Libreria di I/O definisce alcuni tipi specifici (molti dei quali sono in realtà sinonimi, creati con typedef, di altri tipi, che a loro volta dipendono dall'implementazione). I principali sono (per ognuno di essi indichiamo, fra parentesi tonde, l'ambito o la classe in cui è definito, e, fra parentesi quadre, "normalmente implementato come ..."):

• streamsize (namespace std) [sinonimo di int] indica un numero di bytes consecutivi in un oggetto stream; questo tipo (come pure i successivi) è utilizzato come argomento in varie funzioni di I/O

• streamoff (namespace std) [sinonimo di long] indica lo spostamento in byte da una certa posizione in un oggetto stream a un'altra

• fmtflags (ios_base) [tipo enumerato] i suoi enumeratori controllano l'impostazione del formato di lettura o scrittura (vedere più avanti)

• iostate (ios_base) [tipo enumerato] i suoi enumeratori controllano lo stato dell'oggetto stream dopo un'operazione (vedere più avanti)

• openmode (ios_base) [tipo enumerato] i suoi enumeratori controllano il modo di apertura di un file (vedere prossimo paragrafo)

• seekdir (ios_base) [tipo enumerato] i suoi enumeratori si riferiscono a particolari posizioni nell'oggetto stream, e sono: ios_base::beg (posizione iniziale) ios_base::cur (posizione corrente) ios_base::end (posizione finale)

• pos_type (ios) [sinonimo di long] è il tipo della posizione corrente nell'oggetto stream

• off_type (ios) [sinonimo di long] è sostanzialmente un sinonimo di streamoff, con la sola differenza che è definito nella classe ios anzichè nel namespace std

Page 278: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Modi di apertura di un file

Relativamente alle operazioni di I/O su file, bisogna precisare anzitutto che la costruzione di un oggetto stream e l'apertura del file associato all'oggetto sono due operazioni logicamente e cronologicamente distinte (anche se esiste un costruttore che fa entrambe le cose, come vedremo). Di solito si usa prima il costruttore di default dell'oggetto (che non fa nulla) e poi un suo particolare metodo (la funzione open) che gli associa un file e lo apre. Questo permette di chiudere il file (tramite un altro metodo, la funzione close) prima che l'oggetto sia distrutto e quindi riutilizzare l'oggetto stesso associandogli un altro file. Non possono coesistere due files aperti sullo stesso oggetto. Un file ancora aperto al momento della distruzione dell'oggetto viene chiuso automaticamente.

Un file può essere aperto in diversi modi, a seconda di come si impostano i seguenti flags (che sono enumeratori del tipo enumerato openmode):

• ios_base::in il file deve essere aperto in lettura

• ios_base::out il file deve essere aperto in scrittura

• ios_base::ate il file deve essere aperto con posizione (inizialmente) sull'eof (significa "at the end"); di default un file è aperto "at the beginning"

• ios_base::app il file deve essere aperto con posizione (permanentemente) sull'eof (cioè i dati si potranno scrivere solo in fondo al file)

• ios_base::trunc il file deve essere aperto con cancellazione del suo contenuto preesistente; se il file non esiste, viene creato (in tutti gli altri casi deve già esistere)

• ios_base::binary il file deve essere aperto in modo "binario", cioè i dati devono essere scritti o letti esattamente come sono; di default il file é aperto in modo "testo", nel qual caso, in output, ogni carattere newline può (dipende dall'implementazione!) essere trasformato nella coppia di caratteri carriage-return/line-feed (e viceversa in input)

Ogni flag è rappresentato in una voce memoria da 16 o 32 bit, con un solo bit diverso da zero e in una posizione diversa da quella dei bit degli altri flags. Questo permette di combinare insieme due modi con un'operazione di OR bit a bit, oppure di verificare la presenza di un singolo modo in una combinazione esistente, estraendolo con un'operazione di AND bit a bit. Per esempio, la combinazione: ios_base::in | ios_base::out indica che il file può essere aperto sia in lettura che in scrittura. Va precisato, tuttavia, che il significato di alcune combinazioni dipende dall'implementazione e quindi va verificato "sperimentalmente", consultando il manuale del proprio sistema. Per esempio, nelle ultime versioni dello standard, il flag ios_base::out non può mai stare da solo, ma deve essere combinato con altri.

Per concludere, i flags ios_base::in e ios_base::out sono anche usati dai costruttori delle classi che gestiscono l'I/O su stringa.

Page 279: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Operazioni di output

Nella classe ostream sono definite varie funzioni-membro per l'esecuzione delle operazioni di output. Queste funzioni sono utilizzate direttamente per la scrittura sui dispositivi standard stdout e stderr e sono ereditate nelle classi ofstream, fstream, ostringstream e stringstream per la scrittura su file e su stringa.

Metodi operator<<

Alcuni metodi di ostream definiscono tutti i possibili overloads di operator<< con argomento di tipo nativo (compresi i tipi ottenuti mediante i prefissi short, long, signed, unsigned e const). I dati in memoria vengono convertiti in stringhe di caratteri (in free-format, o con un formato specifico, come vedremo) e poi inseriti in *this. Per quello che riguarda i puntatori (a qualunque tipo), è definito l'overload con argomento void*, che scrive il valore dell'indirizzo in formato esadecimale. Fa eccezione il caso di puntatore a carattere, per il quale è definito un overload specifico con argomento char*: in questo caso non viene scritto l'indirizzo, ma il carattere puntato, e tutti i caratteri successivi finchè non si incontra il valore '\0' (interpretato come terminatore di una stringa). Come ben sappiamo, è anche possibile definire ulteriori overloads di operator<<, con argomento di tipo definito dall'utente; questa volta, però, le funzioni non possono essere metodi di ostream, ma funzioni esterne: nella risoluzione di una chiamata, il compilatore si comporterà in ogni caso correttamente, in quanto cercherà, non prima fra i metodi e poi fra le funzioni esterne (che hanno lo stesso livello di preferenza), ma sempre prima fra le funzioni (metodi o no) in cui l'argomento corrisponde esattamente e poi fra quelle in cui la corrispondenza è ottenuta tramite conversione implicita di tipo (questo succede in particolare anche quando il nostro tipo è convertibile implicitamente in un tipo nativo e quindi selezionerebbe un metodo se questo avesse la precedenza). Questa regola offre un grande vantaggio, perchè permette di scrivere ulteriori overloads di operator<< senza bisogno di modificare la classe ostream.

Altre funzioni-membro di ostream

Oltre a operator<<, sono definiti in ostream i seguenti metodi (citiamo i più importanti):

• ostream& ostream::put(char c) inserisce il carattere c nella posizione corrente di *this; ritorna *this

• ostream& ostream::write(char* p, streamsize n) inserisce nella posizione corrente di *this una sequenza di n bytes, a partire dal byte puntato da p; ritorna *this. A differenza di operator<<, scrive i dati binari così come sono in memoria, senza prima convertirli in

Page 280: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

stringhe di caratteri. NOTA: questo metodo è particolarmente indicato per scrivere dati di qualsiasi tipo nativo (per esempio dati binari su file), operando una conversione di tipo puntatore nella chiamata. Per esempio, supponendo che out sia il nome dell'oggetto stream e val un valore intero o floating, si può scrivere val in out con la chiamata: out.write((char*)&val,sizeof(val)); notare il casting, che reintepreta l'indirizzo di val come indirizzo di una sequenza di sizeof(val) bytes. Nel caso invece che il tipo sia definito dall'utente, il discorso è un po' più complicato: la soluzione più "elegante" è quella della cosidetta "serializzazione", che consiste nel creare (nella classe dell'oggetto da scrivere) un metodo specifico, che scriva in successione i diversi membri dell'oggetto.

• pos_type ostream::tellp() ritorna la posizione corrente

• ostream& ostream::seekp(pos_type pos) sposta la posizione corrente in pos; ritorna *this; questo metodo (come il suo overload che segue) si usa principalmente quando l'output è su file ad accesso casuale

• ostream& ostream::seekp(off_type off, ios_base::seekdir seek) sposta la posizione corrente di off bytes a partire dal valore indicato dall'enumeratore seek; ritorna *this; off può anche essere negativo (deve esserlo quando seek coincide con ios_base::end e deve non esserlo quando seek coincide con ios_base::beg); in ogni caso se l'operazione tende a spostare la posizione corrente fuori dal range, la seekp non viene eseguita e la posizione corrente resta invariata; la posizione corrispondente alla fine dello stream (cioè eof o eos) è considerata ancora nel range.

Funzioni virtuali di output

Le funzioni-membro di ostream non sono virtuali, per motivi di efficienza, dato che in un programma le operazioni di I/O sono in genere molto frequenti. Tuttavia si può essere talvolta nella necessità di mandare in output un oggetto di tipo polimorfo, lasciando alla fase di esecuzione del programma la scelta del tipo "concreto" fra quelli derivati da un'unica classe base astratta. Per ottenere questo risultato, bisogna procedere nel seguente modo (supponiamo di chiamare My_base la classe base astratta):

1. dichiarare in My_base la funzione virtuale pura (che chiamiamo ins): virtual ostream& ins(ostream& out) const = 0; // scrive *this su out

2. ridefinire ins in tutte le classi derivate da My_base, in modo che ogni funzione svolga l'operazione di scrittura appropriata per la sua classe

3. definire il seguente overload di operator<< (ovviamente come funzione esterna): ostream& operator<<(ostream& out, const My_base& ogg) { return ogg.ins(out); }

Ciò assicura che operator<< utilizzi, tramite la funzione virtuale ins, la giusta operazione di output in istruzioni del tipo: cout << r; quando r è definito come riferimento a My_base. Questa tecnica è di utilità

Page 281: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

generale per fornire operazioni che si comportano come funzioni virtuali, ma con la selezione dinamica basata sul secondo argomento.

Metodi specifici per l'output su file

Nella classe ofstream, derivata di ostream (e anche nella classe fstream, derivata di iostream, per le operazioni comuni all'output e all'input), sono definiti alcuni metodi che, insieme a quelli ereditati dalla classe base, servono per la scrittura su file. Il più importante di questi è il metodo open:

void ofstream::open(const char* filename, ios_base::openmode mode = ....) void fstream::open(const char* filename, ios_base::openmode mode = ....)

che ha due argomenti: il primo, filename, è il nome del file da aprire (nota: è una stringa del C, non un oggetto string!), il secondo, mode, rappresenta il modo di apertura del file ed è di default, con valore che dipende dalla classe e precisamente:

• in ofstream (sola scrittura): mode = ios_base::out | ios_base::trunc (notare: se il file esiste, viene "troncato", se non esiste viene creato)

• in fstream (lettura e scrittura): mode = ios_base::out | ios_base::in (notare: il file deve esistere)

Se si verifica un errore, non appaiono messaggi, ma nessuna delle successive operazioni sul file viene eseguita. Ci si può accorgere dell'errore interrogando lo stato dell'oggetto (come vedremo).

Fra gli altri metodi definiti in ofstream citiamo:

• void ofstream::close() chiude il file senza distruggere l'oggetto *this, a cui si può così associare un altro file (oppure di nuovo lo stesso, per esempio con modi di apertura diversi)

• costruttore di default crea l'oggetto senza aprire nessun file; deve ovviamente essere seguito da una open

• costruttore con esattamente gli stessi argomenti della open (compresi i defaults) riunisce insieme le operazioni del costruttore di default e della open (a cui è ovviamente alternativo); anche se generalmente il file resta aperto fino alla distruzione dell'oggetto, la "prima" apertura tramite costruttore al posto della open non preclude la possibilità che il file venga chiuso "anticipatamente" (con la close) e che poi venga associato all'oggetto un altro file (con una successiva open)

• bool ofstream::isopen() ritorna true se esiste un file aperto associato all'oggetto

Page 282: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

La classe fstream definisce esattamente gli stessi metodi di ofstream (l'unica differenza è nel modo di apertura di default del file, dato dal secondo argomento del costruttore come nella open corrispondente).

Metodi specifici per l'output su stringa

Nella classe ostringstream, derivata di ostream (e anche nella classe stringstream, derivata di iostream, per le operazioni comuni all'output e all'input), sono definiti alcuni metodi che, insieme a quelli ereditati dalla classe base, servono per la scrittura su stringa. I più importanti sono:

• ostringstream::ostringstream(ios_base::openmode mode = ios_base::out) costruttore di default (con un argomento di default )

• ostringstream::ostringstream(const string& str, ios_base ..come sopra.. ) costruttore per copia da un oggetto string (con il secondo argomento di default )

• string ostringstream::str() crea una copia di *this e la ritorna convertita in un oggetto string. Questo metodo è molto utile, in quanto gli oggetti di ostringstream (e delle altre classi della gerarchia stream) non possiedono le funzionalità delle stringhe; per poterli utilizzare come stringhe è prima necessario convertirli in oggetti string.

• void ostringstream::str(const string& str) questo secondo overload di str esegue l'operazione inversa del precedente: sostituisce in *this una copia di un oggetto string

La classe stringstream definisce esattamente gli stessi metodi di ostringstream, con la differenza che l'argomento di default dei costruttori (mode) è: mode = ios_base::out | ios_base::in

Operazioni di input

Nella classe istream sono definite varie funzioni-membro per l'esecuzione delle operazioni di input. Queste funzioni sono utilizzate direttamente per la lettura dal dispositivo standard stdin e sono ereditate nelle classi ifstream, fstream, istringstream e stringstream per la lettura da file e da stringa.

Metodi operator>>

Alcuni metodi di istream definiscono tutti i possibili overloads di operator>> con argomento di tipo nativo (compresi i tipi ottenuti mediante i prefissi short, long, signed e unsigned). Da *this vengono estratte

Page 283: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

stringhe di caratteri, che sono interpretate secondo un certo formato e poi convertite nel tipo rappresentato dall'argomento, in cui vengono infine memorizzate. Ognuna di queste stringhe (che chiamiamo "stringhe di input") è delimitata da uno o più "spazi bianchi" (così sono definiti i caratteri: spazio, tabulazione, fine riga, fine pagina e ritorno carrello); tutti gli spazi bianchi che precedono e seguono una stringa di input vengono "scartati", cioè eliminati dallo stream e non trasferiti in memoria (anche quando l'argomento è di tipo char, nel qual caso non viene estratta una stringa, ma un singolo carattere, pur sempre tuttavia dopo avere "scartato" tutti gli eventuali spazi bianchi che lo precedono). Pertanto ogni singola esecuzione di operator>> converte e trasferisce in memoria una e una sola stringa di input alla volta, qualunque sia la dimensione dello stream. I caratteri della stringa di input, inoltre, devono essere tutti validi, in relazione al tipo dell' argomento. Per esempio, se il dato da leggere è di tipo int e la stringa di input contiene un "punto", questa viene troncata in modo da lasciare il "punto" come primo carattere della prossima stringa di input da estrarre (vedere la gestione degli errori nella prossima sezione).

Per quello che riguarda i puntatori (a qualunque tipo), è definito un overload di operator>> con argomento void*, che converte la stringa di input in un numero intero e lo memorizza nell'argomento (la cosa ha però scarso interesse, in quanto non si possono mai assegnare valori agli indirizzi). E' importante invece il caso di puntatore a carattere, per il quale è definito un overload specifico con argomento char*: in questo caso la stringa di input non viene convertita, ma trasferita così com'è nell'area di memoria puntata dall'argomento; alla fine viene aggiunto automaticamente il carattere '\0' come terminatore della stringa memorizzata.

Per ciò che concerne la definizione di ulteriori overloads di operator>> con argomento di tipo definito dall'utente, e la scelta fra i metodi e le funzioni esterne, vedere le considerazioni fatte a proposito di operator<<.

Altre funzioni-membro di istream

La principale differenza fra gli overloads di operator>> e gli altri metodi di istream che eseguono operazioni di lettura consiste nel fatto che i primi estraggono stringhe di input, senza spazi bianchi e interpretate secondo un certo formato (formatted input functions), mentre gli altri metodi estraggono singoli bytes (o sequenze di bytes) senza applicare nessun formato (unformatted input functions) e senza escludere gli spazi bianchi. Vediamone i principali:

• int istream::get() estrae un byte e lo ritorna al chiamante. Nota: il valore di ritorno è sempre positivo (in quanto è definito int e contiene un solo byte, cioè al massimo il numero 255; pertanto un valore di ritorno negativo indica convenzionalmente che si è verificato un errore, oppure che la posizione corrente era già sulla fine dello stream (cioè su eof o eos)

• istream& istream::get(char& c) estrae un byte e lo memorizza in c; ritorna *this

• istream& istream::get(char* p, streamsize n, char delim='\n') estrae n-1 bytes e li memorizza nell'area puntata da p (facendo seguire il carattere '\0' come terminatore della stringa memorizzata); ritorna

Page 284: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

*this; il processo di estrazione può essere interrotto in anticipo, per uno dei seguenti motivi:

1. è stata raggiunta la fine dello stream; 2. è stato incontrato il carattere delim; in questo caso delim non

viene estratto e la posizione corrente si attesta sullo stesso delim

• istream& istream::getline(char* p, streamsize n, char delim='\n') è identica alla get precedente, con due differenze:

1. se incontra il carattere delim non lo estrae (come nella get), ma la posizione corrente si attesta dopo delim (cioè delim viene "saltato")

2. se completa l'estrazione di n-1 bytes senza incontrare delim, viene impostata una condizione di errore; in pratica ciò vuol dire che l'argomento n serve per imporre la condizione: posizione di delim - posizione corrente < n

• istream& istream::read(char* p, streamsize n) differisce dalle funzioni precedenti per il fatto che non ha delimitatori (a parte la fine dello stream) e estrae n bytes (senza aggiungere il carattere '\0' in fondo); e quindi non legge stringhe di caratteri, ma dati binari di qualsiasi tipo (vedere la NOTA a proposito del metodo write di ostream)

• streamsize istream::readsome(char* p, streamsize n) come la read, salvo il fatto che ritorna il numero di bytes effettivamente letti

• istream& istream::ignore(streamsize n=1, int delim=EOF) "salta" i prossimi n bytes, oppure i prossimi bytes fino a delim (compreso); il default di delim (EOF) è una costante predefinita che indica la fine dello stream (normalmente implementata con il valore -1); ignore serve soprattutto per "saltare" caratteri invalidi nella lettura formattata da una stringa di input

I metodi di interrogazione e modifica diretta della posizione corrente sono: tellg e seekg (in 2 overloads): hanno gli stessi argomenti e svolgono le stesse operazioni dei corrispondenti tellp e seekp definiti in ostream.

Metodi specifici per input da file e da stringa

Nelle classi ifstream e istringstream, derivate di istream, sono definiti esattamente gli stessi metodi che si trovano rispettivamente in ofstream e ostringstream. L'unica differenza sta nel default dell'argomento mode della open e dei costruttori, che in questo caso è:

mode = ios_base::in

Stato dell'oggetto stream e gestione degli errori

Page 285: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

A ogni oggetto stream è associato uno "stato", impostando e controllando il quale è possibile gestire gli errori e le condizioni anomale nelle operazioni di input-output.

Lo stato dell'oggetto è rappresentato da un insieme di flags (che sono enumeratori del tipo enumerato iostate, definito nella classe ios_base), ciascuno dei quali (come gli enumeratori del tipo openmode) può essere combinato con gli altri con un'operazione di OR bit a bit e separato dagli altri con un'operazione di AND bit a bit. I flags sono i seguenti:

• ios_base::goodbit finora tutto bene e la posizione corrente non è sulla fine dello stream; nessun bit è "settato" (valore 0)

• ios_base::failbit si è verificato un errore di I/O, oppure si è tentato di eseguire un'operazione non consentita (per esempio la open di un file che non esiste)

• ios_base::badbit si è verificato un errore di I/O irrecuperabile

• ios_base::eofbit la posizione corrente è sulla fine dello stream; un successivo tentativo di lettura imposta anche failbit

La classe ios, derivata di ios_base, fornisce alcuni metodi per la gestione e il controllo dello stato:

• ios_base::iostate ios::rdstate() const ritorna lo stato che risulta dall'ultima operazione

• void ios::clear(iostate st=goodbit) imposta lo stato con st (cancellando il valore precedente); chiamando clear() senza argomenti si imposta goodbit, cioè si "resettano" i flags di errore

• void ios::setstate(iostate st) aggiunge il flag st allo stato corrente, eseguendo l'istruzione: clear(rdstate() | st );

• bool ios::good() const ritorna rdstate() == goodbit

• bool ios::fail() const ritorna bool(rdstate() & failbit)

• bool ios::bad() const ritorna bool(rdstate() & badbit)

• bool ios::eof() const ritorna bool(rdstate() & eofbit)

• ios::operator void*() const ritorna NULL se fail() | bad() è true; altrimenti ritorna this (che però, essendo convertito in un puntatore a void, non può essere dereferenziato) NOTA: questo (strano) metodo necessita di un chiarimento: é noto che il casting a puntatore a void non é mai necessario, in quanto un puntatore a void può puntare a qualsiasi tipo di oggetto; quindi anche il semplice nome dell'oggetto può essere reinterpretato come suo casting a puntatore a void (!!!). In pratica il compilatore, quando incontra l'oggetto come operando in una posizione che non gli compete, prima di

Page 286: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

segnalare l'errore cerca se nella classe a cui appartiene l'oggetto é definito un overload del casting a puntatore a void e, se lo trova, lo applica. Nel nostro caso il metodo ritorna normalmente this e quindi un espressione del tipo: cout << cout; scrive in cout il suo indirizzo! Se però si è verificata una condizione di errore, il metodo ritorna NULL e cioè false, se il nome dell'oggetto è inserito in un'espressione logica; questo spiega perchè un'operazione di lettura può funzionare anche come istruzione di controllo in un ciclo o in un costrutto if, come nell'esempio che segue: while ( cin >> .... ) infatti l'operazione >> ritorna cin, che viene convertito da operator void*: questo a sua volta ritorna l'indirizzo di cin finchè non ci sono errori (e quindi true, essendo un indirizzo sempre diverso da zero) e il ciclo prosegue; ma quando il programma tenta di leggere la fine dello stream, si imposta il flag failbit e quindi operator void* ritorna NULL interrompendo il ciclo.

• bool ios::operator !() const ritorna bool(fail() | bad()) NOTA: le espressioni: if(cin) e if(!!cin) sono equivalenti (!), mentre le espressioni: if(cin) e if(cin.good()) non sono equivalenti, in quanto la prima non controlla il flag eofbit

Quando è impostato un qualunque flag diverso da goodbit, nessuna funzione non const definita nell'oggetto stream può essere eseguita (senza messaggi di errore: semplicemente le successive istruzioni con operazioni di I/O non hanno alcun effetto); tuttavia lo stato può essere "resettato" chiamando la clear (successivamente, però, bisogna rimuovere la causa dell'errore se si vuole che le operazioni di I/O riprendano a essere regolarmente eseguite).

Se, durante un'operazione di lettura formattata da una stringa di input, si incontra un carattere non ammissibile in relazione al tipo di dato da leggere, abbiamo già detto che la stringa di input viene "spezzata" in due: la prima, su cui viene normalmente eseguita la lettura, termina lasciando fuori il carattere invalido; la seconda comincia con il carattere invalido (che, se è tale anche in relazione al tipo del successivo dato da leggere, deve essere "saltato" chiamando la ignore). Per quanto riguarda lo stato, il comportamento è diverso a seconda che il carattere invalido sia o meno il primo carattere della stringa di input:

• se non è il primo, lo stato resta definito dal flag goodbit (per la successiva operazione si può chiamare la ignore senza la clear);

• se è il primo, è impostato il flag failbit (bisogna chiamare la clear prima della ignore se si vuole che questa abbia effetto)

Errori gestiti dalle eccezioni

Per una gestione corretta degli errori, sarebbe opportuno controllare lo stato dopo ogni operazione di I/O. Se però le operazioni sono molte, la cosa non risulta molto comoda, anche in considerazione del fatto che gli errori sono in generale poco frequenti. In particolare le operazioni di output sono controllate assai raramente (benchè ogni tanto anche loro falliscano): di solito si verifica che, dopo una open, il file sia stato aperto correttamente, e niente di più.

Page 287: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Diverso è il discorso se si riferisce alle operazioni di input: qui i possibili errori sono vari e diversi: formati sbagliati, errori umani nella immissione dei dati ecc..., senza contare il fatto che bisogna sempre controllare il raggiungimento della fine dello stream. Pertanto l'esame dello stato dopo un'operazione di lettura è quasi sempre necessario.

Tuttavia, come alternativa alla disseminazione di istruzioni if e switch nel programma, è possibile gestire gli errori di input-output anche mediante le eccezioni. A questo scopo è definito nella classe ios_base un oggetto del tipo enumerato iostate (exception mask), che contiene un insieme di flags di stato: quando un'operazione di I/O imposta uno di questi flags, viene generata un'eccezione di tipo ios_base::failure (failure è una classe "annidata" in ios_base) che può essere catturata e gestita da un blocco catch: catch(ios_base::failure) { ..... }

Di default l'exception mask è vuoto (cioè di default gli errori di I/O non generano eccezioni), ma è possibile cambiarne il contenuto chiamando il metodo exceptions di ios: void ios::exceptions(iostate em) che imposta l'exception mask con em. Esiste anche un overload di exceptions senza argomenti che ritorna l'exception mask corrente: ios_base::iostate ios::exceptions() const

Con i due overloads di exceptions è possibile circoscrivere l'uso delle eccezioni in aree precise del programma; per esempio:

ios_base::iostate em = cin.exceptions(); salva l'exception mask corrente (no eccezioni) in em

cin.exceptions(ios_base::badbit|ios_base::failbit); imposta l'exception mask con badbit e failbit

try { ... cin >> ...} blocco delle istruzioni di I/O che possono generare eccezioni

catch(ios_base::failure) { ..... } blocco di gestione delle eccezioni

cin.exceptions(em); ripristina l'exception mask precedente (no eccezioni)

Formattazione e manipolatori di formato

Page 288: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Abbiamo detto che le funzioni operator>> (in istream) e operator<< (in ostream) si distinguono da tutti gli altri metodi delle loro classi per il fatto che eseguono operazioni di I/O formattate: in particolare operator>> converte una stringa di input (delimitata da spazi bianchi) nel dato da memorizzare, mentre operator<< converte il dato da scrivere in una stringa, secondo un certo formato.

Se non si modificano i defaults, i formati, sia di lettura che scrittura, sono predefiniti e obbediscono a determinate regole; per esempio: in output non sono introdotti spazi bianchi (free-format), i numeri sono in base decimale (salvo gli indirizzi, che sono in esadecimale), i dati floating sono scritti con al più sei cifre significative ecc... In tutti gli esempi e gli esercizi visti finora si sono sempre usati (salvo raro casi) i formati predefiniti.

A volte però il programma ha bisogno di utilizzare formati particolari, per esempio per incolonnare i dati, oppure per scrivere i numeri con una base diversa, o in notazione scientifica ecc... E quindi, come già in C con gli specificatori di formato (che abbiamo visto all'inizio di questo corso), così anche in C++ con altri strumenti, è possibile impostare, nelle operazioni di I/O, formati diversi da quello predefinito. A questo scopo è definito nella classe ios_base il tipo enumerato fmtflags, i cui enumeratori (detti format flags) controllano il formato, sia di lettura che di scrittura.

I format flags sono all'incirca una ventina. Ciascuno di loro imposta una particolare opzione, che può essere combinata con altre (con le solite operazioni bit a bit). Come già per la gestione dello stato, esistono anche vari metodi (definiti in ios_base e in ios) che permettono di impostare un insieme di format flags, di "resettarli", di combinarli con altri già impostati ecc...Non ci dilungheremo su questo argomento, perchè, "dal punto di vista dell'utente", è molto più comodo e rapido gestire il formato tramite i cosidetti "manipolatori", i quali possono utilizzare i format flags per modificare il formato nelle stesse istruzioni in cui i dati sono letti o scritti. In generale ogni manipolatore aggiunge (o rimuove) un'opzione. L'effetto di un manipolatore su un oggetto stream è permanente (salvo in un caso, che vedremo), fino a un eventuale manipolatore che lo contraddice o fino alla distruzione dell'oggetto.

Manipolatori senza argomenti

I manipolatori sono funzioni esterne alle classi, definite direttamente in std e raggruppate in alcuni header-files (tutti inclusi da <iostream>). Per capire come "lavorano", bisogna anzitutto sapere che le classi istream e ostream forniscono un ulteriore overload dell'operatore di flusso, con un puntatore a funzione come operando (prendiamo il caso di ostream, che è il più interessante, ma teniamo presente che quanto si dirà vale anche per istream):

ostream& ostream::operator<<(ostream& (*pf)(ostream&)) { return pf(*this); }

vediamo ora come il C++ risolve un'istruzione del tipo: cout << fun; (dove fun è una funzione che abbia (guarda caso) valore di ritorno di tipo ostream e un argomento di tipo ostream ):

Page 289: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

1. trova che l'overload di operator<< con operando puntatore a funzione pf è proprio quello "giusto" e sostituisce fun a pf;

2. in operator<< esegue fun(cout) e ritorna il valore di ritorno di fun

supponiamo ora che fun chiami un metodo della classe del suo argomento e ritorni by reference l'argomento stesso (cioè cout, nel nostro esempio); supponiamo inoltre che il metodo chiamato da fun imposti o rimuova un format flag. Ne consegue che l'istruzione di cui sopra ha l'effetto di modificare il formato (e quindi fun è un manipolatore); inoltre, per il fatto che fun ritorna *this, si può inserire il suo nome (senza argomenti e senza parentesi) all'interno di una sequenza di operazioni di flusso; per esempio (anticipiamo che hex è un manipolatore): cout << hex << 1234; fa sì che il numero 1234 (e tutti i successivi, fino a disposizione contraria) venga scritto in esadecimale.

Alcuni manipolatori sono in due versioni: quella con un certo nome imposta un format flag, quella con lo stesso nome e con prefisso no lo rimuove; di solito la versione con prefisso no è di default. I principali manipolatori sono i seguenti:

dec interi in base decimale (default)

hex interi in base esadecimale

oct interi in base ottale

fixed per i numeri floating corrisponde allo specificatore %f del C

scientific per i numeri floating corrisponde allo specificatore %e del C

left allinea a sinistra in un campo di larghezza prefissata (vedere più avanti)

right allinea a destra in un campo di larghezza prefissata (default)

[no]boolalpha rappresenta un valore booleano con true e false anzichè con 1 e 0

[no]showbase aggiunge il prefisso 0 per i numeri ottali e 0x per i numeri esadecimali

[no]showpoint mostra comunque il punto decimale nei numeri floating

[no]showpos scrive il segno + davanti ai numeri positivi

[no]uppercase scrive lettere maiuscole nelle notazioni esadecimale (X) e esponenziale (E)

[no]skipws ignora gli spazi bianchi (il default è skipws)

flush scarica il buffer di output

ends scrive '\0' e scarica il buffer di output

endl scrive '\n' e scarica il buffer di output

gli ultimi tre manipolatori non modificano il formato ma eseguono un'operazione (e quindi il loro effetto non è permanente, come negli altri casi)

Manipolatori con argomenti

Page 290: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Abbiamo visto che un manipolatore è una funzione che viene eseguita al posto di un puntatore a funzione e quindi il suo nome va specificato, come operando in un'operazione di flusso, senza parentesi e senza argomenti. Esistono tuttavia manipolatori che accettano un argomento, cioè che vanno specificati con un valore fra parentesi. In questi casi (consideriamo al solito solo l'output) l'overload di operator<< non deve avere come argomento un puntatore a funzione, ma un oggetto di un tipo specifico, restituito come valore di ritorno dalla funzione che appare come operando e inizializzato con il valore del suo argomento. Chiariamo quanto detto con un esempio; questa volta l'istruzione è: cout << fun(x) << .... ; dove supponiamo che l'argomento x sia di tipo int. La funzione fun (eseguita con precedenza) non deve fare altro che restituire un oggetto (chiamiamo _fun il suo tipo) inizializzato con x, cioè: _fun fun(int x) { return _fun(x); } a sua volta la classe (o meglio, la struttura) _fun deve essere costituita dai seguenti membri: int i; _fun(int x) : i(x) { } (il costruttore usa l'argomento x per inizializzare il membro i) L'informazione fornita dall'argomento x del manipolatore fun è perciò memorizzata nel membro i della struttura _fun. Ormai il problema è risolto, basta avere un overload di operator<< (che questa volta supponiamo sia una funzione esterna) con right-operand di tipo _fun: ostream& operator<<(ostream& os, _fun& f) che chiami, per l'impostazione del formato, un opportuno metodo di os, utilizzando l'informazione trasmessa nel membro i dell'oggetto f.

Nelle precedenti versioni dello standard esisteva una sola struttura, di nome smanip, e un solo overload di operator<< (con right-operand di tipo smanip) per tutti i manipolatori con argomenti; la struttura smanip conteneva, come ulteriore membro, un puntatore a funzione, da sostituire ogni volta con il manipolatore appropriato. A partire dal compilatore gcc 3.2 smanip è "deprecated" e al suo posto ci sono tante strutture (e tanti overloads di operator<<) quanti sono i manipolatori (in realtà questo non è un problema, perchè i manipolatori con argomenti sono pochi); in compenso ogni operazione è molto più veloce, in quanto chiama la sua funzione direttamente, senza passare attraverso i puntatore a funzione.

I manipolatori con argomenti, forniti dalla Libreria, sono definiti in <iomanip> (che deve essere incluso insieme a <iostream>) e sono 5: setw, setfill, setprecision, setiosflag e resetiosflag; tralasciamo gli ultimi due, i quali hanno come argomento direttamente un format flag (o una combinazione di format flags), coerentemente con il fatto che abbiamo deciso di non descrivere singolarmente i format flags e i metodi che li gesticono (le stesse operazioni si fanno più comodamente ed "elegantemente" usando gli altri manipolatori). Procediamo invece con la descrizione dei primi tre:

• setw(int w) specifica che nella prossima operazione di output il dato dovrà essere scritto in un campo con un numero minimo di caratteri w: se il numero effettivo è superiore, tutti i caratteri vengono scritti normalmente, se è inferiore, il dato è scritto all'interno del campo e allineato di default a

Page 291: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

destra (oppure a sinistra se è stato specificato il manipolatore left); nella posizione che compete ai caratteri rimanenti, viene scritto il cosidetto "carattere di riempimento", che di default è uno spazio (codice 32), ma che può anche essere modificato con setfill. Il manipolatore setw è l'unico che non ha effetto permanente, ma modifica il formato solo relativamente alla prossima operazione (dalla successiva il formato tornerà com'era prima di setw)

• setfill(char c) stabilisce che il "carattere di riempimento" d'ora in poi sarà c

• setprecision(int p) (p deve essere non negativo, altrimenti il manipolatore non ha effetto) influenza esclusivamente l'output di numeri floating e il suo effetto è diverso, a seconda di come è impostato il formato floating; questo può assumere tre diverse configurazioni:

1. fixed: è impostato dal manipolatore fixed; utilizza la rappresentazione: [parte intera].[parte decimale] (corrisponde allo specificatore di formato %f del C); p indica il numero esatto di cifre della parte decimale (compresi eventuali zeri a destra); l'ultima cifra decimale è arrotondata; se p è zero, è arrotondata la cifra delle unità e il punto decimale non è scritto (a meno che non sia stato specificato il manipolatore showpoint)

2. scientific: è impostato dal manipolatore scientific; utilizza la rappresentazione: [cifra intera].[parte decimale]e[esponente] (corrisponde allo specificatore di formato %e del C); come fixed, p indica il numero esatto di cifre della parte decimale e l'ultima cifra decimale è arrotondata; scrive E al posto di e se è stato specificato il manipolatore uppercase; l'esponente è costituito dal segno, seguito da 2 o 3 (dipende dall'implementazione) cifre intere

3. general: è impostato di default; sceglie, fra le rappresentazioni di fixed e di scientific, quella più conveniente (corrisponde allo specificatore di formato %g del C); p indica il numero massimo di cifre significative; l'ultima cifra significativa è arrotondata; gli zeri non significativi della parte decimale non sono scritti; se il numero è arrotondato a intero non è scritto neppure il punto decimale (a meno che non sia stato specificato il manipolatore showpoint). NOTA: questo è l'unico caso in cui non esiste un manipolatore per ripristinare il default. Per tornare al formato general dopo che è stato impostato fixed o scientific, bisogna usare il metodo setf (definito in ios_base), nel seguente modo (supponiamo per esempio che l'oggetto stream sia cout): cout.setf(ios_base::fmtflags(0),ios_base::floatfield);

Manipolatori definiti dall'utente

Applicando gli schemi riportati negli esempi di manipolatori con e senza argomenti, un programmatore può definire nuovi manipolatori, per il suo uso specifico.

Nell'esercizio che segue è definito un manipolatore, chiamato format (con 2 argomenti!), che permette la scrittura di un dato, di tipo double, specificando insieme, in un'unica stringa, il formato floating, il campo e la precisione. Il

Page 292: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

manipolatore deve essere usato nel modo seguente (supponiamo al solito che l'oggetto stream sia cout): cout << format(dato,xw.p); dove: dato è il nostro dato double da scrivere; xw.p è una stringa, e in particolare: x indica il formato floating, che può assumere i valori f, e o g (con il significato dei corrispondenti specificatori di formato del C); w è l'argomento di setw e può essere preceduto dal segno - per indicare l'allineamento a sinistra; p è l'argomento di setprecision

Le altre operazioni di scrittura, eseguite sullo stesso oggetto stream senza format, non vengono influenzate dalle modifiche al formato apportate da format. Per esempio: cout << format(dato1,f7.3) << dato2 ; scrive dato1 con il formato f7.3 e dato2 con il formato precedentemente impostato. L'indipendenza fra i due formati viene realizzata in realtà con un "trucco": i dati gestiti da format non sono scritti direttamente su cout, ma su un oggetto ostringstream, cioè su una stringa, trasferita successivamente su cout.

In considerazione del fatto che a volte si deve scrivere una serie di dati, tutti con lo stesso formato (per esempio per produrre una tabella allineata sulle colonne), si è pensato anche a due overloads di format con un solo argomento: cout << format(xw.p); e cout << format(dato); il primo imposta il formato senza scrivere nulla; il secondo scrive dato utilizzando il formato precedentemente impostato. Per rendere possibile questa opzione, le informazioni sul formato sono memorizzate in membri statici della struttura di appoggio.

Il manipolatore format può essere utile in alcune circostanze, in quanto la disomogeneità di comportamento fra setw (effetto "una tantum") e gli altri manipolatori (effetto permanente) potrebbe talvolta risultare "fastidiosa".

Cenni sulla bufferizzazione

Abbiamo, in varie circostanze, accennato alla presenza di un buffer nelle operazioni di I/O. In effetti il trasferimento dei dati fra l'oggetto stream e il dispositivo esterno non avviene quasi mai direttamente, ma attraverso un'area di memoria in cui i dati vengono accumulati prima di essere trasferiti. Caso tipico è la gestione dell'input da tastiera (vedere: Introduzione all'I/O sui dispositivi standard - Memorizzazione dei dati introdotti da tastiera). In generale la presenza del buffer serve per migliorare l'efficienza delle operazioni, riducendo i "tempi morti" fra I/O e calcolo effettivo. D'altra parte, questo fa sì che il programma non venga esattamente eseguito "in tempo reale", nel senso che l'esecuzione dell'operazione non è sincrona con il risultato. Abbiamo gà visto cosa succede con il buffer di input da tastiera; vediamo ora un esempio degli effetti introdotti dalla presenza del buffer di output: long tm = time(NULL)+5 ;

Page 293: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

cout << "Aspetta 5 secondi ...." ; while ( time(NULL) < tm ) ; cout << " .... ecco fatto!" << endl ; in realtà, a causa del buffer, prima passano 5 secondi e poi le due scritte appaiono contemporaneamente. Per ottenere il risultato voluto, bisogna modificare la seconda istruzione in: cout << "Aspetta 5 secondi ...." << flush ; in quanto, come sappiamo, il manipolatore flush scarica immediatamente il buffer di output. Lo stesso è scaricato automaticamente quando si passa da un'operazione di output a un'operazione di input (in altri termini, si dice che gli oggetti stream cout e cin sono "collegati").

Cenni sulla gerarchia stream buffer

La Libreria Standard mette a disposizione un'altra gerarchia di classi, detta "stream buffer", costituita da una classe base, che si chiama streambuf, e dalle sue derivate, filebuf (per le operazioni di I/O su file) e stringbuf (per le operazioni di I/O su stringa). A ogni oggetto di una classe della gerarchia stream é attached (associato) un oggetto di una classe della gerarchia stream buffer (o sua derivata fornita dall'utente): le due classi lavorano insieme, la prima per le operazioni di I/O ad "alto livello" (per esempio la formattazione), la seconda per l'accesso al buffer di I/O e in generale per l'I/O di "basso livello". In entrambe le classi esistono membri che gestiscono il collegamento fra i due oggetti (per esempio il metodo rdbuf() di ios restituisce l'indirizzo dell'oggetto di streambuf associato e il metodo in_avail() di streambuf restituisce il numero di caratteri ancora presenti nel buffer).

Di solito il programmatore non ha bisogno di lavorare direttamente con gli oggetti di streambuf e può quasi sempre ignorarne l'esistenza. Tuttavia qualche volta può essere necessario accedere al buffer di I/O, per esempio se si deve operare con particolari dispositivi e interfacce che richiedono software di I/O a basso livello: spesso in questi casi è necessario "progettare" una bufferizzazione specifica, e conviene farlo derivando una nuova classe dalla gerarchia stream buffer, piuttosto che dalla gerarchia stream, e associando gli oggetti della nuova classe a quelli delle classi stream già presenti nella Libreria.

Non ci dilungheremo oltre su questo argomento. Torniamo invece alle classi della gerarchia stream e completiamo il discorso fornendo ulteriori ragguagli sulle funzioni che gestitscono il buffer di I/O. Precisiamo anzitutto che la stessa posizione corrente "si muove" in realtà sul buffer e non direttamente sull'oggetto (anche se il valore assoluto della posizione è riferito all'inizio dell'oggetto), e quindi alcuni metodi di gestione della posizione corrente, che abbiamo già visto (tellp, seekp, tellg e seekg), operano effettivamente sul buffer. Inoltre, tutte le volte che si è parlato di caratteri "rimossi" da un oggetto stream (per esempio con il metodo ignore), in realtà si intendeva dire che erano "rimossi" dal buffer, non fisicamente dall'oggetto.

Gestione del buffer di output

Abbiamo già visto visto praticamente "tutto" e cioè i metodi (di ostream) tellp (ricava la posizione corrente) e seekp (imposta la posizione corrente), e i manipolatori flush, ends e endl.

Page 294: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Gestione del buffer di input

Oltre ai metodi di istream, tellg e seekg, già visti, consideriamo i seguenti:

• istream& istream::putback(char c) inserisce c nel buffer prima della posizione corrente e arretra la posizione corrente di 1; l'operazione è valida solo se è preceduta da almeno una normale lettura (cioè non si può inserire un carattere prima dell'inizio dell'oggetto); ritorna *this

• istream& istream::unget() come putback, con la differenza che rimette nel buffer l'ultimo carattere letto

• int istream::peek() ritorna il prossimo carattere da leggere (senza toglierlo dal buffer e senza spostare la posizione corrente); questo metodo (come anche i precedenti) può essere usato per riconoscere il tipo del prossimo dato prima di leggerlo effettivamente (vedere esercizio).

Page 295: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

Conclusioni

La programmazione modulare, la programmazione a oggetti e la programmazione generica forniscono strumenti formidabili per scrivere codice ad alto livello. La possibilità di suddivere un programma in porzioni (quasi) indipendenti rende l'attività dei programmatori più facile, piacevole ed efficace e rende il programma stesso più flessibile, riutilizzabile, estendibile e di più facile manutenzione.

Fra tutti i linguaggi, il C++ è quello che maggiormente permette di realizzare questi obiettivi, grazie ai suoi potenti strumenti concettuali: data hiding, namespace, classe, overload di funzioni e di operatori, eredità, polimorfismo e template. Tuttavia, a differenza da altri linguaggi "puri" di programmazione orientata a oggetti, il C++ non "rinnega" la "cultura" del C, da cui eredita intatta la potenza e verso cui mantiene la compatibilità, preservando un "patrimonio" di conoscenze e realizzazioni che non sarebbe stato conveniente disperdere.

Pertanto il C++ è un linguaggio insieme completo e in continua evoluzione: sul solido impianto del C ha costruito una nuova "filosofia" che gli permette di espandersi nel tempo. A tutt'oggi il C++ si utilizza praticamente in qualsiasi dominio applicativo, inclusi quelli (a noi vicini) dell'insegnamento e della ricerca.

Terminiamo questo corso con una serie di consigli utili per un programmatore C++ non ancora "esperto":

• usa "poco" la direttiva #define; al suo posto usa: o const e enum, per definire valori costanti; o inline, per evitare la perdita di efficienza dovuta alle chiamate di

funzioni; o template, per specificare famiglie di funzioni o di tipi; o namespace, per evitare conflitti nei nomi.

• non dichiarare una variabile locale molto prima di usarla; una dichiarazione può apparire ovunque possa apparire un'istruzione

• non definire mai variabili globali; le variabili non locali siano sempre definite in un namespace

• evita le copie inutili: passa il più possibile gli argomenti per riferimento; se non vuoi che gli argomenti vengano modificati, dichiarali const

• dimentica le funzioni del C di gestione della memoria dinamica (malloc, free e compagnia) e al loro posto usa gli operatori new e delete; per riallocare memoria, non usare la realloc del C, ma i metodi resize o reserve di vector

• suddividi il tuo programma in moduli indipendenti, usando i namespace; se sei coinvolto in un grosso progetto, potrai sviluppare il software in modo più efficiente

• ragguppa il più possibile variabili e funzioni in classi, e usa gli oggetti, istanze delle classi

Page 296: CORSO C++ STANDARD - Antonio Pierrodidatticait.altervista.org/programmazione/CPP/CPP-dispense/CORSO_C.pdf · C++, usando la tecnica della "Programmazione orientata a Oggetti" o OOP

• gli oggetti sono componenti attive, con proprietà e metodi; realizza il data hiding, rendendo in generale private tutte le proprietà e pubblici solo i metodi che vengono chiamati dall'esterno

• se una funzione agisce su un oggetto di una classe, rendila metodo di quella classe; se non è possibile, dichiarala friend (solo però se accede a membri privati)

• sfrutta l'overload degli operatori per definire operazioni fra gli oggetti • associa costruttori e distruttori alle classi che definisci • non ricominciare sempre "da zero": usa l'eredità quando vuoi espandere

un concetto, e la composizione quando vuoi riunire concetti esistenti • struttura la tua gerarchia di classi applicando il polimorfismo: potrai

aggiungere nuove classi senza modificare il codice esistente; non dimenticare di dichiarare virtual il distruttore della classe base

• usa i template quando devi progettare una funzione o una classe da applicare a tipi diversi di oggetti

• minimizza l'uso degli array e delle stringhe del C; la Libreria Standard del C++ mette a disposizione le classi vector e string, che sono più versatili e più efficienti; in generale, non tentare di costruire da solo quello che è già fornito dalla Libreria (difficilmente potresti raggiungere il suo livello di ottimizzazione)

• la Standard Template Library fornisce un insieme di classi (contenitori) e funzioni (algoritmi) che, in quanto template, si possono applicare a una gamma molto vasta di problemi applicativi: non farti mai sfuggire l'occasione di utilizzarla!

• evita le funzioni di I/O del C; usa le classi di flusso e i relativi operatori: sono più facili, più eleganti e possono avere overload