Ubuntu TechHive
rust-and-data-processing-with-polars.md
Rust and Data Processing with Polars
article.detalhe

Rust and Data Processing with Polars

reading.progresso 13 min de leitura

Uma introdução rápida aos fundamentos de Rust juntamente com processamento de dados usando Polars

Processamento de Dados em Rust com Polars

O que torna o Rust diferente

  • Compilado e rápido — compila para código de máquina nativo, sem runtime/GC
  • Seguro quanto à memória — o compilador evita classes inteiras de bugs (erros de ponteiro nulo, condições de corrida) antes que seu programa seja executado
  • Fortemente e estaticamente tipado — cada valor tem um tipo conhecido em tempo de compilação; o compilador detecta incompatibilidades precocemente

Variáveis e Mutabilidade

Variáveis são imutáveis por padrão. Você opta pela mutabilidade com mut.

let x = 5;          // imutável -- não pode ser reatribuído
let mut y = 10;     // mutável
y = 20;             // OK por causa do `mut`
// x = 6;           // ERRO DE COMPILAÇÃO: não é possível atribuir duas vezes a `x`

const MAX: u32 = 100_000;  // constante: sempre imutável, tipo obrigatório

Este padrão inverte a expectativa usual: você declara antecipadamente o que pode mudar, o que torna o código mais fácil de raciocinar.

Tipos de Dados Básicos

Tipos escalares

  • Inteiros: i32, i64, u32, u64 … (i = com sinal, u = sem sinal; número = bits). i32 é o padrão.
  • Ponto flutuante: f64 (padrão), f32
  • Booleano: bool -> true / false
  • Caractere: char -> um único caractere Unicode, entre aspas simples
let count: i64 = 42;
let price: f64 = 19.99;
let is_ready: bool = true;
let letter: char = 'A';

Tipos compostos

  • Tupla: grupo de tamanho fixo com tipos mistos
  • Array: tamanho fixo, todos do mesmo tipo
let person: (i32, f64, char) = (30, 5.9, 'M');
let height = person.1;          // acesso por índice -> 5.9

let nums: [i32; 3] = [1, 2, 3]; // array de 3 i32s
let first = nums[0];            // -> 1

Strings: Dois Tipos

  • &str — uma "fatia de string", geralmente um literal de string fixo/emprestado
  • String — uma string proprietária e expansível que você pode modificar
let literal: &str = "hello";          // texto fixo
let mut owned: String = String::from("hello");
owned.push_str(", world");            // pode crescer porque é proprietária

Funções

  • Declaradas com fn
  • Tipos de parâmetros são obrigatórios; o tipo de retorno vem após ->
  • A última expressão (sem ponto e vírgula) é o valor de retorno
fn add(a: i32, b: i32) -> i32 {
    a + b          // sem ponto e vírgula = este é o valor de retorno
}

fn greet(name: &str) {   // sem `->` significa que não retorna nada
    println!("Hello, {name}!");
}

fn main() {
    let sum = add(2, 3);     // todo programa começa em main()
    println!("Sum: {sum}");
    greet("Aziz");
}

Nota: println! é uma macro (o ! entrega isso), não uma função.

Fluxo de Controle

if / else (é uma expressão!)

let n = 7;
if n % 2 == 0 {
    println!("even");
} else {
    println!("odd");
}

// Como `if` retorna um valor, você pode atribuir com ele:
let label = if n > 5 { "big" } else { "small" };

Loops

// loop: executa para sempre até você usar `break`
let mut i = 0;
loop {
    if i >= 3 { break; }
    i += 1;
}

// while
let mut c = 3;
while c > 0 {
    println!("{c}");
    c -= 1;
}

// for: o mais comum -- itera sobre um intervalo ou coleção
for k in 0..3 {            // 0, 1, 2  (fim exclusivo)
    println!("k = {k}");
}

Ownership (Propriedade): A Grande Ideia

O recurso principal do Rust. Três regras:

  1. Cada valor tem um proprietário
  2. Existe apenas um proprietário por vez
  3. Quando o proprietário sai de escopo, o valor é limpo
let s1 = String::from("hi");
let s2 = s1;              // a propriedade MOVE para s2
// println!("{s1}");      // ERRO: s1 não é mais válido

// Para permitir que outra função use um valor SEM assumir a propriedade,
// você o *empresta* com & (uma referência):
fn length(s: &String) -> usize {
    s.len()              // lê s, não é proprietário
}
let word = String::from("rust");
let n = length(&word);   // empresta; `word` ainda utilizável depois

É isso que permite ao Rust garantir a segurança de memória sem um coletor de lixo. É a parte que leva mais tempo para se acostumar.

Structs: Tipos de Dados Personalizados

struct Order  {
    id: i64,
    amount: f64,
    shipped: bool,
}

let o = Order { id: 1, amount: 42.5, shipped: true };
println!("Order {} costs {}", o.id, o.amount);

Enums e Pattern Matching

Enums permitem que um valor seja uma de várias variantes; match lida com cada uma.

enum Status {
    Pending,
    Shipped,
    Cancelled,
}

let s = Status::Shipped;
match s {
    Status::Pending   => println!("waiting"),
    Status::Shipped   => println!("on the way"),
    Status::Cancelled => println!("nope"),
}

match deve ser exaustivo — lide com todos os casos ou o código não compilará. Outra forma pela qual o compilador impede que você esqueça coisas.

Option e Result: Sem Nulos, Sem Erros Silenciosos

Rust não tem null. Em vez disso:

  • Option — um valor que é Some(x) ou None
  • Result — ou Ok(x) ou Err(e) (esta é a base de todo o tratamento de erros nos exemplos do Polars)
fn divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

match divide(10.0, 2.0) {
    Some(result) => println!("Got {result}"),
    None         => println!("Can't divide by zero"),
}

O Operador ?: Atalho para Tratamento de Erros

Em um Result, ? significa "me dê o valor, ou retorne o erro desta função."

use std::num::ParseIntError;

fn parse_and_double(text: &str) -> Result<i32, ParseIntError> {
    let n = text.parse::<i32>()?;  // se o parse falhar, retorna o Err
    Ok(n * 2)                      // caso contrário, continua
}

É por isso que read_orders(...)? é lido de forma limpa: o ? propaga silenciosamente qualquer falha em vez de forçar um grande bloco de match.

Coleções Comuns

  • Vec — lista expansível (como uma lista Python)
  • HashMap — mapa chave/valor (como um dicionário Python)
let mut v: Vec<i32> = Vec::new();
v.push(1);
v.push(2);
for item in &v { println!("{item}"); }

use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert("alice", 10);
scores.insert("bob", 7);

Cargo: Ferramenta de Build e Gerenciador de Pacotes do Rust

O essencial:

cargo new my_project   # cria um novo projeto
cargo build            # compila
cargo run              # compila + executa
cargo test             # executa testes
cargo add polars       # adiciona uma dependência ao Cargo.toml

Dependências (chamadas de "crates") são declaradas em Cargo.toml e baixadas do crates.io.

O que observar:

  • Ownership / borrowing — a dança do & e mut. Espere lutar com isso no início; torna-se natural com a prática.
  • Dois tipos de string (String vs &str) — converta com .to_string() ou String::from(...).
  • Imutável por padrão — esquecer mut é o erro inicial mais comum.
  • O compilador é seu amigo — as mensagens de erro do Rust são excepcionalmente boas. Leia-as; elas geralmente indicam a correção exata.
  • Macros vs funçõesprintln!, vec!, df! terminam em ! e se comportam de forma um pouco diferente das funções normais.

O que é o Polars

  • Uma biblioteca de DataFrame para trabalhar com dados tabulares (linhas e colunas) — pense em planilhas ou tabelas de banco de dados, em código
  • Escrito em Rust, construído sobre o Apache Arrow (um formato de memória colunar)
  • Colunar: armazena dados por coluna, não por linha — por isso operações de coluna e análises são rápidas
  • Multithreaded por padrão: usa todos os seus núcleos de CPU sem você pedir
  • Disponível diretamente no Rust e no Python via bindings

Os Dois Tipos Principais

  • Series — uma única coluna de dados, todos do mesmo tipo
  • DataFrame — uma coleção de Series; a tabela em si
use polars::prelude::*;

// Uma Series é uma coluna nomeada.
let s = Series::new("amount".into(), &[42.5, 17.0, 9.99]);

// Um DataFrame é construído a partir de colunas. A macro df! é a maneira fácil.
let df = df!(
    "order_id" => &[1, 2, 3],
    "amount"   => &[42.5, 17.0, 9.99],
)?;
println!("{df}");

Note que df! termina em ! — é uma macro, como println! e vec!.

Tudo Retorna um Result

Quase toda operação do Polars pode falhar (tipos incorretos, colunas ausentes, arquivos ruins), então ela retorna PolarsResult. É por isso que você vê ? em toda parte no workshop — ele propaga erros em vez de deixá-los passar silenciosamente.

fn build() -> PolarsResult<DataFrame> {
    let df = df!("a" => &[1, 2, 3])?;   // ? desempacota ou retorna o erro
    Ok(df)
}

Isso se conecta diretamente ao Result e ? do Rust: dados ruins tornam-se um erro que você deve tratar, não um NaN silencioso.

Lendo e Escrevendo Dados

Os quatro formatos da agenda:

// CSV de entrada
let df = CsvReadOptions::default()
    .with_has_header(true)
    .try_into_reader_with_file_path(Some("orders.csv".into()))?
    .finish()?;

// Parquet de saída
let mut file = std::fs::File::create("orders.parquet")?;
ParquetWriter::new(&mut file).finish(&mut df)?;

// Parquet de entrada
let mut f = std::fs::File::open("orders.parquet")?;
let df = ParquetReader::new(&mut f).finish()?;

Ideia chave: Parquet armazena o esquema e os tipos dentro do arquivo, então lê-lo de volta não requer adivinhação. CSV é texto e deve ser inferido ou receber um esquema explícito.

Esquemas: O Contrato

Um Schema declara o nome e o tipo de cada coluna antecipadamente. Forneça um a um leitor e dados ruins falharão ruidosamente em vez de corromper uma coluna.

let mut schema = Schema::default();
schema.with_column("order_id".into(), DataType::Int64);
schema.with_column("amount".into(), DataType::Float64);

DataType=s comuns: =Int64, Float64, String, Boolean, Date.

Selecionando e Filtrando

Você descreve operações com expressõescol(...) refere-se a uma coluna, e você encadeia transformações.

let result = df
    .clone()
    .lazy()
    .filter(col("status").eq(lit("shipped")))   // mantém linhas correspondentes
    .select([col("order_id"), col("amount")])    // escolhe colunas
    .collect()?;                                  // executa
  • col("x") — refere-se à coluna x
  • lit("shipped") — um valor literal para comparar
  • .eq, .gt, .lt — operadores de comparação em expressões

Joins: Combinando Tabelas

Combine linhas de dois DataFrames em uma chave compartilhada.

let joined = orders.join(
    &customers,
    ["customer_id"],                 // chave na tabela esquerda
    ["customer_id"],                 // chave na tabela direita
    JoinArgs::new(JoinType::Inner),  // Inner / Left / Anti / ...
    None,
)?;

Tipos de Join que vale a pena conhecer:

  • Inner — apenas linhas que correspondem em ambos
  • Left — todas as linhas da esquerda, nulos onde não há correspondência
  • Anti — linhas da esquerda sem correspondência (ótimo como verificação de qualidade de dados)

Eager vs Lazy: A Grande Distinção

  • Eager — cada operação é executada imediatamente (DataFrame). Simples, bom para dados pequenos e exploração.
  • Lazy — você constrói um plano de consulta, e nada é executado até .collect(). O Polars então otimiza todo o plano (empurrando filtros para baixo, lendo apenas as colunas necessárias).
// Lazy: scan_* e .lazy() retornam um LazyFrame -- um plano, ainda não dados.
let plan = LazyCsvReader::new(PlPath::new("orders.csv"))
    .with_has_header(true)
    .finish()?
    .filter(col("status").eq(lit("shipped")))
    .select([col("order_id"), col("amount")]);

println!("{}", plan.clone().explain(true)?);  // inspeciona o plano
let df = plan.collect()?;                       // AGORA ele executa

explain(true) imprime o plano otimizado — você pode ver o que o mecanismo decidiu fazer antes de gastar qualquer processamento.

Folha de Dicas de Operações Comuns

df.height();                 // número de linhas
df.width();                  // número de colunas
df.column("amount")?;        // obtém uma coluna (Series)
df.head(Some(5));            // primeiras 5 linhas
df.get_column_names();       // nomes das colunas
df.column("amount")?.dtype();// o tipo de dado da coluna

Por que Polars (vs pandas / Spark)

  • vs pandas — muito mais rápido, multithreaded, otimização lazy, comportamento de memória muito melhor; tipos são mais rigorosos (menos surpresas silenciosas)
  • vs Spark — sem necessidade de cluster para cargas de trabalho em uma única máquina; muitos trabalhos de "precisamos do Spark" são na verdade "o pandas estava muito lento em uma máquina"
  • Polars oferece desempenho mais correção sem a sobrecarga de sistemas distribuídos

Como se conecta aos fundamentos do Rust

  • PolarsResult e ? = Result + operador ?
  • &customers em um join = borrowing (lendo sem assumir a propriedade)
  • &mut df ao escrever Parquet = um borrow mutável
  • df!, col! macros de estilo = a sintaxe de macro !
  • Esquemas e DataType = a ideia do Rust de que "tudo tem um tipo conhecido", aplicada a colunas de tabela

Demonstração

Um CSV Deliberadamente Imperfeito

Use um arquivo com uma coluna de tipo misto, um nulo e uma linha ruim:

order_id,customer_id,amount,status
1,100,42.50,shipped
2,101,,pending
3,102,17.00,shipped
4,bad_id,9.99,shipped

A linha 4 tem um customer_id não numérico. Em um pipeline flexível, isso se torna um NaN silencioso ou uma coluna de objeto. Queremos que seja ruidoso.

Leitura Eager Com Esquema Inferido (o caminho fácil e perigoso)

use polars::prelude::*;

fn main() -> PolarsResult<()> {
    let df = CsvReadOptions::default()
        .with_has_header(true)
        .try_into_reader_with_file_path(Some("orders.csv".into()))?
        .finish()?;

    println!("{df}");
    Ok(())
}

Isso funciona — mas a inferência olhou para uma amostra e adivinhou os tipos. Em um arquivo diferente, ou com mais linhas, o palpite pode mudar. A inferência é conveniente e não determinística; essa combinação é o que te prejudica em produção.

Esquema Explícito (a lição de confiabilidade)

Pare de adivinhar. Declare o contrato:

use polars::prelude::*;
use std::sync::Arc;

fn read_orders(path: &str) -> PolarsResult<DataFrame> {
    let mut schema = Schema::default();
    schema.with_column("order_id".into(), DataType::Int64);
    schema.with_column("customer_id".into(), DataType::Int64);
    schema.with_column("amount".into(), DataType::Float64);
    schema.with_column("status".into(), DataType::String);

    CsvReadOptions::default()
        .with_has_header(true)
        .with_schema(Some(Arc::new(schema)))
        .try_into_reader_with_file_path(Some(path.into()))?
        .finish()
}

Agora customer_id é declarado como Int64. A linha ruim (bad_id) não pode mais passar como texto — o Polars retorna um Err, não uma coluna silenciosamente corrompida. A falha ocorre no momento da leitura, com uma causa clara, em vez de três transformações depois.

Este é o ponto do Rust + Polars

  • O esquema é código — ele é versionado, revisado e testado como qualquer outro contrato
  • finish() retorna PolarsResult. Não há como ignorar uma falha de parse por acidente — o ? força você a tratá-la ou propagá-la
  • Compare com um pipeline tipado dinamicamente onde um parse ruim se torna NaN e flui downstream silenciosamente. Aqui, o sistema de tipos e o tipo de erro tornam o silêncio impossível.

Tratamento de Erros como uma Preocupação de Primeira Classe

Mostre ambos os comportamentos para que o público sinta a diferença:

fn main() {
    match read_orders("orders.csv") {
        Ok(df) => println!("Carregadas {} linhas\n{df}", df.height()),
        Err(e) => eprintln!("CSV falhou em seu contrato: {e}"),
    }
}

Em um pipeline, Err significa que o trabalho para aqui, ruidosamente, com uma mensagem — não às 3 da manhã, quarenta milhões de linhas depois.

Bloqueie com um Teste

O tema da confiabilidade tornado concreto — um teste que afirma o contrato, para que um arquivo upstream malformado falhe em CI, não em prod:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn schema_is_enforced() {
        let df = read_orders("tests/data/orders_good.csv").unwrap();
        assert_eq!(df.height(), 3);
        assert_eq!(
            df.column("amount").unwrap().dtype(),
            &DataType::Float64
        );
    }

    #[test]
    fn bad_types_are_rejected() {
        // O arquivo com `bad_id` NÃO deve carregar silenciosamente.
        assert!(read_orders("tests/data/orders_bad.csv").is_err());
    }
}

bad_types_are_rejected é toda a filosofia em um teste: afirmamos que dados ruins falham. A maioria dos pipelines nunca escreve esse teste porque, em sua stack, dados ruins não falham — eles se espalham.

Lidando com Nulos de Propósito (não por acidente)

O amount vazio na linha 2 é um nulo real. Decida o que ele significa em vez de deixar um palpite decidir:

use polars::prelude::*;

fn parse_options() -> CsvParseOptions {
    CsvParseOptions::default()
        .with_null_values(Some(NullValues::AllColumns(
            vec!["".into(), "NA".into(), "null".into()].into(),
        )))
}

Clareza operacional: nulos são uma decisão documentada no código, não um artefato do que o parser decidiu fazer.

Conclusões da Seção

  • CSV não é tipado e não é seguro por padrão — trate cada leitura como uma fronteira que deve ser validada
  • Esquemas explícitos transformam "espero que faça o parse" em "faz o parse ou gera erro" — determinismo sobre conveniência
  • PolarsResult torna ignorar falhas uma impossibilidade em tempo de compilação
  • Um teste (bad_types_are_rejected) demonstra toda a tese de confiabilidade
  • Rust + Polars importa aqui não porque é mais rápido, mas porque torna a corrupção silenciosa de dados estruturalmente difícil