Ownership e Borrowing em Rust

I am a tech enthusiast who finds solace and excitement in writing code.
With nearly 15 years of dedication to the tech field and extensive experience as a Software Engineering, I've garnered a wealth of experience, particularly in Data Engineering and Data Science.
For the past three years, I've embraced the role of Head of Data and Software at Newtail, a dynamic start-up specializing in Retail Media in Brazil.
O conceito de Ownership e Borrowing é, sem dúvidas, o principal desafio para nós desenvolvedores quando começamos a aprender Rust. Acredito que essa dificuldade existe porque as principais linguagens do mercado não possuem nada parecido com isso. Mas, uma vez que dominamos esse conceito, todo o conhecimento adicional da linguagem não será muito diferente do que já vimos em outras linguagens.
Ownership
O Ownership em RUST visa garantir um software mais seguro em termos de gerenciamento de memória. Para que isso seja possível, o RUST implementa diversas regras que são avaliadas durante a compilação do programa. Dessa forma, todo programa que compila, está livre desses problemas.
Gerenciamento de Memória
Diferente de linguagens que possuem Garbage Collector (ex.: C# e Java), Rust é mais comparável com C e C++, onde precisamos controlar a alocação e liberação da memória HEAP.
A principal vantagem de o desenvolvedor ter controle da memória é que o software usa e libera apenas a quantidade necessária de memória para funcionar. Isso ocorre sem um processo (GC) que interrompa a execução do programa de tempos em tempos para recuperar a memória ocupada por objetos que não estão mais sendo usados.
A principal desvantagem é que o controle e gerenciamento ficam totalmente nas mãos do desenvolvedor, o que acaba gerando erros com muito mais frequência do que gostaríamos.
Principais erros de gerenciamento de memória:
Double Free: Ocorre quando a memória é liberada mais de uma vez
Memory Leak: Ocorre quando a memória alocada nunca é liberada
Dangling Pointer: Ocorre quando um ponteiro mantém a referência da memória mesmo após a sua liberação
Buffer Overflow: Ocorre quando continuamos escrevendo na memória, mesmo após atingirmos o limite da memória alocada
Stack e Heap
Assim como todas as principais linguagens de mercado, Rust também possui alocação de memória em STACK e HEAP para o seu funcionamento.
Stack
A stack é uma área de memória usada para armazenar variáveis locais e informações de controle de funções, organizada em uma estrutura LIFO (Last In, First Out), essa estratégia permite uma alocação muito rápida da memória necessária.
Heap
A heap é uma área de memória usada para alocações dinâmicas, que podem variar em tamanho e vida útil. Em Rust, a alocação na heap é gerenciada pelo sistema através de smart pointers, como `Box`, `Rc`, e `Arc`.
Entendendo o Ownership
Para entender o ownership, precisamos lembrar que só pode existir um "dono" de uma variável em qualquer momento.
Existem três cenários quando usamos uma variável:
Ocorre uma cópia implícita da variável na stack.
A variável é emprestada para outro escopo (borrowing).
A variável é passada por referência (como se fosse um ponteiro).
Cópia Implícita
No exemplo abaixo, podemos ver que ao definir uma variável qualquer, o dono do valor é transferido diretamente para o "nome" da variável e na sequência transferimos (cópia) o seu valor para a função println.
fn main() {
// idade é o owner do valor 32
let idade = 32;
// o valor de idade é copiado e
// passado como valor para a função println
println!("idade={}", idade);
}
Esse comportamento só é possível, pois estamos utilizando um tipo de dado que implementa o Trait de Copy (todos os tipos de dados primitivos da linguagem possuem Copy).
Trait define um comportamento que pode ser utilizado por diferentes objetos. Ele é similar a interfaces em outras linguagens e permite especificar métodos que devem ser implementados por tipos que desejem aderir ao Trait. Traits facilitam a abstração e polimorfismo.Fazendo uma analogia do código com a memória stack. Ocorre uma cópia do valor da variável.

Borrowing - Emprestando valores
O Borrowing garante que existirá apenas um dono por vez de um valor, porém podendo existir mais de uma referência para a mesma. Dessa forma, uma vez que a varável dono chegue ao final do escopo onde está declarada, sua memória será automaticamente liberada.
#[derive(Debug)]
struct Pessoa {
idade: i32
}
fn main() {
// Stack
let pessoa: Pessoa = Pessoa { idade: 32 };
// estamos transferindo o ownership nesse momento e faz com
// que a variável pessoa não possa mais ser utilizada. (borrow)
let pessoa_copia: Pessoa = pessoa;
// mostra o endereço das variáveis
println!("endereço da variável pessoa é {:p}", &pessoa);
println!("endereço da variável pessoa_copia é {:p}", &pessoa_copia);
// mostra o valor das variáveis
println!("valor da variável pessoa é {:?}", pessoa);
println!("valor da variável pessoa_copia é {:?}", pessoa_copia);
}
Erro de compilação:
error[E0382]: borrow of moved value: `pessoa`
--> src/main.rs:12:52
|
8 | let pessoa: Pessoa = Pessoa { idade: 32 };
| ------ move occurs because `pessoa` has type `Pessoa`, which does not implement the `Copy` trait
11 | let pessoa_copia: Pessoa = pessoa;
| ------ value moved here
...
14 | println!("endereço da variável pessoa é {:p}", &pessoa);
| ^^^^^^^ value borrowed here after move
No exemplo acima, vemos que é gerado um erro durante a compilação alegando que a variável pessoa está emprestada (borrowing). Esse exemplo se diferencia do caso do exemplo anterior porque Struct por padrão não implementa a Trait de Copy e pessoa está sendo emprestada para pessoa_copia e não pode mais ser utilizada.
Uma vez que a Struct implementa Copy, ela pode ser passada da mesma forma que o exemplo de Cópia Implícita.
#[derive(Debug, Copy, Clone)]
struct Pessoa {
idade: i32
}
Saída da Execução:
endereço da variável pessoa é 0x7fff7a0bb9b0
endereço da variável pessoa_copia é 0x7fff7a0bb9b4
valor da variável pessoa é Pessoa { idade: 32 }
valor da variável pessoa_copia é Pessoa { idade: 32 }
Vale observar que ambas as variáveis possuem o mesmo valor em endereços de memória diferentes. Literalmente ocorreu uma cópia do valor.
Passando valores por referência
Ficar copiando os valores de um lado para o outro não é nada elegante. Principalmente quando precisamos passar valores relativamente grandes e copiá-los sem necessidade.
Para isso, podemos utilizar a passagem de valores por referência e por sorte, esse comportamento é supersimples.
#[derive(Debug)]
struct Pessoa {
idade: i32
}
fn main() {
// Stack
let pessoa: Pessoa = Pessoa { idade: 32 };
// Pessoa -> &Pessoa / pessoa -> &pessoa
let pessoa_copia: &Pessoa = &pessoa;
// mostra o endereço das variáveis
println!("endereço da variável pessoa é {:p}", &pessoa);
// removemos o & de pessoa_copia, pois queremos apontar para o
// endereço de memória do destino da referência e não para o
// endereço da referência
println!("endereço da variável pessoa_copia é {:p}", pessoa_copia);
// mostra o valor das variáveis
println!("valor da variável pessoa é {:?}", pessoa);
println!("valor da variável pessoa_copia é {:?}", pessoa_copia);
}
Saída da Execução:
endereço da variável pessoa é 0x7ffeede39484
endereço da variável pessoa_copia é 0x7ffeede39484
valor da variável pessoa é Pessoa { idade: 32 }
valor da variável pessoa_copia é Pessoa { idade: 32 }
A imagem abaixo exemplifica o que acontece na Stack.

Neste caso temos alguns comportamentos importantes:
Pessoa não implementa mais Copy
O tipo da variável
pessoa_copiadeixa de serPessoae passa a ser&Pessoa(podemos ler como "Referência de Pessoa")A atribuição passa ser a referência da variável
&pessoa(é retornado a referência do endereço de memória depessoa)pessoa_copianão aponta mais para um novo valor, aponta para o mesmo valor da variável pessoa.
Transferência de Ownership
O que acontece quando retornamos à referência de uma variável em um escopo que já foi finalizado?
#[derive(Debug)]
struct Pessoa {
idade: i32
}
fn cria_pessoa() -> &Pessoa {
let pessoa: Pessoa = Pessoa { idade: 32 };
&pessoa
}
fn main() {
let pessoa = cria_pessoa();
}
Saída da Compilação
error[E0106]: missing lifetime specifier
--> src/main.rs:6:21
|
6 | fn cria_pessoa() -> &Pessoa {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
6 | fn cria_pessoa() -> &'static Pessoa {
| +++++++
help: instead, you are more likely to want to return an owned value
|
6 - fn cria_pessoa() -> &Pessoa {
6 + fn cria_pessoa() -> Pessoa {
|
error[E0515]: cannot return reference to local variable `pessoa`
--> src/main.rs:9:5
|
9 | &pessoa
| ^^^^^^^ returns a reference to data owned by the current function
É claro que temos um erro. Isso porque a variável que definimos só existe dentro do escopo da função e é imediatamente removida assim que o escopo é finalizado.
Para resolver esse caso, bastaria retornar Pessoa, fazendo assim a transferência de Ownership para o escopo "pai".
#[derive(Debug)]
struct Pessoa {
idade: i32
}
fn cria_pessoa() -> Pessoa {
let pessoa: Pessoa = Pessoa { idade: 32 };
pessoa
}
fn main() {
// agora o ownership é da variável pessoa no escopo atual
let pessoa = cria_pessoa();
println!("pessoa={:?}", pessoa);
}
Saída da Execução
pessoa=Pessoa { idade: 32 }
Alocação de espaços de memória na Heap
Até o momento, vimos a criação, cópia e referência de variáveis definidas apenas na Stack, porém quando precisamos criar variáveis que o tamanho é desconhecido em tempo de compilação, precisamos recorrer a Heap.
use std::rc::Rc;
fn main() {
let x: Rc<i32> = Rc::new(32);
let y: Rc<i32> = x.clone();
println!("mostra o endereço das variáveis");
println!("endereço do valor da variável x é {:p}", &x);
println!("endereço do valor da variável y é {:p}", &y);
println!("");
println!("mostra o endereço na heap");
println!("endereço do valor da variável x é {:p}", x);
println!("endereço do valor da variável y é {:p}", y);
println!("");
println!("mostra o valor das variáveis");
println!("valor da variável x é {}", x);
println!("valor da variável y é {}", y);
}
Saída da Execução
mostra o endereço das variáveis
endereço do valor da variável x é 0x7ffd60241688
endereço do valor da variável y é 0x7ffd60241690
mostra o endereço na heap
endereço do valor da variável x é 0x555b86246bb0
endereço do valor da variável y é 0x555b86246bb0
mostra o valor das variáveis
valor da variável x é 32
valor da variável y é 32

Nesse exemplo, usamos um tipo de dado i32, mas poderíamos ter utilizado qualquer tipo de dado. A única informação que precisaria ser copiada para a transferência seria um Rc de tamanho fixo que estaria na Stack enquanto o valor continuaria na Heap.
Mutabilidade das variáveis
Até o momento, vimos o comportamento apenas de variáveis imutáveis, de tal forma que só precisamos garantir que quando formos usar uma variável, ou fazemos uma cópia ou passamos por referência.
Por padrão, toda variável definida é imutável.
fn main() {
let idade = 32;
// gera um erro. por padrão toda variável é imutável por padrão
idade = 33;
}
Saída da compilação
error[E0384]: cannot assign twice to immutable variable `idade`
--> src/main.rs:5:5
|
2 | let idade = 32;
| -----
| |
| first assignment to `idade`
| help: consider making this binding mutable: `mut idade`
...
5 | idade = 33;
| ^^^^^^^^^^ cannot assign twice to immutable variable
Formas de trabalhar com variáveis mutáveis:
Mutabilidade de variáveis sem referência
Transferência de Ownership de uma variável para outro escopo
Mutabilidade de variáveis com referência
Existe apenas uma referência para a variável
Então, para podermos atualizar o valor de uma variável, basta que seja definido a variável como mutável.
fn main() {
let mut idade = 32; // basta definir como mut
idade = 33;
idade = 34;
idade = 35;
}
Porém, o que acontece se tentarmos utilizar os valores e depois tentar fazer essa atribuição quando usamos variáveis que implementam Copy.
fn main() {
let mut idade = 32;
println!("idade={}", idade);
idade = 33;
println!("idade={}", idade);
}
Mesmo que tenhamos utilizado e feito uma nova atribuição, a variável continua sendo utilizável porque neste cenário, sempre é feito a cópia implícita dos valores.
Transferência de Ownership de uma variável para outro escopo
#[derive(Debug)]
struct Pessoa {
idade: i32
}
fn incrementa_idade(p: Pessoa) {
p.idade += 1;
println!("2 pessoa={:?}", pessoa);
}
fn main() {
let pessoa = Pessoa { idade: 32 };
println!("1 pessoa={:?}", pessoa);
incrementa_idade(pessoa);
println!("3 pessoa={:?}", pessoa);
}
Saída da Compilação
error[E0594]: cannot assign to `p.idade`, as `p` is not declared as mutable
--> src/main.rs:7:5
|
7 | p.idade += 1;
| ^^^^^^^^^^^^ cannot assign
|
help: consider changing this to be mutable
|
6 | fn incrementa_idade(mut p: Pessoa) {
| +++
error[E0382]: borrow of moved value: `pessoa`
--> src/main.rs:16:31
|
12 | let pessoa = Pessoa { idade: 32 };
| ------ move occurs because `pessoa` has type `Pessoa`, which does not implement the `Copy` trait
...
15 | incrementa_idade(pessoa);
| ------ value moved here
16 | println!("3 pessoa={:?}", pessoa);
| ^^^^^^ value borrowed here after move
|
note: consider changing this parameter type in function `incrementa_idade` to borrow instead if owning the value isn't necessary
--> src/main.rs:6:24
|
6 | fn incrementa_idade(p: Pessoa) {
| ---------------- ^^^^^^ this parameter takes ownership of the value
| |
| in this function
No momento em que chamamos a função incrementa_idade, transferimos o ownership da variável para a função e além de passarmos o ownership, também passamos uma variável imutável.
#[derive(Debug)]
struct Pessoa {
idade: i32
}
fn incrementa_idade(p: &mut Pessoa) {
p.idade += 1;
println!("2 pessoa={:?}", p);
}
fn main() {
let mut pessoa = Pessoa { idade: 32 };
println!("1 pessoa={:?}", pessoa);
incrementa_idade(&mut pessoa);
println!("3 pessoa={:?}", pessoa);
}
Saída da Execução
1 pessoa=Pessoa { idade: 32 }
2 pessoa=Pessoa { idade: 33 }
3 pessoa=Pessoa { idade: 33 }
Podemos notar que tivemos que definir mut em todas as declarações e passagem de parâmetros. Isso no primeiro momento parece bem chato, porém, sabemos exatamente onde e quando uma variável receberá mutação.
Mutabilidade de variáveis com referência
#[derive(Debug)]
struct Pessoa {
idade: i32
}
fn incrementa_idade(p: &mut Pessoa) {
p.idade += 1;
println!("2 pessoa={:?}", p);
}
fn main() {
let mut pessoa = Pessoa { idade: 32 };
println!("1 pessoa={:?}", pessoa);
let p2 = &pessoa;
incrementa_idade(&mut pessoa);
println!("3 pessoa={:?}", pessoa);
println!("4 p2={:?}", p2);
}
Saída da Compilação
error[E0502]: cannot borrow `pessoa` as mutable because it is also borrowed as immutable
--> src/main.rs:17:22
|
15 | let p2 = &pessoa;
| ------- immutable borrow occurs here
16 |
17 | incrementa_idade(&mut pessoa);
| ^^^^^^^^^^^ mutable borrow occurs here
...
20 | println!("4 p2={:?}", p2);
| -- immutable borrow later used here
Nesse caso, vemos que não é permitido existir uma referência mutável e imutável ao mesmo tempo.
#[derive(Debug)]
struct Pessoa {
idade: i32
}
fn incrementa_idade(p: &mut Pessoa) {
p.idade += 1;
println!("2 pessoa={:?}", p);
}
fn main() {
let mut pessoa = Pessoa { idade: 32 };
println!("1 pessoa={:?}", pessoa);
incrementa_idade(&mut pessoa);
println!("3 pessoa={:?}", pessoa);
let p2 = &pessoa;
println!("4 p2={:?}", p2);
}
Uma vez que alteramos a ordem das referências mutáveis e imutáveis, fazendo com que a referência mutável seja finalizada antes da criação da referência. Dessa forma, não há problemas e podemos seguir sem problemas.