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