Como usar o sistema de tipos do Rust para modelar estados inválidos impossíveis
1. Introdução ao conceito de "estados inválidos impossíveis"
1.1. O que são estados inválidos e por que eliminá-los em tempo de compilação
Estados inválidos são configurações de dados que não deveriam existir no domínio do problema, mas que acabam sendo representáveis devido a limitações da linguagem. Em Rust, o sistema de tipos permite tornar esses estados literalmente impossíveis de serem representados, deslocando a detecção de erros do tempo de execução para o tempo de compilação. Isso reduz drasticamente a necessidade de testes de unidade para validar invariantes e elimina classes inteiras de bugs.
1.2. Comparação com abordagens tradicionais
Em linguagens como Java ou Python, é comum usar boolean flags, valores nulos ou exceções para representar estados. Por exemplo:
// Abordagem frágil em Java
class Conexao {
boolean conectado = false;
void enviar(String msg) {
if (!conectado) throw new IllegalStateException();
// ...
}
}
Esse padrão é frágil porque o estado inválido (enviar mensagem sem estar conectado) só é detectado em execução. Em Rust, podemos modelar isso de forma que o compilador simplesmente não permita chamar enviar() no estado errado.
1.3. Visão geral do sistema de tipos do Rust
Rust oferece tipos algébricos (enums com dados associados), pattern matching exaustivo e ownership. Esses três pilares, combinados, permitem criar modelos de estado onde cada transição é verificada estaticamente.
2. Usando enum para representar estados mutuamente exclusivos
2.1. Modelando uma conexão de rede
enum Conexao {
Desconectado,
Conectando,
Conectado { endereco: String },
}
Aqui, Conectado carrega dados adicionais (o endereço), enquanto Desconectado e Conectando não têm dados associados. Isso já impede que um código tente acessar o endereço de uma conexão que não está conectada.
2.2. Garantindo operações no estado correto
impl Conexao {
fn enviar(&self, mensagem: &str) -> Result<(), &str> {
match self {
Conexao::Conectado { endereco } => {
println!("Enviando '{}' para {}", mensagem, endereco);
Ok(())
}
_ => Err("Conexão não está ativa"),
}
}
}
Ainda que retornemos Err, o pattern matching nos força a considerar todos os casos. Mas podemos ir além e fazer com que enviar() nem exista para estados que não sejam Conectado.
2.3. Exemplo prático: máquina de estados para um protocolo simples
enum Protocolo {
AguardandoHandshake,
HandshakeRecebido { chave: u32 },
SessaoAtiva { id: u64 },
}
impl Protocolo {
fn receber_handshake(self, chave: u32) -> Protocolo {
match self {
Protocolo::AguardandoHandshake => {
Protocolo::HandshakeRecebido { chave }
}
_ => panic!("Handshake inválido neste estado"),
}
}
fn estabelecer_sessao(self) -> Protocolo {
match self {
Protocolo::HandshakeRecebido { chave } => {
let id = chave as u64 * 42;
Protocolo::SessaoAtiva { id }
}
_ => panic!("Não é possível estabelecer sessão"),
}
}
}
A função receber_handshake só pode ser chamada no estado AguardandoHandshake. Se tentarmos chamá-la em SessaoAtiva, o compilador não impede, mas o pattern matching nos força a tratar o erro. Com tipos mais avançados, podemos eliminar até mesmo o panic!.
3. Combinando Option e Result para evitar valores nulos e erros não tratados
3.1. Option<T>: eliminando null
struct Usuario {
nome: String,
email: Option<String>, // pode ou não ter email
}
fn enviar_email(usuario: &Usuario) {
match &usuario.email {
Some(email) => println!("Enviando para {}", email),
None => println!("Usuário sem email cadastrado"),
}
}
O compilador nos obriga a tratar o caso None. Não há surpresas em tempo de execução.
3.2. Result<T, E>: forçando o tratamento explícito de erros
fn dividir(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Divisão por zero".to_string())
} else {
Ok(a / b)
}
}
Quem chama dividir() é forçado a lidar com o erro, seja com match, unwrap ou propagação com ?.
3.3. Exemplo: validação de formulário
enum CampoFormulario {
NaoPreenchido,
Preenchido { valor: String },
Validado { valor: String, resultado: Result<(), Vec<String>> },
}
impl CampoFormulario {
fn preencher(self, valor: String) -> CampoFormulario {
match self {
CampoFormulario::NaoPreenchido => CampoFormulario::Preenchido { valor },
_ => self, // já preenchido ou validado
}
}
fn validar(self) -> CampoFormulario {
match self {
CampoFormulario::Preenchido { valor } => {
let erros = vec![];
// lógica de validação...
let resultado = if valor.is_empty() {
Err(vec!["Campo vazio".to_string()])
} else {
Ok(())
};
CampoFormulario::Validado { valor, resultado }
}
_ => self,
}
}
}
Aqui, só podemos validar um campo que foi preenchido, e o resultado da validação é explicitamente modelado.
4. Tipos fantasma (Phantom Types) para restringir operações em tempo de compilação
4.1. Conceito de phantom types
Phantom types são tipos que não armazenam dados, mas são usados como marcadores em tempo de compilação. Em Rust, usamos PhantomData para isso.
4.2. Exemplo: builder de consulta SQL
use std::marker::PhantomData;
struct SemWhere;
struct ComWhere;
struct Consulta<Estado = SemWhere> {
tabela: String,
clausula_where: Option<String>,
_estado: PhantomData<Estado>,
}
impl Consulta<SemWhere> {
fn nova(tabela: &str) -> Self {
Consulta {
tabela: tabela.to_string(),
clausula_where: None,
_estado: PhantomData,
}
}
fn com_where(mut self, condicao: &str) -> Consulta<ComWhere> {
self.clausula_where = Some(condicao.to_string());
Consulta {
tabela: self.tabela,
clausula_where: self.clausula_where,
_estado: PhantomData,
}
}
}
impl Consulta<ComWhere> {
fn executar(&self) -> String {
format!(
"SELECT * FROM {} WHERE {}",
self.tabela,
self.clausula_where.as_ref().unwrap()
)
}
}
Agora, tentar executar uma consulta sem WHERE resulta em erro de compilação:
// let q = Consulta::nova("usuarios").executar(); // ERRO: método não existe
let q = Consulta::nova("usuarios").com_where("id > 0").executar();
4.3. Vantagens
Erros de lógica (como esquecer uma cláusula obrigatória) são detectados antes da execução, sem custo em tempo de execução.
5. Máquinas de estado tipadas com enums genéricos
5.1. Usando enums com variantes que contêm dados específicos do estado
enum Pedido {
Rascunho { itens: Vec<String> },
Confirmado { itens: Vec<String>, endereco: String },
Enviado { codigo_rastreio: String },
Entregue,
}
5.2. Transições de estado via funções que consomem e produzem novos estados
impl Pedido {
fn confirmar(self, endereco: String) -> Pedido {
match self {
Pedido::Rascunho { itens } => Pedido::Confirmado { itens, endereco },
_ => panic!("Só é possível confirmar rascunhos"),
}
}
fn enviar(self) -> Pedido {
match self {
Pedido::Confirmado { itens: _, endereco: _ } => {
let codigo = format!("BR{}", rand::random::<u32>());
Pedido::Enviado { codigo_rastreio: codigo }
}
_ => panic!("Só é possível enviar pedidos confirmados"),
}
}
}
5.3. Sem permitir saltos
Não é possível pular de Rascunho para Enviado sem passar por Confirmado. O compilador não impede o panic!, mas a estrutura do enum torna a intenção clara e facilita a auditoria.
6. Pattern matching exaustivo como ferramenta de segurança
6.1. Como o compilador obriga a tratar todos os casos
enum EventoUI {
Clique { x: i32, y: i32 },
TeclaPressionada(char),
Redimensionamento { largura: u32, altura: u32 },
}
fn processar_evento(evento: EventoUI) {
match evento {
EventoUI::Clique { x, y } => println!("Clique em ({}, {})", x, y),
EventoUI::TeclaPressionada(c) => println!("Tecla: {}", c),
// Se esquecermos Redimensionamento, o compilador avisa
}
}
6.2. Evitando estados "catch-all" com _
Usar _ indiscriminadamente pode mascarar bugs. Prefira listar todas as variantes explicitamente.
6.3. Exemplo: tratamento de eventos em UI
fn tratar_evento(evento: EventoUI) -> String {
match evento {
EventoUI::Clique { x, y } if x > 0 && y > 0 => "Clique válido".to_string(),
EventoUI::Clique { .. } => "Clique inválido".to_string(),
EventoUI::TeclaPressionada('q') => "Sair".to_string(),
EventoUI::TeclaPressionada(_) => "Outra tecla".to_string(),
EventoUI::Redimensionamento { largura, altura } => {
format!("Nova resolução: {}x{}", largura, altura)
}
}
}
Cada combinação de estado é tratada explicitamente.
7. Boas práticas e armadilhas comuns
7.1. Quando usar tipos simples vs. máquinas de estado complexas
Use enums simples para estados com poucas variantes e sem transições complexas. Para protocolos ou workflows com várias etapas, considere máquinas de estado tipadas.
7.2. Evitando enums muito grandes
Se um enum tem mais de 5-7 variantes, considere decompô-lo em tipos menores. Por exemplo, em vez de:
enum Documento {
Rascunho,
RevisaoPendente,
Aprovado,
Publicado,
Arquivado,
}
Podemos separar em:
enum StatusDocumento {
Rascunho,
Revisao,
Final,
}
E adicionar um campo separado para publicado e arquivado.
7.3. Integração com ownership
O sistema de ownership pode ajudar a garantir que recursos sejam liberados corretamente. Por exemplo, um arquivo aberto só pode ser fechado quando não há mais referências a ele.
8. Conclusão e próximos passos
8.1. Recapitulação dos benefícios
Modelar estados inválidos como impossíveis em Rust resulta em código mais seguro (menos bugs em produção), auto-documentado (os tipos expressam as intenções) e com menos testes de unidade necessários (já que o compilador valida invariantes).
8.2. Ferramentas complementares
O padrão typestate (estados tipados) pode ser implementado manualmente ou com bibliotecas como state_machine_future para máquinas de estado complexas. O crate typestate oferece macros para facilitar a criação.
8.3. Como aplicar em projetos reais
Comece pequeno: substitua boolean flags por enums, use Option em vez de null, e aplique máquinas de estado em módulos críticos (como autenticação, processamento de pedidos, ou protocolos de rede). Com o tempo, esses padrões se tornam naturais e elevam a qualidade do código.
Referências
- The Rust Programming Language - Enums and Pattern Matching — Capítulo oficial sobre enums e pattern matching no Rust Book.
- Rust by Example - Enums — Exemplos práticos de uso de enums em Rust.
- Type State Pattern in Rust — Artigo detalhado sobre o padrão typestate com exemplos de máquinas de estado.
- PhantomData in Rust — Documentação oficial do
PhantomDatapara tipos fantasma. - The State Machine Pattern in Rust - A Practical Guide — Guia prático sobre modelagem de máquinas de estado em Rust.
- Rust Design Patterns - Type State — Padrão de estado comportamental na coleção oficial de design patterns.