Buscando una idea de las decisiones sobre el diseño del lenguaje recolectado basura. ¿Quizás un experto en idiomas podría iluminarme? Vengo de un fondo C ++, por lo que esta área es desconcertante para mí.
Parece que casi todos los lenguajes modernos recolectados de basura con soporte de objetos OOPy como Ruby, Javascript / ES6 / ES7, Actionscript, Lua, etc. omiten por completo el paradigma destructor / finalize. Python parece ser el único con su class __del__()
método. ¿Por qué es esto? ¿Existen limitaciones funcionales / teóricas dentro de los lenguajes con recolección automática de basura que impiden implementaciones efectivas de un método destructor / finalize en objetos?
Me parece extremadamente escaso que estos lenguajes consideren la memoria como el único recurso que vale la pena administrar. ¿Qué pasa con los sockets, identificadores de archivos, estados de aplicación? Sin la capacidad de implementar una lógica personalizada para limpiar recursos y estados que no sean de memoria en la finalización del objeto, debo llenar mi aplicación con myObject.destroy()
llamadas de estilo personalizadas , colocar la lógica de limpieza fuera de mi "clase", romper el intento de encapsulación y relegar mi aplicación a fugas de recursos debido a un error humano en lugar de ser manejado automáticamente por el gc.
¿Cuáles son las decisiones de diseño del lenguaje que hacen que estos lenguajes no tengan ninguna forma de ejecutar una lógica personalizada en la eliminación de objetos? Tengo que imaginar que hay una buena razón. Me gustaría comprender mejor las decisiones técnicas y teóricas que dieron como resultado que estos lenguajes no tengan soporte para la destrucción / finalización de objetos.
Actualizar:
Quizás una mejor manera de formular mi pregunta:
¿Por qué un lenguaje tendría el concepto incorporado de instancias de objeto con clase o estructuras de clase junto con instanciación personalizada (constructores), pero omitiría por completo la funcionalidad de destrucción / finalización? Los idiomas que ofrecen recolección de basura automática parecen ser los principales candidatos para apoyar la destrucción / finalización de objetos, ya que saben con 100% de certeza cuando un objeto ya no está en uso. Sin embargo, la mayoría de esos idiomas no lo admiten.
No creo que sea un caso en el que nunca se llame al destructor, ya que eso sería una pérdida de memoria central, que los gcs están diseñados para evitar. Pude ver un posible argumento que dice que el destructor / finalizador no puede ser llamado hasta cierto tiempo indeterminado en el futuro, pero eso no impidió que Java o Python admitieran la funcionalidad.
¿Cuáles son las razones principales del diseño del lenguaje para no admitir ninguna forma de finalización de objetos?
finalize
/destroy
es una mentira? No hay garantía de que alguna vez se ejecute. E, incluso si, no sabe cuándo (dada la recolección automática de basura), y si es necesario, el contexto todavía está allí (puede que ya se haya recolectado). Por lo tanto, es más seguro garantizar un estado coherente de otras maneras, y uno podría obligar al programador a hacerlo.Respuestas:
El patrón del que habla, donde los objetos saben cómo limpiar sus recursos, se divide en tres categorías relevantes. No confundamos destructores con finalizadores : solo uno está relacionado con la recolección de basura:
El patrón finalizador : método de limpieza declarado automáticamente, definido por el programador, llamado automáticamente.
Los finalizadores son llamados automáticamente antes de la desasignación por un recolector de basura. El término se aplica si el algoritmo de recolección de basura empleado puede determinar los ciclos de vida de los objetos.
El patrón destructor : método de limpieza declarado automáticamente, definido por el programador, llamado automáticamente solo a veces.
Se puede llamar a los destructores automáticamente para los objetos asignados a la pila (porque la duración del objeto es determinista), pero se debe invocar explícitamente en todas las rutas de ejecución posibles para los objetos asignados en el montón (porque la duración del objeto no es determinista).
El patrón de disposición : método de limpieza declarado, definido y llamado por el programador.
Los programadores hacen un método de eliminación y lo llaman ellos mismos: aquí es donde
myObject.destroy()
cae su método personalizado . Si la eliminación es absolutamente necesaria, se debe llamar a los eliminadores en todas las rutas de ejecución posibles.Los finalizadores son los droides que estás buscando.
El patrón finalizador (el patrón sobre el que pregunta su pregunta) es el mecanismo para asociar objetos con recursos del sistema (sockets, descriptores de archivos, etc.) para la recuperación mutua por parte de un recolector de basura. Pero los finalizadores están fundamentalmente a merced del algoritmo de recolección de basura en uso.
Considere esta suposición suya:
Técnicamente falso (gracias, @babou). La recolección de basura se trata fundamentalmente de memoria, no de objetos. Si un algoritmo de recopilación se da cuenta de que la memoria de un objeto ya no está en uso depende del algoritmo y (posiblemente) de cómo se refieren sus objetos entre sí. Hablemos de dos tipos de recolectores de basura en tiempo de ejecución. Hay muchas formas de alterar y aumentar estas técnicas básicas:
Rastreo GC. Estos rastrean la memoria, no los objetos. A menos que se aumente para hacerlo, no conservan referencias a objetos de la memoria. A menos que se aumenten, estos GC no sabrán cuándo se puede finalizar un objeto, incluso si saben cuándo su memoria es inalcanzable. Por lo tanto, las llamadas finalizadas no están garantizadas.
Recuento de referencia GC . Estos usan objetos para rastrear la memoria. Modelan la accesibilidad de objetos con un gráfico dirigido de referencias. Si hay un ciclo en su gráfico de referencia de objetos, entonces todos los objetos en el ciclo nunca tendrán su finalizador llamado (hasta la finalización del programa, obviamente). Nuevamente, las llamadas al finalizador no están garantizadas.
TLDR
La recolección de basura es difícil y diversa. No se puede garantizar una llamada finalista antes de la finalización del programa.
fuente
finalize()
hace que se vuelva a hacer referencia al objeto que se está limpiando?). Sin embargo, no poder garantizar que se llame al finalizador antes de la finalización del programa no impidió que Java lo admitiera. No digo que tu respuesta sea incorrecta, quizás incompleta. Sigue siendo una muy buena publicación. Gracias.En una palabra
La finalización no es un asunto simple que deben manejar los recolectores de basura. Es fácil de usar con GC de conteo de referencias, pero esta familia de GC a menudo es incompleta, lo que requiere que las fugas de memoria sean compensadas por un disparo explícito de destrucción y finalización de algunos objetos y estructuras. Los recolectores de basura de rastreo son mucho más efectivos, pero hacen que sea mucho más difícil identificar el objeto que se finalizará y destruirá, en lugar de solo identificar la memoria no utilizada, lo que requiere una administración más compleja, con un costo en tiempo y espacio, y en complejidad de la implementación.
Introducción
Supongo que lo que está preguntando es por qué los idiomas recolectados de basura no manejan automáticamente la destrucción / finalización dentro del proceso de recolección de basura, como lo indica el comentario:
No estoy de acuerdo con la respuesta aceptada dada por kdbanman . Si bien los hechos indicados son en su mayoría correctos, aunque están fuertemente sesgados hacia el conteo de referencias, no creo que expliquen adecuadamente la situación de la que se queja en la pregunta.
No creo que la terminología desarrollada en esa respuesta sea un gran problema, y es más probable que confunda las cosas. De hecho, como se presenta, la terminología está determinada principalmente por la forma en que se activan los procedimientos y no por lo que hacen. El punto es que, en todos los casos, existe la necesidad de finalizar un objeto que ya no se necesita con algún proceso de limpieza y liberar cualquier recurso que haya estado utilizando, la memoria es solo uno de ellos. Idealmente, todo debe hacerse automáticamente cuando el objeto ya no se use, por medio de un recolector de basura. En la práctica, GC puede estar ausente o tener deficiencias, y esto se compensa con la activación explícita del programa de finalización y recuperación.
El trigerring explícito por parte del programa es un problema, ya que puede permitir errores de programación difíciles de analizar, cuando un objeto que todavía está en uso se termina explícitamente.
Por lo tanto, es mucho mejor confiar en la recolección automática de basura para recuperar recursos. Pero hay dos problemas:
alguna técnica de recolección de basura permitirá pérdidas de memoria que evitarán la recuperación total de los recursos. Esto es bien conocido para el conteo de referencias GC, pero puede aparecer para otras técnicas de GC cuando se usan algunas organizaciones de datos sin cuidado (punto no discutido aquí).
Si bien la técnica GC puede ser buena para identificar los recursos de memoria que ya no se utilizan, finalizar los objetos contenidos en él puede no ser simple, y eso complica el problema de recuperar otros recursos utilizados por estos objetos, que a menudo es el propósito de la finalización.
Finalmente, un punto importante que a menudo se olvida es que los ciclos de GC pueden ser activados por cualquier cosa, no solo por la escasez de memoria, si se proporcionan los ganchos adecuados y si se considera que el costo de un ciclo de GC vale la pena. Por lo tanto, está perfectamente bien iniciar un GC cuando falta algún tipo de recurso, con la esperanza de liberar algunos.
Colectores de basura de conteo de referencia
El conteo de referencias es una técnica de recolección de basura débil , que no manejará los ciclos correctamente. De hecho, sería débil al destruir estructuras obsoletas y reclamar otros recursos simplemente porque es débil al reclamar memoria. Pero los finalizadores se pueden usar más fácilmente con un recolector de basura de conteo de referencia (GC), ya que un GC de recuento de ref reclama una estructura cuando su recuento de ref se reduce a 0, momento en el que su dirección se conoce junto con su tipo, ya sea estáticamente o dinámicamente Por lo tanto, es posible recuperar la memoria con precisión después de aplicar el finalizador adecuado y llamar de forma recursiva al proceso en todos los objetos puntiagudos (posiblemente a través del procedimiento de finalización).
En pocas palabras, la finalización es fácil de implementar con Ref Counting GC, pero sufre de la "incompletitud" de ese GC, de hecho debido a estructuras circulares, precisamente en la misma medida que sufre la recuperación de memoria. En otras palabras, con el recuento de referencias, la memoria está precisamente tan mal gestionada como otros recursos como sockets, identificadores de archivos, etc.
De hecho, la incapacidad de Ref Count GC para recuperar estructuras en bucle (en general) puede verse como una pérdida de memoria . No puede esperar que todos los GC eviten pérdidas de memoria. Depende del algoritmo de GC y de la información de estructura de tipo disponible dinámicamente (por ejemplo, en GC conservador ).
Rastreando recolectores de basura
La familia más poderosa de GC, sin tales fugas, es la familia de rastreo que explora las partes vivas de la memoria, comenzando por punteros de raíz bien identificados. Todas las partes de la memoria que no se visitan en este proceso de rastreo (que en realidad se pueden descomponer de varias maneras, pero tengo que simplificar) son partes no utilizadas de la memoria que se pueden recuperar 1 . Estos recolectores reclamarán todas las partes de la memoria a las que el programa ya no puede acceder, sin importar lo que haga. Reclama estructuras circulares, y los GC más avanzados se basan en alguna variación de este paradigma, a veces altamente sofisticado. Se puede combinar con el recuento de referencias en algunos casos y compensar sus debilidades.
Un problema es que su declaración (al final de la pregunta):
es técnicamente incorrecto para rastrear colectores.
Lo que se sabe con 100% de certeza es qué partes de la memoria ya no están en uso . (Más precisamente, debe decirse que ya no son accesibles , porque algunas partes, que ya no se pueden usar de acuerdo con la lógica del programa, todavía se consideran en uso si todavía hay un puntero inútil en el programa datos.) Pero se necesitan más procesamiento y estructuras apropiadas para saber qué objetos no utilizados pueden haberse almacenado en estas partes de la memoria ahora no utilizadas . Esto no se puede determinar a partir de lo que se conoce del programa, ya que el programa ya no está conectado a estas partes de la memoria.
Por lo tanto, después de un paso de recolección de basura, le quedan fragmentos de memoria que contienen objetos que ya no están en uso, pero a priori no hay forma de saber cuáles son estos objetos para aplicar la finalización correcta. Además, si el colector de rastreo es del tipo de marcado y barrido, puede ser que algunos de los fragmentos puedan contener objetos que ya se hayan finalizado en un pase de GC anterior, pero que no se utilizaron desde entonces por razones de fragmentación. Sin embargo, esto puede tratarse con una escritura explícita extendida.
Si bien un simple recopilador solo reclamaría estos fragmentos de memoria, sin más preámbulos, la finalización requiere un pase específico para explorar esa memoria no utilizada, identificar los objetos allí contenidos y aplicar los procedimientos de finalización. Pero tal exploración requiere la determinación del tipo de objetos que se almacenaron allí, y la determinación del tipo también es necesaria para aplicar la finalización adecuada, si corresponde.
Eso implica costos adicionales en el tiempo de GC (el pase adicional) y posiblemente costos adicionales de memoria para hacer que la información de tipo adecuada esté disponible durante ese pase mediante diversas técnicas. Estos costos pueden ser significativos, ya que a menudo uno solo querrá finalizar unos pocos objetos, mientras que el tiempo y el espacio de arriba podrían afectar a todos los objetos.
Otro punto es que la sobrecarga de tiempo y espacio puede referirse a la ejecución del código del programa, y no solo a la ejecución del GC.
No puedo dar una respuesta más precisa, señalando problemas específicos, porque no conozco los detalles de muchos de los idiomas que enumera. En el caso de C, escribir es un tema muy difícil que conduce al desarrollo de coleccionistas conservadores. Supongo que esto también afecta a C ++, pero no soy un experto en C ++. Esto parece ser confirmado por Hans Boehm, quien realizó gran parte de la investigación sobre GC conservador. El GC conservador no puede reclamar sistemáticamente toda la memoria no utilizada precisamente porque puede carecer de información de tipo precisa sobre los datos. Por la misma razón, no podría aplicar sistemáticamente los procedimientos de finalización.
Por lo tanto, es posible hacer lo que está pidiendo, como sabe de algunos idiomas. Pero no viene gratis. Dependiendo del idioma y su implementación, puede implicar un costo incluso si no utiliza la función. Se pueden considerar varias técnicas y compensaciones para abordar estos problemas, pero eso está más allá del alcance de una respuesta de tamaño razonable.
1: esta es una presentación abstracta de la colección de rastreo (que abarca tanto GC como copiar y marcar y barrer), las cosas varían según el tipo de recopilador de rastreo, y explorar la parte no utilizada de la memoria es diferente, dependiendo de si copiar o marcar y Se utiliza el barrido.
fuente
getting memory recycled
, a la que llamoreclamation
, y que haga algo de limpieza antes de eso, como reclamar otros recursos o actualizar algunas tablas de objetos, a las que llamofinalization
. Estos me parecieron los problemas relevantes, pero es posible que haya perdido un punto en su terminología, que era nuevo para mí.El patrón destructor de objetos es fundamental para el manejo de errores en la programación de sistemas, pero no tiene nada que ver con la recolección de basura. Más bien, tiene que ver con hacer coincidir la vida útil del objeto con un ámbito, y puede implementarse / usarse en cualquier lenguaje que tenga funciones de primera clase.
Ejemplo (pseudocódigo). Supongamos que tiene un tipo de "archivo sin formato", como el tipo de descriptor de archivo Posix. Hay cuatro operaciones fundamentales,
open()
,close()
,read()
,write()
. Desea implementar un tipo de archivo "seguro" que siempre se limpia después de sí mismo. (Es decir, que tiene un constructor y destructor automático).Asumiré que nuestro idioma tiene un manejo de excepciones
throw
,try
yfinally
(en idiomas sin manejo de excepciones, puede configurar una disciplina en la que el usuario de su tipo devuelva un valor especial para indicar un error).Configura una función que acepta una función que hace el trabajo. La función de trabajo acepta un argumento (un identificador para el archivo "seguro").
También proporciona implementaciones de
read()
ywrite()
parasafe_file
(que solo llaman araw_file
read()
ywrite()
). Ahora el usuario usa elsafe_file
tipo de esta manera:Un destructor de C ++ es solo azúcar sintáctico para un
try-finally
bloque. Casi todo lo que he hecho aquí es convertir en lo quesafe_file
se compilaría una clase C ++ con un constructor y un destructor. Tenga en cuenta que C ++ no tienefinally
sus excepciones, específicamente porque Stroustrup sintió que usar un destructor explícito era mejor sintácticamente (y lo introdujo en el lenguaje antes de que el lenguaje tuviera funciones anónimas).(Esta es una simplificación de una de las formas en que las personas han estado manejando errores en lenguajes similares a Lisp durante muchos años. Creo que me topé por primera vez a fines de los 80 o principios de los 90, pero no recuerdo dónde).
fuente
safe_file
ywith_file_opened_for_read
(un objeto que se cierra cuando sale del alcance ) Eso es lo importante, que no tenga la misma sintaxis que los constructores es irrelevante. Lisp, Scheme, Java, Scala, Go, Haskell, Rust, Javascript, Clojure soportan suficientes funciones de primera clase, por lo que no necesitan destructores para proporcionar la misma característica útil.Esta no es una respuesta completa a la pregunta, pero quería agregar un par de observaciones que no se han cubierto en las otras respuestas o comentarios.
La pregunta supone implícitamente que estamos hablando de un lenguaje orientado a objetos al estilo Simula, que en sí mismo es limitante. En la mayoría de los idiomas, incluso aquellos con objetos, no todo es un objeto. La maquinaria para implementar destructores impondría un costo que no todos los implementadores de idiomas están dispuestos a pagar.
C ++ tiene algunas garantías implícitas sobre el orden de destrucción. Si tiene una estructura de datos en forma de árbol, por ejemplo, los elementos secundarios se destruirán antes que los elementos primarios. Este no es el caso en los lenguajes GC'd, por lo que los recursos jerárquicos pueden liberarse en un orden impredecible. Para recursos que no son de memoria, esto puede importar.
fuente
Cuando se diseñaron los dos marcos GC más populares (Java y .NET), creo que los autores esperaban que la finalización funcionara lo suficientemente bien como para evitar la necesidad de otras formas de gestión de recursos. Muchos aspectos del diseño del lenguaje y el marco pueden simplificarse enormemente si no se necesitan todas las características necesarias para acomodar una gestión de recursos 100% confiable y determinista. En C ++, es necesario distinguir entre los conceptos de:
Puntero / referencia que identifica un objeto que es propiedad exclusiva del titular de la referencia, y que no está identificado por ningún puntero / referencia que el propietario no conozca.
Puntero / referencia que identifica un objeto compartible que no es propiedad exclusiva de nadie.
Puntero / referencia que identifica un objeto que es propiedad exclusiva del titular de la referencia, pero al que puede accederse a través de "vistas", el propietario no tiene forma de seguimiento.
Puntero / referencia que identifica un objeto que proporciona una vista de un objeto que es propiedad de otra persona.
Si un lenguaje / marco de GC no tiene que preocuparse por la gestión de recursos, todo lo anterior se puede reemplazar por un solo tipo de referencia.
Me parecería ingenua la idea de que la finalización eliminaría la necesidad de otras formas de gestión de recursos, pero ya sea que tal expectativa fuera o no razonable en ese momento, la historia ha demostrado que hay muchos casos que requieren una gestión de recursos más precisa que la que proporciona la finalización. . Creo que las recompensas de reconocer la propiedad a nivel de lenguaje / marco serían suficientes para justificar el costo (la complejidad debe existir en algún lugar, y moverlo al idioma / marco simplificaría el código del usuario), pero reconozco que hay importantes El diseño se beneficia de tener un solo "tipo" de referencia, algo que solo funciona si el lenguaje / marco es independiente de los problemas de limpieza de recursos.
fuente
El destructor en C ++ en realidad hace dos cosas combinadas. Libera RAM y libera identificadores de recursos.
Otros idiomas separan estas preocupaciones haciendo que el GC se encargue de liberar RAM, mientras que otra función de idioma se encarga de liberar los identificadores de recursos.
De eso se tratan los GC. Solo hacen una cosa y es asegurarse de que no se quede sin memoria. Si la RAM es infinita, todos los GC se retirarían ya que ya no hay ninguna razón real para que existan.
Los idiomas pueden proporcionar diferentes formas de liberar identificadores de recursos mediante:
manual
.CloseOrDispose()
disperso en el códigomanual
.CloseOrDispose()
disperso dentro del "finally
bloque" manualmanuales "bloques de ID de recurso" (es decir
using
,with
,try
-con-recursos , etc.) que automatiza.CloseOrDispose()
después de que el bloque se realiza"bloques de identificación de recursos" garantizados que se automatizan
.CloseOrDispose()
después de que se realiza el bloqueMuchos idiomas utilizan mecanismos manuales (en lugar de garantizados) que crean una oportunidad para la mala gestión de los recursos. Tome este simple código NodeJS:
..donde el programador ha olvidado cerrar el archivo abierto.
Mientras el programa siga ejecutándose, el archivo abierto quedaría atascado en el limbo. Esto es fácil de verificar al intentar abrir el archivo usando HxD y verificar que no se puede hacer:
La liberación de identificadores de recursos en destructores de C ++ tampoco está garantizada. Se podría pensar RAII funciona como garantizados "bloques" ID de recurso, sin embargo, a diferencia de los "bloques" ID de recurso, el lenguaje C ++ no se detiene el objeto proporcionar el bloque RAII de ser filtrado , por lo que el bloque de RAII no puede ser hecho .
Porque administran los identificadores de recursos de otras maneras, como se mencionó anteriormente.
Porque administran los identificadores de recursos de otras maneras, como se mencionó anteriormente.
Porque administran los identificadores de recursos de otras maneras, como se mencionó anteriormente.
Java no tiene destructores.
Los documentos de Java mencionan :
... pero poner el código de gestión de identificación de recursos dentro
Object.finalizer
se considera en gran medida como un antipatrón ( cf. ). Esos códigos deberían escribirse en el sitio de la llamada.Para las personas que usan el antipatrón, su justificación es que podrían haberse olvidado de liberar los identificadores de recursos en el sitio de la llamada. Por lo tanto, lo hacen de nuevo en el finalizador, por si acaso.
No hay muchos casos de uso para los finalizadores, ya que son para ejecutar un fragmento de código entre el momento en que ya no hay referencias fuertes al objeto y el momento en que el GC reclama su memoria.
Un posible caso de uso es cuando desea mantener un registro del tiempo entre el objeto recopilado por el GC y el momento en que ya no hay referencias fuertes al objeto, como tal:
fuente
Encontré una referencia sobre esto en el Dr. Dobbs wrt c ++ que tiene ideas más generales que argumenta que los destructores son problemáticos en un lenguaje donde se implementan. Una idea aproximada aquí parece ser que un propósito principal de los destructores es manejar la desasignación de memoria, y eso es difícil de lograr correctamente. la memoria se asigna por partes, pero se conectan diferentes objetos y luego la responsabilidad / límites de desasignación no son tan claros.
así que la solución a esto de un recolector de basura evolucionó hace años, pero la recolección de basura no se basa en objetos que desaparecen del alcance en las salidas del método (esa es una idea conceptual que es difícil de implementar), sino en un recolector que se ejecuta periódicamente, algo no determinista, cuando la aplicación experimenta "presión de memoria" (es decir, quedarse sin memoria).
en otras palabras, el mero concepto humano de un "objeto recién no utilizado" es, de alguna manera, una abstracción engañosa en el sentido de que ningún objeto puede "instantáneamente" quedar sin usar. los objetos no utilizados solo se pueden "descubrir" ejecutando un algoritmo de recolección de basura que atraviese el gráfico de referencia del objeto y los algoritmos de mejor desempeño se ejecuten de manera intermitente.
es posible que haya un mejor algoritmo de recolección de basura a la espera de ser descubierto que pueda identificar casi instantáneamente objetos no utilizados, lo que podría conducir a un código de llamada de destructor consistente, pero no se ha encontrado uno después de muchos años de investigación en el área.
La solución para las áreas de administración de recursos, como archivos o conexiones, parece ser tener "administradores" de objetos que intenten manejar su uso.
fuente