Por que o Java / C # não pode implementar o RAII?

29

Pergunta: Por que o Java / C # não pode implementar o RAII?

Esclarecimento: Estou ciente de que o coletor de lixo não é determinístico. Portanto, com os recursos de idioma atuais, não é possível que o método Dispose () de um objeto seja chamado automaticamente na saída do escopo. Mas um recurso tão determinístico poderia ser adicionado?

Meu entendimento:

Eu sinto que uma implementação do RAII deve atender a dois requisitos:
1. A vida útil de um recurso deve estar vinculada a um escopo.
2. Implícito. A liberação do recurso deve ocorrer sem uma declaração explícita do programador. Análogo a um coletor de lixo, liberando memória sem uma declaração explícita. A "implicitude" só precisa ocorrer no ponto de uso da classe. É claro que o criador da biblioteca de classes deve implementar explicitamente um destruidor ou um método Dispose ().

Java / C # satisfaz o ponto 1. No C #, um recurso implementando IDisposable pode ser vinculado a um escopo "using":

void test()
{
    using(Resource r = new Resource())
    {
        r.foo();
    }//resource released on scope exit
}

Isso não atende ao ponto 2. O programador deve vincular explicitamente o objeto a um escopo especial "usando". Os programadores podem (e fazem) esquecer de vincular explicitamente o recurso a um escopo, criando um vazamento.

De fato, os blocos "using" são convertidos para o código try-finally-dispose () pelo compilador. Ele tem a mesma natureza explícita do padrão try-finalmente-dispose (). Sem uma liberação implícita, o gancho para um escopo é o açúcar sintático.

void test()
{
    //Programmer forgot (or was not aware of the need) to explicitly
    //bind Resource to a scope.
    Resource r = new Resource(); 
    r.foo();
}//resource leaked!!!

Eu acho que vale a pena criar um recurso de linguagem em Java / C # que permita objetos especiais conectados à pilha por meio de um ponteiro inteligente. O recurso permitiria sinalizar uma classe como vinculada ao escopo, para que ela sempre seja criada com um gancho na pilha. Pode haver opções para diferentes tipos de ponteiros inteligentes.

class Resource - ScopeBound
{
    /* class details */

    void Dispose()
    {
        //free resource
    }
}

void test()
{
    //class Resource was flagged as ScopeBound so the tie to the stack is implicit.
    Resource r = new Resource(); //r is a smart-pointer
    r.foo();
}//resource released on scope exit.

Eu acho que implícita "vale a pena". Assim como a implicitude da coleta de lixo "vale a pena". Blocos usando explícitos são refrescantes para os olhos, mas não oferecem vantagem semântica sobre o try-finalmente-dispose ().

É impraticável implementar esse recurso nas linguagens Java / C #? Poderia ser introduzido sem quebrar o código antigo?

mike30
fonte
3
Não é impraticável, é impossível . O padrão C # não garante destruidores / Disposes são sempre executado, independentemente de como eles são acionados. Adicionar destruição implícita no final do escopo não ajudará nisso.
Telastyn
20
@Telastyn Huh? O que o padrão C # diz agora não é relevante, pois estamos discutindo a alteração desse mesmo documento. A única questão é se isso é prático e, para isso, a única parte interessante sobre a atual falta de garantia são as razões dessa falta de garantia. Observe que usinga execução de Dispose é garantida (bem, descontar o processo de repente morrer sem que uma exceção seja lançada; nesse ponto, toda a limpeza presumivelmente se torna discutível).
4
duplicata de Os desenvolvedores de Java abandonaram conscientemente o RAII? , embora a resposta aceita esteja completamente incorreta. A resposta curta é que Java usa semântica de referência (heap) em vez de semântica de valor (pilha) , portanto, a finalização determinística não é muito útil / possível. C # faz tem valor-semântica ( struct), mas eles são tipicamente evitado exceto em casos muito especiais. Veja também .
BlueRaja - Danny Pflughoeft 30/10
2
É semelhante, não exatamente duplicado.
Maniero 30/10
3
blogs.msdn.com/b/oldnewthing/archive/2010/08/10/10048150.aspx é uma página relevante para esta pergunta.
Maniero 30/10

Respostas:

17

Essa extensão de linguagem seria significativamente mais complicada e invasiva do que você imagina. Você não pode simplesmente adicionar

se o tempo de vida de uma variável de um tipo vinculado à pilha terminar, chame Disposeo objeto ao qual se refere

para a seção relevante da especificação do idioma e pronto. Ignorarei o problema dos valores temporários ( new Resource().doSomething()) que podem ser resolvidos com palavras um pouco mais gerais, esse não é o problema mais sério. Por exemplo, esse código seria quebrado (e esse tipo de coisa provavelmente se torna impossível de fazer em geral):

File openSavegame(string id) {
    string path = ... id ...;
    File f = new File(path);
    // do something, perhaps logging
    return f;
} // f goes out of scope, caller receives a closed file

Agora você precisa de construtores de cópia definidos pelo usuário (ou mova construtores) e comece a invocá-los em qualquer lugar. Isso não apenas traz implicações de desempenho, mas também faz com que essas coisas valorizem efetivamente os tipos, enquanto quase todos os outros objetos são tipos de referência. No caso de Java, esse é um desvio radical de como os objetos funcionam. Em C # menos (já possui structs, mas nenhum construtor de cópias definido pelo usuário para eles AFAIK), mas ainda torna esses objetos RAII mais especiais. Como alternativa, uma versão limitada de tipos lineares (cf. Rust) também pode resolver o problema, ao custo de proibir o alias, incluindo a passagem de parâmetros (a menos que você queira introduzir ainda mais complexidade adotando referências emprestadas do tipo Rust e um verificador de empréstimo).

Isso pode ser feito tecnicamente, mas você acaba com uma categoria de coisas muito diferentes de tudo o mais no idioma. Isso quase sempre é uma péssima idéia, com consequências para implementadores (mais casos extremos, mais tempo / custo em todos os departamentos) e usuários (mais conceitos a aprender, mais possibilidade de erros). Não vale a conveniência adicional.


fonte
Por que você precisa copiar / mover o construtor? O arquivo mantém um tipo de referência. Nessa situação f que é um ponteiro é copiado para o chamador e é responsável por descartar o recurso (o compilador implicitamente iria colocar um padrão de try-finally-dispor no chamador vez)
Maniero
1
@ bigown Se você tratar todas as referências a Fileesse caminho, nada muda e Disposenunca é chamado. Se você sempre liga Dispose, não pode fazer nada com objetos descartáveis. Ou você está propondo algum esquema para algumas vezes descartar e outras não? Em caso afirmativo, descreva-o em detalhes e eu lhe mostrarei situações em que ele falhará.
Não vejo o que você disse agora (não estou dizendo que você está errado). O objeto tem um recurso, não a referência.
Maniero 30/10
Meu entendimento, alterando seu exemplo para apenas um retorno, é que o compilador insere uma tentativa imediatamente antes da aquisição de recursos (linha 3 no seu exemplo) e o bloco de descarte finalmente antes do final do escopo (linha 6). Não tem problema aqui, concorda? Voltar ao seu exemplo. O compilador vê uma transferência, não pode inserir try-finalmente aqui, mas o chamador receberá um objeto (apontador para) File e, assumindo que o chamador não está transferindo esse objeto novamente, o compilador inserirá o padrão try-finally lá. Em outras palavras, todos os objetos descartáveis ​​ID não transferidos precisam aplicar o padrão try-finally.
Maniero 30/10
1
@bigown Em outras palavras, não ligue Disposese uma referência escapar? A análise de escape é um problema antigo e difícil, que nem sempre funciona sem novas alterações no idioma. Quando uma referência é passada para outro método (virtual) ( something.EatFile(f);), deve f.Disposeser chamada no final do escopo? Se sim, você interrompe os chamadores que armazenam fpara uso posterior. Caso contrário, você vazará o recurso se o chamador não for armazenado f. A única maneira um tanto simples de remover isso é um sistema do tipo linear, que (como eu já discuti mais adiante na minha resposta) apresenta muitas outras complicações.
26

A maior dificuldade na implementação de algo assim para Java ou C # seria definir como a transferência de recursos funciona. Você precisaria de alguma maneira para estender a vida útil do recurso além do escopo. Considerar:

class IWrapAResource
{
    private readonly Resource resource;
    public IWrapAResource()
    {
        // Where Resource is scope bound
        Resource builder = new Resource(args, args, args);

        this.resource = builder;
    } // Uh oh, resource is destroyed
} // Crap, there's no scope for IWrapAResource we can bind to!

O pior é que isso pode não ser óbvio para o implementador de IWrapAResource:

class IWrapSomething<T>
{
    private readonly T resource; // What happens if T is Resource?
    public IWrapSomething(T input)
    {
        this.resource = input;
    }
}

Algo como a usingdeclaração do C # provavelmente é o mais próximo que você terá da semântica de RAII sem recorrer à referência de recursos de contagem ou forçar a semântica de valor em qualquer lugar, como C ou C ++. Como Java e C # têm compartilhamento implícito de recursos gerenciados por um coletor de lixo, o mínimo que um programador precisa fazer é escolher o escopo ao qual um recurso está vinculado, exatamente o que usingjá faz.

Billy ONeal
fonte
Supondo que você não precise se referir a uma variável depois que ela estiver fora do escopo (e realmente não deveria existir), afirmo que você ainda pode fazer com que um objeto se auto-descarte escrevendo um finalizador para ele . O finalizador é chamado logo antes da coleta do lixo do objeto. Consulte msdn.microsoft.com/en-us/library/0s71x931.aspx
Robert Harvey
8
@ Robert: Um programa escrito corretamente não pode assumir que os finalizadores já foram executados. blogs.msdn.com/b/oldnewthing/archive/2010/08/09/10047586.aspx
Billy ONeal
1
Hum. Bem, é provavelmente por isso que eles apresentaram a usingdeclaração.
Robert Harvey
2
Exatamente. Essa é uma fonte enorme de bugs para iniciantes em C ++ e também estaria em Java / C #. Java / C # não elimina a capacidade de vazar a referência a um recurso que está prestes a ser destruído, mas ao torná-lo explícito e opcional, eles lembram o programador e oferecem a ele uma escolha consciente do que fazer.
Aleksandr Dubinsky
1
@svick Não é possível IWrapSomethingdescartar T. Quem criou Tprecisa se preocupar com isso, seja usando using, sendo IDisposableele mesmo ou tendo algum esquema de ciclo de vida de recursos ad-hoc.
Aleksandr Dubinsky 30/10
13

A razão pela qual o RAII não pode funcionar em uma linguagem como C #, mas funciona em C ++, é porque em C ++ você pode decidir se um objeto é realmente temporário (alocando-o na pilha) ou se é de longa duração (por alocá-lo no heap usando newe usando ponteiros).

Portanto, em C ++, você pode fazer algo assim:

void f()
{
    Foo f1;
    Foo* f2 = new Foo();
    Foo::someStaticField = f2;

    // f1 is destroyed here, the object pointed to by f2 isn't
}

Em C #, você não pode diferenciar os dois casos, portanto, o compilador não tem idéia se finaliza o objeto ou não.

O que você pode fazer é introduzir algum tipo de variável local especial, que você não pode colocar em campos etc. * e que seria descartado automaticamente quando sair do escopo. Qual é exatamente o que C ++ / CLI faz. Em C ++ / CLI, você escreve um código como este:

void f()
{
    Foo f1;
    Foo^ f2 = gcnew Foo();
    Foo::someStaticField = f2;

    // f1 is disposed here, the object pointed to by f2 isn't
}

Isso é compilado basicamente para o mesmo IL que o seguinte c #:

void f()
{
    using (Foo f1 = new Foo())
    {
        Foo f2 = new Foo();
        Foo.someStaticField = f2;
    }
    // f1 is disposed here, the object pointed to by f2 isn't
}

Para concluir, se eu adivinhasse por que os designers de C # não adicionaram RAII, é porque eles pensaram que não vale a pena ter dois tipos diferentes de variáveis ​​locais, principalmente porque em uma linguagem com GC, a finalização determinística não é útil que frequentemente.

* Não sem o equivalente do &operador, que é em C ++ / CLI %. Embora fazer isso seja "inseguro" no sentido de que, após o término do método, o campo fará referência a um objeto descartado.

svick
fonte
1
O C # poderia fazer o RAII trivialmente se permitisse destruidores para structtipos como o D.
Jan Hudec
6

Se o que incomoda você com os usingblocos é a sua explicitação, talvez possamos dar um pequeno passo em direção a menos explicitação, em vez de alterar as próprias especificações do C #. Considere este código:

public void ReadFile ()
{
  string filename = "myFile.dat";
  local Stream file = File.Open(filename);
  file.Read(blah blah blah);
}

Veja a localpalavra - chave que adicionei? Tudo o que faz é adicionar um pouco mais de açúcar sintático, assim como usingdizer ao compilador para chamar Disposeum finallybloco no final do escopo da variável. Isso é tudo. É totalmente equivalente a:

public void ReadFile ()
{
  string filename = "myFile.dat";
  using (Stream file = File.Open(filename))
  {
      file.Read(blah blah blah);
  }
}

mas com um escopo implícito, e não explícito. É mais simples que as outras sugestões, já que não preciso ter a classe definida como vinculada ao escopo. Apenas açúcar sintático mais limpo e implícito.

Pode haver problemas aqui com escopos difíceis de resolver, embora eu não possa vê-lo agora e aprecio qualquer um que possa encontrá-lo.

Avner Shahar-Kashtan
fonte
1
@ mike30 mas movê-lo para a definição de tipo leva você exatamente aos problemas listados por outras pessoas - o que acontece se você passar o ponteiro para um método diferente ou retorná-lo da função? Dessa forma, o escopo é declarado no escopo, não em outro lugar. Um tipo pode ser descartável, mas não cabe a ele chamar Dispose.
Avner Shahar-Kashtan 30/10
3
@ mike30: Meh. Tudo o que essa sintaxe faz é remover os chavetas e, por extensão, o controle de escopo que eles fornecem.
Robert Harvey
1
@RobertHarvey Exatamente. Ele sacrifica alguma flexibilidade por código mais limpo e menos aninhado. Se aceitarmos a sugestão de @ delnan e reutilizarmos a usingpalavra - chave, podemos manter o comportamento existente e usá-lo também, nos casos em que não precisarmos do escopo específico. Tenha um usingpadrão sem chaves no escopo atual.
Avner Shahar-Kashtan 30/10
1
Não tenho problemas com exercícios semi-práticos em design de linguagem.
Avner Shahar-Kashtan 30/10
1
@RobertHarvey. Você parece ter um viés contra qualquer coisa que não esteja implementada no momento no C #. Não teríamos genéricos, linq, blocos de uso, tipos ipmlicit etc. se estivéssemos satisfeitos com o C # 1.0. Essa sintaxe não resolve o problema da implicitividade, mas é um bom açúcar vincular-se ao escopo atual.
mike30
1

Para um exemplo de como o RAII funciona em uma linguagem de coleta de lixo, verifique a withpalavra - chave em Python . Em vez de confiar em objetos destruídos deterministicamente, vamos associar __enter__()e __exit__()métodos a um determinado escopo lexical. Um exemplo comum é:

with open('output.txt', 'w') as f:
    f.write('Hi there!')

Como no estilo RAII do C ++, o arquivo seria fechado ao sair desse bloco, independentemente de ser uma saída 'normal', a break, uma imediata returnou uma exceção.

Observe que a open()chamada é a função usual de abertura de arquivo. Para fazer isso funcionar, o objeto de arquivo retornado inclui dois métodos:

def __enter__(self):
  return self
def __exit__(self):
  self.close()

Esse é um idioma comum no Python: os objetos associados a um recurso geralmente incluem esses dois métodos.

Observe que o objeto de arquivo ainda pode permanecer alocado após a __exit__()chamada, o importante é que ele seja fechado.

Javier
fonte
7
withem Python é quase exatamente como usingem C # e, como tal, não RAII no que diz respeito a essa questão.
1
O "with" do Python é um gerenciamento de recursos vinculado ao escopo, mas falta a implicação de um ponteiro inteligente. O ato de declarar um ponteiro como inteligente pode ser considerado "explícito", mas se o compilador aplicasse a inteligência como parte do tipo de objetos, ele se inclinaria para "implícito".
mike30
AFAICT, o objetivo da RAII é estabelecer um escopo estrito para os recursos. se você estiver interessado apenas em desalocar objetos, então não, os idiomas coletados pelo lixo não poderão fazê-lo. se você estiver interessado em liberar recursos de maneira consistente, essa é uma maneira de fazê-lo (outra é deferno idioma Go).
Javier
1
Na verdade, acho justo dizer que Java e C # favorecem fortemente construções explícitas. Caso contrário, por que se preocupar com toda a cerimônia inerente ao uso de interfaces e herança?
Robert Harvey
1
@ delnan, Go tem interfaces 'implícitas'.
Javier