A propriedade e os empréstimos do Rust podem ser confusos se não entendermos o que realmente está acontecendo. Isso é particularmente verdadeiro ao aplicar um estilo de programação previamente aprendido a um novo paradigma; chamamos isso de mudança de paradigma. A propriedade é uma ideia nova, mas difícil de entender no começo, mas fica mais fácil quanto mais trabalhamos nela.
Antes de prosseguirmos sobre a propriedade e empréstimo de Rust, vamos primeiro entender o que são “segurança de memória” e “vazamento de memória” e como as linguagens de programação lidam com eles.
A segurança da memória refere-se ao estado de um aplicativo de software em que os ponteiros ou referências de memória sempre se referem à memória válida. Como a corrupção de memória é uma possibilidade, há poucas garantias sobre o comportamento de um programa se ele não for seguro para a memória. Simplificando, se um programa não é realmente seguro para a memória, há poucas garantias sobre sua funcionalidade. Ao lidar com um programa de memória insegura, uma parte maliciosa é capaz de usar a falha para ler segredos ou executar código arbitrário na máquina de outra pessoa.
Vamos usar um pseudocódigo para ver qual é a memória válida.
// pseudocode #1 - shows valid reference { // scope starts here int x = 5 int y = &x } // scope ends here
No pseudocódigo acima, criamos uma variável x
atribuída com um valor de 10
. Usamos o operador &
ou a palavra-chave para criar uma referência. Assim, a sintaxe &x
nos permite criar uma referência que se refere ao valor de x
. Simplificando, criamos uma variável x
que possui 5
e uma variável y
que é uma referência a x
.
Como ambas as variáveis x
e y
estão no mesmo bloco ou escopo, a variável y
tem uma referência válida que se refere ao valor de x
. Como resultado, a variável y
tem um valor de 5
.
Dê uma olhada no pseudocódigo abaixo. Como podemos ver, o escopo de x
é limitado ao bloco no qual ele é criado. Entramos em referências confusas quando tentamos acessar x
fora de seu escopo. Referência pendente…? O que exatamente é isso?
// pseudocode #2 - shows invalid reference aka dangling reference { // scope starts here int x = 5 } // scope ends here int y = &x // can't access x from here; creates dangling reference
Uma referência oscilante é um ponteiro que aponta para um local de memória que foi dado a outra pessoa ou liberado (liberado). Se um programa (também conhecido como processo ) se referir à memória que foi liberada ou apagada, ele pode travar ou causar resultados não determinísticos.
Dito isto, a insegurança de memória é uma propriedade de algumas linguagens de programação que permite aos programadores lidar com dados inválidos. Como resultado, a insegurança de memória introduziu uma variedade de problemas que podem causar as seguintes principais vulnerabilidades de segurança:
Vulnerabilidades causadas por insegurança de memória estão na raiz de muitas outras ameaças graves à segurança. Infelizmente, descobrir essas vulnerabilidades pode ser extremamente desafiador para os desenvolvedores.
É importante entender o que é um vazamento de memória e quais são suas consequências.
Um vazamento de memória é uma forma não intencional de consumo de memória em que o desenvolvedor não consegue liberar um bloco alocado de memória heap quando não é mais necessário. É simplesmente o oposto da segurança da memória. Mais sobre os diferentes tipos de memória posteriormente, mas, por enquanto, apenas saiba que uma pilha armazena variáveis de comprimento fixo conhecidas no tempo de compilação, enquanto o tamanho das variáveis que podem mudar posteriormente no tempo de execução deve ser colocado no heap .
Quando comparada à alocação de memória heap, a alocação de memória pilha é considerada mais segura, pois a memória é liberada automaticamente quando não é mais relevante ou necessária, seja pelo programador ou pelo próprio tempo de execução do programa.
No entanto, quando os programadores geram memória no heap e não conseguem removê-la na ausência de um coletor de lixo (no caso de C e C++), ocorre um vazamento de memória. Além disso, se perdermos todas as referências a um pedaço de memória sem desalocar essa memória, teremos um vazamento de memória. Nosso programa continuará a possuir essa memória, mas não tem como usá-la novamente.
Um pequeno vazamento de memória não é um problema, mas se um programa aloca uma quantidade maior de memória e nunca a desaloca, o consumo de memória do programa continuará aumentando, resultando em negação de serviço.
Quando um programa é encerrado, o sistema operacional recupera imediatamente toda a memória que possui. Como resultado, um vazamento de memória afeta apenas um programa enquanto ele está em execução; não tem efeito uma vez que o programa tenha terminado.
Vamos examinar as principais consequências dos vazamentos de memória.
Vazamentos de memória reduzem o desempenho do computador reduzindo a quantidade de memória disponível (memória heap). Eventualmente, faz com que todo ou parte do sistema pare de funcionar corretamente ou diminua a velocidade severamente. As falhas são comumente associadas a vazamentos de memória.
Nossa abordagem para descobrir como evitar vazamentos de memória varia dependendo da linguagem de programação que estamos usando. Os vazamentos de memória podem começar como um problema pequeno e quase “imperceptível”, mas podem aumentar muito rapidamente e sobrecarregar os sistemas afetados. Sempre que possível, devemos estar atentos a eles e tomar medidas para corrigi-los, em vez de deixá-los crescer.
Vazamentos de memória e insegurança de memória são os dois tipos de problemas que receberam maior atenção em termos de prevenção e correção. É importante observar que consertar um não corrige automaticamente o outro.
Antes de prosseguirmos, é importante entender os diferentes tipos de memória que nosso código usará em tempo de execução.
Existem dois tipos de memória, como segue, e essas memórias são estruturadas de maneira diferente.
registrador do processador
Estático
Pilha
pilha
Tanto o registro do processador quanto os tipos de memória estática estão além do escopo deste post.
A pilha armazena os dados na ordem em que são recebidos e os remove na ordem inversa. Os itens podem ser acessados da pilha na ordem LIFO ( último a entrar, primeiro a sair ). Adicionar dados à pilha é chamado de “push” e remover dados da pilha é chamado de “popping”.
Todos os dados armazenados na pilha devem ter um tamanho fixo conhecido. Os dados com um tamanho desconhecido no tempo de compilação ou um tamanho que pode mudar posteriormente devem ser armazenados no heap.
Como desenvolvedores, não precisamos nos preocupar com alocação e desalocação de memória de pilha; a alocação e desalocação da memória da pilha é “feita automaticamente” pelo compilador. Isso implica que quando os dados na pilha não são mais relevantes (fora do escopo), eles são excluídos automaticamente sem a necessidade de nossa intervenção.
Esse tipo de alocação de memória também é conhecido como alocação de memória temporária , pois assim que a função termina sua execução, todos os dados pertencentes a essa função são descarregados da pilha “automaticamente”.
Todos os tipos primitivos em Rust vivem na pilha. Tipos como números, caracteres, fatias, booleanos, arrays de tamanho fixo, tuplas contendo primitivos e ponteiros de função podem todos ficar na pilha.
Ao contrário de uma pilha, quando colocamos dados no heap, solicitamos uma certa quantidade de espaço. O alocador de memória localiza um local desocupado grande o suficiente no heap, marca-o como em uso e retorna uma referência ao endereço desse local. Isso é chamado de alocação .
Alocar no heap é mais lento do que empurrar para a pilha porque o alocador nunca precisa procurar um local vazio para colocar novos dados. Além disso, como devemos seguir um ponteiro para obter dados na pilha, é mais lento do que acessar dados na pilha. Ao contrário da pilha, que é alocada e desalocada em tempo de compilação, a memória heap é alocada e desalocada durante a execução das instruções de um programa.
Em algumas linguagens de programação, para alocar memória heap, usamos a palavra-chave new
. Essa new
palavra-chave (também conhecida como operator ) denota uma solicitação de alocação de memória no heap. Se houver memória suficiente disponível no heap, o new
operador inicializa a memória e retorna o endereço exclusivo dessa memória recém-alocada.
Vale a pena mencionar que a memória heap é “explicitamente” desalocada pelo programador ou pelo tempo de execução.
Quando se trata de gerenciamento de memória, particularmente memória heap, preferimos que nossas linguagens de programação tenham as seguintes características:
A segurança da memória é garantida de diferentes maneiras pelas linguagens de programação por meio de:
Tanto o gerenciamento de memória baseado em região quanto os sistemas de tipo linear estão além do escopo deste post.
Os programadores devem liberar ou apagar “manualmente” a memória alocada ao usar o gerenciamento de memória explícita. Um operador de “desalocação” (por exemplo, delete
em C) existe em linguagens com desalocação de memória explícita.
A coleta de lixo é muito cara em linguagens de sistema como C e C++, portanto, a alocação de memória explícita continua a existir.
Deixar a responsabilidade de liberar memória para o programador tem a vantagem de dar ao programador controle total sobre o ciclo de vida da variável. No entanto, se os operadores de desalocação forem usados incorretamente, pode ocorrer uma falha de software durante a execução. Na verdade, esse processo manual de alocação e liberação está sujeito a erros. Alguns erros comuns de codificação incluem:
Apesar disso, preferimos o gerenciamento de memória manual em vez da coleta de lixo, pois nos dá mais controle e oferece melhor desempenho. Observe que o objetivo de qualquer linguagem de programação de sistema é chegar o mais "próximo possível do metal". Em outras palavras, eles favorecem um melhor desempenho em relação aos recursos de conveniência na troca.
É inteiramente nossa (desenvolvedores) a responsabilidade de garantir que nenhum ponteiro para o valor que liberamos seja usado.
No passado recente, houve vários padrões comprovados para evitar esses erros, mas tudo se resume a manter uma rigorosa disciplina de código, o que requer a aplicação consistente do método correto de gerenciamento de memória.
Os principais tópicos são:
O gerenciamento automático de memória tornou-se um recurso essencial de todas as linguagens de programação modernas, incluindo Java.
No caso de desalocação automática de memória, os coletores de lixo atuam como gerenciadores automáticos de memória. Esses coletores de lixo examinam periodicamente o heap e reciclam pedaços de memória que não estão sendo usados. Eles gerenciam a alocação e liberação de memória em nosso nome. Portanto, não precisamos escrever código para executar tarefas de gerenciamento de memória. Isso é ótimo, pois os coletores de lixo nos liberam da responsabilidade do gerenciamento de memória. Outra vantagem é que reduz o tempo de desenvolvimento.
A coleta de lixo, por outro lado, tem uma série de desvantagens. Durante a coleta de lixo, o programa deve fazer uma pausa e gastar algum tempo determinando o que precisa ser limpo antes de prosseguir.
Além disso, o gerenciamento automático de memória tem maiores necessidades de memória. Isso se deve ao fato de que um coletor de lixo realiza a desalocação de memória para nós, o que consome memória e ciclos de CPU. Como resultado, o gerenciamento de memória automatizado pode degradar o desempenho do aplicativo, principalmente em aplicativos grandes com recursos limitados.
Os principais tópicos são:
Algumas linguagens fornecem coleta de lixo , que procura a memória que não está mais em uso enquanto o programa é executado; outros exigem que o programador aloque e libere explicitamente a memória . Ambos os modelos têm vantagens e desvantagens. A coleta de lixo, embora talvez seja a mais amplamente utilizada, tem algumas desvantagens; facilita a vida dos desenvolvedores em detrimento de recursos e desempenho.
Dito isto, um oferece controle de gerenciamento de memória eficiente, enquanto o outro fornece maior segurança , eliminando referências pendentes e vazamentos de memória. Rust combina os benefícios dos dois mundos.
Rust adota uma abordagem diferente das outras duas, com base em um modelo de propriedade com um conjunto de regras que o compilador verifica para garantir a segurança da memória. O programa não compilará se alguma dessas regras for violada. Na verdade, a propriedade substitui a coleta de lixo em tempo de execução por verificações em tempo de compilação para segurança da memória.
Leva algum tempo para se acostumar com a propriedade porque é um conceito novo para muitos programadores, como eu.
Neste ponto, temos uma compreensão básica de como os dados são armazenados na memória. Vamos ver a propriedade em Rust mais de perto. A maior característica distintiva do Rust é a propriedade, que garante a segurança da memória em tempo de compilação.
Para começar, vamos definir “propriedade” em seu sentido mais literal. Propriedade é o estado de “possuir” e “controlar” a posse legal de “algo”. Com isso dito, devemos identificar quem é o proprietário e o que o proprietário possui e controla . No Rust, cada valor possui uma variável chamada owner . Simplificando, uma variável é um proprietário e o valor de uma variável é o que o proprietário possui e controla.
Com um modelo de propriedade, a memória é automaticamente liberada (liberada) assim que a variável que a possui sai do escopo. Quando os valores saem do escopo ou seus tempos de vida terminam por algum outro motivo, seus destruidores são chamados. Um destruidor, particularmente um destruidor automatizado, é uma função que remove vestígios de um valor do programa, excluindo referências e liberando memória.
O Rust implementa a propriedade por meio do verificador de empréstimo , um
Conforme declarado anteriormente, o modelo de propriedade é construído sobre um conjunto de regras conhecidas como regras de propriedade e essas regras são relativamente diretas. O compilador Rust (rustc) impõe estas regras:
Os seguintes erros de memória são protegidos por essas regras de propriedade de verificação em tempo de compilação:
Antes de entrar nos detalhes de cada regra de propriedade, é importante entender as distinções entre copy , move e clone .
Um tipo com um tamanho fixo (principalmente tipos primitivos) pode ser armazenado na pilha e removido quando seu escopo termina, e pode ser copiado de forma rápida e fácil para criar uma nova variável independente se outra parte do código exigir o mesmo valor em um escopo diferente. Como copiar memória de pilha é barato e rápido, diz-se que tipos primitivos com tamanho fixo têm semântica de cópia . Ele cria de forma barata uma réplica perfeita (uma duplicata).
Vale a pena notar que tipos primitivos com tamanho fixo implementam o recurso de cópia para fazer cópias.
let x = "hello"; let y = x; println!("{}", x) // hello println!("{}", y) // hello
No Rust, existem dois tipos de strings:
String
(heap alocado e expansível) e&str
(tamanho fixo e não pode sofrer mutação).
Como x
é armazenado na pilha, copiar seu valor para produzir outra cópia para y
é mais fácil. Esse não é o caso de um valor armazenado no heap. É assim que o quadro de pilha se parece:
A duplicação de dados aumenta o tempo de execução do programa e o consumo de memória. Portanto, a cópia não é uma boa opção para grandes blocos de dados.
Na terminologia do Rust, "mover" significa que a propriedade da memória é transferida para outro proprietário. Considere o caso de tipos complexos que são armazenados no heap.
let s1 = String::from("hello"); let s2 = s1;
Podemos supor que a segunda linha (ou seja, let s2 = s1;
) faria uma cópia do valor em s1
e o vincularia a s2
. Mas este não é o caso.
Dê uma olhada no abaixo para ver o que está acontecendo com String
sob o capô. Uma String é composta de três partes, que são armazenadas na pilha . O conteúdo real (olá, neste caso) é armazenado no heap .
String
está usando no momento.String
recebeu do alocador.
Em outras palavras, os metadados são mantidos na pilha enquanto os dados reais são mantidos na pilha.
Quando atribuímos s1
a s2
, os metadados String
são copiados, ou seja, copiamos o ponteiro, o comprimento e a capacidade que estão na pilha. Não copiamos os dados no heap ao qual o ponteiro se refere. A representação dos dados na memória se parece com a seguinte:
Vale a pena notar que a representação não se parece com a abaixo, que é como a memória ficaria se o Rust copiasse os dados do heap também. Se Rust realizasse isso, a operação s2 = s1
poderia ser extremamente lenta em termos de desempenho de tempo de execução se os dados do heap fossem grandes.
Observe que quando os tipos complexos não estão mais no escopo, o Rust chamará a função drop
para desalocar explicitamente a memória do heap. No entanto, ambos os ponteiros de dados na Figura 6 estão apontando para o mesmo local, que não é como o Rust funciona. Entraremos em detalhes em breve.
Conforme declarado anteriormente, quando atribuímos s1
a s2
, a variável s2
recebe uma cópia dos metadados de s1
(ponteiro, comprimento e capacidade). Mas o que acontece com s1
depois de atribuído a s2
? Rust não considera mais s1
válido. Sim, você leu corretamente.
Vamos pensar sobre isso let s2 = s1
atribuição por um momento. Considere o que acontece se Rust ainda considerar s1
como válido após esta atribuição. Quando s2
e s1
saem do escopo, ambos tentarão liberar a mesma memória. Uh-oh, isso não é bom. Isso é chamado de erro livre duplo e é um dos erros de segurança da memória. A corrupção de memória pode resultar da liberação de memória duas vezes, representando um risco de segurança.
Para garantir a segurança da memória, Rust considerou s1
inválido após a linha let s2 = s1
. Portanto, quando s1
não estiver mais no escopo, o Rust não precisará liberar nada. Examine o que acontece se tentarmos usar s1
após a criação de s2
.
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.
Receberemos um erro como o abaixo porque o Rust impede que você use a referência invalidada:
$ cargo run Compiling playground v0.0.1 (/playground) error[E0382]: borrow of moved value: `s1` --> src/main.rs:6:28 | 3 | let s1 = String::from("hello"); | -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait 4 | let s2 = s1; | -- value moved here 5 | 6 | println!("{}, world!", s1); | ^^ value borrowed here after move | = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info) For more information about this error, try `rustc --explain E0382`.
Como Rust “moveu” a propriedade da memória de s1
para s2
após a linha let s2 = s1
, ele considerou s1
inválido. Aqui está a representação da memória após a invalidação de s1:
Quando apenas s2
permanece válido, ele sozinho liberará a memória quando sair do escopo. Como resultado, o potencial para um duplo erro livre é eliminado no Rust. Isso é maravilhoso!
Se quisermos copiar profundamente os dados heap do String
, não apenas os dados da pilha, podemos usar um método chamado clone
. Aqui está um exemplo de como usar o método clone:
let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2);
Ao usar o método clone, os dados do heap são copiados para s2. Isso funciona perfeitamente e produz o seguinte comportamento:
A utilização do método do clone tem sérias consequências; ele não apenas copia os dados, mas também não sincroniza nenhuma alteração entre os dois. Em geral, os clones devem ser planejados com cuidado e com plena consciência das consequências.
Até agora, devemos ser capazes de distinguir entre copiar, mover e clonar. Vamos examinar cada regra de propriedade com mais detalhes agora.
Cada valor tem uma variável chamada seu proprietário. Isso implica que todos os valores são propriedade de variáveis. No exemplo abaixo, a variável s
possui o ponteiro para nossa string e, na segunda linha, a variável x
possui um valor 1.
let s = String::from("Rule 1"); let n = 1;
Só pode haver um proprietário de um valor em um determinado momento. Pode-se ter muitos animais de estimação, mas quando se trata do modelo de propriedade, há apenas um valor a qualquer momento :-)
Vejamos o exemplo usando primitivas , que são de tamanho fixo conhecidas em tempo de compilação.
let x = 10; let y = x; let z = x;
Pegamos 10 e o atribuímos a x
; em outras palavras, x
possui 10. Em seguida, estamos pegando x
e atribuindo-o a y
e também o atribuindo a z
. Sabemos que só pode haver um proprietário em um determinado momento, mas não estamos recebendo nenhum erro aqui. O que está acontecendo aqui é que o compilador está fazendo cópias de x
toda vez que o atribuímos a uma nova variável.
O quadro de pilha para isso seria o seguinte: x = 10
, y = 10
z = 10
. Isso, no entanto, não parece ser o caso: x = 10
, y = x
e z = x
. Como sabemos, x
é o único proprietário deste valor 10, e nem y
nem z
podem possuir este valor.
Como copiar a memória da pilha é barato e rápido, diz-se que os tipos primitivos com tamanho fixo têm semântica de cópia , enquanto os tipos complexos movem a propriedade, como afirmado anteriormente. Assim, neste caso, o compilador faz as cópias .
Neste ponto, o comportamento de
Vamos examinar os dados armazenados no heap e ver como o Rust entende quando limpá-los; o tipo String é um excelente exemplo para este caso de uso. Vamos nos concentrar no comportamento relacionado à propriedade de String; esses princípios, no entanto, também se aplicam a outros tipos de dados complexos.
O tipo complexo, como sabemos, gerencia dados no heap e seu conteúdo é desconhecido em tempo de compilação. Vejamos o mesmo exemplo que vimos antes:
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.
No caso do tipo
String
, o tamanho pode ser expandido e armazenado no heap. Isso significa:
- Em tempo de execução, a memória deve ser solicitada ao alocador de memória (vamos chamá-lo de primeira parte).
- Quando terminarmos de usar nossa
String
, precisamos retornar (liberar) essa memória de volta ao alocador (vamos chamá-la de segunda parte).
Nós (desenvolvedores) cuidamos da primeira parte: quando chamamos
String::from
, sua implementação solicita a memória necessária. Esta parte é quase comum em linguagens de programação.
No entanto, a segunda parte é diferente. Em linguagens com coletor de lixo (GC), o GC monitora e limpa a memória que não está mais em uso, e não precisamos nos preocupar com isso. Em linguagens sem coletor de lixo, é nossa responsabilidade identificar quando a memória não é mais necessária e solicitar que ela seja liberada explicitamente. Sempre foi uma tarefa de programação desafiadora fazer isso corretamente:
- Vamos desperdiçar memória se esquecermos.
- Teremos uma variável inválida se fizermos isso muito cedo.
- Receberemos um bug se fizermos isso duas vezes.
Rust lida com a desalocação de memória de uma maneira inovadora para facilitar nossas vidas: a memória é retornada automaticamente quando a variável que a possui sai do escopo.
Vamos voltar aos negócios. Em Rust, para tipos complexos, operações como atribuir um valor a uma variável, passá-lo para uma função ou retorná-lo de uma função não copiam o valor: elas o movem. Simplificando, os tipos complexos movem a propriedade.
Quando os tipos complexos não estão mais no escopo, o Rust chamará a função drop para desalocar explicitamente a memória do heap.
Quando o proprietário sair do escopo, o valor será descartado. Considere novamente o caso anterior:
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. The value of s1 has already been dropped.
O valor de s1
caiu depois que s1
foi atribuído a s2
(na instrução de atribuição let s2 = s1
). Assim, s1
não é mais válido após esta atribuição. Aqui está a representação da memória após a eliminação de s1:
Existem três maneiras de transferir a propriedade de uma variável para outra em um programa Rust:
Passar um valor para uma função tem uma semântica semelhante à atribuição de um valor a uma variável. Assim como a atribuição, passar uma variável para uma função faz com que ela seja movida ou copiada. Dê uma olhada neste exemplo, que mostra os casos de uso de copiar e mover:
fn main() { let s = String::from("hello"); // s comes into scope move_ownership(s); // s's value moves into the function... // so it's no longer valid from this // point forward let x = 5; // x comes into scope makes_copy(x); // x would move into the function // It follows copy semantics since it's // primitive, so we use x afterward } // Here, x goes out of scope, then s. But because s's value was moved, nothing // special happens. fn move_ownership(some_string: String) { // some_string comes into scope println!("{}", some_string); } // Here, some_string goes out of scope and `drop` is called. // The occupied memory is freed. fn makes_copy(some_integer: i32) { // some_integer comes into scope println!("{}", some_integer); } // Here, some_integer goes out of scope. Nothing special happens.
Se tentássemos usar s após a chamada para move_ownership
, Rust lançaria um erro de tempo de compilação.
Os valores retornados também podem transferir a propriedade. O exemplo abaixo mostra uma função que retorna um valor, com anotações idênticas às do exemplo anterior.
fn main() { let s1 = gives_ownership(); // gives_ownership moves its return // value into s1 let s2 = String::from("hello"); // s2 comes into scope let s3 = takes_and_gives_back(s2); // s2 is moved into // takes_and_gives_back, which also // moves its return value into s3 } // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing // happens. s1 goes out of scope and is dropped. fn gives_ownership() -> String { // gives_ownership will move its // return value into the function // that calls it let some_string = String::from("yours"); // some_string comes into scope some_string // some_string is returned and // moves out to the calling // function } // This function takes a String and returns it fn takes_and_gives_back(a_string: String) -> String { // a_string comes into // scope a_string // a_string is returned and moves out to the calling function }
A propriedade de uma variável sempre segue o mesmo padrão: um valor é movido quando é atribuído a outra variável . A menos que a propriedade dos dados tenha sido movida para outra variável, quando uma variável que inclui dados no heap sai do escopo, o valor será limpo por drop
.
Esperançosamente, isso nos dá uma compreensão básica do que é um modelo de propriedade e como ele influencia a maneira como o Rust lida com valores, como atribuí-los uns aos outros e passá-los para dentro e para fora das funções.
Aguentar. Mais uma coisa…
O modelo de propriedade de Rust, como acontece com todas as coisas boas, tem algumas desvantagens. Percebemos rapidamente alguns inconvenientes assim que começamos a trabalhar no Rust. Podemos ter observado que assumir a propriedade e, em seguida, retornar a propriedade com cada função é um pouco inconveniente.
É irritante que tudo o que passamos para uma função deve ser retornado se quisermos usá-lo novamente, além de quaisquer outros dados retornados por essa função. E se quisermos que uma função use um valor sem se apropriar dele?
Considere o seguinte exemplo. O código abaixo resultará em um erro porque a variável v
não pode mais ser usada pela função main
(em println!
) que inicialmente a possuía, uma vez que a propriedade é transferida para a função print_vector
.
fn main() { let v = vec![10,20,30]; print_vector(v); println!("{}", v[0]); // this line gives us an error } fn print_vector(x: Vec<i32>) { println!("Inside print_vector function {:?}",x); }
Rastrear a propriedade pode parecer bastante fácil, mas pode ficar complicado quando começamos a lidar com programas grandes e complexos. Portanto, precisamos de uma maneira de transferir valores sem transferir a propriedade, que é onde entra em jogo o conceito de empréstimo .
Empréstimo, em seu sentido literal, refere-se a receber algo com a promessa de devolvê-lo. No contexto do Rust, o empréstimo é uma forma de acessar o valor sem reivindicar a propriedade dele, pois deve ser devolvido ao seu proprietário em algum momento.
Quando pegamos emprestado um valor, referenciamos seu endereço de memória com o operador &
. A &
é chamado de referência . As referências em si não são nada de especial - sob o capô, são apenas endereços. Para aqueles familiarizados com ponteiros C, uma referência é um ponteiro para a memória que contém um valor que pertence a (também conhecido como propriedade de) outra variável. Vale a pena notar que uma referência não pode ser nula em Rust. Na verdade, uma referência é um ponteiro ; é o tipo mais básico de ponteiro. Existe apenas um tipo de ponteiro na maioria das linguagens, mas Rust tem diferentes tipos de ponteiros, em vez de apenas um. Ponteiros e seus vários tipos são um tópico diferente que será discutido separadamente.
Simplificando, Rust refere-se à criação de uma referência a algum valor como o empréstimo do valor, que deve eventualmente retornar ao seu proprietário.
Vejamos um exemplo simples abaixo:
let x = 5; let y = &x; println!("Value y={}", y); println!("Address of y={:p}", y); println!("Deref of y={}", *y);
O acima produz a seguinte saída:
Value y=5 Address of y=0x7fff6c0f131c Deref of y=5
Aqui, a variável y
toma emprestado o número pertencente à variável x
, enquanto x
ainda possui o valor. Chamamos y
de referência a x
. O empréstimo termina quando y
sai do escopo e, como y
não possui o valor, ele não é destruído. Para pegar um valor emprestado, pegue uma referência pelo operador &
. A formatação p, saída {:p}
como um local de memória apresentado como hexadecimal.
No código acima, "*" (ou seja, um asterisco) é um operador de referência que opera em uma variável de referência. Este operador de desreferenciação nos permite obter o valor armazenado no endereço de memória de um ponteiro.
Vejamos como uma função pode usar um valor sem se apropriar por empréstimo:
fn main() { let v = vec![10,20,30]; print_vector(&v); println!("{}", v[0]); // can access v here as references can't move the value } fn print_vector(x: &Vec<i32>) { println!("Inside print_vector function {:?}", x); }
Estamos passando uma referência ( &v
) (também conhecida como passagem por referência ) para a função print_vector
em vez de transferir a propriedade (ou seja, passagem por valor ). Como resultado, após chamar a função print_vector
na função main, podemos acessar v
.
Conforme declarado anteriormente, uma referência é um tipo de ponteiro, e um ponteiro pode ser pensado como uma seta apontando para um valor armazenado em outro lugar. Considere o exemplo abaixo:
let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, *y);
No código acima, criamos uma referência a um valor do tipo i32
e, em seguida, usamos o operador de referência para seguir a referência aos dados. A variável x
contém um valor do tipo i32
, 5
. Definimos y
igual a uma referência a x
.
É assim que a memória da pilha aparece:
Podemos afirmar que x
é igual a 5
. No entanto, se quisermos fazer uma afirmação sobre o valor em y
, devemos seguir a referência ao valor ao qual ela se refere usando *y
(daí a desreferência aqui). Depois de desreferenciar y
, temos acesso ao valor inteiro para o qual y
está apontando, que podemos comparar com 5
.
Se tentarmos escrever assert_eq!(5, y);
em vez disso, obteríamos este erro de compilação:
error[E0277]: can't compare `{integer}` with `&{integer}` --> src/main.rs:11:5 | 11 | assert_eq!(5, y); | ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
Por serem tipos diferentes, não é permitido comparar um número e uma referência a um número. Portanto, devemos usar o operador de desreferência para seguir a referência até o valor para o qual ela está apontando.
Assim como a variável, uma referência é imutável por padrão — ela pode se tornar mutável com mut
, mas somente se seu proprietário também for mutável:
let mut x = 5; let y = &mut x;
As referências imutáveis também são conhecidas como referências compartilhadas, enquanto as referências mutáveis também são conhecidas como referências exclusivas.
Considere o caso abaixo. Estamos concedendo acesso somente leitura às referências, pois estamos usando o operador &
em vez de &mut
. Mesmo se a fonte n
for mutável, ref_to_n
e another_ref_to_n
não forem, pois são empréstimos n somente leitura.
let mut n = 10; let ref_to_n = &n; let another_ref_to_n = &n;
O verificador de empréstimo fornecerá o erro abaixo:
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable --> src/main.rs:4:9 | 3 | let x = 5; | - help: consider changing this to be mutable: `mut x` 4 | let y = &mut x; | ^^^^^^ cannot borrow as mutable
Alguém poderia questionar por que um empréstimo nem sempre seria preferível a uma mudança . Se for esse o caso, por que Rust ainda tem semântica de movimento e por que não empresta por padrão? A razão é que nem sempre é possível pegar emprestado um valor em Rust. O empréstimo só é permitido em alguns casos.
O empréstimo tem seu próprio conjunto de regras, que o verificador de empréstimo aplica estritamente durante o tempo de compilação. Essas regras foram implementadas para evitar corridas de dados . Eles são os seguintes:
O escopo de uma referência deve estar contido no escopo do proprietário do valor. Caso contrário, a referência pode se referir a um valor liberado, resultando em um erro use-after-free .
let x; { let y = 0; x = &y; } println!("{}", x);
O programa acima tenta desreferenciar x
depois que o proprietário y
sai do escopo. A ferrugem evita esse erro de uso após a liberação.
Podemos ter tantas referências imutáveis (também conhecidas como referências compartilhadas) a um determinado dado por vez, mas apenas uma referência mutável (também conhecida como referência exclusiva) permitida por momento. Estas regras existem para eliminar corridas de dados . Quando duas referências apontam para o mesmo local de memória ao mesmo tempo, pelo menos uma delas está escrevendo e suas ações não estão sincronizadas, isso é conhecido como corrida de dados.
Podemos ter quantas referências imutáveis quisermos porque elas não alteram os dados. O empréstimo, por outro lado, nos restringe a apenas manter uma referência mutável ( &mut
) por vez para evitar a possibilidade de corridas de dados no tempo de compilação.
Vejamos este:
fn main() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2); }
O código acima que tenta criar duas referências mutáveis ( r1
e r2
) para s
falhará:
error[E0499]: cannot borrow `s` as mutable more than once at a time --> src/main.rs:6:14 | 5 | let r1 = &mut s; | ------ first mutable borrow occurs here 6 | let r2 = &mut s; | ^^^^^^ second mutable borrow occurs here 7 | 8 | println!("{}, {}", r1, r2); | -- first borrow later used here
Esperançosamente, isso esclarece os conceitos de propriedade e empréstimo. Também toquei brevemente no verificador de empréstimo, a espinha dorsal da propriedade e do empréstimo. Como mencionei no início, a propriedade é uma ideia nova que pode ser difícil de compreender no início, mesmo para desenvolvedores experientes, mas fica cada vez mais fácil quanto mais você trabalha nela. Este é apenas um resumo de como a segurança da memória é aplicada no Rust. Tentei tornar este post o mais fácil de entender possível, ao mesmo tempo em que forneci informações suficientes para entender os conceitos. Para obter mais detalhes sobre o recurso de propriedade do Rust, confira a documentação on-line.
Rust é uma ótima escolha quando o desempenho é importante e resolve pontos problemáticos que incomodam muitos outros idiomas, resultando em um avanço significativo com uma curva de aprendizado acentuada. Pelo sexto ano consecutivo, Rust tem sido a linguagem mais amada do Stack Overflow , o que significa que muitas pessoas que tiveram a chance de usá-la se apaixonaram por ela. A comunidade Rust continua a crescer.
De acordo com os resultados da Rust Survey 2021 : O ano de 2021 foi sem dúvida um dos mais importantes da história da Rust. Ele viu a fundação da Rust Foundation, a edição de 2021 e uma comunidade maior do que nunca. Rust parece estar em uma estrada forte enquanto nos dirigimos para o futuro.
Feliz aprendizado!