trilha

Fundamentos de Programação/01 - Fundamentos Duros/Semana 01 - Diagnóstico e o que Acontece por Baixo10 min

Passagem por Valor vs por Referência

Perguntas-guia
  • Sua linguagem passa argumentos por valor, por referência, ou por valor-da-referência?
  • Depois de f(x), em que casos x mudou para quem chamou?
  • Por que a = b às vezes copia e às vezes aliasa? (é o entregável da semana)
  • Como você provaria isso com um teste em 5 linhas?

Conceito

A pergunta é uma só: quando uma função recebe um argumento, ela recebe uma cópia do valor ou um alias para a variável de quem chamou?

Por valor. A função recebe uma cópia. Escrever no parâmetro é invisível para quem chamou.

Por referência. A função recebe um alias para o armazenamento de quem chamou. Escrever no parâmetro muda a variável de quem chamou. É C++ int&, Pascal var, C# ref, Fortran.

A distinção que quase todo mundo erra

Passar um ponteiro por valor não é passagem por referência.

Nos dois casos você consegue mutar o objeto apontado. Só na passagem por referência você consegue religar a variável de quem chamou a outro objeto.

O teste que separa os dois: a função consegue fazer a variável de quem chamou apontar para um objeto diferente? Por referência: sim. Ponteiro por valor: não — você só religou a sua cópia do ponteiro.

Java, Python, JavaScript, Go e C# (sem ref) fazem ponteiro por valor, e todas as cinco são rotineiramente descritas como "por referência". O nome correto para esse comportamento é call by sharing: você e a função compartilham o objeto, mas cada um tem sua própria variável apontando pra ele.

Cópia rasa é a regra universal. Copiar um valor copia exatamente a representação dele. Se a representação contém uma referência, a cópia compartilha o referente. Nenhuma linguagem mainstream copia profundamente por padrão — e por bons motivos: cópia profunda é caríssima, e frequentemente indefinida (o que copiar de um socket aberto? de um grafo com ciclo?).

Consequência prática: a semântica de mutação não está na sintaxe da chamada, está no tipo do argumento. Olhar f(x) não te diz nada — é a assinatura e o tipo que decidem.

Em Go

Go passa tudo por valor. Sempre. Sem exceção.

"Não tem passagem por referência" não é "não tem ponteiro"

A palavra referência cobre duas coisas diferentes, e só uma delas falta em Go:

O que é Go tem?
Referência como valor um valor que denota outro lugar: o tipo *T, o que &x produz sim, e é central
Passagem por referência modo de passagem em que o parâmetro é a variável do chamador não

* faz exatamente o que você espera: *T é o tipo ponteiro, *p desreferencia, &x pega o endereço. O que não existe é o void f(int& x) do C++ — um parâmetro que seja a variável de quem chamou, sem ponteiro no meio.

A prova de que ponteiro é passado por valor: você consegue mutar o apontado, mas não consegue religar o ponteiro do chamador — para isso precisa de mais um nível de indireção. Essa necessidade é a prova.

func mutaApontado(p *int) { *p = 99 } // muta o APONTADO: funciona

func tentaReligar(p *int) {
	outro := 42
	p = &outro // religa a CÓPIA local de p. Invisível para quem chamou.
}

func religaDeVerdade(pp **int) {
	outro := 42
	*pp = &outro // agora sim
}

func main() {
	x := 1
	p := &x
	antes := p

	mutaApontado(p)
	fmt.Println(x) // 99    <- o apontado mudou

	tentaReligar(p)
	fmt.Println(p != antes) // false <- p NÃO mudou

	religaDeVerdade(&p)
	fmt.Println(p != antes, *p) // true 42
}

Em C++ o equivalente de **int é int*&, e ele não é necessário: lá o parâmetro já é a variável do chamador. Em Go, * te dá uma referência como valor que você passa — nunca uma referência como modo de passar.

"Mas se é por valor, como *p = 99 muda o x?"

Porque p também é uma variável: ela tem endereço próprio e guarda um valor, e esse valor por acaso é um endereço. Passagem por valor copia o valor que ela guarda — não a coisa que esse valor aponta.

Imprimir os endereços torna a cópia visível:

func recebe(p *int) {
	fmt.Printf("  funcao: p VALE %p | p MORA EM %p\n", p, &p)
	*p = 99
}

func main() {
	x := 1
	p := &x
	fmt.Printf("main:     p VALE %p | p MORA EM %p\n", p, &p)
	recebe(p)
	fmt.Println(x) // 99
}
main:     p VALE 0x...826128 | p MORA EM 0x...816040
  funcao: p VALE 0x...826128 | p MORA EM 0x...982000
               ^ mesmo valor          ^ endereço DIFERENTE

Os endereços mudam a cada execução — o que importa é que a segunda coluna difere: p MORA EM não é o mesmo nos dois, ou seja, são duas variáveis distintas. Essa é a cópia, tornada visível. O que foi copiado foi o número 0x...826128 — o endereço de x.

Operação Escreve onde Atravessa a cópia?
*p = 99 no endereço escrito em p (isto é, em x) sim — as duas cópias guardam o mesmo endereço
p = &outro em p (na variável local da função) não — cada cópia é uma variável separada

A analogia: p é um papel com um endereço escrito. Passar p dá à função uma fotocópia do papel. *p = 99 é "vá ao endereço escrito no papel e pinte a casa" — a fotocópia tem o mesmo endereço, a casa é a mesma, você vê a pintura. p = &outro é "apague o papel e escreva outro endereço" — só a fotocópia muda. Para a função alterar o seu papel, você entrega o endereço do papel: &p, tipo **int.

A escada de alcance — passagem por valor nunca é violada; cada * compra um degrau:

Assinatura O que é copiado Até onde alcança
f(v int) o número nada do chamador
f(p *int) um endereço 1 degrau: o int apontado
f(pp **int) o endereço de um ponteiro 2 degraus: o ponteiro e o int

E é assim que se reconhece passagem por referência de verdade: ela dá um degrau de alcance sem você escrever *, e sem & no ponto de chamada. Em C++, void f(int& x) chamado como f(y) altera y, e nada na chamada avisa. Em Go você é obrigado a escrever f(&y) — a transferência do direito de mutação fica visível na chamada. Essa é a vantagem real de só existir passagem por valor.

A segunda fonte de confusão: alguns tipos contêm ponteiros na própria representação. Copiar o valor copia o ponteiro fielmente, e as duas cópias passam a olhar o mesmo dado:

Tipo O que a = b copia Mutação visível pra quem chamou?
int, bool, float64, struct, array todos os bytes não
*T o endereço (8 bytes) sim, via *p
[]T header: ptr + len + cap (24 bytes) elementos sim; append não
map[K]V ponteiro pro hmap (8 bytes) sim
chan T ponteiro pro hchan (8 bytes) sim
string header: ptr + len (16 bytes) não (imutável)
func ponteiro pro closure via variáveis capturadas
interface (tipo, dado) — 16 bytes depende do que está dentro

Go é honesto onde Java não é: em Java, Integer e ArrayList têm sintaxe idêntica e semântica de cópia diferente, e nada na declaração avisa. Em Go, []T versus [3]T é visível no tipo.

Duas pegadinhas específicas de Go:

append não cresce o slice de quem chamou — nem quando não realoca. len faz parte do header, e o header foi copiado.

Receiver por valor copia o receiver. Se o tipo tem um sync.Mutex, você copiou o mutex e o lock não protege nada. go vet acusa: passes lock by value.

Respostas às perguntas-guia

1. Sua linguagem passa argumentos por valor, por referência, ou por valor-da-referência?

Conceito: três comportamentos possíveis, e o terceiro (ponteiro por valor, ou call by sharing) é o mais comum e o mais mal nomeado.

Em Go: sempre por valor — inclusive os ponteiros. Ter *T e &x não é ter passagem por referência: o *T é copiado como qualquer outro valor de 8 bytes. Slice, map e chan são valores que contêm ponteiro; "por valor da referência" descreve bem o efeito, mas o mecanismo é um só: copiar os bytes da representação.

2. Depois de f(x), em que casos x mudou para quem chamou?

Conceito: quando f teve acesso ao armazenamento de x (passagem por referência), ou quando f seguiu uma referência que estava dentro de x e mutou o referente. Nunca por reatribuir o parâmetro.

Em Go, mudou quando:

  • f recebeu *T e escreveu em *p
  • x é slice e f escreveu em elemento existente (s[0] = ...)
  • x é map ou chan
  • x é struct que contém ponteiro/slice/map e f seguiu o ponteiro

Nunca por append, e nunca por reatribuição de x dentro de f.

3. Por que a = b às vezes copia e às vezes aliasa?

Conceito: sempre copia. Copia a representação. Quando a representação é uma referência, copiar a referência te dá alias do referente. A pergunta certa nunca é "copia?", é "copia o quê?".

Em Go, desenvolvido em 5. Entregável - Por que a = b às vezes copia — inclusive a tabela de tamanhos, que é literalmente a resposta.

4. Como você provaria isso com um teste em 5 linhas?

Conceito: um teste que separa mutar o referente de religar a variável. Se o primeiro é visível e o segundo não, você tem ponteiro por valor.

Em Go:

func TestAliasing(t *testing.T) {
	s := []int{1, 2, 3}
	muta(s)       // s[0] = 99      -> muta o referente
	acrescenta(s) // s = append(...) -> religa a variável local
	if s[0] != 99 || len(s) != 3 {
		t.Fatalf("esperado [99 2 3] len 3, veio %v len %d", s, len(s))
	}
}

O teste passa: o elemento mudou, o comprimento não. É a prova em duas linhas.

Trade-offs

Do conceito:

  • Por valor é fácil de raciocinar (nenhuma ação a distância) e custa a cópia.
  • Por referência evita cópia e permite funções que religam variáveis do chamador — poder que quase sempre produz código difícil de seguir.
  • Ponteiro por valor é o meio-termo que todas as linguagens gerenciadas escolheram: barato, permite mutação compartilhada intencional, e cobra o preço de ninguém entender direito o que está acontecendo.
  • Cópia rasa universal é a decisão certa por performance e a origem de uma classe inteira de bugs de aliasing.

Da escolha de Go:

  • Uma regra só (sempre valor) é simples; o preço é que a semântica depende do tipo, e a chamada não te diz nada.
  • Struct grande por valor custa a cópia; por ponteiro custa possível alocação (2. Stack vs Heap), indireção e nilabilidade.
  • Slice header de 24 bytes é baratíssimo de copiar — e é exatamente isso que esconde o aliasing do array de trás.
  • Receiver por valor dá imutabilidade local de graça, e é armadilha em qualquer tipo com mutex, contador ou atomic.

Exemplo prático

package main

import "fmt"

type Point struct{ X, Y int }

func mutaElemento(s []int)   { s[0] = 99 }        // VISÍVEL
func acrescenta(s []int)     { s = append(s, 4) } // invisível
func trocaStruct(p Point)    { p.X = 99 }         // invisível
func trocaPonteiro(p *Point) { p.X = 99 }         // VISÍVEL
func mutaMapa(m map[string]int) { m["a"] = 99 }   // VISÍVEL

func main() {
	s := []int{1, 2, 3}
	mutaElemento(s)
	fmt.Println(s) // [99 2 3]     <- mudou

	acrescenta(s)
	fmt.Println(s, len(s)) // [99 2 3] 3  <- NÃO mudou

	p := Point{1, 1}
	trocaStruct(p)
	fmt.Println(p) // {1 1}
	trocaPonteiro(&p)
	fmt.Println(p) // {99 1}

	m := map[string]int{"a": 1}
	mutaMapa(m)
	fmt.Println(m) // map[a:99]    <- mudou
}

O aliasing que morde de verdade — dois slices, um array:

a := []int{1, 2, 3, 4, 5}
b := a[:2]        // len 2, cap 5 — MESMO array de trás
b = append(b, 99) // cap sobra: NÃO realoca, escreve em a[2]
fmt.Println(a)    // [1 2 99 4 5]  <- a mudou sem ninguém tocar em a
fmt.Println(b)    // [1 2 99]

O mesmo append que era invisível na função é destrutivo aqui. A diferença é cap. Se você quer garantia de cópia: b := slices.Clone(a[:2]), ou o idioma a[:2:2] (limita cap, forçando o próximo append a realocar).

O mutex copiado:

import "sync"

type Contador struct {
	mu sync.Mutex
	n  int
}

func (c Contador) IncErrado()  { c.mu.Lock(); defer c.mu.Unlock(); c.n++ } // copia tudo
func (c *Contador) IncCerto()  { c.mu.Lock(); defer c.mu.Unlock(); c.n++ }

IncErrado não incrementa e não protege — go vet acusa IncErrado passes lock by value. Regra prática: se o tipo tem estado mutável, todos os métodos usam receiver ponteiro.

Relacionado


Parte de Semana 01 - Diagnóstico e o que Acontece por Baixo · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas