Devolución de llamada idiomática en Rust

100

En C / C ++ normalmente haría devoluciones de llamada con un puntero de función simple, tal vez pasando un void* userdataparámetro también. Algo como esto:

typedef void (*Callback)();

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

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

¿Cuál es la forma idiomática de hacer esto en Rust? Específicamente, ¿qué tipos debería setCallback()tomar mi función y qué tipo debería mCallbackser? ¿Debería tomar un Fn? FnMut¿ Quizás ? ¿Lo guardo Boxed? Un ejemplo sería asombroso.

Timmmm
fuente

Respuestas:

195

Respuesta corta: para una máxima flexibilidad, puede almacenar la devolución de llamada como un FnMutobjeto en caja , con el establecedor de devolución de llamada genérico en el tipo de devolución de llamada. El código para esto se muestra en el último ejemplo de la respuesta. Para obtener una explicación más detallada, sigue leyendo.

"Punteros de función": devoluciones de llamada como fn

El equivalente más cercano del código C ++ en la pregunta sería declarar la devolución de llamada como un fntipo. fnencapsula funciones definidas por la fnpalabra clave, al igual que los punteros de función de 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!
}

Este código podría ampliarse para incluir un Option<Box<Any>>para contener los "datos de usuario" asociados con la función. Aun así, no sería idiomático Rust. La forma de Rust de asociar datos con una función es capturarlos en un cierre anónimo , al igual que en C ++ moderno. Dado que los cierres no lo son fn, set_callbackdeberá aceptar otros tipos de objetos de función.

Devoluciones de llamada como objetos de función genéricos

Tanto en Rust como en C ++, los cierres con la misma firma de llamada vienen en diferentes tamaños para adaptarse a los diferentes valores que pueden capturar. Además, cada definición de cierre genera un tipo anónimo único para el valor del cierre. Debido a estas restricciones, la estructura no puede nombrar el tipo de su callbackcampo, ni puede usar un alias.

Una forma de incrustar un cierre en el campo de estructura sin hacer referencia a un tipo concreto es hacer que la estructura sea genérica . La estructura adaptará automáticamente su tamaño y el tipo de devolución de llamada para la función concreta o el cierre que le pase:

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

Como antes, la nueva definición de devolución de llamada podrá aceptar funciones de nivel superior definidas con fn, pero esta también aceptará cierres como || println!("hello world!"), así como cierres que capturan valores, como || println!("{}", somevar). Debido a esto, el procesador no necesita userdataacompañar la devolución de llamada; el cierre proporcionado por la persona que llamaset_callback capturará automáticamente los datos que necesita de su entorno y los tendrá disponibles cuando se invoca.

Pero, ¿cuál es el problema con FnMut, por qué no solo Fn? Dado que los cierres contienen valores capturados, las reglas de mutación habituales de Rust deben aplicarse al llamar al cierre. Dependiendo de lo que hagan los cierres con los valores que tengan, se agrupan en tres familias, cada una marcada con un rasgo:

  • Fnson cierres que solo leen datos y se pueden llamar de forma segura varias veces, posiblemente desde varios subprocesos. Ambos cierres anteriores son Fn.
  • FnMutson cierres que modifican datos, por ejemplo, escribiendo en una mutvariable capturada . También se pueden llamar varias veces, pero no en paralelo. (Llamar a un FnMutcierre desde varios subprocesos conduciría a una carrera de datos, por lo que solo se puede hacer con la protección de un mutex). El llamador debe declarar el objeto de cierre como mutable.
  • FnOnceson cierres que consumen parte de los datos que capturan, por ejemplo, al mover un valor capturado a una función que toma su propiedad. Como su nombre lo indica, solo se pueden llamar una vez y la persona que llama debe poseerlos.

Algo contrario a la intuición, cuando se especifica un rasgo vinculado al tipo de objeto que acepta un cierre, FnOncees en realidad el más permisivo. Declarar que un tipo de devolución de llamada genérico debe satisfacer el FnOncerasgo significa que aceptará literalmente cualquier cierre. Pero eso tiene un precio: significa que el titular solo puede llamarlo una vez. Dado que process_events()puede optar por invocar la devolución de llamada varias veces, y dado que el método en sí puede llamarse más de una vez, el siguiente límite más permisivo es FnMut. Tenga en cuenta que tuvimos que marcar process_eventscomo mutante self.

Devoluciones de llamada no genéricas: objetos de rasgo de función

Aunque la implementación genérica de la devolución de llamada es extremadamente eficiente, tiene serias limitaciones de interfaz. Requiere que cada Processorinstancia esté parametrizada con un tipo de devolución de llamada concreto, lo que significa que una sola Processorsolo puede tratar con un solo tipo de devolución de llamada. Dado que cada cierre tiene un tipo distinto, el genérico Processorno puede manejar proc.set_callback(|| println!("hello"))seguido de proc.set_callback(|| println!("world")). Extender la estructura para admitir dos campos de devoluciones de llamada requeriría que toda la estructura se parametrice en dos tipos, lo que rápidamente se volvería difícil de manejar a medida que aumenta el número de devoluciones de llamada. Agregar más parámetros de tipo no funcionaría si el número de devoluciones de llamada necesitara ser dinámico, por ejemplo, para implementar una add_callbackfunción que mantiene un vector de diferentes devoluciones de llamada.

Para eliminar el parámetro de tipo, podemos aprovechar los objetos de rasgo , la característica de Rust que permite la creación automática de interfaces dinámicas basadas en rasgos. Esto a veces se denomina borrado de tipos y es una técnica popular en C ++ [1] [2] , que no debe confundirse con el uso algo diferente del término en los lenguajes Java y FP. Los lectores familiarizados con C ++ reconocerán la distinción entre un cierre que implementa Fny un Fnobjeto de rasgo como equivalente a la distinción entre objetos de función general y std::functionvalores en C ++.

Un objeto de rasgo se crea tomando prestado un objeto con el &operador y lanzándolo o forzándolo a hacer una referencia al rasgo específico. En este caso, dado que Processornecesita poseer el objeto de devolución de llamada, no podemos usar el préstamo, pero debemos almacenar la devolución de llamada en un montón asignado Box<dyn Trait>(el equivalente de Rust std::unique_ptr), que es funcionalmente equivalente a un objeto de rasgo.

Si se Processoralmacena Box<dyn FnMut()>, ya no necesita ser genérico, pero el set_callback método ahora acepta un genérico a ctravés de un impl Traitargumento . Como tal, puede aceptar cualquier tipo de invocable, incluidos los cierres con estado, y empaquetarlo correctamente antes de almacenarlo en el archivo Processor. El argumento genérico de set_callbackno limita el tipo de devolución de llamada que acepta el procesador, ya que el tipo de devolución de llamada aceptada está desacoplado del tipo almacenado en la Processorestructura.

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

Vida útil de las referencias dentro de los cierres en caja

El 'staticlímite de duración del tipo de cargumento aceptado por set_callbackes una forma sencilla de convencer al compilador de que las referencias contenidas en c, que podría ser un cierre que se refiere a su entorno, solo se refieren a valores globales y, por lo tanto, seguirán siendo válidas durante el uso de la llamar de vuelta. Pero el límite estático también es muy torpe: si bien acepta cierres que poseen objetos bien (lo cual nos hemos asegurado anteriormente al hacer el cierre move), rechaza los cierres que se refieren al entorno local, incluso cuando solo se refieren a valores que sobrevivirá al procesador y, de hecho, sería seguro.

Como solo necesitamos las devoluciones de llamada activas mientras el procesador esté vivo, deberíamos intentar vincular su vida útil a la del procesador, que es un límite menos estricto que 'static. Pero si simplemente eliminamos el 'staticlímite de por vida set_callback, ya no se compila. Esto se debe a que set_callbackcrea un nuevo cuadro y lo asigna al callbackcampo definido como Box<dyn FnMut()>. Dado que la definición no especifica una vida útil para el objeto de rasgo en caja, 'staticestá implícita, y la asignación ampliaría efectivamente la vida útil (desde una vida útil arbitraria sin nombre de la devolución de llamada a 'static), lo cual no está permitido. La solución es proporcionar una vida útil explícita para el procesador y vincular esa vida útil tanto a las referencias en el cuadro como a las referencias en la devolución de llamada recibida por 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);
    }
    // ...
}

Dado que estas vidas se hacen explícitas, ya no es necesario usarlo 'static. El cierre ahora puede hacer referencia al sobjeto local , es decir, ya no tiene que serlo move, siempre que la definición de sse coloque antes de la definición de ppara garantizar que la cadena sobreviva al procesador.

usuario4815162342
fuente
15
¡Vaya, creo que esta es la mejor respuesta que he recibido para una pregunta SO! ¡Gracias! Perfectamente explicado. Sin embargo, no entiendo una cosa menor: ¿por qué CBtiene que estar 'staticen el ejemplo final?
Timmmm
9
El Box<FnMut()>usado en el campo de estructura significa Box<FnMut() + 'static>. Aproximadamente "El objeto de rasgo en caja no contiene referencias / cualquier referencia que contenga sobrevive (o igual) 'static". Evita que la devolución de llamada capture a los locales por referencia.
sonroja el
¡Ah, ya veo, creo!
Timmmm
1
@Timmmm Más detalles sobre el 'staticencuadernado en una publicación de blog separada .
user4815162342
3
Esta es una respuesta fantástica, gracias por proporcionarla @ user4815162342.
Dash83