Idiomatische Rückrufe in Rust

90

In C / C ++ würde ich normalerweise Rückrufe mit einem einfachen Funktionszeiger ausführen und möglicherweise auch einen void* userdataParameter übergeben. Etwas wie das:

typedef void (*Callback)();

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

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

Was ist die idiomatische Art, dies in Rust zu tun? Welche Typen sollte meine setCallback()Funktion annehmen und welcher Typ sollte es mCallbacksein? Sollte es eine dauern Fn? Vielleicht FnMut? Muss ich es retten Boxed? Ein Beispiel wäre erstaunlich.

Timmmm
quelle

Antworten:

176

Kurze Antwort: Für maximale Flexibilität können Sie den Rückruf als Box- FnMutObjekt speichern , wobei der Rückruf-Setter für den Rückruftyp generisch ist. Der Code hierfür wird im letzten Beispiel in der Antwort gezeigt. Eine ausführlichere Erklärung finden Sie weiter.

"Funktionszeiger": Rückrufe als fn

Das nächste Äquivalent des C ++ - Codes in der Frage wäre das Deklarieren des Rückrufs als fnTyp. fnkapselt Funktionen, die durch das fnSchlüsselwort definiert sind , ähnlich wie die Funktionszeiger von 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!
}

Dieser Code könnte erweitert werden, um Option<Box<Any>>die der Benutzer zugeordneten "Benutzerdaten" aufzunehmen. Trotzdem wäre es kein idiomatischer Rust. Die Rust-Methode zum Verknüpfen von Daten mit einer Funktion besteht darin, sie in einem anonymen Abschluss zu erfassen , genau wie in modernem C ++. Da Verschlüsse nicht vorhanden sind fn, set_callbackmüssen andere Arten von Funktionsobjekten akzeptiert werden.

Rückrufe als generische Funktionsobjekte

Sowohl in Rust- als auch in C ++ - Abschlüssen mit derselben Anrufsignatur gibt es unterschiedliche Größen, um den unterschiedlichen Werten Rechnung zu tragen, die sie möglicherweise erfassen. Darüber hinaus generiert jede Abschlussdefinition einen eindeutigen anonymen Typ für den Wert des Abschlusses. Aufgrund dieser Einschränkungen kann die Struktur weder den Typ ihres callbackFelds benennen noch einen Alias ​​verwenden.

Eine Möglichkeit, einen Abschluss in das Strukturfeld einzubetten, ohne sich auf einen konkreten Typ zu beziehen, besteht darin, die Struktur generisch zu gestalten . Die Struktur passt ihre Größe und die Art des Rückrufs automatisch an die konkrete Funktion oder den Abschluss an, den Sie an sie übergeben:

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

Nach wie vor kann die neue Definition des Rückrufs Funktionen der obersten Ebene akzeptieren, die mit definiert fnwurden. Diese Definition akzeptiert jedoch auch Schließungen || println!("hello world!")als sowie Schließungen, die Werte erfassen, wie z || println!("{}", somevar). Aus diesem Grund muss der Prozessor userdataden Rückruf nicht begleiten. Der vom Aufrufer von bereitgestellte Abschluss set_callbackerfasst automatisch die benötigten Daten aus seiner Umgebung und stellt sie beim Aufrufen zur Verfügung.

Aber was ist mit dem FnMut, warum nicht einfach Fn? Da Closures erfasste Werte enthalten, müssen beim Aufrufen des Closures die üblichen Mutationsregeln von Rust gelten. Abhängig davon, was die Schließungen mit den Werten tun, die sie enthalten, werden sie in drei Familien eingeteilt, die jeweils mit einem Merkmal gekennzeichnet sind:

  • Fnsind Abschlüsse, die nur Daten lesen und sicher mehrmals aufgerufen werden können, möglicherweise von mehreren Threads. Beide oben genannten Verschlüsse sind Fn.
  • FnMutsind Abschlüsse, die Daten ändern, z. B. durch Schreiben in eine erfasste mutVariable. Sie können auch mehrfach aufgerufen werden, jedoch nicht parallel. (Das Aufrufen eines FnMutAbschlusses von mehreren Threads würde zu einem Datenrennen führen, daher kann dies nur unter dem Schutz eines Mutex erfolgen.) Das Abschlussobjekt muss vom Aufrufer als veränderbar deklariert werden.
  • FnOncesind Abschlüsse, die die von ihnen erfassten Daten verbrauchen , z. B. indem sie in eine Funktion verschoben werden, deren Eigentümer sie sind. Wie der Name schon sagt, dürfen diese nur einmal aufgerufen werden, und der Anrufer muss sie besitzen.

Etwas kontraintuitiv, wenn ein Merkmal angegeben wird, das für den Typ eines Objekts gebunden ist, das einen Abschluss akzeptiert, FnOnceist tatsächlich das freizügigste. Die Erklärung, dass ein generischer Rückruftyp das FnOnceMerkmal erfüllen muss, bedeutet, dass er buchstäblich jede Schließung akzeptiert. Das ist aber mit einem Preis verbunden: Der Inhaber darf ihn nur einmal anrufen. Da process_events()der Rückruf möglicherweise mehrmals aufgerufen wird und die Methode selbst mehrmals aufgerufen werden kann, ist die nächsthöhere Grenze FnMut. Beachten Sie, dass wir process_eventsals mutierend markieren mussten self.

Nicht generische Rückrufe: Objekte mit Funktionsmerkmalen

Obwohl die generische Implementierung des Rückrufs äußerst effizient ist, weist sie schwerwiegende Schnittstellenbeschränkungen auf. Jede ProcessorInstanz muss mit einem konkreten Rückruftyp parametrisiert werden. Dies bedeutet, dass eine einzelne Instanz Processornur mit einem einzelnen Rückruftyp umgehen kann. Da jeder Verschluss einen eigenen Typ hat, kann das Generikum Processornicht proc.set_callback(|| println!("hello"))gefolgt von verarbeiten proc.set_callback(|| println!("world")). Um die Struktur auf zwei Rückruffelder zu erweitern, müsste die gesamte Struktur auf zwei Typen parametrisiert werden, was mit zunehmender Anzahl von Rückrufen schnell unhandlich werden würde. Das Hinzufügen weiterer Typparameter würde nicht funktionieren, wenn die Anzahl der Rückrufe dynamisch sein müsste, z. B. um eine add_callbackFunktion zu implementieren , die einen Vektor verschiedener Rückrufe verwaltet.

Um den Typparameter zu entfernen, können wir Merkmalsobjekte nutzen , die Funktion von Rust, die die automatische Erstellung dynamischer Schnittstellen basierend auf Merkmalen ermöglicht. Dies wird manchmal als Typlöschung bezeichnet und ist eine beliebte Technik in C ++ [1] [2] , nicht zu verwechseln mit der etwas anderen Verwendung des Begriffs durch Java- und FP-Sprachen. Mit C ++ vertraute Leser erkennen die Unterscheidung zwischen einem implementierten Abschluss Fnund einem FnMerkmalsobjekt als äquivalent zur Unterscheidung zwischen allgemeinen Funktionsobjekten und std::functionWerten in C ++.

Ein Merkmalsobjekt wird erstellt, indem ein Objekt beim &Bediener ausgeliehen und auf einen Verweis auf das jeweilige Merkmal geworfen oder gezwungen wird. In diesem Fall Processorkönnen wir , da wir das Rückrufobjekt besitzen müssen, keine Ausleihe verwenden, sondern müssen den Rückruf in einem Heap-zugewiesenen Box<dyn Trait>(dem Rust-Äquivalent von std::unique_ptr) speichern , das funktional einem Merkmalsobjekt entspricht.

Wenn Processorgespeichert wird Box<dyn FnMut()>, muss es nicht mehr generisch sein, aber die set_callback Methode akzeptiert jetzt ein generisches cüber ein impl TraitArgument . Als solches kann es jede Art von aufrufbar akzeptieren, einschließlich Schließungen mit Status, und es ordnungsgemäß verpacken, bevor es in der gespeichert wird Processor. Das generische Argument, set_callbackdie Art des Rückrufs, den der Prozessor akzeptiert, nicht einzuschränken, da der Typ des akzeptierten Rückrufs von dem in der ProcessorStruktur gespeicherten Typ entkoppelt ist .

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

Lebensdauer von Referenzen in Boxverschlüssen

Die 'staticLebensdauer, die an den Typ des von cakzeptierten Arguments gebunden set_callbackist, ist eine einfache Möglichkeit, den Compiler davon zu überzeugen, dass die darin enthaltenen Verweisec , bei denen es sich möglicherweise um einen Abschluss handelt, der sich auf seine Umgebung bezieht, nur auf globale Werte verweisen und daher während der gesamten Verwendung von gültig bleiben zurückrufen. Die statische Bindung ist aber auch sehr hartnäckig: Während sie Verschlüsse akzeptiert, die Objekte besitzen, die in Ordnung sind (was wir oben durch das Schließen sichergestellt haben move), lehnt sie Verschlüsse ab, die sich auf die lokale Umgebung beziehen, selbst wenn sie sich nur auf Werte beziehen, die dies tun überleben den Prozessor und wäre in der Tat sicher.

Da wir die Rückrufe nur so lange benötigen, wie der Prozessor aktiv ist, sollten wir versuchen, ihre Lebensdauer an die des Prozessors zu binden, was weniger streng ist als 'static. Wenn wir jedoch nur die 'staticgebundene Lebensdauer entfernen set_callback, wird sie nicht mehr kompiliert. Dies liegt daran, dass set_callbackein neues Feld erstellt und dem callbackals definierten Feld zugewiesen wird Box<dyn FnMut()>. Da die Definition keine Lebensdauer für das Boxed-Trait-Objekt angibt, 'staticist dies impliziert, und die Zuweisung würde die Lebensdauer (von einer unbenannten willkürlichen Lebensdauer des Rückrufs auf 'static) effektiv verlängern , was nicht zulässig ist. Der Fix besteht darin, eine explizite Lebensdauer für den Prozessor bereitzustellen und diese Lebensdauer sowohl mit den Referenzen in der Box als auch mit den Referenzen im Rückruf zu verknüpfen, die empfangen wurden von 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);
    }
    // ...
}

Da diese Lebensdauern explizit angegeben werden, ist eine Verwendung nicht mehr erforderlich 'static. Der Abschluss kann sich nun auf das lokale sObjekt beziehen , muss es also nicht mehr sein move, vorausgesetzt, die Definition von swird vor die Definition von gesetzt, pum sicherzustellen, dass die Zeichenfolge den Prozessor überlebt.

user4815162342
quelle
13
Wow, ich denke das ist die beste Antwort, die ich je auf eine SO-Frage bekommen habe! Danke dir! Perfekt erklärt. Eine Kleinigkeit verstehe ich allerdings nicht - warum muss CBes 'staticim letzten Beispiel sein?
Timmmm
9
Das Box<FnMut()>im Strukturfeld verwendete bedeutet Box<FnMut() + 'static>. Ungefähr "Das Boxed-Trait-Objekt enthält keine Referenzen / Referenzen, die es überlebt (oder gleichwertig) 'static". Es verhindert, dass der Rückruf Einheimische als Referenz erfasst.
Bluss
Ah ich verstehe, denke ich!
Timmmm
1
@Timmmm Weitere Details zum Einband 'staticin einem separaten Blogbeitrag .
user4815162342
3
Dies ist eine fantastische Antwort. Vielen Dank, dass Sie sie unter user4815162342 bereitgestellt haben.
Dash83