Visão Geral
O objetivo deste artigo é explicar em detalhes o funcionamento de ponteiros e referências em linguagens de programação. Foi usado o termo “ponteiros e referências” justamente para relacionar o comportamento de uma referência para a instância de uma classe com um ponteiro em linguagens de nível mais baixo. O comportamento é essencialmente o mesmo.
É muito importante entender e esclarecer esse conceito, pois o mesmo é **base para o entendimento de orientação a objetos**. Conhecendo esse conceito, dá pra entender como resolver problemas complexos do dia-a-dia usando classes abstratas ou interfaces em OOP (Object Oriented Programming ou Programação Orientada a Objetos).
Tipos por “Valor” e por “Referência”
Esse conceito é muito importante. Quando declaramos alguma variável, dependendo do tipo de dados que estamos declarando essa variável a mesma pode conter diretamente o valor ou simplesmente uma referência para um lugar na memória onde está esse valor.
Exemplos de tipos de dados por valor:
- Int
- Double
- Char
Exemplos de tipos de dados por referência:
- Classes
- Interfaces
Na prática, quando trabalha-se com estruturas grandes (como classes por exemplo), que contém uma série de propriedades e consequentemente um espaço maior na memória, é muito mais inteligente e econômico trabalhar com referências.
Quando deseja-se trabalhar com estruturas pequenas (um único Int32, 32 bits, uma string que pode não ser lá muito pequena, um float, um DateTime), é muito mais simples alocar todo o espaço necessário do que criar uma referência para ele.
O caso específico de string é um pouco mais complexo, pois é um tipo que para uso corrente é um tipo por valor, mas internamente é um tipo por referência. Vamos fingir que esse caso não existe e que string é um tipo por valor. Facilita a compreensão pensar assim. Quem quiser complicar a cabeça é só pensar que uma string também pode ser uma [http://pt.wikipedia.org/wiki/Lista_encadeada lista encadeada]. Na maioria dos compiladores, internamente são tratadas como arrays de char.
O que muda na prática?
Sempre que uma variável por valor é declarada, automaticamente existe um espaço de memória para ela.
Sabemos, pela definição do tipo que os tipos ocupam o seguinte espaço na memória:
- Int: 4 bytes.
- Double: 4 bytes
- Char: 1 byte
- Qualquer tipo por referência: 8 bytes (provavelmente 16 bytes em arquitetura 64 bits. Não tenho certeza.)
Exemplos de tipo por valor
private void teste(){ int a = 10; //Nesse momento, criei um espaço de 4 bytes na memória, que cabe um inteiro. int b = 20; // Nesse momento, criei outro espaço de 4 bytes na memória que cabe um inteiro. }
Exemplos de tipos por referência
Definição de classe exemplo
A classe de exemplo a seguir usa 2 ints e um float, ou seja, ou seja, cada vez que essa classe é instanciada, ela ocupa 12 bytes em memória. Justamente por classes serem estruturas mais complexas, elas não são instanciadas sempre que uma variável é definida (conceito de tipo por referência, o oposto do tipo por valor).
public class MinhaClasse{ private int _testeInt; public int testeInt{ get { return _testeInt; } set { _testeInt = value; } } private int _testeInt2; public int testeInt2{ get { return _testeInt2; } set { _testeInt2 = value; } } private double _testeFloat; public double testeFloat{ get { return _testeFloat;} set { _testeFloat;} } }
Exemplo de Alocação de Referências
Nesse caso, para ilustrar o exemplo, sabemos que “MinhaClasse” ocupa 12 bytes na memória, pq define 2 ints e um float.
Então, exemplificando temos o seguinte comportamento:
private void teste(){ int a = 10; //Nesse momento, criei um espaço de 4 bytes na memória, que cabe um inteiro. int b = 20; // Nesse momento, criei outro espaço de 4 bytes na memória que cabe um inteiro. /* Nesse momento, criei outro espaço de 4 bytes na memória que cabe uma referência para uma instância de MinhaClasse, e por default recebe valor null */ MinhaClasse c; /* Nesse momento, criei outro espaço de 4 bytes na memória que cabe uma referência para uma instância de MinhaClasse, e por default recebe valor null */ MinhaClasse d; /* Nesse momento, criei outro espaço de 12 bytes. Esse sim tem espaço para guardar os valores na memória referente às propriedades da instância da classe. */ d = new MinhaClasse(); /* Nessa última instrução, eu não criei mais nenhum espaço na memória. Somente apontei a mesma instância que estava em d para c. Uso os mesmos 4bytes alocados anteriormente para isso. */ c = d; }
Tentando ser mais didático
Para tentar criar uma analogia que simplifique o entendimento do problema, vamos pensar numa situação do nosso mundo real.
Vamos imaginar um computador numa rede TCP/IP. Como sabemos, quando temos a nossa máquina na rede, ela pode ser encontrada de muitas formas. Inúmeras entradas de DNS e IP’s podem apontar para uma mesma máquina física.
- localhost
- 127.0.0.1
- 67.228.37.26
- http://www.ericlemes.com
Todos os 4 lugares apontam para uma mesma máquina física.
Vamos pensar num tipo hipotético chamado “Computador” e vamos pensar que esse tipo hipotético é “por valor”.
Computador a; Computador b;
Executando esse código, eu criei DOIS COMPUTADORES. Temos nesse caso a imagem de um computador repousando sobre uma mesa.
Vamos apagar o exemplo anterior da nossa cabeça e pensar que este mesmo tipo Computador agora é por referência.
Computador a; Computador b;
Executando o código acima, eu criei ZERO computadores. A imagem aqui é uma mesa vazia, sem nenhum computador repousando sobre ela. O que eu criei nesse caso foi uma entrada “a” e outra entrada “b” no servidor DNS. Ambas não apontam para lugar nenhum (null reference).
Computador a = new Computador(); Computador b = a;
Nesse caso, quando eu dei o “new”, aí sim. Existe um computador repousando sobre a mesa, e a entrada de DNS “a” aponta para esse novo computador. Quando eu declaro Computador b = a, eu estou criando uma nova entrada de DNS, apontando para o mesmo computador.
Computador a = new Computador(); Computador b = new Computador();
Nesse outro exemplo, eu tenho dois computadores repousando sobre a mesa.
Em outras palavras:
Quando uma variável de um tipo por referência é declarada, essa variável aponta para um endereço na memória onde existe uma instância daquele tipo (instância seria o computador, a variável a entrada no servidor DNS). Quando é dado um “new” num tipo por referência, ele cria um espaço na memória onde cabe a instância de uma estrutura e armazena o endereço onde está a estrutura na variável indicada.
Alocação de Memória – Heap x Stack (Pilha)
Recentemente, com a colaboração do Joel Pereira, consegui aprender mais um pouquinho sobre o assunto.
Geralmente os desenvolvedores que começam com linguagens de programação como o C#, de um nível um pouquinho mais alto (talvez eu me inclua nesse caso, apesar de não ter começado com C#), acabam pecando por desconhecer um pouco detalhes de como funciona a alocação de memória num nível mais baixo.
Dentre as áreas de memória dos executáveis, temos como principais as áreas do Heap e do Stack. O Stack é um espaço de memória que nosso programa usa para armazenar os valores das variáveis dentro de um método (variáveis locais). Cada chamada que é empilhada no stack ganha um espaço exclusivo dentro dele, e este é liberado no término da execução do método.
O heap representa a área compartilhada da memória, para todo o programa. Aqui vão todas as variáveis, instâncias de objetos e tudo o mais que precise ser compartilhado em todo o processo.
Sempre que instanciamos uma classe (ex.: new Computador()), alocamos no heap o espaço necessário para as informações do computador, e numa variável local (no stack) o endereço de memória que aponta para essa instância no heap.
Por isso que em linguagens de baixo nível (que não tem um Garbage Collector), acabam ocorrendo problemas de “vazamento de memória” (memory leak). Perde-se o ponteiro para a instância no heap e o espaço ficará lá em uso até que o processo termine.
No caso, em ambientes como o .Net em que existe um garbage collector, esse é o trabalho dele. Procurar e liberar o espaço usado por essas instâncias perdidas no heap que não podem mais serem recuperadas porque não existem mais ninguém apontando pra ela. O problema é que gerenciar a memória do Heap não é uma tarefa muito fácil, por causa dos “buracos” que podem ficar caso deixemos de usar um objeto que está entre dois outros que ainda precisam viver na memória. Já no stack, não temos esse problema, pois a memória está sempre no formato de pilha, simplificando o processo de alocação e liberação da memória. Em outras palavras: gerenciar memória do Stack é mais “barato” que gerenciar memória do Heap.
Em resumo, as variáveis de tipos escalares acabam sempre sendo armazenadas na pilha e as instâncias no heap.
Um outro link interessante sobre o assunto (exemplos em C): http://www.inf.ufsc.br/~ine5384-hp/Estruturas.AlocDinamica.html, de autoria do Prof. Dr. rer.nat. Aldo von Wangenheim.
Outro post interessantíssimo é do Eric Lippert. Ele fala um pouco mais sobre o assunto focado em C# e joga um pouquinho mais de lenha na fogueira, afirmando que nem sempre variáveis locais são armazenadas no stack. Segue o link: http://blogs.msdn.com/ericlippert/archive/2009/04/27/the-stack-is-an-implementation-detail.aspx.
Muito bom, Professor!
Legal que você gostou…
Eu estou precisando fazer uma revisão desse post. Ele já deu muita discussão, principalmente quando entra nos conceitos de gerenciamento de memória, tema que geralmente o pessoal que começou a vida de programador em .Net não olha com muita atenção. Vou tirar um tempo para complementá-lo.
Abraço,
Eric
Ola Ericlemes,
Parabéns pelo post, muito bom! No caso do c# existem duas formas de utilizar uma variável do tipo inteiro, com “Int32” e “int” porque isso? Muda algo? Outra dúvida, podemos fazer no c# Int32 x = new Int32(); nesse caso a variavel a será inicializada com o valor padrão do tipo Int32 que é 0. Mesmo usando o new ela séria um tipo primitivo correto?
Gerson,
Que bom que o post foi útil. Não existe diferença nenhuma entre int e Int32. É um mapeamento direto. A diferença é que o “int” é uma palavra reservada do C# (provavelmente em outras linguagens, vc não terá equivalênica), além de que pode ser que em versões futuras, int mapeie para um outro tipo.
Conceitualmente não existe diferença nenhuma. Continua sendo um tipo primitivo, por valor.
Abraço,
Eric
Muito bom o post…
Bem fácil o entendimento! Bom pra quem está começando, como eu…
Valeu !