
Casi todos los programas no triviales asignan y usan memoria dinámica. Hacerlo correctamente es cada vez más importante a medida que los programas se vuelven más complejos y los errores son aún más caros.
Los problemas típicos son:
- pérdidas de memoria (no libera memoria usada)
- liberación doble (liberación de memoria más de una vez)
- usar después del lanzamiento (uso de un puntero a una memoria previamente liberada)
La tarea es rastrear los punteros responsables de liberar memoria (es decir, aquellos que poseen la memoria) y distinguir los punteros que simplemente apuntan a un fragmento de memoria, controlan dónde se encuentran y cuáles están activos (en alcance).
Las soluciones típicas son las siguientes:
- Recolección de basura (GC): GC posee bloques de memoria y los analiza periódicamente en busca de punteros a estos bloques. Si no se encuentran punteros, se libera memoria. Este esquema es confiable y se usa en lenguajes como Go y Java. Pero el GC tiende a usar mucha más memoria de la necesaria, tiene pausas y ralentiza el código debido al reempaquetado (compuertas de escritura insertadas originalmente).
- Recuento de referencias (RC): un objeto RC posee memoria y almacena un contador de punteros para sí mismo. Cuando este contador disminuye a cero, se libera memoria. También es un mecanismo confiable y es aceptado en lenguajes como C ++ y ObjectiveC. RC es eficiente en memoria, además requiere solo espacio debajo del mostrador. Los aspectos negativos de RC son la sobrecarga de mantener el contador, incrustar un controlador de excepciones para garantizar su reducción y el bloqueo necesario para los objetos compartidos entre los flujos del programa. Para mejorar el rendimiento, los programadores a veces engañan al referirse temporalmente a un objeto RC sin pasar por el contador, creando el riesgo de hacerlo incorrectamente.
- Control manual: la administración manual de la memoria es Sysalny malloc y es gratuita. Es rápido y eficiente en términos de uso de memoria, pero el lenguaje no ayuda a hacer todo correctamente, confiando completamente en la experiencia y el celo del programador. He estado usando malloc y gratis durante 35 años, y con la ayuda de una experiencia amarga e interminable, rara vez cometo errores. Pero esta no es la forma en que la tecnología de programación puede confiar, y tenga en cuenta que dije "raramente" y no "nunca".
Las soluciones 2 y 3 en un grado u otro confían en la fe en el programador para hacer todo correctamente. Los sistemas basados en la fe no escalan bien, y se ha comprobado que los errores de administración de memoria son muy difíciles de volver a verificar (tan malo que algunos estándares de codificación prohíben el uso de memoria dinámica).
Pero también hay una cuarta vía: propiedad y préstamos, OB. Es eficiente desde el punto de vista de la memoria, tan rápido como la operación manual, y está sujeto a una verificación automática. El método ha sido popularizado recientemente por el lenguaje de programación Rust. También tiene sus inconvenientes, en particular la necesidad de repensar la planificación de algoritmos y estructuras de datos.
Puede lidiar con aspectos negativos, y el resto de este artículo es una descripción esquemática de cómo funciona el sistema OB y cómo proponemos escribirlo en el lenguaje D. Inicialmente lo consideré imposible, pero después de pasar un tiempo pensando, encontré una manera. Es similar a lo que hicimos con la programación funcional, con inmutabilidad transitiva y funciones "puras".
Posesión
La decisión de quién posee el objeto en la memoria es ridículamente simple: hay un solo puntero al objeto y es el propietario. También es responsable de la liberación de la memoria, después de lo cual deja de ser válida. Debido al hecho de que el puntero al objeto en la memoria es el propietario, no hay otros punteros dentro de esta estructura de datos y, por lo tanto, la estructura de datos forma un árbol.
La segunda consecuencia es que los punteros usan la semántica de mover en lugar de copiar:
T* f(); void g(T*); T* p = f(); T* q = p;
Está prohibido eliminar un puntero desde el interior de una estructura de datos:
struct S { T* p; } S* f(); S* s = f(); T* q = sp;
¿Por qué no simplemente marcar sp como no válido? El problema es que esto requerirá configurar la etiqueta en tiempo de ejecución, pero debe resolverse en la etapa de compilación, porque simplemente se considera un error de compilación.
La salida del puntero propio fuera de alcance también es un error:
void h() { T* p = f(); }
Debe mover el valor del puntero de manera diferente:
void g(T*); void h() { T* p = f(); g(p);
Esto resuelve los problemas de pérdida de memoria y el uso después de liberarlo (Sugerencia: para mayor claridad, reemplace f () con malloc () y g () con free ().)
Todo esto se puede verificar en la etapa de compilación utilizando la
técnica de Análisis de flujo de datos (DFA) , al igual que se usa para
eliminar subexpresiones comunes . DFA puede desenrollar cualquier enredo de ratas de las transiciones del programa que puedan surgir.
Préstamo
El sistema de tenencia descrito anteriormente es confiable, pero demasiado restrictivo.
Considera:
struct S { void car(); void bar(); } struct S* f(); S* s = f(); s.car();
Para que esto funcione, s.car () debe tener una forma de recuperar el puntero al salir.
Así es como funcionan los préstamos. s.car () toma una copia de s para la duración de s.car (). s no es válido en tiempo de ejecución y vuelve a ser válido cuando sale s.car ().
En D, las funciones miembro
struct obtienen el puntero
this por referencia, de modo que podemos adaptar el préstamo con una pequeña extensión: tomar el argumento por referencia lo toma.
D también admite el alcance de los punteros, por lo que los préstamos son naturales:
void g(scope T*); T* f(); T* p = f(); g(p);
(Cuando las funciones reciben argumentos por referencia o se usan punteros con alcance, se prohíbe que se extiendan más allá de los límites de una función o alcance. Esto corresponde a la semántica de los préstamos).
Pedir prestado de esta manera garantiza la singularidad de un puntero a un objeto en la memoria en cualquier momento dado.
El endeudamiento se puede ampliar aún más con el entendimiento de que el sistema de propiedad también es confiable, incluso si un objeto está indicado adicionalmente por varios punteros constantes (pero solo uno mutable). Un puntero constante no puede cambiar la memoria ni liberarla. Esto significa que se pueden tomar prestados varios punteros constantes del propietario mutable, pero no tiene derecho a ser utilizado mientras estos punteros constantes estén vivos.
Por ejemplo:
T* f(); void g(T*); T* p = f();
Principios
Lo anterior se puede reducir a la siguiente comprensión de que un objeto en la memoria se comporta como si estuviera en uno de dos estados:
- hay exactamente un puntero mutable
- uno o más punteros constantes adicionales
Un lector atento notará algo extraño en lo que escribí: "como si". ¿Qué quería insinuar? Que esta pasando Sí hay uno. Los lenguajes de programación de computadoras están llenos de "como si" debajo del capó, algo así como el dinero en su cuenta bancaria en realidad no está allí (me disculpo si esto fue un gran shock para alguien), y esto no es diferente de eso. Sigue leyendo!
Pero primero, un poco más profundo en el tema.
Integrando las técnicas de propiedad / préstamo en D
¿No son estas técnicas incompatibles con la forma en que las personas suelen escribir en D, y no se romperán casi todos los programas D existentes? ¿Y no es tan fácil de arreglar, sino tanto que tienes que rediseñar todos los algoritmos desde cero?
Si de hecho. A menos que D tenga un arma (casi) secreta: atributos de funciones. Resulta que la semántica de propiedad / préstamo (OB) se puede implementar para cada función por separado después del análisis semántico habitual. Un lector atento podría notar que no se ha agregado una nueva sintaxis, solo se han impuesto restricciones al código existente. D ya tiene un historial de uso de atributos de función para cambiar su semántica, por ejemplo, el atributo
puro para crear funciones "puras". Para habilitar la semántica OB, se agrega el atributo @
live .
Esto significa que el OB se puede agregar al código en D gradualmente, según sea necesario y recursos gratuitos. Esto hace posible agregar OB, y esto es crítico, ya que respalda constantemente el proyecto en un estado totalmente funcional, probado y listo para lanzar. También le permite automatizar el proceso de monitoreo de qué porcentaje del proyecto ya se ha transferido al OB. Esta técnica se agrega a la lista de otras garantías de lenguaje D con respecto a la confiabilidad de trabajar con memoria (como controlar la no distribución de punteros a variables temporales en la pila).
Como si
Algunas cosas necesarias no se pueden realizar con estricta adherencia a los OB, como los objetos de recuento de referencias. Después de todo, los objetos RC están diseñados para tener muchos punteros hacia ellos. Dado que los objetos RC son seguros cuando se trabaja con memoria (si se implementa correctamente), se pueden usar junto con OB sin afectar negativamente la confiabilidad. Simplemente no se pueden crear utilizando la técnica OB. La solución es que hay otros atributos de función en D, como @
system . @
system son características en las que se desactivan muchas comprobaciones de fiabilidad. Naturalmente, el OB también estará deshabilitado en el código con @
system . Aquí es donde la implementación de la tecnología RC se esconde del control OB.
Pero en el código con OB, RC el objeto parece que sigue todas las reglas, ¡así que no hay problema!
Se necesitarán varios tipos de bibliotecas similares para funcionar correctamente con OB.
Conclusión
Este artículo es una descripción básica de la tecnología OB. Estoy trabajando en una especificación mucho más detallada. Es posible que me haya perdido algo y en algún lugar un agujero debajo de la línea de flotación, pero hasta ahora todo se ve bien. Este es un desarrollo muy emocionante para D y estoy ansioso por implementarlo.
Para más discusiones y comentarios de Walter, consulte los temas en
/ r / programando subreddit y en
Hacker News .