1. Deduzione del tipo: che cos'è e perché serve
Se si guarda ai template con gli occhi di un principiante, nasce il naturale desiderio: «non ho studiato template per poi scrivere ovunque a mano <int>». E avete ragione: nella maggior parte dei casi il compilatore sa effettivamente dedurre da solo il tipo T dagli argomenti della chiamata.
Ma ci sono anche momenti in cui il compilatore si alza le spalle e dice: “non capisco quale T vuoi”. Ed è qui che comincia il tipico dolore: il template sembra semplice, ma l'errore sembra un romanzo di 10 pagine.
Tecnicamente questo si chiama template argument deduction — deduzione degli argomenti del template. Nei rapporti del working group sullo standard compaiono regolarmente temi come “class template deduction” e “deduction failure” — la questione è così importante che la si affina da anni anche a livello di standard.
Costruiamo un modello chiaro, così da poter:
- predire in anticipo se T verrà dedotto o no;
- riparare rapidamente un "non dedotto" usando <T> esplicito;
- capire perché T& non è solo un "reference", ma un contratto stringente.
2. Modello di base: da dove il compilatore ottiene T
Immaginate il compilatore come un detective a cui avete dato un template e una chiamata. Il suo compito è trovare un T tale che i parametri della funzione "corrispondano" agli argomenti. Non cerca di "indovinare il significato", confronta le forme dei tipi. Se la corrispondenza è univoca — ottimo. Se è possibile dedurre tipi diversi — si crea conflitto. Se non c'è nulla da cui dedurre — il compilatore vi chiederà di specificare il tipo esplicitamente.
Cominciamo dal caso più amichevole: un parametro — un argomento, tutto ovvio.
#include <iostream>
template <typename T>
T twice(T x) {
return x + x;
}
int main() {
std::cout << twice(21) << '\n'; // 42
std::cout << twice(1.5) << '\n'; // 3
}
Qui il compilatore vede: twice(21) — quindi T = int, e twice(1.5) — quindi T = double.
Importante: la deduzione avviene dagli argomenti della chiamata, non da dove poi assegnate il risultato. Questa è una trappola comune: “assegno a una double, quindi T deve essere double”. Non necessariamente.
3. Un singolo T, ma argomenti di tipi diversi
Ora arriviamo al "non dedotto" più popolare. Il problema: un unico T è usato in due parametri, ma avete passato tipi diversi. Il compilatore cerca di risolvere l'equazione:
- dal primo argomento risulta T = int
- dal secondo argomento risulta T = double
- ma T deve essere uno solo
Spoiler: il compilatore non sa fare il diplomatico.
#include <iostream>
template <typename T>
T add_same(T a, T b) {
return a + b;
}
int main() {
std::cout << add_same(1, 2) << '\n'; // 3
// std::cout << add_same(1, 2.5) << '\n';
// errore: impossibile dedurre T (un argomento int, l'altro double)
}
Non è "stupidità del compilatore", ma una protezione dall'ambiguità. Perché se scegliesse semplicemente double, ci si potrebbe chiedere: perché non long double? Perché non "il tipo più grande"? Perché non "il tipo del primo argomento"? In C++ non si lascia questo alle supposizioni.
Come rimediare? Ci sono due soluzioni oneste: convertire voi stessi gli argomenti allo stesso tipo, oppure specificare esplicitamente quale T volete.
#include <iostream>
template <typename T>
T add_same(T a, T b) {
return a + b;
}
int main() {
std::cout << add_same<double>(1, 2.5) << '\n'; // 3.5
}
Qui diciamo esplicitamente: "calcola in double". Il compilatore allora converte 1 in double, perché T è fissato.
4. Quando non c'è nulla da cui dedurre T
Un caso molto "fastidioso": avete scritto una funzione template, ma i suoi parametri non contengono T. Allora il compilatore guarda la chiamata e pensa: "nessun argomento in cui comparire T, niente da confrontare". Quindi la deduzione è impossibile per definizione.
Un esempio didattico:
#include <iostream>
template <typename T>
T make_zero() {
return T{};
}
int main() {
std::cout << make_zero<int>() << '\n'; // 0
std::cout << make_zero<<double>() << '\n'; // 0
}
Se provate a chiamare make_zero() senza <int>, il compilatore non può indovinare: volete int, double, std::string o magari std::vector<int> (che a sua volta accetta {})?
C'è una trappola simile: quando T appare solo nel tipo di ritorno, non nei parametri.
template <typename T>
T read_value(); // supponiamo vogliamo leggere da cin
int main() {
// auto x = read_value(); // errore: T non si deduce (chiamata senza indizi)
}
Regola pratica: il tipo del template si deduce dagli argomenti della chiamata. Niente argomenti — niente deduzione. Perciò in queste funzioni <T> è solitamente obbligatorio.
5. Le reference come contratto: perché T& non accetta valori temporanei
Finora abbiamo parlato di "quale T scegliere". Ora un'altra categoria di problemi: T potrebbe essere deducibile, ma l'argomento non può essere legato al parametro perché avete scelto la forma T&.
È importante non farsi intimidire da "reference = complicato", ma cogliere l'idea semplice. T& significa: "prendo un oggetto modificabile, lo modificherò". Un valore temporaneo (per esempio 1 o a + b) non è un "oggetto modificabile", è il risultato di un calcolo che vive poco.
#include <iostream>
template <typename T>
void increment(T& x) {
++x;
}
int main() {
int a = 10;
increment(a);
std::cout << a << '\n'; // 11
// increment(1);
// errore: non si può legare il temporaneo 1 a T&
}
Qui molti iniziano a cambiare tutto a caso finché non compila. Più utile pensare così: se increment modifica l'argomento, allora l'argomento deve essere una variabile.
Se non volete modificare l'argomento, quasi sempre è meglio usare const T&. Questo accetta sia variabili che temporanei.
#include <iostream>
template <typename T>
void print_twice(const T& value) {
std::cout << value << " | " << value << '\n';
}
int main() {
print_twice(42); // 42 | 42
}
Può sembrare un dettaglio, ma in pratica la scelta tra T& e const T& risolve metà dei problemi di template "non compila".
6. Argomenti insidiosi e diagnosi rapida della deduzione
Ora un blocco dove gli errori amano nascondersi. Buona notizia: in questi casi spesso la colpa non è vostra, ma del fatto che alcuni argomenti in C++ si comportano in modo speciale.
Inizializzazione con lista {...}
Le graffe {1, 2, 3} non sono sempre "un oggetto di tipo vector". A volte sono semplicemente una "lista di inizializzazione" senza un tipo ovvio finché non dite dove va inserita.
#include <iostream>
template <typename T>
void print_one(const T& x) {
std::cout << x << '\n';
}
int main() {
// print_one({1, 2, 3});
// errore: il compilatore non capisce cosa sia T per {1,2,3}
}
Conclusione: {...} spesso richiede un contesto esplicito (per esempio std::vector<int>{1,2,3}), altrimenti T non ha "a cosa aggrapparsi".
nullptr e template con puntatori
Sapete già che nullptr può essere assegnato a qualsiasi puntatore. Ma dedurre il parametro template ha regole più strette.
#include <iostream>
template <typename T>
void reset_ptr(T* p) {
p = nullptr; // copia locale del puntatore, non cambia fuori
}
int main() {
int* p = nullptr;
reset_ptr(p); // T = int, tutto ok
// reset_ptr(nullptr);
// errore: non si può dedurre T da nullptr (è std::nullptr_t, non T*)
}
Per chiamare con nullptr, bisogna o fornire una variabile puntatore, o specificare esplicitamente il tipo:
reset_ptr<int>(nullptr); // ora T è fissato, nullptr viene convertito in int*
Letterali stringa "hello": array o puntatore
Questo è un punto dove C++ mostra il suo "retaggio storico". Un literal stringa "hi" non è std::string, e neppure necessariamente const char* come "tipo originario". Formalmente è un array di caratteri. Poi intervengono le regole di trasformazione dell'array in puntatore (decay).
Per questo lo stesso argomento può dedurre T in modo diverso a seconda della forma del parametro.
#include <iostream>
template <typename T>
void show_type_by_value(T x) {
(void)x;
std::cout << "by value\n"; // by value
}
template <typename T>
void show_type_by_ref(T& x) {
(void)x;
std::cout << "by ref\n"; // by ref
}
int main() {
show_type_by_value("hi"); // T verrà dedotto in modo che x diventi un puntatore (decay)
// show_type_by_ref("hi"); // errore: non si può legare il literal a T& NON-const
}
Se si usa const T&, la reference può legarsi anche al literal, e T rispecchierà più da vicino la "forma reale" dell'argomento (fino all'array). Non è necessario memorizzarlo a memoria, ma è utile sapere che letterali e array sono argomenti "con effetti speciali".
Specificare esplicitamente T: quando è normale
Molti principianti vedono func<int>(...) come una "sconfitta": se il compilatore non ha dedotto, il template è cattivo. In realtà è uno strumento di design. A volte il tipo non può essere dedotto onestamente, altre volte volete controllare in quale tipo gli argomenti verranno convertiti.
Per esempio, una funzione "leggi valore da console" logicamente deve dire: "di che tipo leggiamo?". È perfettamente naturale esprimerlo con <T>.
#include <iostream>
#include <string_view>
template <typename T>
T read_from_stdin(std::string_view prompt) {
std::cout << prompt;
T value{};
std::cin >> value;
return value;
}
int main() {
int age = read_from_stdin<int>("Age? "); // input: 20
double price = read_from_stdin<double>("Price? "); // input: 19.99
std::cout << age << '\n'; // 20
std::cout << price << '\n'; // 19.99
}
Nota importante: la funzione ha un parametro prompt, ma da esso non si può dedurre T (non compare in quel parametro). Quindi <int> e <double> qui non sono "tappabuchi", ma parte dell'interfaccia: scegliete il tipo di ritorno.
Checklist rapida: come capire un "non dedotto"
Quando vedete un errore di compilazione tipo "no matching function" o "couldn't deduce template parameter", è utile non andare nel panico, ma scorrere rapidamente una breve checklist mentale. Suona quasi come un diagramma di flusso: "da dove viene T e perché lì non ha nulla da cui nascere".
flowchart TD
A[Chiamata a funzione template] --> B{Ci sono argomenti dove compare T?}
B -- No --> C["Impossibile dedurre T: serve func<T>(...) o cambiare la firma"]
B -- Sì --> D{Gli argomenti danno lo stesso T?}
D -- No --> E[Conflitto: tipi diversi -> T esplicito o convertire argomenti]
D -- Sì --> F{Il parametro T& richiede un oggetto modificabile?}
F -- Sì, ma è stato passato un temporaneo --> G[Usare const T& o passare una variabile]
F -- Tutto ok --> H[Deduzione riuscita; eventuali problemi sono nel corpo del template]
7. Esempio: utility template nell'"Expense Tracker"
Per non lasciare i template come matematica astratta su T, applichiamoli a una piccola applicazione console. Facciamo un tracker di spese minimal: aggiungiamo acquisti e stampiamo la somma.
Modello minimo e aggiunta di una singola voce (niente menu complessi — qui siamo sui template, non sull'UX).
#include <iostream>
#include <string>
#include <vector>
struct Expense {
std::string title;
double amount{};
};
int main() {
std::vector<Expense> expenses;
expenses.push_back(Expense{"Coffee", 3.50});
std::cout << expenses[0].title << ": " << expenses[0].amount << '\n'; // Coffee: 3.5
}
Aggiungiamo ora due utility template tipiche del codice reale.
La prima — read_from_stdin<T>(): dimostra il caso in cui T non si deduce, e questo è normale.
#include <iostream>
#include <string_view>
template <typename T>
T read_from_stdin(std::string_view prompt) {
std::cout << prompt;
T value{};
std::cin >> value;
return value;
}
La seconda — clamp_value: mostra il caso in cui T non si deduce da tipi misti, ed è perfettamente normale.
template <typename T>
T clamp_value(T x, T lo, T hi) {
if (x < lo) return lo;
if (hi < x) return hi;
return x;
}
Usiamo ora questo in main, in modo da vedere subito entrambe le categorie di problemi e le loro soluzioni.
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
struct Expense {
std::string title;
double amount{};
};
template <typename T>
T read_from_stdin(std::string_view prompt) {
std::cout << prompt;
T value{};
std::cin >> value;
return value;
}
template <typename T>
T clamp_value(T x, T lo, T hi) {
if (x < lo) return lo;
if (hi < x) return hi;
return x;
}
int main() {
std::vector<Expense> expenses;
std::string title;
std::cout << "Title? ";
std::cin >> title;
double amount = read_from_stdin<double>("Amount? "); // input: 1000
amount = clamp_value(amount, 0.0, 100000.0);
expenses.push_back(Expense{title, amount});
std::cout << "Added: " << expenses.back().title << '\n'; // Added: <title>
}
Qui è utile notare che read_from_stdin<double> deve essere chiamato con <double>, perché altrimenti T non si deduce: nei parametri non compare T, c'è solo std::string_view.
Mentre clamp_value deduce T dagli argomenti, perché T compare direttamente nei parametri della funzione.
Se per errore avessimo scritto clamp_value(amount, 0, 100000.0), otterremmo un conflitto: un argomento int, gli altri double. Non è un bug, è il caso "un singolo T non può essere due".
8. Errori tipici
Errore #1: aspettarsi che il compilatore "deduca T dal tipo della variabile a sinistra".
Spesso sembra logico: se scrivo double x = add_same(1, 2.5);, allora T dovrebbe diventare double. Ma la deduzione avviene dagli argomenti della chiamata, non dal contesto di assegnamento. Si risolve convertendo gli argomenti o specificando esplicitamente add_same<double>(...).
Errore #2: usare T& "per abitudine" e poi sorprendersi che non accetti letterali e temporanei.
T& è il contratto "modifico l'oggetto", quindi non si possono passare 1, a + b o il literal stringa "hi". Se la funzione legge solo, quasi sempre è meglio const T&: accetta variabili e temporanei, ed è più amichevole.
Errore #3: chiamare un template senza argomenti e senza <T>, poi fissarsi sull'errore "couldn't deduce template parameter".
Se la funzione non ha parametri, o T appare solo nel tipo di ritorno, il compilatore non può indovinare T. Non è una "difficoltà dei template", è logica: non c'è nulla da cui dedurre. Quindi scrivete make_zero<int>(), read_from_stdin<double>(...), ecc.
Errore #4: mescolare tipi negli argomenti quando un singolo T è usato più volte, sperando in una conversione automatica.
Chiamare add_same(1, 2.5) sembra naturale ("sommare numeri"), ma per la deduzione è una contraddizione: T deve essere uno. Soluzione: convertire gli argomenti allo stesso tipo o dichiarare esplicitamente T.
Errore #5: passare nullptr a un template con T* e stupirsi che T non si deduca.
nullptr può essere convertito in qualsiasi puntatore, ma non è possibile dedurre T da esso, perché non è un puntatore a un particolare T. Se volete chiamare con nullptr, di solito passate una variabile puntatore o scrivete func<int>(nullptr).
GO TO FULL VERSION