Rust: uma alternativa para o C

Rust: uma alternativa para o C

Na vasta gama de linguagens de programação existentes, existem pelo menos dois grupos: de baixo nível e de alto nível. As primeiras são eficientes e dão mais controle ao programador, embora sacrifiquem a facilidade de uso (por exemplo, C e C++); os últimos, por outro lado, tendem a ter uma sintaxe amigável e uma curva de aprendizado rápida, mas seu desempenho e segurança são reduzidos (Python). A linguagem predominante do primeiro grupo é C. Porém, um novo concorrente surgiu recentemente: Rust.

Este texto começa com uma introdução à programação de sistemas para entender do que se trata e qual a sua importância. Em seguida, conheceremos Rust, uma linguagem de programação moderna que oferece uma alternativa real ao C. Posteriormente, apresentaremos vários exemplos de código que tornam aparentes as vantagens do Rust sobre o C, embora com um custo: por ser uma linguagem voltada para a segurança, requer o programador para tornar explícitos muitos problemas que têm a ver com o gerenciamento de memória. Terminaremos com algumas conclusões sobre o futuro do Rust.

Programação de sistemas

Falar sobre linguagens de baixo e alto nível não é o mais preciso. Pode ser considerado desatualizado. Seria melhor dizer idiomas tipados estaticamente e tipados dinamicamente, se houver. Uma linguagem de programação estaticamente tipada, como C ou C++ ou a que vamos apresentar neste artigo: Rust, tende a ser uma melhor alternativa para a programação de sistemas (nomeadamente, desenvolvimento de sistemas operativos, kernel, software de microcontroladores, compiladores, etc.) . etc. Resumindo: um software que permite suportar outro software) do que um interpretado, pelas seguintes razões:

1) É mais eficiente: o código pode ser executado em menos tempo. Uma vez que uma variável define seu tipo, ela não muda em tempo de execução, permitindo assim uma otimização adicional – antes da execução – pelo compilador.

2) Os executáveis ​​consomem menos recursos (espaço em disco e memória). Fundamental em hardware que não possui muitos recursos (por exemplo, Raspberry Pi).

Da mesma forma, uma linguagem como Python não é uma opção séria para a programação do sistema. Isto, em particular, porque o seu sistema de tipos é dinâmico (exemplo: uma variável pode passar de um valor numérico para uma lista de elementos [ou vice-versa] em tempo de execução) o que a torna sujeita a erros que, se o programador não tiver cuidado, pode levar muito tempo para detectar (para não mencionar prejudicial ao desempenho). E ninguém que constrói um software tão importante, seja um driver ou um sistema operacional, quer isso em suas partes críticas.

Nota: uma linguagem de programação não é sua implementação. Ou seja, não é correto dizer "linguagem compilada" ou "linguagem interpretada", pois ambas as questões têm a ver com sua implementação e não com suas próprias características. Por outro lado, dizer que uma linguagem tem tipagem estática ou dinâmica é correto. Exemplo: pypy é um compilador JIT (Just-In-Time) para Python; para C++ existe o interpretador Cling. Lembre-se de que, se uma linguagem é tipada estaticamente, sua implementação mais popular quase certamente será um compilador, para aplicar mais otimizações em tempo de compilação (o inverso também é verdadeiro para linguagens tipadas dinamicamente).

Por algumas décadas, o mundo da programação de baixo nível foi dominado, primeiro por C e depois por C++. Entre os sistemas desta classe –escritos em C– encontramos o kernel do Linux, o gerenciador de versões Git, o sistema operacional UNIX ou o software a bordo dos carros autônomos da Tesla. Todos funcionam em outro software, a título de suporte.

Um pouco de história: C surgiu nos Laboratórios Bell por Dennis Ritchie (cientista da computação) no início dos anos 1970. Foi a evolução da linguagem B (projetada alguns anos antes pelo próprio Ritchie e Ken Thompson) e foi criada para desenvolver UNIX (dadas as limitações de escrever diretamente em Assembly e B - que tinha, por exemplo, apenas um tipo de dado - ).

Se olharmos para os projetos de sucesso que foram escritos em C, por que os concorrentes ainda estão surgindo hoje?

Introdução à Rust

Rust é uma linguagem de programação com um sistema de tipo estático. Sua implementação principal é um compilador –até agora semelhante ao C–. No entanto, ele contém várias melhorias sobre ele.

Abaixo, descrevo aqueles que considero relevantes (ver Figura 1).

Um pouco de história: Enquanto engenheiro da Mozilla (na área de Pesquisa) em 2006, Graydon Hoare iniciou o projeto interno que daria origem ao Rust. A primeira versão do compilador foi escrita em OCaml. Então, por problemas de desempenho, eles mudaram para LLVM e, em 2011, nasceu a primeira versão pública, embora ainda não fosse estável. Não seria até 2014 quando a primeira versão (1.0) seria apresentada. Hoare disse que dois motivos o levaram a criar o Rust: maior segurança e tratamento mais amigável da concorrência, detalhes que em C e C++ não costumam ser simples. Ele acrescenta: "O público-alvo (para Rust) são os programadores C++ frustrados... que precisam escrever código seguro sem tanta dor".

Observação: você pode ver as instruções de instalação do Rust – dependendo do seu sistema operacional – neste link. A versão do compilador Rust usada neste artigo é 1.65.).

Figura 1. Quatro vantagens que geralmente são atribuídas ao Rust: 1) Maior segurança no gerenciamento de memória. 2) Suporte para WebAssembly. 3) Boa performance. 4) Melhor gerenciador de dependências (Cargo).

Usando Cargo

Cargo é o projeto Rust e o gerenciador de pacotes, incluído na instalação do Rust. Através dele podemos criar projetos e adicionar ou remover dependências dos projetos que criamos. Assim, cada projeto que iniciarmos poderá ter diferentes versões de uma mesma biblioteca sem cair em conflitos.

No terminal podemos criar um projeto como este:

> cargo new primer-proyecto

Created binary (application) `primer-proyecto` package

Isso geraria um diretório com o nome do projeto, um arquivo Cargo.toml e uma pasta src dentro.

> cd primer-proyecto

> ls 

Cargo.toml src

O arquivo Cargo.toml contém todas as dependências que nosso projeto terá. Assim, se alguém precisar que reproduzamos seu projeto em outro computador, bastaria que ele nos fornecesse seu arquivo Cargo.toml e depois fizesse um load build para baixar todas as dependências.

Dentro da pasta src encontramos um arquivo "start" que, assim como o C, se chama main:

> ls src/

main.rs

Como você deve ter percebido, a extensão de um arquivo Rust é rs, por fim, para rodar o arquivo main.rs, pode ser feito com o comando: cargo run.

> cargo run

Hello, world!

Se fizermos um ls novamente veremos que, após executar o run load, uma pasta de destino foi criada e dentro de outra pasta com o modo debug. Isso conterá o binário (executável) com o mesmo nome de projeto.

> cd target/debug

> ./primer-proyecto

Hello, world!

O último exemplo deste artigo mostrará como adicionar uma dependência no Cargo.

Vantagens do Rust sobre o C

O mínimo que se espera de uma linguagem moderna que quer se comparar com outra do passado é, sem dúvida, que ela possa melhorar os aspectos que causam frustrações e dores de cabeça aos programadores da linguagem anterior.


Exemplo 1. Verificação dos índices de um array

O gerenciamento de memória em C costuma ser um dos pontos delicados e causa vários erros nos programas se não for usado com cuidado. Um problema clássico em C é criar um array e tentar acessar um índice que não existe.


Caso correto:

#include <stdio.h>

 

int main(int argc, char const *argv[])

{

   const int numeros_primos[] = {2, 3, 5, 7, 13};

   const int SIZE = sizeof(numeros_primos) / sizeof(int);

   for (int i = 0; i < SIZE; ++i){

       printf("%d \n", numeros_primos[i]);

   }   

   return 0;

}

Caso incorreto: Ocorre quando tentamos acessar um índice que não existe (por exemplo, alterando o operador “<” para “<=”). O resultado é o seguinte:

> gcc main.c -o ejemplo1

> ./ejemplo1

13 

32764

O último número está errado porque tenta acessar o índice 5, mas em C (e Rust), os índices começam em 0. Então o array números_primos tem até o índice 4. Algo ainda pior seria isso:

for (int i = 0; i <= SIZE + 10; ++i){

Compilamos e executamos:

> gcc main.c -o ejemplo1 && ./ejemplo1

13 

32765 

-1245470208 

1540912055 

-1353468080 

22026 

-1242309497 

32538 

1446578568 

32765 

Os valores corretos são até o índice válido: 4 (valor 13). Então o resto são valores retirados da pilha que não fazem sentido, causando um comportamento undefined.

Isso acontece porque, tanto em C quanto em C++, não há validação de faixas de índice em arrays. Nesse ponto, o compilador coloca o ônus do cuidado no programador.

Em Rust, o oposto é verdadeiro: o compilador não apenas verifica o intervalo dos índices em uma matriz, mas também fornece mais informações sobre ela. Aqui está o código equivalente (bem-sucedido) em Rust:

fn main() {

    let numeros_primos: [i32; 5] = [2, 3, 5, 7, 13];

    let size: usize = numeros_primeros.len();

    for i in 0..size {

        println!("{}", numeros_primeros[i]);

    }

}

A explicação:

- A palavra-chave let indica que esta variável será imutável, ou seja, uma vez atribuído o valor não pode ser modificado.

- O tamanho de um array (neste caso 5), pode ser obtido com a função len. Mais interessante ainda é que, se ao invés de adicionar um tipo usize (número positivo que representa o tamanho do array) mudarmos para size: i32, o compilador retornará um erro, pois a função len retorna apenas um usize. Isso indica mais restrições no Rust para cometer menos falhas de tempo de execução.

Agora, o caso interessante é o que acontece se tentarmos acessar um índice inválido ou inexistente. Primeiro alterando a linha for para: for i in 0..size+10.

> cargo run

3

5

7

13

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src/main.rs:5:24

note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

O compilador nos diz que o tamanho do array prime_numbers é 5 e está tentando acessar o índice 5 e, como começa em 0, não nos permite compilar.

Muito melhor, certo?

Exemplo 2. Gerenciamento de estruturas e passagem por referência

Algo muito importante em C é o gerenciamento de estruturas (struct) pois é normal passá-las como argumentos para uma função. É aqui que podem surgir problemas.

Suponha que precisamos armazenar as informações dos livros: título, autor e um identificador. Em C, usando uma estrutura, ficaria assim:

#include <stdio.h>

#include <string.h>


struct Libros {

   char titulo[50];

   char autor[50];

   int identificador;

};


void mostrarLibro(struct Libros libro) {


   printf("Libro título: %s \n", libro.titulo);

   printf("Libro autor: %s \n", libro.autor);

   printf("Libro identificador: %d \n", libro.identificador);

}


int main(int argc, char const *argv[])

{


    struct Libros PrimerLibro;


    strcpy(PrimerLibro.titulo, "Don Quijote de la Mancha");

    strcpy(PrimerLibro.autor, "Miguel de Cervantes"); 

    PrimerLibro.identificador = 1;


    mostrarLibro(PrimerLibro);

    return 0;

}

Compilamos e executamos:

> gcc main.c -o ejemplo1 && ./ejemplo1

Libro título: Don Quijote de la Mancha 

Libro autor: Miguel de Cervantes 

Libro identificador: 1

A função strcpy copia um valor (segundo argumento) para o ponteiro do primeiro (atributo da estrutura Book). Isso pode parecer confuso, mas é normal ter esses tipos de nomes de função em C.

Agora vem o interessante: mude o valor do título já atribuído. Em C, isso pode ser feito usando a referência de ponteiro de uma estrutura. Primeiro vamos adicionar a função:

void modificarTitulo(struct Libros* libro, char titulo[50]){

    strcpy(libro->titulo, titulo);

}

Vemos que agora o primeiro argumento é o ponteiro para a estrutura (símbolo “*”). Então, precisamos passar a referência da estrutura:

modificarTitulo(&PrimerLibro, "Don Quijote de la Mancha - Edición conmemorativa");

A versão equivalente em Rust apresenta várias questões interessantes que tornam esta linguagem mais segura que C, embora com um preço: a sintaxe em alguns casos pode se tornar complexa de entender.

struct Libros {

    titulo: String,

    autor: String,

    identificador: i32,

}


fn mostrarLibro(libro: Libros) {

    println!("Libro título: {}", libro.titulo);

    println!("Libro autor: {}", libro.autor);

    println!("Libro identificador: {}", libro.identificador);

}


fn modificarTitulo(libro: &mut Libros, titulo: String){

    libro.titulo = titulo;

}


fn main() {


    let mut primerLibro: Libros = Libros { 

                                titulo: String::from("Don Quijote de la Mancha"), 

                                autor: String::from("Miguel de Cervantes"), 

                                identificador: 1 };

    

    modificarTitulo(&mut primerLibro, String::from("Don Quijote de la Mancha - Edición conmemorativa"));

    mostrarLibro(primer_libro);


}

A primeira coisa diferente que pode ser notada é a mudança do tipo de dado title e author para String, ou seja, em C, o tipo String não existe. Rust lida com sequências de caracteres com dois tipos: String e str. A primeira é uma sequência de caracteres dinâmicos que podem ser modificados. Em vez disso, str é imutável. Para criar uma String, você deve usar a função String::from.

Outra questão interessante é a palavra-chave mut. Esta é a chave para saber quando um valor pode "mutar", ou seja, depois de atribuir um valor, ele pode ser reatribuído a outro.

Para a função modifyTitle, logo antes do tipo de dados (Livros), deve ser adicionado &mut. Que significa? Esta referência (à estrutura) permite modificar seus atributos. Isso em C pode ser feito simplesmente com o e comercial "&" e um ponteiro. Em Rust, por outro lado, você deve ser explícito, embora para nós seja melhor porque apenas vendo mut você pode saber que uma variável mudaria seu valor.

Isso pode parecer interessante e dar clareza sobre C. Agora, se for executado, o título da estrutura firstBook muda.

> cargo run

Libro título: Don Quijote de la Mancha - Edición conmemorativa

Libro autor: Miguel de Cervantes

Libro id: 1

Tudo bem. Mas se adicionarmos esta linha após o mostrarlivro… :

println!("{}", primerLibro.titulo);

…encontramos algo curioso:

> cargo run

error[E0382]: borrow of moved value: `primerLibro`

Em outras palavras, o valor da variável primeiroLibro foi movido para um novo proprietário (a função showBook). Isso é entendido se virmos a assinatura da função: fn showBook(book: Books). O primeiro argumento, a variável book, não recebe uma referência, alterando o valor. Resolve-se passando a variável firstBook (Levick) por referência, adicionando o símbolo “&” antes do tipo (não é necessário adicionar o mut, pois nenhum atributo da estrutura vai ser modificado).

fn mostrarLibro(libro: &Libros) {

    println!("Libro título: {}", libro.titulo);

    println!("Libro autor: {}", libro.autor);

    println!("Libro identificador: {}", libro.identificador);

}

mostrarLibro(&primerLibro);

O design do Rust foi planejado para ser muito seguro com o gerenciamento de memória. Evite erros na alocação de recursos de uma variável em tempo de execução. Portanto, é mais rigoroso na fase de compilação estática, embora possa se tornar bastante complicado se você não estiver claro sobre certos princípios.


Exemplo 3. Propriedade e movimentação de variáveis


Rust melhora o gerenciamento de memória se compararmos com C (como vimos nos exemplos anteriores) e até mesmo o mesmo compilador nos dá mais informações. No entanto, esse gerenciamento de memória pode se tornar –em alguns casos– complicado. Veja este exemplo:

#[derive(Debug)]

struct Persona {

    identificador: i32

}


fn main(){


    let persona = Persona { identificador: 659440 };

    let otra_persona = persona;


    println!("{:?}", otra_persona);

    println!("{:?}", persona);

}

Esse código cria uma estrutura Person com o identificador 659440. Ele a atribui à variável person (na Figura 2 você pode ver como ficaria o estado da pilha). Em seguida, ele cria uma nova variável, other_person, que atribui (move) o valor de person a ela e, por fim, exibe as variáveis ​​other_person e person na tela.

Figura 2. A pilha ao criar a variável pessoa.
Nota: Toda vez que uma variável local é declarada em qualquer linguagem de programação, ela está na pilha, quando a referida variável é dinâmica (precisa alocar memória), ou seja, pode mudar em tempo de execução, está no heap (exemplo: estruturas e arranjos). Considere a declaração: "let number: i32 = 100;", que não será encontrada no heap, pois os valores estão contidos na mesma pilha.

A macro acima: “#[derive(Debug)]” permite exibir uma struct usando um print na tela. Caso contrário, você teria que implementar uma maneira manual de exibir cada atributo Person.

Porém, ao tentar compilar ele retorna o seguinte erro:

error[E0382]: borrow of moved value: `persona`

  --> src/bin/ejemplo3.rs:12:20

   |

8  |     let persona = Persona { identificador: 659440 };

   |         ------- move occurs because `persona` has type `Persona`, which does not implement the `Copy` trait

9  |     let otra_persona = persona;

   |                       ------- value moved here

...

12 |     print!("{:?}", persona);

   |                    ^^^^^^^ value borrowed here after move

O que diz é que ao tentar exibir os valores da variável pessoa (último println) ela foi movida para outro local (mudança de proprietário). E, claro, foi movido quando a variável other_person foi atribuída (consulte a Figura 3 para entender o que acontece na pilha). Para evitar esses casos, é conveniente ativar a cópia da estrutura (Klabnik & Nichols; Rust Ownership, Move and Borrow - Parte 1).

Figura 3. Movimento da variável pessoa para outra_pessoa. Os atributos não podem mais ser acessados ​​a partir da variável pessoa porque ela mudou de propriedade.

A primeira coisa é adicionar Copy e Clone na macro Person:

#[derive(Debug, Copy, Clone)]

Então o problema está resolvido:

fn main(){


    let persona = Persona { identificador: 659440 };

    let otra_persona = persona;


    print!("{:?} \n", otra_persona);

    print!("{:?} \n", persona);

}

Resultado:

Persona { identificador: 659440 } 

Persona { identificador: 659440 } 

Para copiar uma estrutura com segurança, é necessário adicionar o Copy e o Clone.

A diferença entre os dois é que existem tipos de dados que podem ser copiados implicitamente (como no exemplo anterior), portanto não é necessário chamar a função clone:

let otra_persona = persona.clone();

No entanto, se a estrutura Person tiver um atributo que não suporte a cópia implícita (por exemplo, String), será necessário chamar a função clone explicitamente (implementá-la manualmente).

Um exemplo: implementar manualmente a função clone. Então, a primeira coisa seria remover o Copy and Clone da macro (como iremos implementar):

#[derive(Debug)]

struct Persona {

   identificador: i32

}


impl Clone for Persona {

    fn clone(&self) -> Persona {

        Self {

            identificador: self.identificador.clone() 

        }

    }

}

let otra_persona = persona.clone();

Entender como funciona a memória no Rust talvez seja o maior desafio da linguagem. Mas, embora possa parecer difícil às vezes, no final apresenta uma maneira mais eficaz e segura de escrever códigos de sistemas. Cabe a você saber se o esforço vale a pena.


Exemplo 4. Adicionando dependências com Cargo

Adicionar novas bibliotecas a um projeto Rust é simples. Outro exemplo: adicionar a biblioteca rand para gerar números aleatórios. Dentro do arquivo Cargo.toml, e em [dependencies], adicione o seguinte:

[dependencies]

rand = "0.8.4"

Agora crie um arquivo com este exemplo:

use rand::prelude::*;


fn main(){

    let numero_aleatorio: u32 = random();

    println!("{}", numero_aleatorio);

}

Em seguida, execute o comando:

> cargo build

Ele começará a baixar a biblioteca rand com base na versão atribuída. Tudo o que resta é executar a corrida de carga.

> cargo run

2412007460

Conclusão

O Rust é seguro e eficiente. Neste artigo mostramos quais são as vantagens do Rust em relação ao C, transformando-o em uma boa alternativa para sistemas onde se deseja evitar falhas no gerenciamento de memória por ter um sistema mais restritivo no que diz respeito ao gerenciamento do tempo de vida das variáveis. Isso faz com que muitos problemas sejam detectados na fase de compilação e não na execução (como costuma acontecer em C e C++).

No entanto, Rust não é fácil de aprender em comparação com Python (ou outra linguagem de tipagem dinâmica). Sua sintaxe precisa ser explícita para evitar erros. Embora tenha vantagens sobre C, é uma linguagem que leva tempo para entender, especialmente se você nunca foi exposto ao gerenciamento manual de memória antes.

O futuro de Rust parece brilhante. Em dezembro de 2022 -quando este artigo foi escrito-, Rust aparece na posição 20 do TOEBI (o ranking mais popular para medir o impacto de uma linguagem de programação na indústria).

No entanto, é preciso ser cauteloso. A quantidade de código, ou seja, sistemas escritos em C e C++ é enorme e, mesmo que uma linguagem apresente características superiores, a adoção de uma ferramenta depende mais de outros fatores: uma comunidade robusta e que empresas importantes passem a utilizá-la (validação) .

Recomendações de leituras

Recomendo dar uma olhada –e se você ler, melhor ainda– o livro The Rust Programming Language (Klabnik & Nichols, 2018) para obter mais conhecimento da linguagem.

Código

Todo o código apresentado neste artigo está localizado no seguinte repositório.

Referências

  • Klabnik, S., & Nichols, C. (2018). The Rust Programming Language. https://lise-henry.github.io/books/trpl2.pdf
  • Levick, R. ·. (2018). Rust: Pass-By-Value or Pass-By-Reference? https://blog.ryanlevick.com/rust-pass-value-or-reference/
  • Rust Ownership, Move and Borrow - Part 1. (2021). Rust Community. https://www.openmymind.net/Rust-Ownership-Move-and-Borrow-part-1/