CodeGym /Corsi /C++ SELF /Deduzione del tipo e casi in cui non funziona

Deduzione del tipo e casi in cui non funziona

C++ SELF
Livello 55, Lezione 3
Disponibile

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).

1
Compito
C++ SELF, livello 55, lezione 3
Bloccato
Duplicazione del valore
Duplicazione del valore
1
Compito
C++ SELF, livello 55, lezione 3
Bloccato
Addizione double
Addizione double
1
Compito
C++ SELF, livello 55, lezione 3
Bloccato
Tipo su richiesta
Tipo su richiesta
1
Compito
C++ SELF, livello 55, lezione 3
Bloccato
Contratto delle referenze
Contratto delle referenze
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION