
Quase todos os programas não triviais alocam e usam memória dinâmica. Fazê-lo corretamente está se tornando cada vez mais importante, à medida que os programas se tornam mais complexos e os erros são ainda mais caros.
Problemas típicos são:
- vazamentos de memória (não liberando memória usada)
- liberação dupla (liberação de memória mais de uma vez)
- usar após o lançamento (uso de um ponteiro para uma memória liberada anteriormente)
O desafio é rastrear os ponteiros responsáveis por liberar memória (ou seja, aqueles que possuem a memória) e distinguir os ponteiros que simplesmente apontam para um pedaço de memória, controlam onde estão localizados e quais estão ativos (no escopo).
As soluções típicas são as seguintes:
- Coleta de Lixo (GC) - O GC possui blocos de memória e os verifica periodicamente quanto a indicadores para esses blocos. Se nenhum ponteiro for encontrado, a memória será liberada. Esse esquema é confiável e é usado em linguagens como Go e Java. Mas o GC tende a usar muito mais memória do que o necessário, faz uma pausa e diminui a velocidade do código devido à reembalagem (portas de gravação com inserção original).
- Contagem de referência (RC) - Um objeto RC possui memória e armazena um contador de ponteiros para si mesmo. Quando esse contador diminui para zero, a memória é liberada. Também é um mecanismo confiável e é aceito em linguagens como C ++ e ObjectiveC. RC é eficiente em memória, além de exigir apenas espaço embaixo do balcão. Os aspectos negativos do RC são a sobrecarga de manter o contador, incorporar um manipulador de exceções para garantir sua redução e bloquear necessário para objetos compartilhados entre os fluxos do programa. Para melhorar o desempenho, os programadores às vezes são enganados ao se referirem temporariamente a um objeto RC ignorando o contador, criando o risco de fazê-lo incorretamente.
- Controle manual - O gerenciamento manual de memória é gratuito e gratuito. É rápido e eficiente em termos de uso de memória, mas a linguagem não ajuda a fazer tudo corretamente, confiando completamente na experiência e no zelo do programador. Uso o malloc e é gratuito há 35 anos e, com a ajuda de uma experiência amarga e sem fim, raramente cometo erros. Mas não é dessa maneira que a tecnologia de programação pode confiar, e observe que eu disse "raramente" e não "nunca".
As soluções 2 e 3, em um grau ou outro, dependem da fé no programador para fazer tudo corretamente. Os sistemas baseados na fé não escalam bem e os erros de gerenciamento de memória são comprovadamente difíceis de verificar novamente (tão ruins que alguns padrões de codificação proíbem o uso de memória dinâmica).
Mas há também uma quarta maneira - Propriedade e Empréstimo, OB. É eficiente na memória, tão rápido quanto na operação manual e está sujeito a verificação automática. O método foi recentemente popularizado pela linguagem de programação Rust. Ele também tem suas desvantagens, em particular a necessidade de repensar o planejamento de algoritmos e estruturas de dados.
Você pode lidar com aspectos negativos, e o restante deste artigo é uma descrição esquemática de como o sistema OB funciona e como propomos escrevê-lo na linguagem D. Inicialmente, considerei isso impossível, mas, depois de passar algum tempo pensando, encontrei uma maneira. É semelhante ao que fizemos com a programação funcional - com imutabilidade transitiva e funções "puras".
Posse
A decisão de quem é o proprietário do objeto na memória é ridiculamente simples - existe um único ponteiro para o objeto e ele é o proprietário. Ele também é responsável pela liberação da memória, após a qual ela se torna inválida. Devido ao fato de o ponteiro para o objeto na memória ser o proprietário, não há outros ponteiros dentro dessa estrutura de dados e, portanto, a estrutura de dados forma uma árvore.
A segunda consequência é que os ponteiros usam a semântica de mover em vez de copiar:
T* f(); void g(T*); T* p = f(); T* q = p;
É proibido remover um ponteiro de dentro de uma estrutura de dados:
struct S { T* p; } S* f(); S* s = f(); T* q = sp;
Por que não marcar sp como inválido? O problema é que isso exigirá definir o rótulo em tempo de execução, mas deve ser resolvido no estágio de compilação, porque é simplesmente considerado um erro de compilação.
A saída do próprio ponteiro fora do escopo também é um erro:
void h() { T* p = f(); }
Você deve mover o valor do ponteiro de maneira diferente:
void g(T*); void h() { T* p = f(); g(p);
Isso resolve os problemas de vazamento de memória e o uso após a liberação (Dica: para maior clareza, substitua f () por malloc () e g () por free ().)
Tudo isso pode ser verificado no estágio de compilação usando a técnica
Data Flow Analysis (DFA) , da mesma forma que é usada para
remover subexpressões comuns.O DFA pode desenrolar qualquer emaranhado de rato das transições de programa que possam surgir.
Empréstimos
O sistema de posse descrito acima é confiável, mas muito restritivo.
Considere:
struct S { void car(); void bar(); } struct S* f(); S* s = f(); s.car();
Para que isso funcione, s.car () deve ter uma maneira de colocar o ponteiro de volta na saída.
É assim que o empréstimo funciona. s.car () tira uma cópia de s pela duração de s.car (). s é inválido no tempo de execução e torna-se válido novamente quando s.car () sai.
Em D, funções-membro
struct obtêm o ponteiro
this por referência, para que possamos adaptar o empréstimo com uma pequena extensão: obtendo o argumento por referência é necessário.
D também suporta o escopo de ponteiros, portanto, o empréstimo é natural:
void g(scope T*); T* f(); T* p = f(); g(p);
(Quando as funções recebem argumentos por referência ou são usados ponteiros com escopo, eles são proibidos de se estender para além dos limites de uma função ou escopo. Isso corresponde à semântica do empréstimo.)
Emprestar dessa maneira garante a exclusividade de um ponteiro para um objeto na memória a qualquer momento.
Os empréstimos podem ser expandidos ainda mais com o entendimento de que o sistema de propriedade também é confiável, mesmo que um objeto seja adicionalmente indicado por vários indicadores constantes (mas apenas um mutável). Um ponteiro constante não pode alterar ou liberar memória. Isso significa que vários ponteiros constantes podem ser emprestados do proprietário mutável, mas ele não tem o direito de ser usado enquanto esses ponteiros constantes estiverem ativos.
Por exemplo:
T* f(); void g(T*); T* p = f();
Princípios
O precedente pode ser reduzido ao seguinte entendimento de que um objeto na memória se comporta como se estivesse em um dos dois estados:
- existe exatamente um ponteiro mutável para ele
- um ou mais ponteiros constantes adicionais
Um leitor atento notará algo estranho no que escrevi: "como se". O que eu queria sugerir? O que está havendo? Sim, existe um. As linguagens de programação de computador estão cheias de "como se" ocultas, algo como o dinheiro na sua conta bancária na verdade não existe (peço desculpas se foi um choque grave para alguém), e isso não é diferente disso. Continue lendo!
Mas primeiro, um pouco mais fundo no tópico.
Integrando técnicas de propriedade / empréstimo em D
Essas técnicas não são incompatíveis com a maneira como as pessoas costumam escrever em D e quase todos os programas D existentes não são interrompidos? E não é tão fácil de corrigir, mas é preciso reprojetar todos os algoritmos do zero?
Sim de fato. A menos que D tenha uma arma (quase) secreta: atributos de funções. Acontece que a semântica de propriedade / empréstimo (OB) pode ser implementada para cada função separadamente após a análise semântica usual. Um leitor atento pode perceber que nenhuma nova sintaxe foi adicionada, apenas restrições foram impostas ao código existente. D já tem um histórico de uso de atributos de função para alterar sua semântica, por exemplo, o atributo
puro para criar funções "puras". Para ativar a semântica de OB, o atributo @
live é adicionado.
Isso significa que o OB pode ser adicionado ao código em D gradualmente, conforme necessário e liberar recursos. Isso possibilita a adição de OBs, e isso é crítico, apoiando constantemente o projeto em um estado totalmente funcional, testado e pronto para lançamento. Também permite automatizar o processo de monitoramento de qual porcentagem do projeto já foi transferida para o OB. Essa técnica é adicionada à lista de outras garantias da linguagem D relacionadas à confiabilidade do trabalho com memória (como controlar a não distribuição de ponteiros para variáveis temporárias na pilha).
Como se
Algumas coisas necessárias não podem ser realizadas com estrita adesão às OBs, como objetos de contagem de referência. Afinal, os objetos RC são projetados para ter muitos indicadores para eles. Como os objetos RC são seguros ao trabalhar com memória (se implementados corretamente), eles podem ser usados junto com OBs sem afetar negativamente a confiabilidade. Eles simplesmente não podem ser criados usando a técnica OB. A solução é que existem outros atributos de função em D, como @
system . @
system são recursos em que muitas verificações de confiabilidade estão desabilitadas. Naturalmente, o OB também será desativado no código com o
sistema @. É aqui que a implementação da tecnologia RC está escondida do controle OB.
Mas no código com OB, RC, o objeto parece seguir todas as regras, então não há problema!
Serão necessários vários tipos de bibliotecas semelhantes para funcionar com êxito com o OB.
Conclusão
Este artigo é uma visão geral básica da tecnologia OB. Estou trabalhando em uma especificação muito mais detalhada. É possível que eu tenha perdido algo e em algum lugar um buraco abaixo da linha d'água, mas até agora tudo parece bom. Este é um desenvolvimento muito empolgante para D e estou ansioso para implementá-lo.
Para discussões e comentários adicionais de Walter, consulte os tópicos em
/ r / programming subreddit e no
Hacker News .