Ponteiros E Gerenciamento De Memória Em C

Cá estamos novamente para mais um tópico de Computação. Depois de falarmos sobre conversão de base numérica e até um pouco sobre http vamos entender como funciona o gerenciamente de memória RAM e o que são referências nas linguagens de programação. Para entender o assunto, usaremos uma linguagem que lida muito bem com memória, a linguagem C.

Palavra computacional

Antes de falarmos diretamente sobre memória, há uma convenção que precisamos estabelecer. Definimos por palavra computacional uma medida usada no reservamento de espaços na memória. Os sistemas operacionais modernos trabalham normalmente em arquiteturas de 32 ou 64 bits, isso define nossa palavra (word). 32 bits é o mesmo que 4 bytes (já que cada byte possui 8 bits). Veremos que quando declaramos uma variável int em C, estamos pedindo para o sistema operacional reservar metade de uma palavra computacional na memória principal, o que pode ser esses 2 bytes nos sistemas de 32 bits ou 4 bytes nos sistemas de 64 bits. Alguns tipos em C pedem uma palavra computacional, como o tipo float.

Memória principal

Memórias são lugares onde guardamos dados importantes e todo programa trabalha com pelo menos um tipo de memória. Um computador pode ser dividido em tipos de memória como vemos na imagem abaixo:

A pilha de memória fala sobre vantagens e desvantagens das mesmas, a memória principal ou primária é uma das mais vantajosas, pois tem um espaço relativamente grande e de rápido acessado para escrita e leitura, ou seja, se tivermos que gravar dados em algum lugar de forma a recuperá-los rapidamente, a memória principal é um bom lugar. Um nome mais comum para esse local é Memória RAM.
A memória secundária fala sobre o disco rígido (HD). É muito custoso (demorado) gravar dados no disco, por ser formado por pratos magnéticos, mas sua grande vantagem é a de trabalhar com uma tecnologia que torna os dados persistentes (você pode desligar seu computador que os dados ainda estarão lá), diferente da memória principal que é volátil (zerada ao desligarmos a máquina).

O que são variáveis?

Um dos primeiros conceitos que aprendemos nos cursos de programação são variáveis. Quando falamos em variáveis estamos falando em lugares na memória principal. Por exemplo, no código abaixo, estamos apenas declarando uma variável do tipo inteiro em C, com valor 25.

1
2
3
4
5
#include<stdio.h>

int main() {
   int idade = 25;
}

Ao declararmos uma variável inteira em C, estamos ,na verdade, pedindo para o sistema operacional reservar (ou alocar) um espaço na memória RAM do tamanho de ½ palavra computacional, sendo então 4 bytes numa arquitetura de 64 bits. Para o computador e o sistema operacional, o nome “idade” é irrelevante, ele é apenas um label para humanos e a cada vez que é citado, estamos na verdade falando de um lugar da memória.

Acessando valores

Vamos utilizar a função printf do C para imprimir na tela valores.

1
2
3
4
5
6
7
#include<stdio.h>

int main() {
   int idade = 25;
   printf("%d\n", idade);
   // temos um resultado na tela: 25, \n quer dizer uma quebra de linha no console
}

Ao passar “idade” para a função printf, estamos ordenando para o sistema operacional imprimir na tela o valor guardado no endereço de memória com label idade.

Descobrindo o endereço na memória de variáveis

Mas se quiséssemos imprimir o próprio endereço de memória ao invés do valor guardado usaríamos o formatador “%p” e acessaríamos “&idade”, veja abaixo:

1
2
3
4
5
6
#include<stdio.h>

int main() {
   int idade = 25;
   printf("%p\n", &idade);
}

O “ê comercial” (&) explicita que estamos falando do endereço de memória e não do valor contido na varíavel. A saída será um hexadecimal como 0x7fff943aa52c. Claro que se você rodar o programa, outro valor será encontrado, já que como o próprio nome diz, a memória ram é uma memória de acesso randômico (aleatório).

Ponteiros

Agora que entendemos que cada variável na verdade é um lugar na memória que guarda um valor, vamos falar de ponteiros. A definição se torna bem mais simples do que parece, mas primeiro vamos incrementar nosso código, veja abaixo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<stdio.h>

int main() {
   int idade = 25;
   // declaração de uma variavel ponteiro
   // que irá apontar para um lugar que guarda um valor inteiro
   int * ponteiro_idade;
   //variavel ponteiro_idade recebe o endereco de idade
   ponteiro_idade = &idade;

   // valor guardado e o local da memória onde está
   printf("%d, %p\n", idade, &idade);
   // o valor de ponteiro_idade é o endereco de idade 
   // para acessar o valor do endereço apontado por ponteiro_idade
   // usamos * 
   printf("%p, %d\n", ponteiro_idade, *ponteiro_idade);
}

Ou seja, se variáveis guardam valores de um certo tipo, uma variavel ponteiro guarda um endereço de memória, que provavelmente é o endereço de outra variável. Veja o esquema abaixo:

Passagem por valor e referência

Quando estudamos funções pela primeira vez, aprendemos dois conceitos básicos sobre os paramêtros. É possível fazer passagem por valor:

1
2
3
function minha_func(int idade) {
  printf("%d \n", idade);
}

Nessa passagem, ao chamarmos a funcao “minha_func” passando um valor inteiro, esse valor será copiado para um novo lugar na memória que será manipulado dentro da função. Numa passagem por refência, a função “minha_func” espera um ponteiro para um inteiro, ou seja, passaríamos o próprio endereço de um lugar que guarda um inteiro. Se alteramos o valor da variável dentro da função, fora dela, o valor permaneceria alterado.

1
2
3
function minha_func(int * idade) {
  printf("%d \n", *idade);
}

Tipos de Alocação

Em C, podemos declarar tipos composto de dados, veja abaixo um tipo de dado que define uma pessoa:

1
2
3
4
5
typedef struct s_pessoa {
  int idade;
  char* nome;
  struct s_pessoa * irmao ;
} Pessoa;

Existem duas formas de construir “instâncias” ou variáveis desse tipo, utilizando alocação estático e dinâmico.

Alocação estática

1
2
3
4
int main() {
  Pessoa eu;
  eu.idade = 25;
}

No alocamento estático, assim que o programa é iniciado, um espaço na memória já é reservado para a variavel “eu” do tipo Pessoa. Como podemos ver abaixo são reservadas 4 bytes pro int, e 2 palavras para os endereços guardados nas variáveis ponteiro.

É intessante vermos os endereços da própria estrutura e dos campos da mesma.

1
2
3
4
5
6
7
int main() {
    Pessoa eu;
    printf("%p\n", &eu);
    printf("%p\n", &(eu.idade));
    printf("%p\n", &(eu.nome));
    printf("%p\n", &(eu.irmao));
}

Teremos o seguinte resultado na tela

1
2
3
4
0x7fff1b6b46c0
0x7fff1b6b46c0
0x7fff1b6b46c8
0x7fff1b6b46d0

Podemos perceber que o endereço da estrutura é o endereço do primeiro campo, e num sistema de 64 bits, o terceiro campo é o endereço do segundo + 8 bytes (tamanho do segundo campo). Você pode perceber que apesar do primeiro campo ter 4 bytes, está a 8 bytes de distancia do segundo. Isso acontece por causa de uma correção com padding que o C faz para manter as estruturas proporcionais ao tamanho da palavra.

Alocação dinâmica

Na alocação dinâmica, trabalhamos com ponteiros para tipos compostos. Quando o programa se inicia, nada é alocado para a variavel além de uma palavra para guardar um endereço.

1
2
3
4
5
6
int main() {
  Pessoa * eu;
  // muitas e muitas linhas de código ...
  eu = malloc(sizeof(Pessoa));
  eu->idade = 25;
}

Apenas quando a função malloc é invocado, uma chamada para o sistema operacional solicita um espaco de memória do tamanho do tipo Pessoa (3 palavras) e o endereço para o início desse local é guardado na variavel “eu”. Perceba também que o operador de acesso na estrutura também muda de ponto para seta (–>).

Desalocação de memória

Vamos olhar para o nosso último código e imaginar que abaixo da última linha, há centenas de milhares de linhas de código. E que na verdade a variavel eu não foi utilizada. O que aconteceria é que o programa teria alocado uma espaço desnecessário na memória e que esse espaço só seria liberado para outros programas, por exemplo, após nossa execução terminar. Você pode pensar que é um assunto dispensável, mas se nossa estrutura fosse muito maior que 1 palavra computacional, teríamos um impacto bem negativo.
Em linguagens de mais alto nível, temos mecanismos automáticos e padrão para desalocar memória. Um mecanismo chamado garbage collector entra em ação de tempos em tempos em linguagens como o Java para perceber quais variáveis não estao sendo usadas e podem ser liberadas. Como eles sabem quais variáveis? Há várias técnicas (e problemas envolvidos) mas a mais simples é a de contagem de referência. Nela, cada vez que a variável é referenciada, um contador é incrementado, e quando deixa de ser citada, decrementado. Sabendo assim que todas as variáveis com contador zero podem ser desalocadas.
Em linguagens de mais baixo nível, como C, é preciso destruir memórias “na mão”, utilizando a função free.

Conclusão

Apesar de parecer um assunto complicado a princípio, os ponteiros são facilmente manipulados na linguagem C, e nos dão base para entender vários outros conceitos usadas em linguagens mais modernas, como Java ou PHP. Espero voltar em breve para falar sobre estruturas de dados que utilizamos no dia-a-dia.

Editado 2017: Se você curtiu esse texto, talvez goste também do blog Quero Ser Programador novo projeto com textos iniciantes sobre Algoritmos, Estruturas de dados, Complexidade de Algoritmos e assuntos importantes para iniciantes.

Referências

Comments