Mecânica da linguagem de pilhas e ponteiros

Prelúdio


Este é o primeiro dos quatro artigos da série que fornecerá informações sobre a mecânica e o design de ponteiros, pilhas, pilhas, análise de escape e semântica de Go / ponteiro. Este post é sobre pilhas e indicadores.

Índice:

  1. Mecânica da linguagem em pilhas e ponteiros
  2. Mecânica da linguagem na análise de escape ( tradução )
  3. Mecânica da linguagem no perfil de memória
  4. Filosofia de Design em Dados e Semântica

Introdução


Não vou dissimular - os ponteiros são difíceis de entender. Se usados ​​incorretamente, os ponteiros podem causar erros desagradáveis ​​e até problemas de desempenho. Isto é especialmente verdade ao escrever programas competitivos ou multithread. Não é de surpreender que muitas linguagens tentem ocultar indicadores de programadores. No entanto, se você escrever no Go, não poderá escapar dos ponteiros. Sem uma compreensão clara dos ponteiros, será difícil escrever código limpo, simples e eficiente.

Bordas do quadro


As funções são executadas dentro dos limites dos quadros que fornecem um espaço de memória separado para cada função correspondente. Cada quadro permite que a função funcione em seu próprio contexto e também fornece controle de fluxo. Uma função tem acesso direto à memória dentro de seu quadro por meio de um ponteiro, mas o acesso à memória fora do quadro requer acesso indireto. Para uma função acessar a memória fora de seu quadro, essa memória deve ser usada em conjunto com esta função. A mecânica e as limitações estabelecidas por esses limites devem ser entendidas e estudadas primeiro.

Quando uma função é chamada, ocorre uma transição entre dois quadros. O código vai do quadro da função de chamada para o quadro da função chamada. Se os dados forem necessários para chamar a função, esses dados deverão ser transferidos de um quadro para outro. A transferência de dados entre dois quadros no Go é feita "por valor".

A vantagem da transmissão de dados "por valor" é a legibilidade. O valor que você vê na chamada de função é o que é copiado e aceito no outro lado. É por isso que associo "passar por valor" a WYSIWYG, porque o que você vê é o que recebe. Tudo isso permite que você escreva um código que não oculte o custo de alternar entre duas funções. Isso ajuda a manter um bom modelo mental de como cada chamada de função afetará o programa durante a transição.

Veja este pequeno programa que chama uma função passando dados inteiros "por valor":

Listagem 1:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
10
11    // Pass the "value of" the count.
12    increment(count)
13
14    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc int) {
19
20    // Increment the "value of" inc.
21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
23 }

Quando o programa Go é iniciado, o tempo de execução cria a goroutine principal para começar a executar todo o código, incluindo o código dentro da função principal. Gorutin é o caminho de execução que se encaixa no encadeamento do sistema operacional, que finalmente roda em algum kernel. A partir da versão 1.8, cada goroutine é fornecida com um bloco inicial de memória contínua de 2048 bytes de tamanho, que forma o espaço da pilha. Esse tamanho inicial da pilha mudou ao longo dos anos e pode mudar no futuro.

A pilha é importante porque fornece espaço de memória física para os limites do quadro que são atribuídos a cada função individual. No momento em que a goroutine principal executa a função principal na Listagem 1, a pilha de programas (em um nível muito alto) terá a seguinte aparência:

Figura 1:



Na Figura 1, você pode ver que parte da pilha foi "emoldurada" para a função principal. Esta seção é chamada de " quadro de pilha " e é esse quadro que denota o limite da função principal na pilha. O quadro é definido como parte do código que é executado quando a função é chamada. Você também pode ver que a memória da variável count foi alocada em 0x10429fa4 dentro do quadro para main.

Há outro ponto interessante, ilustrado na Figura 1. Toda a memória da pilha sob o quadro ativo não é válida, mas a memória do quadro ativo e acima é válida. Você precisa entender claramente o limite entre a parte válida e inválida da pilha.

Endereços


As variáveis ​​são usadas para atribuir um nome a uma célula de memória específica para melhorar a legibilidade do código e ajudá-lo a entender com quais dados você está trabalhando. Se você tiver uma variável, terá um valor na memória e, se tiver um valor na memória, ele deverá ter um endereço. Na linha 09, a função principal chama a função println interna para exibir o "valor" e o "endereço" da variável count.

Listagem 2:

09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")

O uso do "e" comercial "&" para obter o endereço da localização de uma variável não é novo, outros idiomas também usam esse operador. A saída da linha 09 deve se parecer com a saída abaixo se você estiver executando o código em uma arquitetura de 32 bits como o Go Playground:

Listagem 3:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

Chamada de Função


Em seguida, na linha 12, a função principal chama a função de incremento.

Listagem 4:

12    increment(count)

Fazer uma chamada de função significa que o programa deve criar uma nova seção de memória na pilha. No entanto, tudo é um pouco mais complicado. Para concluir com êxito uma chamada de função, espera-se que os dados sejam transferidos através do limite do quadro e colocados em um novo quadro durante a transição. Em particular, espera-se que um valor inteiro seja copiado e transmitido durante a chamada. Você pode ver esse requisito observando a declaração da função de incremento na linha 18.

Listagem 5:

18 func increment(inc int) {

Se você olhar novamente para a chamada da função de incremento na linha 12, verá que o código passa o "valor" da contagem de variáveis. Este valor será copiado, transferido e colocado em um novo quadro para a função de incremento. Lembre-se de que a função de incremento só pode ler e gravar na memória em seu próprio quadro; portanto, é necessário que a variável inc receba, armazene e acesse sua própria cópia do valor do contador transmitido.

Pouco antes do código dentro da função de incremento começar a ser executado, a pilha do programa (em um nível muito alto) terá a seguinte aparência:

Figura 2:



Você pode ver que agora existem dois quadros na pilha - um para o principal e um abaixo para o incremento. Dentro do quadro para incremento, você pode ver a variável inc contendo o valor 10, que foi copiado e passado durante a chamada de função. O endereço da variável inc é 0x10429f98 e tem menos memória porque os quadros são colocados na pilha, que são apenas detalhes de implementação que não significam nada. O importante é que o programa recuperou o valor da contagem do quadro para main e colocou uma cópia desse valor no quadro para aumentar usando a variável inc.

O restante do código dentro do incremento é incrementado e exibe o "valor" e o "endereço" da variável inc.

Listagem 6:

21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")

A saída da linha 22 no playground deve ser algo como isto:

Listagem 7:

inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]

Aqui está a aparência da pilha após executar as mesmas linhas de código:

Figura 3:



Após executar as linhas 21 e 22, a função de incremento termina e retorna o controle para a função principal. Em seguida, a função principal novamente exibe o "valor" e o "endereço" da contagem da variável local na linha 14.

Listagem 8:

14    println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")

A saída completa do programa no playground deve ser algo como isto:

Listagem 9:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]
count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

O valor da contagem no quadro para main é o mesmo antes e depois da chamada para incrementar.

Retorno de funções


O que realmente acontece com a memória na pilha quando a função sai e o controle retorna à função de chamada? A resposta curta não é nada. Aqui está a aparência da pilha após o retorno da função de incremento:

Figura 4:



A pilha parece exatamente a mesma da Figura 3, exceto que o quadro associado à função de incremento agora é considerado memória inválida. Isso ocorre porque o quadro para main agora está ativo. A memória criada para a função de incremento permaneceu intocada.

A limpeza do quadro de memória da função de retorno será uma perda de tempo, porque não se sabe se essa memória será necessária novamente. Então a memória permaneceu do jeito que estava. Durante cada chamada de função, quando um quadro é capturado, a memória da pilha para esse quadro é limpa. Isso é feito inicializando quaisquer valores que se ajustem ao quadro. Como todos os valores são inicializados como seu "valor zero", as pilhas são limpas corretamente a cada chamada de função.

Partilha de Valor


E se fosse importante para a função de incremento trabalhar diretamente com a variável count que existe dentro do quadro para main? É aqui que chega a hora dos ponteiros. Os ponteiros têm um propósito - compartilhar um valor com uma função para que a função possa ler e gravar esse valor, mesmo que o valor não exista diretamente dentro de seu quadro.

Se você acha que não precisa "compartilhar" o valor, não precisa usar um ponteiro. Ao aprender dicas, é importante pensar que usando um dicionário limpo, não operadores ou sintaxe. Lembre-se de que os ponteiros devem ser compartilhados e, ao ler o código, substitua o operador & pela frase "compartilhamento".

Tipos de ponteiros


Para cada tipo que você declarou ou que foi declarado diretamente pelo próprio idioma, você obtém um tipo de ponteiro gratuito que pode ser usado para compartilhar. Já existe um tipo interno chamado int, portanto, existe um tipo de ponteiro chamado * int. Se você declarar um tipo chamado Usuário, receberá um tipo de ponteiro chamado * Usuário gratuitamente.

Todos os tipos de ponteiros têm duas características idênticas. Primeiro, eles começam com o caractere *. Em segundo lugar, todos eles têm o mesmo tamanho na memória e uma representação ocupando 4 ou 8 bytes que representam o endereço. Em arquiteturas de 32 bits (por exemplo, no playground), os ponteiros requerem 4 bytes de memória e, em arquiteturas de 64 bits (por exemplo, no seu computador), exigem 8 bytes de memória.

Na especificação, tipos de ponteirosão considerados literais de tipo , o que significa que são tipos sem nome compostos de um tipo existente.

Acesso indireto à memória


Veja este pequeno programa que faz uma chamada de função, passando o endereço "por valor". Isso dividirá a variável count do quadro da pilha principal com a função de incremento:

Listagem 10:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
10
11    // Pass the "address of" count.
12    increment(&count)
13
14    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc *int) {
19
20    // Increment the "value of" count that the "pointer points to". (dereferencing)
21    *inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
23 }

Três mudanças interessantes foram feitas no programa original. A primeira alteração está na linha 12:

Listagem 11:

12    increment(&count)

Desta vez, na linha 12, o código não copia e passa o "valor" para a variável count, mas passa a contagem para o seu "endereço". Agora você pode dizer: "Estou compartilhando" a contagem de variáveis ​​com o incremento da função. É isso que o operador & diz: "compartilhar".

Entenda que isso ainda está "passando por valor" e a única diferença é que o valor que você passa é o endereço, não o número inteiro. Endereços também são valores; é isso que é copiado e passado através da borda do quadro para chamar a função.

Como o valor do endereço é copiado e passado, você precisa de uma variável dentro do quadro de incremento para obter e salvar esse endereço inteiro. Uma declaração de variável de ponteiro inteiro está na linha 18.

Listagem 12:

18 func increment(inc *int) {

Se você passou o endereço do valor do tipo Usuário, a variável teria que ser declarada como * Usuário. Apesar de todas as variáveis ​​de ponteiro armazenarem valores de endereço, elas não podem receber nenhum endereço, apenas endereços associados ao tipo de ponteiro. O princípio básico de compartilhamento de um valor é que a função de recebimento deve ler ou gravar nesse valor. Você precisa de informações sobre o tipo de qualquer valor para ler e gravar nele. O compilador garantirá que apenas os valores associados ao tipo de ponteiro correto sejam usados ​​com esta função.

Aqui está a aparência da pilha depois de chamar a função de incremento:

Figura 5:



A Figura 5 mostra a aparência da pilha quando a "passagem por valor" é realizada usando o endereço como valor. A variável de ponteiro dentro do quadro para a função de incremento agora aponta para a variável de contagem, localizada dentro do quadro para principal.

Agora, usando a variável ponteiro, a função pode executar uma operação de leitura e alteração indireta para a variável count localizada dentro do quadro para main.

Listagem 13:

21    *inc++

Desta vez, o caractere * atua como um operador e é aplicado à variável do ponteiro. Usar * como operador significa "o valor que o ponteiro aponta". Uma variável de ponteiro fornece acesso indireto à memória fora do quadro da função que a utiliza. Às vezes, essa leitura ou escrita indireta é chamada de desreferenciamento de ponteiro. A função de incremento ainda precisa ter uma variável de ponteiro em seu quadro, que ela pode ler diretamente para executar o acesso indireto.

A Figura 6 mostra a aparência da pilha após a linha 21.

Figura 6:



Aqui está a saída final deste programa:

Listagem 14:

count:  Value Of[ 10 ]              Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 0x10429fa4 ]      Addr Of[ 0x10429f98 ]   Value Points To[ 11 ]
count:  Value Of[ 11 ]              Addr Of[ 0x10429fa4 ]

Você pode perceber que o "valor" da variável inc ponteiro corresponde ao "endereço" da variável count. Isso estabelece um relacionamento de compartilhamento que permite acesso indireto à memória fora do quadro. Assim que a função de incremento grava no ponteiro, a alteração fica visível para a função principal quando o controle é retornado a ela.

Variáveis ​​de ponteiro não são especiais


Variáveis ​​de ponteiro não são especiais porque são as mesmas variáveis ​​que qualquer outra variável. Eles têm uma alocação de memória e contêm significado. Aconteceu que todas as variáveis ​​de ponteiro, independentemente do tipo de valor para o qual possam apontar, sempre têm o mesmo tamanho e apresentação. O que pode ser confuso é que o caractere * atua como um operador dentro do código e é usado para declarar um tipo de ponteiro. Se você pode distinguir uma declaração de tipo de uma operação de ponteiro, isso pode ajudar a eliminar algumas confusões.

Conclusão


Esta postagem descreve o objetivo dos ponteiros, a operação da pilha e a mecânica dos ponteiros no Go. Este é o primeiro passo para entender a mecânica, os princípios de design e as técnicas de uso necessárias para escrever código coerente e legível.

No final, aqui está o que você aprendeu:

  • As funções são executadas dentro dos limites do quadro, que fornecem um espaço de memória separado para cada função correspondente.
  • Quando uma função é chamada, ocorre uma transição entre dois quadros.
  • A vantagem da transmissão de dados "por valor" é a legibilidade.
  • A pilha é importante porque fornece espaço de memória física para os limites do quadro que são atribuídos a cada função individual.
  • Toda a memória da pilha abaixo do quadro ativo é inválida, mas a memória do quadro ativo e acima é válida.
  • , .
  • , , .
  • — , , .
  • , , , , .
  • - , .
  • - - , , . , .

All Articles