Callbacks idiomáticos en Rust
(1)
Respuesta corta: para obtener la máxima flexibilidad, puede almacenar la devolución de llamada como un objeto
FnMut
caja, con el
FnMut
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 en la respuesta.
Para 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 tipo
fn
.
fn
encapsula funciones definidas por la palabra clave
fn
, 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 mut p = Processor { callback: simple_callback };
p.process_events(); // hello world!
}
Este código podría extenderse para incluir una
Option<Box<Any>>
para contener los "datos de usuario" asociados con la función.
Aun así, no sería Rid idiomático.
La forma Rust de asociar datos con una función es capturarlos en un
cierre
anónimo, al igual que en C ++ moderno.
Como los cierres no son
fn
,
set_callback
deberá aceptar otros tipos de objetos de función.
Callbacks como objetos de función genéricos
Tanto en los cierres Rust como en C ++ con la misma firma de llamada vienen en diferentes tamaños para acomodar diferentes tamaños de los valores capturados que almacenan en el objeto de cierre. Además, cada sitio de cierre genera un tipo anónimo distinto que es el tipo del objeto de cierre en tiempo de compilación. Debido a estas restricciones, la estructura no puede hacer referencia al tipo de devolución de llamada por nombre o un alias de tipo.
Una forma de poseer un cierre en la estructura sin referirse a un tipo concreto es haciendo 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 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!")
|| println!("hello world!")
, así como cierres que capturan valores, como
|| println!("{}", somevar)
|| println!("{}", somevar)
.
Debido a esto, el cierre no necesita un argumento separado de datos de
userdata
;
simplemente puede capturar los datos de su entorno y estará disponible cuando se llame.
Pero, ¿cuál es el trato con el
FnMut
, por qué no solo
Fn
?
Dado que los cierres contienen valores capturados, Rust aplica en ellos las mismas reglas que en otros objetos de contenedor.
Dependiendo de lo que hagan los cierres con los valores que poseen, se agrupan en tres familias, cada una marcada con un rasgo:
-
Fn
son cierres que solo leen datos, y se pueden llamar de forma segura varias veces, posiblemente desde múltiples subprocesos. Los dos cierres anteriores sonFn
. -
FnMut
son cierres que modifican datos, por ejemplo, escribiendo en una variablemut
capturada. También pueden llamarse varias veces, pero no en paralelo. (Llamar a un cierre deFnMut
desde múltiples subprocesos conduciría a una carrera de datos, por lo que solo se puede hacer con la protección de un mutex). El llamante debe declarar el objeto de cierre mutable. -
FnOnce
son cierres que consumen los datos que capturan, por ejemplo, moviéndolos a una función que los posee. Como su nombre lo indica, estos pueden llamarse solo una vez, y la persona que llama debe ser el propietario.
Algo contradictorio, cuando se especifica un rasgo limitado para el tipo de objeto que acepta un cierre,
FnOnce
es en realidad el más permisivo.
Declarar que un tipo de devolución de llamada genérico debe satisfacer el rasgo
FnOnce
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 como se puede llamar al método en sí más de una vez, el siguiente límite más permisivo es
FnMut
.
Tenga en cuenta que tuvimos que marcar
process_events
como
self
mutante.
Callbacks no genéricos: objetos de rasgos 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 instancia del
Processor
se parametrice con un tipo de devolución de llamada concreto, lo que significa que un solo
Processor
solo puede tratar con un solo tipo de devolución de llamada.
Dado que cada cierre tiene un tipo distinto, el
Processor
genérico no puede manejar
proc.set_callback(|| println!("hello"))
seguido de
proc.set_callback(|| println!("world"))
.
Extender la estructura para admitir dos campos de devolución de llamada requeriría que la estructura completa se parametrice a 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 tuviera que ser dinámico, por ejemplo, para implementar una función
add_callback
que mantenga un vector de devoluciones de llamada diferentes.
Para eliminar el parámetro de tipo, podemos aprovechar los
objetos
de
rasgos
, la característica de Rust que permite la creación automática de interfaces dinámicas basadas en rasgos.
Esto a veces se conoce como
borrado de tipo
y es una técnica popular en C ++
[1]
[2]
, que no debe confundirse con el uso algo diferente del término de los lenguajes Java y FP.
Los lectores familiarizados con C ++ reconocerán la distinción entre un cierre que implementa
Fn
y un objeto de rasgo
Fn
como equivalente a la distinción entre objetos de función general y valores de
std::function
en C ++.
Un objeto de rasgo se crea tomando prestado un objeto con el operador
&
y convirtiéndolo o coaccionándolo a una referencia al rasgo específico.
En este caso, dado que el
Processor
necesita ser el propietario del objeto de devolución de llamada, no podemos usar préstamos, sino que debemos almacenar la devolución de llamada en un
Box<Trait>
asignado por el montón (el equivalente Rust de
std::unique_ptr
), que es funcionalmente equivalente a un rasgo objeto.
Si el
Processor
almacena
Box<FnMut()>
, ya no necesita ser genérico, pero el
método
set_callback
ahora es genérico, por lo que puede
set_callback
correctamente lo que se le pueda
set_callback
antes de almacenar el box en el
Processor
.
La devolución de llamada puede ser de cualquier tipo siempre que no consuma los valores capturados.
set_callback
siendo genérico no incurre en las limitaciones discutidas anteriormente, ya que no afecta la interfaz de los datos almacenados en la estructura.
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();
}
En C / C ++ normalmente haría devoluciones de llamada con un puntero de función simple, tal vez pasando también un parámetro
void* userdata
.
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()
mi función
setCallback()
y qué tipo debería ser
mCallback
?
¿Debería tomar un
Fn
?
Tal vez
FnMut
?
¿Lo guardo en
Boxed
?
Un ejemplo sería asombroso.