Funzioni: concetti avanzati

16 dicembre 2016

Il costrutto di funzione in C++ presenta alcune caratteristiche proprie rispetto l’analogo in linguaggio C, che ne agevolano l’utilizzo in molti contesti. In questa lezione esamineremo quelle usate più frequentemente.

Parametri opzionali

La definizione di una funzione che implementi un dato algoritmo o computazione, dovrebbe sempre esporre al chiamante tutti i parametri che concorrono alla determinazione del risultato finale, evitando quindi di usare parametri hard-coded che ne vincolino l’applicazione. Tuttavia, per alcuni di questi parametri è possibile stabilire a priori i valori che saranno usati più di frequente, o che si adattano al maggior numero di casi d’uso dell’algoritmo in questione.

In altri casi, invece, non tutti i parametri di input concorrono nella determinazione del risultato finale. Ad esempio, a volte i parametri passati per indirizzo sono usati per restituire un valore di ritorno aggiuntivo, di cui non sempre è necessario tenere conto.

Il linguaggio C++ offre la possibilità di definire i cosiddetta parametri opzionali, con ciò intendendo che il chiamante non è tenuto ad indicare un valore per essi e può lasciare che vengano applicati quelli predefiniti, inclusi nella dichiarazione della funzione con la seguente sintassi:

<tipo> <nome_funzione> (<tipo> <nome_opt_arg1> = <valore>);		

L’esempio seguente mostra il caso di una funzione che calcola la divisione tra due numeri interi e restituisce il resto tramite un parametro opzionale di tipo puntatore a intero.

#include <iostream>

int divisione(int dividendo, int divisore, int* resto = nullptr)
{
	int risultato = dividendo / divisore;
	
	if (resto != nullptr)
	{
		*resto = dividendo % divisore;
	}

	return risultato;
}

int main()
{
	int a = 10;
	int b = 3;
	int c = 0;
	int d = divisione(a, b, &c);

	std::cout << a << " / " << b << " = " << d << " col resto di " << c << std::endl;
	std::cout << a << " / " << b << " = " << divisione(a, b) << std::endl;			

	return 0;
}		

Quando per il parametro opzionale è fornito un indirizzo valido, esso è usato per immagazzinare il valore del resto. In caso contrario, il resto non viene calcolato.

Qual è il vantaggio rispetto all’uso di due distinte funzioni, una per la divisione e una per il resto, chiamate all’occorrenza? A parte la sintesi del codice, c’è anche il beneficio non banale di ottimizzare l’uso della memoria a stack. Ogni chiamata a funzione, infatti, provoca un cambio di contesto e la creazione di un nuovo stack frame. Queste operazioni, se eseguite di frequente, pesano più della copia del parametro opzionale e del tempo richiesto per valutare la necessità di calcolare il resto o meno.

Overloading

L’overloading delle funzioni (talvolta detto anche sovraccarico) è una tecnica che consente di riutilizzare lo stesso nome per più di una funzione. L’unico vincolo consiste nel garantire al compilatore la possibilità di desumere quale funzione in particolare usare di volta in volta.

Ciò è possibile quando il tipo di uno o più parametri di input differisce tra una funzione e l’altra. Nell’esempio seguente supponiamo di voler definire una seconda funzione divisione per calcolare la divisione in virgola mobile, i cui parametri ed il valore restituito sono tutti di tipo float.

#include <iostream>

int divisione(int dividendo, int divisore, int* resto = nullptr)
{
	// come sopra...
}

float divisione(float dividendo, float divisore)
{
	return dividendo / divisore;
}

int main()
{
	std::cout << "divisione intera:" << divisione(10, 3) << std::endl; // ok: int e int
	std::cout << "divisione in virgola mobile: " << divisione(10.0f, 3.0f) << std::endl; // ok: float e float

	//int a = divisione(10.0f, 3);   // errore: float e int
	//int b = divisione(10.0, 3.0);  // errore: double e double 

	return 0;
}		

Per le prime due istruzioni della funzione main il compilatore è in grado di desumere la versione corretta in base al tipo dei parametri. La terza e la quarta istruzione sono invece commentate poiché produrrebbero errori di compilazione: infatti, il tipo dei parametri forniti è differente da quello previsto per le due funzioni.

Se fosse definita solo una delle due funzioni, come nell’esempio precedente, l’ambiguità verrebbe risolta a tempo di compilazione con un cast, cioè una conversione, al tipo richiesto dalla firma della funzione. La presenza di più funzioni, come in questo caso, rende “rischioso” lasciare che il compilatore selezioni automaticamente una delle due, e per questo motivo la compilazione del listato viene terminata.

È importante notare che il tipo del valore restituito non influisce nella selezione della funzione sovraccaricata. Quando si assegna il valore di ritorno di una funzione, infatti, può capitare che il tipo della variabile assegnata non corrisponda. Tuttavia ciò non sempre si traduce in un errore di compilazione. In C++ esistono dei meccanismi di inferenza che consentono al compilatore di operare autonomamente una conversione di tipo, secondo criteri definiti dallo standard. Ad esempio, una variabile double può essere convertita in float, e poi in int, in maniera automatica, a scapito della precisione del valore rappresentato. Questo è il motivo per cui la selezione di una funzione sovraccaricata non può basarsi solo sul tipo restituito.

Le stesse regole di inferenza, tuttavia, entrano in gioco anche per la valutazione degli argomenti in input. L’esempio seguente mostra l’importanza della conoscenza dell’esistenza di questi meccanismi, proponendo un caso particolare in cui due funzioni, etrambe di nome test, presentano una firma distinta, una con un argomento booleano e l’altra con un argomento di tipo std::string.

#include <iostream>

void test(bool x)
{
	if (x)
	{	
		std::cout << "Sorpresa!" << std::endl;
	}
}

void test(std::string x)
{
	std::cout << x << std::endl;
}

int main()
{	
	test("Quale funzione verrà invocata?");
	return 0;
} 	

L’invocazione della funzione test con un argomento di tipo letterale stringa si traduce in una chiamata al primo sovraccarico, trattando di fatto il parametro come un valore booleano. Questo perchè il compilatore, applicando la sequenza di conversioni previste dallo standard, converte prima il letterale stringa in un puntatore ad una variabile di tipo char, ed in seguito tale puntatore viene convertito in un valore booleano. La scritta che comparirà a schermo sarà quindi la già anticipata “Sorpresa!”.

Sebbene esista una corversione predefinita tra letterale striga ed il tipo std::string, la conversione a bool è sempre preferita, in caso di ambiguità, per via della retrocompatibilità di C++ con C. Se così non fosse, usare un compilatore per C++ su dei listati in puro C, ad esempio per una libreria da includere come dipendenza della nostra applicazione, produrrebbe una sequenza di errori incomprensibili.

Alcune ambiguità possono infine manifestarsi anche quando i metodi sovraccaricati presentano valori opzionali; in questo caso, tuttavia, è probabile che il compilatore ci informi della necessità di esplicitare quale funzione intendiamo usare, producendo un messaggio di errore piuttosto che operare una scelta in modo silente.

Funzioni e procedure

In C++, a differenza di altri linguaggi, esiste una differenza solo teorica tra funzioni e procedure. Queste ultime, nell’ambito della programmazione procedurale “pura”, sono dei blocchi di codice che espletano una determinata operazione senza produrre un valore di ritorno. Le funzioni, invece, mutuando il concetto matematico di relazione, associano sempre ad un elemento del proprio dominio un elemento del codominio: producono cioè un valore di ritorno. In C e C++, l’ambiguità è risolta grazie all’esistenza della parola chiave void: una procedura è quindi un particolare tipo di funzione il cui tipo di ritorno è void. Ciò significa, per il compilatore, che la funzione non restituisce alcun valore.

L’eredità del linguaggio C

Sebbene il costrutto di funzione in C++ presenti delle caratteristiche proprie ed originali, è innegabile la diretta discendenza dal linguaggio C.

Buona parte dei costrutti sintattici è infatti del tutto identica, compresi alcuni che abbiamo volutamente tralasciato, come quello della definizione dei puntatori a funzione.

Questi costrutti sono presenti per le già citate ragioni di compatibilità e sono indubbiamente utili e irrinunciabili, ma le revisioni più recenti dello standard ne sconsigliano l’applicazione diretta in virtù della definizione di nuovi strumenti sintattici, come classi, template e librerie standard, che vedremo nel seguito.

Dinnazi all’agilità propria del C, la verbosità (e complessità) di questi costrutti in C++ potrebbe sembrare gratuita. In realtà, essa è giustificata dal fatto di rendere possibile la determinazione di errori concettuali, anche gravi, già a tempo di compilazione piuttosto che a tempo di esecuzione, risparmiando allo sviluppatore ore di faticosa caccia al bug.

Tutte le lezioni

1 ... 13 14 15 ... 53

Se vuoi aggiornamenti su Funzioni: concetti avanzati inserisci la tua e-mail nel box qui sotto:
Tags:
 
X
Se vuoi aggiornamenti su Funzioni: concetti avanzati

inserisci la tua e-mail nel box qui sotto:

Ho letto e acconsento l'informativa sulla privacy

Acconsento al trattamento di cui al punto 3 dell'informativa sulla privacy