Richiami idiomatici in Rust


100

In C / C ++ normalmente farei callback con un semplice puntatore a funzione, magari passando anche un void* userdataparametro. Qualcosa come questo:

typedef void (*Callback)();

class Processor
{
public:
    void setCallback(Callback c)
    {
        mCallback = c;
    }

    void processEvents()
    {
        for (...)
        {
            ...
            mCallback();
        }
    }
private:
    Callback mCallback;
};

Qual è il modo idiomatico di farlo in Rust? In particolare, quali tipi dovrebbe setCallback()assumere la mia funzione e quale dovrebbe mCallbackessere? Dovrebbe volerci un Fn? Forse FnMut? Lo salvo Boxed? Un esempio sarebbe fantastico.

Risposte:


195

Risposta breve: per la massima flessibilità, è possibile memorizzare il callback come FnMutoggetto boxed , con il setter callback generico sul tipo di callback. Il codice per questo è mostrato nell'ultimo esempio nella risposta. Per una spiegazione più dettagliata, continua a leggere.

"Puntatori a funzione": richiama come fn

L'equivalente più vicino del codice C ++ nella domanda sarebbe dichiarare callback come fntipo. fnincapsula le funzioni definite dalla fnparola chiave, proprio come i puntatori a funzione di C ++:

type Callback = fn();

struct Processor {
    callback: Callback,
}

impl Processor {
    fn set_callback(&mut self, c: Callback) {
        self.callback = c;
    }

    fn process_events(&self) {
        (self.callback)();
    }
}

fn simple_callback() {
    println!("hello world!");
}

fn main() {
    let p = Processor {
        callback: simple_callback,
    };
    p.process_events(); // hello world!
}

Questo codice potrebbe essere esteso per includere un Option<Box<Any>>per contenere i "dati utente" associati alla funzione. Anche così, non sarebbe Rust idiomatico. Il modo Rust per associare i dati a una funzione è catturarli in una chiusura anonima , proprio come nel moderno C ++. Poiché le chiusure non lo sono fn, set_callbacksarà necessario accettare altri tipi di oggetti funzione.

Callback come oggetti funzione generici

Sia in Rust che in C ++ le chiusure con la stessa firma di chiamata sono disponibili in dimensioni diverse per adattarsi ai diversi valori che potrebbero acquisire. Inoltre, ogni definizione di chiusura genera un tipo anonimo univoco per il valore della chiusura. A causa di questi vincoli, la struttura non può nominare il tipo del suo callbackcampo, né può utilizzare un alias.

Un modo per incorporare una chiusura nel campo struct senza fare riferimento a un tipo concreto è rendere generico lo struct . La struttura adatterà automaticamente la sua dimensione e il tipo di callback per la funzione concreta o la chiusura che gli passi:

struct Processor<CB>
where
    CB: FnMut(),
{
    callback: CB,
}

impl<CB> Processor<CB>
where
    CB: FnMut(),
{
    fn set_callback(&mut self, c: CB) {
        self.callback = c;
    }

    fn process_events(&mut self) {
        (self.callback)();
    }
}

fn main() {
    let s = "world!".to_string();
    let callback = || println!("hello {}", s);
    let mut p = Processor { callback: callback };
    p.process_events();
}

Come prima, la nuova definizione di callback sarà in grado di accettare funzioni di primo livello definite con fn, ma questa accetterà anche chiusure come || println!("hello world!"), così come chiusure che catturano valori, come || println!("{}", somevar). Per questo motivo il processore non ha bisogno userdatadi accompagnare la richiamata; la chiusura fornita dal chiamante di set_callbackcatturerà automaticamente i dati di cui ha bisogno dal suo ambiente e li avrà a disposizione quando invocato.

Ma qual è il problema con il FnMut, perché non solo Fn? Poiché le chiusure contengono valori acquisiti, le normali regole di mutazione di Rust devono essere applicate quando si chiama la chiusura. A seconda di cosa fanno le chiusure con i valori che detengono, sono raggruppate in tre famiglie, ciascuna contrassegnata da un tratto:

  • Fnsono chiusure che leggono solo i dati e possono essere chiamate in modo sicuro più volte, possibilmente da più thread. Entrambe le chiusure di cui sopra sono Fn.
  • FnMutsono chiusure che modificano i dati, ad esempio scrivendo in una mutvariabile catturata . Possono anche essere chiamati più volte, ma non in parallelo. (Chiamare una FnMutchiusura da più thread porterebbe a una corsa di dati, quindi può essere eseguita solo con la protezione di un mutex.) L'oggetto di chiusura deve essere dichiarato mutabile dal chiamante.
  • FnOncesono chiusure che consumano alcuni dei dati che acquisiscono, ad esempio spostando un valore acquisito in una funzione che ne assume la proprietà. Come suggerisce il nome, questi possono essere chiamati solo una volta e il chiamante deve possederli.

Un po 'controintuitivamente, quando si specifica un tratto legato al tipo di un oggetto che accetta una chiusura, FnOnceè in realtà il più permissivo. Dichiarare che un tipo di callback generico deve soddisfare il FnOncetratto significa che accetterà letteralmente qualsiasi chiusura. Ma questo ha un prezzo: significa che il titolare può chiamarlo solo una volta. Poiché process_events()può scegliere di invocare la richiamata più volte e poiché il metodo stesso può essere chiamato più di una volta, il successivo limite più permissivo è FnMut. Nota che abbiamo dovuto contrassegnare process_eventscome mutante self.

Callback non generici: oggetti tratto di funzione

Anche se l'implementazione generica del callback è estremamente efficiente, presenta gravi limitazioni di interfaccia. Richiede che ogni Processoristanza sia parametrizzata con un tipo di callback concreto, il che significa che una singola istanza Processorpuò gestire solo un singolo tipo di callback. Dato che ogni chiusura ha un tipo distinto, il generico Processornon può gestire proc.set_callback(|| println!("hello"))seguito da proc.set_callback(|| println!("world")). L'estensione della struttura per supportare due campi di callback richiederebbe la parametrizzazione dell'intera struttura su due tipi, che diventerebbero rapidamente ingombranti man mano che il numero di callback cresce. L'aggiunta di più parametri di tipo non funzionerebbe se il numero di callback dovesse essere dinamico, ad esempio per implementare una add_callbackfunzione che mantiene un vettore di callback differenti.

Per rimuovere il parametro type, possiamo sfruttare gli oggetti trait , la funzionalità di Rust che consente la creazione automatica di interfacce dinamiche basate sui tratti. Questo a volte è indicato come cancellazione del tipo ed è una tecnica popolare in C ++ [1] [2] , da non confondere con l'uso un po 'diverso del termine nei linguaggi Java e FP. I lettori che hanno familiarità con C ++ riconosceranno la distinzione tra una chiusura che implementa Fne un Fnoggetto tratto come equivalente alla distinzione tra oggetti funzione generali e std::functionvalori in C ++.

Un oggetto tratto viene creato prendendo in prestito un oggetto con l' &operatore e proiettandolo o costringendolo a un riferimento al tratto specifico. In questo caso, poiché è Processornecessario possedere l'oggetto callback, non possiamo usare il prestito, ma dobbiamo memorizzare il callback in un heap allocato Box<dyn Trait>(l'equivalente Rust di std::unique_ptr), che è funzionalmente equivalente a un oggetto trait.

Se Processormemorizza Box<dyn FnMut()>, non deve più essere generico, ma il set_callback metodo ora accetta un generico ctramite un impl Traitargomento . In quanto tale, può accettare qualsiasi tipo di chiamata, comprese le chiusure con stato, e inscatolarlo adeguatamente prima di memorizzarlo nel file Processor. L'argomento generico a set_callbacknon limita il tipo di callback che il processore accetta, poiché il tipo di callback accettato è disaccoppiato dal tipo memorizzato nella Processorstruttura.

struct Processor {
    callback: Box<dyn FnMut()>,
}

impl Processor {
    fn set_callback(&mut self, c: impl FnMut() + 'static) {
        self.callback = Box::new(c);
    }

    fn process_events(&mut self) {
        (self.callback)();
    }
}

fn simple_callback() {
    println!("hello");
}

fn main() {
    let mut p = Processor {
        callback: Box::new(simple_callback),
    };
    p.process_events();
    let s = "world!".to_string();
    let callback2 = move || println!("hello {}", s);
    p.set_callback(callback2);
    p.process_events();
}

Durata delle referenze all'interno delle chiusure in scatola

La 'staticdurata legata al tipo di cargomento accettato da set_callbackè un modo semplice per convincere il compilatore che i riferimenti contenuti in c, che potrebbe essere una chiusura che si riferisce al suo ambiente, si riferiscono solo a valori globali e rimarranno quindi validi durante l'uso del richiama. Ma il limite statico è anche molto pesante: mentre accetta bene le chiusure che possiedono oggetti (cosa che abbiamo assicurato sopra effettuando la chiusura move), rifiuta le chiusure che si riferiscono all'ambiente locale, anche quando si riferiscono solo a valori che sopravvivono al processore e sarebbero infatti al sicuro.

Poiché abbiamo solo bisogno dei callback attivi finché il processore è attivo, dovremmo cercare di legare la loro durata a quella del processore, che è un limite meno stretto di 'static. Ma se rimuoviamo solo il 'staticlimite di durata da set_callback, non viene più compilato. Questo perché set_callbackcrea una nuova casella e la assegna al callbackcampo definito come Box<dyn FnMut()>. Poiché la definizione non specifica una durata per l'oggetto tratto in scatola, 'staticè implicita e l'assegnazione amplierebbe effettivamente la durata (da una durata arbitraria senza nome del callback a 'static), che non è consentita. La soluzione consiste nel fornire una durata esplicita per il processore e legare tale durata sia ai riferimenti nella casella che ai riferimenti nella richiamata ricevuta da set_callback:

struct Processor<'a> {
    callback: Box<dyn FnMut() + 'a>,
}

impl<'a> Processor<'a> {
    fn set_callback(&mut self, c: impl FnMut() + 'a) {
        self.callback = Box::new(c);
    }
    // ...
}

Con queste vite rese esplicite, non è più necessario utilizzare 'static. La chiusura può ora riferirsi sall'oggetto locale , cioè non deve più essere move, a condizione che la definizione di ssia posta prima della definizione di pper garantire che la stringa sopravviva al processore.


15
Wow, penso che questa sia la migliore risposta che abbia mai avuto a una domanda SO! Grazie! Perfettamente spiegato. Una cosa minore non capisco però: perché CBdeve essere 'staticl'ultimo esempio?
Timmmm

9
L' Box<FnMut()>usato nel campo struct significa Box<FnMut() + 'static>. All'incirca "L'oggetto tratto in scatola non contiene riferimenti / qualsiasi riferimento che contiene sopravvive (o è uguale) 'static". Impedisce al callback di acquisire i locali per riferimento.
bluss

Ah capisco, credo!
Timmmm

1
@Timmmm Maggiori dettagli sul 'staticlimite in un post sul blog separato .
user4815162342

3
Questa è una risposta fantastica, grazie per averla fornita @ user4815162342.
Dash83
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.