Compreendendo ponteiros para ponteiros em C

0
10

Parece um pesadelo de recursão, mas ponteiros para ponteiros são essenciais na programação de sistemas. Você está essencialmente apontando para um endereço de memória que contém o endereço dos seus dados reais. Essa indireção de camada dupla é o que os desenvolvedores chamam de identificador. Não é apenas um truque de codificação. É um mecanismo necessário para que os sistemas operacionais gerenciem a memória heap com eficiência.

Considere a pilha como uma sala lotada. Às vezes, o sistema operacional precisa embaralhar as pessoas para liberar espaço. Se você apontar diretamente para alguém, essa pessoa não poderá se mover sem quebrar sua referência. Mas se você segurar um ponteiro para uma lista de nomes, o sistema operacional poderá alterar a localização de qualquer pessoa nessa lista sem que você precise atualizar sua referência inicial. Esse é o poder de uma alça.

Veja como essa lógica se traduz em código C bruto. Você declara p como um ponteiro para um ponteiro. Então q se torna um ponteiro padrão. Você aloca memória para p e, em seguida, aloca memória para o que p aponta. Finalmente, você desreferencia duas vezes para atribuir o valor 12.

Windows e macOS contam com essa estrutura para compactação de memória. A distinção aqui é vital. Você, o programador, gerencia o ponteiro externo p. O sistema operacional gerencia o ponteiro interno *p. Como o sistema operacional controla *p, ele pode realocar o bloco de dados real (**p) em qualquer lugar do heap. Ele simplesmente atualiza *p para refletir o novo endereço. Seu código continua usando p sem problemas.

Além do gerenciamento de memória do sistema operacional, esse padrão é essencial para passar ponteiros para funções. Se você precisar de uma função para modificar o próprio ponteiro, passe um ponteiro para esse ponteiro. É a única maneira de reatribuir a referência dentro de um escopo diferente.

Gerenciando ponteiros para estruturas

A complexidade não para em números inteiros simples. Você pode aninhar essa lógica dentro de estruturas. Isso é comum ao lidar com dados de comprimento variável, como strings.

Pegue a estrutura Addr. Ele contém matrizes de tamanho fixo para nomes, cidades e telefones. Mas os comentários variam em extensão. Portanto, comment é definido como um ponteiro para char. Ao alocar memória para a própria estrutura, você está reservando espaço para os ponteiros e os arrays fixos. Você ainda não está reservando espaço para o texto do comentário real.

Você aloca s primeiro. Então você lê a entrada do usuário nos buffers fixos. Depois disso, você lê o comentário em um formato temporário

Você perde memória ao ignorar como os ponteiros se aninham.

Pegue um ponteiro s que aponta para uma estrutura. Essa estrutura contém outro ponteiro. Esse segundo ponteiro aponta para uma string real na memória. Duas camadas de indireção. Uma alocação para a estrutura. Uma alocação para a string.

Bastante simples até você tentar limpar.

É aqui que a maioria dos desenvolvedores tropeça. Você vê grátis(s). Você acha que terminou. Você está errado.

Veja este trecho de código:

A variável s aponta para a estrutura Addr. Dentro dessa estrutura, existe um campo comentário. comment aponta para um bloco de memória heap alocado para os dados da string.

Ao chamar free(s), você libera memória para a própria estrutura. A estrutura Addr desapareceu. A memória é devolvida ao sistema.

Mas o que acontece com s->comment?

Ele desaparece no éter.

O ponteiro para os dados da string foi armazenado dentro da estrutura que você acabou de liberar. Você não pode mais acessá-lo. Você não chamou free() na string. A memória permanece alocada, mas inacessível.

Este é um vazamento de memória.

Não é um acidente. Não é uma mensagem de erro. O programa funciona bem. Ele consome lentamente mais RAM até que o sistema troque ou trave.

Como consertar vazamentos de ponteiro duplo

Você precisa liberar o ponteiro interno primeiro. Ou armazene-o em uma variável temporária antes de liberar a estrutura externa.

Agora os dados da string são liberados. Então a estrutura é liberada. Não há blocos perdidos.

Por que isso acontece com tanta frequência

As pessoas tratam os ponteiros como valores. Eles esquecem que ponteiros são referências a recursos.

Quando uma estrutura contém um ponteiro, essa estrutura não é independente. Depende de memória externa. Liberar o contêiner não libera o conteúdo.

Isso piora com o aninhamento mais profundo. Um ponteiro para um ponteiro para um ponteiro. Três níveis. Você precisa de três chamadas free(). Na ordem certa.

Se você esquecer um, você vaza.

O problema de get()

O exemplo usa gets(). Essa função é perigosa. Ele foi removido do padrão C11 porque permite buffer overflows. Mas a lógica da memória permanece a mesma.

Quer você use gets(), fgets() ou scanf(), a estratégia de alocação é o gargalo para vazamentos aqui.

Você aloca para s.
Você aloca para s->comment.

Se você liberar apenas s, você deixará s->comment para trás.

Principais conclusões

  • Ponteiros duplos exigem duplo grátis.
  • Verifique cada malloc para obter um free correspondente.
  • Se uma estrutura contém um ponteiro para a memória dinâmica, essa memória deve ser liberada antes da estrutura.
  • free() não libera membros recursivamente.

É fácil perder. O código compila. Ele funciona. O vazamento é silencioso.

Até que não seja.

Construindo listas vinculadas

Você pode acabar com um desastre de memória se descartar o contêiner antes dos dados para os quais ele aponta. A estrutura que segura o ponteiro é limpa. O bloco de string permanece. Torna-se um bloco perdido. Isso acontece quando a ordem de descarte está errada.

Vinculando

As estruturas podem apontar para si mesmas. Isso permite encadear registros idênticos. O resultado é uma lista vinculada. É uma maneira padrão de organizar dados em C.

Aqui está como você o define:

typedef struct {nome do char[21]; cidade char[21]; estado do caractere[21]; Endereço próximo; } Endereço;
Endereço
primeiro;

O campo next contém o endereço do registro subsequente. Você usa uma única variável de ponteiro para iniciar a cadeia.

O custo da flexibilidade

O compilador permite alterar regras que podem parecer contra-intuitivas à primeira vista. Com experiência suficiente, você pode projetar estruturas semelhantes às mostradas acima. É um movimento de poder.

Mas não é isento de riscos. Você está caminhando na linha tênue entre o código inteligente e o espaguete insustentável.

Por que é importante

Não se trata apenas de sintaxe. É sobre o que a linguagem permite que você faça quando você ultrapassa suas restrições.

  • Controle : você obtém controle granular sobre o layout da memória.
  • Interoperabilidade : você pode conversar com bibliotecas C mais facilmente.
  • Desempenho : Às vezes, ignorar as verificações de segurança economiza ciclos.

O problema? O compilador não salvará você de si mesmo. Ele permitirá que você compile código que trava em tempo de execução.

Como usá-lo com segurança

Se você vai fazer isso, faça-o com intenção.

  1. Isole-o : não espalhe esse padrão por toda a sua base de código. Mantenha-o em um módulo pequeno e bem testado.
  2. Documente tudo : Futuro você agradecerá presente. Ou te odeio. Provavelmente te odeio.
  3. Use abstrações : envolva os ponteiros brutos ou blocos inseguros em uma interface limpa. Esconda a bagunça.

A verificação da realidade

A maioria dos desenvolvedores não precisa disso. Eles usarão estruturas padrão. Eles ficarão mais felizes com isso.

Mas quando você se deparar com uma parede onde a biblioteca padrão não cabe, você ficará feliz que o compilador não o tenha impedido.

A questão é se você está pronto para pagar o custo de manutenção.

É uma troca. Velocidade para segurança. Poder para clareza.

Você escolhe.

попередня статтяUnix vs Linux: Por que a distinção é importante para os desenvolvedores
наступна статтяComo os sintetizadores mudaram a música quebrando as regras da forma