O ganho de produtividade inesperado do Rust
(lubeno.dev)- Rust aumenta a produtividade e a manutenibilidade ao permitir fazer refatorações com confiança mesmo em codebases grandes, graças às suas fortes garantias de segurança
- O compilador detecta antecipadamente bugs relacionados a agendamento assíncrono, reforçando a estabilidade ao evitar comportamento indefinido
- Linguagens como TypeScript frequentemente deixam bugs assíncronos passarem para produção por causa de um sistema de tipos mais frouxo
- O sistema de tipos do Rust deixa claro o impacto das mudanças no código, aumentando a confiança e a disposição para experimentar em projetos complexos
- Diferente do Rust, o Zig tem verificações mais frouxas no tratamento de erros e pode deixar passar bugs causados por erros de digitação, reduzindo a confiabilidade
Resumo e contexto
- O backend da Lubeno é escrito 100% em Rust, e a codebase chegou a um ponto em que ficou difícil entender tudo de cabeça à medida que cresceu
- Em projetos grandes, normalmente fica difícil verificar os efeitos colaterais das mudanças, o que leva a queda de produtividade
- As garantias de segurança do Rust deixam claro o impacto das mudanças no código, reduzindo o medo de refatorar
- Isso contribui para melhor manutenibilidade e produtividade no longo prazo
- O texto começa com um caso em que o compilador Rust detectou um bug assíncrono e explora as vantagens de produtividade da linguagem
Exemplo das garantias de segurança do Rust
- Situação do problema: uma struct foi envolvida em um mutex para permitir acesso concorrente, e uma operação assíncrona foi executada após adquirir o lock
let lock = mutex.lock(); db.insert_commit(commit).await; - Descoberta do problema: o rust-analyzer não mostrou erro, mas um erro de compilação apareceu no arquivo de definição das rotas
.route("/api/git/post-receive", post(git::post_receive)) ^^^^^^^^^^^^^^^^^ error: future cannot be sent between threads safely - Análise da causa:
- O framework web cria uma tarefa assíncrona para cada conexão HTTP, e o escalonador de tarefas move essas tarefas entre threads
- O mutex precisa ser liberado na mesma thread, e se a thread mudar em um ponto de
.await, pode ocorrer comportamento indefinido - O compilador Rust rastreia o tempo de vida do lock e detecta a possibilidade de ele ser liberado em outra thread
- Como resolver: liberar o lock antes do
.await - Importância: o Rust evita em tempo de compilação bugs assíncronos que são difíceis de reproduzir no ambiente de desenvolvimento
Caso comparativo com TypeScript
- Situação do problema: ocorreu um bug de redirecionamento assíncrono em código TypeScript
if (redirect) { window.location.href = redirect; } let content = await response.json(); if (content.onboardingDone) { window.location.href = "/dashboard"; } else { window.location.href = "/onboarding"; } - Causa do problema:
window.location.hrefnão redireciona imediatamente; ele agenda o redirecionamento, e a execução do código continua- Por causa de uma condição de corrida, acontece um redirecionamento não intencional
- Como resolver: adicionar
returnao blocoifif (redirect) { window.location.href = redirect; return; } - Limitação: o TypeScript não tem rastreamento de lifetimes nem regras de empréstimo, então não consegue detectar esse tipo de bug em tempo de compilação
- O problema foi encontrado em produção, e o debugging consumiu muito tempo
Vantagens do Rust para refatoração
- Em desenvolvimento web, Python, Ruby e JavaScript/Node.js têm alta produtividade inicial, mas quando a codebase cresce, o acoplamento frouxo dificulta mudanças
- Depois das mudanças, surgem erros inesperados, reduzindo a disposição de alterar o código
- No Rust, o sistema de tipos mostra com clareza o impacto das mudanças, reduzindo o medo de refatorar
- Exemplo: alertas do tipo “esta mudança pode afetar outras partes” ajudam a evitar problemas antes que aconteçam
- Mesmo com o crescimento da codebase, há aumento de produtividade, com reaproveitamento do código existente e manutenção da segurança nas mudanças
Comparação com testes
- Testes são úteis para evitar regressões durante refatorações, mas como o compilador não os impõe, eles podem ser omitidos
- Escrever testes traz uma grande carga mental, pois exige decidir o nível de abstração, comportamento vs. detalhes de implementação e se realmente previnem erros
- O compilador do Rust bloqueia antecipadamente erros comuns, reduzindo a carga de decisão que os testes exigem
- Propriedades que não podem ser verificadas pelo sistema de tipos podem ser complementadas com testes
Comparação com Zig
- Zig é uma linguagem de programação de sistemas semelhante ao Rust, mas mais frouxa no tratamento de erros
- Exemplo de código de tratamento de erro
const FileError = error{ AccessDenied }; fn doSomethingThatFails() FileError!void { return FileError.AccessDenied; } pub fn main() !void { doSomethingThatFails() catch |err| { if (err == error.AccessDenid) { std.debug.print("Access was denied!\n", .{}); } }; } - Por causa do erro de digitação em
AccessDenid, surge um bug, mas o compilador do Zig trata isso como número e a compilação é concluída com sucesso
- Exemplo de código de tratamento de erro
- Ao usar
switch, o erro de digitação é detectado, mas em instruções if ele é ignorado, gerando problemas de confiabilidade - O Rust evita esse tipo de brecha de projeto e verifica com rigor erros de digitação e falhas lógicas
Implicações
- O Rust melhora a produtividade e a estabilidade de projetos grandes com suas garantias de segurança e seu sistema de tipos rigoroso
- Até problemas complexos como bugs assíncronos podem ser detectados em tempo de compilação, reduzindo o custo de manutenção
- Os casos de TypeScript e Zig mostram os riscos de verificações frouxas e reforçam o valor do compilador rigoroso do Rust
- O Rust se firma como uma ferramenta poderosa no desenvolvimento web não apenas pela produtividade inicial, mas também pela gestão de codebases no longo prazo
3 comentários
Sempre que vejo gente dizendo que isso é o melhor, que é uma linguagem poderosa!!
O que eu acabo sentindo é: será que estão tentando convencer o pessoal a usar Rust porque, na prática, não tem tantos desenvolvedores Rust quanto parece??
Será que só eu sinto que os posts recomendados sobre Rust são tipo o Sikgaek dizendo "Tenta! Tenta!"?
Comentários no Hacker News
No ano passado, fiz o port de um driver de rede
virtio-hostescrito em Rust. Troquei o backend, o mecanismo de interrupção e mudei de biblioteca para processo standalone. Era um programa complexo, lidando com mapeamento de memória, interrupções de VM, sockets de rede e multithreading. Eu quase não tinha experiência com Rust, e também pouca comvirtio, mas quando o projeto compilou, ele funcionou perfeitamente. Tirando um bug relacionado aDrop, foi fácil corrigir. Acho que as bibliotecas em Rust ajudaram bastante por serem estruturadas de um jeito que dificulta o uso incorretoAcho Rust excelente. Mas não concordo com a opinião de que o bug de atribuição de
hrefé culpa do TypeScript. O ponto central do problema é que, mesmo definindo ohref, a navegação da página não acontece imediatamente, e sim depois. O mesmo problema poderia existir em Rust. Se Rust tivesse uma funçãoset_hrefe esse comportamento fosse processado depois, um código como este seria possível:set_href('/foo')if (some_condition) {set_href('/bar')}Acho que em Rust isso não seria projetado assim. Fazer a ação acontecer em um setter não é um bom design de biblioteca, e é estranho que a atribuição de
hrefnão provoque a navegação imediatamente. Numa biblioteca padrão de Rust, não haveria uma implementação tão tola assim. Não é uma questão de Rust vs TypeScript, e sim da diferença entre a biblioteca padrão de Rust e a Web Platform API. Concordo, porém, que Rust não ofereceria esse tipo de experiência ao usuárioFalando formalmente, também não é desejável projetar setters que disparem ações imediatamente. O nome também deveria mudar para algo como
navigate_to(href). Em ambiente de navegador, como todo código JS roda por callbacks e é controlado pelo event loop, também é natural que não execute imediatamenteO exemplo em Rust é interessante, mas só pelo exemplo em TypeScript não dá para concluir se TS é adequado para projetos grandes. Em Ruby, eu também fico inseguro porque preciso capturar bugs em runtime com frequência, mas no fim, antes do commit, tudo funciona bem e é fácil ler e modificar o código, o que é satisfatório. O problema da navegação é um problema do JavaScript, herdado pelo TS. Isso acontece porque o JS permite alterar propriedades livremente. Mas como a página também não desaparece instantaneamente, esse comportamento faz sentido quando você o conhece
Tecnicamente, em Rust, dependendo de
set_hrefretornar()ou!, seria possível dar uma pista mais clara sobre o significado. Mas em redirecionamentos condicionais ainda seria difícil impedir esse uso incorretoO que eu queria dizer era que, com o modelo de ownership do Rust, seria possível projetar a API para que
window.set_href('/foo')tomasse posse dewindow, tornando impossível chamá-la duas vezes. TypeScript nem tem o conceito de rastreamento de lifetime, então isso é impossível. Como a API JS já existe, também não há como introduzir um sistema de ownership do lado do TypeScript. Eu queria mostrar isso como um caso em que vários recursos do Rust se combinam para oferecer garantias mais fortesO fundamento do seu argumento de que Rust é melhor soa, no fim, como “os programadores Rust são melhores”. Não acho que programadores Rust fariam esse tipo de raciocínio circular
O código depois da atribuição continua sendo executado a menos que haja um retorno antecipado explícito. Sinceramente, não entendo por que alguém pensaria que uma atribuição de valor faria a execução do script parar. Pode até faltar contexto no exemplo em TS, mas é um exemplo estranho para chamar de “data race”
Atribuir um valor a
window.location.hreftem o efeito colateral de fazer o navegador navegar para aquele link. Esse comportamento é surpreendente, e como uma simples atribuição carrega uma nova página, lembra um poucoexecve, então não é tão estranho pensar que a execução de JS pararia imediatamente. Não se deve depender dessa suposição ao programar, mas como o comportamento em si é meio estranho, acho compreensível se confundirPense nisso ou não, esse tipo de bug fica claramente corrigível assim que alguém te avisa. O ponto principal que o autor queria defender é que bugs desse tipo, que o TS não consegue capturar, podem ser realmente difíceis de encontrar e consumir bastante tempo
exit(),execve()etc. de fato interrompem a execução imediatamente, então dá para imaginar que um redirecionamento se comportaria da mesma formaÉ estranho implicar com alguém só por compartilhar a própria experiência
Essa atribuição tem um grande efeito colateral, que é fazer a página ser abandonada. Não acho absurdo tratá-la como uma ação assíncrona de efeito imediato. Eu mesmo já fiz essa suposição
É uma história sobre um desenvolvedor perceber que sistemas de tipos estáticos são úteis. Sempre acho engraçado quando vejo textos assim
Será que a maioria das vantagens não vem simplesmente de usar tipagem estática, ou seja, uma linguagem compilada? Java, Go e C++ são assim também. TypeScript tem suas manhas: compila para JS e herda os problemas do JS, mas ainda assim é utilizável. Rust tem um sistema de tipos mais rígido, então oferece verificações adicionais em tempo de compilação, mas em troca é mais difícil de aprender e também de ler, na minha opinião
Concordo até certo ponto, mas Rust tem mais dimensões no sistema de tipos, como ownership, acesso compartilhado/exclusivo, thread safety e sum type. Graças ao sistema de ownership/borrowing, fica claro se a passagem de parâmetro é uma view temporária ou uma transferência total. Isso é muito vantajoso em programas grandes ou ao usar bibliotecas externas. Por exemplo, o tipo slice do Go não deixa claro em runtime quais operações são permitidas, e também é ambíguo como emprestá-lo apenas para leitura. Rust consegue garantir thread safety no nível do sistema de tipos, impedindo em tempo de compilação data races que, em outras linguagens, seriam difíceis de encontrar em runtime
Colocar todas as linguagens de tipagem estática no mesmo saco é sinal de que você ainda não sentiu o verdadeiro poder de union(sum) type e pattern matching. Depois que você se acostuma com union type, as linguagens estáticas mais tradicionais deixam de parecer suficientes
Uma grande vantagem são
traits/impl traits. Em Rust, dá para adicionar traits depois a qualquer tipo, como Extension Methods em C#. Na maioria das linguagens, o tipo fica fechado quando é definido na biblioteca, mas em Rust dá para continuar acumulando funcionalidade gradualmente até em tipos simples. Esse caráter late-bound é um elemento que injeta dinamismo no sistema de tipos. Indo um pouco ao extremo, o verdadeiro superpoder do Rust não é o borrow checker, e sim a abertura e a flexibilidade do sistema de tipos. Não é preciso projetar tudo desde o início; dá para expandir aos poucosNem toda linguagem de tipagem estática produz o mesmo efeito. Java no fim depende de
Objecte casting em runtime. Go não temenum. C++ ganhou algo comovariant, mas para usar com segurança ainda exige tratamento manual tipotry/except, então estruturalmente é desconfortávelDizem que Rust é difícil de aprender, mas, se você realmente aprender direito, não é difícil. No começo de aprender a programar, muitas vezes é importante ir escrevendo qualquer coisa até algo funcionar, e Rust é uma linguagem pouco amigável com esse estilo. Não recomendo como linguagem de entrada, mas não acho difícil de ler
A forte segurança do Rust aumenta muito a confiança ao mexer na codebase. Com essa confiança, refatorar partes centrais deixa de ser assustador e, no fim, isso melhora bastante a produtividade e a manutenibilidade. Mas é para isso que existem testes. Sem testes, um compilador rigoroso ajuda muito, claro, mas com bons testes dá para refatorar com confiança em qualquer linguagem
É melhor quando o compilador consegue provar estaticamente o que for possível. O ideal é usar testes só nas situações em que garantias estáticas são difíceis. O estágio final ideal seria verificação formal, embora na prática seja muito difícil, então não vale como regra geral, mas como princípio está certo
Bons testes e um sistema de tipos bem aproveitado são ambos eficazes para encontrar bugs. Mas escrever testes também lembra a tirinha xkcd “Standards”. É como tentar corrigir padrões criando mais um padrão: você combate bugs escrevendo mais código. Ainda assim, a manutenção do sistema de tipos fica a cargo do designer da linguagem, então não precisa ser administrada projeto a projeto
Toda vez que você refatora código, também precisa refatorar os testes, então o trabalho dobra
Acho que o sistema de tipos de Rust ou F# brilha mais na hora de refatorar código. A expressão “refatoração sem medo” cai perfeitamente
O exemplo de Zig é chocante. Parece tão instável que não consigo entender como alguém pode achar esse design bom
Acho que isso provavelmente é um bug. Mas em uma linguagem creator-driven como Zig, é importante que o criador também reconheça que é um bug para que ele possa ser corrigido. Se for visto como intencional, pode continuar assim para sempre
Toda linguagem tem um pouco de design instável. Por exemplo, em Go ou Zig é preciso sempre fazer
mutex.unlock()explicitamente, e ele não é liberado automaticamente ao sair do escopo. Por outro lado, em Rust operações comoasfacilitam conversões entre tipos numéricos, e por causa disso já passei um dia inteiro caçando bugsNo começo eu não tinha visto esse erro, mas percebi depois de ler este comentário
Fico pensando se um linter não poderia detectar referências a erros inexistentes dentro do sistema e recomendar o uso de
switchcom esse tipo de avisoEu achava que os error sets eram gerados com base nas assinaturas das funções. É algo meio peculiar
Gosto do fato de um sistema de tipos estático forte e sound oferecer vários recursos. Eu também já tive a experiência de refatorar em larga escala com facilidade em uma codebase Haskell (1 milhão de SLOC). Mesmo sem recursos avançados, só o sistema de tipos já foi suficiente
Rust detectou corretamente que o lock estava sendo mantido ao atravessar a fronteira de
await, mas para saber se liberar esse lock antes doawaité realmente seguro é preciso mais contexto. Acho que o lock deve ser mantido até a criação do commit da transação; se for liberado antes doawait, pode haver problemas de concorrência. Não conheço bem Rust async, mas depois do commit não seria o caso de bloquear comjoinouselect?Se você precisa manter o lock durante o
await, basta usar um mutex async-aware. Os cratesfuturesoutokioimplementam esse tipo de lock. Costuma ser usado quando o lock fica retido por muito tempo ou precisa atravessarawait. O custo é maior do que o de um lock normalSe for necessário manter o lock mesmo atravessando fronteiras de
await, você pode usar o mutex async-aware do Tokio. Consulte a documentação detokio/sync/struct.Mutex