Alura > Cursos de Programação > Cursos de .NET > Conteúdos de .NET > Primeiras aulas do curso .NET: gerenciamento de memória para otimização de performance

.NET: gerenciamento de memória para otimização de performance

Entendendo referências - Apresentação

Olá, tudo bem? Eu sou o Daniel Artine e serei seu instrutor neste curso de Gerenciamento de Memória com .NET da Alura!

Audiodescrição: Daniel se descreve como um homem de barba e cabelos pretos e olhos castanho-escuros. Ele veste uma camisa branca e está sentado em uma cadeira preta em frente a uma parede clara iluminada em azul.

O que vamos aprender?

Vamos entender o que abordaremos neste curso e quais tópicos serão discutidos. Para começar, falaremos sobre algumas questões importantes, como o funcionamento de classes, o que são classes de fato e como elas são armazenadas na memória do computador.

Iremos explorar também como listas e LinkedList funcionam, o que é uma LinkedList, as diferenças entre elas, e os benefícios e desvantagens de cada uma. Analisaremos vários cenários e questionaremos: métodos sempre possuem retorno ou nem sempre? Como lidamos com isso? O que é struct? Quais são os benefícios de utilizá-la? E o que é record? O que ganhamos ao usá-las e por que existem essas possibilidades?

No final, vamos entender o que é record struct, basicamente uma combinação entre record e struct. Ao longo do curso, analisaremos diversos tópicos e cenários para entender por que poderíamos ou não utilizar determinadas ferramentas que o .NET oferece.

Esperamos que você aproveite o curso. Te encontramos na primeira aula!

Entendendo referências - Atribuição de valores

Qual será o ponto de partida do nosso curso? A ideia é, no caso em que trabalhamos agora, lidar com um projeto que já foi inicializado, mas em um contexto onde lidaremos com classes específicas, como se estivéssemos desenvolvendo uma biblioteca para um projeto de escopo maior.

Atribuição de valores

Qual é o projeto em que estamos trabalhando nesse momento? Temos, basicamente, três classes: a classe Coordenada.cs, a classe Usuario.cs, e também a classe UsuarioDto.cs.

Caso já tenha feito os cursos de API com .NET da Alura, que não são necessariamente pré-requisitos para este curso, você vai entender o conceito do DTO do usuário. Porém, para este curso, não vamos aplicar esses conceitos específicos, então não precisa se preocupar caso não tenha feito os cursos. É interessante fazer para expandir o seu conhecimento.

Temos um projeto com essas três classes, mais a classe Program.cs que está vazia, e a ideia do projeto é, a partir do que já temos, desenvolver eventuais modificações para que ele tenha melhor utilização de memória e que possamos entender, por meio deste projeto, como torná-lo mais bem voltado para o consumo de memória e como lidamos com a memória no escopo desse projeto que futuramente seriam as libs, um projeto de escopo maior.

Um ponto de partida foi dado. Vamos começar, então, a sessão da classe Usuario para entendermos algumas coisas. Por exemplo: vamos analisar com mais detalhes o que temos nessa classe. Temos um construtor, a public class Usuario, onde temos um nome, um email e uma lista de strings de telefone.

Temos também a definição das propriedades. Primeiro, temos um Id que não definimos no construtor, mas temos as outras três propriedades de Nome, Email e Telefones que estão sendo definidas.

Inicializando a classe Program.cs

O que queremos fazer agora, antes de começar a entender um pouco da teoria? Vamos inicializar a classe Program.cs, criando um Usuario usuario igual a new Usuario(). Feito isso, vamos inicializar esse usuário, cujo nome será "Daniel", o e-mail será "daniel@email.com". Por fim, temos uma lista de telefones, então digitamos new List<string>(), que será inicializada com os valores da lista eventualmente, por exemplo, "12345678".

Para termos uma boa utilização de espaço, vamos quebrar as linhas.

Program.cs:

using UsuarioLib;

Usuario usuario = 
    new Usuario(
        "Daniel", 
        "daniel@email.com",
        new List<string>() {"12345678"});

Outro ponto que vamos abordar agora é a questão de como cada propriedade da classe Usuario é armazenada na memória, a nível de, quando instanciamos essa classe em Program.cs, o que acontece efetivamente? Vamos desenvolver um cenário para que possamos partir mais para a teoria de como o gerenciamento de memória do .NET funciona.

No momento em que instanciarmos um Usuario(), algo útil que precisamos nos preocupar é o seguinte: se temos alguma lista de telefones, isto é, se a pessoa usuária tem algum telefone na lista com oito dígitos, o que vamos querer fazer será converter para nove dígitos, para ficarmos no padrão atual, ou seja, adicionar um nove na frente.

Criando o método PadronizaTelefones()

Então, vamos criar no arquivo Usuario.cs um método chamado public void PadronizaTelefones(). A ideia será que, para cada telefone da lista, vamos adicionar, caso ele tenha oito dígitos, um 9 na frente.

Podemos fazer isso com um link, então digitamos Telefones.Select(), indicando que para cada telefone que tivermos na lista, será verificado se o tamanho dele é igual a 8. Se for, vamos falar que o telefone será igual a "9" mais telefone. Se não for igual a 8, o telefone será ele mesmo.

Usuario.cs:

public void PadronizaTelefones()
{
    Telefones.Select(telefone =>
        telefone.Length == 8 ?
        telefone = "9" + telefone :
        telefone
    );
}

Utilizamos o link para indicar que, para cada telefone da lista de telefones, se o tamanho desse telefone (telefone.Lenght) em específico for 8, deve ser colocado o 9 na frente; se não, deve ser mantido o valor atual.

Criando o método ExibeTelefones()

Além desse método, teremos um public void ExibeTelefones(). No escopo do método, faremos basicamente um Telefones.ForEach(), e para cada telefone que tivermos da pessoa usuária, daremos um Console.WriteLine() para telefone.

Usuario.cs:

public void ExibeTelefones()
{
    Telefones.ForEach(telefone => Console.WriteLine(telefone));
}

Chamando os métodos criados

De volta à classe Program.cs, vamos fazer um teste para entender o que está acontecendo efetivamente. Primeiro, ao final do código, vamos chamar usuario.ExibeTelefones(), e em seguida, vamos chamar usuario.PadronizaTelefones(). Por fim, vamos fazer usuario.ExibeTelefones() novamente.

Program.cs:

usuario.ExibeTelefones();

usuario.PadronizaTelefones();

usuario.ExibeTelefones();

O que esperamos por padrão que aconteça? No momento em que chamarmos ExibeTelefones() pela primeira vez, vamos exibir o telefone cadastrado, que é "12345678". No momento em que chamamos PadronizaTelefones(), efetuamos a padronização, e por fim, ao exibir novamente, esperamos que esteja no padrão "912345678".

O que você imagina que vai acontecer? A depender do que você estiver pensando, já teremos uma quebra de expectativa boa para justificar tudo o que devemos abordar nesse conceito de gerenciamento de memória.

Ao executar, é exibido duas vezes "12345678". Ou seja, ele não alterou o telefone. Por quê?

Talvez você já esteja pensando em argumentos, como "strings são imutáveis", ou "foi feito algo errado". Vamos analisar o método que fizemos de PadronizaTelefones() com o Select(). O Select() projeta cada elemento da sequência em uma nova forma. Então, se avaliarmos bem o que acontece, para cada telefone, fazemos essa devida validação.

Mas por que não alterou? Porque o retorno do Select() é um IEnumerable<string>, pois Telefones é uma lista de strings. Então, o que deveríamos fazer, de certa forma, seria falar que a lista de Telefones, a partir desse momento, será igual ao Select() que fizemos seguido de .ToList().

Usuario.cs:

public void PadronizaTelefones()
{
    Telefones = Telefones.Select(telefone =>
        telefone.Length == 8 ?
        telefone = "9" + telefone :
        telefone
    ).ToList();
}

A partir desse momento, se reexecutarmos a aplicação, teremos a exibição antiga e a nova no terminal.

Conclusão

Você pode pensar que esse conteúdo é muito simples e se perguntar: qual é a utilidade prática disso? Isso que acabou de acontecer é uma situação clássica onde, caso já tenhamos o conhecimento de como toda a memória é armazenada, como os conceitos são armazenados, as classes, os tipos primitivos e afins são armazenados no .NET, não correríamos o risco de ter, por exemplo, esse bug em um ambiente produtivo.

Já imaginou isso sendo levado à frente e gerando esse problema? Seria uma dor de cabeça. Por isso é importante, além de começar a avançar nesse projeto com diversas outras questões de gerenciamento de memória, como falamos anteriormente na apresentação do curso, também entender o que devemos fazer para armazenar as nossas classes, os nossos objetos e tipos primitivos.

Vamos entender isso melhor no próximo vídeo. Te encontramos lá e até mais!

Entendendo referências - Stack e Heap

Vamos começar entendendo o que efetivamente aconteceu no vídeo anterior. Entenderemos por que a lista não foi alterada, por que depois ela foi e como aquilo tudo está sendo armazenado na memória.

Stack e Heap

O que precisamos entender é que existem três principais estruturas que armazenam os dados, não os dados no sentido de banco, por exemplo, mas os dados no sentido de valores, de referências, instâncias e afins.

O primeiro tipo que vamos abordar é a stack, onde quando temos algum valor armazenado, por exemplo, um int, um bool, um double, um byte e afins, como var a = 0, int b = 5, ou double valor = 2.253, qualquer tipo desses valores primitivos que temos armazenado, os valores serão armazenados na stack, que é essa estrutura cuja tradução literal é uma pilha de dados que temos para armazenar esses tipos de valores.

A outra estrutura que temos é a heap, onde vamos armazenar tipos mais complexos, os valores dos objetos que temos efetivamente. Então, no momento em que fizermos a instância, correspondente ao momento no código em que fazemos Usuario usuario = new Usuario(), criamos na heap os valores, nesse caso com Id, que não definimos, o Nome Daniel e o Email "daniel@email.com".

Se tivéssemos, por exemplo, outra pessoa usuária criada com alguma outra informação definida, esses valores também ficariam na heap de dados.

Um último tipo aqui que temos é o LOH (Large Object Heap). O que isso quer dizer? Para que ele serve? Ele é basicamente uma heap, mas para objetos muito grandes, ou seja, 85 kb ou mais.

Mas como tudo isso que falamos agora se relaciona com o problema que tivemos anteriormente, de alterar cada elemento da lista, mas, aparentemente, não ocorrer essa mudança como esperado e precisar de uma atribuição manual para resolver?

O que acontece é que, por exemplo, no momento em que criamos uma pessoa usuária no código, na stack, apontamos para 1. Quando fazemos Usuario u = new Usuario(), a referência, isto é, o ponteiro para onde esse valor está na memória vai ficar na stack, e o valor, efetivamente, vai ficar na heap.

Então, se fizermos outro tipo de instância, como, por exemplo, outra pessoa usuária, quando fizéssemos a execução do código, teríamos dois ponteiros diferentes apontando para diferentes valores na heap.

Mas por que o que aconteceu anteriormente aconteceu efetivamente? Porque quando criamos a lista de telefones, que está dentro do nosso usuário, mas temos uma lista apontando, ela não era um tipo primitivo. Vimos que o que é armazenado na stack são as referências e tipos primitivos, mas a lista de telefones é uma lista de strings.

O que acontece nesse momento é que, quando fizemos a inserção do valor, apontamos para um espaço específico na memória. Quando fizemos Telefones.Select(), o Select() projetou cada elemento da sequência em uma nova forma. Então, o que ele fez efetivamente foi apontar para um novo lugar na memória. Assim, o Telefones.Select() apontou para outro espaço em memória, e esse outro espaço em memória tem o valor que queremos de fato.

É como se tivéssemos {"12345678"} e, ao fazer a operação, obtivéssemos {"912345678"}. Basicamente, foi isso que aconteceu. Temos a lista com esse valor, fizemos o Telefones.Select(), que retorna uma nova sequência, e essa nova sequência estará na stack, apontando para um espaço diferente.

No momento em que executamos essa atribuição, paramos de apontar, a partir dessa referência, para o valor que está na heap, e apontamos para o novo valor. Então, todo o espaço que era apontado pela lista de telefones foi perdido na memória e, posteriormente, será coletado pelo garbage collector, para não ficarmos com muito lixo na memória.

Conclusão

Por isso, devemos entender os conceitos de stack e heap, principalmente, além de ter a noção da existência do LOH.

Em resumo, a partir da stack, deixamos de apontar para a lista antiga e passamos a apontar para uma nova. Então, o valor que tínhamos foi perdido e, a partir desse momento, apontamos para um novo local dentro da memória, mas com o mesmo ponteiro que tínhamos, ou seja, a lista de telefones.

A partir de agora, vamos seguir otimizando o nosso projeto a nível de memória, entendendo quais outros tipos de estrutura podemos utilizar. Te encontramos no próximo vídeo e até mais!

Sobre o curso .NET: gerenciamento de memória para otimização de performance

O curso .NET: gerenciamento de memória para otimização de performance possui 122 minutos de vídeos, em um total de 44 atividades. Gostou? Conheça nossos outros cursos de .NET em Programação, ou leia nossos artigos de Programação.

Matricule-se e comece a estudar com a gente hoje! Conheça outros tópicos abordados durante o curso:

Aprenda .NET acessando integralmente esse e outros cursos, comece hoje!

Conheça os Planos para Empresas