Chamadas idiomáticas em Rust

101

Em C / C ++, eu normalmente faria callbacks com um ponteiro de função simples, talvez passando um void* userdataparâmetro também. Algo assim:

typedef void (*Callback)();

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

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

Qual é a maneira idiomática de fazer isso no Rust? Especificamente, que tipos minha setCallback()função deve assumir e que tipo deve mCallbackser? Deve demorar um Fn? Talvez FnMut? Eu salvo Boxed? Um exemplo seria incrível.

Timmmm
fonte

Respostas:

201

Resposta curta: para flexibilidade máxima, você pode armazenar o retorno de chamada como um FnMutobjeto em caixa , com o setter de retorno de chamada genérico no tipo de retorno de chamada. O código para isso é mostrado no último exemplo da resposta. Para uma explicação mais detalhada, continue lendo.

"Ponteiros de função": retornos de chamada como fn

O equivalente mais próximo do código C ++ na questão seria declarar o retorno de chamada como um fntipo. fnencapsula funções definidas pela fnpalavra - chave, muito parecido com os ponteiros de função do 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!
}

Esse código pode ser estendido para incluir um Option<Box<Any>>para conter os "dados do usuário" associados à função. Mesmo assim, não seria Rust idiomático. A maneira do Rust de associar dados a uma função é capturá-los em um fechamento anônimo , assim como no C ++ moderno. Como os fechamentos não são fn, set_callbackele precisará aceitar outros tipos de objetos de função.

Callbacks como objetos de função genéricos

Em ambos os fechamentos Rust e C ++ com a mesma assinatura de chamada vêm em tamanhos diferentes para acomodar os diferentes valores que eles podem capturar. Além disso, cada definição de fechamento gera um tipo anônimo exclusivo para o valor do fechamento. Devido a essas restrições, a estrutura não pode nomear o tipo de seu callbackcampo, nem pode usar um alias.

Uma maneira de inserir um encerramento no campo de estrutura sem se referir a um tipo concreto é tornando a estrutura genérica . A estrutura irá adaptar automaticamente seu tamanho e o tipo de retorno de chamada para a função concreta ou fechamento que você passar para ela:

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, a nova definição de retorno de chamada será capaz de aceitar funções de nível superior definidas com fn, mas esta também aceitará encerramentos como || println!("hello world!"), bem como encerramentos que capturem valores, como || println!("{}", somevar). Por isso, o processador não precisa userdataacompanhar o retorno de chamada; o encerramento fornecido pelo chamador de set_callbackcapturará automaticamente os dados de que precisa de seu ambiente e os terá disponíveis quando chamado.

Mas qual é o problema FnMut, por que não apenas Fn? Uma vez que os encerramentos mantêm valores capturados, as regras de mutação usuais de Rust devem ser aplicadas ao chamar o encerramento. Dependendo do que os fechamentos fazem com os valores que mantêm, eles são agrupados em três famílias, cada uma marcada com uma característica:

  • Fnsão encerramentos que apenas leem dados e podem ser chamados com segurança várias vezes, possivelmente a partir de vários threads. Ambos os fechamentos acima são Fn.
  • FnMutsão fechamentos que modificam dados, por exemplo, gravando em uma mutvariável capturada . Eles também podem ser chamados várias vezes, mas não em paralelo. (Chamar um FnMutencerramento de vários threads levaria a uma disputa de dados, portanto, isso só pode ser feito com a proteção de um mutex.) O objeto de encerramento deve ser declarado mutável pelo chamador.
  • FnOncesão fechamentos que consomem alguns dos dados que capturam, por exemplo, movendo um valor capturado para uma função que assume sua propriedade. Como o nome indica, eles podem ser chamados apenas uma vez e o chamador deve ser o proprietário deles.

Um tanto contra-intuitivamente, ao especificar um traço ligado ao tipo de um objeto que aceita um fechamento, FnOnceé na verdade o mais permissivo. Declarar que um tipo de retorno de chamada genérico deve satisfazer a FnOncecaracterística significa que ele aceitará literalmente qualquer encerramento. Mas isso tem um preço: significa que o titular só pode fazer a chamada uma vez. Como process_events()pode optar por invocar o retorno de chamada várias vezes e como o próprio método pode ser chamado mais de uma vez, o próximo limite mais permissivo é FnMut. Observe que tivemos que marcar process_eventscomo mutante self.

Callbacks não genéricos: objetos de característica de função

Embora a implementação genérica do retorno de chamada seja extremamente eficiente, ela possui sérias limitações de interface. Exige que cada Processorinstância seja parametrizada com um tipo de retorno de chamada concreto, o que significa que um só Processorpode lidar com um único tipo de retorno de chamada. Dado que cada closure possui um tipo distinto, o genérico Processornão pode manipular proc.set_callback(|| println!("hello"))seguido por proc.set_callback(|| println!("world")). Estender a estrutura para suportar dois campos de retorno de chamada exigiria que toda a estrutura fosse parametrizada em dois tipos, o que rapidamente se tornaria difícil de controlar conforme o número de retornos de chamada aumentasse. Adicionar mais parâmetros de tipo não funcionaria se o número de callbacks precisasse ser dinâmico, por exemplo, para implementar uma add_callbackfunção que mantém um vetor de callbacks diferentes.

Para remover o parâmetro de tipo, podemos tirar proveito de objetos de características , o recurso do Rust que permite a criação automática de interfaces dinâmicas com base em características. Isso às vezes é referido como eliminação de tipo e é uma técnica popular em C ++ [1] [2] , que não deve ser confundida com o uso um pouco diferente do termo nas linguagens Java e FP. Os leitores familiarizados com C ++ reconhecerão a distinção entre um fechamento que implementa Fne um Fnobjeto de característica como equivalente à distinção entre objetos de função geral e std::functionvalores em C ++.

Um objeto de característica é criado pegando emprestado um objeto com o &operador e lançando-o ou coagindo-o a uma referência à característica específica. Nesse caso, uma vez que Processorprecisa possuir o objeto de retorno de chamada, não podemos usar o empréstimo, mas devemos armazenar o retorno de chamada em um heap alocado Box<dyn Trait>(o equivalente em Rust de std::unique_ptr), que é funcionalmente equivalente a um objeto de característica.

Se Processorarmazena Box<dyn FnMut()>, ele não precisa mais ser genérico, mas o set_callback método agora aceita um genérico cpor meio de um impl Traitargumento . Como tal, ele pode aceitar qualquer tipo de chamada, incluindo fechamentos com estado, e encaixotá-lo adequadamente antes de armazená-lo no Processor. O argumento genérico para set_callbacknão limita o tipo de retorno de chamada que o processador aceita, pois o tipo de retorno de chamada aceito é desacoplado do tipo armazenado na Processorestrutura.

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

Tempo de vida das referências dentro de fechos em caixas

O 'statictempo de vida limitado ao tipo de cargumento aceito por set_callbacké uma maneira simples de convencer o compilador de que as referências contidas em c, que pode ser um fechamento que se refere ao seu ambiente, se referem apenas a valores globais e, portanto, permanecerão válidas durante o uso do ligue de volta. Mas o limite estático também é muito pesado: embora aceite fechamentos que possuem objetos muito bem (o que garantimos acima ao fazer o fechamento move), ele rejeita fechamentos que se referem ao ambiente local, mesmo quando eles se referem apenas a valores que sobreviveria ao processador e seria de fato seguro.

Como precisamos apenas dos callbacks ativos enquanto o processador estiver ativo, devemos tentar vincular seu tempo de vida ao do processador, que é um limite menos estrito do que 'static. Mas se apenas removermos o 'staticlimite de vida set_callback, ele não será mais compilado. Isso ocorre porque set_callbackcria uma nova caixa e a atribui ao callbackcampo definido como Box<dyn FnMut()>. Como a definição não especifica um tempo de vida para o objeto de característica em caixa, 'staticestá implícito, e a atribuição efetivamente ampliaria o tempo de vida (de um tempo de vida arbitrário sem nome do retorno de chamada para 'static), o que não é permitido. A correção é fornecer um tempo de vida explícito para o processador e vincular esse tempo de vida às referências na caixa e às referências no retorno de chamada recebido 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);
    }
    // ...
}

Com essas vidas tornadas explícitas, não é mais necessário usar 'static. O fechamento agora pode se referir ao sobjeto local , ou seja, não precisa mais ser move, desde que a definição de sseja colocada antes da definição de ppara garantir que a string sobreviva ao processador.

user4815162342
fonte
16
Uau, acho que esta é a melhor resposta que já recebi para uma pergunta SO! Obrigado! Explicado perfeitamente. Porém, uma coisa pequena não entendo - por que CBtem que estar 'staticno exemplo final?
Timmmm
9
O Box<FnMut()>usado no campo de estrutura significa Box<FnMut() + 'static>. Aproximadamente "O objeto de traço em caixa não contém referências / quaisquer referências que contenha outlive (ou igual) 'static". Impede que o retorno de chamada capture locais por referência.
bluss
Ah entendo, eu acho!
Timmmm
1
@Timmmm Mais detalhes sobre o 'staticencadernamento em uma postagem separada no blog .
user4815162342
3
Esta é uma resposta fantástica, obrigado por fornecer @ user4815162342.
Dash83