Esta é uma tradução da primeira parte do artigo. O artigo foi escrito em 2008. Após 10 anos, quase perdeu sua relevância.
Liberação determinística de recursos - uma necessidade
Ao longo de mais de 20 anos de experiência em codificação, às vezes desenvolvi meus próprios idiomas para resolver problemas. Eles variavam de linguagens simples e imperativas a expressões regulares especializadas para árvores. Ao criar idiomas, existem muitas recomendações e algumas regras simples não devem ser violadas. Um deles:
Nunca crie um idioma de exceção no qual não haja liberação determinística de recursos.
Adivinha quais recomendações o tempo de execução do .NET não segue e, como resultado, todos os idiomas baseados nele?
O motivo dessa regra existir é que a liberação determinística de recursos é necessária para criar programas suportados . A liberação determinada de recursos fornece um certo ponto no qual o programador tem certeza de que o recurso foi liberado. Existem duas maneiras de escrever programas confiáveis: a abordagem tradicional é liberar recursos o mais cedo possível e a abordagem moderna é liberar recursos por tempo indeterminado. A vantagem da abordagem moderna é que o programador não precisa liberar recursos explicitamente. A desvantagem é que é muito mais difícil escrever um aplicativo confiável, existem muitos erros sutis. Infelizmente, o tempo de execução do .NET foi criado usando uma abordagem moderna.
O .NET suporta a liberação não determinística de recursos usando o método Finalize
, que tem um significado especial. Para liberação determinística de recursos, a Microsoft também adicionou a interface IDisposable
(e outras classes, as quais discutiremos mais adiante). No entanto, para o tempo de execução, IDisposable
é uma interface normal, como todos os outros. Esse status de "segunda categoria" cria algumas dificuldades.
No C #, a "versão determinística para os pobres" pode ser implementada usando as try
e finally
ou using
(que é quase a mesma coisa). A Microsoft vem discutindo há muito tempo se deve ou não fazer contagem de links, e me parece que a decisão errada foi tomada. Como resultado, para liberação determinística de recursos, você precisa usar as construções desajeitadas finally
\ using
ou uma chamada direta para IDisposable.Dispose
, que está repleta de erros. Para um programador C ++ que está acostumado a usar o shared_ptr<T>
duas opções não são atraentes. (A última frase deixa claro onde o autor tem esse relacionamento - aprox.
IDisposable
IDisposable
é uma solução para liberação determinística de recursos oferecidos pela Misoftro. Um é para os seguintes casos:
- Qualquer tipo que possua recursos gerenciados (
IDisposable
). Um tipo deve necessariamente possuir , isto é, gerenciar o tempo de vida, os recursos e não apenas se referir a eles. - Qualquer tipo que possua recursos não gerenciados.
- Qualquer tipo que possua recursos gerenciados e não gerenciados.
- Qualquer tipo herdado de uma classe que implementa
IDisposable
. Não recomendo herdar de classes que possuem recursos não gerenciados. Melhor usar um anexo.
IDisposable
ajuda a liberar recursos deterministicamente, mas tem seus próprios problemas.
Dificuldades IDisposable - Usabilidade
Objetos IDisposable
estão IDisposable
usar bastante pesado. O uso de um objeto deve ser envolvido em uma construção using
. A má notícia é que o C # não permite o using
com um tipo que não implementa IDisposable
. Portanto, o programador deve consultar a documentação todas as vezes para entender se é necessário escrever using
, ou apenas escrever using
qualquer lugar, e depois apagar onde o compilador jura.
C ++ gerenciado é muito melhor nesse sentido. Ele suporta semântica de pilha para tipos de referência , que funciona como somente para tipos quando necessário. O C # pode se beneficiar da capacidade de escrever using
qualquer tipo.
Este problema pode ser resolvido com. ferramentas de análise de código. Para piorar a situação, se você esquecer de usar, o programa pode passar nos testes, mas travar enquanto trabalha "nos campos".
Em vez de contar os links, o IDisposable
tem outro problema - determinar o proprietário. Quando em C ++ a última cópia do shared_ptr<T>
sai do escopo, os recursos são liberados imediatamente, sem a necessidade de pensar em quem deve liberar. IDisposable
pelo contrário, força o programador a determinar quem "possui" o objeto e é responsável por liberá-lo. Às vezes, a propriedade é óbvia: quando um objeto encapsula outro e ele implementa IDisposable
, ele é responsável pela liberação de objetos filhos. Às vezes, a vida útil de um objeto é determinada por um bloco de código, e o programador simplesmente usa o using
torno desse bloco. No entanto, existem muitos casos em que um objeto pode ser usado em vários lugares e é difícil determinar sua vida útil (embora, neste caso, a contagem de referência funcione perfeitamente).
Dificuldades IDisposable - Compatibilidade com versões anteriores
Adicionar IDisposable
à classe e remover IDisposable
da lista de interfaces implementadas é uma mudança de última hora. O código do cliente que não espera IDisposable
não IDisposable
recursos se você adicionar IDisposable
a uma de suas classes passadas por referência a uma interface ou classe base.
A própria Microsoft encontrou esse problema. IEnumerator
não IEnumerator
herdado de IDisposable
e IEnumerator<T>
herdado. Se você passar IEnumerator<T>
código que recebe IEnumerator
, Dispose
não será chamado.
Este não é o fim do mundo, mas fornece alguma essência secundária do IDisposable
.
Dificuldades IDisposable - Criando uma hierarquia de classes
A maior desvantagem causada pelo IDisposable
no campo do design da hierarquia é que cada classe e interface deve prever se o IDisposable
será necessário por seus descendentes.
Se a interface não herdar IDisposable
, mas as classes que implementam a interface também implementarem IDisposable
, o código final ignorará a liberação determinística ou deverá verificar se o objeto implementa a interface IDisposable
. Mas, para isso, não será possível usar a construção using e você terá que escrever uma try
feia e finally
.
Em resumo, o IDisposable
complica o desenvolvimento de software reutilizável. O principal motivo é a violação de um dos princípios do design orientado a objetos - separação de interface e implementação. A liberação de recursos deve ser um detalhe de implementação. A Microsoft decidiu tornar a liberação determinística de recursos uma interface de segunda classe.
Uma das soluções não tão bonitas é fazer com que todas as classes implementem IDisposable
, mas na grande maioria das classes, IDisposable.Dispose
não fará nada. Mas isso não é muito bonito.
Outra dificuldade com o IDisposable
é coleções. Algumas coleções “possuem” objetos neles, e outras não. No entanto, as próprias coleções não implementam IDisposable
. O programador deve se lembrar de chamar IDisposable.Dispose
nos objetos da coleção ou criar seus próprios descendentes de classes de coleção que implementam IDisposable
para significar propriedade.
Dificuldades IDisposable - estado "errôneo" adicional
IDisposable
pode ser chamado explicitamente a qualquer momento, independentemente da vida útil do objeto. Ou seja, um estado "liberado" é adicionado a cada objeto, no qual é recomendável lançar uma ObjectDisposedException
. Verificar o status e lançar exceções é uma despesa adicional.
Em vez de verificar cada espirro, é melhor considerar acessar o objeto no estado "liberado" como "comportamento indefinido" como uma chamada à memória liberada.
Dificuldades IDisposable - sem garantias
IDisposable
é apenas uma interface. Uma classe que implementa IDisposable
oferece suporte à liberação determinística, mas não a garante . Para o código do cliente, não há problema em chamar Dispose
. Portanto, uma classe que implementa IDisposable
deve oferecer suporte à liberação determinística e não determinística.
Complexidades IDisposable - Implementação Complexa
A Microsoft oferece um padrão para implementar IDisposable
. (Anteriormente, havia um padrão geralmente terrível, mas, relativamente recentemente, após o aparecimento do .NET 4, a documentação foi corrigida, inclusive sob a influência deste artigo. Nas edições antigas dos livros do .NET, é possível encontrar a versão antiga. - aprox. )
IDisposable.Dispose
não pode ser chamado, portanto, a classe deve incluir um finalizador para liberar recursos.IDisposable.Dispose
pode ser chamado várias vezes e deve funcionar sem efeitos colaterais visíveis. Portanto, é necessário adicionar uma verificação se o método já foi chamado ou não.- Os finalizadores são chamados em um thread separado e podem ser chamados antes da
IDisposable.Dispose
. O uso do GC.SuppressFinalize
para evitar essas "corridas".
Além disso:
- Os finalizadores são chamados, inclusive para objetos que lançam uma exceção no construtor. Portanto, o código de liberação deve funcionar com objetos parcialmente inicializados.
- A implementação de um
IDisposable
em uma classe herdada de CriticalFinalizerObject
requer construções não triviais. void Dispose(bool disposing)
é um método viral e deve ser executado na região de execução restrita , que requer uma chamada para RuntimeHelpers.PrepareMethod
.
Dificuldades IDisposable - Não é adequado para lógica de conclusão
Desligando um objeto - geralmente ocorre em programas em threads paralelos ou assíncronos. Por exemplo, uma classe usa um thread separado e deseja concluí-lo usando ManualResetEvent
. Isso pode ser feito em IDisposable.Dispose
, mas pode levar a um erro se o código for chamado no finalizador.
Para entender as limitações do finalizador, você precisa entender como o coletor de lixo funciona. Abaixo está um diagrama simplificado no qual muitos detalhes relacionados a gerações, links fracos, recuperação de objetos, coleta de lixo em segundo plano etc. são omitidos.
O coletor de lixo .NET usa o algoritmo de marcação e varredura. Em geral, a lógica é assim:
- Pause todos os threads.
- Pegue todos os objetos raiz: variáveis na pilha, campos estáticos, objetos
GCHandle
, fila de finalização. No caso de descarregar o domínio do aplicativo (encerramento do programa), considera-se que as variáveis na pilha e nos campos estáticos não são raízes. - Recursivamente, percorra todos os links dos objetos e marque-os como "alcançáveis".
- Percorra todos os outros objetos que possuem destruidores (finalizadores), declare-os acessíveis e coloque-os na fila de finalização (
GC.SuppressFinalize
diz ao GC para não fazer isso). Os objetos são enfileirados em uma ordem imprevisível.
Em segundo plano, um fluxo (ou vários) de finalização funciona:
- Retira um objeto da fila e inicia seu finalizador. É possível executar vários finalizadores de objetos diferentes ao mesmo tempo.
- O objeto é removido da fila e, se mais ninguém se referir a ele, será limpo na próxima coleta de lixo.
Agora deve ficar claro por que é impossível acessar recursos gerenciados do finalizador - você não sabe em que ordem os finalizadores são chamados. Mesmo chamando IDisposable.Dispose
outro objeto do finalizador pode levar a um erro, pois o código de liberação do recurso pode funcionar em outro thread.
Existem algumas exceções quando você pode acessar recursos gerenciados de um finalizador:
- A finalização de objetos herdados de
CriticalFinalizerObject
é executada após a finalização de objetos não herdados dessa classe. Isso significa que você pode chamar ManualResetEvent
do finalizador até que a classe seja herdada de CriticalFinalizerObject
- Alguns objetos e métodos são especiais, como o Console e alguns métodos de Thread. Eles podem ser chamados dos finalizadores, mesmo que o programa termine.
No caso geral, é melhor não acessar recursos gerenciados dos finalizadores. No entanto, a lógica da conclusão é necessária para software não trivial. No Windows.Forms
contém a lógica de conclusão no método Application.Exit
. Ao desenvolver sua biblioteca de componentes, a melhor coisa a fazer é concluir a lógica de conclusão com IDisposable
. Terminação normal em caso de chamar IDisposable.Dispose
e emergência caso contrário.
A Microsoft também encontrou esse problema. A classe StreamWriter
possui um objeto Stream
(dependendo dos parâmetros do construtor na versão mais recente - aprox. Por. ). StreamWriter.Close
libera o buffer e chama Stream.Close
(também ocorre se envolvido com o using
- aprox. Por. ). Se o StreamWriter
não StreamWriter
fechado, o buffer não será liberado e o bate-papo de dados será perdido. A Microsoft simplesmente não redefiniu o finalizador, "resolvendo" o problema de conclusão. Um ótimo exemplo da necessidade de lógica de conclusão.
Eu recomendo a leitura
Muitas informações sobre o .NET internos neste artigo são fornecidas pelo CLR de Jeffrey Richter via C #. Se você ainda não o tem, compre-o . Sério. Esse é o conhecimento necessário para qualquer programador de C #.
Conclusão do tradutor
A maioria dos programadores .NET nunca encontrará os problemas descritos neste artigo. O .NET evoluirá para aumentar o nível de abstração e reduzir a necessidade de "malabarismo" de recursos não gerenciados. No entanto, este artigo é útil, pois descreve os detalhes profundos de coisas simples e seu impacto no design do código.
A próxima parte será uma discussão detalhada de como trabalhar com recursos gerenciados e não gerenciados no .NET com vários exemplos.