paint-brush
Propriedade e empréstimo de Rust reforçam a segurança da memóriapor@senthilnayagan
2,176 leituras
2,176 leituras

Propriedade e empréstimo de Rust reforçam a segurança da memória

por Senthil Nayagan31m2022/07/15
Read on Terminal Reader
Read this story w/o Javascript

Muito longo; Para ler

A propriedade e o empréstimo de 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. Se um programa não é realmente seguro para a memória, há poucas garantias sobre sua funcionalidade.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Propriedade e empréstimo de Rust reforçam a segurança da memória
Senthil Nayagan HackerNoon profile picture

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.

O que é segurança de memória?

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.


Desenhado por Freepik.


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


Referência oscilante

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:


  • Leituras fora dos limites
  • Gravações fora dos limites
  • Use-After-Free


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.

O que é um vazamento de memória?

É importante entender o que é um vazamento de memória e quais são suas consequências.


Desenhado por Freepik.


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.

Insegurança de memória vs. vazamentos de memória

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.


Figura 1: Insegurança de memória versus vazamentos de memória.


Vários tipos de memórias e como elas funcionam

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.

Memória de pilha e como ela funciona

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.


Memória heap e como ela funciona

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.

Como várias outras linguagens de programação garantem a segurança da memória?

Quando se trata de gerenciamento de memória, particularmente memória heap, preferimos que nossas linguagens de programação tenham as seguintes características:

  • Preferimos liberar memória o mais rápido possível quando ela não for mais necessária, sem sobrecarga de tempo de execução.
  • Nunca devemos manter uma referência a um dado que foi liberado (também conhecido como referência pendente). Caso contrário, falhas e problemas de segurança podem ocorrer.


A segurança da memória é garantida de diferentes maneiras pelas linguagens de programação por meio de:

  • Desalocação explícita de memória (adotada por C, C++)
  • Desalocação automática ou implícita de memória (adotada por Java, Python e C#)
  • Gerenciamento de memória baseado em região
  • Sistemas de tipo linear ou único


Tanto o gerenciamento de memória baseado em região quanto os sistemas de tipo linear estão além do escopo deste post.

Desalocação de memória manual ou explícita

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:

  • referência pendente
  • Vazamento de memória


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:

  • Tenha maior controle sobre o gerenciamento de memória.
  • Menos segurança como resultado de referências pendentes e vazamentos de memória.
  • Resulta em um tempo de desenvolvimento mais longo.

Desalocação de memória automática ou implícita

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:

  • Elimina a necessidade de desenvolvedores liberarem memória manualmente.
  • Fornece segurança de memória eficiente sem referências pendentes ou vazamentos de memória.
  • Código mais simples e direto.
  • Ciclo de desenvolvimento mais rápido.
  • Tenha menos controle sobre o gerenciamento de memória.
  • Causa latência, pois consome memória e ciclos de CPU.

Como o Rust garante a segurança da memória?

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.


Figura 2: Rust tem melhor controle sobre o gerenciamento de memória e fornece maior segurança sem problemas de memória.


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.


Gerenciamento explícito de memória versus gerenciamento implícito de memória versus modelo de propriedade de Rust.


Leva algum tempo para se acostumar com a propriedade porque é um conceito novo para muitos programadores, como eu.

Propriedade

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.


Figura 3: A vinculação de variáveis mostra o proprietário e seu valor/recurso.


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.

Verificador de Empréstimo

O Rust implementa a propriedade por meio do verificador de empréstimo , um analisador estático . O verificador de empréstimo é um componente do compilador Rust que rastreia onde os dados são usados em todo o programa e, seguindo as regras de propriedade, é capaz de determinar onde os dados precisam ser liberados. Além disso, o verificador de empréstimo garante que a memória desalocada nunca possa ser acessada em tempo de execução. Ele ainda elimina a possibilidade de corridas de dados causadas por mutação simultânea (modificação).

Regras de propriedade

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:

  • No Rust, cada valor possui uma variável chamada de proprietário.
  • Só pode haver um proprietário por vez.
  • Quando o proprietário sair do escopo, o valor será descartado.


Os seguintes erros de memória são protegidos por essas regras de propriedade de verificação em tempo de compilação:

  • Referências oscilantes: é onde uma referência aponta para um endereço de memória que não contém mais os dados aos quais o ponteiro estava se referindo; este ponteiro aponta para dados nulos ou aleatórios.
  • Use after libera: É aqui que a memória é acessada depois de liberada, o que pode travar. Esse local de memória também pode ser usado por hackers para executar código.
  • Liberações duplas: é aqui que a memória alocada é liberada e depois liberada novamente. Isso pode fazer com que o programa falhe, expondo potencialmente informações confidenciais. Isso também permite que um hacker execute qualquer código que escolher.
  • Falhas de segmentação: é onde o programa tenta acessar a memória que não tem permissão para acessar.
  • Buffer overrun: É onde o volume de dados excede a capacidade de armazenamento do buffer de memória, fazendo com que o programa trave.


Antes de entrar nos detalhes de cada regra de propriedade, é importante entender as distinções entre copy , move e clone .

cópia de

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:


Figura 4: Ambos x e y têm seus próprios dados.

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.

jogada

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 .

  • Pointer - aponta para a memória que contém o conteúdo da string.
  • Comprimento - é quanta memória, em bytes, o conteúdo da String está usando no momento.
  • Capacidade - é a quantidade total de memória, em bytes, que a String recebeu do alocador.


Em outras palavras, os metadados são mantidos na pilha enquanto os dados reais são mantidos na pilha.


Figura 5: A pilha contém os metadados enquanto a pilha contém o conteúdo real.


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:


Figura 6: A variável s2 obtém uma cópia do ponteiro, comprimento e capacidade de s1.


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.


Figura 7: Se Rust copiou os dados do heap, outra possibilidade para o que let s2 = s1 pode fazer é a replicação de dados. No entanto, o Rust não copia por padrão.


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:


Figura 8: 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!

clone

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:


Figura 9: ao usar o método clone, os dados do heap são copiados para s2.


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.

Regra de propriedade 1

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;

Regra de propriedade 2

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 :-)


Desenhado por Freepik.


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.


Figura 10: O compilador fez cópias de x para y e z.


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 ligação variável é semelhante ao de outras linguagens de programação. Para ilustrar as regras de propriedade, precisamos de um tipo de dado complexo.


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.


Regra de Propriedade 3

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:


Figura 11: Representação da memória após a eliminação de s1.

Como a propriedade se move

Existem três maneiras de transferir a propriedade de uma variável para outra em um programa Rust:

  1. Atribuir o valor de uma variável a outra variável (já foi discutido).
  2. Passando valor para uma função.
  3. Retornando de uma função.

Passando valor para uma função

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.

Retornando de uma funçã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.

Desenhado por Freepik.


É 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

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.


Desenhado por Freepik.


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 .

Seguindo o ponteiro para o valor com o operador de desreferência

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:


Representação da memória da pilha.


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.

As referências são imutáveis por padrão

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


Regras de Empréstimo

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:

  1. O escopo do mutuário não pode durar mais que o escopo do proprietário original.
  2. Pode haver várias referências imutáveis, mas apenas uma referência mutável.
  3. Os proprietários podem ter referências imutáveis ou mutáveis, mas não ambas ao mesmo tempo.
  4. Todas as referências devem ser válidas (não podem ser nulas).

A referência não deve sobreviver ao proprietário

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.

Muitas referências imutáveis, mas apenas uma referência mutável permitida

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


Observações finais

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!


Desenhado por Freepik.