Nessun risultato. Prova con un altro termine.
Guide
Notizie
Software
Tutorial
  • Lezione 68 di 93
  • livello avanzato
Indice lezioni

Classi template

Su C++ è possibile sfruttare la parola chiave template per definire classi generiche, sfruttando i principi della programmazione generica: ecco come.
Su C++ è possibile sfruttare la parola chiave template per definire classi generiche, sfruttando i principi della programmazione generica: ecco come.
Link copiato negli appunti

Come visto nella lezione precedente per la definizione di funzioni, in C++ anche le classi possono essere template di modo da adattarsi all'impiego con tipi di dato differente.

Tra le molteplici applicazioni delle classi template, la più diffusa è ancora una volta la programmazione generica. Come esempio si considerino le classi contenitore che implementano strutture dati come liste, vettori o dizionari, già incluse nelle librerie standard, e che si avvalgono proprio della programmazione template.

La descrizione del funzionamento di tali classi richiede un'attenzione particolare e sarà oggetto delle lezioni successive, nelle quali esse saranno esaminate in maniera più approfondita.

In questa lezione prenderemo in esame un semplice esempio di base per la definizione di una classe generica che espleta il calcolo delle quattro operazioni aritmetiche su tipi numerici. In questo contesto accenneremo anche ad alcuni aspetti problematici della metaprogrammazione e agli strumenti che lo standard C++ predispone per la loro risoluzione.

Il listato seguente contiene la definizione di una classe template di nome GenericCalc. Anche in questo caso, il preambolo costituito dalla parola chiave template seguita da una lista di parametri typename NomeTipo tra parentesi angolari viene preposto alla definizione della classe. Il simbolo usato per il nome del parametro template, in questo caso T, viene usato nel contesto della classe al posto di un tipo specifico.

Ad esempio, la firma dei quattro metodi statici definiti in GenericCalc per il calcolo della somma, sottrazione, divisione e moltiplicazione di valori numerici utilizza T per definire il tipo del valore di ritorno e degli argomenti. Ciò consente di utilizzare questa classe per qualunque tipo di dato per cui siano definiti gli operatori +, −, * e / come mostrato nel listato seguente.

#include <iostream>
#include <string>
 
template <typename T>
class GenericCalc
{
public:
    static T sum(T arg1, T arg2)
    {
        return arg1 + arg2;
    }
     
    static T subtract(T arg1, T arg2)
    {
        return arg1 - arg2;
    }
     
    static T divide(T arg1, T arg2)
    {
        return arg1 / arg2;
    }
     
    static T multiply(T arg1, T arg2)
    {
        return arg1 * arg2;
    }
};
 
int main()
{
    int i1 = 2;
    int i2 = 6;
    int isum = GenericCalc<int>::sum(i1, i2);
    std::cout << i1 << " + " << i2 << " = " << isum << "\n";
     
    float f1 = 2.6f;
    float f2 = 6.8f;
    float fsum = GenericCalc<float>::sum(f1, f2);
    std::cout << f1 << " + " << f2 << " = " << fsum << "\n";
     
    return 0;
}

In questo esempio, il metodo sum è invocato con argomenti di tipo intero e valori in virgola mobile, sfruttando il medesimo meccanismo di istanziazione discusso nella lezione precedente.

Dichiarazione e definizione di metodi template

Per brevità, la definizione della classe generica GenericCalc è inclusa nel listato della funzione main, mentre di norma, essa dovrebbe essere definita in un proprio file header (.h).

In questo secondo caso, si tenga presente che l'implementazione dei metodi template della classe deve essere sempre e comunque inclusa nella sua dichiarazione; un metodo template infatti non deve mai essere definito in un file di implementazione (.cpp).

Ciò è necessario poiché il compilatore necessita della definizione completa del metodo per operare l'istanziazione, e pertanto essa deve essere presente in ogni unità di compilazione, che ricordiamo essere costituita da un file di implementazione (.cpp) più tutti i file header (.h) incorporati in base alle direttive date al preprocessore.

Definire un metodo template nello header file della classe di appartenenza soddisfa tale requisito e rende possibile la sua corretta istanziazione.

Restrizioni al processo di istanziazione

Per quanto minimale e perfettamente funzionante, ad una riflessione più attenta, risulta evidente che l'implementazione della classe GenericCalc è fin troppo generica. Nulla vieta infatti di passare come argomenti delle funzioni membro di GenericCalc tipi arbitrari, per i quali i quattro operatori aritmetici non sono definiti o applicabili.

Sebbene il compilatore sia in grado di rilevare tali problemi e segnalarli in fase di compilazione, esistono dei rivolti subdoli che possono dar luogo a comportamenti inattesi.

Come esempio, si consideri il caso del tipo std::string, per cui esiste un sovraccarico dell'operatore + con la valenza di concatenazione, ma non quelli degli altri operatori aritmetici, che risultano inapplicabili. Ciò determina un comportamento inconsistente, che può essere osservato nel frammento seguente:

int main()
{
    std::string s1("hello");
    std::string s2("world");
     
    // Ok
    std::string ssum = GenericCalc<std::string>::sum(s1, s2);
    std::cout << s1 << " + " << s2 << " = " << ssum << "\n";
     
    // Errore: operator* non definito per il tipo string
    std::string smul = GenericCalc<std::string>::multiply(s1, s2);
    std::cout << s1 << " * " << s2 << " = " << smul << "\n";
     
    return 0;
}

In questo caso, restando immutata l'implementazione di GenericCalc, l'uso improprio dei metodi ivi definiti con parametri di tipo std::string determina un'incongruenza. Il compilatore infatti, non avendo a disposizione un sovraccarico dell'operatore * adeguato per il tipo string, rileverà un errore critico nell'invocazione del metodo multiply, ma non avrà alcun problema ad istanziare il metodo sum.

Il problema che abbiamo davanti non è meramente una questione di sintassi. In realtà, è evidente che la classe GenericCalc è concepita per essere applicata solamente a tipi numerici. Ciò nonostante, un uso sprovveduto dei costrutti di metaprogrammazione rende possibile violare tale vincolo, esponendo l'implementazione della classe al rischio di usi impropri.

Fortunatamente, la capacità espressiva del sistema di metaprogrammazione di C++ è tale da consentire l'applicazione di opportune restrizioni al processo di istanziazione, garantendo un controllo minuzioso per ciò che riguarda l'imposizione dei vincoli di natura concettuale del nostro modello dati.

Ad esempio in questo caso, sarebbe formalmente corretto restringere l'istanziazione, anche parziale, dei metodi di GenericCalc ai soli tipi numerici, escludendo così di fatto tutte le altre tipologie di dato, anche qualora esse fossero dotate di sovraccarichi degli operatori +, −, * e /, poichè la loro semantica potrebbe non essere compatibile con quella degli operatori aritmetici tradizionali.

La libreria standard mette a disposizione delle classi template pronte all'uso per questo scopo. Il frammento seguente contiene una versione modificata della classe GenericCalc che di fatto restringe l'istanziabilità dei suoi metodi ai soli tipi interi (int, char, long eccetra) ed a singola (float) o doppia precisione (double):

#include <iostream>
#include <type_traits>
 
template <
    typename T,
    typename = typename std::enable_if<std::is_arithmetic<T>::value>::type
>
class GenericCalc
{
public:
    static T sum(T arg1, T arg2)
    {
        return arg1 + arg2;
    }
     
    static T subtract(T arg1, T arg2)
    {
        return arg1 - arg2;
    }
     
    static T divide(T arg1, T arg2)
    {
        return arg1 / arg2;
    }
     
    static T multiply(T arg1, T arg2)
    {
        return arg1 * arg2;
    }
};

Le modalità d'uso della classe e dei suoi metodi sono le stesse, ad eccezione del fatto che ogni tentativo di istanziazione, anche parziale, della classe con tipi di dato incompatibili verrebbe prontamente segnalato in fase di compilazione.

Ciò avviene perché in questo caso la classe usa due parametri template invece che uno. Il primo serve, come in precedenza, a identificare un tipo di dato numerico generico, mentre il secondo è un parametro condizionale che dipende logicamente dal primo, usato per restringere l'istanziazione ai soli tipi primitivi del linguaggio cui siano applicabili gli operatori aritmetici.

La criptica sintassi usata per la definizione del secondo parametro in effetti giustifica il diffuso pregiudizio sulla illeggibilità dei costrutti di metaprogrammazione di C++ da parte del programmatore.

Tuttavia, in questo semplice esempio, che si differenza dal primo praticamente per una sola riga di codice, sono racchiusi tutti o quasi i cardini della metaprogrammazione C++ come type_traits, SFINAE, e risoluzioni dei sovraccarichi. Quindi la difficoltà di comprensione è ben giustificata.

Nelle lezioni successive la sintassi usata in questo esempio verrà analizzata in modo più approfondito, dopo avere introdotto in modo graduale tutti i concetti propedeutici necessari per una comprensione più profonda del sistema di metaprogrammazione di C++ e delle sue potenzialità.

Ti consigliamo anche