Máquinas de estado com tipos
Esta aula explora o padrão Typestate em Rust, que modela máquinas de estado usando o sistema de tipos para garantir em tempo de compilação que operações inválidas sejam impossíveis. Você aprenderá a representar estados como tipos, transições como funções e como isso aumenta a segurança e expressividade do código.
Máquinas de estado são um conceito fundamental em programação, usadas para modelar sistemas que podem estar em diferentes estados e que reagem a eventos realizando transições. Em linguagens tradicionais, o estado é frequentemente representado por uma variável (como um enum ou inteiro) e as transições são verificadas em tempo de execução, o que pode levar a erros se não forem cuidadosamente validadas. Rust oferece uma alternativa poderosa: o padrão Typestate, onde o estado é representado pelo tipo da própria variável. Isso move a validação para o tempo de compilação, eliminando uma classe inteira de bugs.
Nesta aula, vamos mergulhar nesse padrão, entendendo como ele funciona, por que é valioso e como implementá-lo em Rust. Você verá exemplos práticos que demonstram a segurança e a clareza que ele proporciona, além de exercícios para fixar o conceito.
Typestate pattern
O padrão Typestate (também conhecido como "state machine in types") é uma técnica de design em linguagens com sistemas de tipos estáticos avançados, como Rust, onde o estado de um objeto é codificado em seu tipo. Em vez de ter um campo que armazena o estado atual (por exemplo, um enum), o tipo da própria variável indica em qual estado ela se encontra. As operações que fazem transições de estado são funções que consomem o valor atual e produzem um novo valor com um tipo diferente, representando o novo estado.
Esse padrão garante que apenas as operações válidas para um determinado estado possam ser chamadas, já que o compilador rejeita qualquer chamada que não corresponda ao tipo esperado. Isso elimina a necessidade de verificações em tempo de execução (como match ou if) para validar transições, tornando o código mais seguro e auto-documentado. Além disso, o código se torna mais expressivo, pois os estados são explícitos nos tipos das funções.
Vamos ver um exemplo simples: uma conexão de rede que pode estar nos estados Desconectada, Conectada e Autenticada. Em uma implementação tradicional, você teria um enum e verificaria se a operação é permitida. Com Typestate, você define tipos separados para cada estado e funções que transformam um tipo no outro.
Estados como tipos
Em Rust, cada estado é representado por um tipo distinto, geralmente uma struct unitária (sem campos) ou um tipo com dados relevantes. Por exemplo, para uma conexão, poderíamos ter:
struct Desconectada;
struct Conectada;
struct Autenticada;
Esses tipos não possuem campos, mas podem ser estendidos para armazenar informações específicas do estado, como endereço ou token de autenticação. A máquina de estados é então modelada por funções que recebem um estado e retornam outro:
fn conectar(desconectada: Desconectada) -> Conectada {
// lógica de conexão
Conectada
}
fn autenticar(conectada: Conectada) -> Autenticada {
// lógica de autenticação
Autenticada
}
Note que a função conectar só aceita um valor do tipo Desconectada e retorna Conectada. Se você tentar chamá-la com um valor Conectada, o compilador emitirá um erro. Isso impede que você execute uma transição inválida, como autenticar antes de conectar.
O tipo da variável muda conforme as operações são aplicadas, e você precisa usar a variável retornada para prosseguir. Isso cria um fluxo linear de estados, onde o compilador garante que todas as etapas sejam seguidas na ordem correta.
Segurança em compile-time
A principal vantagem do padrão Typestate é a segurança em tempo de compilação. Erros de transição inválida são detectados durante a compilação, não em tempo de execução. Isso reduz drasticamente a possibilidade de bugs que só apareceriam quando o programa fosse executado, especialmente em sistemas críticos como protocolos de rede, drivers de dispositivo ou máquinas de estados em jogos.
Além disso, o código se torna mais robusto porque o compilador força o programador a lidar com todos os estados possíveis. Se você tentar usar um valor em um estado onde a operação não é suportada, o código nem compila. Isso é particularmente útil em APIs públicas, onde os usuários da sua biblioteca não podem erroneamente usar o sistema de forma incorreta.
Outra vantagem é a eliminação de verificações em tempo de execução. Em uma implementação tradicional, você faria algo como:
enum Estado { Desconectada, Conectada, Autenticada }
fn autenticar(estado: Estado) -> Result<Estado, Erro> {
if let Estado::Conectada = estado {
// faz autenticação
Ok(Estado::Autenticada)
} else {
Err(Erro::TransicaoInvalida)
}
}
Com Typestate, essa verificação é desnecessária, pois o tipo garante que você só tem uma Conectada se já passou pela conexão. Isso torna o código mais rápido (sem custo em tempo de execução) e mais simples.
Exemplos
Vamos construir um exemplo mais completo: uma máquina de estados para um processo de login. Teremos os estados Anônimo, Logado e Admin. As transições serão: login (de Anônimo para Logado), promover (de Logado para Admin) e logout (de Logado ou Admin para Anônimo). Implementaremos isso com tipos.
struct Anonimo;
struct Logado(&'static str); // armazena o nome do usuário
struct Admin(&'static str);
impl Anonimo {
fn login(self, username: &'static str) -> Logado {
Logado(username)
}
}
impl Logado {
fn logout(self) -> Anonimo {
Anonimo
}
fn promover(self) -> Admin {
let Logado(username) = self;
Admin(username)
}
}
impl Admin {
fn logout(self) -> Anonimo {
Anonimo
}
}
fn main() {
let usuario = Anonimo;
let usuario = usuario.login("alice");
// let usuario = usuario.login("bob"); // erro: método `login` não existe para `Logado`
let usuario = usuario.promover();
let usuario = usuario.logout();
// agora está Anonimo novamente
}
Observe que tentar chamar login em um valor Logado resulta em erro de compilação, pois o método não está definido para esse tipo. Isso é exatamente a segurança que queremos.
Outro exemplo comum é uma máquina de estados para um protocolo de comunicação, onde você só pode enviar dados após estabelecer conexão, e só pode fechar após terminar. Com Typestate, isso fica explícito nos tipos.
Também é possível usar o padrão Typestate com enums que carregam dados, desde que os estados sejam tipos distintos. A flexibilidade do Rust permite combinar com traits, generics e outras features para criar máquinas de estados ainda mais expressivas.
Boas práticas e observações finais
Ao usar o padrão Typestate, é importante manter os tipos simples e focados. Cada estado deve ter apenas os dados necessários para aquele estado. Evite colocar métodos que não fazem sentido para o estado, pois isso quebraria a garantia de segurança.
Uma boa prática é usar nomes de métodos que indiquem a transição, como connect, send, close, etc. Isso torna o código legível e auto-documentado. Além disso, considere usar #[must_use] nas funções de transição para forçar o uso do valor retornado, evitando que o programador ignore o novo estado.
O padrão Typestate é uma ferramenta poderosa, mas não é adequado para todos os casos. Se o número de estados for muito grande ou as transições forem complexas, pode ser mais simples usar um enum tradicional com validação em runtime. Avalie os trade-offs para cada situação.
Referências
- The Rust Programming Language - Enums
- Rust Reference - Structs
- Rust Reference - Functions
- Rust by Example - Structs
- Rust by Example - Functions
- The Rust Programming Language - Generics
- The Rust Programming Language - Advanced Types
Exercícios
- Defina tipos para uma máquina de estados de um semáforo:
Vermelho,Amarelo,Verde. Implemente as transiçõesavancar(Vermelho -> Verde, Verde -> Amarelo, Amarelo -> Vermelho) eparar(Verde -> Amarelo, Amarelo -> Vermelho). - Crie uma máquina de estados para uma porta que pode estar
AbertaouFechada. Implementeabrir(Fechada -> Aberta) efechar(Aberta -> Fechada). - Implemente uma máquina de estados para um processo de pedido:
Rascunho,Submetido,Aprovado,Rejeitado. Transições:submeter(Rascunho -> Submetido),aprovar(Submetido -> Aprovado),rejeitar(Submetido -> Rejeitado). - Adicione dados aos estados: no exemplo do pedido, faça
Rascunhoarmazenar um título (String),Submetidoarmazenar título e data,Aprovadoarmazenar título, data e aprovador. Implemente as transições preservando os dados. - Escreva um teste que verifica que uma transição inválida não compila. Por exemplo, tente chamar
aprovarem umRascunhoe explique o erro.
struct Vermelho;
struct Amarelo;
struct Verde;
impl Vermelho {
fn avancar(self) -> Verde { Verde }
}
impl Verde {
fn avancar(self) -> Amarelo { Amarelo }
fn parar(self) -> Amarelo { Amarelo }
}
impl Amarelo {
fn avancar(self) -> Vermelho { Vermelho }
fn parar(self) -> Vermelho { Vermelho }
}
struct Aberta;
struct Fechada;
impl Fechada {
fn abrir(self) -> Aberta { Aberta }
}
impl Aberta {
fn fechar(self) -> Fechada { Fechada }
}
struct Rascunho;
struct Submetido;
struct Aprovado;
struct Rejeitado;
impl Rascunho {
fn submeter(self) -> Submetido { Submetido }
}
impl Submetido {
fn aprovar(self) -> Aprovado { Aprovado }
fn rejeitar(self) -> Rejeitado { Rejeitado }
}
struct Rascunho { titulo: String }
struct Submetido { titulo: String, data: String }
struct Aprovado { titulo: String, data: String, aprovador: String }
struct Rejeitado { titulo: String, data: String }
impl Rascunho {
fn submeter(self) -> Submetido {
Submetido { titulo: self.titulo, data: String::from("2025-01-01") }
}
}
impl Submetido {
fn aprovar(self, aprovador: String) -> Aprovado {
Aprovado { titulo: self.titulo, data: self.data, aprovador }
}
fn rejeitar(self) -> Rejeitado {
Rejeitado { titulo: self.titulo, data: self.data }
}
}
O código let r = Rascunho; r.aprovar(); não compila porque o método aprovar não está definido para o tipo Rascunho. O compilador emite um erro como: error[E0599]: no method named `aprovar` found for struct `Rascunho` in the current scope. Isso demonstra a segurança em tempo de compilação: a transição inválida é impedida antes mesmo de executar.