Mutabilidade e passagem de argumentos
Princípio: dentro de um processo não há aliasing concorrente
Seção intitulada “Princípio: dentro de um processo não há aliasing concorrente”Um processo tem uma única linha de execução. O scheduler o suspende nos pontos cooperativos (io.*, send/recv, <->) e pode preemptá-lo entre instruções (seção 3). Em qualquer caso, a troca é sempre para outro processo, com seu próprio heap isolado (seção 5), e nenhum outro processo toca o heap dele. Dois trechos de código nunca mutam a mesma memória ao mesmo tempo dentro de um processo. É o data-race sob aliasing que obriga o Rust a ter borrow checker e lifetimes; intra-processo, esse motivo não existe.
O que sobra é menor: use-after-free, confinado a @mm(none) (onde você pediu responsabilidade estilo C); e bug de aliasing por mutar uma estrutura enquanto se segura outra referência a ela. O modelo abaixo elimina o segundo por padrão e mantém o primeiro como território explícito de ponteiro.
Imutável por padrão
Seção intitulada “Imutável por padrão”A linguagem é imutável por padrão. O caso seguro é o que se escreve sem cerimônia; só se marca o que tem capacidade de escrita. Há quatro formas de declaração, e o atalho é o caso comum:
| Declaração | Significado |
|---|---|
x := … |
imutável, runtime (default); atalho de let |
let x := … |
imutável, runtime (forma explícita) |
var x := … |
mutável, runtime |
const x := … |
constante de compilação (ver seção 10) |
Tipo explícito ao estilo Odin: x: int = 5. E := é literalmente : = com o tipo omitido: o mesmo construto, tipo inferido. A regra: := (ou : T =) declara, = pelado só atribui (a um var); o : é o que marca declaração.
x := 5 // declara, imutávelx = 10 // ERRO: x não é var
var y := 5 // declara, mutávely = 10 // oklet e o := pelado significam o mesmo, imutável runtime; let é a forma explícita, igual var é para mutável. const é o eixo de compilação e mora na seção 10. A gramática completa de declaração e as demais keywords estão na seção 14 (Sintaxe).
Um nome é declarado uma vez por escopo: re-declará-lo no mesmo escopo é erro de compilação. x := 1 e depois x := 2 (ou um segundo let x) no mesmo escopo é rejeitado — não é mutação (que é var mais =) nem shadowing (a linguagem não tem shadowing local, seção 6/9), então só pode ser uma redeclaração ilegal de um nome imutável. Para mudar um valor no lugar, declare-o var e atribua com =; para reusar um nome com outro valor, abra um novo escopo. (A checagem é uniforme entre backends: o compilador nativo tem que rejeitar o re-:=, não rebindar silenciosamente.) Sombrear um parâmetro com um let/:= no corpo é permitido (seção 14): o parâmetro vive num escopo externo, então o binding do corpo o sombra em vez de re-declará-lo.
Capacidade de escrita declarada e nunca exercida é erro de compilação, no espírito do “import/variável não-usada” do Go. Um var que nunca é mutado deveria ser let/:=, e o compilador recusa:
var x := compute()return x * 2 // ERRO: 'x' é var mas nunca foi mutado. Use a forma imutávelIsso é o que dá dente ao imutável-by-default: cada pedido de mutabilidade tem que ser pago com um uso, senão não compila. Mata o reflexo de “deixa tudo mutável por via das dúvidas”.
Três conceitos para valor e mutação
Seção intitulada “Três conceitos para valor e mutação”São três mecanismos, cada um respondendo a uma pergunta diferente; não são sabores intercambiáveis:
| Conceito | Pergunta que responde | É um tipo? |
|---|---|---|
| valor (default) | “só preciso do dado” | n/a |
mut (passagem) |
“quero mutar o estado de quem me chamou, transitório e seguro” | não |
*T (ponteiro) |
“quero uma referência que persiste e pode ser compartilhada” | sim |
Sob uma ótica, mut e *T são a mesma coisa: handles de escrita. Daí a regra única que governa os dois:
Escrever num lugar exige que o lugar seja
var.mut(escrita transitória) e*T(escrita persistente) são os dois jeitos de obter escrita; ambos exigem alvovar. Imutável (let/:=/const) permite só ler, copiar e compartilhar.
Manter os três em eixos distintos é o que evita o emaranhado &T / &mut T / *const T / *mut T do Rust. Aqui não existe “ponteiro mutável” como tipo à parte: mut não é tipo, e há um só tipo de ponteiro.
Coleções são valores: copiam no bind, compartilham só via view ou ponteiro
Seção intitulada “Coleções são valores: copiam no bind, compartilham só via view ou ponteiro”O default por valor é uniforme: vale para coleções também, não só para escalares e structs. Uma slice, um array e uma List são tipos por valor. Fazer o bind de uma copia, construir um struct com uma num campo copia para o campo, e retornar uma de uma função copia para fora. Dois bindings nunca compartilham silenciosamente um backing store:
xs := []int{1, 2, 3}var ys := xs // ys é uma cópia independenteys[0] = 9 // xs continua [1, 2, 3]; só ys mudouCompartilhar nunca é implícito; você pede, e há exatamente dois jeitos de pedir:
- uma view,
xs[a..b](e a forma da slice inteiraxs[..]), que é uma janela limitada sobre o mesmo backing store (seção 6,[]T=[*]T+ comprimento). Escrever pela view escreve o original. - um ponteiro,
&x, que é um alias persistente com identidade no heap (abaixo).
var xs := []int{1, 2, 3}var v := xs[..] // uma view: compartilha o backing de xsv[0] = 9 // xs agora é [9, 2, 3]Escrever por uma view é por elemento (v[i] = x) ou uma região inteira de uma vez (xs[a..b] = ys, uma escrita em bloco que sobrescreve a janela a partir de ys, com a largura casando o intervalo). Ambas alcançam o backing, então uma escrita de região num [*]T preenche o buffer que ele aponta do mesmo jeito.
É por isso que as duas perguntas de aliasing colapsam numa única resposta. “Um campo de struct aliasa a slice de que o construí?” Não, a construção copia. “let ys := xs congela uma referência compartilhada a um xs mutável?” Não, tira um snapshot por cópia, que é exatamente o que você queria ao salvar o estado. O aliasing só existe onde você o escreveu (uma view ou um &), então não há coloring acidental: um binding imutável de uma coleção mutável é sempre uma cópia limpa, nunca um alias somente-leitura escondido que precise ser rastreado.
A cópia é uma garantia semântica, não um memcpy obrigatório. Para um valor imutável nada nunca escreve no backing store, então o compilador é livre para aliasá-lo internamente sem cópia (linha acima, sobre compartilhamento de imutáveis): o comportamento observável é cópia-no-bind, a implementação elide a cópia sempre que uma escrita nunca puder ser observada. Você raciocina sobre valores; o compilador otimiza por baixo.
Mutabilidade é do lugar, não do campo
Seção intitulada “Mutabilidade é do lugar, não do campo”Uma struct é mutada como um todo, pelo lugar por onde se chega nela. Um campo carrega um tipo e uma visibilidade, nunca um var. p.x = v é legal exatamente quando o lugar enraizado em p é var; o campo herda a mutabilidade do binding, não declara a sua. É a mesma regra do lugar de cima, aplicada um nível abaixo: escrever p.x é escrever num lugar, e o lugar é var ou não é.
Mutabilidade e visibilidade parecem que deviam decorar ambas um campo, quatro caixinhas a marcar (pub/priv × mut/imut). Não decoram, porque são eixos diferentes vivendo em níveis diferentes (seção 2). Visibilidade é do campo: responde “quem pode alcançar isto”, uma fronteira de acesso que é inerentemente por-campo e atravessa módulos. Mutabilidade é do lugar: responde “isto pode ser escrito”, uma capacidade que flui do binding por todo o caminho do lvalue. Um marcador var num campo não teria onde ficar de pé coerentemente: dentro de um let p ele não poderia escrever (a regra do lugar proíbe), e dentro de um var p ele seria redundante. Ou contradição ou no-op, então o campo nunca o carrega.
O caso que tenta você a um var por-campo (um id fixo na construção enquanto o name ainda muda) é encapsulamento, e encapsulamento é o eixo da visibilidade fazendo o seu trabalho: torna o id privado, sem método que o escreva. A struct inteira pode ser var; os próprios métodos do tipo decidem quem muta o quê. Então as quatro caixinhas colapsam em duas para um campo (pub/priv), e mutabilidade não contribui nenhuma, porque nunca foi do campo a carregar.
mut: passagem com write-back
Seção intitulada “mut: passagem com write-back”mut é o equivalente ao inout do Swift. Por padrão, argumentos vão por valor: a função recebe uma cópia e não altera a variável de quem chamou. Para a função mutar a variável do caller, marca-se o parâmetro com mut, e a marca se repete no call site. Pela regra acima, o alvo tem que ser var:
fn process(value: int) { ... } // por valor; o caller não mudafn process(value: mut int) { ... } // write-back
var x := 5process(x) // cópia: x continua 5process(mut x) // muta x in-place
z := 5process(mut z) // ERRO: z é imutável; mut exige varDentro da função, um parâmetro mut é mutável (é o propósito dele); um parâmetro comum é binding imutável:
fn a(value: int) { value = 9 } // ERRO: parâmetro comum é imutávelfn b(value: mut int) { value = 9 } // ok: muta e devolve ao caller
fn accumulate(total: mut int, values: []int) { loop v in values { total += v }}var sum := 0accumulate(mut sum, data)Quer mexer numa cópia local sem write-back? Rebind com var, sem sintaxe nova: é a mesma declaração mutável:
fn process(value: int) { var acc := value // cópia mutável local acc += 10 // mexe na minha; não afeta o caller}Propriedades de mut:
- Não é um tipo. Não se declara uma variável
mut, não se guarda em struct ou array, não se repassa adiante. Existe só durante a chamada. - Exclusivo. Não dá para passar o mesmo lugar como
mutduas vezes na mesma chamada (f(mut x, mut x)é erro). A garantia é local: o compilador só verifica “este lugar já foi emprestadomutnesta chamada?”, sem análise de lifetime do programa inteiro. - Não escapa. Como o acesso não vira valor e morre no fim da chamada, a exclusividade é checável localmente. Mesmo espírito do
@transfer(“esta variável já foi transferida?”):transferé a versão entre-processos (exclusivo, origem invalidada para sempre);muté a versão intra-processo e com escopo (exclusivo pela duração da chamada, origem volta depois). - Nunca exercido é erro. Um parâmetro
mutque a função jamais escreve é um contrato falso: promete write-back e não cumpre. Cai na mesma regra dovarnunca-mutado.
mut se aplica a lugares (lvalues): mut x, mut p.campo, mut arr[i], mut *p. Isso não cria um *mut: é o mesmo mut sobre um lugar que por acaso foi alcançado via ponteiro, e você passa o apontado em write-back. (Em variável simples, a exclusividade é garantida estaticamente; em mut *p não, porque o ponteiro pode aliasar. Mas aí você já está em território de ponteiro, onde a responsabilidade é sua.)
Write-back é a semântica, by-ref é a implementação. “Cópia entra, cópia sai” é como você raciocina sobre mut (sem aliasing espúrio para atrapalhar), mas o compilador baixa mut para passagem por referência quando a exclusividade vale, e para um lugar direto ela sempre vale. Então passar uma struct grande como mut, ou preencher uma árvore campo a campo via helpers (f(mut node.left)), é zero-cópia: o padrão C de “constrói na stack, passa pra função preencher” funciona sem forçar heap, e sem sopa de asteriscos, já que você escreve mut node.left, não &node.left aqui e *p ali. A cópia literal sobra só para escalares, onde é mais barata que um ponteiro de qualquer jeito.
Ponteiros: *T
Seção intitulada “Ponteiros: *T”Ponteiro é para quem precisa segurar uma referência: grafos, listas intrusivas, um cache que várias partes apontam, FFI com C, MM manual. É um tipo de primeira classe, e o *T (o ponteiro-de-um) tem uma só forma, sem *const/*mut. (O ponteiro-de-muitos [*]T, base de coleções, vem adiante; é outro construto, não uma variante de mutabilidade do *T.)
A razão de um tipo só (sem *const/*mut) cai da regra de escrita combinada com imutável-by-default: ponteiro só se forma a partir de um lugar var com identidade persistente, seja uma alocação no heap, um elemento de estrutura ou FFI. Não de uma variável de stack, não de um lugar imutável. Logo todo *T aponta para algo mutável escrever através dele é sempre válido não há split const/mut.
Você nunca precisa de um ponteiro só-leitura, porque imutável é compartilhável de graça: como nada nunca escreve num valor imutável, o compilador aliasa o backing store livremente, sem cópia (não é nem copy-on-write, é compartilhamento puro). Passa por valor; o compilador compartilha. Ponteiro visível existe só para aliasing mutável persistente, que é sempre read-write. *const/*mut reaparece num único lugar: o boundary de FFI com C, no escape hatch unsafe, porque o C exige. Isolado, não polui o core.
Forma-se um ponteiro com &lugar, de um lugar var com identidade no heap. Note que o binding do ponteiro e a mutabilidade do apontado são independentes:
p := &node.next // p é imutável (não vou re-apontar), mas aponta p/ lugar var*p = other // escrevo através dele, o apontado é mutávelDeref é explícito em todo acesso, com *p, tanto na leitura quanto na escrita:
fn accumulate(total: *int, values: []int) { // total aponta p/ algo no heap, mutável loop v in values { *total = *total + v } // * em cada leitura e escrita}Nullabilidade via Optional[*T]. O ponteiro em si é não-nulável; a ausência mora em um Optional[*T] à parte, explícito no type system. Você lida com “pode não existir” só quando o tipo diz que pode.
Ponteiro-de-muitos: [*]T
Seção intitulada “Ponteiro-de-muitos: [*]T”*T aponta para um objeto. Uma coleção precisa de outra coisa: N elementos contíguos que ela possui, o backing store de uma List, de um Map. Para isso há [*]T: um ponteiro para uma sequência contígua de T que não carrega comprimento. É o “ponteiro-de-muitos” (estilo Zig [*]T), distinto do ponteiro-de-um.
A relação com slice fecha o modelo: []T é [*]T + comprimento. Um slice é uma view limitada sobre um [*]T; o ponteiro cru diz onde começa, o comprimento diz onde para. Toda fatia segura de um buffer cru é um []T; o buffer em si é o [*]T.
*T // um objeto[]T // view: [*]T + comprimento (emprestada, limite conhecido)[N]T // array por valor, N fixo em compilação[*]T // N contíguos que EU possuo, indexável, SEM comprimentoUm valor de slice ou array se escreve com o tipo na cabeça e os elementos em { }, e como os elementos são um grupo-de-valor homogêneo eles separam com , (não o ; de campos de struct, seção 14): []int{1, 2, 3} é um slice, [3]int{1, 2, 3} um array fixo, e [_]int{1, 2, 3} um array cujo tamanho o compilador infere do literal (aqui 3, o [...] do Go com _). Um valor de slice também nasce ao fatiar (buf[a..b], onde o comprimento nasce). Indexar xs[i] lê um elemento, com limite checado para []T/[N]T (não-checado só para o [*]T cru).
Indexar [*]T é não-checado, logo unsafe. Ele não sabe o próprio tamanho (é o trade do “sem comprimento”), então o compilador não tem limite a checar, exatamente como um T* cru do C. Por isso o [*]T cru mora dentro de uma coleção, e a coleção o embrulha em portas seguras: l.at(i) (checa i contra o len e dá panic se estourar) e l.get(i) (devolve Optional[T]) validam contra o len que a coleção guarda. Segura por fora, unsafe por dentro: o mesmo modelo de contenção do unsafe (seção 5).
Sem aritmética de ponteiro com +/-. ptr + i é o footgun implícito do C, e sobrecarregar + para ponteiro fere “um símbolo, um intento”. As três operações que implementar uma coleção exige são explícitas e nomeadas:
items[i] // índice não-checado → T (unsafe)items[a..b] // fatia não-checada → []T (a ponte [*]T → []T; é onde o comprimento entra)items.offset(k) // avança a base → [*]T (método nomeado, p/ ring buffer / layout manual)Acesso é indexação, fatiar produz []T (onde o comprimento nasce), avançar a base é um método (offset), nunca um operador. Você tem o poder do ponteiro cru do C (índice, fatia, avanço de base) com a explicitude da linguagem: nomeado e indexado, não +.
Nasce do MemoryManager (seção 5), colorless intacto. mm.alloc(n, align) devolve um Ptr, reinterpretado como [*]T tipado. O [*]T carrega o MemoryManager que o alocou; liberar a coleção devolve o buffer pelo MM dele, sem coloring, sem rastreio manual. Daí o backing store típico:
decl List[T] { items: [*]T // o buffer cru que eu possuo len: usize // quantos estão vivos cap: usize // quantos cabem antes de realocar}A List é escrevível na própria linguagem; não desce para um RawPtr opaco. O [*]T é a peça honesta que faltava entre “um ponteiro” (*T) e “uma view” ([]T): o buffer que você aloca, possui e faz crescer.
Por que ponteiro não vira atalho preguiçoso para mut
Seção intitulada “Por que ponteiro não vira atalho preguiçoso para mut”A preocupação legítima: se dá para mutar via ponteiro, por que aprender mut? Programador segue o caminho mais curto de digitar. Foi o que aconteceu com o operador ? em Rust/Zig, usado para fugir de tratar erro só porque tinha um caractere. A resposta não é tornar mut mais atraente; é fazer com que ponteiro não seja substituto, do mesmo jeito que em Go um valor não substitui um erro. Os dois precisam deixar de resolver o mesmo problema. Três defesas, em ordem de força:
- Ponteiro nasce de identidade no heap (
var), não de qualquer lvalue de stack (a espinha dorsal). Não se saca um ponteiro armazenável de uma variável local de stack. Para mutar um escalar local você simplesmente não tem como pegar ponteiro; só temmut. Isso fecha o atalho na origem para a maioria dos casos e resolve o dangling clássico sem lifetimes:
- Sob GC: um
*Tvivo é uma referência que otraceenxerga o GC mantém o alvo vivo o ponteiro não dangla, sem precisar de lifetime. - Sob arena/none: a vida vai até o drop da arena, ou é sua responsabilidade; escape-hatch explícito.
Proibir &local não tira nada de você, porque o único motivo decente de querer o endereço de um local, “devolver mutação para a variável”, virou justamente mut.
-
Deref custa em todo uso (imposto direcionado). Para um objeto no heap, onde o ponteiro é legal,
f(&obj)ef(mut obj)ambos existem, e é aí que o deref decide. O call site é quase empate (um token), mas o corpo da função é onde a digitação mora, e lá o ponteiro paga*a cada acesso, enquantomutusa o valor direto. Para mutar, o número de acessos é sempre > 1, então o caminho-ponteiro fica mais caro no todo. E o imposto é direcionado: passar um ponteiro adiante sem dereferenciar (plumbing, persistência, aliasing, o uso legítimo) não paga nada; só paga quem dereferencia para mutar, que é exatamente o caso de quem deveria ter usadomut. Não é castigo artificial: deref é uma operação real (indireção), então torná-la visível é coerente com a explicitude da linguagem. -
Intenção diferente na assinatura (reforço).
f(x: mut int)promete “muto esta variável e devolvo, exclusivo, acaba aqui”;f(p: *int)promete “guardo/compartilho este endereço, pode aliasar, talvez sobreviva à chamada”. O compilador/linter trata “peguei ponteiro de algo só para escrever uma vez e descartei” como code smell: uso da ferramenta de persistência para um trabalho transitório.
Efeito combinado: o atalho preguiçoso ou nem compila (local de stack, defesa 1) ou fica mais verboso a cada linha (deref, defesa 2), enquanto o ponteiro legítimo não paga pedágio. mut não compete; ponteiro foi para outro domínio da linguagem. Não há efeito-?, porque os dois deixaram de resolver o mesmo problema.
Interação com @mm
Seção intitulada “Interação com @mm”Para um parâmetro mut, qualquer realocação necessária para mutar o objeto usa o MM do próprio objeto (que viaja com ele, seção 5), não o @mm da função. O @mm da função governa apenas as alocações novas que ela origina do zero:
@mm(gc)fn append_log(buf: mut Buffer, line: string) { buf.push(line) // se push realocar, usa buf.mm (= Arena), não o @mm(gc)}
var buf: Buffer @mm(arena) = Buffer.new()append_log(mut buf, "erro X") // muta in-place na arena; o GC não encostaA função muta buf sem saber nem se importar que ele é arena-backed: colorless, igual ao resto do design. Escalares são o caso trivial: um int vive no stack, não aloca, e @mm sobre ele é no-op. Mutar é só uma escrita no stack, sem ponteiro e sem MM.
Reatribuir o resultado a um var (x = process(x)) não é cópia cara: como o binding antigo morre, o compilador faz move/in-place (estilo RVO).
Escopo transient: expressões e comportamentos de fronteira
Seção intitulada “Escopo transient: expressões e comportamentos de fronteira”Alguns construtos da linguagem não denotam um valor com tempo de vida; denotam uma transição. Eles só existem cruzando uma fronteira (uma chamada, o limite de um processo) e somem do outro lado; nunca ficam soltos, nunca se ligam a um nome. Chamamos esse escopo de transient, e ele tem dois habitantes:
- Expressões transient:
mute lambdas. Elas são a transição.mut xnão é um valor; é “passe a permissão de escrita dexpor esta chamada” (acima, nesta seção). Uma lambda-argumentofn(p) => …não é um valor que você segura; é “esta receita, pro callee rodar quando quiser” (seção 14). Nenhuma vive em lugar nenhum. - Comportamentos transient:
@transfere@promote. Eles anotam um valor que cruza, governando como ele atravessa a barreira entre processos:@transfermove (invalida a origem, o objeto viaja com seumm),@promote(mm)copia para outroMemoryManager(seção 5). O valor existe; a anotação rege a travessia. (A cópia simples é o default não-marcado, por isso não há@copy.)
A diferença entre os dois lados: a expressão transient é a transição (sem valor por baixo); o comportamento transient modifica um valor na transição.
Transient é um eixo, ortogonal a comptime/runtime. Comptime vs runtime é o eixo do quando (em que fase a expressão é avaliada). Transient é o eixo do onde (em que posição ela pode aparecer, só na fronteira). São independentes, e a reflexão (seção 10) prova: a lambda de reflect(T).construct(fn(f) => …) é transient no eixo onde (só argumento) e comptime no eixo quando (desenrolada por campo). Se transient fosse um terceiro valor no eixo comptime/runtime, essa combinação seria contraditória; como é outro eixo, ela é livre e correta.
A regra, dita uma vez: expressões transient só existem em posição de argumento/parâmetro. A gramática já a impõe em dois lugares separados: lambda “só como argumento direto” (seção 14), mut só em parâmetro/call-site (acima). Nomear o escopo transient diz a mesma regra uma vez, e explica por que ela existe.
E a regra é a trava que mata dois footguns clássicos, por construção:
- O mut tainting do Rust, onde
muté uma propriedade que se propaga e infecta assinaturas, não acontece, porque ummuttransient não existe solto pra propagar. - As arrow functions soltas do JS,
const f = () => …boiando, capturando escopo, criando confusão de identidade, não acontecem, porque uma lambda transient não pode ser ligada a um nome. Quer um valor-função pra guardar? Use umafnnomeada, ou um binding de tipo função. A categoria é a trava.
Como o variant-set generalizou o error-set (seção 7), o escopo transient generaliza: qualquer construto futuro que seja “uma transição, não um valor” herda a regra de graça.