O uso de variáveis ​​de ponteiro não é uma sobrecarga de memória?


29

Em linguagens como C e C ++, ao usar ponteiros para variáveis, precisamos de mais um local de memória para armazenar esse endereço. Então isso não é uma sobrecarga de memória? Como isso é compensado? Os ponteiros são usados ​​em aplicativos críticos de pouca memória?


11
Os benefícios da alocação dinâmica de memória superam amplamente o custo do ponteiro.
James McLeod

32
Como você acha que outras linguagens (Java, C #, ...) armazenam referências a objetos? (Dica: eles usam ponteiros).
Erik Eidt

6
Um ponteiro pode estar em registros ou ser passado como argumento. Nos dois casos, não há sobrecarga de memória óbvia . E o ponteiro pode ser computada (por exemplo, através de aritmética de ponteiro, funções que retornam ponteiros, etc)
Basile Starynkevitch

9
Que tal passar um argumento struct (grande) por endereço? Se você contar isso como uma variável de ponteiro, é inevitável para muitos algoritmos e usa muito menos espaço do que passar a estrutura por valor!
PJTraill

4
Isso parece com a tarefa de alguns. A pergunta foi criada para explorar se a resposta entende os ponteiros e como eles são usados.
Michael Shaw

Respostas:


34

Na verdade, a sobrecarga realmente não está nos 4 ou 8 bytes extras necessários para armazenar o ponteiro. Na maioria das vezes, os ponteiros são usados ​​para alocação dinâmica de memória , o que significa que invocamos uma função para alocar um bloco de memória, e essa função retorna para nós um ponteiro que aponta para esse bloco de memória. Esse novo bloco, por si só, representa uma sobrecarga considerável.

Agora, você não precisa se envolver na alocação de memória para usar um ponteiro: você pode ter uma matriz intdeclarada estaticamente ou na pilha e pode usar um ponteiro em vez de um índice para visitar os int, e é tudo muito bonito, simples e eficiente. Nenhuma alocação de memória é necessária, e o ponteiro normalmente ocupa exatamente tanto espaço na memória quanto um índice inteiro.

Além disso, como Joshua Taylor nos lembra em um comentário, ponteiros são usados ​​para passar algo por referência. Por exemplo, struct foo f; init_foo(&f);alocaria f na pilha e depois chamaria init_foo()com um ponteiro para isso struct. Isso é muito comum. (Apenas tome cuidado para não passar esses ponteiros "para cima".) No C ++, você pode ver isso sendo feito com uma "referência" ( foo&) em vez de um ponteiro, mas as referências nada mais são do que ponteiros que você não pode alterar e eles ocupam o mesma quantidade de memória.

Mas a principal razão pela qual os ponteiros são usados ​​é para alocação dinâmica de memória, e isso é feito para resolver problemas que não poderiam ser resolvidos de outra forma. Aqui está um exemplo simplista: imagine que você deseja ler todo o conteúdo de um arquivo. Onde você vai guardá-los? Se você tentar com um buffer de tamanho fixo, poderá ler apenas arquivos que não excedam esse buffer. Mas, usando a alocação de memória, você pode alocar a quantidade de memória necessária para ler o arquivo e prosseguir para lê-lo.

Além disso, o C ++ é uma linguagem orientada a objetos e há certos aspectos do OOP, como abstração, que só são possíveis usando ponteiros. Mesmo linguagens como Java e C # fazem uso extensivo de ponteiros, elas simplesmente não permitem que você manipule diretamente os ponteiros, de modo a impedir que você faça coisas perigosas com eles, mas ainda assim, essas linguagens só começam a fazer sentido quando você tiver percebi que nos bastidores tudo é feito usando ponteiros.

Portanto, os ponteiros não são usados ​​apenas em aplicativos com pouco tempo de memória crítica, são usados ​​em qualquer lugar .


5
"a principal razão pela qual os ponteiros são usados ​​é para alocação dinâmica de memória" Esse pode ser o caso em C ++, mas não necessariamente em C. Em C, os ponteiros são sua única maneira de passar algo por referência. Se você não deseja copiar uma estrutura inteira, precisará de um ponteiro para ela. Isso ainda faz sentido, mesmo se você não estiver fazendo nenhuma alocação dinâmica. Por exemplo, struct foo f; init_foo(&f);alocaria fna pilha e depois chamaria init_foocom um ponteiro para essa estrutura. Isso é muito comum. (Só tome cuidado para não passar os ponteiros "para cima".)
Joshua Taylor

@ JoshuaTaylor isso é muito correto, eu tinha esquecido. Posso alterá-lo para minha resposta? (Esta é considerada uma boa prática de programadores SE porque os comentários são efêmeros, enquanto as respostas não são.)
Mike Nakis

Por todos os meios, adicione sua resposta. :)
Joshua Taylor

2
Isso representa uma sobrecarga considerável por si só, porque esse bloco terá um cabeçalho (oculto) que geralmente será tão grande quanto alguns ponteiros. => Na verdade, implementações modernas de malloccabeçalho MUITO BAIXO sobrecarregam, pois agrupam blocos alocados em "buckets". Por outro lado, isso se traduz em sobre-alocação em geral: você perguntar para 35 bytes e obter 64 (sem o seu conhecimento) desperdiçando assim 29 ...
Matthieu M.

1
@MikeNakis: parece melhor, obrigado por estarem comigo :)
Matthieu M.

35

Então isso não é uma sobrecarga de memória?

Claro, um endereço extra (geralmente 4/8 bytes, dependendo do processador).

Como isso é compensado?

Não é. Se você precisar do indireto necessário para os ponteiros, poderá pagar por isso.

Os ponteiros são usados ​​em aplicativos críticos de pouca memória?

Não fiz muito trabalho lá, mas presumo que sim. O acesso ao ponteiro é um aspecto elementar da programação de montagem. São necessárias quantidades triviais de memória e as operações do ponteiro são rápidas - mesmo no contexto desses tipos de aplicativos.


1
@DavidGrinberg Isso pressupõe apenas que não há otimização do valor de retorno .
Darkhogg

3
Usei ponteiros ao escrever aplicativos TSR para DOS que precisavam caber 15k nos anos 80. Então, sim, eles são usados ​​em aplicativos com pouca memória.
Gort the Robot

1
@StevenBurnap Você chama 15k de pouca memória para um aplicativo TSR? Certa vez, escrevi uma ferramenta TSR que consumia apenas 16 bytes de memória.
kasperd

22
@kasperd: E de volta no meu dia, tínhamos apenas zeros
Jack


11

Não tenho a mesma opinião sobre Telastyn.

Globais do sistema em um processador incorporado podem ser endereçados com endereços específicos e codificados.

Globais em um programa serão endereçados como um deslocamento de um ponteiro especial que aponta para o local na memória onde globais e estáticos são armazenados.

As variáveis ​​locais aparecem quando uma função é inserida e são endereçadas como um deslocamento de outro ponteiro especial, geralmente chamado de "ponteiro de quadro". Isso inclui os argumentos para a função. Se você for cuidadoso com os empurrões e pops com o ponteiro da pilha, poderá eliminar o ponteiro do quadro e acessar as variáveis ​​locais diretamente do ponteiro da pilha.

Portanto, você paga pelo indireto dos ponteiros, quer esteja percorrendo uma matriz ou apenas pegando alguma variável local ou global não digna de nota. É apenas baseado em um ponteiro diferente, dependendo da variável que é. O código que é bem compilado manterá esse ponteiro em um registro da CPU, em vez de recarregá-lo sempre que for usado.


6

Sim, claro. Mas é um ato de equilíbrio.

Normalmente, os aplicativos com pouca memória são construídos tendo em mente a compensação entre a sobrecarga de algumas variáveis ​​de ponteiro em comparação com a sobrecarga do que seria uma enorme programa (que deve ser armazenado na memória, lembre-se!) Se ponteiros não pudessem ser usados .

Essa consideração se aplica a todos os programas, porque ninguém deseja criar uma bagunça horrível e impossível de manter, com o código duplicado à direita e no centro, vinte vezes maior do que precisa.


5

Em linguagens como C e C ++, ao usar ponteiros para variáveis, precisamos de mais um local de memória para armazenar esse endereço. Então isso não é uma sobrecarga de memória?

Você assume que o ponteiro precisa ser armazenado. Esse não é sempre o caso. Toda variável é armazenada em algum endereço de memória. Digamos que você tenha um longdeclarado como long n = 5L;. Isso aloca armazenamento para nem algum endereço. Podemos usar esse endereço para fazer coisas sofisticadas, como *((char *) &n) = (char) 0xFF;manipular partes de n. O endereço de nnão é armazenado em nenhum lugar como uma sobrecarga extra.

Como isso é compensado?

Mesmo que os ponteiros sejam armazenados explicitamente (por exemplo, em estruturas de dados como listas), a estrutura de dados resultante geralmente é mais elegante (mais simples, mais fácil de entender, mais fácil de manusear etc.) do que uma estrutura de dados equivalente sem ponteiros.

Os ponteiros são usados ​​em aplicativos críticos de pouca memória?

Sim. Os dispositivos que usam microcontroladores geralmente contêm muito pouca memória, mas o firmware pode usar ponteiros para lidar com vetores de interrupção ou gerenciamento de buffer, etc.


Algumas variáveis não são armazenados na memória, mas apenas em registos
Basile Starynkevitch

@BasileStarynkevitch: Você pode sugerir que as variáveis ​​permaneçam nos registradores, mas o compilador não é obrigado a fazê-lo. A menos que você esteja programando no assembler, você realmente não tem esse nível de controle sobre o armazenamento imediato. E mesmo no assembler, qualquer sub-rotina que você invocar provavelmente derramará os registros na pilha, para que possa usá-los para suas variáveis. Portanto, para qualquer programa não trivial, é quase garantido que suas variáveis ​​gastarão pelo menos algum tempo na memória.
TMN

O compilador pode ser permitido para armazenar algumas variáveis locais em apenas registros, e mais otimização compiladores estão fazendo isso (tentativa com gcc -fverbose-asm -S -O2a compilar algum código C)
Basile Starynkevitch

@BasileStarynkevitch Não sei ao certo o que você está tentando fazer com a observação de que variáveis ​​podem ser armazenadas apenas em registros. Você poderia por favor elaborar?
31415 Lawrence

5

Ter um ponteiro definitivamente consome alguma sobrecarga, mas você também pode ver o lado positivo. Em C, você pode usar estruturas de dados complexas, como cadeias e estruturas, apenas devido a ponteiros. Até suas variáveis ​​normais têm uma entrada na tabela de símbolos que armazena o endereço para onde sua variável está apontando. Portanto, acho que isso não gera muita sobrecarga em termos de memória (apenas 4 ou 8 bytes). Mesmo linguagens como java usam ponteiros internamente (referência), eles simplesmente não permitem que você os manipule, pois isso tornará a JVM menos segura. Você deve usar ponteiros somente quando não tiver outra opção, como tipos de dados ausentes, estruturas (em c), pois o uso de ponteiros pode levar a erros se não forem tratados adequadamente e são comparativamente mais difíceis de depurar.

De fato, suponha que você queira passar uma variável por referência, então é fácil manter um ponteiro em vez de replicar toda a estrutura e sincronizar as alterações entre elas (mesmo para copiá-las, você precisará de ponteiro). Como você lidaria com alocações e desalocações de memória não contíguas sem ponteiro?




3

Então isso não é uma sobrecarga de memória?

Sim não Talvez?

Essa é uma pergunta incômoda, porque imagine o intervalo de endereçamento de memória na máquina e um software que precisa acompanhar permanentemente onde as coisas estão na memória de uma maneira que não pode ser ligada à pilha.

Por exemplo, imagine um reprodutor de música em que o arquivo de música é carregado com o botão pressionado pelo usuário e descarregado da memória volátil quando o usuário tenta carregar outro arquivo de música.

Como podemos rastrear onde os dados de áudio são armazenados? Precisamos de um endereço de memória para ele. O programa não apenas precisa acompanhar os blocos de dados de áudio na memória, mas também onde eles estão na memória. Portanto, precisamos manter em torno de um endereço de memória (ou seja, um ponteiro). E o tamanho do armazenamento necessário para o endereço de memória corresponderá ao intervalo de endereçamento da máquina (por exemplo: ponteiro de 64 bits para um intervalo de endereçamento de 64 bits).

Portanto, é uma espécie de "sim", requer armazenamento para rastrear um endereço de memória, mas não é possível evitá-lo para uma memória alocada dinamicamente desse tipo.

Como isso é compensado?

Falando apenas do tamanho de um ponteiro, você pode evitar o custo em alguns casos, utilizando a pilha. Por exemplo, nesse caso, os compiladores podem gerar instruções que efetivamente codificam o endereço de memória relativo, evitando o custo de um ponteiro. No entanto, isso deixa você vulnerável a estouros de pilha, se você fizer isso para alocações grandes e de tamanho variável, e também tende a ser impraticável (se não totalmente impossível) para uma série complexa de ramificações direcionadas pela entrada do usuário (como no exemplo de áudio acima).

Outra maneira é usar estruturas de dados mais contíguas. Por exemplo, uma sequência baseada em matriz pode ser usada em vez de uma lista duplamente vinculada que requer dois ponteiros por nó. Também podemos usar um híbrido desses dois como uma lista não rolada que armazena apenas ponteiros entre cada grupo contíguo de N elementos.

Os ponteiros são usados ​​em aplicativos críticos de pouca memória?

Sim, muito comumente, como muitos aplicativos críticos para o desempenho são escritos em C ou C ++ que são dominados pelo uso do ponteiro (eles podem estar atrás de um ponteiro inteligente ou de um contêiner como std::vectorou std::string, mas a mecânica subjacente se resume a um ponteiro usado para acompanhar o endereço de um bloco de memória dinâmico).

Agora, de volta a esta pergunta:

Como isso é compensado? (Parte dois)

Os ponteiros costumam ser muito baratos, a menos que você os armazene como um milhão deles (que ainda é um mísero * 8 megabytes em uma máquina de 64 bits).

* Observe como Ben apontou que um "mísero" 8 megas ainda é do tamanho do cache L3. Aqui eu usei "míseros" mais no sentido do uso total de DRAM e do tamanho relativo típico dos blocos de memória que um uso saudável dos ponteiros apontará.

Onde os ponteiros ficam caros não são os ponteiros, mas:

  1. Alocação dinâmica de memória. A alocação dinâmica de memória tende a ser cara, pois precisa passar por uma estrutura de dados subjacente (por exemplo, alocador de amigos ou lajes). Embora muitas vezes sejam otimizados até a morte, eles são de uso geral e projetados para lidar com blocos de tamanho variável que exigem que eles façam pelo menos um pouco de trabalho semelhante a uma "pesquisa" (embora leve e possivelmente até constante) para encontre um conjunto gratuito de páginas contíguas na memória.

  2. Acesso à memória. Isso costuma ser a maior sobrecarga para se preocupar. Sempre que acessamos a memória alocada dinamicamente pela primeira vez, há uma falha de página obrigatória e falhas de cache, movendo a memória para baixo na hierarquia de memória e para dentro de um registro.

Acesso à memória

O acesso à memória é um dos aspectos mais críticos do desempenho, além dos algoritmos. Muitos campos críticos de desempenho, como os mecanismos de jogos AAA, concentram grande parte de sua energia em otimizações orientadas a dados, que se resumem a padrões e layouts de acesso à memória mais eficientes.

Uma das maiores dificuldades de desempenho de linguagens de nível superior que desejam alocar cada tipo definido pelo usuário separadamente por meio de um coletor de lixo, por exemplo, é que eles podem fragmentar bastante a memória. Isso pode ser especialmente verdadeiro se nem todos os objetos forem alocados de uma só vez.

Nesses casos, se você armazenar uma lista de um milhão de instâncias de um tipo de objeto definido pelo usuário, o acesso a essas instâncias sequencialmente em um loop pode ser bastante lento, pois é análogo a uma lista de um milhão de ponteiros que apontam para regiões diferentes da memória. Nesses casos, a arquitetura deseja buscar a memória dos níveis superior, mais lento e mais alto da hierarquia em blocos grandes e alinhados, com a esperança de que os dados ao redor desses blocos sejam acessados ​​antes da remoção. Quando cada objeto dessa lista é alocado separadamente, geralmente acabamos pagando por ele com falhas de cache, quando cada iteração subsequente pode ser carregada de uma área completamente diferente da memória, sem que objetos adjacentes sejam acessados ​​antes da remoção.

Atualmente, muitos compiladores para esses idiomas estão fazendo um ótimo trabalho na seleção de instruções e na alocação de registros, mas a falta de controle mais direto sobre o gerenciamento de memória aqui pode ser fatal (embora muitas vezes menos propensa a erros) e ainda criar idiomas como C e C ++ bastante popular.

Otimizando indiretamente o acesso ao ponteiro

Nos cenários mais críticos para o desempenho, os aplicativos geralmente usam pools de memória que agrupam memória de partes contíguas para melhorar a localidade de referência. Nesses casos, mesmo uma estrutura vinculada, como uma árvore ou uma lista vinculada, pode ser otimizada para cache, desde que o layout da memória de seus nós seja de natureza contígua. Isso está efetivamente tornando a desreferenciação de ponteiros mais barata, embora indiretamente, melhorando a localidade de referência envolvida ao desmarcá-las.

Perseguindo indicadores

Suponha que tenhamos uma lista vinculada individualmente como:

Foo->Bar->Baz->null

O problema é que, se alocarmos todos esses nós separadamente em relação a um alocador de uso geral (e possivelmente nem todos de uma vez), a memória real poderá ser dispersada da seguinte maneira (diagrama simplificado):

insira a descrição da imagem aqui

Quando começamos a procurar por ponteiros e acessar o Foonó, começamos com uma falha obrigatória (e possivelmente uma falha de página) movendo um pedaço de sua região de memória de regiões mais lentas da memória para regiões mais rápidas da memória, como:

insira a descrição da imagem aqui

Isso nos leva a armazenar em cache (possivelmente também a página) uma região de memória para acessar apenas uma parte dela e expulsar o restante, à medida que procuramos indicadores nesta lista. Ao assumir o controle sobre o alocador de memória, no entanto, podemos alocar essa lista de forma contígua assim:

insira a descrição da imagem aqui

... e, assim, melhorar significativamente a velocidade com a qual podemos desreferenciar esses ponteiros e processar seus apontadores. Portanto, embora muito indiretos, podemos acelerar o acesso ao ponteiro dessa maneira. Obviamente, se apenas armazenássemos esses contíguos em uma matriz, não teríamos esse problema em primeiro lugar, mas o alocador de memória aqui, que nos fornece controle explícito sobre o layout da memória, pode salvar o dia em que uma estrutura vinculada é necessária.

* Nota: este é um diagrama e discussão muito simplificados demais sobre a hierarquia de memória e a localidade de referência, mas espero que seja apropriado para o nível da pergunta.


1
"míseros 8 megabytes" é o tamanho típico de todo o cache L3 em uma CPU moderna
Ben Voigt

1
@BenVoigt Vou tentar desambiguar isso um pouco. Claro que seria horrível se cada ponteiro apontasse para um pedaço de memória de 32 bits, por exemplo

@BenVoigt Adicionou uma nota de rodapé para tentar esclarecer essa parte!

Esse esclarecimento parece bom.
Ben Voigt

1

Então isso não é uma sobrecarga de memória?

É de fato uma sobrecarga de memória, mas muito pequena (ao ponto da insignificância).

Como isso é compensado?

Não é compensado. Você precisa perceber que o acesso aos dados através de um ponteiro (desreferenciando um ponteiro) é extremamente rápido (se bem me lembro, ele usa apenas uma instrução de montagem por desreferencia). É rápido o suficiente para que, em muitos casos, seja a alternativa mais rápida que você tem.

Os ponteiros são usados ​​em aplicativos críticos de pouca memória?

Sim.


0

Você só precisa do uso de memória extra (4-8 bytes por ponteiro, normalmente) enquanto precisar desse ponteiro. Existem muitas técnicas que tornam isso mais acessível.

A técnica mais fundamental que torna os ponteiros poderosos é que você não precisa manter todos os ponteiros. Às vezes você pode usar um algoritmo para construir um ponteiro de um ponteiro para outra coisa. O exemplo mais trivial disso é a aritmética de matriz. Se você alocar uma matriz de 50 números inteiros, não precisará manter 50 ponteiros, um para cada número inteiro. Você normalmente acompanha um ponteiro (o primeiro) e usa a aritmética do ponteiro para gerar os outros rapidamente. Às vezes, você pode manter temporariamente um desses ponteiros para um elemento específico da matriz, mas apenas enquanto precisar. Quando terminar, você pode descartá-lo, desde que tenha informações suficientes para regenerá-las mais tarde, se necessário. Isso pode parecer trivial, mas é exatamente o tipo de ferramenta de conservação que você

Em situações de memória extremamente apertada, isso pode ser usado para minimizar custos. Se você estiver trabalhando em um espaço de memória muito pequeno, geralmente tem uma boa noção de quantos objetos precisa manipular. Em vez de alocar um número inteiro de um por vez e manter ponteiros completos para eles, você pode aproveitar o conhecimento do desenvolvedor de que nunca terá mais de 256 números inteiros nesse algoritmo específico. Nesse caso, você pode manter um ponteiro para o primeiro número inteiro e acompanhar um índice usando um caractere (1 byte) em vez de usar um ponteiro completo (4/8 bytes). Você também pode usar truques algorítmicos para gerar alguns desses índices em tempo real.

Esse tipo de consciência de memória era muito popular no passado. Por exemplo, os jogos NES dependeriam amplamente de sua capacidade de compilar dados e gerar ponteiros algoritmicamente, em vez de terem que armazenar todos eles por atacado.

Situações extremas de memória também podem levar alguém a fazer coisas como alocar todos os espaços nos quais você opera no momento da compilação. Em seguida, o ponteiro que você precisa armazenar para essa memória é armazenado no programa e não nos dados. Em muitas situações de restrição de memória, você tem memória de dados e programa separada (geralmente ROM x RAM), portanto, poderá ajustar a maneira como usa seu algoritmo para enviar os ponteiros para a memória do programa.

Fundamentalmente, você não pode se livrar de toda a sobrecarga. No entanto, você pode controlá-lo. Usando técnicas algorítmicas, você pode minimizar o número de ponteiros que pode armazenar. Se você estiver usando ponteiros para a memória dinâmica, nunca ficará abaixo do custo de manter 1 ponteiro nesse ponto de memória dinâmica, porque essa é a quantidade mínima de informações necessárias para acessar qualquer coisa nesse bloco de memória. No entanto, em cenários de restrição de memória ultra-apertada, esse costuma ser o caso especial (restrições de memória dinâmica e de memória ultra-apertada tendem a não aparecer nas mesmas situações).


0

Em muitas situações, os ponteiros realmente economizam memória. Uma alternativa comum ao uso de ponteiros é fazer uma cópia de uma estrutura de dados. Uma cópia completa de uma estrutura de dados será maior que um ponteiro.

Um exemplo de aplicativo de tempo crítico é uma pilha de rede. Uma boa pilha de rede será projetada para ser "cópia zero" - e, para isso, é necessário o uso inteligente de ponteiros.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.