Professional Documents
Culture Documents
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
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
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
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.
Livello di partenza
Concetti fondamentali di programmazione in C e C++, tenendo presente gli
obiettivi, e quindi:
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.
Moduli 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
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
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
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>
Sequenze di escape
\a
\b
carattere backspace
\f
\n
va a capo (newline)
\t
tabulazione orizzontale
\"
carattere virgolette
\r
\\
return)
carattere backslash
da solo alla fine della riga = continua la control string nella riga successiva
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
d, i
f, e
E, G
carattere
stringa di caratteri
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
default
d,i,u,o,x,X
(valori interi)
f,e,E
(valori
floating)
6 cifre decimali
g,G
(valori
floating)
6 cifre significative
c
(carattere)
s
(stringa)
significato
default
Allineamento a destra
spazio
Il campo specificato da
width riempito da
spazi bianchi
# (usato
con
o,x,X)
# (usato
con
e,E,f,g,G)
Il punto decimale
scritto solo se seguito
da altre cifre
# (usato
con g,G)
char
float
double
bool
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")
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
un'amica
(contiene un apostrofo)
7bello
for
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
typeid
typename
union
unsigned
using
virtual
void
volatile
wmain
while
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.
L'operatore sizeof
PC (32 bit)
con Unix
1
short
int
long
float
double
long double
12
16
bool
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
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;
int i
= true;
( i inizializzata con 1 )
int i
= 7 < 2;
Le Costanti in C++
Costanti intere
Esempi:
15.75
-1.5e2
25E-4
10.
'A'
carattere A
'\n'
carattere newline
'\003'
carattere cuoricino
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'
"A"
Specificatore const
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).
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
Tabella riassuntiva
Visibilit globale
specificatore extern specificatore extern
nel file di definizione
negli altri files
Variabile globale senza
inizializzazione
Variabile globale con
inizializzazione
Costante globale
vietato
opzionale
obbligatorio
obbligatorio
File scope
specificatore static
obbligatorio
specificatore static
senza inizializzazione
obbligatorio
senza inizializzazione
default
Operatori e operandi
Definizione di operatore e regole generali
Operatore di assegnazione
Operatori matematici
Il C++ pu, a differenza da altri linguaggi, operare sulle variabili intere a livello
del bit.
Gli operatori binari &, |, e ^ eseguono operazioni logiche bit a bit fra i due
operandi, e precisamente:
&
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)
0 0 1 1 1 0 1 0
(58)
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)
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
MiaVariabile *= 4
equivale a
MiaVariabile =
Operatori relazionali
>=
<
<=
==
!=
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
a >= b
a<b
a <= b
a == b
a != b
Esempi:
Operatori logici
||
5 && 2
5&2
equivale a
MiaVariabile =
Es:
int a, b, c=5 ;
a = c++;
b = ++c;
Operatore condizionale
minimo = a < b ? a : b ;
Conversioni di tipo
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++ ;
SIMBOLO E
OPERANDI
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
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
------da destra
sinistra
DESCRIZIONE OPERATORE
ASSOCIATIVITA'
a
a
a
a
a
a
a
a
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
da sinistra a
destra
scorrimento a destra
scorrimento a sinistra
binario
binario
da sinistra a
destra
da sinistra a
destra
minore
minore o uguale
maggiore
maggiore o uguale
binario
binario
binario
binario
da sinistra
destra
da sinistra
destra
da sinistra
destra
da sinistra
destra
a
a
a
a
uguale
diverso
binario
binario
expr == expr
expr != expr
da sinistra a
destra
da sinistra a
destra
binario
da sinistra a
destra
binario
expr ^ expr
da sinistra a
destra
OR bit a bit
binario
expr | expr
da sinistra a
destra
AND logico
binario
da sinistra a
destra
OR logico
binario
expr || expr
da sinistra a
destra
espressione condizionale
ternario
expr ? expr :
expr
da destra a
sinistra
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
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
sinistra
da destra
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
a
a
a
a
a
a
a
a
a
da destra a
sinistra
da destra a
sinistra
serializzazione delle espressioni
binario
expr , expr
da sinistra a
destra
Ordine di valutazione
Esempi:
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 << 'A' << " ha codice ascii: " << (int)'A' <<
"\n";
visualizza la frase:
A ha codice ascii 65
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:
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.
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.
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."
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
/* il codice C pippo.c */
#include <stdio.h>
int main() {
puts("ciao pippo!");
return 0;
}
Per effettuare la compilazione
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 :
./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:
./prova
ciao pippo!
echo $?
0
g++ -c pippo.cpp
In questo caso viene creato il file oggetto pippo.o .
Per effettuare il link usiamo
// 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:
// 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:
#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;
}
./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
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
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;
}
Livello
O0
32.2
O1
5.4
O2
5.2
O3
5.2
// 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;
};
// 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,
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
# 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.
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
one
@echo DUE!
three: one two
@echo E TRE!
all:
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:
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
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:
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
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
Es.
Invece:
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; }
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.
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.
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.
int conta;
int conta=0;
while (conta<10)
{
cout << conta <<
'\n' ;
conta+=2;
}
(loop infinito)
Array
Cos' 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 [ ]
Array multidimensionali
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).
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".
Sequenza valida
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";
elementi).
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.
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.
Funzioni
Definizione di una funzione
Gli argomenti vanno specificati insieme al loro tipo (come nelle dichiarazioni
delle variabili) e, se pi d'uno, separati con delle virgole.
Es.
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
funzione:
prog. chiamante:
nella funzione:
Argomenti di default
chiamata:
scrive();
definizione:
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
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.
Commenti
Indirizzo di rientro in B
Argomento 1 passato a
La funzione B chiama la funzione C con due
C
argomenti
Argomento 2 passato a
C
Indirizzo di rientro in A
Argomento 1 passato a
B
La funzione A chiama la funzione B con tre
Argomento 2 passato a
argomenti
B
Argomento 3 passato a
B
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.
if ( n <= 1 ) return 1;
return n
fact(n-1);
Fattoriale in pps
}
[p22]
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:
Header-files
<io.h> , <stdio.h>
<math.h> , <stdlib.h>
<ctype.h>
Conversioni numeri-stringhe
<stdlib.h>
<string.h>
Gestione dell'ambiente
<direct.h> , <stdlib.h>
<stdio.h> , <stdlib.h>
<search.h> , <stdlib.h>
<time.h>
<stdarg.h>
Riferimenti
Costruzione di una variabile mediante copia
funzione:
prog. chiamante:
nella funzione:
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
oppure
#include "filename"
La direttiva #include viene usata quasi esclusivamente per inserire gli headerfiles (.h) ed particolarmente utile quando in uno stesso programma ci sono pi
implementation-files che includono lo stesso header-file.
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.
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:
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.
Direttive condizionali
oppure
#if defined(identificatore1)
oppure ...
#if !defined(identificatore1)
...... blocco di direttive e/o istruzioni ........
#elif espressione2
oppure
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";
Editor di testo
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).
Indirizzi e Puntatori
Operatore di indirizzo &
&(a+1)
&(a>b?a:b)
&a = b
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!).
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
double** pointer_to_pointer
dichiara (e definisce) la variabile pointer_to_pointer,
puntatore a puntatore a variabile di tipo double
Operatore di dereferenziazione *
Puntatori a void
definiti:
int* iptr;
void* vptr;
ammessa l'assegnazione:
vptr = iptr;
ma non:
iptr = vptr;
bens:
iptr = (int*)vptr;
oppure
Es.:
programma chiamante:
funzione:
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.
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 :
int lista[5];
1.
non ammesso
*(A+i)
int A[ ] = {0,0,0};
nella funzione:
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
funz(.... A,....) ;
dichiarazione di funz:
copy il programma
file1 e file2 sono i parametri
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
char** argv
Puntatori e Funzioni
Funzioni che restituiscono puntatori
int* funz();
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:
chiamate:
fsel(funz1);
fsel(funz2);
definizione fsel:
Puntatori e Costanti
Puntatori a costante
int* ptv;
ptc = &datov;
ptv = &datoc;
*ptc
= 10;
Puntatori costanti
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 lvalue, invece, la deref. di un puntatore costante che punta a una variabile.
Es.:
int dato1,dato2;
*ptr
= 10;
ptr = &dato2;
void funz(int*);
( ok! )
Typedef
pul parray[100];
ecc...
double a2[100];
double a3[100];
ecc...
a a1;
a a2;
a a3;
ecc...
Strutture
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
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.:
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
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:
costruzione oggetti:
accesso:
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
struct
data ;
data ;
costruzione oggetto:
accesso:
tizio.pnascita->anno = 1957;
la presenza dei due punti, seguita dal numero di bit riservati, identifica la
definizione di una struttura di tipo bit field.
Tipi enumerati
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.:
dominio 0:1
dominio 0:15
dominio -1024:1023
en2 oggetto3 = 3 ;
Gli enumeratori sono ammessi nelle operazioni fra numeri interi e, in questi
casi, sono converititi implicitamente in int.
Operatore new
Operatore delete
Es.:
allocazione:
deallocazione:
delete [ ] punt ;
int a ;
punt = &a ;
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).
Definizione di namespace
Namespace annidati
void f( );
namespace A
void g( );
namespace B
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.:
Namespace sinonimi
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;
Namespace anonimi
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).
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
{
OK: A::x e B::x sono definiti in due ambiti
diversi
int x ;
}
void f( ) {... A::y= ...}
namespace A
int x ;
int y ;
}
void f( ) {... A::y= ...}
adesso OK
Parola-chiave using
namespace A
}
namespace B
using namespace A;
using namespace B;
using A::x;
Eccezioni
Segnalazione e gestione degli errori
Il costrutto try
try
{
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
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 }
catch (tipo1) {
blocco1}
catch (tipo2) {
blocco2}
catch (...)
istruzione .........
Blocchi innestati
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:
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 ;
Funzioni membro
class point {
class point {
double x;
double x;
double y;
double y;
public:
public:
{ 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->set(1.5, 0.9) ;
}
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 headerfiles, 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.
Classi membro
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.
e quindi .....
l'istruzione di chiamata della
funzione:
ogg.init( ) ;
init(&ogg) ;
Dati-membro statici
In C++ la parola-chiave static ha un ulteriore significato: se un datomembro 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 ;
..................
};
int A::sm = 10 ;
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
Funzioni-membro statiche
class A { .....
static int conta( ) ;
(prototipo)
..... };
Funzioni friend
DEFINIZIONE CLASSE
class A {
int mp ; ..........
DEFINIZIONE FUNZIONE
void amica(A ogg, .....)
{
Classi friend
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(......) {......}
point::point( ) {x=3.5;
y=2.1;}
point p ;
point::point(double d)
{x=d; y=d;}
point p = point(3.0);
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 (~)
2.
3.
4.
5.
prototipo:
~point( );
definizione esterna:
point::~point( ) {......}
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
COSTRUTTORE
Persona::Persona (int n)
Persona::~Persona ( )
char* cognome;
public:
Persona (int);
~Persona ( );
.... altri metodi };
DISTRUTTORE
delete [ ] nome;
delete [ ] cognome;
}
DEFINIZIONE DELL'OGGETTO NEL PROGRAMMA
Persona Tizio(25);
Costruttori di copia
COSTRUTTORE DI COPIA
class A {
A::A(const A& a)
int* pa;
public:
pa = new int ;
A(const A&);
........ };
*pa
= *a.pa ;
Liste di inizializzazione
CLASSE
class A {
int m1, m2;
COSTRUTTORE
A::A(int p, double q) : m1(p), m2(0),
r(q)
{
double r;
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
class C {
A ma;
B mb;
int mc; ........ };
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 :
costruttore di B :
costruttore di C :
Array di 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.
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
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
somma(a,moltiplica(b,c))
operazione :
p = p1+p2 ;
funzione somma
:
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.
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:
operazione :
p += p1 ;
definizione metodo :
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 :
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 ( , )
- - );
- - );
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
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 Cstyle o in function-style. Se eseguita implicitamente, pu verificarsi
un'ambiguit nel caso sia definita anche la conversione in senso inverso.
Esempio:
A a;
a+d;
double d ;
deve convertire un tipo A in double o un double in A ?
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
Oggetti-funzione
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
p->ma = 7 ;
Operatore di assegnazione
OPERATORE DI
ASSEGNAZIONE
operazioni :
CLASSE
A a1 ; ........ A a2 = a1 ; A a1 , a2 ; ........ a2 = a1 ;
A::A(const A& a)
dim = a.dim ;
int* pa;
if (dim != a.dim)
int dim;
class A {
public:
A( );
delete [] pa;
A(const A&);
dim = a.dim ;
A& operator=
(const A&);
........ };
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.
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
Espressioni-operazione
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.
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.
Nella definizione di una classe derivata per eredit multipla, le due classi
genitrici vanno indicate entrambe, separate da una virgola:
class AB : A3, B4 { ........ } ;
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;
private
protected
public
private:
inaccessibili
inaccessibili
inaccessibili
protected:
privati
protetti
protetti
public:
privati
protetti
pubblici
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 = .......
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
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 {
int n;
public: B(int,int,float);
.... altri membri .... };
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)
La dichiarazione using
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:
ogg.B2::ma
Polimorfismo
Late binding e polimorfismo
seleziona la funzione-membro di A
b.display()
seleziona la funzione-membro di B
Funzioni virtuali
class B : public A {
class C : public A {
..............
};
};
};
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:
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
Il seguente diagramma chiarisce quanto detto, nel caso del nostro esempio:
Classi astratte
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 datimembro 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:
Particolare cura stata dedicata alla gestione delle eccezioni. Sono stati
individuati quattro tipi di errori possibili:
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
Template
Programmazione generica
dato-membro di tipo
parametrizzato
public:
A(const T& m) : mem(m) { }
T get( );
dichiarazione di funzione-membro
con
parametrizzato
........ };
pu
nella
return mem ;
}
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:
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.
Istanza di un template
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 { ........ };
Funzioni template
FUNZIONE
CHIAMATA
NOTE
Il secondo argomento
dedotto di tipo double
template<class
T,class U>
T fun1(U);
int m =
fun1<int>(d);
template<class
T,class U>
U fun2(T);
int m =
Il primo argomento non si
fun2<double,int>(d); pu omettere,
anche se deducibile
CHIAMATA
fun(1,2);
RISOLUZIONE
NOTE
argomento
fun<int>(1,2);
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<int>(a,c);
int* p = ...;
argomento
dedotto,
conversione
"banale"
fun(a,p);
ERRORE
conversione non
ammessa da int*
a double
Template e modularit
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:
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.
Input-Output;
Header files
Il namespace std
invece di :
algoritmi
<deque>
<functional>
oggetti-funzione
<iterator>
iteratori
<list>
<map>
<memory>
<numeric>
operazioni numeriche
<queue>
<set>
contenitore: insieme
<stack>
<utility>
<vector>
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.
Iteratori
{
*to
= *from;
++from;
++to;
input
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.
reverse_iterator
const_reverse_iterator
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();
iterator end();
reverse_iterator rbegin();
const_reverse_iterator rbegin()
const;
reverse_iterator rend();
begin()
end()
rbegin()
n-1
rend()
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:
Contenitori Standard
i contenitori associativi
size_type
reference
equivale a value_type&
mapped_type
list
deque
stack
queue
mappato
multiset
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
size_type Cont::max_size()
const
Cont::Cont(const Cont& c)
Cont& Cont::operator=(const
Cont& c)
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:
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)
vector
deque
list
priority_queue
efficiente
efficiente (solo
vedere nota
canc.)
stack
contenit
associat
non
non defin
definita
non
non definita
definita
non
vedere not
definita
efficiente
efficiente (solo
non definita
ins.)
inefficiente efficiente
efficiente
queue
cancellazione in testa
void Cont::pop_front()
(in queue e in priority_queue cambia nome in pop)
inserimento in "mezzo"
(vedere nota)
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 funzionimembro 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
C C C C C N N N
end
rbegin
rend
resize
size
C C C N N N N N
empty
C C C C C C C C
max_size
reserve
C C C C C N N N
capacity
C N N N N N N N
costruttore di default
costruttore di copia
C C C C C C C C
operator=
assign
I I I I I I I I
I I I N N N N N
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
clear
erase
I I C L L N N N
C C C C C N N N
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
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:
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))
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.
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
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:
nel primo caso e ...
*it == val
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.
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.
end
rbegin
rend
resize
size
empty
max_size
reserve
capacity
costruttore di default
costruttore di copia
operator= assign
erase
Note:
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);
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:
Concatenazioni e inserimenti
string
string
string
string
string
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:
Ricerca di sotto-stringhe
Nella classe string sono definiti molti metodi che ricercano la stringaargomento 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 stringaargomento da utilizzare per la ricerca.
Cerca una sotto-stringa:
Operazioni di input-output
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.
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
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.
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.
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.
(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.
Ogni processo ha la sua copia della libreria statica che sta usando, caricata
in memoria.
Gli eseguibili collegati con librerie statiche sono pi grandi.
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.
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>.
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.
Operazioni di output
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
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.
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
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
Operazioni di input
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
*this;
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
cin.exceptions(ios_base::badbit|ios_base::failbit);
imposta l'exception
mask con badbit e
failbit
catch(ios_base::failure) { ..... }
cin.exceptions(em);
ripristina l'exception
mask precedente (no
eccezioni)
hex
oct
fixed
scientific
left
right
[no]boolalpha
[no]showbase
esadecimali
[no]showpoint
[no]showpos
[no]uppercase
[no]skipws
flush
ends
endl
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
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".
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).
Conclusioni