Idiomatyczne połączenia zwrotne w Rust

101

W C / C ++ normalnie wykonywałbym wywołania zwrotne ze zwykłym wskaźnikiem funkcji, może też przekazując void* userdataparametr. Coś takiego:

typedef void (*Callback)();

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

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

Jaki jest idiomatyczny sposób na zrobienie tego w Rust? W szczególności, jakie typy powinna setCallback()przyjmować moja funkcja i jaki powinien mCallbackbyć typ ? Czy powinno to zająć Fn? Może FnMut? Czy ja to zapisuję Boxed? Przykład byłby niesamowity.

Timmmm
źródło

Odpowiedzi:

201

Krótka odpowiedź: aby uzyskać maksymalną elastyczność, możesz przechowywać wywołanie zwrotne jako FnMutobiekt w ramce , z generycznym ustawiaczem wywołań zwrotnych dla typu wywołania zwrotnego. Kod do tego jest pokazany w ostatnim przykładzie odpowiedzi. Aby uzyskać bardziej szczegółowe wyjaśnienia, czytaj dalej.

„Wskaźniki funkcji”: wywołania zwrotne jako fn

Najbliższym odpowiednikiem kodu C ++ w pytaniu byłoby zadeklarowanie wywołania zwrotnego jako fntypu. fnhermetyzuje funkcje zdefiniowane przez fnsłowo kluczowe, podobnie jak wskaźniki funkcji w 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!
}

Ten kod można rozszerzyć, aby zawierał Option<Box<Any>>„dane użytkownika” związane z funkcją. Mimo to nie byłaby to idiomatyczna rdza. Sposób w Rust na skojarzenie danych z funkcją polega na przechwyceniu ich w anonimowym zamknięciu , tak jak we współczesnym C ++. Ponieważ domknięcia nie są fn, set_callbackbędą musiały zaakceptować inne rodzaje obiektów funkcyjnych.

Wywołania zwrotne jako ogólne obiekty funkcji

Zarówno w Rust, jak i C ++ domknięcia z tą samą sygnaturą wywołania mają różne rozmiary, aby pomieścić różne wartości, które mogą przechwycić. Ponadto każda definicja zamknięcia generuje unikalny typ anonimowy dla wartości zamknięcia. Z powodu tych ograniczeń struktura nie może nazwać typu swojego callbackpola ani użyć aliasu.

Jednym ze sposobów osadzenia zamknięcia w polu struct bez odwoływania się do konkretnego typu jest utworzenie struktury ogólnej . Struktura automatycznie dostosuje swój rozmiar i typ wywołania zwrotnego dla konkretnej funkcji lub zamknięcia, które do niej przekażesz:

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

Tak jak poprzednio, nowa definicja wywołania zwrotnego będzie mogła akceptować funkcje najwyższego poziomu zdefiniowane za pomocą fn, ale ta będzie również akceptować domknięcia || println!("hello world!"), a także zamknięcia, które przechwytują wartości, takie jak || println!("{}", somevar). Z tego powodu procesor nie musi userdatatowarzyszyć wywołaniu zwrotnemu; zamknięcie zapewniane przez dzwoniącego set_callbackautomatycznie przechwytuje potrzebne dane z jego środowiska i udostępnia je po wywołaniu.

Ale o co chodzi FnMut, dlaczego nie tylko Fn? Ponieważ domknięcia przechowują przechwycone wartości, podczas wywoływania zamknięcia muszą obowiązywać zwykłe reguły mutacji Rusta. W zależności od tego, co zamknięcia robią z wartościami, które wyznają, są pogrupowane w trzy rodziny, z których każda jest oznaczona cechą:

  • Fnsą zamknięciami, które tylko odczytują dane i mogą być bezpiecznie wywoływane wiele razy, prawdopodobnie z wielu wątków. Oba powyższe zamknięcia są Fn.
  • FnMutto domknięcia, które modyfikują dane, np. poprzez zapis do przechwyconej mutzmiennej. Można je również wywoływać wielokrotnie, ale nie równolegle. (Wywołanie FnMutzamknięcia z wielu wątków doprowadziłoby do wyścigu danych, więc można to zrobić tylko z ochroną muteksu). Obiekt zamknięcia musi być zadeklarowany jako zmienny przez obiekt wywołujący.
  • FnOncesą domknięciami, które pochłaniają część danych, które przechwytują, np. przenosząc przechwyconą wartość do funkcji, która przejmuje jej własność. Jak sama nazwa wskazuje, można je wywołać tylko raz, a dzwoniący musi je posiadać.

Nieco sprzecznie z intuicją, określając cechę związaną z typem obiektu, który akceptuje zamknięcie, FnOncejest w rzeczywistości najbardziej tolerancyjna. Zadeklarowanie, że ogólny typ wywołania zwrotnego musi spełniać FnOncecechę, oznacza, że ​​zaakceptuje dosłownie każde zamknięcie. Ale ma to swoją cenę: oznacza to, że posiadacz może zadzwonić tylko raz. Ponieważ process_events()może zdecydować się na wielokrotne wywołanie wywołania zwrotnego, a sama metoda może być wywoływana więcej niż raz, następną najbardziej dopuszczalną granicą jest FnMut. Zauważ, że musieliśmy oznaczyć process_eventsjako mutujący self.

Nieogólne wywołania zwrotne: obiekty cech funkcji

Mimo że generyczna implementacja wywołania zwrotnego jest niezwykle wydajna, ma poważne ograniczenia dotyczące interfejsu. Wymaga Processorsparametryzowania każdej instancji za pomocą konkretnego typu wywołania zwrotnego, co oznacza, że ​​pojedynczy Processormoże obsługiwać tylko jeden typ wywołania zwrotnego. Biorąc pod uwagę, że każde zamknięcie ma inny typ, rodzaj ogólny Processornie może obsłużyć, proc.set_callback(|| println!("hello"))po którym następuje proc.set_callback(|| println!("world")). Rozszerzenie struktury w celu obsługi dwóch pól wywołań zwrotnych wymagałoby sparametryzowania całej struktury do dwóch typów, co szybko stałoby się nieporęczne w miarę wzrostu liczby wywołań zwrotnych. Dodanie większej liczby parametrów typu nie zadziałałoby, gdyby liczba wywołań zwrotnych musiała być dynamiczna, np. W celu zaimplementowania add_callbackfunkcji, która utrzymuje wektor różnych wywołań zwrotnych.

Aby usunąć parametr typu, możemy skorzystać z obiektów cech , funkcji Rusta, która umożliwia automatyczne tworzenie dynamicznych interfejsów na podstawie cech. Jest to czasami określane jako wymazywanie typu i jest popularną techniką w C ++ [1] [2] , której nie należy mylić z nieco innym użyciem tego terminu w językach Java i FP. Czytelnicy zaznajomieni z C ++ rozpoznają różnicę między zamknięciem, które implementuje, Fna Fnobiektem cechy jako równoważną rozróżnieniu między ogólnymi obiektami funkcji a std::functionwartościami w C ++.

Obiekt cechy tworzony jest przez wypożyczenie obiektu od &operatora i rzucenie lub wymuszenie odniesienia do określonej cechy. W tym przypadku, ponieważ Processormusimy posiadać obiekt wywołania zwrotnego, nie możemy użyć wypożyczania, ale musimy przechowywać wywołanie zwrotne w przydzielonej stercie Box<dyn Trait>(odpowiednik Rusta std::unique_ptr), co jest funkcjonalnym odpowiednikiem obiektu cechy.

Jeśli Processorprzechowuje Box<dyn FnMut()>, nie musi już być rodzajowy, ale set_callback metoda akceptuje teraz rodzaj ogólny cza pośrednictwem impl Traitargumentu . W związku z tym może akceptować wszelkiego rodzaju wywołania, w tym zamknięcia ze stanem, i odpowiednio je zapakować przed przechowywaniem w Processor. Ogólny argument set_callbacknie ogranicza rodzaju wywołania zwrotnego akceptowanego przez procesor, ponieważ typ zaakceptowanego wywołania zwrotnego jest oddzielony od typu przechowywanego w Processorstrukturze.

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

Żywotność referencji w zamknięciach pudełkowych

Okres 'staticistnienia związany z typem cargumentu akceptowanego przez set_callbackjest prostym sposobem na przekonanie kompilatora, że zawarte w nim odwołaniac , które mogą być zamknięciem odnoszącym się do jego środowiska, odnoszą się tylko do wartości globalnych i dlatego pozostaną ważne przez cały czas używania oddzwonić. Ale statyczne wiązanie jest również bardzo trudne: chociaż akceptuje zamknięcia, które są właścicielami obiektów w porządku (co zapewniliśmy powyżej, wykonując zamknięcie move), odrzuca domknięcia, które odnoszą się do środowiska lokalnego, nawet jeśli odnoszą się tylko do wartości, które przeżyje procesor i faktycznie będzie bezpieczny.

Ponieważ potrzebujemy żywych wywołań zwrotnych tylko tak długo, jak działa procesor, powinniśmy spróbować powiązać ich żywotność z czasem życia procesora, co jest mniej ścisłe niż 'static. Ale jeśli po prostu usuniemy 'staticzwiązany czas życia z set_callback, to już się nie kompiluje. Dzieje się tak, ponieważ set_callbacktworzy nowe pudełko i przypisuje je do callbackpola zdefiniowanego jako Box<dyn FnMut()>. Ponieważ definicja nie określa okresu istnienia obiektu cechy w pudełku, 'staticjest implikowana, a przypisanie skutecznie rozszerzyłoby okres istnienia (z nienazwanego dowolnego czasu życia wywołania zwrotnego do 'static), co jest niedozwolone. Rozwiązaniem jest zapewnienie wyraźnego czasu życia procesora i powiązanie tego czasu życia zarówno z odwołaniami w polu, jak i odwołaniami w wywołaniu zwrotnym otrzymanym przez 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);
    }
    // ...
}

Ponieważ te okresy życia są wyraźnie określone, nie jest już konieczne używanie 'static. Zamknięcie może teraz odnosić się do sobiektu lokalnego , tj. Nie musi już być move, pod warunkiem, że definicja szostanie umieszczona przed definicją, paby zapewnić, że ciąg znaków przeżyje procesor.

user4815162342
źródło
16
Wow, myślę, że to najlepsza odpowiedź, jaką kiedykolwiek otrzymałem na TAKIE pytanie! Dziękuję Ci! Doskonale wyjaśnione. Nie rozumiem jednak jednej drobnej rzeczy - dlaczego CBmusi być 'staticw ostatnim przykładzie?
Timmmm,
9
Box<FnMut()>Stosowane w środkach polowych Struct Box<FnMut() + 'static>. Z grubsza: „Obiekt cechy w ramce nie zawiera żadnych odniesień / żadnych odniesień, które zawiera, jest żywy (lub równy) 'static”. Zapobiega przechwytywaniu przez wywołanie zwrotne miejscowych przez odniesienie.
bluss
Ach, tak myślę!
Timmmm,
1
@Timmmm Więcej szczegółów o oprawie 'staticw osobnym poście na blogu .
user4815162342
3
To fantastyczna odpowiedź, dziękujemy za jej udzielenie @ user4815162342.
Dash83