CodeGym /Corsi /C++ SELF /std::function — quando serve e qual è il costo

std::function — quando serve e qual è il costo

C++ SELF
Livello 22, Lezione 3
Disponibile

1. Perché serve std::function

Prima che si ricorra a std::function di solito non è per una questione estetica, ma perché il compilatore l'ultima volta ti ha detto: «No, così non va». E va bene: il compilatore non è un nemico, semplicemente non vuole indovinare quale tipo intendevi.

Immagina la situazione: hai scritto due regole diverse per elaborare dati. Accettano gli stessi parametri e ritornano lo stesso tipo, ma sono scritte in modo differente (per esempio, una è una lambda, l'altra una funzione normale). A livello umano questo è «la stessa interfaccia». In C++ sono entità diverse.

Il problema chiave è: ogni lambda ha il suo tipo, anche se due lambda sono «semanticamente» identiche. Perciò una variabile definita con auto può contenere esattamente un tipo di lambda, e normalmente non si può «sostituirla» con un'altra lambda diversa.

Una piccola dimostrazione del «dolore»:

#include <iostream>

int main() {
    auto f = [](int x) { return x + 1; };

    // f = [](int x) { return x * 2; }; // <- errore di compilazione: altro tipo di lambda

    std::cout << f(10) << '\n'; // 11
}

Perché succede? Perché auto f = ... ha dedotto il tipo della lambda concreta (il suo closure type), e da quel punto in poi f deve rimanere proprio di quel tipo.

Cos'è std::function e quale problema risolve

Quando diciamo «voglio memorizzare qualcosa di invocabile», in realtà vogliamo un contenitore per un comportamento: «questa è la firma — l'implementazione può essere diversa». Questo è esattamente ciò che fa std::function.

std::function<R(Args...)> è un oggetto-wrapper che può contenere al suo interno diverse entità invocabili (callable) con la stessa firma: funzioni normali, lambda (anche con capture), e altri functor. Al suo interno nasconde il tipo concreto e fornisce un'interfaccia unificata operator().

La cosa più importante da capire a questo punto: std::function serve quando vogliamo un singolo tipo di variabile per implementazioni diverse di callable con la stessa firma.

Sintassi di base: dichiarazione, assegnazione, chiamata

La sintassi di std::function può sembrare un po' «formale», ma è onesta: dichiari esplicitamente il contratto di chiamata.

Quasi sempre ti servirà:

  • #include <functional>
  • la scrittura della firma: std::function<ritorno(parametri...)>

Esempio minimo: memorizziamo «qualcosa che prende un int e ritorna un int»:

#include <functional>
#include <iostream>

int main() {
    std::function<int(int)> f = [](int x) { return x + 1; };

    std::cout << f(10) << '\n'; // 11

    f = [](int x) { return x * 2; };
    std::cout << f(10) << '\n'; // 20
}

Questa è la «magia pratica»: la variabile f è unica, ha un unico tipo (std::function<int(int)>), ma possiamo cambiare l'implementazione a piacere.

std::function può essere vuoto

Una std::function ha uno «stato vuoto»: può non contenere alcuna funzione. Questo è comodo (per esempio, «callback opzionale»), ma pericoloso se ci si dimentica di controllare.

#include <functional>
#include <iostream>

int main() {
    std::function<void()> action; // vuoto

    if (!action) {
        std::cout << "action is empty\n"; // action is empty
    }

    action = [] { std::cout << "run\n"; };

    if (action) {
        action(); // run
    }
}

Se chiami una std::function vuota si ottiene un errore a runtime (di solito viene lanciata l'eccezione std::bad_function_call). Finché non trattiamo try/catch, la tua protezione principale è il controllo di vuotezza: if (f) { f(...); }.

2. Quando usare e quanto costa

Quando std::function è realmente necessario

È facile «innamorarsi» di std::function e cominciare a usarlo ovunque «per sicurezza». Aggiunge flessibilità, ma a volte introduce complessità e overhead non necessari. Conviene distinguere i casi tipici.

Scegliere il comportamento a runtime

Per esempio, hai un'impostazione: «ordinare per lunghezza» o «ordinare lessicograficamente». Vuoi memorizzare la regola scelta in una variabile e applicarla più tardi.

Con auto ti scontreresti con i «tipi di lambda diversi». Con std::function tutto funziona:

#include <functional>
#include <iostream>
#include <string>

int main() {
    bool by_length = true;

    std::function<bool(const std::string&, const std::string&)> cmp;

    if (by_length) {
        cmp = [](const std::string& a, const std::string& b) { return a.size() < b.size(); };
    } else {
        cmp = [](const std::string& a, const std::string& b) { return a < b; };
    }

    std::cout << cmp("cat", "horse") << '\n'; // 1 (true)
}

Comodo restituire callable da una funzione

A volte vuoi scrivere una factory: «dammi una regola/filtro in base a un parametro». Restituire una lambda direttamente è possibile, ma non si può scrivere il tipo della lambda nella firma senza auto. std::function rende il tipo di ritorno esplicito e comodo per l'API.

#include <functional>

std::function<bool(int)> make_is_big(int limit) {
    return [limit](int x) { return x >= limit; };
}

Il senso: restituiamo una «funzione-check» che cattura internamente limit.

Quando std::function è spesso superfluo

Se scrivi:

std::sort(v.begin(), v.end(), [](int a, int b){ return a < b; });

allora non serve avvolgere il comparatore in std::function. Gli algoritmi accettano le lambda direttamente e il compilatore di solito ottimizza meglio tali chiamate.

Regola pratica: se il callable vive «entro una singola riga» e non hai bisogno di conservarlo per dopo, std::function di solito non è necessario.

Quanto costa usare std::function

std::function non è «polimorfismo gratuito». Risolve il problema, ma ha un costo.

Costo 1: involucro nascosto e chiamata indiretta

Quando una lambda è in auto, il compilatore conosce il suo tipo preciso e spesso può inlineare la chiamata e ottimizzare aggressivamente.

Quando il callable è nascosto dentro std::function, la chiamata di solito avviene tramite un livello aggiuntivo di indirezione (cioè «chiama ciò che è dentro l'involucro»).

Costo 2: possibili allocazioni di memoria

Alcuni callable sono piccoli (per esempio una lambda senza capture) e possono essere conservati dentro std::function senza memoria dinamica. Ma se il callable è grande (per esempio catturi un grosso oggetto per valore), std::function può dover allocare memoria.

A questo punto è più importante capire l'idea generale: std::function può essere più pesante di quanto sembri.

Costo 3: le copie possono essere costose

std::function è copiabile, ma la copia può significare copiare ciò che contiene. Quindi come «piccola cosa che trasportiamo continuamente» a volte std::function si trasforma in una «piccola zavorra».

Mini-tabella: auto vs puntatore a funzione vs std::function

Cosa memorizziamo Esempio di tipo Si possono catturare variabili? Si possono memorizzare diverse implementazioni in una variabile? Commento
Lambda di tipo concreto
auto f = []{...};
Sì No Opzione più leggera, ma tipo fisso
Puntatore a funzione
int (*pf)(int)
No Sì, ma solo funzioni/lambda senza capture Semplice e veloce, ma senza capture
std::function
std::function<int(int)>
Sì Sì Il più flessibile dei tre, ma più costoso

3. Esempio pratico: mini-dispatcher di comandi

Costruiamo ora qualcosa di pratico che mostra bene il senso di std::function: una tabella di comandi dove ogni comando è nome + handler. Gli handler saranno diversi (per esempio lambda con capture), ma vogliamo conservarli nello stesso contenitore.

Immaginiamo un modello semplice di task:

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

Ora definiamo un «comando» come coppia: nome + handler. L'handler prenderà la lista di task e un argomento del comando (per esempio una stringa dopo il comando):

#include <functional>
#include <string>
#include <string_view>
#include <vector>

struct Command {
    std::string name;
    std::function<void(std::vector<Task>&, std::string_view)> run;
};

Nota: è proprio qui che std::function è comodo. Vogliamo che run abbia lo stesso tipo per tutti i comandi, anche se le implementazioni differiscono.

Comando più semplice: list

Scriviamo una funzione di stampa (per ora senza fronzoli):

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (const auto& t : tasks) {
        std::cout << (t.done ? "[x] " : "[ ] ") << t.id << ": " << t.title << '\n';
    }
}

Creiamo ora il comando list come lambda senza capture:

Command make_list_command() {
    return Command{
        "list",
        [](std::vector<Task>& tasks, std::string_view) {
            print_tasks(tasks);
        }
    };
}

Comando con capture: add, con «next id»

Per aggiungere task ci serve un contatore next_id. Possiamo tenerlo esternamente e catturarlo per riferimento. Questo è un buon esempio, ma attenzione alle regole di lifetime: next_id deve vivere più a lungo del comando.

Command make_add_command(int& next_id) {
    return Command{
        "add",
        [&next_id](std::vector<Task>& tasks, std::string_view arg) {
            tasks.push_back(Task{next_id, std::string(arg), false});
            ++next_id;
        }
    };
}

Ricerca del comando e esecuzione

Riuniamo i comandi in un vettore e troviamo quello giusto per nome usando std::find_if.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;
    int next_id = 1;

    std::vector<Command> cmds;
    cmds.push_back(make_list_command());
    cmds.push_back(make_add_command(next_id));

    std::string cmd = "add";
    std::string arg = "Buy milk";

    auto it = std::find_if(cmds.begin(), cmds.end(),
                           [&](const Command& c) { return c.name == cmd; });

    if (it != cmds.end() && it->run) {
        it->run(tasks, arg);
    }

    print_tasks(tasks); // [ ] 1: Buy milk
}

Dettaglio importante: it->run è una std::function, e la controlliamo per vuotezza. Nel nostro esempio non è vuota, ma la buona abitudine è utile.

4. Errori tipici con std::function

Errore 1: «Mettiamo std::function ovunque, tanto è universale».
L'universalità non è sempre un vantaggio. Se il callable non va conservato «per dopo», se è usato una sola volta (soprattutto dentro std::sort/std::find_if/std::transform), allora std::function spesso aggiunge overhead e complica la lettura. In quei casi la lambda inline è di solito più chiara: la regola è vicina al punto d'uso.

Errore 2: firma sbagliata per std::function.
Situazione comune: si scrive std::function<void(int)>, poi si prova ad assegnare una lambda che prende un const int& o che ritorna bool. Formalmente sono contratti diversi. Buona pratica: prima descrivi a parole «cosa accetta e cosa ritorna», poi scrivi R(Args...).

Errore 3: chiamare una std::function vuota.
std::function può essere «vuota» — è uno stato valido. Ma se la chiami come funzione otterrai un errore a runtime (di solito un'eccezione che, se non gestita, termina il programma). Finché non usi try/catch, la protezione principale resta il controllo: if (f) { f(...); }.

Errore 4: capture per riferimento dove il lifetime non è garantito.
È facile creare un comando che cattura una reference a una variabile locale, poi mettere il comando in un contenitore ed eseguirlo più tardi. Se la variabile è già «morta», il comportamento è indefinito. Nel nostro esempio con make_add_command(int& next_id) è fondamentale che next_id viva più a lungo dei comandi. Se non sei sicuro, cattura per valore o progetta meglio il lifetime dello stato.

Errore 5: mettere in std::function capture troppo pesanti senza capire il costo.
Se catturi nella lambda una grossa std::string o un intero std::vector per valore, quella cattura finirà dentro std::function. Copiare/spostare tale std::function diventa costoso e a volte richiede allocazioni. Cattura per valore solo ciò che vuoi veramente «fissare come configurazione» e cerca di mantenere queste cose piccole.

1
Compito
C++ SELF, livello 22, lezione 3
Bloccato
Scelta del trucco
Scelta del trucco
1
Compito
C++ SELF, livello 22, lezione 3
Bloccato
Handler opzionale
Handler opzionale
1
Compito
C++ SELF, livello 22, lezione 3
Bloccato
Fabbrica di trasformazioni
Fabbrica di trasformazioni
1
Compito
C++ SELF, livello 22, lezione 3
Bloccato
Dispatcher di comandi
Dispatcher di comandi
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION