trilha

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

Mutabilidade

Perguntas-guia
  • Quais tipos da sua linguagem são imutáveis por natureza?
  • Que classe de bug a imutabilidade elimina — e qual custo ela cobra?
  • O que acontece quando um objeto mutável é chave de hash ou default de função?
  • Onde no seu código atual uma cópia defensiva resolveria um bug latente?

Conceito

Um valor é mutável quando seu estado pode mudar depois de criado, no lugar, de forma observável por qualquer alias que aponte para ele.

O ponto que faz o resto fazer sentido

Mutação sozinha é inofensiva. Aliasing sozinho é inofensivo. O problema é a soma dos dois.

Se ninguém mais aponta pro valor, mutar é seguro. Se o valor nunca muda, dez mil aliases são seguros. Ação a distância só existe quando alguém muta algo que outro alguém está olhando.

Isso reformula o que imutabilidade faz: ela não elimina aliasing — ela torna aliasing seguro.

O que imutabilidade compra:

  • compartilhar sem copiar e sem travar. Se ninguém pode mutar, ninguém precisa de cópia defensiva nem de lock. É por isso que linguagens funcionais lidam bem com concorrência: o problema não é resolvido, é inexistente.
  • identidade estável. Chave de hash, cache e conjunto exigem que o valor não mude depois de indexado — mutar uma chave depois de inserida corrompe a estrutura de forma silenciosa.
  • raciocínio local. O valor que você leu continua sendo o valor que você leu.
  • snapshot barato via estruturas persistentes com compartilhamento estrutural.

O que ela cobra: toda "modificação" aloca. Construir um valor incrementalmente com operações imutáveis ingênuas é quadrático — cada passo copia o acumulado.

O espectro de suporte das linguagens (importa mais que a pergunta binária):

Nível O que significa Exemplos
Imutabilidade profunda imposta mutação simplesmente não existe Haskell, Clojure, Erlang
Imutabilidade rasa opt-in você marca; o compilador cobra Rust (& vs &mut), C++ const
Semântica de valor atribuição copia; limita o aliasing em vez de proibir mutação C, Go (structs), Swift
Convenção a linguagem não ajuda Go (no geral), Java, Python

Note que semântica de valor não é imutabilidade. Ela reduz aliasing — e o original continua mutável por quem o tiver.

Onde a mutabilidade morde, sempre nos mesmos quatro lugares: chave de hash, valor default compartilhado, cache/igualdade, e concorrência.

A contramedida universal: cópia defensiva. Duas fronteiras, e a maioria dos projetos cuida só de uma:

  • saída — o que você devolve para fora
  • entrada — o que você aceita e guarda

Em Go

Go fica quase todo na última linha da tabela: convenção.

O que é imutável de fato:

  • strings[0] = 'x' não compila. Fatiar é O(1) e compartilha o array de bytes.
  • const — e só para tipos básicos: bool, string, numéricos. Não existe const de struct, slice ou map.
  • E acabou. Não há readonly, final, frozen, nem struct imutável de verdade.

O que Go dá em troca: semântica de valor. Struct e array são copiados na atribuição e na passagem, o que dá imutabilidade efetiva enquanto ninguém tiver ponteiro. É a linha 3 da tabela, e é genuinamente útil — mas é uma garantia mais fraca do que parece.

O buraco clássico: struct "imutável" com campo slice ou map. O campo é um header/ponteiro; a cópia da struct compartilha os dados de trás. Cópia rasa, sempre.

Onde Go ajuda mais que a média: o compilador proíbe estruturalmente chave de mapa não comparável. Slice, map e func não compilam como chave. Isso mata na compilação um bug que Java e Python só descobrem em produção.

Onde ele não ajuda:

  • struct com ponteiro compila como chave e compara pelo ponteiro
  • map[any]V compila e dá panic em runtime com chave não comparável
  • time.Time como chave é armadilha: == compara wall clock, monotonic e location. Use .Equal ou normalize
  • array é chave válida ([2]int sim, []int não)

O equivalente ao "default mutável compartilhado": Go não tem argumento default, mas tinha a variável de loop capturada — corrigido no Go 1.22, quando cada iteração passou a ganhar variável nova. Antes disso todo for _, v := range xs { go func(){ use(v) }() } era bug.

Ferramentas de cópia defensiva: slices.Clone e maps.Clone, stdlib desde o Go 1.21. Ambas rasas.

Respostas às perguntas-guia

1. Quais tipos da sua linguagem são imutáveis por natureza?

Conceito: a pergunta relevante não é quais tipos, é em que nível do espectro a linguagem está — imposta, opt-in, semântica de valor, ou convenção.

Em Go:string (e constantes, que não são valores em runtime). Nada mais. Slice, map e chan já são referências mutáveis compartilhadas.

2. Que classe de bug a imutabilidade elimina — e qual custo ela cobra?

Conceito: elimina mutação a distância — o dado que você guardou muda porque alguém tinha alias dele. Cobra alocação em toda modificação, e construção ingênua vira O(n²).

Em Go, a mutação a distância aparece em dois lugares muito específicos: o getter que devolve o slice interno, e o construtor que guarda o slice recebido de fora. O custo aparece na concatenação de string em loop — cada += aloca uma string nova. Use strings.Builder.

3. O que acontece quando um objeto mutável é chave de hash ou default de função?

Conceito: chave de hash — o hash é calculado na inserção. Se a chave muda depois, o valor fica num bucket onde ninguém mais o procura: perdido, sem erro. Default compartilhado — o valor é criado uma vez e reusado entre chamadas, então uma mutação vaza de uma invocação para a seguinte.

Em Go, o compilador te salva do primeiro caso (slice/map/func não compilam como chave) e não te salva de struct-com-ponteiro, de map[any], nem de time.Time. Default de função não existe; o equivalente era a variável de loop, resolvido no 1.22.

4. Onde no seu código atual uma cópia defensiva resolveria um bug latente?

Conceito: nas duas fronteiras do tipo — o que sai e o que entra. A maioria protege só a saída.

Em Go: slices.Clone no getter que devolve []T, e clone antes de guardar um slice recebido de fora no construtor ou setter.

Trade-offs

Do conceito:

  • Imutabilidade imposta elimina a classe de bug e te obriga a estruturas persistentes, que têm constante pior e complexidade conceitual maior.
  • Opt-in verificado (Rust) dá o melhor dos dois e cobra em curva de aprendizado — o compilador passa a discutir com você.
  • Semântica de valor é barata e dá garantia rasa, o que é uma armadilha justamente porque parece garantia total.
  • Convenção custa zero e falha exatamente quando o time cresce.

Da escolha de Go:

  • String imutável dá slicing O(1) e chave de mapa segura; cobra alocação em toda modificação.
  • Semântica de valor evita aliasing sem depender do GC; copiar struct grande custa, e a cópia é sempre rasa.
  • Cópia defensiva é decisão, não regra: ela custa alocação em caminho quente. Quando você escolher não copiar, documente o contrato ("o slice devolvido é interno, não modifique").
  • Receiver por valor parece imutabilidade e não é: protege os campos escalares, não o que está atrás dos ponteiros.

Exemplo prático

package main

import (
	"fmt"
	"maps"
	"slices"
	"strings"
)

type Config struct {
	nome string
	tags []string // <- o furo na "imutabilidade" da struct
}

func (c Config) Tags() []string       { return c.tags }              // vaza o interno
func (c Config) TagsSeguro() []string { return slices.Clone(c.tags) } // cópia defensiva

func main() {
	c := Config{nome: "prod", tags: []string{"a", "b"}}
	d := c              // "cópia" da struct...
	d.Tags()[0] = "X"   // ...mas o array de trás é compartilhado
	fmt.Println(c.tags) // [X b]  <- o "original" mutou

	c2 := Config{nome: "prod", tags: []string{"a", "b"}}
	c2.TagsSeguro()[0] = "X"
	fmt.Println(c2.tags) // [a b]  <- protegido

	// string é imutável:
	s := "olá"
	// s[0] = 'O' // erro: cannot assign to s[0] (neither addressable nor a map index expression)
	s = strings.ToUpper(s) // gera string nova
	fmt.Println(s)

	// concat em loop é O(n^2); Builder é O(n):
	var b strings.Builder
	for i := 0; i < 3; i++ {
		b.WriteString("x")
	}
	fmt.Println(b.String())

	m := map[string]int{"a": 1}
	m2 := maps.Clone(m) // cópia RASA (Go 1.21+)
	m2["a"] = 2
	fmt.Println(m["a"], m2["a"]) // 1 2
}

O que o compilador deixa passar como chave:

type Chave struct{ p *int }

func main() {
	x, y := 1, 1
	m := map[Chave]string{}
	m[Chave{&x}] = "primeiro"
	m[Chave{&y}] = "segundo"
	fmt.Println(len(m)) // 2 — mesmo valor apontado, ponteiros diferentes

	// _ = map[[]int]string{} // erro de compilação: invalid map key type []int

	qualquer := map[any]string{}
	qualquer[[]int{1}] = "x" // COMPILA e dá panic: hash of unhashable type []int
}

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