Ponteiros, stack e heap em Go
Mateus Vinícius
Posted on March 3, 2024
Pointers
Ponteiros são variáveis cujo valor é um endereço na memória, e esse endereço pode conter qualquer tipo de valor - uma string, um int, uma struct e etc.
A sintaxe básica de ponteiros em Go é relativamente simples, mas iremos entrar nos detalhes ao decorrer do texto:
// declarando uma variável do tipo int
var foo int = 3
// exibindo na tela o endereço na memória da variável "foo"
// O resultado será algo como: 0xc000110010
fmt.Println(&foo)
// declarando uma variável do tipo ponteiro
// cujo endereço apontado é um variável do tipo int.
// estamos apontando para o endereço da variável foo
var bar *int = &foo
// exibindo na tela o valor da variável "bar", que é um endereço na memória.
// o resultado será o mesmo do Println anterior
// já que ambos apontam para o mesmo endereço na memória
fmt.Println(bar)
// exibindo na tela o valor original contido no endereço
// Resultado: 3
fmt.Println(*bar)
Primeiro é importante diferenciar os diferentes usos do operador *, também conhecido como star operator.
Star operator num tipo é chamado de pointer type, e indica que aquela variável é o ponteiro para algo - como int, string etc -, esse algo é chamado de base. Por exemplo, o código abaixo indica que a variável baz é um pointer type de base string:
var string name = "Matt"
var baz *string = &name
Já se o uso do star operator seguido de uma variável é uma forma de obter o valor original do endereço na memória que o ponteiro está salvando. Chamamos isso de dereferencing.
var name string = "Matt"
var baz *string = &name
fmt.Println(*baz) // Resultado: "Matt"
Apesar da sintaxe simples, o uso apropriado de ponteiros no Go pode se tornar complexo devido ao fato de não ser sempre claro qual será o comportamento do compilador na alocação de memória. Basicamente um ponteiro pode ser salvo na stack ou no heap, e a escolha de um ou outro é muitas vezes implícita.
Stack
Stack - ou pilha - é uma estrutura de dado que salva cada bloco de código que é executado numa goroutine, de forma isolada e ordenada, e cada bloco é chamado de frame. Exemplo, no código abaixo temos uma função main, que chama uma função greetings, que chama a função fmt.Println:
package main
import "fmt"
func main() {
name := "Matt"
greetings(name)
}
func greetings(name string) {
fmt.Println(name)
}
Executando o código, esse seria o comportamento da stack:
Após a execução do frame terminar, a stack automaticamente se limpa sozinha, jogando fora toda a memória alocada em cada frame e finalizando nosso programa.
Normalmente frames não podem acessar endereços na memória de fora dele, então se um ponteiro for criado num frame e apenas acessado nesse frame, aquela memória normalmente será alocada dentro da stack, no próprio frame, e será zerada no momento em que esse frame terminar a sua execução e for removido da stack. O mesmo vale caso o ponteiro seja criado num frame e passado pra baixo na execução dos frames filhos, por exemplo no código abaixo:
package main
import "fmt"
func main() {
name := "Matt"
greetings(&name)
}
func greetings(name *string) {
fmt.Println(*name)
}
A função greetings recebe um ponteiro que aponta para o endereço na memória da variável name, e essa variável está salva dentro do frame main, que não será removido da stack até a função greetings e fmt.Println terminarem sua execução, então é seguro manter a variável salva lá.
Importante mencionar que, caso a variável passada não seja um ponteiro e sim uma string, por exemplo, o valor é copiado ao passar de um frame para o outro, pois, como dito anteriormente, frames normalmente não podem acessar memória de fora do seu próprio bloco de execução.
Dado o comportamento de self cleaning, ou seja, de remover da stack o frame assim que ele finaliza sua execução, a stack não precisa de ajuda do garbage collector, o que melhora a performance do código.
Heap
Mas existem situações em que não seria seguro manter a memória salva no próprio frame, pois ele pode ser acessado de fora mesmo após o frame ser removido da stack, o que causaria a perda do seu valor, retornando apenas nil. Por exemplo, no trecho abaixo:
package main
import "fmt"
func main() {
name := createName()
greetings(name)
}
func createName() *string {
name := "Matt"
return &name
}
func greetings(name *string) {
fmt.Println(*name)
}
A função createName cria o ponteiro que aponta para a variável name, mas ao finalizar sua execução a função greetings ainda precisa acessar esse valor. O comportamento da stack, nesse caso, seria esse:
Mas se o frame da função createName foi removido da stack após sua execução, o que significa que toda a memória foi zerada, como a função greetings ainda assim conseguiu acessá-lo? A resposta é que, nesse caso, a memória não foi alocada dentro do frame na stack, mas sim num lugar de fora, chamado heap.
O heap é o local que guarda essas memórias alocadas que precisam ser compartilhadas entre frames e até entre stacks, sem que sejam apagadas após a execução do frame que as criou.
Dado esse comportamento pode parecer muito tentador usar apenas memória alocada no heap, mas, como não há o comportamento de self cleaning, o heap precisa de ajuda do garbage collector do Go, e isso têm um custo computacional, criando cada vez mais latência no programa.
Mas nem sempre a alocação em memória do Go é tão previsível assim, por isso destaquei o "normalmente" ao descrever os comportamentos de alocação na stack e no heap, mas isso será melhor explorado num momento futuro. Por ora basta entender que cada tipo de alocação tem suas vantagens e desvantagens, e que usar ponteiros, apesar de parecer simples, pode trazer certa complexidade e custos por trás.
Posted on March 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.