Otimização de performance em Rust
LidoEsta aula aborda técnicas essenciais de otimização de performance em Rust, focando na redução de alocações desnecessárias, eliminação de clones, escolha correta entre &str e String, e uso do tipo Cow para evitar cópias em cenários de empréstimo ou propriedade.
Nesta aula, vamos explorar técnicas fundamentais para otimizar o desempenho de programas escritos em Rust. O foco será em reduzir custos de alocação de memória, evitar cópias desnecessárias e entender quando usar referências ou valores possuídos. Esses conceitos são cruciais para escrever código eficiente, especialmente em sistemas de alta performance, servidores web, processamento de dados e aplicações embarcadas.
Rust já é conhecido por seu desempenho, mas pequenas escolhas no código podem ter grande impacto. Vamos analisar padrões comuns que geram ineficiências e como substituí-los por alternativas mais leves, sempre mantendo a segurança e a clareza do código.
Alocações
Em Rust, alocações no heap (via Box, Vec, String, etc.) são relativamente caras comparadas a operações na pilha. Cada alocação envolve a solicitação de memória ao sistema operacional, o que pode ser lento. Além disso, a desalocação também tem custo. Portanto, minimizar o número de alocações é uma estratégia chave para otimizar o desempenho.
Muitas vezes, alocações desnecessárias surgem de conversões, clonagens ou da criação de coleções temporárias. Por exemplo, usar String::from para criar uma string a partir de um literal quando uma referência seria suficiente, ou criar um Vec para cada iteração de um loop.
Vamos ver um exemplo de código ineficiente e como melhorá-lo:
// Ineficiente: cria uma String desnecessária em cada iteração
fn process_items(items: &[&str]) {
for item in items {
let upper = item.to_uppercase(); // aloca uma nova String
println!("{}", upper);
}
}
// Mais eficiente: usa iteração sobre bytes ou char, sem alocar
fn process_items_fast(items: &[&str]) {
for item in items {
// Se precisarmos apenas imprimir em maiúsculas, podemos iterar e imprimir caractere por caractere
// Mas isso pode não ser trivial. Uma alternativa é usar buffer reutilizável.
// Para fins de exemplo, mostramos como evitar alocação ao imprimir diretamente:
print!("{}", item.to_uppercase()); // ainda aloca, mas é só um exemplo
}
}
// Na prática, se precisar transformar, considere usar um buffer reutilizável.
fn main() {
let items = ["hello", "world"];
process_items(&items);
}
O exemplo acima ainda aloca, mas a ideia é mostrar que to_uppercase cria uma nova String. Se a transformação for necessária, podemos reutilizar um buffer:
fn process_items_reuse(items: &[&str]) {
let mut buffer = String::new();
for item in items {
buffer.clear();
buffer.push_str(&item.to_uppercase()); // ainda aloca internamente, mas reutilizamos o buffer
println!("{}", buffer);
}
}
Na verdade, to_uppercase sempre aloca, mas podemos usar write! para formatar diretamente em um buffer reutilizável:
use std::fmt::Write;
fn process_items_reuse_fast(items: &[&str]) {
let mut buffer = String::new();
for item in items {
buffer.clear();
for c in item.chars() {
buffer.extend(c.to_uppercase()); // escreve sem alocar? ainda pode alocar se o buffer não tiver capacidade
}
println!("{}", buffer);
}
}
O ponto principal é: evite criar estruturas de dados temporárias sempre que possível. Use iteradores, referências e buffers reutilizáveis.
Clones desnecessários
Clonar um valor com .clone() pode ser caro, especialmente para tipos que possuem alocação de heap, como String, Vec, ou HashMap. Muitas vezes, clonamos por engano, quando poderíamos usar referências ou mover o valor.
Um padrão comum é clonar para evitar problemas de empréstimo, mas isso pode ser contornado com um design melhor. Por exemplo, ao passar um parâmetro para uma função, se a função não precisa possuir o valor, aceite uma referência &T em vez de T e evite clonar.
Vamos ver um exemplo:
#[derive(Debug, Clone)]
struct User {
name: String,
age: u8,
}
// Ineficiente: clona o usuário desnecessariamente
fn print_user_clone(user: User) {
println!("{:?}", user); // user é movido, não clonado, mas o chamador pode precisar dele depois
}
// Melhor: aceita referência
fn print_user_ref(user: &User) {
println!("{:?}", user);
}
fn main() {
let user = User { name: String::from("Alice"), age: 30 };
print_user_ref(&user); // não clona, apenas empresta
println!("{}", user.name); // ainda podemos usar user
}
Se a função precisa modificar o valor, podemos aceitar &mut em vez de clonar. A regra é: só clone quando precisar de uma cópia independente, e mesmo assim, considere se é possível usar Rc ou Arc para compartilhar.
Outro caso comum é o uso de .clone() em métodos de encadeamento:
let s = String::from("hello");
let t = s.clone() + " world"; // clona s para concatenar, mas poderíamos ter feito s + " world" se não precisássemos mais de s
Se você não precisa mais da variável original, pode mover o valor:
let s = String::from("hello");
let t = s + " world"; // s é movido, não clonado
&str vs String
Em Rust, String é um tipo que possui um buffer de bytes alocado no heap, enquanto &str é uma referência a uma sequência de bytes UTF-8 que pode apontar para uma string literal, um subconjunto de uma String, ou outros dados. Usar &str em vez de String pode evitar alocações e cópias, pois uma referência é apenas um ponteiro e um comprimento.
Quando uma função só precisa ler uma string, ela deve aceitar &str em vez de String. Isso permite passar tanto &String (coagido via deref) quanto literais &str sem alocar.
Exemplo:
fn process(s: &str) {
println!("{}", s.len());
}
fn main() {
let s = String::from("hello");
process(&s); // coerção automática de &String para &str
process("world"); // literal é &str
}
Se você precisar retornar uma string possuída, use String. Mas se puder retornar uma fatia da string original, use &str com um lifetime adequado.
Um erro comum é usar String como parâmetro em funções que não precisam de propriedade, causando alocações desnecessárias quando o chamador tem apenas um &str e precisa convertê-lo para String.
Considere também que String pode ser convertida em &str com &string (via deref), mas a conversão inversa (str para String) sempre aloca.
Cow
O tipo Cow (Copy-on-Write) é um enum que pode representar tanto um valor emprestado (Borrowed) quanto um valor possuído (Owned). Ele é útil em situações onde queremos evitar cópias quando possível, mas permitimos que o valor seja possuído se necessário.
A assinatura é Cow<'a, B> onde B é um tipo que implementa ToOwned (como str, Path, [T]). Para strings, usamos Cow<'a, str>.
Um uso comum é em funções que recebem uma string e precisam modificá-la apenas em alguns casos. Em vez de sempre clonar, podemos usar Cow para armazenar uma referência ou uma cópia, dependendo da necessidade.
Exemplo:
use std::borrow::Cow;
fn normalize(input: &str) -> Cow {
if input.contains('é') {
// Se contém acento, precisamos modificar, então possuímos uma nova String
Cow::Owned(input.replace('é', "e"))
} else {
// Caso contrário, não precisamos modificar, usamos a referência original
Cow::Borrowed(input)
}
}
fn main() {
let s1 = String::from("café");
let n1 = normalize(&s1);
println!("{}", n1); // exibe "cafe"
let s2 = "hello";
let n2 = normalize(s2);
println!("{}", n2); // exibe "hello", sem alocação
}
O Cow permite que o chamador decida se quer evitar alocação quando a transformação não é necessária. Ele também é útil em APIs que retornam strings que podem ser literais ou computadas.
Para usar Cow, você precisa importar std::borrow::Cow. Note que Cow implementa Deref, então você pode chamar métodos de str diretamente.
Boas práticas e observações finais
Além das técnicas acima, considere:
- Use iteradores em vez de loops com índices sempre que possível; eles são otimizados e evitam verificações de limites.
- Evite usar
format!para concatenar strings em loops; usepush_strouwrite!em um buffer. - Prefira tipos como
u32em vez deusizequando o valor for pequeno, mas em coleções isso não importa. - Use o perfilador (como
perfoucriterion) para identificar gargalos reais antes de otimizar.
Lembre-se de que otimização prematura pode prejudicar a clareza. Sempre meça o desempenho para garantir que as mudanças realmente trazem benefícios.
Referências
- The Rust Book - Understanding Ownership
- Rust Standard Library - Cow
- Rust Standard Library - String
- Rust Standard Library - str
- The Rust Book - Smart Pointers
- Rust by Example - Borrowing
- Rust Reference - Expressions
Exercícios
- Explique a diferença entre
Stringe&stre dê um exemplo de quando usar cada um como parâmetro de função. - Escreva uma função que recebe um slice de strings e retorna a concatenação de todas elas, mas sem alocar uma nova String por iteração. Dica: use um buffer reutilizável.
- Considere o seguinte código. Quantas alocações ocorrem? Explique e sugira uma otimização.
fn main() { let mut s = String::from("foo"); for _ in 0..10 { let t = s.clone() + "bar"; println!("{}", t); } } - Usando
Cow, escreva uma função que retorna uma versão da string de entrada sem espaços no início e no final, mas sem alocar se a string já estiver sem espaços. - Dado o seguinte struct e função, identifique a ineficiência e proponha uma correção:
#[derive(Clone)] struct Data { content: String, } fn process(data: Data) { println!("{}", data.content); } fn main() { let d = Data { content: String::from("important") }; process(d.clone()); // clona desnecessariamente println!("{}", d.content); // ainda usa d }
String é um tipo possuído que aloca memória no heap para armazenar os dados. &str é uma referência para uma sequência de bytes imutável que pode apontar para uma string literal ou para um pedaço de uma String. Use &str quando a função só precisa ler a string, pois isso evita alocação. Use String quando a função precisa possuir a string (por exemplo, para armazená-la em uma estrutura) ou quando precisa modificar a string de forma que o buffer cresça. Exemplo: fn print_len(s: &str) vs fn store(s: String).
fn concat_strings(strings: &[&str]) -> String {
let mut result = String::new();
for s in strings {
result.push_str(s);
}
result
}
fn main() {
let v = ["Hello ", "world", "!"];
let s = concat_strings(&v);
println!("{}", s);
}
s a cada iteração, resultando em 10 alocações para os clones, mais uma alocação para cada concatenação (que pode alocar um novo buffer). O total é pelo menos 20 alocações. Podemos otimizar movendo s para dentro do loop (se não precisarmos mais dele) ou usando um buffer reutilizável: let mut s = String::from("foo"); let mut buffer = String::new(); for _ in 0..10 { buffer.clear(); buffer.push_str(&s); buffer.push_str("bar"); println!("{}", buffer); } Assim, apenas uma alocação inicial para s e uma para o buffer (e possíveis realocações se o buffer crescer).
use std::borrow::Cow;
fn trim_str(s: &str) -> Cow {
let trimmed = s.trim();
if trimmed.len() == s.len() {
Cow::Borrowed(s)
} else {
Cow::Owned(trimmed.to_string())
}
}
fn main() {
let s1 = " hello ";
let t1 = trim_str(s1);
println!("{}", t1); // aloca
let s2 = "world";
let t2 = trim_str(s2);
println!("{}", t2); // não aloca
}
process recebe Data por valor, o que move o valor, mas o chamador clona antes de chamar para poder usar d depois. Melhor: altere a função para aceitar uma referência: fn process(data: &Data) e chame process(&d). Assim não há clone nem move.