Pular para o conteúdo

Posse e empréstimo

Arcane.Posse: dono exclusivo com liberação determinística, empréstimo com escopo, contagem de referência que solta na hora certa, e a referência fraca que quebra o ciclo.

Numa linguagem com coleta automática, vazar memória quase nunca é o problema: o coletor resolve. O que ele não resolve é o recurso — o arquivo que não fecha, a conexão que fica aberta, o cadeado que ninguém solta — porque o coletor não promete quando passa. E há o defeito irmão: duas partes do programa escrevendo no mesmo objeto porque nenhuma delas sabe quem manda nele.

Arcane.Posse traz a disciplina de posse para esse mundo: quem é o dono, quem tomou emprestado, e quando solta.

Dono: posse exclusiva, liberação na hora#

dataforge
adopt Arcane.Posse as P

fechados := []
d := P.dono("conexao", lambda x => fechados.append(x))

assert d.usar(lambda x => len(x)) is 7     // empresta para ler
assert d.vivo()

d.soltar()                                  // o finalizador roda AGORA
assert not d.vivo()
assert fechados is ["conexao"]

d.soltar()                                  // idempotente: nada acontece
assert len(fechados) is 1

Usar depois de soltar é erro, e a mensagem diz o que aconteceu — não "objeto inválido":

dataforge
adopt Arcane.Posse as P

d := P.dono("arquivo")
d.soltar()

monitor:
    d.usar(lambda x => x)
    assert no
handle RuntimeError as e:
    assert "soltou" in e.message

com: o RAII, inclusive no caminho de erro#

Metade dos recursos vaza pelo caminho de erro — que é justamente o que ninguém testa. P.com solta no fim, sempre.

dataforge
adopt Arcane.Posse as P

fechados := []

valor := P.com(P.dono("a", lambda x => fechados.append(x)),
               lambda x => len(x))
assert valor is 1 and fechados is ["a"]

monitor:
    P.com(P.dono("b", lambda x => fechados.append(x)),
          lambda x => trigger "falhou no meio")
handle Error as e:
    assert e.message is "falhou no meio"

assert fechados is ["a", "b"]               // soltou mesmo falhando

Mover: quem move, perde#

dataforge
adopt Arcane.Posse as P

a := P.dono([1, 2])
b := a.mover()

assert a.movido()
assert b.usar(lambda x => len(x)) is 2

monitor:
    a.usar(lambda x => len(x))
    assert no
handle RuntimeError as e:
    assert "moveu" in e.message

E o dataforge check acusa antes de rodar quando dá para provar pelo fluxo do arquivo:

dataforge
// dataforge check acusa a última linha com 'posse-movida':
//
//   a := P.dono([1])
//   b := a.mover()
//   a.usar(lambda x => len(x))     ← 'a' já foi movido (linha 2)
out "veja o bloco acima" 

Cópia e clone são coisas diferentes#

FormaO que fazQuando
copiar()outro dono do mesmo valoro valor é o recurso: uma conexão, um socket
clonar()outro dono de uma cópiao valor é o dado, e cada um segue o seu caminho
clonar(copiador)a cópia que você escrevequando a profundidade é sua decisão
dataforge
adopt Arcane.Posse as P

original := P.dono([1, 2])

rasa := original.copiar()
rasa.mudar(lambda x => x.append(3))         // mexe no MESMO valor

funda := original.clonar(lambda x => [...x])
funda.mudar(lambda x => x.append(9))        // mexe na cópia

assert original.usar(lambda x => len(x)) is 3
assert funda.usar(lambda x => len(x)) is 4

Empréstimo: muitos leem, ou um escreve#

A regra do borrow checker, cobrada quando roda — como o RefCell. O empréstimo vive no corpo que o recebeu, e não até o fim do bloco: é o mesmo efeito prático dos non-lexical lifetimes, por um caminho mais simples.

dataforge
adopt Arcane.Posse as P

c := P.celula({"n": 0})

assert c.ler(lambda v => v["n"]) is 0
c.escrever(lambda v => v.set("n", 5))
assert c.ler(lambda v => v["n"]) is 5
assert c.emprestimos() is 0                 // nada aberto agora

// duas leituras ao mesmo tempo: pode
assert c.ler(lambda v => c.ler(lambda w => 1)) is 1

// escrever no meio de uma leitura: não
monitor:
    c.ler(lambda v => c.escrever(lambda w => w.set("n", 9)))
    assert no
handle RuntimeError as e:
    assert "lendo" in e.message

E o empréstimo não sobrevive ao escopo que o criou:

dataforge
adopt Arcane.Posse as P

d := P.dono([1, 2])
fugitivo := d.emprestar()      // sem corpo, o empréstimo já nasce encerrado

monitor:
    fugitivo.ler()
    assert no
handle RuntimeError as e:
    assert "escopo" in e.message

Compartilhado: contagem determinística#

Cada clonar() soma um; cada soltar() tira um. Quando o último sai, o finalizador roda — naquele instante, e não quando o coletor decidir passar. É essa previsibilidade que justifica a peça existir ao lado de uma referência comum.

dataforge
adopt Arcane.Posse as P

fechados := []
a := P.compartilhado("cache", lambda x => fechados.append(x))
b := a.clonar()
c := a.clonar()

assert a.contar() is 3
b.soltar()
assert a.contar() is 2 and fechados is []

c.soltar()
a.soltar()
assert a.contar() is 0 and fechados is ["cache"]

P.atomico é o mesmo com a contagem válida entre threads — o Arc. A contagem sem trava perde incrementos em silêncio; medido neste repositório, 40.425 de 80.000.

dataforge
adopt Arcane.Posse as P
adopt Arcane.Concurrent as C

raiz := P.atomico("recurso")
copias := []

action clonar_uma(i):
    copias.append(raiz.clonar())

C.para_cada(clonar_uma, [i cycle i in range(1, 51)])
assert raiz.contar() is 51

O ciclo vaza — e a referência fraca o quebra#

Dois compartilhados que se apontam nunca chegam a zero, e o finalizador de nenhum dos dois roda. É o problema do Rc em qualquer linguagem, e aqui ele aparece como é, em vez de sumir num silêncio:

dataforge
adopt Arcane.Posse as P

fechados := []
pai := P.compartilhado({"nome": "pai"}, lambda x => fechados.append("pai"))
filho := P.compartilhado({"nome": "filho"}, lambda x => fechados.append("filho"))

pai.usar(lambda v => v.set("filho", filho.clonar()))    // forte
filho.usar(lambda v => v.set("pai", pai.clonar()))      // forte: o ciclo

pai.soltar()
filho.soltar()
assert fechados is []                       // ninguém soltou: os dois vazam

A saída é a mesma de sempre: uma das voltas é fraca.

dataforge
adopt Arcane.Posse as P

fechados := []
pai := P.compartilhado({"nome": "pai"}, lambda x => fechados.append("pai"))
filho := P.compartilhado({"nome": "filho"}, lambda x => fechados.append("filho"))

pai.usar(lambda v => v.set("filho", filho.clonar()))    // forte
filho.usar(lambda v => v.set("pai", P.fraco(pai)))      // FRACA: não conta

filho.soltar()
pai.soltar()
assert fechados is ["pai", "filho"]         // o de fora primeiro, e o que ele possuía

A fraca responde Talvez — ela não promete que o valor ainda existe, e o tipo diz isso:

dataforge
adopt Arcane.Posse as P

forte := P.compartilhado({"id": 1})
fraca := P.fraco(forte)

assert fraca.vivo() and fraca.obter().tem()
forte.soltar()
assert not fraca.vivo()
assert not fraca.obter().tem()

O que o `check` prova#

CódigoAcusaSeveridade
posse-movidausar um nome depois de mover(), com a linha do movimentoerro
recurso-vazadoP.dono(…) numa ação que ninguém solta, move, devolve nem passa adianteaviso
emprestimo-escapad.usar(lambda x => x) — o corpo devolve o próprio empréstimoaviso

Os dois últimos são aviso, e não erro, porque a análise vê um arquivo só: o recurso pode ser guardado num campo, entregue por um caminho que este arquivo não enxerga, ou solto num defer. E a acusação só vale para o que nasceu de Arcane.Posse — um blueprint com um método chamado mover não tem nada a ver com posse.

O que não existe, e por quê#

Não existePor quê
ownership imposto pela linguagema posse aqui é opt-in: ela vale para o que você declara com P.dono. Impor a todo valor exigiria mudar a semântica de atribuição da linguagem inteira
lifetimes explícitos ('a), elisão, variâncianão há inferência de região em tempo de compilação: o empréstimo tem escopo de corpo, e a análise estática cobre o que o fluxo de um arquivo prova
ponteiro cru, aritmética de endereço, unsafenão há endereço para manipular: o valor é um objeto do interpretador
stack vs heap, Box para mover ao heapa distinção não existe aqui; Dono é sobre protocolo, não sobre onde o valor mora
borrow checker em tempo de compilaçãoa regra é cobrada quando roda (como RefCell), e o check prova o subconjunto que o fluxo permite