Linguaggio C ++

 

 

 

Linguaggio C ++

 

I riassunti, le citazioni e i testi contenuti in questa pagina sono utilizzati per sole finalità illustrative didattiche e scientifiche e vengono forniti gratuitamente agli utenti.

 

“Linguaggio C++ con introduzione al C”

Autore: David Bandinelli

Revisione: 1.0 (28/09/2002)

Bibliografia:
“Linguaggio C seconda edizione” di B. W. Kernighan e D. M. Ritchie
“Il linguaggio C++ seconda edizione” di B. Stroustrup
“Thinking in C++ second edition” di B. Eckel

 

Parte 1 : Linguaggio C

 

Introduzione al linguaggio C

Il C e’ un linguaggio di programmazione di uso generale caratterizzato dalla sinteticita’, seppure dotato di un  vasto insieme di istruzioni per il controllo del flusso, da strutture dati avanzate e di un vasto insieme di operatori.
Non e’ un vero e proprio “linguaggio di alto livello” e non e’ specializzato in alcuna area applicativa ma la mancanza di restrizioni, la sua generalita’ e la buona portabilita’ del codice, lo rendono conveniente ed efficiente per una vasta serie di applicazioni.

Il C fu sviluppato da Dennis Ritchie presso i laboratori Bell su di un sistema DEC PDP-11 basato sul sistema operativo UNIX, il quale venne quasi interamente scritto utilizzando il linguaggio C.
Nonostante la stretta relazione tra il linguaggio C e UNIX questo viene utilizzato su molte altre piattaforme data l’ottima flessibilita’ del linguaggio.

Per molti anni la definizione del linguaggio e’ stata quella presentata nel manuale di riferimento contenuto nel testo “Linguaggio C” di Kernighan e Ritchie, definito ancora oggi la “bibbia del C”.
Nel 1983, l’istituto Nazionale Americano per gli Standard (ANSI) ha costituito un comitato per la definizione aggiornata e completa del C.
Il risultato del lavoro, lo standard C ANSI, e’ stato approvato nel 1989 ed e’ quindi questa la versione del linguaggio che viene supportata da tutti i compilatori su tutti i sistemi operativi.


Introduzione all’ambiente di sviluppo

Durante il corso utilizzeremo il compilatore C/C++ Microsoft contenuto all’interno dell’ambiente di sviluppo denominato Microsoft Visual Studio (di fatto lo standard per lo sviluppo di applicazioni in ambiente Windows).
Questa la sequenza dei passi per editare/compilare un programma C in tale ambiente:

  1. Avviare Il Microsoft Visual C++ 6.0 all’interno del menu di avvio del Microsoft Visual Studio 6.0
  2. Selezionare File -> New e scegliere Win32 Consolle Application, dando prima un nome al progetto
  3. Lo wizard del Visual Studio chiedera’ che tipo di applicazione vogliamo creare e a questo punto scegliere “Empty Project”
  4. Per aggiungere un sorgente al progetto selezionare File -> New e scegliere la categoria C/C++ source file specificando il nome del sorgente (es. Test.c)

 

A questo punto e’ possibile scrivere il programma C che verra’ poi eseguito in finestra DOS dato che abbiamo scelto di creare un’applicazione di tipo Win32 Consolle.
Una volta scritto il sorgente e’ possibile compilare ed eseguire il programma selezionando Build -> Compile e successivamente (se non ci sono errori), Build -> Execute.
Se in fase di compilazione sono riportati degli errori e’ sufficiente fare doppio click sull’errore per evidenziare la riga di codice dove si e’ verificato l’errore.
E’ da ricordare che il C a differenza del Visual Basic e’ un linguaggio interamente compilato e quindi prima di poter eseguire anche una sola riga di codice e’ necessario compilare il sorgente e produrre un eseguibile (.exe) privo di errori.


 

Le caratteristiche fondamentali del linguaggio C

Iniziamo ad analizzare le caratteristiche principali del linguaggio tramite l’esame di un semplice programma che stampa il messaggio “Hello World” :

(listato 1.1)
#include <stdio.h>

 

/* Questo e' un commento */

 

int main() /* funzione main restituisce un intero e non riceve nessun parametro in input */
{
printf ("Hello World \n");
return 0;
}

Un programma C deve sempre contenere la funzione main, difatti l’esecuzione del programma comincia sempre a partire dalla funzione main.
In genere la funzione main richiama altre funzioni che possono essere scritte dall’autore del programma (e quindi presenti nel listato) oppure possono far parte delle funzioni di libreria predefinite.
I commenti in C iniziano con /* e terminano con */, le istruzioni C possono continuare liberamente su piu’ righe ed inoltre il C e’ un linguaggio case sensitive, quindi attenzione ai nomi delle variabili.
L’istruzione include specifica al compilatore di includere le informazioni relative alla libreria standard di input/output (da cui infatti viene richiamata la funzione printf che serve per mandare l’output a video).
Le istruzioni che iniziano con il simbolo # non sono vere e proprie parti del listato C ma vengono interpretate dal preprocessore ed in genere servono per indicare quali librerie di funzioni verranno utilizzate, definire macro, compilare condizionalmente parti di programma ecc.

Le principali istruzioni del preprocessore sono:
# include        <nome file>
Prima della compilazione il file specificato viene incluso all’interno del listato e poi il sorgente viene compilato (si utilizza in genere per includere gli header file ovvero file con estensione .h che contengono solitamente prototipi e definizioni di tipi e funzioni).

#define           nome   testo da sostituire
(es. #define TRUE 1)
Prima della compilazione il compilatore sostituisce nel sorgente ogni occorrenza di ‘nome’ con il testo da sosituire.
Questa sostituzione puo’ essere come quella dell’esempio, utilizzata per rendere piu’ chiaro e leggibile un sorgente, oppure puo’ essere utilizzata per la sostituzione di vere e proprie macroistruzioni come in questo esempio:

# define forever          for(;;)

in questo caso la parola ‘forever’ viene utilizzata al posto del ciclo infinito.

#if, elif, else, endif (compilazione condizionale)
Queste istruzioni servono per dire al compilatore di compilare o meno alcune parti del sorgente a seconda di certe condizioni:
Es.

#if SYSTEM == DOS
#define HDR <dos.h>
#elif SYSTEM == UNIX
#define HDR <unix.h>
#else
#define HDR <default.h>
#endif
#include HDR

In questo caso si seleziona quale header file includere a seconda del valore del parametro SYSTEM.

 

Tipi di dati e costanti

In C esiste un ristretto numero di tipi di dati fondamentali:

Tipi di dichiarazione

Rappresentazione

Char

Carattere (es. ‘a’)

Int

Numero intero (es. 3)

Short

Numero intero corto (dipende dalla macchina)

Long

Numero intero lungo (dipende dalla macchina)

Float

Floating point singola precisione

Double

Floating point doppia precisione

La dimensioni in byte ed il range di questi tipi dipendono dalla macchina e dal compilatore e non devono essere mai utilizzati in maniera assoluta (es. Scrivere programmi che assumono interi di 4 byte,che se portati e ricompilati su di un’altra macchina in cui gli interi occupano due byte possono non funzionare o dare errori inaspettati).
Per evitare questo tipo di errori esiste l’istruzione sizeof che restituisce il numero di byte occupati da un certo tipo, variabile ecc.
Oltre a questi tipi esistono due qualificatori, signed e unsigned che possono essere associati ai char e agli int per specificare la presenza o meno di valori negativi (il default e’ signed).

Le costanti vengono specificate in questo modo (oltre a poterle definire con la direttiva #define):

Const Int a = 3; /* costante intera */
Const long a = 3L; /* costante intera lunga */
Const Unsigned long a = 3UL; /* costante intera lunga positiva */
Const float a = 3.14; /* costante float */
Const Int a = 0x1f; /* costante intera espressa in esadecimale */
Const char line[MAX+1] = "pippo"; /* costante stringa */

I valori delle costanti non possono essere modificati durante l’esecuzione di un programma.

I tipi enumerati sono una forma particolare di costante e sono utilizzati in genere per assegnare valori costanti a dei nomi (come alternativa alla direttiva #define).

enum mesi { GEN = 1, FEB, MAR, APR, MAG, GIU, LUG, AGO, SETT, OTT, NOV, DIC};

(manca typedef)

 

Conversioni di tipo

Quando un operatore ha operandi di tipo diverso il compilatore effettua delle conversioni automatiche di tipo seguendo un certo insieme di regole; esiste un operatore che permette la conversione esplicita dei tipi detto “operatore di cast”.

Alcuni esempi di possibili conversioni fra tipi:
Int i;
Char c; /* viene rappresentato come short int */
i = c; /* nella variabile intera finisce il valore ASCII del carattere */
c = i; /* il valore  viene convertito in short int ed eventualmente troncato */

float x;
double d;

i = x; /* il valore del float diventa intero e viene troncato */
x = d; /* il valore del double viene troncato e trasformato in float */

Da questi esempi si nota come in C sia perfettamente lecito effettuare anche le conversioni di tipo che comportano una perdita di informazione (es. Assegnare un float ad un intero); al limite il compilatore puo’ dare un messaggio di warning ma la conversione viene eseguita.
Si consiglia di prestare attenzione a questo tipo di conversioni implicite perche’ possono essere fonte di errori molto difficili da “scovare” (es. Assegnando un int ad uno short int il programma effettua la conversione a short e potrebbe non dare nessun errore per piccoli valori per poi dare risultati imprevedibili una volta oltrepassato il range dello short).
La sintassi dell’operatore di cast e’ la seguente:

X = (float) d; /* in questo caso il valore d viene convertito in un float esplicitamente prima di essere assegnato */

 

Array; dichiarazione inizializzazione ed uso

Questa prima parte della trattazione degli array sara’  incompleta a causa dello strettissimo legame che esiste in C tra array e puntatori,  quindi dovremo per forza di cose ritornare successivamente sull’argomento nei prossimi capitoli una volta affrontati i puntatori.

Dichiarazione di un array:

Int a[10];
Definisce un vettore monodimensionale di interi a di ampiezza 10; l’indice del primo elemento parte da a[0] e quindi l’indice dell’ultimo elemento e’ a[9].

Int a[10][5];
Definisce un vettore bidimensionale (o matrice) di interi formato da 10 righe e 5 colonne.
In C un vettore bidimensionale e’ in realta’ un vettore monodimensionale in cui ogni elemento e’ a sua volta un vettore e quindi ci si riferisce ad un elemento con a[j][k] e non a(j,k) come negli altri linguaggi.

 

Stringhe come array di caratteri

In C il tipo stringa non esiste come tipo predefinito, ma viene rappresentato tramite un array di caratteri che deve essere sempre terminato da un carattere speciale denominato appunto “terminatore di fine di stringa”; questo carattere e’ il ‘\0’.

Es.
Char str[6] = “pippo”; /* si dichiara ed inizializza un vettore di caratteri che ospita la stringa ‘pippo’, notare che la dimensione del vettore deve essere di 6 caratteri per ospitare il terminatore di fine stringa */

NOTA BENE: Gran parte degli errori piu’ subdoli che si verificano in un programma C derivano dalla dimenticanza di allocare spazio per il terminatore di fine stringa; dato che il compilatore non controlla questo tipo di errori e’ facile incorrere in comportamenti anomali del programma a runtime causati da “sforamenti” di memoria.

 

Operatori

Il C e’ un linguaggio molto ricco di operatori, questi si possono dividere in varie categorie di cui elenchero’ solo gli operatori principali o che hanno qualcosa di particolare rispetto agli altri linguaggi:

Operatori aritmetici

L’operatore % (modulo) restituisce il resto della divisione intera.

Operatori logici
>          maggiore
<          minore
>=       maggiore o uguale
<=       minore o uguale
==       uguaglianza     (if a == b)
!=        diversita’         (if a != b)
!           not logico        (if !a)
&&      and logico       (if a == 5 && b == 3)
||          or logico          (if a == 5 || b == 3)

(A cond B ? TRUE : FALSE)           operatore ternario
L’operatore ternario condensa in un unica riga la seguente struttura di controllo:
IF A > B THEN
<condizione vera>
ELSE
<condizione falsa>

NOTA BENE: Attenzione a non confondere l’operatore di assegnazione ‘=’ con l’operatore di confronto per uguaglianza ‘==’, infatti un tipico errore che si fa in C e’ questo: (if a = 5) invece di (if a == 5); il compilatore non da errore ma la prima condizione e’ sempre vera, dato che non e’ un confronto ma un assegnazione.

Operatori di incremento e decremento

++       Incrementa di 1          (b++ equivale a b = b + 1)
--         Decrementa di 1         (b— equivale a b = b – 1)

(forma prefissa, forma postfissa)

Attenzione:
Se n vale 5, x = n++; assegna a x il valore 5 e poi n viene incrementato mentre x = ++n; assegna a x il valore 6 perche’ n viene incrementato prima. 

Operatori di shift e di manipolazione bit a bit

A << B           Shift a sinistra            Il valore A (come sequenza di bit) e’ shiftato a sinistra di B posizioni; in assenza di overflow questo equivale ad una moltiplicazione per 2^B
           
A << B           Shift a destra              Il valore A (come sequenza di bit) e’ shiftato a destra di B posizioni; in assenza di overflow questo equivale ad una divisione per 2^B

A & B             AND bit a bit             Viene fatto l’AND tra gli operandi bit a bit

A | B               OR bit a bit                Viene fatto l’OR tra gli operandi bit a bit

 

Il seguente listato esemplifica alcuni elementi del linguaggio trattati nei paragrafi 1.4, 1.5, 1.6 e 1.7

(listato 1.2)
# include <stdio.h>
# define MAX 1000

/* indentazione, commenti  */

int main()
{
char c = 'd';
int i = 3,k;
double eps = 1.0e-5;
float f = 0.25;
unsigned int year = 1500;
double d;
int v[10] = {0,1,2,3,4,5,6,7,9};

const double pigreco = 3.14;

/* ERRORE pigreco = 3.15; */
/* non si puo’ modificare una costante */

     /* '\0' e' il terminatore di fine stringa */
char line[MAX+1] = "pippo";

enum mesi { GEN = 1, FEB, MAR, APR, MAG, GIU, LUG, AGO, SETT, OTT, NOV, DIC};

     /* operatori */
if ((year%4 == 0 && year%100 != 0) || year %400 == 0)
printf ("%d e' un anno bisestile \n", year);
else
printf ("%d non e' un anno bisestile \n", year);

     printf("pre incremento %d \n",++year);
printf("pre decremento %d \n",--year);
printf("post incremento %d \n",year++);
printf("post decremento %d \n",year--);

     /* cambia a seconda del tipo di CPU */
printf ("int occupa %d bytes \n",sizeof(int));
printf("unsigned int occupa %d bytes \n",sizeof(unsigned int));
printf ("long int occupa %d bytes \n",sizeof(long int));
printf ("char occupa %d bytes \n",sizeof(char));
printf ("float occupa %d bytes \n",sizeof(float));
printf ("double occupa %d bytes \n",sizeof(double));
printf ("line occupa %d bytes \n",sizeof(line));

     /* conversioni di tipo */
k = 5;
/* d = (double)i + (double)k; */
d = i + k;
printf ("somma %f \n",d);
k = c; /* assegno ad un intero un char */
printf ("carattere %c \n",k);
printf ("ascii %d \n",k);

     /* assegnazione ed incremento */
k = 1;
k += 2; /* equivale a k = k + 2; */
printf ("k diventa %d \n",k);

     return 0;
}


 

Istruzioni condizionali e di gestione del flusso

Istruzione If

Questi sono i vari tipi di sintassi che possono essere utilizzati per costruire blocchi if-then-else; e’ da notare che se si vuole eseguire piu’ di una istruzione in seguito ad una condizione e’ necessario racchiudere il blocco tra le parentesi graffe e che l’espressione da valutare deve essere sempre racchiusa tra parentesi tonde.

If (espressione) istruzione;

If (espressione)
{
<blocco di istruzioni>
}

if (espressione)
istruzione;
else
istruzione;

if (espressione)
istruzione;
else
if (espressione)
istruzione;
else
istruzione;

 

Istruzione Switch

Switch (var)
{
case 1:
istruzione;
break;
case 2:
istruzione;
break;
case n:
istruzione;
break;
default:
istruzione;
break;
}

E’ da notare che l’istruzione break provoca l’uscita immediata dal ramo dello switch dove e’ stata eseguita l’istruzione, altrimenti l’esecuzione continuerebbe dal caso immediatamente successivo.
Il caso default viene eseguito se nessuna delle altre condizioni e’ soddisfatta.

Istruzioni For e While

for (cond iniz.;cond. Uscita;incremento)
{
<blocco di istruzioni>
}

es. For (k = 0; k < 5; k ++)

In genere il ciclo for si compone di tre espressioni,di cui quella centrale e’ una espressione condizionale, ed e’ equivalente ad un ciclo while cosi’ costruito:

Cond iniz.;
While (cond. Uscita)
{
<blocco di istruzioni>
incremento;
}

Istruzioni break e continue

for (cond iniz.;cond. Uscita;incremento)
{
<blocco di istruzioni>
if (condizione)
break;
else
continue;
<altre istruzioni>
}

Se la condizione e’ vera e quindi viene eseguito il break il programma esce completamente dal ciclo for, se invece la condizione e’ falsa e viene eseguito quindi il continue, il programma rimarra’ nel ciclo for ma sara’ saltato il blocco di istruzioni successivo al continue.

Istruzioni goto e label

Seppure l’utilizzo sia sconsigliato il C fornisce i costrutti goto e label per effettuare salti incondizionati.


If (condizione)
Goto <label>


label:
<blocco di istruzioni>

 

Visibilita’ (Scope) e classi di memorizzazione

Lo scope (visibilita’) di una variabile dichiarata all’inizio di una funzione si estende per tutta la funzione (ovvero in tutta la funzione posso far riferimento alla variabile e utilizzarla), mentre la visibilita’ di una variabile dichiarata all’interno di un blocco di parentesi graffe si estende solo all’interno del blocco (ovvero ricevero’ un errore se tentero’ di utilizzare la variabile al di fuori del blocco).
Esistono due classi di memorizzazione per le variabili, automatica e statica.
Le variabili appartenenti alla classe automatica perdono il loro contenuto una volta usciti dal blocco (funzione o parte di programma racchiusa da parentesi graffe) all’interno del quale sono state dichiarate.
Le variabili appartenenti alla classe statica mantengono il loro valore fra l’uscita ed il successivo rientro in altri funzioni o blocchi ed addirittura possono mantenere anche il valore globalmente a tutto il programma se dichiarati extern.

Es.
void f()
{
auto int a;
a = 3;
}

/* una volta che il programma esce dalla funzione f il valore di a e’ perso (auto e’ la classe di memorizzazione di default) */
void f()
{
register int a;
a = 3;
}

/* una volta che il programma esce dalla funzione f il valore di a e’ perso (register suggerisce al compilatore di provare a memorizzare la variabile in un registro interno della CPU per esigenze di velocita’; oggi questa funzionalita’ e’ obsoleta) */


void f()
{
static int a;
a = 3;
}

/* una volta che il programma esce dalla funzione f il valore di a e’ mantenuto all’interno di tutte le funzioni del modulo sorgente corrente */

void f()
{
extern int a;
a = 3;
}

/* extern indica al compilatore che a e’ dichiarata come variabile globale per tutti i moduli dell’applicazione, solitamente in un file di header comune a tutti i moduli; il valore di una variabile extern viene mantenuto per tutta la durata dell’esecuzione del programma */

 

Funzioni

L’utilizzo delle funzioni permette di scomporre problemi complessi in moduli piu’ semplici, riutilizzabili poi in seguito per la risoluzione di problemi di diversa natura.
Il linguaggio C e’ stato ideato con l’intenzione di rendere le funzioni altamente efficienti ed utilizzabili; difatti i programmi C sono spesso formati da un vasto insieme di semplici funzioni richiamate una dopo l’altra nella funzione principale main.

La dichiarazione standard di ogni funzione ha questa forma:

Tipo-ritornato nome-funzione(parametri in input)
{
<istruzioni>
<ritorno valore di uscita>
}

Alcune di queste parti possono essere omesse, difatti le funzioni possono non avere nessun parametro di input e non resitituire nessun valore di output.
Int somma(int a, int b)
{
return a + b;
}

In questo esempio la funzione riceve in input due parametri di tipo intero e restituisce un valore di tipo intero.

Void f()
{
}
Questa funzione non restituisce niente e non riceve nessun parametro di input.

Per poter essere richiamata una funzione deve essere nota al compilatore, ovvero se la chiamata di una funzione si trova nel sorgente prima della sue definizione il compilatore generera’ un errore.
Per evitare questo esiste un meccanismo chiamato prototyping che permette di specificare nome e tipo delle funzioni utilizzate in testa ad un sorgente o in un file di header separato (.h) che puo’ essere incluso con una direttiva #include.

Es.
/* prototype della funzione somma */
Int somma(int, int);

Main()
{
int c;
c = somma(2,3);
}

int somma(int a, int b)
{
return a + b;
}

Se non avessimo specificato il prototipo avremmo ricevuto un errore dal compilatore dato che la funzione somma era stata richiamata prima della sua definizione.

 

Funzioni ricorsive e con numero variabile di argomenti

In C le funzioni possono essere usate in modo ricorsivo, ovvero una funzione puo’ richiamare se stessa direttamente o indirettamente.
La ricorsione richiede l’impiego di notevoli quantita’ di memoria perche’ ogni chiamata nidificata occupa un notevole spazio nello stack per salvare lo stato ed inoltre non e’ certo una tecnica particolarmente veloce.
Tuttavia per la risoluzione di alcuni tipi di problemi, come la gestione di strutture dati definite ricorsivamente come gli alberi, l’utilizzo di funzioni ricorsive si rivela particolarmente conveniente ed elegante.


/* Calcola il Fattoriale di un numero  utilizzando la Ricorsione*/
int calcFatt(int numero)
{
int f;
if (!numero)
f=1;
else
f=numero*calcFatt(numero-1); /
return f;
}

In C e’ possibile definire funzioni che hanno un numero di parametri variabile; la gestione degli argomenti variabili e’ effettuata tramite l’utilizzo di tre macro (va_start, va_arg e va_end) definite all’interno della libreria standard stdarg.h.
Esempio:

(listato 1.3)
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>

/* prototipo di funzione ad argomenti variabili */
void test_fn( const char *types, ... );

int main( void )
{
/* si testa la funzione passando un intero, una stringa ed un altro intero e poi successivamente una stringa ed un intero */
test_fn( "isi", 1, "abc", 546 );
test_fn( "si", "def", 789 );
return 0;
}

static void test_fn(
const char *types,     /* tipo parametri (i,s)     */
... )              /* argomenti variabili      */
{
va_list argument;
int   arg_int;
char *arg_string;
const char *types_ptr;

    types_ptr = types;
va_start( argument, types );
/* gli argomenti variabili partono dopo il parametro types */

    while( *types_ptr != '\0' )
{
if (*types_ptr == 'i')
{
/* se l’argomento variabile e’ di tipo int lo devo specificare alla macro va_arg */
arg_int = va_arg( argument, int );
printf( "integer: %d\n", arg_int );
}
else if (*types_ptr == 's')
{
/* in questo caso l’argomento e’ di tipo stringa */
arg_string = va_arg( argument, char * );
printf( "string:  %s\n", arg_string );
}
/* si passa all’argomento successivo */
++types_ptr;
}
va_end( argument );
/* fine degli argomenti variabili */
}

Il programma di prova produce il seguente output:
integer: 1
string:  abc
integer: 546
string:  def
integer: 789


Puntatori; puntatori ed array; array di puntatori; puntatori a funzione

 

Una delle funzionalita’ piu’ interessanti e piu’ utilizzate del linguaggio C sono i puntatori, ovvero variabili che contengono l’indirizzo di memoria di altre variabili.
L’utilizzo di puntatori permette di scrivere codice particolarmente efficiente e compatto anche se non immediatamente comprensibile al programmatore C principiante; inoltre l’utilizzo errato dei puntatori puo’ generare errori a runtime di difficile individuazione causati da “sforamenti di memoria” e da puntatori che puntano a qualcosa di inatteso.

Partiamo direttamente con alcuni esempi:

Int *ip;
/* dichiarazione di una variabile ip di tipo puntatore ad intero, ovvero la variabile ip non conterra’ un valore intero ma solo l’indirizzo di memoria in cui verra’ memorizzata una variabile intera */

int x = 5;
/* dichiariamo ed inizializziamo una normale variabile intera */

ip = &x;
/* in questo modo assegniamo al puntatore ip l’indirizzo della variabile x (e non il contenuto) */

*ip = 9;
/* *ip = 9 e’ equivalente a dire x = 9, in quanto l’operatore * chiamato operatore di dereferimento serve ad indicare la variabile puntata da ip */

ip = 9;
/* ERRORE in questo modo assegniamo ad un puntatore un indirizzo (9) che fara’ puntare ad un area imprecisata della memoria dato che non abbbiamo modo di sapere che cosa sia memorizzato in tale area; pensate un attimo a cosa puo’ succedere se dopo questa istruzione proviamo ad eseguire *ip = 0; abbiamo appena scritto zero in un’area imprecisata della memoria !!! */

int z;
int *ip2;
/* dichiariamo un altro intero ed un altro puntatore ad intero */ 

z = *ip;
/* z assume il valore puntato da ip cioe’ quello di x; equivalente a scrivere z = x */

ip2 = ip;
/* ip2 adesso punta alla stessa area di memoria di ip, ovvero punta ad x e’ quindi possibile scrivere indifferentemente *ip = 4 oppure *ip2 = 4 ed entrambe le istruzioni cambiano il contenuto di x */

(puntatore void *)

double *funz( int *p)
{
return (double *)p;
/* cast a puntatore a double da puntatore a int */
}
/* questa e’ la dichiarazione di una funzione che ritorna un puntatore ad un double ed accetta in input un puntatore ad intero */

main()
{
int a = 5;
double *d;
int *b = &a;

     d = funz(&a);
/* dato che funz si aspetta in input un puntatore ad intero gli passo l’indirizzo di una normale variabile intera */

d = funz(b);
/* altrimenti gli passo direttamente b a cui ho gia’ assegnato l’indirizzo di a precedentemente */

}

Passaggio per valore e per indirizzo

Poiche’ il C passa alle funzioni gli argomenti per valore, la funzione chiamata non ha un modo diretto per alterare il contenuto di una variabile nella funzione chiamante; per permettere questo e’ necessario che la funzione chiamante passi gli argomenti per indirizzo e non per valore.


Es.
(listato 1.4)
Main()
{
int a = 3, b = 5;

swap (a,b) /* ERRORE: passaggio per valore */
swap (&a, &b) /* CORRETTO: passaggio per indirizzo */

}

/* la funzione di swap e’ cosi’ realizzata, notare che la funzione si aspetta come argomento due puntatori ad intero e non due interi */

void swap (int *x, int *y)
{
int temp;
temp = *x;
*x = *y;
*y = temp;
}

 

Puntatori e vettori

In C la relazione esistente tra puntatori e vettori e’ cosi’ stretta da permettere che qualsiasi operazione effettuabile indicizzando un vettore possa essere eseguita anche tramite i puntatori.
In generale, la versione che utilizza i puntatori e’ piu’ veloce ma puo’ risultare di piu’ difficile comprensione per i programmatori C principianti.

Dato un vettore dichiarato come
Int a[10];

Possiamo dichiarare un puntatore ad intero
Int *pa;

Pa = &a[0];
/* il puntatore pa punta al primo elemento del vettore a, ovvero all’elemento con indice 0 */

int x;
x = *pa;
/* copia nella variabile x il contenuto di a[0] */

Se pa punta ad un elemento del vettore a, possiamo affermare che pa+1 punta all’elemento successivo e pa-1 punta all’elemento precedente del vettore.

Se pa punta ad a[0],
X = *(pa+1)
/* assegna ad x il contenuto di a[1] */

Queste operazioni aritmetiche su puntatori sono indipendenti dal tipo di dato puntato, ovvero aggiungere uno ad un puntatore ad intero non significa spostarsi in memoria di un byte, ma significa shiftare di tanti byte quanti servono a contenere la variabile puntata (in questo caso 4 per un intero).

Pa = &a[0];
/* questa assegnazione puo’ anche essere scritta come pa = a; dato che in C il nome di un vettore e’ sinonimo della posizione del suo primo elemento */

x = a[I];
/* questa assegnazione puo’ anche essere scritta come x = *(a + I); dato che un espressione sotto forma di vettori e indici e’ perfettamente equivalente ad una che utilizzi puntatori e offset */

 

Passaggio di vettori a funzioni

Un vettore puo’ essere passato ad una funzione indifferentemente come vettore o come puntatore; e’ anche possibile passare ad una funzione solo una parte di un array, passandole un puntatore all’inizio del sottovettore.

Es.
(listato 1.5)
Main()
{
char msg[10] = “ciao”;

     stampa(msg);
/* passa alla funzione il vettore di caratteri */
/* da notare che il nome del vettore e’ equivalente all’indirizzo del primo elemento */

     stampa(msg + 2);
/* passa alla funzione il sottovettore “ao”, ovvero a partire dall’indice a[2], equivalente a richiamare stampa(&msg[2]); */
}


void stampa( char s[] )
/* oppure */
void stampa( char *s )
{
printf(“%s”, s);
}

 

Vettori di puntatori

Dato che i puntatori sono a loro volta delle variabili, essi possono essere memorizzati in array di puntatori.

Es.
(listato 1.6)
Char *multiMsg[10];
/* multiMsg e’ un vettore di 10 elementi ognuno dei quali e’ un puntatore a carattere */

main()
{
char *multiMsg[3];

     char p1[] = "msg1";
char p2[] = "msg2";
char p3[] = "msg3";

     multiMsg[0] = p1;
/* oppure */
*multiMsg = p1;
multiMsg[1] = p2;
multiMsg[2] = p3;

/* ogni elemento del vettore di puntatori multiMsg punta ad un diverso array di caratteri, ovvero ad una stringa */
printf("%s", multiMsg[0]); /* stampa msg1 */
}

 

Puntatori a funzione
Sebbene una funzione C non sia una variabile, e’ possibile comunque dichiarare dei puntatori alle funzioni ed utilizzarli in assegnazioni, inserirli in vettori, passarli ad altre funzioni, restituirli ecc.


Es.
(listato 1.7)
int Funzione(char carattere)
{
return((int)carattere - 32);
}

/* definizione del nuovo tipo di dati PUNTATOREAFUNZIONE, che rappresenta un tipo generico di puntatore a tutte le funzioni che restituiscono un int e prendono in input un char */
typedef int (* PUNTATOREAFUNZIONE)(char);

/* Questa funzione riceve come argomento un puntatore a funzione */
int funzione2(PUNTATOREAFUNZIONE p)
{
int valore;
/* chiamata di funzione utilizzando il puntatore a funzione ricevuto come argomento */
valore = (p)('A');
return 0;
}

void main(void)
{
/* Dichiarazione del vero e proprio puntatore a funzione */
PUNTATOREAFUNZIONE PuntatoreAFunzione;
int valore;

/* assegnazione dell'indirizzo della funzione al puntatore; come per gli array il nome della funzione restituisce l’indirizzo */
PuntatoreAFunzione = Funzione;

/* questi due tipi di chiamata sono assolutamente equivalenti */
valore = Funzione('Z');
valore = (* PuntatoreAFunzione)('Z');
}

Utilizzando i puntatori in maniera non chiara si può facilmente produrre codice C difficile da leggere (“offuscato”):

printf("Hai inserito %d\n",*(&(*(&k))));

equivale a:

printf("Hai inserito %d\n",k);

 

Allocazione dinamica della memoria

L’allocazione dinamica della memoria e’ gestita in C da varie funzioni di libreria che risiedono nella libreria standard stdlib.h
Le principali funzioni per l’allocazione dinamica della memoria sono:

Void *malloc(size_t n)
Void free(void *)

La malloc restituisce un puntatore generico (void *) all’area di memoria allocata (grande size_t bytes) oppure NULL se la memoria e’ terminata o si e’ verificato qualsiasi altro errore.

Il tipo size_t e’ definito all’interno dell’header file stdlib.h come:

#ifndef _SIZE_T_DEFINED
typedef unsigned int size_t;
#define _SIZE_T_DEFINED
#endif

(da notare il meccanismo ifndef -> define per evitare che il tipo size_t venga definito piu’ volte se piu’ sorgenti dello stessa applicazione includono l’header stdlib.h).

La funzione free serve per liberare memoria allocata tramite malloc; attenzione a ad utilizzare la free solo dopo la malloc e solo per liberare memoria allocata da malloc altrimenti si possono ottenere effetti imprevedibili.

Es.
(listato 1.8)
/* puntatore generico da usare con malloc */
void *m;
/* puntatore a carattere */
char *msg;

/* allochiamo spazio per 5 bytes; la sizeof non e’ necessaria ma e’ solo per portabilita’ del codice su altre piattaforme */
m = malloc(5 * sizeof(char));
if (m != NULL)
{
/* si utilizza la memoria se malloc restituisce un risultato diverso da NULL */
msg = (char *) m;
strcpy(msg,"ciao");
printf ("%s\n",msg);
}

/* alla fine si libera la memoria tramite una istruzione free */
free(m);

 

Strutture ed Unioni (Struct ed Union)

Le strutture

Una struttura e’ una collezione contenente una o piu’ variabili, di qualsiasi tipo, raggruppate insieme in modo da formare un’entita’ logica (possono ricordare il tipo “record” presente in altri linguaggi come il Pascal).
Le strutture possono essere nidificate, possono essere copiate, passate alle funzioni e da queste restituite.
La struct e’ una struttura dati potente e flessibile che si e’ poi evoluta nella classe presente nel linguaggio C++.

Per esemplificare l’utilizzo delle strutture creeremo alcuni esempi di struct che possono essere utilizzate come base per programmi grafici.
L’oggetto base e’ il punto che possiamo decidere di rappresentare come una coppia di coordinate x e y.

/* dichiarazione della struttura per rappresentare un punto */
Struct point
{
int x;
int y;
};

A questo punto e’ possibile dichiarare variabili di tipo struct point ed utilizzarle:

Struct point pt;
/* dichiarazione ed inizializzazione */
Struct point maxPt = { 320, 200 };

/* ecco come si accede ai membri di una struttura */
Pt.x = 100;
Pt.y = 50;

 

Ecco la dichiarazione di una struttura nidificata

/* ogni membro della struttura rect e’ di tipo struct point */
Struct rect
{
struct point pt1;
struct point pt2;
};

struct rect rt;

rt.pt1.x = 1;
rt.pt1.y = 1;
rt.pt2.x = 100;
rt.pt2.y = 100;

 

Strutture e funzioni

Una struttura puo’ essere copiata, assegnata, indirizzata tramite l’operatore & oppure manipolata tramite l’accesso ai suoi membri.La copia e l’assegnamento comprendono anche il passaggio di argomenti alle funzioni e la restituzione di valori dalle funzioni.

Es.
(listato 1.9)
/* Questa funzione crea una struttura per un punto a partire da due coordinate e restituisce la struttura creata */
Struct point MakePoint(int x, int y)
{
struct point temp;

     temp.x = x;
temp.y = y;
return temp;
}

struct rect screen;
struct point middle;

/* posso utilizzare direttamente la funzione MakePoint perche’ restituisce oggetti di tipo struct point */
screen.pt1 = MakePoint(0,0);
screen.pt2 = MakePoint(320,200);

middle = MakePoint ((screen.pt1.x + screen.pt2.x) / 2, (screen.pt1.y + screen.pt2.y) / 2);

/* in questa funzione sia i parametri di input che quelli di output sono delle strutture */
struct point AddPoint(struct point p1, struct point p2)
{
p1.x += p2.x;
p1.y += p2.y;
return p1;
}

Se ad una funzione deve essere passata una struttura spesso e’ conveniente passare un puntatore alla struttura invece della struttura stessa:

/* dichiarazione di un puntatore a struct point */
Struct point *pp;

Struct point origin = MakePoint(1,1);

/* assegniamo al puntatore pp l’indirizzo della struttura origin */
Pp = &origin;

/* per accedere ai membri di una struttura tramite puntatore si usa */
Printf (“Le coordinate sono (%d, %d)\n”, (*pp).x, (*pp).y);

/* oppure per comodita’ esiste questa notazione alternativa */

Printf (“Le coordinate sono (%d, %d)\n”, pp->x, pp->y);

/* posso anche dichiarare un vettore di strutture */

struct point pt[1000];
struct point *p;

pt[1].x = 1;
pt[1].y = 1;

/* il puntatore p punta al primo elemento del vettore di strutture */
p = &pt[0];

/* quando incremento p di 1 il puntatore mi viene incrementato in realta’ di sizeof(struct point) bytes grazie all’aritmetica dei puntatori */

Printf (“Le coordinate sono (%d, %d)\n”, p->x, p->y);

P += 1;

Printf (“Le coordinate sono (%d, %d)\n”, p->x, p->y);

Le Unioni
Una Union e’ una variabile che puo’ contenere (in momenti diversi) oggetti di tipo e dimensione differenti, dei quali il compilatore gestisce l’ampiezza in base ai requisisti di allineamento.
Le union consentono di manipolare diversi tipi di dati all’interno di un’unica area di memoria allo scopo di ottimizzarne l’occupazione.
E’ chiaro che oggi strutture di questo tipo sono usate raramente data l’enorme abbondanza di memoria disponibile al programmatore per i dati.

Union u
{
int i;
float f;
char *s;
} u;

/* la variabile u verra’ creata sufficientemente grande da contenere il piu’ ampio dei tre tipi */

u.i = 3;
printf (“%d”, u.i);

u.f = 3.14;
printf (“%f”, u.f);

/* nel momento in cui riempio il membro u.f il contenuto del membro u.i e u.s vengono distrutti; e’ chiaro quindi che per utilizzare le union il programmatore deve tenere traccia del tipo correntemente memorizzato nella struttura */


 

Gestione argomenti da linea di comando

Il linguaggio C mette a disposizione dello sviluppatore alcuni meccanismi per intercettare i parametri passati all’eseguibile dalla linea di comando.
L’esecuzione di un programma C infatti prevede sempre la chiamata della funzione main definita al suo interno, alla quale vengono sempre passati due argomenti denominati argc e argv.
Il primo “argoment count” o argc e’ il numero degli argomenti passati al programma da linea di comando ed il segondo “argoment value” o argv e’ un puntatore ad un vettore di stringhe che contengono gli argomenti passati, uno per stringa.
Riepilogando, dato un programma denominato echo che viene richiamato in questo modo:

Echo ciao a tutti

Avremo argc che varra’ 4 (il nome del programma vale come argomento) e argv[0] varra’ “echo”, argv[1] varra’ “ciao” e cosi’ via.

Es.
(listato 1.10)
#include <stdio.h>

main (int argc, char *argv[])
{
int I;
/* facciamo partire l’indice da 1 perche’ vogliamo stampare solo gli argomenti dato che argv[0] e’ il nome stesso dell’eseguibile */

/* si stampano tutti gli argomenti ricevuti dalla linea di comando */
for (I = 1; I < argc; I ++)
printf (“%s “,argv[I]);
return 0;
}

NOTA:
Per impostare i parametri della linea di comando all’interno dell’ambiente di sviluppo Visual Studio occorre specificare i parametri in :

Project -> Settings -> Debug -> Program Arguments

 

 

Le piu’ importanti funzioni della libreria standard

Le funzioni di I/O

Le funzioni di I/O in linguaggio C operano attraverso flussi di dati in ingresso/uscita detti stream (in questo caso il rapporto con il sistema operativo UNIX e’ molto stretto).
Generalmente ogni programma in esecuzione puo’ accedere a 3 stream di default che vengono denominati stdin, stdout e stderr.
Lo stdin e’ il flusso standard da cui un programma riceve l’input; generalmente si tratta della tastiera ma questo puo’ venire rediretto (ad esempio su UNIX e DOS con il comando <).
Lo stdout e’ il flusso standard per l’output di un programma che di solito e’ il video ma che puo’ essere rediretto su di un altro file o sulla stampante o su altri dispositivi di output.
Lo stderr e’ il flusso standard di output su cui finiscono gli errori durante l’esecuzione del programma; anche questo flusso in genere va al video ma esistono modi per redirigerlo.

Per poter utilizzare le funzioni di I/O in un programma C e’ necessario includere il file <stdio.h> che contiene tutte le definizioni ed i prototipi necessari alle funzioni di I/O.

Le principali funzioni di I/O sono:

/* legge un carattere dallo stdin e lo ritorna, EOF se l’input non e’ disponibile oppure il file e’ terminato */
Int getchar(void)

/* invia un carattere allo stdout e ritorna il carattere scritto oppure EOF se si verifica un errore */
Int putchar(int)

/* La funzione printf converte, formatta e invia allo stdout i suoi argomenti a seconda del formato specificato; ritorna il numero di caratteri stampati */
Int printf(char *format, arg1, arg2, …)

 

La funzione printf e’ molto utilizzata ed ha un enorme quantita’ di formati utilizzabili; adesso elencheremo alcuni esempi riportando i formati piu’ comunemente utilizzati:

 

%d   numero decimale
%0nd numero decimale sempre su n cifre
%-d  numero decimale allineato a sinistra
%c   carattere singolo
%s   stringa terminata da \0
%f   double
%.nf double con precisione n (arrotondato)
\n   newline
\t   tab

 

A = 5;
Printf (“%d”, a); /* stampa 5 */

A = 5;
Printf (“%03d”, a); /* stampa 005 */

A = 5;
Char B[] = “ciao”;
Printf (“%03d\t%s”, a); /* stampa 005 ciao */

A = 5.34967;
Printf (“%.2f”, a); /* stampa 5.35 */

 

/* la funzione sprintf ha lo stesso comportamento della printf ma l’output viene inviato ad una stringa che deve essere ampia abbastanza per contenerlo */
Int sprintf(char *string, char *format, arg1, arg2, …)

/* la funzione scanf legge caratteri dallo stdin interpretandoli in base al formato specificato e memorizzandoli nei vari argomenti */
Int scanf(char *format, arg1, arg2, …)

/* Per leggere da tastiera un intero; notare che scanf richiede l’indirizzo degli argomenti */
int a;
a = scanf(“Inserire un numero %d :”, &a);

Il concetto di stream rimane valido anche per l’accesso a file generici che non siano quelli di default (stdin, stdout e stderr).

Per aprire un puntatore al file si utilizza la funzione fopen() definita  come:

FILE *fopen(char *name, char *mode)

Tale funzione ritorna un puntatore a FILE. La stringa "name" e' il nome del   file su disco a cui vogliamo accedere; la stringa "mode" definisce il tipo di accesso. Se per una qualsiasi ragione il file risulta non accessibile, viene ritornato un puntatore nullo.
Le possibili modalita' di accesso ai files sono:


  - "r" (read),
- "w" (write),
- "a" (append).

Per aprire un file dobbiamo avere uno stream (puntatore al file) che punta  ad una struttura FILE.
Cosi', per aprire in lettura un file chiamato myfile.dat, dovremo avere:

FILE *stream;  /* dichiarazione dello stream */

stream = fopen ("myfile.dat","r");

E' buona norma controllare l'esito dell'apertura del file:

if ((stream = fopen ("myfile.dat","r")) == NULL)
{
printf("Errore apertura %s \n", "myfile.dat");
exit(1);
}

Le funzioni fprintf ed fscanf sono comunemente utilizzate per l'accesso ai  files:

int fprintf(FILE *stream, char *format, args ...)
int fscanf(FILE *stream, char *format, args ...)

Sono simili a printf e scanf, tranne per il fatto che i dati sono letti  dallo stream, che deve essere aperto con fopen().

Ad esempio:

char *string[80]
FILE *fp;
if ((fp=fopen("file.dat","r")) != NULL)
fscanf(fp,"%s",string);

Il puntatore alla stream viene incrementato automaticamente con tutte le  funzioni di lettura/scrittura su file, quindi non e' necessario preoccuparsi  di farlo manualmente.

Altre funzioni di I/O da file sono:

int getc(FILE *stream),       int fgetc(FILE *stream)
int putc(char ch, FILE *s),   int fputc(char ch, FILE *s)

Queste funzioni sono come getchar e putchar. "getc" e' definita come  macro di preprocessore in stdio.h, "fgetc" e' una funzione di libreria C;  con entrambe si ottiene lo stesso risultato.

Esistono poi le funzioni:

fflush(FILE *stream)   - per fare la "flush" di una stream
fclose(FILE *stream)   - per fare la "close" di una stream

Ad esempio:

FILE *fp;
if ( (fp=fopen("file.dat","r")) == NULL)
{
printf("Impossibile aprire file.dat\n");
exit(1);
}
...
fclose(fp);

E' anche possibile accedere ai flussi di default utilizzando fprintf, etc., dato che sono file a tutti gli effetti:

fprintf(stderr,"Errore");
fscanf(stdin,"%s",string);

 

Funzioni di manipolazione delle stringhe

Le funzioni che permettono di operare su stringhe di caratteri sono definite nell’header <string.h>

/* concatena due stringhe mettendo str2 alla fine di str1 */
Char *strcat(char *str1, char *str2)

/* confronta due stringhe e ritorna un valore nullo se sono uguali, diverso da 0 se sono diverse */
int strcmp( const char *string1, const char *string2 )

/* copia una stringa in un’altra */
Char *strcpy(char *destination, char *source)

/* ritorna la lunghezza della stringa str */
size_t strlen(const char *str)


Altre funzioni di libreria
Esistono molte altre funzioni nella libreria standard oltre a quelle citate; segue un elenco dei principali header file da includere e le funzioni piu’ importanti di ogni header file:

 

Header ctype.h
Contiene funzioni per controllare se un carattere e’ formato da cifre, oppure da caratteri alfanumerici, caratteri non stampabili ecc.

/* ritorna un valore diverso da 0 se il carattere e’ una cifra decimale */
Int isdigit(int c)

Header math.h
Contiene funzioni matematiche che perlopiu’ operano su tipi double.

/* ritorna il seno di x */
double sin(double x)

Header stdlib.h
Contiene funzioni di varia utilita’ come quelle per l’allocazione di memoria dinamica, di conversione fra numeri e stringhe, di sort e search all’interno di vettori, di generazione di numeri casuali ecc..

/* converte la stringa s in un intero */
int atoi(const char *s)

Header stdarg.h
Contiene funzioni e macro per la gestione di funzioni con numero variabile di argomenti (vedi capitolo 1.11)

Header time.h
Contiene tipi e funzioni che permettono di manipolare l’ora e la data.
Le informazioni relative a data e ora sono contenute nella seguente struttura dati:

Struct tm
{
int tm_sec;   /*secondi dopo il minuto */
int tm_min;   /* minuti dopo l’ora */
int tm_hour;  /* ore dopo mezzanotte */
int tm_mday;  /* giorno del mese */
int tm_mon;   /* mesi dopo Gennaio */
int tm_year;  /* anni dopo il 1900 */
int tm_wday;  /* giorni dopo la Domenica (0-6) */
int tm_yday;  /* giorni dopo il 1 Gennaio (0-365) */
int tm_isdst; /* positivo se l’ora legale e’ in vigore

/* restituisce l’ora corrente */
time_t time(time_t *tp)

/* converte l’ora locale contenuta nella struttura tp nell’ora corrente */
time_t mktime(struct tm *tp)

/* riempie la struct tm a partire da un puntatore a time_t */
struct tm *localtime(const time_t *tp)

/* converte la data contenuta nella struttura *tp in una stringa leggibile del tipo: Sun Jan 3 15:30:27 2002 */
char *asctime(const struct tm *tp)

 

(listato 1.11)
/* Esempio: stampa della data odierna */
#include <stdio.h>
#include <time.h>

main()
{
/* tipo aritmetico time_t per memorizzare l’ora e la data corrente */
time_t tempo;
/* struttura tm che permette poi di utilizzare le informazioni data e ora */
struct tm *segnatempo;

     /* recuperiamo la data e ora corrente */
tempo = time(&tempo);
/* riempiamo la struttura segnatempo a partire dall’ora recuperata con la funzione time tramite la funzione localtime */
segnatempo = localtime(&tempo);

/* per stampare in modo comprensibile la struttura segnatempo possiamo usare asctime */
printf ("%s\n", asctime(segnatempo));
}

 

 

Parte 2 : Linguaggio C++

Introduzione al linguaggio C++

Il linguaggio C++e’ stato sviluppato a partire dal 1980 circa da Bjarne Stroustrup presso il laboratori Bell della AT & T.
Il termine “C++” coniato da Rick Mascitti nel 1983 rappresenta la natura evolutiva del linguaggio rispetto al suo predecessore, il linguaggio C, che risulta comunque incluso come sottoinsieme del C++.
Le principali ragioni alla base della nascita del nuovo linguaggio erano quelle di creare un linguaggio che supportasse tutti i paradigmi della programmazione Object Oriented e che pero’ allo stesso tempo non costringesse i programmatori ad imparare un linguaggio completamente nuovo.
Fu scelto quindi di adottare come base del nuovo linguaggio il C che era gia’ usato da milioni di programmatori in tutto il mondo e aveva comunque ottime doti di versatilita’ e di portabilita’, e nel processo di aggiunta delle nuove estensioni vennero anche riviste e migliorate molte delle caratteristiche del linguaggio C, rendendo il C++ un linguaggio decisamente piu’ potente e sicuro rispetto al suo predecessore.

Ecco alcuni dei concetti cardine che hanno guidato la progettazione del nuovo linguaggio:

Efficienza e velocita’

Il C++ mantiene la buona velocita’ di esecuzione dei programmi ed efficienza del C pur aggiungendo tutta una serie di nuovo costrutti e strutture dati.

Manutenibilita’ e chiarezza dei programmi

Il C++ tende ad risolvere i problemi ragionando in termini di entita’ astratte (classi) che sono quindi piu’ vicine alla rappresentazione del problema rispetto alla programmazione procedurale tradizionale.
Questo comporta la scrittura di programmi piu’ comprensibili e quindi piu’ manutenibili.

Massimo riutilizzo del codice

Tramite l’utilizzo di classi, librerie di classi e template il concetto di riuso del codice viene spinto agli estremi in C++ rendendo il linguaggio ideale per  lo sviluppo di grosse applicazioni condivise tra piu’ programmatori.

Gestione degli errori

Uno dei grossi limiti del C ovvero la gestione degli errori a runtime viene superato in C++ grazie alla gestione delle eccezioni.

Riassumiamo inoltre i concetti cardine della OOP (Object Oriented Programming) attorno a cui ruotano tutti le principali caratteristiche del linguaggio C++:

Astrazione e modellazione del problema tramite oggetti

L’approccio Object Oriented si propone di spingere al massimo l’astrazione del linguaggio di programmazione mettendo a disposizione strumenti al programmatore per rappresentare elementi nel dominio del problema (“oggetti”).
Questo permette di descrivere i problemi da risolvere nei termini del problema stesso piu’ che nei termini del computer su cui girera’ il programma che risolvera’ il problema.
Gli oggetti (chiamati istanze di una classe secondo la terminologia OO) hanno un legame diretto con le entita’ reali o concettuali che rappresentano il problema da risolvere (entita’ come ‘servizi’, ‘persona’, ‘edificio’, ‘polizza’ possono essere rappresentati in C++ grazie alle classi).
Gli oggetti modellati in C++ possono scambiarsi messaggi tramite le rispettive “interfacce” che definiscono in che modo ogni oggetto possa interagire con gli altri.

L’implementazione resta nascosta (incapsulamento)

Il C++ permette di mantenere completamente nascosta l’implementazione vera e propria di una classe, in modo che gli oggetti possano essere veramente usati come “black box” solamente tramite le loro interfacce.
Questo permette inoltre di variare l’implementazione di una classe (magari per ottimizzarla) senza toccare una sola riga del codice che la utilizza a patto di mantenere la stessa interfaccia.

Riutilizzo dell’implementazione

Una volta che una classe e’ stata disegnata ed implementata questa puo’ (e deve) venire riutilizzata nella soluzione di altri problemi, abbassando drasticamente il tempo di sviluppo del software.

Ereditarieta’
Se una classe risolve solo una parte di un nuovo problema e/o necessita’ comunque di ulteriori specializzazioni, questa puo’ essere usata come base per ereditare una nuova classe che puo’ poi essere modificata ma che comunque mantiene tutte le funzionalita’ della classe base.

Polimorfismo
Il polimorfismo, realizzato in C++ tramite le funzioni virtuali, si puo definire come la capacita' di ottenere un comportamento specializzato, cioe' e' possibile invocare un metodo definito in una classe specializzata (es. Rettangolo->disegna), di cui fa parte un oggetto (es. l'oggetto di tipo RettangoloColorato), attraverso un riferimento all'oggetto, anche se di tipo meno specializzato (es. tramite un puntatore ad un oggetto di tipo Figura).
Il concetto verra’ chiarito meglio tramite gli esempi nel capitolo 2.6

 


Le caratteristiche fondamentali del linguaggio C++

 

Come abbiamo fatto precedentemente per il linguaggio C, iniziamo l’analisi del C++ partendo da un semplice listato che effettua la stampa di un messaggio a video:

// Questo e’ un commento C++

// Libreria standard di I/O del C++
#include <iostream>
using namespace std;

int main()
{
cout << "Hello, World! " << endl;
}

Come si puo’ capire dal codice, esistono alcune differenze con l’equivalente programma C sebbene sia possibile ignorare completamente le nuove funzionalita’ e scrivere codice totalmente C-like.
Questo significa che il programma del capitolo 1.2 sarebbe stato compilato senza alcun problema da qualsiasi compilatore C++.

I commenti in C++ possono essere indicati sia nella forma /* */ che utilizzando il doppio slash (//) in testa alla riga da commentare; l’estensione dei file contenenti codice C diventa .cpp invece di .c; l’estensione degli header file diventa .hpp invece di .h.

 

I namespace
Il C++ offre un meccanismo per evitare “collisioni” tra nomi di funzioni uguali all’interno di librerie diverse (immaginate ad esempio di aver comprato da fornitori esterni due librerie che contengono entrambi una funzione chiamata Initialize(); in C questo provocherebbe una collisione ed impedirebbe di fatto l’utilizzo contemporaneo delle due librerie).
In C++ ogni insieme di definizioni all’interno di una libreria o di un programma e’ racchiuso in un namespace, e quindi si possono avere definizioni con lo stesso nome a patto che si trovino in namespace diversi.

Using namespace std;

Indica che si vogliono esporre tutti gli elementi (definizioni) contenute all’interno del namespace denominato std (standard) al cui interno sono contenute tutte le definizioni delle funzioni della libreria standard C++.

(creare un namespace e operatore di visibilita’ ::)

Per stampare il messaggio su video e’ stata usata al posto di printf contenuta in stdio (che comunque e’ usabile in C++) la nuova libreria di manipolazione dei flussi di I/O denominata iostream.
Parleremo comunque piu’ diffusamente della libreria iostream nel capitolo 2.10 dedicato alla libreria standard C++.

 

Principali differenze tra C e C++

Prima di analizzare le istruzioni e le strutture dati orientate alla programmazione Object Oriented vorrei elencare quali sono le principali differenze tra il linguaggio C ed il C++. Sebbene si possa compilare in C++ del codice scritto in perfetto stile C, esistono molte nuove potenti funzionalita’ del C++ che si prefiggono lo scopo di superare i limiti del linguaggio predecessore, che e’ quindi importante imparare ad usare perche’ forniscono al programmatore nuovi strumenti per rendere il codice piu’ potente ed efficiente.

 

Definizione di variabili “on the fly”
Esiste una differenza significativa tra il C ed il C++ per quanto riguarda la definizione delle variabili, in quanto il C (come molti linguaggi tradizionali) richiede che tutte le variabili siano definite rigorosamente all’inizio del blocco al cui interno verranno usate.
Questo puo’ comportare problemi perche’ molti programmatori non sanno esattamente di quali variabili avranno bisogno durante la scrittura del blocco di codice (o della funzione) e quindi saranno costretti a tornare continuamente in testa al blocco per aggiungere nuove variabili.
Il C++ invece permette di definire variabili ovunque all’interno del codice permettendo al programmatore di poter definire una variabile nel momento esatto in cui ne avra’ bisogno.
Questo rende il codice piu’ leggibile perche’ chi legge il codice vede la variabile definita vicino al contesto in cui viene utilizzata.


Es.

// Definizione “on the fly” di variabili

#include <iostream>
using namespace std;

int main()
{
// blocco di istruzioni …

  { // inizio di un nuovo blocco
// il C  richiede la definizione all’inizio del blocco
int q = 0;

     // Definizione nel punto di utilizzo, all’interno del
// ciclo for
for(int i = 0; i < 100; i++)
{
q++; // q era gia’ stata definita in precedenza
// Definizione all fine del blocco
int p = 12;
}
// Questa p e’ differente da quella precedente
int p = 1;
} // Fine del blocco di visibilita’ per p = 1 e q
}

 

Allocazione dinamica della memoria

In C l’allocazione dinamica di memoria era gestita dalle istruzioni malloc e free mentre in C++ sono stati introdotti due nuovi operatori (new, delete) per effettuare le stesse operazioni.

L’istruzione new si occupa di allocare memoria dinamicamente per un qualsiasi oggetto:

new unsigned char[newBytes];

 

La sintassi generale dell’istruzione new e’ la seguente:

new Type;

dove Type descrive il tipo di variabile di cui si richiede l’allocazione dinamica nello heap (memoria virtuale).
Nel caso precedente abbiamo allocato spazio per un array di unsigned char lungo newBytes, ma e’ anche possibile richiedere spazio per un tipo semplice (es. New int).
Una espressione new ritorna un puntatore ad un oggetto del tipo richiesto; nel caso dell’array verra’ restituito un puntatore al primo elemento dell’array.
Il compilatore controlla che il puntatore di ritorno della new venga assegnato ad un puntatore di tipo corretto.
E’ chiaro che ogni volta che si richiede memoria dinamicamente tramite l’utilizzo di new possa verificarsi il caso che la memoria non sia disponibile; e’ sempre quindi buona norma testare il valore di ritorno del puntatore restituito da new ed assicurarsi che non valga 0.
L’istruzione delete e’ il contrario di new e deve essere esplicitamente richiamata per liberare memoria allocata tramite l’utilizzo di new.
Non esistono meccanismi automatici di “garbage collecting” da parte del compilatore e quindi bisogna sempre ricordarsi di utilizzare delete se non si vuole lasciare memoria inutilizzata a giro durante l’esecuzione del programma.
Se si sta rilasciando spazio allocato per un array e’ necessario utilizzare l’istruzione delete in questo modo:

Delete []myArray;

Es.

(listato 2.1)
#include <iostream>
#include <stdlib.h>
#include <string.h>
using namespace std;

int main()
{
// allochiamo spazio dinamicamente per un buffer di  // 1000 caratteri
char *p = new char[1000];

     // controlliamo se la new e’ andata a buon fine
if( p == 0 )
{
cerr << "Memoria insufficiente " << endl;
return -1;
}

// utilizziamo la memoria allocata   
strcpy(p,"ciao");
cout << "p contiene " << p << endl;

     // a questo punto la rilasciamo
delete(p);
return 0;
}

Passaggio per riferimento

I puntatori funzionano piu’ o meno nello stesso modo sia in C che in C++, ma il C++ aggiunge un metodo addizionale per passare parametri ad una funzione, il passaggio per riferimento.
Come avevamo visto nel capitolo 1.12 era possibile gia’ in C passare ad una funzione l’indirizzo di una variabile invece del valore; il C++ invece permette di passare l’indirizzo di una variabile tramite un riferimento (reference) ottenendo del codice sintatticamente piu’ pulito e comprensibile.
Riscriviamo l’esempio del capitolo 1.12 utilizzando il passaggio per riferimento al posto di quello per indirizzo:

(listato 2.2)
#include <iostream>
using namespace std;

// versione C swap(int *x, int *y)
// invece di un puntatore la funzione si aspetta un
// riferimento
void swap (int& x, int& y)
{
int temp;
temp = x;
x = y;
y = temp;
}

 

int main()
{
int a = 3, b = 5;

// sembra una normale chiamata per valore mentre in  // realta’ stiamo passando un riferimento alle due
// variabili
swap (a,b);

     cout << "a , b " << a << b << endl;
return 0;
}


 

Le classi in C++

Il concetto di classe in C++ fornisce al programmatore uno strumento per creare nuovi tipi utilizzabili nello stesso modo di quelli predefiniti e che siano strettamente corrispondenti alle entita’ concettuali del problema che si vuole modellare.
L’idea fondamentale per la definizione di un nuovo tipo consiste nel separarare i dettagli secondari dell’implementazione (ad esempio la struttura dati completa richiesta per memorizzare l’oggetto)  dalle proprieta’ fondamentali per un uso corretto (come l’elenco delle funzioni che possono aver accesso ai dati).
Tale separazione puo’ essere espressa incanalando ogni utilizzo della struttura dati e le routine interne di gestione attraverso una interfaccia specifica.

Una classe (class) non e’ altro che un nuovo tipo definito dall’utente che per molti versi e’ simile alle struct del C ma che permette in piu’ di incorporare all’interno della struttura sia i dati, sia le funzioni necessarie per operare sui dati e di restringere l’accesso ai membri della struttura (funzioni e dati) a solo quegli elementi che permettono alla classe di interagire con l’esterno (“interfaccia”).

Per arrivare a definire bene il concetto di classe supponiamo di dover implementare il concetto di una struttura per la rappresentazione di una data e di una serie di funzioni che operano su questa rappresentazione:

Struct date
{
int day;
int month;
int year;
};
date today;

void set_date(date *, int, int, int);
void next_date(date *);
void print_date(const date *);

In questo modo si definisce la struttura dati e le funzioni che vi operano ma non esiste nessun legame tra dati e funzioni.
Il legame puo’ essere stabilito dichiarando le funzioni come proprie della struttura:

Struct date
{
int day;
int month;
int year;

void set (int, int, int);
void get (int *, int *, int *);
void next ();
void print ();

};

// la dichiarazione della funzione propria deve specificare
// il nome della struttura a cui si riferisce
void date::print()
{
// notare che all’interno di una funzione propria si
// puo’ fare riferimento direttamente ai membri della
// struttura a cui si riferisce (es. Day)
cout day << “/” << month << “/” << year;
}

Le funzioni cosi’ dichiarate vengono dette funzioni proprie e possono essere richiamate solo associandole alla struttura entro cui sono definite in questo modo:

Date today;
Date my_birthday;

Main()
{
my_birthday.set(21,10,1972);
today.set(20,9,2002);

my_birthday.print();
today.next();

// questo e’ errato ma il programmatore non puo’ fare
// niente utilizzando una struct per impedire che i
// membri interni della struttura vengano variati
// senza utilizzare funzioni proprie.
today.month = 13;
}

La dichiarazione della struttura date che abbiamo appena visto fornisce un insieme di funzioni per il trattamento della data ma non specifica che queste funzioni dovrebbero essere le uniche a poter accedere ai membri interni degli oggetti di tipo date.
E’ proprio per esprimere questa restrizione che in C++ e’ stato introdotta la definizione di class come evoluzione delle struct:


(listato 2.3)
Class date
{
private: // puo’ essere omesso
int day;
int month;
int year;

public:
void set (int, int, int);
void get (int *, int *, int *);
void next ();
void print ();
};

// la funzione propria deve specificare il nome della
// classe a cui si riferisce
void date::print()
{
// Alle funzioni membro viene sempre passato un
// puntatore alla classe da cui dipende in modo
// da poter accedere ai dati e funzioni della classe
// a cui appartiene la funzione; il puntatore nascosto
// e’ sempre dichiarato implicitamente come
// date *const this (non puo’ essere modificato)

     cout << day << “/” << month << “/” << year;

     // questa forma e’ equivalente
cout << this->day << “/” << this->month << “/” << this ->year;
}

L’identificativo public separa il corpo della classe in due parti.
I nomi contenuti nella prima parte, ovvero quella privata, possono essere utilizzati solo da funzioni proprie della classe; la seconda parte, ovvero quella pubblica, costituisce l’interfaccia agli oggetti della classe e quindi le funzioni dell’area pubblica possono essere richiamate anche all’interno di funzioni non membro.

 

Main()
{
// si istanzia un oggetto di tipo date
Date today;

     // chiamare una funzione membro di una classe si
// definisce anche richiamare un metodo della classe
today.set(20,9,2002);

today.print();

     // questa istruzione provochera’ un errore di
// compilazione perche’ il membro month e’ privato
// e puo’ essere acceduto solo da funzioni proprie
// della classe date   
today.month = 13; //ERRORE !!!

// Questo e’ un metodo alternativo di istanziare un
// oggetto, tramite un puntatore alla classe date e
// l’utilizzo dell’operatore new
date *pd = new date;

     // chiamata alle funzioni membro tramite puntatore
pd->set(21,10,1972);
pd->print();

     // gli oggetti allocati tramite new possono essere
// distrutti esplicitamente tramite delete
delete(pd);
}

E’ possibile dichiarare una funzione propria in grado di leggere ma non di modificare l’oggetto per cui viene richiamata; l’intenzione di non apportare modifiche all’oggetto *this viene indicata mettendo const dopo la lista degli argomenti.
Se nella funzione membro get si prova a modificare day, month o year si ricevera’ un errore di compilazione.

void get (int& d, int& m, int& y) const { d = day; m = month; y = year; }

// ERRORE di compilazione, si e’ provato a modificare il
// contenuto della variabile membro year
void get (int& d, int& m, int& y) const { d = day; m = month; y = year; year = 4000; }

Funzioni proprie “inline”

Quando si programma utilizzando le classi, e’ molto comune l’impiego di brevi funzioni; in effetti si hanno molte piu’ funzioni di quante se ne impiegherebbero con uno stile di programmazione tradizionale.
Questo puo’ portare a gravi inefficienze nell’esecuzione del codice a causa dell’alto costo macchina rappresentato da un chiamata a funzione.
Le funzioni inline sono state realizzate per ovviare a questo problema.
Una funzione membro definita (e non solo dichiarata) all’interno della dichiarazione della classe oppure all’esterno ma includendo l’identificatore inline prima del nome della funzione viene trattata e risolta dal compilatore in modo da eliminare ogni overhead dovuto al cambio di contesto (in sostanza non viene richiamata veramente una funzione ma il codice viene inserito direttamente nella definizione della classe).

Es.
Class date
{

public:
// la dichiarazione e la definizione della funzione
// get si trovano entrambi all’interno della classe
// date e quindi la funzione get e’ da considerarsi
// “inline”
void get (int& d, int& m, int& y) const { d = day; m = month; y = year; }
}

// lo stesso risultato si puo’ ottenere definendo la
// funzione get al di fuori della classe ma mettendo
// l’identificatore inline prima della funzione
inline const void date::get (int& d, int& m, int& y)
{
d = day; m = month; y = year;
}

I “costruttori”
L’inizializzazione di una classe tramite l’utilizzo di funzioni come set non e’ elegante e puo’ essere fonte di errori dato che siccome non e’ obbligatorio, il programmatore puo’ dimenticarsi di eseguire l’inizializzazione e quindi utilizzare metodi della classe senza avere dei valori significativi all’interno della struttura dati interna (es’ stampare la data odierna senza che nessuna data odierna sia stata caricata nella classe date).

Un approccio migliore consiste nel dichiarare una funzione che ha l’esplicito scopo di inizializzare gli oggetti della classe; questo tipo di funzione viene definito “costruttore” di una classe e viene richiamato automaticamente dal compilatore nel momento in cui un oggetto appartenente ad una certa classe verra’ istanziato.
La funzione costruttore ha sempre lo stesso nome della classe per cui effettua l’inizializzazione.

Es.
Class date
{

date(int, int, int); // costruttore della classe date
};


Se il costruttore richiede argomenti, come in questo caso, e’ obbligatorio specificarli:

Date today = date(21,10,1972);

// oppure
date xmas(25,12,2002);

// errore, mancano gli argomenti
date birthday;

Si puo’ pensare anche di fornire piu’ costruttori diversi per una classe in funzione di quale potrebbero le possibili chiamate effettuate dai “consumatori” della classe (nota bene quanto sia importante la progettazione dell’interfaccia della classe):

Class date
{
int month, day, year;
public:
date(int, int, int);
date(int, int); // solo giorno mese e anno corrente
date(int); // solo giorno e anno/mese correnti
date(); // prende la data odierna come default
date(const char *); // la data e’ alfanumerica

}

Il compilatore e’ in grado di selezionare il costruttore corretto al momento della chiamata a patto che i tipi dei parametri siano sufficientemente diversi:

Date today(21);
Date xmas(“25 Dicembre 2002”);
Date now; // verra’ chiamato il costruttore senza parametri

Un modo per ridurre la proliferazione dei costruttori e’ quello di utilizzare argomenti di default; ad esempio nella classe date ogni argomento potrebbe avere come valore di default il giorno/mese/anno corrente:

Class date
{
int month, day, year;
public:

// si specificano i valori di default nel costruttore
// e’ chiaro che lo 0 non deve mai essere un valore
// ammesso per gli argomenti.
// in questo modo e’ sufficiente un solo costruttore
date(int d = 0, int m = 0, int y = 0);

}

date::date(int d, int m, int y)
{
// se d, m o y valgono 0 si usano dei valori di
// default
day = d ? d : today.day;
month = m ? m : today.month;
year = y ? y : today.year;
// si controlla che la data sia valida
// …
}

 

E’ possibile effettuare il seguente assegnamento tra due oggetti appartenenti alla stessa classe:

Date date1(21,10,1972);
Date date2(22,10,2002);

Date1 = date2;

Esiste infatti un costruttore di default definito come copia elemento per elemento di oggetti appartenenti alla stessa classe; se non si desidera questo costruttore di default e’ possibile ridefinirlo come vedremo meglio nel capitolo in cui ci occuperemo dell’overload (2.7).

I “distruttori”
Generalmente ogni classe definita dagli utenti avra’ un costruttore che ne assicura una corretta inizializzazione.
Molti tipi richiedono anche l’operazione inversa, un “distruttore”, che assicuri una corretta eliminazione e pulizia degli oggetti allocati all’interno della classe.
Il nome del distruttore per una classe X e’ ~X().
Il distruttore e’ particolarmente usato per quelle classi che al loro interno allocano memoria dinamicamente tramite l’utilizzo dell’istruzione new e che quindi richiedono che la memoria venga rilasciata per mezzo della delete.
L’utilizzo del distruttore in questi casi assicura che la memoria venga sempre rilasciata (il costruttore e’ sempre richiamato dal compilatore) nel momento in cui l’oggetto istanziato cessa di esistere all’interno dello “scope” dell’applicazione (vedere il capitolo 1.9 riguardante lo “scope”).

 

Per esemplificare l’utilizzo del distruttore si consideri la seguente classe che implementa un semplice oggetto stack:


(listato 2.4)
Class stack
{
int size;
char *top;
char *s;
public:
// costruttore
stack(int sz)      { top=s=new char[size=sz]; }
// distruttore
~stack()           { delete[] s; }
// metodo push
void push(char c)  { *top++ = c; }
// metodo pop
char pop()         { return *--top; }
};

 

void func()
{
// istanzio un oggetto di tipo stack
// il costruttore viene richiamato automaticamente
stack s1(100);
s1.push(‘a’);
s1.push(‘b’);
char ch = s1.pop();
cout << ch << endl;
// il distruttore viene richiamato automaticamente
// appena il programma esce dalla funzione func()
}

 

Funzioni amiche (“friend”)
Talvolta puo’ essere necessario garantire l’accesso ad una funzione non propria alla parte privata di una classe, per esempio nel caso in cui una funzione privata di una classe implementi un particolare calcolo od algoritmo che potrebbe essere utilizzato senza modifiche in un’altra classe.
Invece di duplicare la funzione membro in due classi diverse (soluzione inefficiente ed inelegante) si puo’ autorizzare una funzione non propria ad accedere alla parte privata di una classe tramite il costrutto “friend”.

E’ possibile dichiarare come friend sia funzioni globali sia funzioni membro di una classe diversa, sia un’intera classe.


(listato 2.5)
// dichiariamo la classe X in maniera incompleta perche’
// altrimenti non avrei potuto passarla come parametro
// alla funzione f della classe Y
class X;

// dichiarazione della classe Y
class Y
{
void f(X*);
};

// adesso posso dichiarare in maniera completa la classe X
class X
{
private:
int i;
public:
X();   // costruttore
// la funzione g e’ dichiarata friend e non e’ membro di
// nessuna classe
friend void g(X*, int);
// la funzione friend f appartiene alla classe Y 
friend void Y::f(X*);    
// tutta la classe Z e’ friend della classe X
friend class Z;
// la funzione friend h non appartiene a nessuna classe e
// non riceve niente come argomento
friend void h();
};

// costruttore della classe X
X::X()
{
i = 0;
}

// la funzione g e’ friend della classe X
void g(X* x, int i)
{
// si possono manipolare i membri privati della classe X
x->i = i;
}


// la funzione f appartiene alla classe Y ed e’ friend
// della classe X
void Y::f(X* x)
{
// anche qua si accede ai membri privati della classe X
x->i = 47;
}

// definizione della classe Z (tutta la classe e’ friend
// della classe X
class Z
{
private:
int j;
public:
Z();
void g(X* x);
};

// costruttore della classe Z
Z::Z()
{
j = 99;
}

// funzione membro g della classe Z
void Z::g(X* x)
{
// dato che tutta la classe Z e’ friend della classe X
// possiamo accedere al membro privato i
x->i += j;
}

// h puo’ istanziare oggetti della classe X ed accedere
// direttamente ai suoi membri privati
void h()
{
X x;
x.i = 100;
}

int main()
{
X x;
Z z;
z.g(&x);
}

Classi nidificate

E’ possibile dichiarare una classe all’interno della definizione di un’altra creando una serie di classi nidificate.
In generale questo tipo di approccio non e’ considerato “puro” secondo i principi della programmazione Object Oriented che consiglierebbe l’utilizzo dell’ereditarieta’ (vedi capitolo 2.5) per costruire relazioni gerarchiche tra classi.
Infatti le classi nidificate non hanno automaticamente accesso ai membri privati delle classi che le contengono.
Per poter aver accesso ai membri privati e’ necessario definire prima la classe nidificata e poi dichiararla esplicitamente friend della classe che la contiene.

Es.

const int sz = 20;

// definizione della classe esterna
class Holder
{
private:
int a[sz];
public:
Holder();
// definizione della classe annidata
class Pointer
{
private:
Holder* h;
int* p;
public:
Pointer(Holder* h);
void next();

};
// la classe annidata deve essere definita friend
// della classe esterna se si vuole che i membri di
// Pointer possano accedere agli elementi di Holder
friend Holder::Pointer;
};

// costruttore della classe esterna
void Holder::Holder()
{
memset(a, 0, sz * sizeof(int));
}


// costruttore della classe interna, notare che
// per riferirsi ad una funzione membro di una classe
// nidificata e’ necessario specificare tutto il percorso
// per raggiungerla dato che si trova nascosta all’interno
// dello spazio di visibilita’ della classe che la
// racchiude
void Holder::Pointer::initialize(Holder* h)
{
h = h;
p = h->a;
}

 

L’ereditarieta’

Uno dei concetti chiave del C++ e di tutta la programmazione Object Oriented e’ quello di massimizzare il riutilizzo del codice.
Per ottenere questo obiettivo una delle tecniche che mette a disposizione il C++ e’ quella di poter creare nuove classi riutilizzando il codice contenuto in classi gia’ esistenti che si suppone siano gia’ state ben realizzate e testate.
Esistono due modi per riutilizzare una classe gia’ esistente; il primo metodo, abbastanza intuitivo, e’ quello di creare oggetti di classi gia’ esistenti e di combinarli all’interno di una nuova classe (questo procedimento e’ chiamato composizione di classi esistenti).
Il secondo metodo e’ quello di creare una nuova classe ereditandone una gia’ esistente ed aggiungendo codice alla classe ereditata in modo da specializzarne il comportamento ed adattandolo alle proprie esigenze.
L’ereditarieta’ e’ uno dei concetti cardine della programmazione object oriented ed ha una importanza fondamentale per la programmazione in C++.

 

Es. di composizione di classi

// data la definizione di una classe X
class X
{
int i;
public:
X() { i = 0; }
void set(int ii) { i = ii; }
int read() const { return i; }
int permute() { return i = i * 47; }
};

// possiamo definire una classe Y che utilizza al suo
// interno un oggetto appartenente alla classe X
class Y
{
int i;
public:
X x; // Oggetto di classe X
Y() { i = 0; }
void f(int ii) { i = ii; }
int g() const { return i; }
};

int main() {
Y y;
y.f(47);
// si accede all’oggetto incapsulato di classe x
y.x.set(37);
}

Per chiarire il concetto di ereditarieta’ si supponga di dover realizzare un programma riguardante i dipendenti di una ditta; una possibile struttura dati potrebbe essere:

(listato 2.6)
Class dipendente
{
// I membri “protected” di una classe sono accessibili
// solo alle funzioni proprie della stessa classe oppure
// a quelle ereditate.
protected:
char *name;
short age;
short dept;
int salary;
public:
dipendente();
void set_name(char *);
char *get_name();
void set_dept(short d) { dept = d; }
short get_dept() { return dept; }
int calculate_salary();
void print();

};


// metodo print della classe base
void dipendente::print()
{
cout << "Dipendente. dept: " << dept << endl;
}

Se volessimo definire un tipo manager potremmo a questo punto definire la nuova classe ereditando le proprieta’ (ed il comportamento) della classe dipendente ed aggiungendo in piu’ alcune informazioni che sono applicabili sono ai manager e non ai dipendenti:

// definizione di classe derivata (manager deriva da
// dipendente, oppure la classe manager e’ ereditata dalla
// classe dipendente).
// la clausola public specifica che la classe manager
// eredita l’accesso a tutti i membri pubblici della classe
// base (naturalmente puo’ accedere ai membri privati se
// questi sono indicati come “protected”).
Class manager : public dipendente
{
// gruppo di dipendenti coordinati dal manager
dipendente *group;
// livello (applicabile solo ai manager)
short level;
public:
void set_level(short l) { level = l; }
short get_level() { return level; }
void print();
};

// metodo print della classe derivata
void manager::print()
{
cout << "Manager. dept: " << dept << endl;
}

main()
{
manager m1;
dipendente e1,e2;

     e1.set_dept(1);
// viene eseguito il metodo print della classe base
e1.print();
// errore il livello esiste solo nella classe
// ereditata e non in quella base
//e1.set_level(3);

     m1.set_level(4);
// chiamata ad un metodo definito nella classe base
// da un oggetto di tipo classe derivata
m1.set_dept(3);
// viene eseguito il metodo print della classe
// derivata
m1.print();
}

Per poter dare il permesso alle funzioni proprie delle classi ereditate di accedere ai membri della classe base si utilizza la clausola “protected” al posto della clausola “private”.
Il motivo per cui normalmente una funzione propria di una classe derivata non puo’ accedere agli elementi privati della propria classe base, e’dovuto al fatto che il concetto di elemento privato diverrebbe senza significato se vi fosse una funzione che prmettesse al programmatore di ottenere accesso alla parte privata di una classe semplicemente derivandone una nuova.

Possiamo inoltre notare che la funzione propria print della classe derivata  ridefinisce quella che era gia’ presente nella classe base; in sostanza e’ possibile specializzare il comportamento di una classe derivata semplicemente riscrivendo le funzioni di cui si vuole variare il comportamento.
Sara’ il compilatore poi a richiamare la funzione appropriata (quella della classe base oppure, se esiste, quella ridefinita nella classe ereditata).

Soffermandoci un attimo sulla fase di design del software, ci accorgiamo che solitamente le relazioni di tipo “e’ un” vengono espresse mediante l’ereditarieta’ (es. una automobile e’ un veicolo), mentre le relazioni di tipo “ha un” vengono espresse tramite la composizione (es. una automobile ha un motore).

Ereditarieta’, “costruttori” e “distruttori”

Se la classe di base da cui si eredita possiede un costruttore, allora questo deve essere richiamato anche nella classe derivata; se il costruttore richiede parametri questi devono essere forniti.

E’ infatti possibile, al momento della definizione del costruttore della classe derivata, specificare una chiamata al costruttore della classe base in modo da fornire gli argomenti necessari per l’inizializzazione.

Per chiarire il concetto, se questo e’ il costruttore della classe base:


// costruttore della classe base dipendente; richiede
// l’argomento a per inizializzare il campo age
dipendente::dipendente(int a)
{
age = a;
dept = 0;
salary = 0;
}

quando si definisce la classe derivata e’ necessario specificare il costruttore in questo modo:

// costruttore della classe derivata manager; richiede gli
// argomenti a, necessario per la classe base e l
// necessario per la classe ereditatata.
// Come si puo’ vedere l’argomento a viene direttamente
// passato al costruttore della classe dipendente mentre l
// viene utilizzato per inizializzare il campo level.
manager :: manager(int a, short l) : dipendente(a)
{
level = l;
}

Quando si compongono insieme piu’ oggetti appartenenti a classi differenti la sintassi di chiamata dei vari costruttori e’ simile:

MyClass::MyClass(int I) : compClass1(i), compClass2(I + 1)
{

}

Quando si istanzia un oggetto di una classe ereditata da un’altra prima viene eseguito il costruttore della classe base e poi il costruttore di quella derivata.
La distruzione avviene nell’ordine opposto: prima viene chiamato il distruttore della classe derivata e infine quell della classe base.

 

Gerarchie di classi
Una classe derivata puo’ essere a sua volta utilizzata come classe base per una nuova classe:

Class dipendente { … }
Class manager : public dipendente { … }
Class direttore : public manager { … }

La classe direttore ereditera’ in maniera gerarchica sia le proprieta’ della classe della classe manager (un direttore e’ un manager) e sia quelle della classe dipendente (un direttore e’ anche un dipendente).

Ereditarieta’ multipla
Una classe derivata puo’ essere creata ereditando da piu’ classi base e non da una sola; questa tecnica e’ chiamata Ereditarieta’ multipla (Multiple Inheritance) :

Class dipendente { … }
Class dipendenteTemporaneo { … }
Class segretaria : public dipendente { … }
// la classe segretariaTemp eredita sia dalla classe
// segretaria che dalla classe dipendenteTemporaneo
Class segretariaTemp : public segretaria, public dipendenteTemporaneo  

E’ necessario utilizzare con attenzione la tecnica dell’ereditarieta’ multipla perche’ da un lato si genera una gerarchia delle classi molto complessa che puo’ compromettere in parte la chiarezza e la manutenibilita’ del codice, ed anche perche’ si possono creare situazioni ambigue in seguito a certe caratteristiche delle classi ereditate.

Vediamo alcuni esempi:

Duplicazione di oggetti
Supponiamo di creare una nuova classe chiamata mi, ereditando da due classi base d1 e d2:

Class mi : public d1, public d2

Supponiamo inoltre che sia d1, che d2 siano state entrambe derivate da una classe base chiamata b:

Class d1 : public b
Class d2 : public b

In questo caso si verifica che sia d1 che d2 contengono al loro interno gli oggetti della classe b, e questo comporta che la classe mi contiene due volte al suo interno gli oggetti ereditati dalla classe b tramite le classi d1 e d2.
Si hanno quindi nella classe mi oggetti duplicati che provocano uno spreco di spazio ed introducono ambiguita’ nell’accesso ai membri della clase base, come nell’esempio seguente:


(listato 2.7)
class base
{
protected:
int b;
public:
base() { b = 0; }
void print() const { cout << b << endl; }
};

// classe d1 ereditata dalla classe base
class d1 : public base
{
public:
void print() const { cout << b << endl; }
};

// classe d2 ereditata dalla classe base
class d2 : public base
{
public:
void print() const { cout << b << endl; }
};

// il compilatore non permette di creare la classe
// mi perche’ non sa risolvere l’ambiguita creata
// nella ridefinizione della funzione membro print()
// infatti non puo’ risolvere l’argomento b che potrebbe
// essere quello della classe base b ereditata in d1
// oppure quello della classe base b ereditata in d2
class mi : public d1, public d2
{
public:
void print() const { cout << b << endl; }
};

Per risolvere l’ambiguita’ e’ necessario specificare a quale argomento b ci stiamo riferendo:

class mi : public d1, public d2
{
public:
void print() const { cout << d1::b << endl; }
};

Per una trattazione dettagliata degli ulteriori problemi che si possono verificare nell’utilizzo dell’ereditarieta’ multipla e le possibili soluzioni, si faccia riferimento al capitolo 22 (Multiple Inheritance) del testo “Thinking in C++” di Bruce Eckel.

 

Il “polimorfismo” e le funzioni virtuali

Il “Polimorfismo” (implementato in linguaggio C++ tramite le funzioni virtuali) e’ il terzo “pilastro” di un linguaggio di programmazione Object Oriented dopo l’astrazione dei dati e l’ereditarieta’.
Il polimorfismo spinge ancora piu’ avanti il concetto di separazione tra l’interfaccia e l’implementazione e permette di ottenere un codice ancora piu’ leggibile e manutenibile. Permette inoltre la creazioni di programmi che possono facilmente “crescere”, tramite l’aggiunta di nuove funzionalita’, in maniera estremamente veloce ed efficiente.
Le funzioni virtuali permettono ad una classe di distinguersi da un’altra simile anch’essa derivata dalla stessa classe di base; questa distinzione viene espressa attraverso differenze di comportamento delle funzioni che possono essere richiamate attraverso la classe di base.

Cerchiamo di chiarire il concetto con alcuni esempi:

(listato 2.8)
enum nota { DO, RE, MI, FA, SOL, LA, SI };

class StrumentoMusicale
{
public:
void suona(nota) const
{
cout << "StrumentoMusicale::suona" << endl;
}
};

// la classe AFiato deriva dalla classe
// StrumentoMusicale
class AFiato : public StrumentoMusicale
{
public:
// Ridefinisce la funzione suona
void suona(nota) const
{
cout << "AFiato::suona" << endl;
}
};


// la funzione accorda ha come argomento un oggetto
// di tipo StrumentoMusicale passato per riferimento
// ma chiaramente accetta anche l’indirizzo di un
// oggetto derivato dalla classe base
void accorda(StrumentoMusicale& i)
{
i.suona(LA);
}

int main()
{
AFiato flauto;
// questa funzione produce l’output corretto
flauto.suona(LA);
// la funzione accorda produce l’output sbagliato
// ovvero stampa il messaggio della classe base e non
// di quella derivata.
accorda(flauto);

  return 0;
}

 

“Early binding”  vs “Late binding”
Il problema consiste nel fatto che il compilatore non e’ in grado di capire (nel caso della chiamata alla funzione accorda) che sta ricevendo l’indirizzo di un oggetto appartenente ad una classe derivata e non alla classe base.
Questo comportamento e’ causato dalla tecnica di risoluzione delle chiamate a funzione denominata “Early binding” che deriva direttamente dal linguaggio C (utilizzata comunque normalmente in qualsiasi linguaggio non Object Oriented).
Questo tipo di gestione delle chiamate a funzione e’ risolto in fase di compilazione, quindi la funzione accorda() viene risolta dal compilatore prima dell’esecuzione del programma; dato che esaminando gli argomenti della funzione questa si aspetta un indirizzo di un oggetto di tipo StrumentoMusicale, quando poi verra’ eseguito il main, verra’ in effetti eseguita la funzione suona della classe base.
La soluzione e’ denominata “Late binding” ovvero “risoluzione a runtime” o “binding dinamico”.
Questo meccanismo permette al compilatore di ignorare al momento della compilazione l’argomento in input della funzione e di risolverlo a runtime, ovvero durante l’esecuzione del programma, tramite una struttura dati appositamente costruita in fase di compilazione.

Per segnalare al compilatore che vogliamo usare il “late binding” per un certa funzione il C++ richiede che venga usata l’istruzione virtual nel momento della dichiarazione della funzione.
Se una funzione e’ dichiarata virtuale nella classe base, sara’ considerata virtuale anche in tutte le classi derivate.
Per ottenere il comportamento corretto dall’esempio precedente e’ sufficiente rendere virtuale la funzione suona() all’interno della classe base:

class StrumentoMusicale
{
public:
virtual void suona(nota) const
{
cout << "StrumentoMusicale::suona" << endl;
}
};

Questa modifica fa in modo che il metodo suona() sia risolto a runtime e quindi il programma e’ in grado di accorgersi che sta ricevendo l’indirizzo di un oggetto appartenente ad una classe derivata dalla classe base StrumentoMusicale.

Una volta che la funzione suona() e’ stata definita come virtuale, e’ possibile aggiungere nuove classi senza che si debba in nessun modo cambiare la funzione accorda().
In un programma OOP ben progettato, la maggior parte delle funzioni seguira’ lo stesso modello della funzione accorda() e comunicheranno quindi solo con l’interfaccia della classe base.
Un programma concepito in questo modo e’ facilmente ampliabile e modificabile dato che e’ possibile aggiungere nuove funzionalita’ ereditando nuove classi dalla classe base.
Le funzioni che operereranno tramite l’interfaccia della classe base non avranno bisogno di essere minimamente modificate per poter operare con le nuove classi.

Es.
(seconda versione listato 2.8)
class StrumentoMusicale
{
public:
virtual void suona(nota) const
{
cout << "StrumentoMusicale::suona" << endl;
}
// aggiungiamo una nuova funzione virtuale
virtual char* cosa() const
{
return "StrumentoMusicale";
}
};


// aggiungiamo una nuova classe derivata dalla classe
// base
class ACorde : public StrumentoMusicale
{
public:
void suona(nota) const
{
cout << "ACorde::suona" << endl;
}
char* cosa() const { return "ACorde"; }
};

// aggiungiamo un ulteriore livello di ereditarieta’
class Violino : public ACorde
{
public:
void suona(nota) const
{
cout << "Violino::suona" << endl;
}
char* cosa() const { return "Violino"; }
};

// la funzione accorda rimane invariata
void accorda(StrumentoMusicale& i)
{
i.suona(LA);
}

int main()
{
AFiato flauto;
accorda(flauto);

  Violino Stradivari;
accorda(Stradivari);
cout << Stradivari.cosa() << endl;

  return 0;
}

Classi astratte e funzioni virtuali “pure”

Spesso accade che, per ragioni di design, si desideri che una classe di base serva solo da interfaccia per le classi derivate, ovvero che non sia possibile istanziare oggetti del tipo classe base ma solo utilizzarla come base per ereditare nuove classi.
Questo puo’ essere fatto rendendo una classe base “astratta” e fornendola di almeno una “funzione virtuale pura”.
Le funzioni virtuali pure si riconoscono dalla seguente sintassi:

virtual void X() = 0;

Se si prova ad istanziare un oggetto appartenente alla classe che contiene la funzione X(), si ricevera’ un errore di compilazione.
Quando una classe astratta viene ereditata tutte le funzioni virtuali pure devono ricevere una implementazione altrimenti anche la funzione ereditata diviene astratta.
La creazione di una funzione virtuale pura permette di dichiarare una funzione membro nell’interfaccia di una classe, senza essere obbligati a fornire una implementazione di comodo (che potrebbe contenere errori) e allo stesso tempo obbligando le classi ereditate a fornire una definizione della funzione.
Nell’esempio precedente, le funzioni contenute all’interno della classe base StrumentoMusicale non hanno un vero e proprio significato, ma servono solo per esprimere una particolare forma di interfaccia alle funzioni derivate e quindi puo’ essere convenientemente resa astratta, in modo da evitare di dover fornire implementazione per le funzioni Suona() o cosa().

 

Es.
// facciamo diventare la classe StrumentoMusicale una
// classe di base astratta
class StrumentoMusicale
{
public:
// funzioni virtuali pure:
virtual void suona(nota) const = 0;
virtual char* cosa() const = 0;
};

Il resto del programma rimane uguale in quanto le funzioni virtuali pure suona() e cosa() trovano la loro implementazione nelle classi derivate.

 

Costruttori, distruttori e funzioni virtuali
Il meccanismo delle funzioni virtuali non funziona per il costruttore, ovvero e’ necessario fornire sempre un’implementazione (inline) non virtuale anche per il costruttore di una classe base astratta:

// il costruttore non puo' essere virtuale
StrumentoMusicale()
{
cout << "Costr. StrumentoMusicale()" << endl;
}

Per quanto riguarda il distruttore e’ possibile utilizzarlo con le funzioni virtuali sebbene non possa essere definito puro e debba sempre avere un’implementazione:

 // il distruttore puo' essere virtuale
virtual ~StrumentoMusicale()
{
cout << "~StrumentoMusicale()" << endl;
}

 

Array statico di oggetti vs Array dinamico

#include <iostream>
#include <stdlib.h>
#include "automobile.hpp"

using namespace std;

int main(int argc, char *argv[])
{

int k = 0;
int i = 0;

  // array statico
automobile arrAuto[10];

cout << "Quante automobili ?";
cin >> k;
// array dinamico
automobile *dynamicArray = new automobile[k];

// array statico
for(i = 0;i < k;i++)
{
cout << "Produttore ?";
cin >> arrAuto[i].produttore;
cout << "Modello ?";
cin >> arrAuto[i].modello;
}

  // array dinamico
for(i = 0;i < k;i++)
{
cout << "Produttore ?";
cin >> dynamicArray[i].produttore;
cout << "Modello ?";
cin >> dynamicArray[i].modello;
}

  // array statico
for(i = 0;i < k;i++)
{
cout << i << " : " <<
arrAuto[i].produttore << " - "
<< arrAuto[i].modello << endl;
}

  // array dinamico
for(i = 0;i < k;i++)
{
cout << "(DYN) " << i << " : " <<
dynamicArray[i].produttore << " - "
<< dynamicArray[i].modello << endl;
}

  // Eliminazione array dinamico:
delete[] dynamicArray;

system("PAUSE");
return 0;
}

 

L’Overload di operatori

Il meccanismo dell’Overload (“Sovraccarico”) degli operatori in C++ consiste nella possibilita’ di definire un significato per gli operatori quando questi vengono applicati ad oggetti di una classe specifica.
Oltre agli operatori aritmetici e’ possibile definire chiamate (), meccanismi di indicizzazione [] e di dereferenziazione ->, oppure ridefinire le operazioni di assegnamento e inizializzazione.

Inizialmente si e’ tentati di utilizzare massicciamente questa tecnica dato che permette di scrivere del codice particolarmente sintetico ed elegante; bisogna sempre ricordare pero’ che l’overload degli operatori non e’ altro che un artifizio sintattico, ovvero semplicemente un modo alternativo di effettuare chiamate a funzione, ed andrebbe utilizzato solamente per rendere piu’ chiaro e leggibile il codice di un programma.
Un obiezione che solitamente viene fatta all’utilizzo dell’overloading e’ il fatto che possa cambiare il significato del codice che siamo abituati a leggere; questo non e’ vero perche’ in realta’ e’ possibile ridefinire il significato solo di operatori che lavorano su tipi (e quindi classi) definiti dall’utente.
Non e’ infatti possibile effettuare l’overload di operatori predefiniti del linguaggio che operano su tipi primitivi (es. non e’ assolutamente possibile ridefinire il significato dell’espressione 1 + 1 oppure 1 > 0).

Nell’esempio seguente viene creata una classe Integer (che incapsula il tipo primitivo int) per mostrare la tecnica dell’overload degli operatori + e +=.


(listato 2.9)
// Integer e’ il tipo definito dall’utente
class Integer
{
int i;
public:
// costruttore
Integer(int ii) { i = ii; }
// questa funzione definisce il comportamento
// dell’operatore di addizione (+)
const Integer operator+(const Integer& rv) const
{
return Integer(i + rv.i);
}
// questa funzione ridefinisce il comportamento
// dell’operatore di addizione / assegnamento (+=)
Integer& operator+=(const Integer& rv)
{
i += rv.i;
return *this;
}
int print() { return i; }
};

int main()
{
int i = 1, j = 2, k = 3;
k += i + j;
// sembrano interi ma in realta’ sono oggetti di tipo
// “Integer”
Integer I(1), J(2), K(3);
// utilizzo degli operatori ridefiniti
K += I + J;

// il risultato e’ 16 in entrambi i casi
return 0;
}

Sebbene sia possibile effettuare l’overload di quasi tutti gli operatori forniti dal C, ci sono alcune regole da seguire.
In particolare, non e’ possibile utilizzare operatori che non abbiano significato in C (ad esempio l’operatore ** per rappresentare l’elevazione a potenza), non e’ possibile cambiare l’ordine di valutazione degli operandi e non e’ possibile neanche variare il numero degli operandi di un operatore.
Queste regole servono per mantenere uniformita’ tra gli operatori che lavorano sui tipi primitivi e tra quelli che operano sulle classi definite dall’utente in modo da non creare ambiguita’ per l’utente che poi dovra’ utilizzare i nuovi operatori.

Gli operatori di incremento e decremento (++ e --) pongono un problema poiche’ e’ necessario definirne un comportamento diverso a seconda se sono utilizzati in forma prefissa o postfissa.
La soluzione consiste nel richiamare funzioni diverse a seconda del fatto che siano utilizzati prima o dopo un oggetto:

++a  genera una chiamata a operator++(a)
a++  genera una chiamata a operator++(a, int)

Operatori “speciali”

Molti operatori particolari utilizzano una differente sintassi quando vengono sottoposti all’overloading.

Solitamente l’operatore di indicizzazione, operator[ ], verra’ utilizzato per restituire un riferimento dato che l’oggetto su cui verra’ utilizzato si comportera’ come un array.

Es.
Char & operator[](int I);

Const char& string::operator[] (int I) const
{
return p->s[I];
}

L’operatore virgola (,) e’ richiamato quando appare accanto ad un oggetto del tipo per cui la virgola e’ definita, ma non e’ richiamato quando e’ usato per separare liste di argomenti durante una chiamata a funzione.

Es.
class After
{
public:
const After& operator,(const After&) const
{
return *this;
}
};

class Before {};


Before& operator,(int, Before& b)
{
return b;
}

int main()
{
After a, b;
a, b;  // chiamata all’operatore ,

  Before c;
1, c;  // chiamata all’operatore ,
}

L’operatore chiamata a funzione ( ) deve essere una funzione membro per la classe in cui e’ ridefinito ed ha la caratteristica di accettare qualsiasi numero di argomenti.

Anche gli operatori new e delete possono essere sottoposti all’overload. Quando il compilatore si accorge che e’ stato utilizzato l’operatore new per creare un nuovo aggetto appartenente alla classe definita dall’utente, viene richiamata la funzione membro operator new al posto della versione globale dell’operatore.
Chiaramente, per ogni altro tipo di oggetto, viene chiamata la funzione standard degli operatori new  e delete.

L’operatore operator->* puo’ essere ridefinito quando si voglia simulare il comportamento della sintassi predefinita “puntatore a membro”.

L’operatore operator->, altrimenti detto “smart operator” puo’ essere utilizzato per far apparire un oggetto di una classe come un puntatore.
Lo “smart pointer” deve essere ridefinito in una funzione membro e deve ritornare un oggetto (o reference all’oggetto) che abbia a sua volta uno “smart pointer”, oppure un puntatore che possa essere usato per selezionare cio’ che la freccia dello “smart pointer” sta puntando.

Nel seguente esempio si dimostrera l’applicazione dell’overload agli operatori ++ e ->:

(listato 2.10)
// classe oggetto
class Obj
{
static int j;
public:
void g() { cout << j++ << endl; }
};


// Definizione di alcune variabili statiche
int Obj::j = 11;
// in questo esempio la classe container puo'
// contenere fino a 100 oggetti
static const int sz = 100;

// Classe container di oggetti del tipo Obj
class ObjContainer
{
Obj* a[sz];
int index;
public:
ObjContainer()
{
index = 0;
memset(a, 0, sz * sizeof(Obj*));
}
void add(Obj* obj)
{
if(index >= sz) return;
a[index++] = obj;
}
friend class Sp;
};

// creiamo una classe in grado di "iterare"
// sugli oggetti della classe container;
// e' stata dichiarata friend di ObjContainer
// in modo da poter accedere agli oggetti contenuti
class Sp
{
ObjContainer* oc;
int index;
public:
Sp(ObjContainer* objc)
{
index = 0;
oc = objc;
}
// si definisce l'operatore ++
// 0 indica fine lista
int operator++()
{ // Utilizzo prefisso
if(index >= sz) return 0;
if(oc->a[++index] == 0) return 0;
return 1;
}
int operator++(int)
{ // Utilizzo postfisso
return operator++(); // E' uguale a quello prefisso
}
// si definisce l'operatore "smart pointer"
Obj* operator->() const
{
if(oc->a[index]) return oc->a[index];
static Obj dummy;
return &dummy;
}
};

int main()
{
const int sz = 10;

  // si dichiara un array di 10 oggetti di tipo Obj
Obj o[sz];
// dichiariamo un container per questi oggetti
ObjContainer oc;
// riempiamo il container con i 10 oggetti
for(int i = 0; i < sz; i++)
oc.add(&o[i]);
// creiamo un iteratore
Sp sp(&oc);

  do
{
// utilizzo dello "smart pointer" ridefinito
// per accedere alla funzione g() membro di Obj
sp->g();
} while(sp++); // operatore ++

  return 0;
}
Come si puo’ vedere all’interno del main( ), sebbene la classe sp non abbia come funzione membro la funzione g( ), il meccanismo dello “smart pointer” permette la chiamata alla funzione g( ) tramite il puntatore ad Obj ritornato dalla funzione di overload Sp::operator->.

 

L’overloading dell’operatore di assegnamento (‘=’)
E’ bene prestare molta attenzione all’operatore di assegnamento ‘=’, dato che si tratta di una operazione cosi’ fondamentale per il programmatore.
Vediamo alcuni esempi:

MyType b;
MyType a = b;
a = b;

Nella seconda linea l’oggetto a viene “istanziato”, ovvero viene creato un nuovo oggetto a di tipo “MyType”, fatto che in C++ comporta la chiamata del “costruttore di copia” o “copy constructor” (come gia’ spiegato nel capitolo 2.4).
Nella terza linea il comportamento e’ differente, in quanto a sinistra del segno di uguale abbiamo un oggetto che era gia’ stato inizializzato in precedenza e quindi il costruttore di copia non verra’ utilizzato.
In questo caso viene richiamata la funzione MyType::operator= per l’oggetto a, ricevendo come argomento qualsiasi cosa compaia alla destra del segno di uguale.
In generale, tutte le volte che si inizializza un oggetto tramite un segno di uguale invece della forma standard (es. MyType a;), il compilatore andra’ a cercare se esiste il costruttore di copia e se non esiste verra’ eseguito quello standard (copia bit a bit da un oggetto all’altro) .

La funzione operator= puo’ essere solo una funzione membro di una classe per evitare qualsiasi forma di ambiguita’ con l’operatore globale ‘=’ del linguaggio.
Quando viene creata una funzione che ridefinisce l’operatore di uguaglianza e’ necessario che il programmatore si occupi di copiare esplicitamente qualsiasi informazione presente nell’oggetto a destra del segno di uguale in modo da implementare l’operazione di “assegnamento” verso un oggetto appartenente alla propria classe.
Quando si hanno degli oggetti molto semplici l’implementazione non e’ difficile:

Es.
(listato 2.11)
// definiamo una semplice classe Value che contiene
// due interi ed un float
class Value
{
int a, b;
float c;
public:
Value(int aa = 0, int bb = 0, float cc = 0.0)
{
a = aa;
b = bb;
c = cc;
}
// ridefiniamo l’operatore di uguaglianza
Value& operator=(const Value& rv)
{
a = rv.a;
b = rv.b;
c = rv.c;
return *this;
}
// ridefiniamo anche l’operatore << in modo da
// poter usare facilmente cout per stampare I
// valori del nostro oggetto di tipo Value
friend ostream& operator<<(ostream& os, const Value& rv)
{
return os << "a = " << rv.a << ", b = "
<< rv.b << ", c = " << rv.c;
}
};

int main()
{
Value A, B(1, 2, 3.3);
cout << "A: " << A << endl;
cout << "B: " << B << endl;
A = B;
cout << "A dopo l’assegnamento: " << A << endl;
}

In questo esempio, l’oggetto alla sinistra del segno di uguale copia tutti gli elementi dell’oggetto alla destra e poi ritorna un riferimento a se stesso.

E’ chiaro che se gli oggetti non sono cosi’ semplici ridefinire l’operatore di assegnamento puo’ essere un’operazione piu’ complicata; ad esempio se gli oggetti in gioco contengono puntatori ad altri oggetti, probabilmente la tecnica utilizzata nell’esempio precedente non va piu’ bene (invece di allocare i nuovi oggetti, dopo la copia si avrebbero puntatori allo stesso oggetto).
Si potrebbe pensare di copiare qualsiasi oggetto referenziato dal puntatore nel momento in cui si effettua l’assegnazione, oppure si possono adottare altre strategie piu’ complesse.
In ogni caso, quando si progetta una classe, bisognerebbe sempre pensare ai possibili utilizzi che potrebbero farne gli utilizzatori e quindi pensare alle possibili strategie per gestire l’operatore di assegnamento.

 

I “template”

Un “template” in C++ puo’ essere definita come una classe di tipo “container” che in piu’ e’ indipendente dal tipo degli oggetti che dovra’ contenere.
Solitamente il tipo degli oggetti contenuti in una classe “container” non e’ importante per chi definisce la classe ma e’ fondamentale per chi utilizza un determinato contenitore.
L’idea e’ quindi quella di poter specificare come parametro della classe contenitore il tipo dell’oggetto contenuto.

Per chiarire meglio il concetto partiamo con un esempio che definisce una classe container di tipo stack che puo’ ospitare al suo interno oggetti di qualsiasi tipo.

Es. (2.12)
// si specifica che la classe stack e’ di tipo template
template<class T>
class stack
{
T* v;
T* p;
int sz;
public:
// costruttore
stack(int s) { v = p = new T[sz = s]; }
// distruttore
~stack() { delete[] v; }

     // il push riceve un valore di tipo T
void push(T a) { *p++ = a; }
// il pop ritorna un valore di tipo T
T pop() { return *--p; }

     int size() const { return p-v; }
};

// la classe complex viene inserita per mostrare
// che la classe container stack puo’ accogliere
// oggetti di qualsiasi tipo
class complex
{
double re, im;
public:
// e’ fondamentale l’importanza dei parametri di
// default nel costruttore, altrimenti quando
// invocato automaticamente dalla classe template
// verrebbero a mancare i valori per i due argomenti
complex(double r = 0, double i = 0) { re=r; im=i; }

  // ridefiniamo l’operatore << per poter stampare
// facilmente il contenuto di un oggetto di tipo complex
friend ostream&
operator<<(ostream& os, const complex& cv)
{
return os << "reale = " << cv.re << ",
imm. = " << cv.im;
}
};

int main()
{
// in questo momento il valore T nella definizione
// della classe stack viene sostituito con <int>
stack<int>*p = 0;
p = new stack<int>(10);

     p->push(1);
p->push(2);

     // la classe stack viene utilizzata per memorizzare
// sia int che double
stack<double>*pd = 0;
pd = new stack<double>(10);
pd->push(1.456);
pd->push(2.678);

     // si utilizza la classe stack anche per oggetti
// di tipo complex
stack<complex>*pc = 0;
// se il costruttore della classe complex non
// avesse i valori di default questa istruzione
// darebbe errore di compilazione
pc = new stack<complex>(10);

     pc->push(complex(3.5,2.9));
cout << pc->pop() << endl;

     return 0;
}

Il prefisso template <class T> specifica la dichiarazione di un template e il fatto che nella dichiarazione venga utilizzato un parametro T che poi verra’ sostituito in base al tipo che verra’ poi passato al momento della creazione di un oggetto della classe stack (si noti che il parametro T puo’ essere un qualsiasi tipo incluso il nome di una classe come nell’esempio precedente).

Template di funzioni

Oltre alle classi template e’ possibile definire funzioni template globali, cioe’ funzioni template che non sono proprie a una classe.
Una funzione template definisce una famiglia di funzioni cosi’ come una classe template definisce una famiglia di classi.

Illustriamo la tecnica tramite un esempio che definisce una funzione template globale sort che permette di effettuare l’algoritmo di bubble sort su differenti tipi di array:
(listato 2.13)
// la funzione template sort riceve come argomento un
// vettore ed il parametro T viene automaticamente
// sostituito dal compilatore con il tipo corrente
template<class T> void sort (vector<T>& v)
{
// algoritmo bubble sort
unsigned int n = v.size();
for (int i=0;i<n-1;i++)
for (int j=n-1;i<j;j--)
// swap degli elementi
if (v[j] < v[j-1])
{
T temp = v[j];
v[j] = v[j-1];
v[j-1] = temp;
}
}

 

int main()
{
// per implementare gli array utilizziamo
// una classe template predefinita denominata
// vector
vector<int> vi(3);
vi[0] = 25;
vi[1] = 47;
vi[2] = 12;
// si ordina un array di interi
sort(vi);

 

     vector<char> vc(3);

     vc[0] = 'c';
vc[1] = 'b';
vc[2] = 'a';
// si ordina un array di caratteri
sort(vc);

     return 0;
}

 Il metodo utilizzato permette di generalizzare con eleganza l’algoritmo di sort tramite la funzione template che presenta pero’ un problema: alcuni tipi non dispongono di un operatore < tramite il quale fare il confronto, mentre altri come char *, possiedono un operatore < che non esegue l’operazione necessaria all’algoritmo di ordinamento.
Per gestire questi casi e’ necessario effettuare l’overload dell’operatore < nel caso di una classe definita dall’utente, oppure specificare una implementazione corretta della funzione template per un certo tipo di dato:

Void sort(vector<char *>& v)
{

// stesso algoritmo ma lo swap diventa:
if (strcmp(v[j], v[j-1])<0)
{
char *temp = v[j];
v[j] = v[j-1];
v[j-1] = temp;
}
}

 

Gestione delle eccezioni

La gestione degli errori durante l’esecuzione di un programma e’ sempre stato uno dei punti deboli del linguaggio C e quindi, durante lo sviluppo del C++, si e’ voluto introdurre un nuovo meccanismo di intercettazione delle anomalie piu’ potente e comprensibile. 

“Lanciare” le eccezioni

La filosofia di base della gestione degli errori in C++ consiste nel mandare informazioni riguardanti la situazione di errore ad una parte dell’applicazione (“error handler”) dove questa anomalia possa essere riconosciuta e gestita.
Questo procedimento chiamato “exception throw” consiste nell’istanziare un oggetto appartenente ad una classe dedicata alla gestione delle eccezioni e inviarlo alla routine di gestione di errori tramite la parola chiave throw:

throw myerror(“Si e’ verificata una anomalia !”);

Myerror e’ una classe comune che accetta un char * come argomento; si puo’ utilizzare qualsiasi tipo quando si effettua il throw, ma spesso e’ comodo avere un messaggio che puo’ aiutare a capire il contesto in cui si e’ verificato l’errore.
Al momento dell’esecuzione dell’istruzione throw l’oggetto contenente le informazioni relative all’errore viene creato (durante la normale esecuzione senza errori tale oggetto non esiste) e ritornato alla routine di gestione delle eccezioni.
Solitamente, in alcuni blocchi critici del programma, si identificheranno una serie di istruzioni throw ognuna per una diversa tipologia di errore che si potra’ verificare a runtime.    

 
Intercettare le eccezioni

Se da una funzione viene “lanciata” una eccezione si suppone che questa venga intercettata da qualche parte e in base a questo una qualche sorta di strategia di gestione dell’errore venga attivata.
In effetti uno dei vantaggi della gestione delle eccezioni in C++ deriva dal fatto che si puo’ scrivere del codice senza preoccuparsi troppo dei possibili errori che si potrebbero verificare ad ogni chiamata a funzione e demandare comunque la gestione dei possibili errori ad una parte speciale del programma (il cosiddetto “error handler”).
Se una funzione “lancia” un eccezione tramite throw, il flusso del programma si interrompera’ immediatamente ed il controllo verra’ passato alla routine di gestione degli errori; se non si vuole questo effetto e’ necessario racchiudere la parte di codice da cui non si vuole saltare immediatamente in seguito ad un’eccezione all’interno di un blocco detto try.

try
{
// Codice che potrebbe generare eccezioni
}

Le eccezioni che vengono “lanciate” vengono poi intercettate da una routine di gestione degli errori che avra’ un handler per ogni tipo diverso di eccezione che si vuole intercettare:

try
{
// codice che puo’ generare eccezioni
}
catch(type1 id1)
{
// handle per eccezioni di tipo 1
}
catch(type2 id2)
{
// handle per eccezioni di tipo 2
}
// etc...

Una volta che il flusso del programma arriva all’interno di un blocco di gestione di una eccezione in genere il programma dovra’ terminare perche’ l’errore non e’ recuperabile; si segnalera’ quindi l’errore ed il contesto in cui si e’ verificato e si procedera’ poi alla chiamata di una funzione exit ritornando un codice di errore.

Quando si scrive del codice che puo’ generare eccczioni e’ sempre bene chiedersi se, al momento dell’attivazione di una eccezione, le risorse occupate verranno liberate correttamente.
Il piu’ delle volte il sistema e’ in grado di gestire il rilascio delle risorse correttamente, ma potrebbe verificarsi un problema con i costruttori; infatti se un eccezione e’ attivata prima del completamento di un costruttore, il distruttore associato all’oggetto non verra’ richiamato.
E’ chiaro quindi che occorre prestare particolare attenzione alla scrittura del codice all’interno del costruttore.

 

Principali funzioni della libreria standard C++

Le funzioni della libreria standard che erano state create per il linguaggio C sono utilizzabili anche in C++ con le stesse modalita’ (vedi capitolo 1.16).
E’ chiaro pero’ che nella fase di sviluppo del C++ si e’ voluto creare anche una nuova parte della libreria standard in cui le funzioni di libreria seguono la metodologia Object Oriented.
Le nuove funzioni (o meglio classi) sono state realizzate per potenziare il linguaggio C soprattutto nelle due aree piu’ utilizzate ed in cui l’utilizzo delle funzioni della libreria standard si rivelava piu’ complessa e macchinosa: le due aree sono la gestione dell’I/O e la manipolazione delle stringhe di caratteri.

 

I flussi di I/O

Dato che le operazioni di I/O sono la base di quasi tutte le applicazioni, la prima e piu’ importante libreria di classi standard per il C++ riguarda proprio la gestione di questa problematica; come si puo’ intuire dagli esempi visti fino ad ora il nome della libreria di I/O del C++ e’ iostream, mentre quella di I/O su file si chiama fstream.
Le principali classi fornite all’interno della libreria iostream sono:

Ostream         Gestisce il flusso di output (stream stdout e stderr del C)
Istream           Gestisce il flusso di input (stream stdin del C)
Ofstream        Gestisce l’output su file su disco
Ifstream         Gestisce l’input da file su disco
Sstream          Gestisce un flusso di dati di tipo stringa di caratteri
Ios                   Gestisce la formattazione dei flussi in I/O (i vari %d, %f del C)

Se uno stream e’ in grado di fornire input (istream), l’oggetto cin puo’ essere utilizzato insieme all’operatore >> per accedervi.

char I[100];
// I dati provenienti dallo stdin finiscono in i
Cin >> I;

Per inviare output ad un flusso (ostream), puo’ essere utilizzato l’oggetto cout insieme all’operatore <<.


// I dati presenti in I vengono inviati allo stdout
Cout << “I=” << I;

Gli operatori << e >> supportano una certa gamma di tipi primitivi e non (come la classe string); se e’ necessario utilizzare cin e cout per l’input/output su classi definite dall’utente e’ necessario effettuare l’overload di tali operatori (come nell’esempio 2.12).

Nell’esempio seguente illustreremo il funzionamento dei flussi ifstream e ofstream per le operazioni di I/O su file:

(listato 2.14)
// fstream e’ necessario per l’I/O su file
#include <fstream> 
#include <iostream>
using namespace std;

// classe per la gestione delle eccezioni
class fileErr {};

int main()
{
const int sz = 100;
char buf[sz];

  // racchiudiamo nel blocco try le operazioni
// di accesso ai file
try
{
// Apre il file in lettura
ifstream in("test.cpp");
// se la lettura non va a buon fine lancia un
// eccezione
if (!in)
throw (fileErr());
// Apre il file in scrittura
ofstream out("test.out");
// contatore per le righe del file
int i = 1;


    // Un primo metodo per leggere le righe di un file
while(in.get(buf, sz))
{
// la funzione get non legge il carattere \n
in.get();
// l'output su file o su video e' lo stesso
out << i++ << ": " << buf << endl;
}
}
// Alla fine del blocco ci pensano i distruttori
// a chiudere i file, oppure e’ possibile chiamare
// esplicitamente in.close() e out.close()

  // handler per le anomalie del blocco try
catch(fileErr)
{
cout << "Errore apertura file" << endl;
exit(-1);
}

  // rilegge il file prodotto
ifstream in("test.out");
// Altro metodo per leggere un file
while(in.getline(buf, sz))
{
// manda a video il risultato
cout << buf << endl; // aggiunge \n
}
return 0;
}

La classe ios e’ utilizzata per manipolare il flusso di input/output e permette quindi di eseguire una vasta gamma di operazioni di formattazione.

Es.

// output di campi in virgola mobile
// la modalita di output e’ settata a virgola mobile
// con precisione di 4 cifre dopo la virgola
cout.setf(ios::fixed, ios::floatfield);
cout.precision(4);
cout << 1234.45 << endl;

// verra’ stampato 1234.4500


La manipolazione delle stringhe
La gestione delle stringhe di caratteri in linguaggio C e’ sempre stata problematica a causa dell’implementazione del tipo stringa tramite un vettore di caratteri.
Questo portava ad un’ottima flessibilita’ per il programmatore esperto ma costringeva il principiante ad apprendere tutta una serie di concetti avanzati (gestione degli array, puntatori, gestione dinamica della memoria) per poter effettuare anche le piu’ elementari operazioni sulle stringhe.
La classe standard C++ denominata string e’ stata creata proprio per occuparsi (e quindi mascherare) tutte le operazioni di basso livello che devono essere effettuate sugli array di caratteri, spesso fonte di errori nei programmi in linguaggio C.
Per usare la classe stringa del C++ e’ necessario includere lo header file <string> ed utilizzare il namespace std.

L’utilizzo della classe, grazie all’overloading degli operatori, e’ quanto mai semplice ed intuitivo; infatti e’ possibile eseguire assegnazioni tra oggetti di tipo stringa utilizzando l’operatore =, e’ possibile concatenare stringhe utilizzando l’operatore + e perfino appendere una stringa in coda ad un’altra tramite l’operatore +=.
Inoltre la libreria iostream e’ stata progettata per operare semplicemente con la classe string ed e’ quindi possibile inviare direttamente a cout gli oggetti string per effettuarne la stampa.

Esempio:
(listato 2.15)
// header per la classe string
#include <string>
#include <iostream>
using namespace std;

int main()
{
// dichiariamo due oggetti stringa vuoti
string s1, s2;
// dichiarazione ed inizializzazione
string s3 = "Hello, World.";

  // altro tipo di inizializzazione
string s4("ciao");

  s2 = "oggi"; // assegnazione
s1 = s3 + " " + s4; // concatenazione
s1 += " proprio "; // append
// stampa di oggetti stringa
cout << s1 + s2 + "!" << endl;

  return 0;
}

Introduzione ad STL (Standard Template Library)

 

“Container” ed “Iteratori”

Durante la scrittura di varie applicazioni si presenta spesso il problema di non sapere a priori quanti (e quali) oggetti  dovranno essere allocati e per quanto tempo dovranno essere mantenuti, dato che le esigenze diverranno chiare solamente a runtime.
In ottica Object Oriented, la soluzione a questo tipo di problemi consiste nel creare un tipo particolare di oggetto chiamato “container” (o “collection”), il cui scopo sia quello di contenere in maniera totalmente dinamica altri tipi di oggetti oppure puntatori ad oggetti.
Gli oggetti container possono essere espansi a runtime e non e’ quindi necessario conoscere a priori il numero degli oggetti che dovranno contenere.
Il C++ fornisce una ottima libreria di oggetti container generici che possono adattarsi ad una vasta gamma di esigenze; la libreria e’ denominata  STL ovvero Standard Template Library.
La libreria STL contiene differenti tipi di classi contenitore che permettono di realizzare le piu’ svariate strutture dati come vettori, liste, tabelle di hash, code, alberi e cosi’ via.
Solitamente l’operazione di aggiunta di un elemento ad un container e’ realizzata tramite metodi “push”, “add” o simili ed e’ abbastanza uniforme indipendentemente dal tipo di container che stiamo utilizzando.
L’operazione di estrazione di un oggetto da un container puo’ essere invece abbastanza complessa perche’ molto influenzata dal tipo di struttura dati implementata dal container (ad esempio recuperare dati da un vettore tramite indicizzazione e’ ben diverso dal’estrarre un elemento da una lista o da una coda).
Per ovviare a queste problematiche e’ stato ideato un oggetto particolare detto “iteratore”, il cui scopo e’ quello di recuperare gli oggetti all’interno di un container e di restituirli all’utente sotto forma di una sequenza, indipendentemente dal tipo di container su cui si sta operando.

 

La libreria STL

La libreria C++ denominata STL e’ un potente insieme di oggetti container ed algoritmi realizzata per raggiungere la massima portabilita’ ed astrazione del codice.
L’utilizzo degli iteratori permette di raggiungere un alto livello di astrazione rispetto alla struttura dati su cui si sta operando ed e’ possibile usare gli oggetti e gli algoritmi di STL per operare indifferentemente su tipi primitivi oppure su oggetti creati dall’utente in modo da raggiungere la massima uniformita’ e generalizzazione del codice.
L’unico svantaggio di questo alto livello di astrazione ed indipendenza e’ rappresentato dal fatto che e’ necessario investire una certa quantita’ di tempo inizialmente per capire come opera STL; l’investimento e’ comunque limitato dato che una volta compresi i concetti base di STL, questi sono applicati in maniera uniforme ai diversi tipi di oggetti che e’ possibile gestire.

Vediamo adesso un esempio di come funzionano i container e gli iteratori STL utilizzati per contenere oggetti polimorfici:

(listato 2.16)
// e’ necessario includere vector per poter utilizzare
// questo tipo di container STL
#include <vector>
using namespace std;

// classe base shape
class Shape
{
public:
// metodo virtuale draw
virtual void draw() = 0;
// distruttore virtuale
virtual ~Shape() {};
};

// classe derivata Circle
class Circle : public Shape
{
public:
void draw() { cout << "Circle::draw\n"; }
~Circle() { cout << "~Circle\n"; }
};

// classe derivata Triangle
class Triangle : public Shape
{
public:
void draw() { cout << "Triangle::draw\n"; }
~Triangle() { cout << "~Triangle\n"; }
};

// classe derivata Square
class Square : public Shape
{
public:
void draw() { cout << "Square::draw\n"; }
~Square() { cout << "~Square\n"; }
};

// definiamo un tipo chiamato Container
// che contiene puntatori ad oggetti di tipo
// Shape ed e' implementato tramite un
// container vector di STL
typedef std::vector<Shape*> Container;
// dichiariamo anche un tipo iteratore Iter
// associato al container
typedef Container::iterator Iter;

int main()
{
// dichiariamo un vector di nome shapes
Container shapes;
// mettiamo dentro al vector 3 oggetti
// di classi ereditate da Shape
shapes.push_back(new Circle);
shapes.push_back(new Square);
shapes.push_back(new Triangle);

  // utilizziamo l'iteratore per scorrere
// la sequenza di oggetti; e’ possibile utilizzare
// l’operatore ++ grazie all’overload
for(Iter i = shapes.begin(); i != shapes.end(); i++)
// chiamata al metodo draw virtuale
// ridefinito in ogni classe derivata
// il metodo corretto viene richiamato
// grazie al "polimorfismo"
(*i)->draw();

  // il container va ripulito con delete
// dato che gli oggetti erano stati
// creati in maniera dinamica con new
for(Iter j = shapes.begin(); j != shapes.end(); j++)
delete *j;

  return 0;
}

Da notare che il loop che scorre tutti gli elementi di una sequenza utilizzando l’iteratore:

for(Iter i = shapes.begin();i != shapes.end(); i++)

deve utilizzare solo l’operatore ‘!=’ per testare la condizione di fine sequenza; ogni altro operatore non avrebbe funzionato.

E’ interessante notare che e’ possibile cambiare il tipo di container STL usato in questo esempio semplicemente modificando due linee di programma; volendo utilizzare una lista al posto di un vettore e’ sufficiente includere <list> al posto di <vector> e modificare la prima typedef in questo modo:

typedef std::list<Shape*> Container;

Questa e’ una dimostrazione dell’uniformita’ di interfaccia che presenta STL.

Il prossimo esempio dara’ una dimostrazione della potenza di STL nel manipolare un container di oggetti di tipo string:

(listato2.17)
#include <string>
#include <vector>
#include <fstream>
#include <iostream>
#include <iterator>
// libreria che gestisce un flusso di stringhe
#include <sstream>
using namespace std;

int main(int argc, char* argv[])
{
// legge un file specificato come parametro
// da linea di comando
ifstream in(argv[1]);
// dichiara un vettore di stringhe
vector<string> strings;

  string line;

  // riempie il vettore con le righe lette dal file
while(getline(in, line))
strings.push_back(line);

  // Aggiunge il numero di linea ad ogni riga letta
int i = 1;
// w e’ l’iteratore sul vettore di stringhe
vector<string>::iterator w;
for(w = strings.begin(); w != strings.end(); w++)
{
// utilizza il flusso di stringhe ostringstream
ostringstream ss;
ss << i++;
// da notare che tramite l’iteratore w stiamo
// modificando le stringhe all’interno del vettore
// strings
*w = ss.str() + ": " + *w;
}


  // Invia al flusso cout il vettore di stringhe
// aggiungendo ad ogni riga il \n
copy(strings.begin(), strings.end(),
ostream_iterator<string>(cout, "\n"));

// il vettore di stringhe viene ripulito
// automaticamente da STL una volta "out of scope"
}

E’ da notare come non si siano dovuti usare gli operatori new e delete in questo esempio per allocare le stringhe nel vector via via che venivano lette; e’ possibile infatti lasciare che il lavoro di gestione della memoria sia effettuato da STL se nel vector vengono memorizzati gli oggetti e non i puntatori.
E’ chiaro che non si puo’ utilizzare il polimorfismo come nell’esempio precedente senza i puntatori ma in piu’ abbiamo il vantaggio di non doverci preoccupare della gestione della memoria e del rilascio delle risorse.

STL non contiene solo una collezione di container, ma anche una serie di algoritmi che possono tornare utili in piu’ di una situazione; l’esempio seguente dimostra l’utilizzo dell’algoritmo replace( ) STL per sostituire le occorrenze di ‘X’ all’interno di una stringa con ‘Y’:

#include <string>
// per utilizzare gli algoritmi STL e’ necessario includere
// questo header file
#include <algorithm>
#include <iostream>
using namespace std;

int main()
{
string s("aaaXaaaXXaaXXXaXXXXaaa");
replace(s.begin(), s.end(), 'X', 'Y');
}

 

In questo capitolo e’ stata data solo una breve dimostrazione di quali siano le reali potenzialita’ di STL, dato che la libreria contiene decine di container e di algoritmi; per una trattazione piu’ completa ed approfondita fare riferimento al capitolo 20 del testo “Thinking in C++”.


 Parte 3 : Appendice

 

Indice dei listati a corredo del testo

Linguaggio C

 

Listato 1.1                  Primo programma C  
Listato 1.2                  Tipi, conversioni, operatori
Listato 1.3                  Funzioni con argomenti variabili
Listato 1.4                  Passaggio parametri per valore/indirizzo
Listato 1.5                  Passaggio di array a funzioni
Listato 1.6                  Vettori di puntatori
Listato 1.7                  Puntatori a funzione
Listato 1.8                  Allocazione dinamica della memoria
Listato 1.9                  Strutture
Listato 1.10                Argomenti della linea di comando
Listato 1.11                Gestione di data e ora

Linguaggio C++

 

Listato 2.1                  Allocazione dinamica memoria (new, delete)
Listato 2.2                  Passaggio parametri per “reference”
Listato 2.3                  Le classi
Listato 2.4                  Costruttori e distruttori
Listato 2.5                  Funzioni “friend”
Listato 2.6                  Ereditarieta’
Listato 2.7                  Ereditarieta’ multipla
Listato 2.8                  Le funzioni virtuali
Listato 2.9                  L’overload degli operatori (1)
Listato 2.10                L’overload degli operatori (2)
Listato 2.11                Overload dell’operatore di copia
Listato 2.12                Classi “template”
Listato 2.13                Funzioni “template”
Listato 2.14                I flussi di I/O
Listato 2.15                La classe “string”
Listato 2.16                Esempio STL (1)
Listato 2.17                Esempio STL (2)


Indice dei contenuti

 

Documentazione per il corso “Linguaggio C++ con introduzione al C”
Parte 1 : Linguaggio C
1.1        Introduzione al linguaggio C
1.2        Introduzione all’ambiente di sviluppo
1.3        Le caratteristiche fondamentali del linguaggio C
1.4        Tipi di dati e costanti
1.5        Conversioni di tipo
1.6        Array; dichiarazione inizializzazione ed uso
1.7        Operatori
1.8        Istruzioni condizionali e di gestione del flusso
1.9        Visibilita’ (Scope) e classi di memorizzazione
1.10       Funzioni
1.11       Funzioni ricorsive e con numero variabile di argomenti
1.12       Puntatori; puntatori ed array; array di puntatori; puntatori a funzione
1.13       Allocazione dinamica della memoria
1.14       Strutture ed Unioni (Struct ed Union)
1.15       Gestione argomenti da linea di comando
1.16       Le piu’ importanti funzioni della libreria standard
Parte 2 : Linguaggio C++
2.1        Introduzione al linguaggio C++
2.2        Le caratteristiche fondamentali del linguaggio C++
2.3        Principali differenze tra C e C++
2.4        Le classi in C++
2.5        L’ereditarieta’
2.6        Il “polimorfismo” e le funzioni virtuali
2.7        L’Overload di operatori
2.8        I “template”
2.9        Gestione delle eccezioni
2.10       Principali funzioni della libreria standard C++
2.11       Introduzione ad STL (Standard Template Library)
Parte 3 : Appendice
3.1        Indice dei listati a corredo del testo

 

Fonte: http://www.davidbandinelli.it/appunti/appunti_cc++.doc

 

 


 

 

Linguaggio C ++

 

 

Visita la nostra pagina principale

 

Linguaggio C ++

 

Termini d' uso e privacy

 

 

 

Linguaggio C ++