¿Por qué no puede Java / C # implementar RAII?

29

Pregunta: ¿Por qué Java / C # no puede implementar RAII?

Aclaración: Soy consciente de que el recolector de basura no es determinista. Entonces, con las características del lenguaje actual, no es posible llamar automáticamente al método Dispose () de un objeto al salir del ámbito. Pero, ¿podría agregarse una característica tan determinista?

Mi entendimiento:

Creo que una implementación de RAII debe cumplir dos requisitos:
1. La vida útil de un recurso debe estar vinculada a un alcance.
2. Implícito. La liberación del recurso debe ocurrir sin una declaración explícita del programador. Análogo a un recolector de basura que libera memoria sin una declaración explícita. La "implicidad" solo necesita ocurrir en el punto de uso de la clase. El creador de la biblioteca de clases, por supuesto, debe implementar explícitamente un destructor o el método Dispose ().

Java / C # satisface el punto 1. En C #, un recurso que implementa IDisposable puede vincularse a un alcance "en uso":

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

Esto no satisface el punto 2. El programador debe vincular explícitamente el objeto a un alcance especial "de uso". Los programadores pueden (y lo hacen) olvidar vincular explícitamente el recurso a un ámbito, creando una fuga.

De hecho, el compilador convierte los bloques "en uso" en código try-finally-dispose (). Tiene la misma naturaleza explícita del patrón try-finally-dispose (). Sin un lanzamiento implícito, el gancho a un alcance es el azúcar sintáctico.

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!!!

Creo que vale la pena crear una función de lenguaje en Java / C # que permita objetos especiales que se enganchan a la pila mediante un puntero inteligente. La característica le permitiría marcar una clase como vinculada al alcance, de modo que siempre se cree con un gancho en la pila. Podría haber opciones para diferentes tipos de punteros 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.

Creo que lo implícito "vale la pena". Del mismo modo que la implicidad de la recolección de basura "vale la pena". El uso explícito de bloques es refrescante para los ojos, pero no ofrece ninguna ventaja semántica sobre try-finally-dispose ().

¿No es práctico implementar tal característica en los lenguajes Java / C #? ¿Se podría introducir sin romper el código antiguo?

mike30
fuente
3
No es poco práctico, es imposible . El estándar C # no garantiza que los destructores / Disposes se ejecuten nunca , independientemente de cómo se activen. Agregar destrucción implícita al final del alcance no ayudará.
Telastyn
20
@Telastyn ¿Eh? Lo que dice el estándar C # ahora no es relevante, ya que estamos discutiendo cambiar ese mismo documento. El único problema es si esto es práctico de hacer, y para eso lo único interesante sobre la falta actual de garantía son las razones de esta falta de garantía. Tenga en cuenta que usingla ejecución de Dispose está garantizada (bueno, descontando el proceso de morir repentinamente sin que se produzca una excepción, en ese momento toda la limpieza presumiblemente se vuelve discutible).
44
duplicado de ¿Los desarrolladores de Java abandonaron conscientemente RAII? , aunque la respuesta aceptada es completamente incorrecta. La respuesta corta es que Java usa semántica de referencia (montón) en lugar de semántica de valor (pila) , por lo que la finalización determinista no es muy útil / posible. C # hace tener valor-semántica ( struct), pero por lo general se evitan excepto en casos muy especiales. Ver también .
BlueRaja - Danny Pflughoeft
2
Es similar, no es un duplicado exacto.
Maniero
3
blogs.msdn.com/b/oldnewthing/archive/2010/08/10/10048150.aspx es una página relevante para esta pregunta.
Maniero

Respuestas:

17

Tal extensión de lenguaje sería significativamente más complicada e invasiva de lo que parece pensar. No puedes simplemente agregar

Si el tiempo de vida de una variable de un tipo enlazado a la pila finaliza, llame Disposeal objeto al que se refiere

a la sección correspondiente de la especificación del idioma y listo. Ignoraré el problema de los valores temporales ( new Resource().doSomething()) que se pueden resolver con una redacción un poco más general, este no es el problema más grave. Por ejemplo, este código se rompería (y este tipo de cosas probablemente se vuelve imposible de hacer en general):

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

Ahora necesita constructores de copia definidos por el usuario (o mover constructores) y comenzar a invocarlos en todas partes. Esto no solo conlleva implicaciones de rendimiento, sino que también hace que estas cosas valoren efectivamente los tipos, mientras que casi todos los demás objetos son tipos de referencia. En el caso de Java, esta es una desviación radical de cómo funcionan los objetos. En C #, menos (ya tiene structs, pero no tiene constructores de copia definidos por el usuario para ellos AFAIK), pero todavía hace que estos objetos RAII sean más especiales. Alternativamente, una versión limitada de tipos lineales (cf. Rust) también puede resolver el problema, a costa de prohibir el alias, incluido el paso de parámetros (a menos que desee introducir aún más complejidad adoptando referencias prestadas similares a Rust y un verificador de préstamos).

Se puede hacer técnicamente, pero terminas con una categoría de cosas que son muy diferentes de todo lo demás en el idioma. Esta es casi siempre una mala idea, con consecuencias para los implementadores (más casos extremos, más tiempo / costo en cada departamento) y usuarios (más conceptos para aprender, más posibilidad de errores). No vale la pena la conveniencia adicional.


fuente
¿Por qué necesitas copiar / mover constructor? El archivo sigue siendo un tipo de referencia. En esa situación, f, que es un puntero, se copia a la persona que llama y es responsable de deshacerse del recurso (el compilador implícitamente pondría un patrón de intentar finalmente deshacerse en la persona que llama)
Maniero
1
@bigown Si trata cada referencia de Fileesta manera, nada cambia y Disposenunca se llama. Si siempre llama Dispose, no puede hacer nada con objetos desechables. ¿O está proponiendo algún esquema para algunas veces deshacerse y otras no? Si es así, descríbalo en detalle y te diré situaciones en las que falla.
No veo lo que dijiste ahora (no digo que estés equivocado). El objeto tiene un recurso, no la referencia.
Maniero
Según tengo entendido, cambiar su ejemplo a solo un retorno, es que el compilador insertará una prueba justo antes de la adquisición de recursos (línea 3 en su ejemplo) y el bloque de disposición final justo antes del final del alcance (línea 6). No hay problema aquí, ¿de acuerdo? De vuelta a tu ejemplo. El compilador ve una transferencia, no pudo insertar try-finally aquí, pero la persona que llama recibirá un objeto (puntero a) File y suponiendo que la persona que llama no transfiere este objeto nuevamente, el compilador insertará el patrón try-finally allí. En otras palabras, cada objeto IDisposable no transferido necesita aplicar el patrón try-finally.
Maniero
1
@bigown En otras palabras, no llame Disposesi se escapa una referencia? El análisis de escape es un problema antiguo y difícil, esto no siempre funcionará sin más cambios en el idioma. Cuando se pasa una referencia a otro método (virtual) ( something.EatFile(f);), ¿debería f.Disposellamarse al final del alcance? En caso afirmativo, interrumpe las llamadas que almacenan fpara su uso posterior. Si no, pierde el recurso si la persona que llama no almacena f. La única forma algo simple de eliminar esto es un sistema de tipo lineal, que (como ya discutí más adelante en mi respuesta) presenta muchas otras complicaciones.
26

La mayor dificultad para implementar algo como esto para Java o C # sería definir cómo funciona la transferencia de recursos. Necesitaría alguna forma de extender la vida útil del recurso más allá del alcance. 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!

Lo peor es que esto puede no ser obvio para el implementador de IWrapAResource:

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

Algo así como la usingdeclaración de C # es probablemente lo más parecido a tener semántica RAII sin recurrir a recursos de conteo de referencia o forzar semántica de valor en todas partes como C o C ++. Debido a que Java y C # tienen un uso compartido implícito de los recursos administrados por un recolector de basura, lo mínimo que un programador debería poder hacer es elegir el alcance al que está vinculado un recurso, que es exactamente lo que usingya hace.

Billy ONeal
fuente
Suponiendo que no tiene la necesidad de referirse a una variable después de que se haya salido del alcance (y realmente no debería haber tal necesidad), afirmo que aún puede hacer que un objeto se elimine automáticamente escribiendo un finalizador para él. . Se llama al finalizador justo antes de que el objeto se recolecte basura. Ver msdn.microsoft.com/en-us/library/0s71x931.aspx
Robert Harvey
8
@Robert: un programa escrito correctamente no puede asumir que los finalizadores se ejecuten. blogs.msdn.com/b/oldnewthing/archive/2010/08/09/10047586.aspx
Billy ONeal
1
Hm. Bueno, probablemente por eso se les ocurrió la usingdeclaración.
Robert Harvey
2
Exactamente. Esta es una gran fuente de errores de novato en C ++, y también estaría en Java / C #. Java / C # no elimina la capacidad de filtrar la referencia a un recurso que está a punto de ser destruido, pero al hacerlo explícito y opcional le recuerdan al programador y le dan una opción consciente de qué hacer.
Aleksandr Dubinsky
1
@svick No es hasta IWrapSomethingdisponer de T. Quien haya creado Tdebe preocuparse por eso, ya sea usando using, siendo él IDisposablemismo o teniendo un esquema de ciclo de vida de recursos ad-hoc.
Aleksandr Dubinsky
13

La razón por la cual RAII no puede funcionar en un lenguaje como C #, pero funciona en C ++, es porque en C ++ puede decidir si un objeto es realmente temporal (asignándolo en la pila) o si es de larga duración (por asignándolo en el montón usando newy usando punteros).

Entonces, en C ++, puedes hacer algo como esto:

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

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

En C #, no puede diferenciar entre los dos casos, por lo que el compilador no tendría idea de si finalizar el objeto o no.

Lo que podría hacer es introducir algún tipo de variable local especial, que no pueda poner en campos, etc. * y que se eliminaría automáticamente cuando salga del alcance. Que es exactamente lo que hace C ++ / CLI. En C ++ / CLI, escribe 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
}

Esto compila básicamente al mismo IL que el siguiente 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, si tuviera que adivinar por qué los diseñadores de C # no agregaron RAII, es porque pensaron que no vale la pena tener dos tipos diferentes de variables locales, principalmente porque en un lenguaje con GC, la finalización determinista no es útil que a menudo.

* No sin el equivalente del &operador, que en C ++ / CLI es %. Aunque hacerlo es "inseguro" en el sentido de que una vez que finaliza el método, el campo hará referencia a un objeto dispuesto.

svick
fuente
1
C # podría hacer RAII trivialmente si permitiera destructores para structtipos como D.
Jan Hudec
6

Si lo que le molesta con los usingbloques es su explicidad, tal vez podamos dar un pequeño paso hacia una menor explicidad, en lugar de cambiar la propia especificación de C #. Considera este código:

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

¿Ves la localpalabra clave que agregué? Todo lo que hace es agregar un poco más de azúcar sintáctica, como usingdecirle al compilador que llame Disposeen un finallybloque al final del alcance de la variable. Eso es todo. Es totalmente equivalente a:

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

pero con un alcance implícito, en lugar de uno explícito. Es más simple que las otras sugerencias, ya que no tengo que tener la clase definida como vinculada al alcance. Solo azúcar sintáctico más limpio e implícito.

Puede haber problemas aquí con ámbitos difíciles de resolver, aunque no puedo verlo en este momento, y agradecería a cualquiera que pueda encontrarlo.

Avner Shahar-Kashtan
fuente
1
@ mike30, pero moverlo a la definición de tipo lo lleva exactamente a los problemas que otros enumeraron: ¿qué sucede si pasa el puntero a un método diferente o lo devuelve desde la función? De esta forma, el alcance se declara en el ámbito, no en otro lugar. Un tipo puede ser desechable, pero no depende de él llamar a Dispose.
Avner Shahar-Kashtan
3
@ mike30: Meh. Todo lo que hace esta sintaxis es eliminar las llaves y, por extensión, el control de alcance que proporcionan.
Robert Harvey
1
@RobertHarvey Exactamente. Sacrifica algo de flexibilidad por un código más limpio y menos anidado. Si tomamos la sugerencia de @ delnan y reutilizamos la usingpalabra clave, podemos mantener el comportamiento existente y usarlo también, para los casos en que no necesitamos el alcance específico. Tener un usingvalor predeterminado sin llaves para el alcance actual.
Avner Shahar-Kashtan
1
No tengo ningún problema con los ejercicios semi prácticos en el diseño del lenguaje.
Avner Shahar-Kashtan
1
@RobertHarvey. Parece tener un sesgo en contra de todo lo que no está implementado actualmente en C #. No tendríamos genéricos, linq, bloques de uso, tipos ipmlicit, etc. si estuviéramos satisfechos con C # 1.0. Esta sintaxis no resuelve el problema de la implícitaidad, pero es un buen azúcar para unirse al alcance actual.
mike30
1

Para ver un ejemplo de cómo funciona RAII en un lenguaje recolectado, revise la withpalabra clave en Python . En lugar de confiar en objetos destruidos determinísticamente, le permite asociar __enter__()y __exit__()métodos a un ámbito léxico dado. Un ejemplo común es:

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

Al igual que con el estilo RAII de C ++, el archivo se cerraría al salir de ese bloque, sin importar si es una salida 'normal' break, una inmediata returno una excepción.

Tenga en cuenta que la open()llamada es la función habitual de apertura de archivos. Para que esto funcione, el objeto de archivo devuelto incluye dos métodos:

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

Este es un idioma común en Python: los objetos que están asociados con un recurso generalmente incluyen estos dos métodos.

Tenga en cuenta que el objeto del archivo aún podría permanecer asignado después de la __exit__()llamada, lo importante es que esté cerrado.

Javier
fuente
77
withen Python es casi exactamente como usingen C #, y como tal no RAII en lo que respecta a esta pregunta.
1
El "con" de Python es una gestión de recursos vinculada al alcance, pero le falta la implícita de un puntero inteligente. El acto de declarar un puntero como inteligente podría considerarse "explícito", pero si el compilador imponía la inteligencia como parte del tipo de objeto, se inclinaría hacia "implícito".
mike30
AFAICT, el objetivo de RAII es establecer un alcance estricto a los recursos. si solo está interesado en hacer la desasignación de objetos, entonces no, los lenguajes recolectados de basura no pueden hacerlo. Si está interesado en liberar recursos constantemente, esta es una forma de hacerlo (otra está deferen el lenguaje Go).
Javier
1
En realidad, creo que es justo decir que Java y C # favorecen fuertemente las construcciones explícitas. De lo contrario, ¿por qué molestarse con toda la ceremonia inherente al uso de interfaces y herencia?
Robert Harvey
1
@delnan, Go tiene interfaces 'implícitas'.
Javier