129 votes

Les callbacks idiomatiques en Rust

En C/C++, je ferais normalement des callbacks avec un simple pointeur de fonction, en passant peut-être un fichier void* userdata également. Quelque chose comme ça :

typedef void (*Callback)();

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

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

Quelle est la manière idiomatique de faire cela en Rust ? Plus précisément, quels types devraient être mes setCallback() et quel type de fonction doit mCallback être ? Doit-il prendre un Fn ? Peut-être FnMut ? Dois-je le sauvegarder ? Boxed ? Un exemple serait étonnant.

272voto

user4815162342 Points 27348

Réponse courte : Pour un maximum de flexibilité, vous pouvez stocker le rappel sous forme de boîte FnMut avec le setter de callback générique sur le type de callback. Le code correspondant est présenté dans le dernier exemple de la réponse. Pour une explication plus détaillée, lisez la suite.

"Function pointers" : callbacks comme fn

L'équivalent le plus proche du code C++ dans la question serait de déclarer le callback comme un fn type. fn encapsule les fonctions définies par le fn comme les pointeurs de fonction du 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 mut p = Processor { callback: simple_callback };
    p.process_events();         // hello world!
}

Ce code pourrait être étendu pour inclure un Option<Box<Any>> pour contenir les "données utilisateur" associées à la fonction. Même ainsi, ce ne serait pas idiomatique de Rust. La façon Rust d'associer des données à une fonction est de les capturer dans un fichier anonyme de type fermeture comme dans le C++ moderne. Puisque les fermetures ne sont pas fn , set_callback devra accepter d'autres types d'objets fonctionnels.

Les rappels en tant qu'objets de fonction génériques

Dans Rust et C++, les fermetures ayant la même signature d'appel ont des tailles différentes pour s'adapter aux différentes tailles des valeurs capturées qu'elles stockent dans l'objet de fermeture. De plus, chaque site de fermeture génère un type anonyme distinct qui est le type de l'objet de fermeture au moment de la compilation. En raison de ces contraintes, la structure ne peut pas faire référence au type de rappel par son nom ou par un alias de type.

Une façon de posséder une fermeture dans la structure sans faire référence à un type concret est de faire en sorte que la struct Générique . Le struct adaptera automatiquement sa taille et le type de callback pour la fonction concrète ou la fermeture que vous lui passez :

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();
}

Comme auparavant, la nouvelle définition de la fonction de rappel pourra accepter les fonctions de haut niveau définies par l'option fn mais celui-ci acceptera également les fermetures telles que || println!("hello world!") ainsi que des fermetures qui capturent des valeurs, telles que || println!("{}", somevar) . Pour cette raison, la fermeture n'a pas besoin d'un système séparé. userdata Il peut simplement capturer les données de son environnement et elles seront disponibles lorsqu'il sera appelé.

Mais c'est quoi le problème avec le FnMut pourquoi ne pas simplement Fn ? Puisque les closures détiennent des valeurs capturées, Rust leur applique les mêmes règles que celles qu'il applique aux autres objets conteneurs. En fonction de ce que les closures font avec les valeurs qu'elles contiennent, elles sont regroupées en trois familles, chacune marquée par un trait :

  • Fn sont des fermetures qui ne lisent que des données et qui peuvent être appelées plusieurs fois en toute sécurité, éventuellement à partir de plusieurs threads. Les deux fermetures ci-dessus sont Fn .
  • FnMut sont des fermetures qui modifient les données, par exemple en écrivant dans un fichier capturé. mut variable. Ils peuvent également être appelés plusieurs fois, mais pas en parallèle. (L'appel d'une FnMut à partir de plusieurs threads conduirait à une course aux données, donc cela ne peut être fait qu'avec la protection d'un mutex). L'objet de fermeture doit être déclaré mutable par l'appelant.
  • FnOnce sont des fermetures qui consommer les données qu'ils capturent, par exemple en les déplaçant vers une fonction qui les possède. Comme leur nom l'indique, elles ne peuvent être appelées qu'une seule fois, et l'appelant doit les posséder.

De manière quelque peu contre-intuitive, lorsque l'on spécifie une limite de trait pour le type d'un objet qui accepte une fermeture, FnOnce est en fait le plus permissif. En déclarant qu'un type de callback générique doit satisfaire à la norme FnOnce signifie qu'il acceptera littéralement n'importe quelle fermeture. Mais cela a un prix : cela signifie que le détenteur n'est autorisé à l'appeler qu'une seule fois. Puisque process_events() peut choisir d'invoquer la fonction de rappel plusieurs fois, et comme la méthode elle-même peut être appelée plus d'une fois, la limite suivante la plus permissive est la suivante FnMut . Notez que nous avons dû marquer process_events comme mutant self .

Rappels non génériques : objets de trait de fonction

Même si l'implémentation générique de la fonction de rappel est extrêmement efficace, elle présente de sérieuses limitations d'interface. Elle exige que chaque Processor doit être paramétrée avec un type de rappel concret, ce qui signifie qu'une seule instance de Processor ne peut traiter qu'un seul type de rappel. Étant donné que chaque fermeture a un type distinct, la fonction générique Processor ne peut pas gérer proc.set_callback(|| println!("hello")) suivi par proc.set_callback(|| println!("world")) . L'extension de la structure pour supporter deux champs de callbacks nécessiterait que la structure entière soit paramétrée pour deux types, ce qui deviendrait rapidement difficile à gérer lorsque le nombre de callbacks augmente. L'ajout de paramètres de type supplémentaires ne fonctionnerait pas si le nombre de callbacks devait être dynamique, par exemple pour implémenter un système de contrôle d'accès. add_callback qui maintient un vecteur de différents rappels.

Pour supprimer le paramètre de type, nous pouvons tirer parti de la fonction objets de trait la fonctionnalité de Rust qui permet la création automatique d'interfaces dynamiques basées sur des traits. On y fait parfois référence en tant que effacement de type et est une technique populaire en C++ [1] [2] à ne pas confondre avec l'utilisation quelque peu différente du terme par les langages Java et FP. Les lecteurs familiers avec le C++ reconnaîtront la distinction entre une fermeture qui implémente le langage Fn et un Fn comme équivalente à la distinction entre les objets de fonction générale et les objets de fonction générale. std::function en C++.

Un objet trait est créé en empruntant un objet avec l'attribut & et le couler ou le contraindre à une référence au trait spécifique. Dans ce cas, puisque Processor doit posséder l'objet de rappel, nous ne pouvons pas utiliser l'emprunt, mais devons stocker le rappel dans un objet de rappel alloué par le tas Box<Trait> (l'équivalent Rust de std::unique_ptr ), qui est fonctionnellement équivalent à un objet trait.

Si Processor magasins Box<FnMut()> il n'a plus besoin d'être générique, mais l'option set_callback méthode est maintenant générique, de sorte qu'il peut correctement mettre en boîte tout appelable que vous lui donnez avant de stocker la boîte dans le fichier Processor . Le callback peut être de n'importe quel type tant qu'il ne consomme pas les valeurs capturées. set_callback Le fait d'être générique n'entraîne pas les limitations mentionnées ci-dessus, car cela n'affecte pas l'interface des données stockées dans la structure.

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

impl Processor {
    fn set_callback<CB: 'static + FnMut()>(&mut self, c: CB) {
        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();
}

18 votes

Wow, je pense que c'est la meilleure réponse que j'ai jamais eue à une question sur le SO ! Merci ! C'est parfaitement expliqué. Il y a une petite chose que je ne comprends pas - pourquoi est-ce que CB doivent être 'static dans le dernier exemple ?

9 votes

Le site Box<FnMut()> utilisé dans le champ struct signifie Box<FnMut() + 'static> . En gros "L'objet de trait encadré ne contient aucune référence / toutes les références qu'il contient dépassent (ou égalent) 'static ". Cela empêche la callback de capturer les locaux par référence.

0 votes

Ah je vois, je pense !

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X