Appunti C++

Appunti e tecniche utili nella programmazione in linguaggio C++.
Autore: Matteo Lucarelli
ultima versione su: matteolucarelli.altervista.org
Documento in costruzione

Strutture e Unioni

La struttura è un' entità utile per raggruppare informazioni di tipo differente. La seguente dichiarazione definisce, ad esempio, una struttura comoda per memorizzare dei dati anagrafici:

struct utente {
	char nome[128];
	char cognome[128];
	char cf[16];
};

I singoli campi di una variabile di tipo struttura saranno quindi selezionabili tramite l'operatore di selezione "." (punto), mentre l'nizializzazione sarà possibile "in un colpo solo":

// inizializzazione
utente u1 = { "mario", "rossi", "mrorss76h23w198w" };

// accesso agli elementi
printf("Nome Utente:%s\n", u1.nome );

// assegnazione
utente u2=u1;

Le strutture risultano molto comode in associazione agli array:

utente ListaUtenti[1000];

//.. popolamento lista ..//

for (i=0;i<countUtenti;i++) printf("Nome Utente %i : %s\n",i, ListaUtenti[i].nome );

Le strutture si possono quindi pensare come classi composte solo da membri pubblici (in effetti è la classe ad essere un'estensione del concetto di struttura). Per tale motivo l'uso del C++ rende inutile il concetto di struttura, completamente sostituibile dalla classe.

Le unioni sono sintatticamente identiche alle strutture, con la differenza dell'uso della parola chiave union in luogo di struct:

union utente {
	char codice[128];
	char nome[128];
	char nickname[128];
};
La differenza sta nel fatto che in una unione i vari campi vengono memorizzati a partire dallo stesso inidirizzo, e non sono quindi utilizzabili contemporaneamente. Le unioni vengono quindi utilizzate per memorizzare dati differenti in modo esclusivo, oppure per ottimizzare l'utilizzo della memoria.

Enumerazioni

I tipi enumerativi vengono utilizzati laddove sia necessario stabilire rigidamente il range di valori che una certa variabile può assumere. Ad esempio:

// definizione del tipo enumerativo
enum Mese { 
	gennaio, 
	febbraio, 
	marzo, 
	aprile, 
	maggio, 
	giugno, 
	luglio, 
	agosto, 
	settembre, 
	ottobre, 
	novembre 
};

// definizione ed assegnazione di una variabile enumerativa
Mese mesecorrente = marzo;

I tipi enumerazione sono rappresentati internamente tramite l'associazione ad un intero, che può anche essere assegnato ed utilizzato esplicitamente:

enum giorno {
	lun=1,
	mar=2,
	mer=3,
	gio=4,
	ven=5,
	sab=6,
	dom=7
}

// l'assegnazione ad un intero è possibile
int mumeromese = mesecorrente;
L'utilizzo di tipi enumerativi è una delle tecniche di programmazione sicura, permettendo un rigido controllo sul range valori che possono essere assunti da una variabile.

Funzioni inline e Macro

La suddivisione di un programma in funzioni è utile (o meglio necessaria) per molti motivi, anche se questo penalizza in qualche misura le prestazioni. Quando si utilizzano frequenti chiamate a funzioni brevi è possibile ricorrere alla definizione di funzione inline:

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

Il vantaggio sta nel fatto che la funzione inline viene sostituita alla sua chiamata nella fase di compilazione, evitando quindi l'overhead dovuto alla reale chiamata runtime. Va notato comunque che non tutte le funzioni possone essere utilizzate in questo modo, cosa particolarmente vera per le funzioni ricorsive.

Un'altra possibilità è data dalla definizione di macro:

#define max(a,b) a > b ? a : b 
che, come tutte le direttive del preprocessore, viene sostituita alle relative chiamate prima della fase di compilazione. L'uso delle macro nosconde comunque qualche insidia, ad esempio:
// definizione errata ( quad(x+y) verrebbe espanso in x+y*x+y )
#define quad(a) a*a

// definizione corretta
#define quad(a) (a)*(a)

L'utilizzo delle macro e delle funzioni inline è spesso alternativo. Le differenze principali sono:

Define e Costanti

Il linguaggio C fornisce due differenti sintassi per l'uso di valori costanti. La prima prevede l'uso della parola chiave const:

const double pi = 3,141592654;
Mentre la seconda si appoggia alla direttiva define del preprocessore:
#define pi 3,141592654
Le differenze tra le due forme sono sostanzialmente le stesse viste tra macro e funzioni inline. Per quanto riguarda le costanti numeriche definite tramite la direttiva #define è comunque sempre consigliabile, per evitare ambiguità, l'uso degli specificatori di tipo:
#define INT_CONST 12345
#define LONG_CONST 12345L
#define UNSIGNED_CONST 12345U
#define FLOAT_CONST 1.2345F
#define DOUBLE_CONST 1.2345

Nell'utilizzo di puntatori e costanti la posizione dello specificatore const non è indifferente, e porta a due significati completamente differenti:

float* const p;  // puntatore costante a float (il puntatore è costante)
const float* p;  // puntatore a costante float (l'oggetto puntato è costante)

Overloading di funzioni ed operatori

Tramite l'overloading è possibile attribuire allo stesso nome di funzione piu` significati. Tale tecnica risulta molto comoda nel caso ad esempio in cui siano differenti i tipi restituiti, oppure il numero di parametri:

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

float somma(float a, float b) { return a + b; }
double somma(double a, double b) { return a + b; }

Si noti che i parametri delle varie versioni della funzione devono essere differenti, perchè le varie versioni vengono distinte proprio in base ai parametri forniti, nel caso in cui nessuna delle varianti ammetta gli stessi tipi, se possibili, verranno utilizzate le consuete regole di casting.

Gli operatori possono essere pensati come particolari tipi di funzioni. Il linguaggio C non permette di definire nuovi operatori, ma quelli esistenti possono, come delle funzioni, essere ridefiniti o sovraccaricati. Esistono comunque alcune limitazioni, ad esempio non tutti gli operatori ammettono l'overloading, tra questi l'operatore ternario "?:", il "." e gli operatori di cast. Non è inoltre possibile ridefinirne l'associatività o in numero di argomenti, quindi un operatore unario rimarra`sempre unario ed analogamente uno associativo a sinistra rimmarra sempre associativo a sinistra.

Un operatore e` indicato dalla keyword operator seguita dal simbolo dell'operatore. Il seguente esempio illustra l'estensione dell'operatore somma ai numeri complessi:

// definizione del numero complesso
struct complesso{
	double r;
	double i;
};

// overloading dell'operatore "+"
complesso operator+ (complesso a, complesso b) {
	complesso ret;
	ret.r = a.r + b.r ;
	ret.i = a.i + b.i ;
	return ret;
}

// quindi si potrà scrivere
complesso c1 = { 0.3 , 5 };
complesso c1 = { 7 , 5.2 };
complesso c3=c1+c2;

Come si vede la possibilità risulta molto comoda, anche se va usata con attenzione visto che nasconde molti tranelli, ad esempio alcuni operatori, come il "-" unario e binario, sono in effetti già sovraccaricati.

Il passaggio dei parametri per reference

Gli operatori * e & sono in qualche caso utilizzabili in alternativa. Per passare dei valori modificabili ad una funzione è infatti possibile utilizzare le due sintassi equivalenti:

void funzione(int *parametro);
void funzione(int ¶metro);
Nel primo caso viene passato il classico puntatore, mentre nel secondo il valore viene passato per riferimento, quindi è trattato come una normale variabile ma le modifiche influenzeranno il valore anche al di fuori della funzione. Il vantaggio nel secondo caso è dato dal fatto che nella chiamata non c'è differenza con un normale passaggio per valore, tuttavia il passaggio per riferimento nasconde al chiamante il fatto che si stia in effetti passando un indirizzo, e non una copia, creando così potenziali problemi in fase di debugging.

Il passaggio per reference costante permette però di evitare la copia temporanea della variabile, ottimizzando le prestazioni:

void funzione(const int ¶metro);

I namespace

Per evitare ambiguità dovute all'utilizzo degli stessi nomi in parti diverse de codice (magari scritte da persone diverse) è possibile utilizzare i namespace, ovvero definire gli ambiti di validità di un certo set di definizioni.

namespace n1 {
float pi = 3.14;
//.. altre funzioni e variabili ..//
}

namespace n2 {
double pi = 3.14159;
//.. altre funzioni e variabili ..//
}

// utilizzo
float va1 = n1::pi;      //3.14
double va2 = n2::pi;    // 3.141592

L'uso è analogo per quanto riguarda le funzioni.

Nel caso in cui non esistano possibilità di conflitto è possibile utilizzare la direttiva using, che evita il continuo ricorso alla specifica del namespace utilizzato. Nell'esempio viene anche illustrato lo scope della direttiva using, analogo a quello della definizione di variabili:

using namespace n1;
float va1 = pi;      //3.14
{
	using namespace n2;
	double va2 = pi;    // 3.141592
	float va1=n2::pi    // 3.14
}

Come si vede per risolvere eventuali l'ambiguità basta qualificare totalmente il nome, tornando ad utilizzare il risolutore di scope (::).

Extern e Static

Gli specificatori extern e static vengono utilizzati per modificare il tempo di vita e l'ambito di visibilità di variabili e costanti. Il loro comportamento è differente a seconda degli tipo di oggetti sui quali vengono applicati.

Le variabili locali possono essere dinamiche (cioè cessano di esistere al di fuori del loro scope), oppure statiche (cioè conservano il valore dell'ultimo accesso), il comportamento di default è il primo, quindi:

void funz(){

	// count esiste solo all'interno della funzione
	// ma mantiene il valore tra chiamate successive
	static int count;

	// ...
}

Le variabili globali (quindi definite all'esterno di ogni blocco) sono necessariamente statiche, in tal caso lo specificatore static serve a limitarne la visibilità all'interno del file di definizione. Se non sono definite statiche vengono rese visibili in altri blocchi del programma tramite lo specificatore extern. Tra i vari file oggetto del programma ne deve esistere uno, ed uno solo, in cui la variabile non è definita extern:

// file 1
int globInt;

// file 2 - utilizza globInt del file1
extern int globInt;

// file 3 - errore di definizione multipla
int globInt;

Le costanti globali invece hanno di default visibilità limitata al file nel quale sono definite, per renderle globali devono essere definite extern anche nel file che le inizializza. Anche in questo caso l'inizializzazione deve e può essere solo una:

// file 1
extern const int globConst1=1234;
const int globConst2=5678;

// file 2 - usa la costante inizializzata da file 1
extern const int globConst1;

// file 3 - errore: globConst2 non è globale
extern const int globConst2;

Per quanto riguarda le classi i valori membro definiti static esistono indipendentemente dall'istanza e sono condivisi tra le differenti istanze. Devono inoltre essere inizializzati globalmente:

// file prova.h
class prova{
	public: static c;
}
prova::c=0;

// in un altro file
include <prova.h>

prova p1,p2;

// il valore modificato è sempre lo stesso
p1.c=1;
p2.c=10;
prova::c=100;

La gestione delle eccezioni

La completa intercettazione degli errori che possono verificarsi durante l'esecuzione di una programma non è un compito facile. Per le cosiddette eccezioni, cioè eventi particolarmente rari e difficilmente prevedibili, ci viene incontro un particolare meccanismo del linguaggio, chiamato appunto gestione delle eccezioni.

Una funzione che utilizzi la gestione delle eccezioni si presenterà in questo modo:

float dividi(int a, int b) throw(char*){
	if (b!=0) return ((float)a/b);
	throw "Errore: divisione per zero";
}
dove la prima throw, facoltativa, serve a segnalare il tipo di eccezione che può essere sollevata dalla funzione (in questo caso una stringa, ma potrebbe anche essere un elenco), mentre la seconda viene eseguita all'effettivo verificarsi dell'errore. Il vantaggio, rispetta ad esempio ad un valore di ritorno che codifichi un errore, è dato dal fatto che il chiamante non può ingnorare un'eccezione, che eventualmente verrà gestita in modo automatico. Inoltre le risorse occupate dal codice che ha generato l'errore vengono liberate in modo automatico. Si noti che la funzione non è tenuta a ritornare alcun valore in caso di sollevamento di una eccezione, mentre non ci sono restrizioni sul tipo ritornato dall'eccezione.

L'eccezione sollevata deve quindi evidentemente essere gestita. L'intenzione di catturare e gestire l'eventuale eccezione viene segnalata al compilatore utilizzando un blocco try:

//..in un'altra zona del programma..//
try{
	dividi(x,y);
}
mentre la vera e propria gestione dell'eccezione viene specificata con un blocco catch (detto exception handler), che viene chiamato al verificarsi dell'errore:
catch(char* message) {
	cout << message << endl;
	// .. altro codice per recuperare l'esecuzione ..//
}
Ogni blocco catch potrà gestire un singolo tipo di eccezione, quindi andrà previsto un blocco per ogni tipo che possa verificarsi. Le catch inoltre devono seguire immediatamente il blocco try.

Esiste anche la possibilità di inserire una catch generica, indipendente dal tipo di eccezione sollevata:

catch(...) {
	//.. gestione generica dell'errore ..//
}

Quando viene generata una eccezione (throw) il controllo risale indietro fino al primo blocco try. Gli oggetti memorizzati sullo stack fino a quel momento nei vari blocchi annidati da cui si esce vengono distrutti. Nel momento in cui si giunge ad un blocco try anche gli oggetti staticamente allocati fino a quel momento dentro il blocco vengono distrutti ed il controllo passa alla fine del blocco. Questo procedimento viene chiamato stack unwinding (srotolamento dello stack). Il tipo dell'eccezione viene quindi confrontato con i parametri delle catch che seguono la try. Se viene trovata una catch del tipo corretto, si passa ad eseguire le istruzioni contenute in quel blocco. A questo punto l'eccezione viene considerata gestita e il controllo passa alla prima istruzione che segue la lista di catch.

E' inoltre possibile passare esplicitamente l'eccezione ad un blocco try ancora piu` esterno. Per fare ciò basta utilizzare throw (senza argomenti) all'interno del blocco catch

try {
	//...
}
catch(...) {
	//...
	throw; 
}

Nel caso in cui il tipo do eccezione lanciata dalla trow non sia previsto da nessuna catch viene eseguita la funzione predefinita unexpected(), che per default chiama la funzione, sempre predefinita, terminate() che provoca l'uscita del programma. Nel caso in cui invece non sia possible trovare un blocco try la funzione terminate() viene chiamata direttamente. E' possibile alterare tale comportamento ridefinendo le funzioni da chiamare, utilizzando allo scopo le funzioni set_unexpected e set_terminate:

#include <exception>

void customUnexpected() {

	//.. codice ..//
	
	abort();
}

set_unexpected(customUnexpected);
Le funzioni che sostituiscono unexpected() e terminate() non devono ritornare, cioè devono terminare l'esecuzione del programma.

matteolucarelli.altervista.org
©opyright info