Pular para o conteúdo

Rust para desenvolvedores Go: guia prático

Se você escreve Go, vai se sentir em casa no Rust mais do que imagina. As duas linguagens compilam para um único binário rápido. As duas têm structs com métodos no lugar de classes, não têm herança, devolvem erros como valores em vez de usar exceções, têm um formatador padrão que todo mundo usa e uma ferramenta de linha de comando que compila, testa e baixa pacotes. Uma struct do Go com alguns métodos vira Rust quase linha por linha.

O que é novo tem a ver, principalmente, com memória e com ser explícito. O Rust não tem garbage collector (coletor de lixo): cada valor tem um único dono, e o compilador verifica o ownership (posse) e o borrowing (empréstimo) antes de o programa rodar. Não existe nil (você usa Option), e o if err != nil vira Result e o operador ?. As interfaces continuam existindo (elas se chamam traits), mas um tipo precisa dizer com todas as letras que implementa uma. Conte com passar a maior parte do seu tempo de estudo no ownership. O resto vai parecer um Go mais rigoroso e mais detalhado.

Por que desenvolvedores Go aprendem Rust? Normalmente por um destes motivos: um serviço em que as pausas do garbage collector ou o uso de memória começam a pesar; trabalho pesado de CPU; código que roda sem sistema operacional (embarcado) ou no navegador (WebAssembly); ou simplesmente para o compilador pegar data races e quebras do tipo nil antes da produção. Para uma comparação mais ampla do Rust com Go, C++ e Python, veja Rust vs outras linguagens. Esta página vai mais fundo e foi escrita para você, que programa em Go.

Use esta tabela como um guia de conversação. A última coluna leva à lição deste site que explica o lado do Rust.

Go Rust Saiba mais
Garbage collector Ownership e borrowing, verificados em tempo de compilação; a memória é liberada quando o dono sai de escopo Ownership, Borrowing
struct + métodos struct para os dados + um bloco impl para os métodos Structs
Receiver por valor func (a Account) &self (lê, sem cópia) ou self (toma o ownership) Structs
Receiver por ponteiro func (a *Account) &mut self (pode alterar o valor) Structs
Função construtora NewAccount(...) Uma função associada, por convenção Account::new(...) Structs
Letra maiúscula = exportado A palavra-chave pub Módulos
Zero values (var c Config) Não há zero values implícitos; todo campo precisa ser preenchido. #[derive(Default)] + Config::default() Structs, Traits comuns
Embedding de structs Não existe embedding. Coloque a struct num campo e escreva pequenos métodos que repassam a chamada, ou compartilhe comportamento com traits Traits, Padrões de projeto
interface (satisfeita implicitamente) trait (implementado explicitamente com impl Trait for Type) Traits
[]Shape com tipos diferentes Vec<Box<dyn Shape>> (um trait object) Trait objects
any / interface{} + type switch Um enum com uma variante por caso, e depois match Enums, Match
Genéricos func Max[T cmp.Ordered](a, b T) T Genéricos com trait bounds: fn max<T: PartialOrd>(a: T, b: T) -> T Genéricos, Genéricos avançados
Ponteiro nil, map nil, interface nil Option<T>: ou Some(valor) ou None Option
v, ok := m[k] m.get(&k) devolve um Option HashMap
(T, error) + if err != nil { return err } Result<T, E> + o operador ? Result
errors.New, sentinel errors, errors.Is / errors.As Um enum de erro sobre o qual você faz match (o crate thiserror ajuda) Erros personalizados, anyhow e thiserror
fmt.Errorf("...: %w", err) anyhow com .context("..."), ou map_err anyhow e thiserror
panic / recover panic! para bugs de verdade; recuperar é raro (std::panic::catch_unwind) Panic
defer Drop, que roda quando um valor sai de escopo (RAII) Escopo, Ponteiros inteligentes
go f() std::thread::spawn(...), ou tokio::spawn(...) para tarefas async Concorrência, Async/Await
chan T, make(chan T, n) std::sync::mpsc::channel() (sem limite) ou sync_channel(n); tokio::sync::mpsc em código async Canais (channels)
select tokio::select! em código async, ou o select! do crossbeam-channel Async a fundo
sync.Mutex ao lado dos dados O Mutex<T> envolve os dados; ele destrava sozinho quando o guard é descartado Estado compartilhado
sync.WaitGroup JoinHandle::join(), ou std::thread::scope Concorrência
sync.Once std::sync::OnceLock / LazyLock Truques da biblioteca padrão
context.Context Não há um equivalente único: descarte (drop) um future para cancelá-lo, tokio::time::timeout, ou o CancellationToken do tokio-util Async a fundo
Valores func e closures Closures, descritas pelos traits Fn, FnMut, FnOnce Closures, Fn, FnMut e FnOnce
for i, v := range s for (i, v) in s.iter().enumerate() For em Rust, Iteradores
O único laço for loop, while e for Loops em Rust
Iteradores range-over-func (iter.Seq) O trait Iterator, com adaptadores como filter, map, sum, collect Iteradores, Iteradores personalizados
switch match (o compilador confere se você tratou todos os casos) Match, Pattern matching
const + iota Um enum (opcionalmente com números explícitos) Enums
:= let (imutável por padrão; let mut para permitir mudanças) Variáveis
string (bytes, normalmente UTF-8) String (com dono) e &str (emprestada), sempre UTF-8 válido Strings
rune, for _, r := range s char, for c in s.chars() Strings
[]byte Vec<u8> ou &[u8] Slices
fmt.Println, %v, %+v println!, {} (Display), {:?} e {:#?} (Debug, com #[derive(Debug)]) Saída em Rust
String() string (a interface Stringer) Implemente Display (e o .to_string() vem de graça) Traits comuns
Struct tags `json:"user_name"` Atributos: #[serde(rename = "user_name")] JSON com Serde
Conversões int(x), float64(n) as para números, From/Into, e TryFrom quando a conversão pode falhar Conversão de tipos, Conversões
O overflow de inteiros dá a volta em silêncio Panic nos builds de debug, dá a volta em release; checked_add, wrapping_add e saturating_add dizem o que você quer Overflow de inteiros
*T e &x Referências &T e &mut T (nunca nulas, não podem viver mais que o valor); Box<T> para um valor no heap com dono Borrowing, Ponteiros inteligentes
Pacotes Módulos (mod) dentro de um crate; use no lugar de import Módulos
go generate Macros e scripts de build build.rs Macros, Macros procedurais
Pacote unsafe Blocos unsafe e ponteiros brutos (raw pointers) Unsafe em Rust
cgo extern "C" e FFI (o bindgen gera bindings a partir de headers C) FFI: chamando C
Build tags //go:build linux #[cfg(target_os = "linux")] e features do Cargo Features do Cargo e cfg

O Go tem slices e maps embutidos na linguagem. O Rust os tem na biblioteca padrão, e mais alguns.

Go Rust Saiba mais
[]T que é seu e recebe append Vec<T> Vetores
[]T passado para uma função que só lê &[T] (um slice emprestado de um Vec ou de um array) Slices
[N]T [T; N] Arrays
append(s, x) / append(s, other...) v.push(x) / v.extend(other) Vetores
s[a:b] &v[a..b] Slices
len(s), cap(s), make([]T, 0, n) v.len(), v.capacity(), Vec::with_capacity(n) Vetores
map[K]V HashMap<K, V> HashMap
map[K]struct{} ou map[K]bool usado como conjunto HashSet<T> HashSet
Juntar e ordenar as chaves do map antes de imprimir BTreeMap<K, V> (as chaves ficam ordenadas) HashMap
container/list VecDeque<T> (push_back / pop_front) VecDeque e BinaryHeap
container/heap (implementar 5 métodos) BinaryHeap<T>: só push e pop; o maior primeiro, envolva em Reverse para o menor primeiro VecDeque e BinaryHeap
slices.Sort, sort.Slice v.sort(), v.sort_by(...), v.sort_by_key(...) Ordenação e busca
slices.Contains, slices.Index v.contains(&x), v.iter().position(...) Vetores
strings.Builder Uma String com push_str, ou write! Strings
sync.Map Mutex<HashMap<K, V>>, RwLock, ou o crate dashmap Estado compartilhado

Duas coisas para saber. Primeiro, assim como os maps do Go, um HashMap do Rust não tem ordem fixa, então ordene antes de imprimir ou use um BTreeMap. Segundo, a famosa surpresa dos slices do Go (dois slices dividindo o mesmo array por baixo, e um append alterando o outro) não tem como acontecer no Rust: enquanto uma parte do seu código tem um empréstimo &mut de um Vec, mais ninguém pode olhar para ele.

Boa notícia: você está acostumado com uma ferramenta que faz tudo, e o Rust também tem uma. O Cargo cobre go build, go run, go test, go mod e go install.

Go Rust Observações
Comando go cargo Compila, executa, testa, adiciona pacotes, publica. Veja Cargo e crates
go mod init cargo new my_app (cargo new --lib para uma biblioteca) Cria a pasta, o Cargo.toml, o src/main.rs e um repositório git
go.mod Cargo.toml
go.sum Cargo.lock Criado e atualizado para você
go get github.com/gin-gonic/gin cargo add axum
go get -u cargo update Atualiza dentro das faixas de versão do Cargo.toml
pkg.go.dev crates.io para os pacotes, docs.rs para a documentação da API
go build / go run / go test cargo build / cargo run / cargo test Acrescente --release para um build otimizado; builds de debug são bem mais lentos na execução
go vet, staticcheck, golangci-lint cargo clippy Oficial, com centenas de lints úteis
gofmt / goimports cargo fmt (rustfmt) Um estilo padrão, igualzinho ao gofmt
go doc cargo doc --open Os exemplos de código nos comentários de documentação rodam como testes. Veja Documentação
go test -bench criterion (rodado com cargo bench) Veja Desempenho
go test -cover cargo llvm-cov ou cargo tarpaulin
go test -race Não é preciso em código seguro: data races são erros de compilação. O Miri verifica código unsafe
go test -fuzz cargo fuzz Instale com cargo install cargo-fuzz
testify assert! e assert_eq! embutidos; pretty_assertions para diffs mais bonitos Veja Testes em Rust e Testes avançados
gomock / mockery mockall, ou fakes escritos à mão que implementam o seu trait
go install pkg@latest cargo install name
go work (workspaces) Um workspace do Cargo Um Cargo.toml no topo lista os crates membros
Linha toolchain no go.mod rustup e um arquivo rust-toolchain.toml Veja Ferramentas do Rust
GOOS=linux GOARCH=arm64 go build rustup target add aarch64-unknown-linux-gnu + cargo build --target ... Muitas vezes precisa de um linker para o alvo; o cross ou o cargo-zigbuild facilitam
govulncheck cargo audit (avisos do RustSec), cargo deny
gopls rust-analyzer Funciona no VS Code, Neovim, Helix, Zed e outros
GoLand JetBrains RustRover
Delve rust-gdb / rust-lldb, ou o CodeLLDB no VS Code
pprof cargo flamegraph, perf, ou qualquer profiler nativo
air (recarregamento automático) bacon ou cargo watch

Um Cargo.toml pequeno ao lado do go.mod que você conhece:

module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0
[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Uma diferença que você vai notar logo: a biblioteca padrão do Go inclui servidor HTTP, JSON, templates e criptografia. A biblioteca padrão do Rust é menor de propósito, então coisas como JSON, HTTP, async e números aleatórios vêm de crates. Adicionar um é um único cargo add, e um punhado de crates (serde, tokio, clap, regex, rand) é usado por quase todo mundo.

Bibliotecas e frameworks: “O que usamos no lugar de…?”

Seção intitulada “Bibliotecas e frameworks: “O que usamos no lugar de…?””

Essa é a primeira pergunta prática. “A gente usa Gin e GORM. Qual é a versão em Rust?” Aqui estão os equivalentes mais próximos. Como no Go, o Rust normalmente tem duas ou três boas opções da comunidade em vez de uma resposta oficial.

Go Rust Observações
Gin, Echo, Chi, Fiber axum (construímos uma API com ele em Projeto: API web com Axum), actix-web, Rocket axum e actix-web são muito usados em produção
Servidor do net/http hyper (baixo nível), normalmente usado através do axum Não há servidor HTTP na biblioteca padrão
Middleware de http.Handler Camadas do tower e o tower-http (CORS, compressão, tracing, timeouts) O axum usa o tower diretamente
Cliente do net/http reqwest Assíncrono por padrão, com um cliente bloqueante opcional. Veja Redes em Rust
grpc-go tonic (com prost para protobuf)
gorilla/websocket tokio-tungstenite, ou a feature ws do axum
html/template, text/template askama (verificado em tempo de compilação), tera, minijinja
net (TCP/UDP) std::net, ou tokio::net para código async Veja Redes em Rust
errgroup tokio::task::JoinSet
Go Rust Observações
encoding/json + struct tags serde + serde_json Também lida com TOML (crate toml) e outros formatos. Veja JSON com Serde
database/sql + pgx sqlx (assíncrono, SQL puro conferido com o seu banco de dados em tempo de compilação), tokio-postgres Não tem relação com o jmoiron/sqlx do Go, apesar do nome
sqlc As macros query! do sqlx Os dois partem do SQL que você escreve e devolvem resultados tipados
GORM SeaORM (assíncrono, no estilo de entidades), Diesel (query builder com tipos verificados) Veja Bancos de dados
go-sqlite3 rusqlite
go-redis redis

CLI, configuração, logging e ferramentas do dia a dia

Seção intitulada “CLI, configuração, logging e ferramentas do dia a dia”
Go Rust Observações
cobra, urfave/cli, flag clap (os argumentos saem de uma struct, com derive) Veja Argumentos de linha de comando e Projeto: CLI com clap
viper config ou figment Combine arquivos e variáveis de ambiente em camadas e depois desserialize com o serde
log/slog, zap, zerolog tracing + tracing-subscriber, ou log + env_logger Logging estruturado. Veja Logging
regexp regex A mesma ideia do RE2 do Go: casamento em tempo linear, sem backreferences. Veja Expressões regulares
time std::time para durações; chrono ou jiff para datas e fusos horários Veja Datas e horas
embed include_str! / include_bytes!, ou rust-embed para pastas inteiras
bubbletea ratatui Interfaces no terminal
client_golang do Prometheus prometheus ou metrics
sync/atomic std::sync::atomic
Go Rust Observações
Wails Tauri (interface web + backend em Rust) Ideia bem parecida. Veja Apps com interface gráfica
Fyne egui, Iced, Slint Não existe um toolkit de interface gráfica oficial
Ebitengine macroquad, Bevy O Bevy está antes da 1.0 e muda a API entre versões. Veja Desenvolvimento de jogos
TinyGo embedded-hal, Embassy O Rust roda sem sistema operacional e sem heap. Veja Rust embarcado
GOOS=js GOARCH=wasm wasm-bindgen, Leptos, Yew, Dioxus Binários pequenos, sem GC para embutir. Veja WebAssembly

É aqui que os desenvolvedores Go se sentem em casa. Aqui está um pequeno tipo de conta em Go:

type Account struct {
Owner string // exportado: letra maiúscula
balance int // não exportado
}
func NewAccount(owner string, balance int) *Account {
return &Account{Owner: owner, balance: balance}
}
func (a *Account) Deposit(amount int) { a.balance += amount }
func (a *Account) Balance() int { return a.balance }
func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() {
account := NewAccount("Ada", 100)
account.Deposit(50)
fmt.Println(account)
fmt.Println("balance is", account.Balance())
}

E em Rust:

use std::fmt;
// `pub` faz o papel da letra maiúscula do Go.
pub struct Account {
pub owner: String,
balance: i64, // privado, como um campo em minúscula no Go
}
impl Account {
// Como o NewAccount do Go: uma função comum. `new` é só uma convenção.
pub fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// Como um receiver por ponteiro: este método altera a conta.
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// Só lê. Nenhuma cópia é feita, diferente de um receiver por valor no Go.
pub fn balance(&self) -> i64 {
self.balance
}
}
// O método String() do Go (a interface Stringer) vira Display.
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}", self.owner, self.balance)
}
}
fn main() {
let mut account = Account::new("Ada", 100);
account.deposit(50);
println!("{account}");
println!("o saldo é {}", account.balance());
}

Saída:

Ada: 150
o saldo é 150
▶ Experimente você mesmo

O formato é o mesmo: dados numa struct, métodos ligados a ela, um construtor que é só uma função. As diferenças são pequenas, mas importantes. Os métodos ficam juntos num bloco impl. O receiver diz exatamente o que o método faz: &self lê, &mut self altera, e o self simples toma o valor e o consome. E como deposit altera a conta, a variável precisa ser declarada com let mut. No Go, qualquer um que tenha um *Account pode alterá-lo; no Rust, o direito de alterar algo fica visível no código.

Lado a lado: interfaces implícitas viram traits explícitos

Seção intitulada “Lado a lado: interfaces implícitas viram traits explícitos”

No Go, um tipo satisfaz uma interface só por ter os métodos certos:

type Shape interface {
Area() float64
Name() string
}
type Circle struct{ R float64 }
type Rect struct{ W, H float64 }
// Nenhum "implements" em lugar nenhum: ter os métodos basta.
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Name() string { return "circle" }
func (r Rect) Area() float64 { return r.W * r.H }
func (r Rect) Name() string { return "rect" }
func describe[T Shape](s T) {
fmt.Printf("%s: %.2f\n", s.Name(), s.Area())
}
func main() {
describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}}
total := 0.0
for _, s := range shapes {
total += s.Area()
}
fmt.Printf("total area: %.2f\n", total)
}

Em Rust, você escreve a ligação com impl Shape for Circle:

use std::f64::consts::PI;
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
r: f64,
}
struct Rect {
w: f64,
h: f64,
}
// Esta linha é a parte que o Go deixa de fora.
impl Shape for Circle {
fn area(&self) -> f64 {
PI * self.r * self.r
}
fn name(&self) -> &str {
"círculo"
}
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn name(&self) -> &str {
"retângulo"
}
}
// Genérico, como o describe[T Shape](s T) do Go.
fn describe<T: Shape>(s: &T) {
println!("{}: {:.2}", s.name(), s.area());
}
fn main() {
describe(&Circle { r: 1.0 });
// Como o []Shape do Go: tipos diferentes por trás de um mesmo trait.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("área total: {total:.2}");
}

Saída:

círculo: 3.14
área total: 9.14
▶ Experimente você mesmo

Ser explícito custa uma linha por tipo, e compra algumas coisas que o Go não faz. Um trait pode ter métodos padrão, então os tipos ganham comportamento compartilhado de graça. Você pode implementar um trait seu para tipos que você não escreveu, até i32 ou String (o Go só deixa adicionar métodos a tipos do seu próprio pacote). E o describe genérico é compilado uma vez para cada tipo concreto, então a chamada não tem busca em tempo de execução. O Box<dyn Shape> é o estilo das interfaces do Go: um valor que aponta para “algum tipo que implementa Shape”, com o método encontrado em tempo de execução. Veja Traits e Trait objects.

No Go, um ponteiro que pode faltar é nil, e você precisa lembrar de verificar:

type User struct {
Name string
Manager *User // nil significa "sem gerente"
}
func findUser(users []*User, name string) *User {
for _, u := range users {
if u.Name == name {
return u
}
}
return nil
}
func main() {
ada := &User{Name: "Ada"}
users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} {
u := findUser(users, name)
if u == nil {
fmt.Println(name + ": no such user")
continue
}
boss := "nobody"
if u.Manager != nil {
boss = u.Manager.Name
}
fmt.Println(name, "reports to", boss)
}
}

Em Rust, “pode estar faltando” faz parte do tipo. Um Option<&User> é ou Some(user) ou None, e o compilador não deixa você usar o usuário antes de tratar o None:

struct User {
name: String,
manager: Option<String>, // Go: Manager *User, que pode ser nil
}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> {
users.iter().find(|u| u.name == name)
}
fn main() {
let users = vec![
User { name: "Ada".to_string(), manager: None },
User { name: "Bob".to_string(), manager: Some("Ada".to_string()) },
];
for name in ["Bob", "Ada", "Eve"] {
match find_user(&users, name) {
Some(user) => {
let boss = user.manager.as_deref().unwrap_or("ninguém");
println!("{name} responde a {boss}");
}
None => println!("{name}: usuário não encontrado"),
}
}
}

Saída:

Bob responde a Ada
Ada responde a ninguém
Eve: usuário não encontrado
▶ Experimente você mesmo

A mesma ideia substitui as buscas com comma-ok do Go. if email, ok := emails[name]; ok { ... } vira if let Some(email) = emails.get(name) { ... }. Um cuidado: no Go, emails["missing"] devolve quietinho o zero value "". No Rust, emails["missing"] causa um panic, então use .get() sempre que a chave puder não existir. O 'a em find_user é um lifetime: ele diz ao compilador que o &User devolvido aponta para dentro de users. Você vai conhecer os lifetimes logo.

O tratamento de erros do Go é famoso pelo padrão de três linhas:

func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("invalid port %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("port %d out of range", n)
}
return n, nil
}
func address(host, port string) (string, error) {
p, err := parsePort(port)
if err != nil {
return "", err
}
return fmt.Sprintf("%s:%d", host, p), nil
}
func main() {
for _, port := range []string{"8080", "abc", "70000", "0"} {
addr, err := address("localhost", port)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("listening on", addr)
}
}

O Rust mantém a ideia (erros são valores que você devolve), mas dobra o padrão inteiro num único caractere, o ?:

fn parse_port(s: &str) -> Result<u16, String> {
// Um u16 não cabe 70000, então o parse() já pega o "fora do intervalo" para nós.
let port: u16 = s.parse().map_err(|e| format!("porta inválida {s:?}: {e}"))?;
if port == 0 {
return Err("a porta 0 é reservada".to_string());
}
Ok(port)
}
fn address(host: &str, port: &str) -> Result<String, String> {
// `?` significa: se isto for um erro, devolva-o agora mesmo.
let port = parse_port(port)?;
Ok(format!("{host}:{port}"))
}
fn main() {
for port in ["8080", "abc", "70000", "0"] {
match address("localhost", port) {
Ok(addr) => println!("escutando em {addr}"),
Err(e) => println!("erro: {e}"),
}
}
}

Saída:

escutando em localhost:8080
erro: porta inválida "abc": invalid digit found in string
erro: porta inválida "70000": number too large to fit in target type
erro: a porta 0 é reservada
▶ Experimente você mesmo

As mensagens “invalid digit found in string” (dígito inválido encontrado na string) e “number too large to fit in target type” (número grande demais para o tipo de destino) continuam em inglês porque vêm da biblioteca padrão do Rust. Há uma diferença mais profunda do que economizar linhas. No Go, n, err := strconv.Atoi(s) dá os dois valores, e nada impede você de usar n quando err não é nil. Um Result é ou o valor ou o erro, então você não consegue chegar ao valor sem tratar o erro antes. E se você ignorar um Result por completo, o compilador avisa.

Em projetos de verdade, os erros String dão lugar a algo mais rico. Um enum de erro (muitas vezes escrito com o crate thiserror) faz o papel dos sentinel errors e do errors.Is: quem chama faz match na variante. O crate anyhow oferece .context("loading config")?, que é o fmt.Errorf("loading config: %w", err) do Rust. Veja Erros personalizados e anyhow e thiserror.

Lado a lado: goroutines e channels viram threads, mpsc e tarefas

Seção intitulada “Lado a lado: goroutines e channels viram threads, mpsc e tarefas”

Aqui está o clássico fan-in do Go: inicie algumas goroutines, mande os resultados por um channel e feche-o quando elas terminarem:

func main() {
results := make(chan int)
var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(n)
}
go func() {
wg.Wait()
close(results)
}()
total := 0
for r := range results {
total += r
}
fmt.Println("total:", total)
}

A versão em Rust, com threads e um canal da biblioteca padrão, é um pouco mais curta:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] {
let tx = tx.clone(); // cada thread recebe o próprio sender
thread::spawn(move || {
tx.send(n * n).unwrap();
});
}
// Descarte o nosso próprio sender. Quando todos os senders somem, o canal
// é fechado e o laço abaixo termina: nada de WaitGroup.
drop(tx);
let total: i32 = rx.iter().sum();
println!("total: {total}");
}

Saída:

total: 14
▶ Experimente você mesmo

Algumas coisas funcionam de outro jeito. mpsc significa “multi-producer, single-consumer” (vários produtores, um consumidor): você pode clonar o sender, mas não o receiver (para channels com vários leitores, como no Go, use o crossbeam-channel). O mpsc::channel() não tem limite, algo que o Go não tem; o mpsc::sync_channel(n) é o make(chan T, n) do Go, e o sync_channel(0) é um canal sem buffer. Ninguém chama close: o canal fecha quando o último sender é descartado. O move entrega a cada thread a própria cópia de n e o próprio tx, e é assim que o Rust garante que as threads nunca compartilhem dados de forma insegura. Veja Canais (channels) e Concorrência.

O thread::spawn inicia uma thread real do sistema operacional, que é mais pesada que uma goroutine. Isso é tranquilo para alguns poucos workers. Para milhares de conexões simultâneas, o Rust usa tarefas async num runtime como o tokio, que é bem mais parecido com as goroutines:

#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in [1, 2, 3] {
// Como `go func() { ... }()`, mas você recebe um handle de volta.
handles.push(tokio::spawn(async move { n * n }));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("total: {total}");
}

Saída:

total: 14
▶ Experimente você mesmo

Sendo sincero: este é um ponto em que o Go é mais simples. No Go, qualquer função pode bloquear, e o runtime cuida disso. No Rust, você marca funções como async, chama com .await e escolhe um runtime. A recompensa é que o compilador se recusa a compilar código em que duas threads poderiam alterar o mesmo dado ao mesmo tempo, então o go test -race não teria nada para encontrar. Veja Async/Await, Async a fundo e Estado compartilhado para o Arc<Mutex<T>>, a versão Rust de uma struct com um sync.Mutex ao lado dos dados.

No Go, você agenda a limpeza com defer, e as chamadas adiadas rodam em ordem inversa quando a função retorna:

func process() {
fmt.Println("open file")
defer fmt.Println("close file")
fmt.Println("lock mutex")
defer fmt.Println("unlock mutex")
fmt.Println("working")
}
func main() {
process()
fmt.Println("done")
}

No Rust, a limpeza pertence ao próprio valor. Quando um valor sai de escopo, a implementação de Drop dele roda, e os valores são descartados em ordem inversa, igualzinho aos seus defers:

struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("{}", self.0);
}
}
fn process() {
println!("abrir arquivo");
let _file = Guard("fechar arquivo");
println!("travar mutex");
let _lock = Guard("destravar mutex");
println!("trabalhando");
} // _lock é descartado aqui, depois _file
fn main() {
process();
println!("pronto");
}

Saída:

abrir arquivo
travar mutex
trabalhando
destravar mutex
fechar arquivo
pronto
▶ Experimente você mesmo

Em código de verdade, você raramente escreve um Drop: um File se fecha sozinho, um MutexGuard se destrava sozinho, um TcpStream se desconecta sozinho. Não tem nada para esquecer. Duas diferenças em relação ao defer valem a pena conhecer. O Drop roda no fim do bloco, e não da função, então a armadilha do Go de defers se acumulando dentro de um laço não existe: cada volta do laço limpa o que é seu. E cuidado com let _ = Guard(...): o _ sozinho não guarda o valor, então ele é descartado na hora. Use um nome como _lock.

Todo mundo que vem do Go passa pelos mesmos buracos. Conhecer todos com antecedência ajuda.

  • Passar um valor pode movê-lo. No Go, passar um slice para uma função copia um pequeno cabeçalho, e os dois lados continuam usando o slice. No Rust, passar um Vec o move para dentro da função, e você não pode mais usá-lo depois:

    fn total(prices: Vec<u32>) -> u32 {
    prices.iter().sum()
    }
    fn main() {
    let prices = vec![3, 4, 5];
    let sum = total(prices);
    println!("{sum} from {} items", prices.len());
    }
    error[E0382]: borrow of moved value: `prices`
    --> src/main.rs:8:37
    |
    6 | let prices = vec![3, 4, 5];
    | ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait
    7 | let sum = total(prices);
    | ------ value moved here
    8 | println!("{sum} from {} items", prices.len());
    | ^^^^^^ value borrowed here after move

    O compilador diz (em inglês) que prices foi movido na linha 7 (“value moved here”) e usado depois na linha 8 (“value borrowed here after move”). A correção é o hábito do Go em versão Rust: faça a função pegar emprestado um slice, fn total(prices: &[u32]), e chame com total(&prices). Leia primeiro Ownership; essa página explica essa regra. A página do E0382 cobre esse erro em detalhes.

  • O borrow checker. Você pode ter muitos leitores (&T) ou um único escritor (&mut T) ao mesmo tempo, nunca os dois. Código Go em que muitas structs guardam ponteiros para uma mesma struct compartilhada e a alteram precisa de outro design em Rust, ou de smart pointers (ponteiros inteligentes) como Rc<RefCell<T>> ou Arc<Mutex<T>>. Prepare-se para algumas semanas frustrantes. Depois fica bem mais fácil.

  • Sem zero values. let x: i32; seguido de println!("{x}") é um erro de compilação, e todo campo de uma struct precisa ser preenchido quando você a cria. Se você quer valores padrão no estilo do Go, derive Default e escreva Config { port: 8080, ..Default::default() }.

  • Implementar um trait é explícito. Ter um método area não transforma o seu tipo num Shape. Você precisa de impl Shape for Circle. A vantagem: você encontra todas as implementações só buscando por elas.

  • String vs &str. O Go tem um único tipo string. O Rust tem dois: String é dona do texto, &str pega o texto emprestado. Funções normalmente recebem &str, structs normalmente guardam String. E você não consegue indexar uma string com s[0] como no Go; use s.chars() ou s.as_bytes()[0]. Veja Strings.

  • Coisas não usadas geram avisos, não erros. O Go se recusa a compilar com um import ou uma variável sem uso. O Rust só avisa, e você pode começar um nome com _ para dizer “sem uso de propósito”.

  • Overflow de inteiros. O Go dá a volta em silêncio. O Rust causa um panic nos builds de debug e dá a volta nos builds de release, então escolha checked_add, wrapping_add ou saturating_add quando o overflow for possível. Literais inteiros são i32 por padrão, e índices são usize. Veja Overflow de inteiros.

  • Uma biblioteca padrão menor. Nada de servidor HTTP, JSON ou runtime async na std. Você vai fazer cargo add deles. No começo parece estranho, mas os crates populares são estáveis e bem documentados.

  • Async precisa de um runtime, e se espalha. Uma async fn não faz nada até receber um await dentro de um runtime como o tokio, e só código async pode usar .await. O Go não tem essa divisão. Comece com threads simples; parta para o async quando precisar de muitas conexões simultâneas.

  • Tempo de compilação. O Go foi feito para compilar muito rápido. O Rust compila mais devagar, principalmente em builds limpos com muitos crates. Use o cargo check enquanto edita; ele pula a geração de código e é bem mais rápido.

  • Compilar para outra plataforma pede um pouco de configuração. GOOS=linux go build simplesmente funciona. No Rust, você adiciona o alvo com o rustup e muitas vezes precisa de um linker para ele também. Ferramentas como o cross cuidam disso para você.

Os dois são boas escolhas, e muitos times usam os dois. Aqui vai uma regra prática justa.

Escolha Go quando você quer entregar serviços de rede e ferramentas internas rápido, o seu time valoriza um código que qualquer pessoa consegue ler depois de uma semana, builds rápidos importam muito, ou você trabalha no mundo cloud-native (Kubernetes, Docker, Terraform), onde Go é a língua comum. A biblioteca padrão do Go e a concorrência simples dele deixam muitos programas de servidor fáceis de escrever.

Escolha Rust quando você precisa de latência previsível, sem pausas de garbage collection, de pouco uso de memória, ou de cada gota de CPU (parsers, bancos de dados, engines de jogos, vídeo, compressão). O Rust também se encaixa melhor em código sem sistema operacional, em WebAssembly e em bibliotecas que você quer chamar a partir de outras linguagens. E vale a pena quando uma quebra causada por uma data race ou por uma verificação de nil esquecida sai cara. O Discord escreveu um post bem conhecido sobre a migração de um serviço de Go para Rust (em inglês) por causa de picos de latência causados pela garbage collection.

O custo do Rust é o tempo de aprendizado e os builds mais lentos. O custo do Go é um garbage collector e bugs que aparecem em tempo de execução em vez de em tempo de compilação. Qual custo dói mais depende do seu projeto.

Você não precisa ler este site na ordem. Como desenvolvedor Go, este caminho pula o que você já sabe e investe tempo onde o Rust é diferente:

  1. Começando com Rust e Ferramentas do Rust: instale o Rust, configure o rust-analyzer e rode cargo new.
  2. Variáveis e Tipos de dados: passe os olhos, principalmente para aprender let mut e os tipos numéricos.
  3. Ownership, depois Borrowing, depois Slices: as páginas mais importantes para você. Vá com calma.
  4. Strings: String vs &str.
  5. Structs e Enums, depois Match e Pattern matching. Enums com dados substituem muitos type switches do Go.
  6. Option e Result, depois Erros personalizados e anyhow e thiserror.
  7. Traits, Genéricos, Traits comuns e Trait objects.
  8. Closures e Iteradores: o Rust usa cadeias de iteradores muito mais que o Go.
  9. Vetores, HashMap e HashSet.
  10. Módulos, Cargo e crates e Testes em Rust.
  11. Lifetimes e Ponteiros inteligentes.
  12. Concorrência, Canais (channels), Estado compartilhado e depois Async/Await.
  13. JSON com Serde e Logging, e depois construa o projeto de API web.

Os dois exercícios trazem um trecho de Go. Converta para Rust.

Este programa Go conta palavras e as imprime em ordem alfabética. Converta para Rust. Dica: use um HashMap<&str, u32> e *counts.entry(word).or_insert(0) += 1 no lugar do counts[w]++ do Go. O text.split_whitespace() é o strings.Fields.

func main() {
text := "the cat and the dog and the bird"
counts := map[string]int{}
for _, w := range strings.Fields(text) {
counts[w]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, counts[k])
}
}
Ver solução
use std::collections::HashMap;
fn main() {
let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
let mut keys: Vec<&str> = counts.keys().copied().collect();
keys.sort();
for key in keys {
println!("{key}: {}", counts[key]);
}
}

Saída:

and: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ Experimente você mesmo

O Rust não tem um zero value em que se apoiar aqui, então o entry(word).or_insert(0) diz “comece em 0 se a palavra for nova”. Indexar com counts[key] é seguro porque toda chave veio do próprio mapa. Mais curto ainda: use um BTreeMap no lugar do HashMap, e as chaves já saem ordenadas, então você pode dispensar o vetor keys. (O texto ficou em inglês porque a saída depende da ordem alfabética.)

Este código Go devolve um sentinel error e o verifica com errors.Is. Converta para que withdraw devolva um Result<(), WithdrawError>, em que WithdrawError é um enum com duas variantes. Dica: implemente fmt::Display para o enum, para poder imprimi-lo, e faça match na variante onde o Go usa errors.Is.

var ErrInsufficientFunds = errors.New("insufficient funds")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error {
if n <= 0 {
return fmt.Errorf("invalid amount %d", n)
}
if n > a.Balance {
return ErrInsufficientFunds
}
a.Balance -= n
return nil
}
func main() {
acc := &Account{Balance: 100}
for _, n := range []int{30, 0, 500} {
if err := acc.Withdraw(n); err != nil {
if errors.Is(err, ErrInsufficientFunds) {
fmt.Println("declined:", err)
} else {
fmt.Println("error:", err)
}
continue
}
fmt.Println("ok, balance", acc.Balance)
}
}
Ver solução
use std::fmt;
enum WithdrawError {
InvalidAmount(i64),
InsufficientFunds,
}
impl fmt::Display for WithdrawError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WithdrawError::InvalidAmount(n) => write!(f, "valor inválido {n}"),
WithdrawError::InsufficientFunds => write!(f, "saldo insuficiente"),
}
}
}
struct Account {
balance: i64,
}
impl Account {
fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> {
if n <= 0 {
return Err(WithdrawError::InvalidAmount(n));
}
if n > self.balance {
return Err(WithdrawError::InsufficientFunds);
}
self.balance -= n;
Ok(())
}
}
fn main() {
let mut acc = Account { balance: 100 };
for n in [30, 0, 500] {
match acc.withdraw(n) {
Ok(()) => println!("ok, saldo {}", acc.balance),
Err(e @ WithdrawError::InsufficientFunds) => println!("recusado: {e}"),
Err(e) => println!("erro: {e}"),
}
}
}

Saída:

ok, saldo 70
erro: valor inválido 0
recusado: saldo insuficiente
▶ Experimente você mesmo

O sentinel error e o erro do fmt.Errorf viram duas variantes de um mesmo enum, e o errors.Is vira um braço do match. O e @ WithdrawError::InsufficientFunds casa com a variante e ainda entrega o valor inteiro como e, para você imprimir. Um bônus legal: se você adicionar uma terceira variante depois, o compilador mostra cada match que precisa ser atualizado. O crate thiserror pode escrever a implementação de Display para você.

  • Muita coisa do Go vem junto: structs com métodos, nada de herança, erros como valores, um formatador padrão e uma ferramenta só para compilar e testar.
  • As grandes mudanças são ownership no lugar de um garbage collector, Option no lugar de nil, e Result + ? no lugar de if err != nil.
  • Interfaces viram traits, e implementar um é explícito (impl Trait for Type). O defer vira o Drop automático no fim de um escopo.
  • Goroutines e channels viram thread::spawn + mpsc para poucos workers, ou tarefas do tokio para muitos; o compilador elimina as data races.
  • O Cargo substitui go build, go test e go mod; o clippy e o rustfmt substituem o go vet e o gofmt. No lugar do Gin, veja o axum ou o actix-web; no lugar do GORM, o SeaORM ou o Diesel; no lugar do pgx e do sqlc, o sqlx; e no lugar do cobra, o clap.

Teste o que aprendeu

1. No Go, um tipo satisfaz uma interface só por ter os métodos certos. Como um tipo do Rust implementa um trait?

2. O que o ? faz depois de uma chamada que devolve um Result?

3. Com o std::sync::mpsc, quando o for msg in rx (ou o rx.iter()) para?

4. O seu time Go constrói APIs HTTP com o Gin. Qual crate do Rust é o mais parecido?

Pontuação: 0 / 4

  • The Rust Programming Language (em inglês): o livro oficial. Os capítulos 4 (ownership), 9 (erros), 10 (genéricos e traits) e 16 (concorrência) são os que mais importam para quem vem do Go.
  • Tokio tutorial (em inglês): o runtime assíncrono que a maioria dos servidores Rust usa, o mais perto do escalonador de goroutines do Go.
  • Documentação do axum (em inglês): o framework web para olhar primeiro se você vem do Gin, do Echo ou do Chi.
  • Why Discord is switching from Go to Rust (em inglês): a história real de um time que migrou um serviço sensível à latência de Go para Rust.
  • Are We Web Yet? (em inglês): um panorama da comunidade sobre as bibliotecas e os frameworks web do Rust.