Pular para o conteúdo

Perguntas de entrevista sobre Rust

Estas são perguntas que aparecem o tempo todo em entrevistas de Rust, de vagas júnior até sênior. Cada resposta é curta o bastante para ser dita em voz alta e tem um link para a lição que explica o assunto a fundo.

Não decore as respostas palavra por palavra. Quem entrevista percebe, e geralmente faz uma pergunta de acompanhamento para ver se você entendeu de verdade. Use esta página para conferir o que você já sabe e depois vá até a lição indicada em tudo que parecer meio frágil. Se você consegue explicar uma resposta com suas próprias palavras e escrever um exemplo pequeno, está pronto.

O Cargo é a ferramenta oficial de build e o gerenciador de pacotes do Rust. Ele já vem junto com o Rust. Com uma única ferramenta você consegue:

  • criar um projeto (cargo new),
  • compilar e rodar o projeto (cargo build, cargo run),
  • rodar os testes (cargo test),
  • adicionar bibliotecas, chamadas de crates, do crates.io (cargo add serde).

As configurações e dependências de um projeto ficam no Cargo.toml. As versões exatas ficam travadas no Cargo.lock, então os builds podem ser repetidos com o mesmo resultado. Um ponto extra que conta a seu favor: outras linguagens muitas vezes precisam de várias ferramentas para isso, mas em Rust todo mundo usa o Cargo, então qualquer projeto parece familiar.

Saiba mais: Cargo e crates, Começando com Rust.

mut deixa você alterar o valor da mesma variável. O tipo precisa continuar o mesmo.

Shadowing (sombreamento) é escrever let de novo com o mesmo nome. Isso cria uma variável nova, que esconde a antiga. A nova pode até ter um tipo diferente.

fn main() {
let spaces = " ";
let spaces = spaces.len(); // shadowing: uma variável nova, agora um número
println!("{spaces}");
let mut count = 1;
count += 1; // mut: a mesma variável recebe um valor novo
println!("{count}");
}

Saída:

3
2
▶ Experimente você mesmo

O shadowing é prático para transformar um valor passo a passo (como converter um texto em número) sem inventar nomes como input_str e input_num. E a variável final continua imutável.

Saiba mais: Variáveis em Rust.

P: Qual é a diferença entre uma macro e uma função?

Seção intitulada “P: Qual é a diferença entre uma macro e uma função?”

Uma função é chamada em tempo de execução, com um número fixo de argumentos com tipo. Uma macro roda em tempo de compilação: ela recebe código e escreve mais código antes de o compilador verificar tudo. Dá para reconhecer uma macro pelo !, como em println!, vec! e format!.

Macros fazem coisas que funções não fazem, como receber um número variável de argumentos (println!("{} {}", a, b)) ou verificar uma string de formatação em tempo de compilação. As macros do Rust também trabalham com a estrutura do código, e não com texto puro, então elas evitam os bugs clássicos das macros de C:

macro_rules! square {
($x:expr) => {
$x * $x
};
}
fn main() {
// Em C, uma macro baseada em texto daria 2 + 1 * 2 + 1 = 5.
println!("{}", square!(2 + 1));
}

Saída:

9
▶ Experimente você mesmo

O lado ruim, sendo sincero: macros são mais difíceis de ler, escrever e depurar. Use uma função quando uma função der conta do recado.

Saiba mais: Macros em Rust.

Zero-cost abstractions (abstrações de custo zero) quer dizer que recursos de alto nível não deveriam deixar seu programa mais lento do que o código de baixo nível que você escreveria à mão. “Você não paga pelo que não usa, e o que você usa, não conseguiria escrever melhor sozinho.”

Por exemplo, uma cadeia de iteradores como .iter().filter(...).map(...).sum() costuma virar o mesmo código de máquina de um laço escrito à mão. Os genéricos são outro exemplo: o compilador cria uma cópia especializada para cada tipo que você usa (isso se chama monomorfização), então não há nenhuma busca em tempo de execução.

Seja sincero sobre a parte do “custo”: é custo zero em tempo de execução. Você ainda paga em tempo de compilação, e os genéricos podem deixar o arquivo do programa maior.

Saiba mais: Iteradores em Rust, Genéricos em Rust.

P: Os iteradores do Rust são preguiçosos (lazy)?

Seção intitulada “P: Os iteradores do Rust são preguiçosos (lazy)?”

Sim. Métodos como .map() e .filter() não fazem trabalho nenhum sozinhos. Eles montam uma descrição do trabalho. Nada acontece até alguém consumir o iterador, como .collect(), .sum() ou um laço for.

fn main() {
let numbers = vec![1, 2, 3];
let doubled = numbers.iter().map(|n| {
println!("dobrando {n}");
n * 2
});
println!("Nada rodou ainda.");
let result: Vec<i32> = doubled.collect();
println!("{:?}", result);
}

Saída:

Nada rodou ainda.
dobrando 1
dobrando 2
dobrando 3
[2, 4, 6]
▶ Experimente você mesmo

É também por isso que o compilador avisa se você escrever numbers.iter().map(...); e nunca usar o resultado: isso não faria nada.

Saiba mais: Iteradores em Rust.

São três:

  1. Todo valor em Rust tem um dono (owner), que é uma variável.
  2. Só pode existir um dono por vez.
  3. Quando o dono sai de escopo, o valor é descartado (dropped), ou seja, a memória dele é liberada.

Essas regras deixam o Rust liberar a memória exatamente na hora certa, sem garbage collector (coletor de lixo) e sem você chamar free à mão. O compilador verifica as regras, então erros como “use after free” (usar depois de liberar) ou “double free” (liberar duas vezes) não acontecem em código seguro.

Saiba mais: Ownership (posse), Escopo em Rust.

  • Move: atribuir ou passar um valor transfere o ownership. A variável antiga não pode mais ser usada. Esse é o comportamento padrão para tipos como String e Vec. Nada é copiado, a não ser um pequeno ponteiro, o tamanho e a capacidade.
  • Copy: para tipos pequenos e simples, como i32, f64, bool e char, a atribuição faz uma cópia barata, bit a bit, e as duas variáveis continuam utilizáveis.
  • Clone: .clone() é uma cópia profunda explícita. Em uma String, ele copia todo o texto para uma memória nova. Isso pode sair caro, e é por isso que o Rust obriga você a escrever.
fn main() {
let x = 5;
let y = x; // Copy: x continua utilizável
println!("{x} {y}");
let a = String::from("oi");
let b = a.clone(); // Clone: uma cópia completa do texto
let c = a; // Move: `a` não pode ser usada depois desta linha
println!("{b} {c}");
}

Saída:

5 5
oi oi
▶ Experimente você mesmo

Saiba mais: Ownership (posse).

Copy é um trait marcador que diz “este tipo pode ser duplicado só copiando os bits dele”. Tipos que são Copy são copiados em vez de movidos na atribuição.

Inteiros, números de ponto flutuante, bool, char, referências compartilhadas (&T) e tuplas ou arrays formados só por tipos Copy são todos Copy. String, Vec e Box não são, porque são donos de uma memória em outro lugar, e copiar o ponteiro criaria dois donos.

Você pode derivá-lo para seus próprios tipos, mas só se todos os campos forem Copy:

#[derive(Clone, Copy)]
struct User {
name: String,
age: u32,
}
error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:2:8
|
1 | #[derive(Clone, Copy)]
| ---- in this derive macro expansion
2 | struct User {
| ^^^^
3 | name: String,
| ------------ this field does not implement `Copy`

O compilador diz que o trait Copy não pode ser implementado para esse tipo, porque o campo name (uma String) não implementa Copy.

Copy sempre exige Clone também. E um tipo que implementa Drop não pode ser Copy.

Saiba mais: Traits comuns, Ownership (posse).

  • String é um texto com dono (owned), que pode crescer e fica guardado na heap. Você pode alterá-lo, por exemplo com push_str.
  • &str (um “string slice”) é uma visão emprestada de algum texto. É só um ponteiro e um tamanho. Literais como "olá" são &str.

Uma boa regra: funções que só leem texto devem receber &str. Assim elas aceitam os dois, porque uma &String vira &str automaticamente.

fn greet(name: &str) {
println!("Olá, {name}!");
}
fn main() {
let owned: String = String::from("Ana");
let literal: &str = "Ben";
greet(&owned); // &String funciona onde se espera &str
greet(literal);
let mut full = owned;
full.push_str(" Silva"); // só uma String pode crescer
greet(&full);
}

Saída:

Olá, Ana!
Olá, Ben!
Olá, Ana Silva!
▶ Experimente você mesmo

Saiba mais: Strings em Rust, Slices em Rust.

Em qualquer momento, para um mesmo valor, você pode ter ou:

  • qualquer número de referências compartilhadas (&T), ou
  • exatamente uma referência mutável (&mut T),

mas nunca as duas coisas ao mesmo tempo. E toda referência precisa sempre apontar para dados válidos.

Por quê? Se alguém pudesse alterar os dados enquanto outra pessoa está lendo, você teria bugs como um vetor mudando a memória de lugar enquanto você guarda um ponteiro para dentro dele. Com threads, isso é exatamente o que chamamos de data race (condição de corrida nos dados). O Rust impede isso em tempo de compilação. Desde a edição 2018, o compilador é esperto sobre quando um empréstimo termina (isso se chama non-lexical lifetimes): um empréstimo só dura até o último uso, e não até o fim do bloco.

Saiba mais: Borrowing (empréstimo).

Um lifetime (tempo de vida) é o nome que o compilador dá para “por quanto tempo uma referência é válida”. Toda referência tem um. Na maior parte do tempo o compilador descobre sozinho (isso se chama lifetime elision, ou elisão de lifetimes), então você nem os vê.

Você só precisa escrevê-los quando o compilador não consegue saber como a referência de saída se relaciona com as de entrada, como em uma função que devolve uma de duas strings emprestadas:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let first = String::from("borrow checker");
let second = String::from("lifetimes");
println!("{}", longest(&first, &second));
}

Saída:

borrow checker
▶ Experimente você mesmo

(As strings ficaram em inglês porque o resultado depende do tamanho delas com .len(), e letras com acento ocupam mais bytes.)

Um ponto importante para dizer na entrevista: as anotações de lifetime não mudam quanto tempo nada vive. Elas só descrevem relações, para que o compilador possa verificar que nenhuma referência vive mais do que os dados para os quais aponta.

Saiba mais: Lifetimes em Rust.

Option<T>, um enum com duas variantes: Some(valor) ou None. Como ele é um tipo diferente de T, o compilador obriga você a tratar o caso None antes de usar o valor. Não tem como esquecer uma verificação de null, porque o código nem compila.

fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("Ana"),
2 => Some("Ben"),
_ => None,
}
}
fn main() {
for id in [1, 3] {
match find_user(id) {
Some(name) => println!("Encontrei {name}"),
None => println!("Nenhum usuário com id {id}"),
}
}
}

Saída:

Encontrei Ana
Nenhum usuário com id 3
▶ Experimente você mesmo

Um detalhe extra legal: Option<&T> e Option<Box<T>> não ocupam nenhuma memória a mais do que um ponteiro comum. Por dentro, o Rust usa o padrão de bits do “null” para representar None, então a segurança aqui não custa nada.

Saiba mais: Option em Rust, If Let / While Let em Rust.

P: O que é um trait? É a mesma coisa que uma interface?

Seção intitulada “P: O que é um trait? É a mesma coisa que uma interface?”

Um trait define um conjunto de métodos que um tipo pode implementar. Ele é parecido com uma interface em Java, C# ou Go, e esse é um bom jeito de começar a resposta. Depois, cite as diferenças:

  • Traits podem ter implementações padrão de métodos.
  • Você pode implementar um trait para um tipo depois que o tipo foi escrito, até para tipos que não são seus, como implementar seu próprio trait para i32. (A “orphan rule”, ou regra do órfão, diz que ou o trait ou o tipo precisa ser seu.)
  • Traits são usados como restrições em genéricos (T: Display), resolvidas em tempo de compilação, e também como trait objects (dyn Display) em tempo de execução.
  • Rust não tem herança de classes. Traits mais composição fazem esse papel.

Saiba mais: Traits em Rust, Traits comuns.

P: Qual é a diferença entre genéricos e trait objects?

Seção intitulada “P: Qual é a diferença entre genéricos e trait objects?”

Os dois deixam uma função trabalhar com vários tipos. A diferença é quando o tipo é decidido:

  • Genéricos (fn f<T: Speak>(x: &T)) usam despacho estático (static dispatch). O compilador cria uma cópia separada da função para cada tipo. As chamadas são tão rápidas quanto chamadas de função normais, mas o programa pode ficar maior.
  • Trait objects (&dyn Speak, Box<dyn Speak>) usam despacho dinâmico (dynamic dispatch). Existe uma única cópia da função, e o método certo é procurado em tempo de execução por meio de uma tabela (a “vtable”). É um pouquinho mais lento, mas deixa você misturar tipos diferentes em uma mesma coleção.
trait Speak {
fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Speak for Dog {
fn speak(&self) -> String {
String::from("Au au")
}
}
impl Speak for Cat {
fn speak(&self) -> String {
String::from("Miau")
}
}
fn speak_static<T: Speak>(animal: &T) -> String {
animal.speak()
}
fn speak_dynamic(animal: &dyn Speak) -> String {
animal.speak()
}
fn main() {
println!("{}", speak_static(&Dog));
println!("{}", speak_dynamic(&Cat));
// Só trait objects conseguem misturar tipos em um único Vec:
let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &animals {
println!("{}", animal.speak());
}
}

Saída:

Au au
Miau
Au au
Miau
▶ Experimente você mesmo

Regra prática: comece com genéricos. Use trait objects quando precisar de uma coleção com tipos diferentes, ou quando quiser manter o tempo de compilação e o tamanho do binário menores.

Saiba mais: Genéricos em Rust, Trait objects (dyn).

P: Qual é a diferença entre Box<dyn Trait> e impl Trait?

Seção intitulada “P: Qual é a diferença entre Box<dyn Trait> e impl Trait?”

Os dois permitem dizer “algum tipo que implementa este trait” sem dar nome a ele. A diferença está em quantos tipos concretos são permitidos:

  • impl Trait significa um tipo específico, que o compilador conhece mas não obriga você a escrever. É despacho estático (static dispatch): sem alocação no heap, sem vtable, tão rápido quanto escrever o nome do tipo. Na posição de argumento (fn f(x: impl Display)), é só um jeito mais curto de escrever um genérico. Na posição de retorno, é como você devolve coisas com tipos longos ou impossíveis de nomear, como closures e cadeias de iteradores.
  • Box<dyn Trait> é um trait object no heap. O tipo real pode ser diferente a cada vez, decidido em tempo de execução, e o método é encontrado por uma vtable. Isso custa uma alocação e uma chamada indireta, mas é o único jeito de devolver um entre vários tipos, ou de guardá-los num mesmo Vec.
use std::fmt::Display;
// impl Trait: um único tipo escondido, fixado na compilação.
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}
// Box<dyn Trait>: o tipo real pode mudar em tempo de execução.
fn label(as_number: bool) -> Box<dyn Display> {
if as_number {
Box::new(42)
} else {
Box::new("quarenta e dois")
}
}
fn main() {
let v: Vec<u32> = evens(10).collect();
println!("{v:?}");
println!("{}", label(true));
println!("{}", label(false));
}

Saída:

[0, 2, 4, 6, 8]
42
quarenta e dois
▶ Experimente você mesmo

Um bom detalhe para comentar: se você mudar label para devolver impl Display e tirar os Box, o código não compila. O if devolve um i32 e o else devolve um &str, e impl Trait precisa ser um único tipo. Regra prática: use impl Trait por padrão e passe para Box<dyn Trait> quando você realmente precisar escolher entre tipos em tempo de execução.

Saiba mais: Trait objects (dyn), Genéricos em Rust.

Um match precisa tratar todos os valores possíveis, senão o código não compila. Isso significa que, quando você adiciona uma variante nova a um enum, o compilador mostra todos os lugares que precisam ser atualizados. Em outras linguagens, um case esquecido em um switch simplesmente não faz nada, sem avisar.

Você pode usar _ para pegar todo o resto, mas cuidado: esse “pega tudo” também esconde as variantes novas que você adicionar depois, e você perde essa rede de segurança.

Saiba mais: Match em Rust, Pattern matching, Enums em Rust.

Erros recuperáveis são valores normais do tipo Result<T, E>: ou Ok(valor) ou Err(erro). Uma função que pode falhar diz isso no tipo de retorno, e quem chama precisa lidar com isso. Nada sobe pela pilha de forma invisível, como acontece com uma exceção.

As vantagens: você vê pela assinatura da função que ela pode falhar, e o compilador avisa se você ignorar um Result. O custo é digitar um pouco mais, e o operador ? elimina quase todo esse trabalho.

Saiba mais: Result em Rust.

P: Quando usar panic! em vez de devolver um Result?

Seção intitulada “P: Quando usar panic! em vez de devolver um Result?”
  • Devolva um Result quando a falha é esperada e quem chama poderia razoavelmente lidar com ela: um arquivo que não existe, uma entrada inválida do usuário, um timeout de rede.
  • Use panic quando algo é um bug, ou seja, o programa chegou a um estado que “deveria ser impossível” e continuar seria errado. Acessar um índice depois do fim de um vetor causa panic por esse motivo.

Panics também são aceitáveis em testes, exemplos e protótipos rápidos. Em uma biblioteca, evite entrar em panic por causa de uma entrada que quem chama controla. Devolva um erro e deixe essa pessoa decidir.

Saiba mais: Panic em Rust, Result em Rust.

  • unwrap() entrega o valor de dentro do Ok/Some, ou entra em panic com uma mensagem genérica.
  • expect("msg") faz o mesmo, mas entra em panic com a sua mensagem. Use para explicar por que você acredita que aquilo não pode falhar.
  • ? não entra em panic. Com Err/None, ele devolve o erro da função atual, para que quem chamou possa tratá-lo. A própria função precisa devolver um Result ou um Option.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> {
let n: i32 = text.parse()?; // se der erro, devolve para quem chamou
Ok(n * 2)
}
fn main() {
println!("{:?}", double("21"));
println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("um número fixo no código sempre deve ser convertido");
println!("{n}");
}

Saída:

Ok(42)
Err(ParseIntError { kind: InvalidDigit })
7
▶ Experimente você mesmo

InvalidDigit (dígito inválido) é o nome que o Rust dá para o erro: "abc" tem caracteres que não são dígitos.

Em código de produção, prefira o ?. Use expect quando uma falha seria um bug, e deixe o unwrap para testes e experimentos rápidos.

Saiba mais: Result em Rust, Option em Rust.

Normalmente com um enum que lista as formas como seu código pode falhar, mais as implementações de std::fmt::Display e std::error::Error. Implementar From para outros tipos de erro permite que o ? faça a conversão automaticamente.

Na prática, muitos projetos usam crates auxiliares: thiserror para definir tipos de erro em bibliotecas e anyhow para aplicações que só precisam repassar os erros com contexto. Para programas rápidos, Box<dyn Error> consegue guardar qualquer erro.

Saiba mais: Erros personalizados.

Eles resolvem as duas metades do mesmo problema, então a resposta sincera é “muitas vezes, os dois”:

  • thiserror é para bibliotecas, ou para qualquer código cujos chamadores precisem reagir a erros específicos. Você escreve o seu próprio enum de erro e o #[derive(Error)] preenche o código repetitivo de Display e Error. Quem chama recebe um tipo de verdade, em que dá para fazer match, como ConfigError::MissingField.
  • anyhow é para aplicações, em que a maioria dos erros só precisa ser informada, e não tratada um por um. Toda função devolve anyhow::Result<T>, o ? aceita quase qualquer tipo de erro, e .context("ao ler a configuração") acrescenta uma explicação amigável por cima. A desvantagem: quem chama já não consegue fazer match no erro com facilidade.

Uma combinação comum é thiserror dentro dos crates de biblioteca e anyhow no main e no resto do binário. Nenhum dos dois crates é mágico. Ambos só eliminam código repetitivo em cima do trait padrão std::error::Error, então você sempre pode escrever a mesma coisa à mão.

Saiba mais: anyhow e thiserror, Erros personalizados.

Não. A memória é liberada quando o dono dela sai de escopo, em um ponto que o compilador conhece de antemão (isso se chama RAII, e o método de limpeza é o Drop). Isso dá um desempenho previsível, sem pausas de GC.

Quando você realmente precisa de ownership compartilhado, você escolhe usar contagem de referências (Rc ou Arc), que libera o valor quando o último dono deixa de existir.

Saiba mais: Ownership (posse), Ponteiros inteligentes.

  • Box<T>: um dono, valor na heap. Use para tipos recursivos (como uma árvore ou lista ligada), valores grandes ou trait objects (Box<dyn Trait>).
  • Rc<T>: vários donos em uma única thread. Ele conta as referências e libera o valor quando a contagem chega a zero. Os dados são só de leitura.
  • Arc<T>: como o Rc, mas a contagem é atômica, então é seguro compartilhar entre threads. Um pouco mais lento que o Rc.
  • RefCell<T>: deixa você alterar dados por meio de uma referência compartilhada (“mutabilidade interior”). As regras de borrowing são verificadas em tempo de execução em vez de em tempo de compilação, e quebrá-las causa panic.

Eles se combinam. Rc<RefCell<T>> é “compartilhado e alterável, em uma thread”. Arc<Mutex<T>> é a versão segura para threads.

use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2]));
let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow());
println!("donos: {}", Rc::strong_count(&shared));
}

Saída:

[1, 2, 3]
donos: 2
▶ Experimente você mesmo

Saiba mais: Ponteiros inteligentes.

Sim, e é honesto admitir isso. O Rust garante segurança de memória (nada de use-after-free, nada de ponteiros soltos, nada de data races), mas vazar memória não é considerado inseguro. Dois jeitos comuns:

  • Um ciclo de Rc: dois valores que apontam um para o outro com Rc nunca chegam a uma contagem zero. A solução é fazer uma das direções ser uma referência Weak.
  • Funções como Box::leak e std::mem::forget, que vazam de propósito.

No código do dia a dia, vazamentos são raros, porque o ownership libera quase tudo automaticamente.

Saiba mais: Ponteiros inteligentes.

unsafe marca um bloco (ou uma função) onde você pode fazer cinco coisas extras que o compilador não consegue verificar, como desreferenciar ponteiros brutos (raw pointers), chamar funções unsafe (incluindo código C por FFI) ou acessar variáveis estáticas mutáveis. Lá dentro, você promete que as regras estão sendo respeitadas.

Importante: unsafe não desliga o borrow checker nem as outras verificações. Ele só libera essas poucas operações extras.

Ele é usado para conversar com bibliotecas em C, escrever estruturas de dados de baixo nível e espremer desempenho onde o compilador não consegue provar que algo é seguro. A própria biblioteca padrão usa unsafe por dentro de APIs seguras como Vec. Boa prática: mantenha os blocos unsafe pequenos, envolva-os em uma API segura e escreva um comentário // SAFETY: explicando por que está correto. A maioria do código de aplicação nunca precisa disso.

Saiba mais: Unsafe em Rust.

São traits marcadores que o compilador usa para manter as threads seguras:

  • Send: um valor desse tipo pode ser movido para outra thread.
  • Sync: um valor desse tipo pode ser compartilhado por referência entre threads (&T é Send).

A maioria dos tipos é as duas coisas, e o compilador implementa esses traits automaticamente. A exceção famosa é o Rc, cuja contagem não é segura para threads. Tente mandar um para uma thread, e o código não compila:

use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let handle = thread::spawn(move || {
println!("{data}");
});
handle.join().unwrap();
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
...
= help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`

O erro diz que um Rc<i32> não pode ser enviado entre threads com segurança, porque ele não implementa Send.

Troque Rc por Arc e funciona. Isso é boa parte do que as pessoas querem dizer com a “fearless concurrency” (concorrência sem medo) do Rust: erros de segurança entre threads viram erros de compilação.

Saiba mais: Concorrência em Rust.

P: Como compartilhar dados alteráveis entre threads?

Seção intitulada “P: Como compartilhar dados alteráveis entre threads?”

A resposta clássica é Arc<Mutex<T>>. O Arc deixa várias threads serem donas do valor. O Mutex garante que só uma thread por vez pode alterá-lo: você chama .lock() e recebe uma guarda (guard) que destrava sozinha quando sai de escopo.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Total: {}", *counter.lock().unwrap());
}

Saída:

Total: 4000
▶ Experimente você mesmo

Cite também a alternativa: em vez de compartilhar memória, mande mensagens entre as threads com canais (std::sync::mpsc). E, para contadores simples, tipos atômicos como AtomicUsize dispensam o lock.

Saiba mais: Concorrência em Rust.

P: Como os canais se comparam com estado compartilhado usando Mutex?

Seção intitulada “P: Como os canais se comparam com estado compartilhado usando Mutex?”

São dois estilos de dividir trabalho entre threads:

  • Canais (std::sync::mpsc) passam mensagens. Um valor é movido para dentro do canal e sai do outro lado, então só uma thread é dona dele por vez. Eles combinam com pipelines e grupos de workers: “os workers produzem resultados, uma thread junta tudo”. Costumam ser mais fáceis de entender, porque duas threads nunca mexem nos mesmos dados.
  • Estado compartilhado (Arc<Mutex<T>>) significa que várias threads usam os mesmos dados, revezando por meio de um lock. Combina quando muitas threads precisam ler e atualizar uma mesma estrutura, como um cache ou uma tabela de contadores. Os riscos são deadlocks e lentidão quando as threads ficam esperando o lock.

Aqui está o contador da resposta anterior, reescrito com um canal. Cada worker conta sozinho, sem lock, e envia o seu resultado:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for _ in 0..4 {
let tx = tx.clone();
thread::spawn(move || {
let mut count = 0;
for _ in 0..1000 {
count += 1;
}
tx.send(count).unwrap();
});
}
drop(tx); // descarte o sender original, senão o rx.iter() nunca termina
let total: i32 = rx.iter().sum();
println!("Total: {total}");
}

Saída:

Total: 4000
▶ Experimente você mesmo

A linha drop(tx) é um ótimo detalhe para comentar: o rx.iter() fica esperando até todos os senders sumirem, então esquecer dela faz o programa travar. Nos dois estilos, o Rust verifica a segurança entre threads em tempo de compilação. A escolha é sobre qual design fica mais claro para o seu problema, e muitos programas usam os dois.

Saiba mais: Canais (channels), Concorrência em Rust.

Não, e dizer isso mostra que você entende de verdade. O Rust seguro impede data races (duas threads mexendo na mesma memória ao mesmo tempo, com pelo menos uma escrevendo). Ele não impede:

  • Deadlocks, por exemplo duas threads, cada uma esperando um lock que a outra está segurando.
  • Condições de corrida na sua lógica, por exemplo verificar um valor e agir com base nele depois que outra thread já o alterou.

Então o Rust elimina uma classe inteira de bugs difíceis, mas você ainda precisa planejar seus locks com cuidado.

Saiba mais: Concorrência em Rust.

  • Threads são gerenciadas pelo sistema operacional. Cada uma tem sua própria pilha, e o sistema operacional alterna entre elas. São ótimas para trabalho pesado de CPU. Mas milhares de threads gastam muita memória.
  • Tarefas async são bem mais leves. Uma async fn devolve um Future, e um runtime (normalmente o Tokio) roda muitos futures em poucas threads. Quando uma tarefa está esperando (pela rede, por um timer, por um arquivo), o .await deixa outras tarefas rodarem. É ideal para programas que esperam muito, como servidores web atendendo milhares de conexões.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await; // finge que está esperando a rede
format!("{name} terminou")
}
#[tokio::main]
async fn main() {
// As duas rodam ao mesmo tempo, então isso leva uns 200ms, e não 300ms.
let (a, b) = tokio::join!(fetch("primeira", 200), fetch("segunda", 100));
println!("{a}");
println!("{b}");
}

Saída:

primeira terminou
segunda terminou
▶ Experimente você mesmo

Dois detalhes sinceros que quem entrevista gosta de ouvir: os futures do Rust são preguiçosos e não fazem nada até receberem .await, e a biblioteca padrão não inclui um runtime, então você escolhe um. Além disso, código bloqueante (como um cálculo demorado ou std::thread::sleep) dentro de uma tarefa async bloqueia as outras tarefas daquela thread.

Saiba mais: Async/Await em Rust, Concorrência em Rust.

  • Comece simples e depois aprofunde. Dê primeiro uma resposta de uma frase (“Rc é um ponteiro com contagem de referências para ownership compartilhado em uma thread”). Depois acrescente detalhes se a pessoa parecer interessada.
  • Explique o porquê. As regras do Rust existem para impedir bugs específicos. Dizer “as regras de borrowing impedem data races e ponteiros soltos” é mais forte do que só recitar as regras.
  • Use um exemplo pequeno. Escrever três linhas de código, ou descrevê-las, muitas vezes explica mais do que um discurso longo.
  • Seja sincero sobre os prós e contras. Rust tem custos: compilação mais lenta, uma curva de aprendizado, mais digitação no tratamento de erros. Admitir isso mostra experiência, não fraqueza.
  • Diga “não sei” quando não souber, e depois raciocine em voz alta. “Nunca usei Pin diretamente, mas sei que tem a ver com futures que não podem mudar de lugar na memória” é uma ótima resposta.
  • Tenha uma história pronta. É comum perguntarem: “Conte de uma vez em que o borrow checker barrou você.” Pense em um exemplo real do seu próprio código e no que você aprendeu com ele.
  • Pratique escrevendo código. A página de Exercícios de Rust e os projetos Jogo de adivinhação e Lista de tarefas são bons aquecimentos.

Teste o que aprendeu

1. Qual smart pointer você usaria para compartilhar dados só de leitura entre threads?

2. O que o operador ? faz quando encontra um Err?

3. Qual destes problemas o Rust seguro impede em tempo de compilação?

4. As anotações de lifetime mudam quanto tempo um valor vive?

Pontuação: 0 / 4

Todos os links abaixo estão em inglês.

  • The Rust Programming Language (o Rust Book): o livro oficial. A maioria das respostas desta página vem direto dos capítulos dele sobre ownership, traits, erros e concorrência.
  • Rust by Example: exemplos curtos que você pode rodar, ótimos para relembrar a sintaxe logo antes de uma entrevista.
  • The Rust Reference: as regras exatas da linguagem, para quando o entrevistador perguntar “mas exatamente quando isso acontece?”
  • The Rustonomicon: o guia de Rust unsafe, com mais contexto sobre Send, Sync e layout de memória para perguntas de nível sênior.
  • Asynchronous Programming in Rust: como futures, async/.await e runtimes se encaixam, no livro oficial de async do projeto Rust.