Rust 0x05 = Vetores, Ownership e Borrowing;

Gil Mendes
17 min readJan 30, 2017

--

Bem, este artigo vai ser um pouco mais puxado do que os anteriores, isto porque chegamos à verdadeira diferença entre o Rust e as outras linguagens. Vamos começar por um conceito básico, depois terminaremos em grande, falando do conceito de empréstimo (ou borrowing).

Vetores

Podemos olhar para os vetores como arrays que ajustam o seu tamanho de forma dinâmica. Eles são implementados como Vec<T>, que pode ser encontrado na biblioteca standard. O T significa que podemos ter vetores de qualquer tipo (mas iremos abordar este assunto assim que chegarmos ao genéricos). Os vetores alocam sempre os seus dados na heap e você pode cria-los usando a macro vec!:

let vector = vec![1, 2, 3, 4, 5];

Repare que ao contrario da macro do println!, aqui usamos parênteses retos ([]). O Rust também permite que nós os usemos em qualquer outra situação, mas é apenas por convenção.

Existe, também, uma variante do vec! para repetir um valor inicial:

let vector = vec![0; 10];

O código acima, aloca um vetor de dez (10) posições em que todas elas estão inicializadas com o valor zero (0).

Note, os vetores são guardados como arrays contíguos de T na heap. Isto significa que ao compilar tem que saber o tamanho de T no momento da compilação (por outras palavras, necessita de saber o numero de bytes que são necessários para armazenar T). Mas o tamanho de algumas coisas não pode ser obtido durante no momento da compilação. Para esses casos será necessário armazenar um apontador para essa “coisa”, felizmente, o tipo Box funciona na perfeição para essas situações.

Aceder a Elementos

Para aceder a um determinado elemento de um vetor é tão fácil quanto aceder a um que esteja armazenado num array, apenas precisamos do índice que queremos aceder:

let vector = vec![1, 2, 3, 4, 5];

println!("O quarto elemento é igual a {}", vector[3]);

Como pode notar, à semelhança dos arrays, os índices começam no valor 0, assim sendo a quarta posição é obtida através de v[3].

É importante que use uma variável do tipo usize, para aceder a um índice, caso contrário não irá funcionar. Tome atenção ao seguinte código:

let vector = vec![1, 2, 3, 4, 5];

let i: usize = 0;
let x: i32 = 0;

// funciona
vector[i];

// não funciona
vector[x];

Como pode ver o compilador irá mostrar um erro por não usarmos um usize para aceder à posição.

error[E0277]: the trait bound `std::vec::Vec<{integer}>: std::ops::Index<i32>` is not satisfied
--> src/main.rs:11:5
|
11 | vector[x];
| ^^^^^^^^^ the trait `std::ops::Index<i32>` is not implemented for `std::vec::Vec<{integer}>`
|
= note: the type `std::vec::Vec<{integer}>` cannot be indexed by `i32`

Fora dos Limites

Tal como em outras linguagens, é claro que não podemos aceder a posições além das existentes. Vamos executar o código abaixo e ver quais o resultados que vamos obter:

let vector = [1, 2, 3, 4, 5];

println!("O item de índice 10 é {}", vector[10]);

E o resultado é um panic, com a mensagem index out of bounds: the len is 5 but the index is 10.

warning: this expression will panic at run-time
--> src/main.rs:4:38
|
4 | println!("O item de índice 10 é {}", vector[10]);
| ^^^^^^^^^^ index out of bounds: the len is 5 but the index is 10

Finished debug [unoptimized + debuginfo] target(s) in 0.58 secs
Running `target/debug/hello_world`
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', src/main.rs:4

No output, pode ainda ver que o compilador consegue avisar que vai haver um problema durante a execução do programa.

Uma forma de tratar um error out-of-bounds sem que o programa entre em ‘pânico’ é utilizando o método get ou get_mut, que devolve um None quando é dado um índice invalido:

let v = vec![1, 2, 3];
match v.get(10) {
Some(x) => println!("O item no índice 10 é {}", x),
None => println!("O índice é inválido.")
}

Iteração

Uma vez que temos um vetor, podemos querer iterá-lo usando um ciclo for. Existem três versões para o fazer:

let mut v = vec![1, 2, 3, 4, 5];

for i in &v {
println!("Referência para {}", i);
}

for i in &mut v {
println!("Referência mutável para {}", i);
}

for i in v {
println!("Toma posse do vetor e dos seus elementos {}", i);
}

Note que não é possível voltar a usar o vetor novamente após o iterar através de tomada de posse, mas é possível iterar o vetor múltiplas vezes caso este seja iterado por referência. Por exemplo, o código abaixo não irá compilar:

let mut v = vec![1, 2, 3, 4, 5];

for i in v {
println!("Toma posse do vetor e dos seus elementos {}", i);
}

for i in v {
println!("Toma posse do vetor e dos seus elementos {}", i);
}

Já este funciona na perfeição:

let mut v = vec![1, 2, 3, 4, 5];

for i in &v {
println!("Referência para {}", i);
}

for i in &v {
println!("Referência para {}", i);
}

Os vetores têm muitos outros métodos poderosos, estes podem ser vistos na documentação da API.

Ownership

Esta é a primeira de três partes que aborda o sistema de posse/propriedade (ownership) do Rust. Esta é uma das funcionalidades mais distintivas e atraentes do Rust, é aqui que os novos desenvolvedores encontram mais dificuldades no processo de aprendizagem e é um dos conceitos mais importantes para se familiarizar com a linguagem. O ownership é a forma como o Rust alcança o seu grande objetivo, segurança de memória. Assim, iremos falar de ownership, borrowing (empréstimo) e lifetimes (tempos de vida).

Objetivo

Antes de entramos em detalhes, duas notas importantes sobre o sistema de ownership.

O Rust tem o seu foco na segurança e na velocidade. A linguagem consegue isso através de muitas “zero-cost abstractions” (ou, abstrações de custo zero), em outras palavras isto significa que o Rust implementa as suas abstrações com o menor custo possível. O sistema de ownership é um excelente exemplo disso, todas as analises são executadas durante a compilação. Assim sendo não existem custos de performance durante a execução.

Contudo, este sistema tem um pequeno custo, a curva de aprendizagem. Praticamente todos os novos desenvolvedores Rust passam pela experiência, já conhecida no mundo Rust como, “luta com a verificação de empréstimos”, quando o compilador do Rust recusa a compilar um programa que o autor pensa ser válido. Isto acontece frequentemente, devido ao modelo mental que o programador tem de como o sistema de ownership deveria funcionar e esse modelo não correspondem de como efetivamente o Rust o implementa. Por isso também irá passar por um mau bocado, nos inícios. Contudo, os programadores mais experientes, relatam que eles próprios passaram por estas mesmas dificuldade, mas as batalhas com o sistema de verificação de empréstimos tornaram-se cada vez menos frequentes.

Com estas notas em mente, podemos continuar o nosso processo de aprendizagem do sistema de ownership.

Um Pequeno Passo

As variáveis em Rust possuem uma propriedade, elas tem um “dono” a qual elas estão ligadas. Isto significa que quando uma variável sai fora do scope o Rust irá libertar o recurso. Por exemplo:

fn example() {
let v = vec![1, 2, 3];
}

Quando v entra no scope, um novo vetor é criado na stack e este aloca espaço na heap para armazenar os seus elementos. Quando v vai fora do scope, no fim da função example(), o Rust irá apagar todas as coisas relacionadas com o vetor, até mesmo a memória alocada na heap. Isto acontece sempre no fim do scope.

Um Pouco Mais de Semântica

Existe outros mecanismos a atuar por detrás da execução do nosso código. O Rust assegura que apenas existe um único bind (ligação) para cada recurso. Por exemplo, se criar-mos um vetor e atribuír-mos esse vetor a outra variável ficamos impossibilitados de usar a primeira:

let v = vec![1, 2, 3];

let v2 = v;

Assim, se depois tentar-mos usar de novo a primeira variável (v) iremos obter um erro:

let v = vec![1, 2, 3];

let v2 = v;

println!("O valor do elemento 0 é {}", v[0]);

Que irá assemelhar-se com isto:

error[E0382]: use of moved value: `v`
--> src/main.rs:6:44
|
4 | let v2 = v;
| -- value moved here
5 |
6 | println!("O valor do elemento 0 é {}", v[0]);
| ^ value used here after move
|
= note: move occurs because `v` has type `std::vec::Vec<i32>`, which does not implement the `Copy` trait

Algo semelhante acontece quando definimos uma função que toma o ownership de uma variável passada por argumento:

fn takeOwnership(v: Vec<i32>) {
// Não importa o que esta função faz!
}

fn main() {
let v = vec![1, 2, 3];

takeOwnership(v);

println!("O valor do elemento 0 é {}", v[0]);
}

O mesmo erro irá ser mostrado (use of moved value). Quando transferimos o ownership para outra coisa, nós dizemos que, a coisa a que nos referimos, foi movida.

Os Detalhes

A razão pela qual o Rust não permite usar uma variável depois desta ser movida é subtile, mas importante.

Considere o código abaixo:

let x = 10;

Aqui, o Rust irá alocar um inteiro i32 na stack e irá copias os bits que representam o valor 10 para a localização de memória alocada anteriormente.

Agora, considere o seguinte fragmento de código:

let v = vec![1, 2, 3];

let mut v2 = v;

A primeira linha aloca memória na stack para armazenar o objeto v, como como no código acima. Mas em adição a isto, também será alocada memória na heap para armazenar, efetivamente, os dados ([1, 2, 3]). O Rust copia o endereço de da memória alocada na heap para um apontador interno, que faz parte do objeto do vetor que está colocado na stack (vamos chama-lo de data pointer).

Como pode notar, agora existem duas regiões de memória distintas que representam o vetor (na heap e na stack). Assim sendo, não existe um bloco contíguo de memória, mas sim duas zonas completamente separadas. Estas duas zonas de memória devem estar em concordância durante toda a sua existência, com isto quero dizer que o seu tamanho, capacidade, etc, devem corresponder.

Assim, quando movermos o vetor v para uma variável v2, o Rust não faz uma copia integral das duas zonas de memória, mas sim apenas uma copia da memória alocada na stack. Isto significa que, existem dois apontadores para a mesma zona de memória da heap, que contem o conteúdo do vetor. Isto viola a garantia de segurança de memória do Rust ao introduzir um data race, pois existe a possibilidade de aceder a v e a v2 ao mesmo tempo.

Por exemplo, se nós truncarmos o nosso vetor a apenas dois elementos, através de v2:

v2.truncate(2);

e v continuar acessível, este irá estar a aceder a um vetor inválido, pois este não terá conhecimento de que o vetor foi truncado. Isto acontece, porque todas as variáveis de controlo estão localizadas na stack e assim sendo não são partilhadas pelas duas variáveis.

Note que o compilador pode, por vezes, impedir que esta copia de dados na stack aconteça e assim este mecanismo não é tão ineficiente quando parece.

Copy

Até agora assumimos que quando o ownership é transferido para outra variável, não é possível usar a variável original. Contudo, existe um trait que altera esse comportamento, esse trait chama-se Copy. Nós ainda não falamos sobre trais, por isso, por agora, pode olhar para eles como uma espécie de anotação que adiciona novos comportamentos a um tipo. Por exemplo:

let v = 1;

let v2 = v;

println!("O valor de v é {}", v);

Neste caso, v é um i32, em que este implementa o trait Copy. Isto significa que, tal como o que acontece quando movemos uma variável, o valor de v é copiado para v2. Mas, ao contrário de mover, aqui nós conseguimos continuar a aceder a variável v, mesmo depois desta operação. Isto é porque o tipo i32 não armazena dados em qualquer outro lugar que não na stack.

Note que todos os tipos primitivos implementam o Copy.

Mais do Que Ownership

Claro que se tivéssemos que lidar com o ownership em todas as funções que escrevêssemos, isto de repente ficava tedioso. Por exemplo:

fn example(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) {
// Faz alguma coisa!

// devolve o ownership e o resultado da função
(v1, v2, 42)
}

fn main() {
let v1 = vec![1, 2, 3];
let v2 = vec![1, 2, 3];

let (v1, v2, answer) = example(v1, v2);
}

Horrendo 🤢!

Felizmente o Rust oferece uma funcionalidade que nos ajuda a resolver este problema. O borrowing.

Borrowing

Terminamos a parte do ownership com um código horrível, isso porque ele não tirava partido do mecanismo de borrowing. O primeiro passo é:

fn example(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 {
// Faz alguma coisa!

// devolve o resultado da operação
42
}

fn main() {
let v1 = vec![1, 2, 3];
let v2 = vec![1, 2, 3];

let answer = example(&v1, &v2);

// v1 e v2 podem ser usados aqui
}

Muito melhor, mas vejamos um exemplo mais realista, em que é feita a soma de dois vetores:

fn main() {
/// Soma os elementos de um vetor de i32
///
/// Não se precisa de preocupar com o funcionamento do `fold`, o importante aqui é que estamos
/// a emprestar uma referência imutavel
fn sum_vec(v: &Vec<i32>) -> i32 {
v.iter().fold(0, |a, &b| a + b)
}

/// Calcula a soma de dois vetores
///
/// Este tipo de borrowing não permite mutação
fn sum_two_vetors(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 {
// faz a soma individual dos dois vetores
let sum1 = sum_vec(v1);
let sum2 = sum_vec(v2);

// devolve a soma dos dois vetores
sum1 + sum2
}

let v1 = vec![1, 2, 3];
let v2 = vec![10, 12, 14];

let total = sum_two_vetors(&v1, &v2);
println!("Total: {}", total);
}

Em vez de tomarmos o Vec<i32> como argumento, nos o tomamos como referências (&Vec<i32>). Assim, invés de passarmos os dois vetores diretamente, passados as referências dos mesmos (&v1 e &v2). As referências são do tipo &T, e em vez de tomarem o ownership de uma variável, ela empresta (borrows) o ownership. Quando usamos uma referência, assim que esta vai fora do scope o recurso não é desalocado. Isto quer dizer que após a chamada da função sum_two_vetors() podemos voltar a usar as variáveis originais.

As referência, à semelhança das variáveis, são imutáveis. Isso quer dizer que não é possível editar o vetor dentro das funções:

fn main() {
fn example(v: &Vec<i32>) {
v.push(50);
}

let v = vec![1, 2, 3];
example(&v);
}

Caso o faça, irá-se deparar com o seguinte error:

error: cannot borrow immutable borrowed content `*v` as mutable
--> src/main.rs:3:9
|
3 | v.push(50);
| ^

Colocar um valor dentro do vetor, obriga-o a mutá-lo por isso não nos é permitido.

Referências Mutáveis

Existe um segundo tipo de referências &mut T. As referências mutáveis permitem alterar os recursos partilhados. Por exemplo:

let mut x = 10;
{
let y = &mut x;
*y += 1;
}
println!("O valor de x é {}", x);

O código acima irá imprimir: O valor de x é 11. Nós criamos uma referência mutável (y) de x, e adiciona-mos um ao valor por ela apontada.

Note que o x tem que ser mutável para que isto seja permitido. Não é possível criar uma referência mutável para um valor imutável.

Também pode notar que foi adicionado um asterisco (*) antes de y, e assim torna-lo *y, isto porque y é uma referência mutável. Você também terá que usar um asterisco para aceder ao valor da referência.

Tivemos que adicionar uma scope extra para não colocar a referência ao mesmo nível que o valor original, por isso iria resultar num error:

error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
--> src/main.rs:7:35
|
4 | let y = &mut x;
| - mutable borrow occurs here
...
7 | println!("O valor de x é {}", x);
| ^ immutable borrow occurs here
8 | }
| - mutable borrow ends here

Como se pode ver, existem regras.

As Regras

Em primeiro, nenhum borrow deve durar um scope maior do que o do dono. Segundo, deve ter um borrow de um destes dois tipos, mas nunca os dois em simultâneo:

  • uma ou mais referências (&T) para um recurso;
  • Exactamente uma referência mutável (&mut T).

Isto pode parecer muito similar, mas não são as mesmas coisas, talvez a definição de data race possa esclarecer:

Existe uma data race quando dois ou mais apontadores possuem acesso a mesma localização de memória ao mesmo tempo, em que apenas um está a escrever e as operações não são sincronizadas.

Assim sendo, podemos ter quantas referências nós quisermos sem que haja qualquer problema, visto que nenhuma delas está a escrever. Contudo, apenas podemos ter uma referência mutável.

Problemas que o Borrowing Previne

Mas porque é que estas regras tão restritas existem? Bem, estas regras permitem evitar muitos problemas de data races. Aqui ficam alguns exemplos:

Invalidação do Iterador

Um dos exemplos é a “Invalidação do Iterador”, isto acontece quando se tenta alterar uma coleção que se está a iterar. O sistema de verificação do Rust impede que este tipo de situações aconteçam:

let mut v = vec![1, 2, 3];

for i in &v {
println!("{}", i);
}

Neste caso não existe problemas, o resultado será a impressão dos três elementos do vetor. Á medida que iteramos o vetor, nós apenas estamos a dar as referências dos elementos, assim sendo, v é imutável, o que significa que o vetor não pode ser alterado durante o processo de iteração:

let mut v = vec![1, 2, 3];

for i in &v {
println!("{}", i);
v.push(45);
}

O resultado é o erro que pode ser visto abaixo:

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:6:9
|
4 | for i in &v {
| - immutable borrow occurs here
5 | println!("{}", i);
6 | v.push(45);
| ^ mutable borrow occurs here
7 | }
| - immutable borrow ends here

Não é possível modificar o vetor v porque este encontra-se emprestado para o loop.

Use After Free

As referências não devem existir mais tempo do que o próprio recurso a qual estas se referem. O Rust faz uma verificação aos scopes durante a compilação para assegurar que isso acontece.

Se o Rust não fizesse este tipo de verificações, poderíamos acidentalmente usar uma referência invalida. Por exemplo:

fn main() {
let y: &i32;
{
let x = 5;
y = &x;
}

println!("{}", y);
}

Neste caso obtemos o seguinte erro:

error: `x` does not live long enough
--> src/main.rs:6:5
|
5 | y = &x;
| - borrow occurs here
6 | }
| ^ `x` dropped here while still borrowed
...
9 | }
| - borrowed value needs to live until here

error: aborting due to previous error

Por outras palavras, y apenas é valido no scope onde x existe. Assim que x deixa de existir, todas as suas referências tornam-se invalidas. E isto responde à mensagem de erro acima (“does not live long enough”).

O mesmo error ocorre quando a referência é declarada antes do que a variável a qual se refere. Isto ocorre porque os recursos no mesmo scope são libertados na ordem inversa à que são declarados:

fn main() {
let y: &i32;
let x = 5;
y = &x;

println!("{}", y)
}

E o erro:

error: `x` does not live long enough
--> src/main.rs:7:1
|
4 | y = &x;
| - borrow occurs here
...
7 | }
| ^ `x` dropped here while still borrowed
|
= note: values in a scope are dropped in the opposite order they are created

No exemplo acima, y é declaro antes de x, isso significa que y vive mais tempo do que x, o que não é permitido.

Se quiser pode ver um outro artigo que publiquei à alguns meses atrás onde pode ler mais sobre borrowing. Mas apenas como leitura complementar, não é obrigatório.

Lifetimes

Por fim e porque este artigo já vai comprido e precisa de tempo para ser assimilado, vamos falar de tempos de vida (ou, lifetimes).

Emprestar uma referência para um recurso que alguém possui pode ser complicado. Por exemplo, imagine este conjunto de operações:

  1. Eu adquiro um handler para um qualquer tipo de recurso;
  2. Eu empresto-lhe uma referência para o recurso;
  3. Eu decido que terminei com o recurso e liberto-o, enquanto o handler continua com a referência;
  4. O handler decide usar o recurso.

Yap! A referência agora aponta para um recurso inválido. A isto é chamado um dangling pointer ou então “use after free”.

let y: &i32;        // introduz a referência y
{
let x = 5; // introduz um valor de scope x
y = &x; // armazena a referência de x a y
} // x sai fora do scope

println!("{}", y) // y ainda aponta para x

Para corrigir isto, precisamos de a quarta etapa nunca aconteça, depois da terceira. No exemplo anterior o compilador é capaz de reportar o problema uma vez que consegue ver os tempos de vida das varias variáveis na função.

Mas quando temos uma função que recebe argumentos por referência, a situação fica mais complexa. Por exemplo, considere o seguinte exemplo:

fn skip_prefix(line: &str, prefix: &str) -> &str {
// ...
}

let line = "lang:en=Hello World!";
let lang = "en";

let v;
{
let p = format!("lang:{}=", lang); // -+ p entra no scope
v = skip_prefix(line, p.as_str()); // |
} // -+ p sai fora do scope
println!("{}", v);

Acima, temos a função skip_prefix que recebe duas referências para uma string e devolve uma outra referência também para uma string. Nós chamados a função passando as referências de line e p, em que estas duas variáveis têm tempos de vida diferentes. Agora a segurança da linha do println! depende da referência que é retornada pela função skip_prefix, se é a uma referência para a ainda viva line ou a já descartada p.

Por causa da ambiguidade acima referida, o Rust recusa-se a compilar o código. Para que a compilação aconteça é necessário dizer ao compilar mais sobre o tempo de vida das referências. Para isso, indicamos explicitamente o tempo de vida das mesmas:

fn skip_prefix<'a, 'b>(line: &'a str, prefix: &'b str) -> &'a str {
// ...
}

Vamos lá examinar as alterações feitas sem ir muito a funda na sintaxe, a essa iremos lá mais daqui a pouco. A primeira alteração foi a adição de <’a, ‘b> depois do nome da função. Isto introduz dois parâmetros de tempo de vida, ‘a e ‘b. Depois, na assinatura da função, foi adicionado a cada referência os parâmetros criados anteriormente, após o &. Isto informa o compilar como os tempos de vida entre as diferentes referências estão relacionados.

Agora, o compilador consegue deduzir que o valor de retorno da função, possui o mesmo tempo de vida que o parâmetro line. O que torna o uso da referência v seguro, mesmo depois depois de p sair fora do scope.

Sintaxe

O a lê-se “o tempo de vida a”. Tecnicamente, todas as referências tem um tempo de vida associado a si, mas o compilador permite que os omitamos em casos mais simples. Antes de avançarmos em maior detalhe sobre a sintaxe, considere o seguinte código:

fn example<'a>(...)

já falamos anteriormente sobre a sintaxe das funções, mas não fizemos referência aos <> depois do nome da função. As funções podem receber parâmetros genéricos ente estes dois sinais (<>), os tempos de vida é um desses tipos. Outros serão falados assim que abordamos os genéricos, mais à frente nesta série.

No caso acima a nossa função apenas possui uma única declaração de um tempo de vida. Caso a função possuísse dois iria-se assemelhar à seguinte linha:

fn example<'a, 'b>(...)

No que toca à associação dos tempos de vida as referências passadas nos parâmetros, fica algo como:

fn example<'a>(x: &'a i32)

No caso da referência ser mutável, ficaria:

fn example<'a>(x: &'a mut i32)

Note que os tempos de vida podem ser usamos em outras estruturas, mas iremos lá chegar assim que falar-mos delas.

Múltiplos Tempos de Vida

Durante as nossas aventuras de programação, podemo-nos deparar com a necessidade de aplicar tempos de vida em múltiplas referências, tal como pode ver no código a seguir:

fn bigger<'a>(x: &'a str, y: &'a str) -> &'a str {

Ok, mas em grande parte dos casos o que iremos ter são situações em que necessitamos de usar múltiplos tempos de vida, quando isso acontece a nossa função irá assemelhar-se com o código abaixo:

fn bigger<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {

Neste exemplo, x e y têm diferentes scopes, mas o retorno tem o mesmo tempo de vida que x.

‘static

Existe um tempo de vida chamado “static”, este é um tempo de vida especial. Isto sinaliza que alguma coisa irá ter o tempo de vida igual à execução do programa. Grande parte dos programadores Rust conhecem pela primeira vez o ‘static quando estas a trabalhar com strings:

let name: &'static str = "Alec Sadler";

As strings já possuem o tipo &’static str porque estas são uma referência para o segmento de dados (data segment) do binários final. Outro exemplo são as constantes:

static VERSION: i32 = 6;
let x: &'static i32 = &FOO;

Isto faz com que o i32 passe a ser armazenado no segmento de dados do binários, e x é uma referência para ele.

Bem, acho que é suficiente por hoje. Já discutimos imensos conceitos e alguns deles, confesso, que são confusos e que podem demorar um pouco a interiorizar, mas à medida que se vai usando o nosso cérebro vai-se habitando a eles.

Por agora é tudo, fiquem em paz e boas codificações! 🙂

--

--