Cómo habilitar el paradigma de Rust Ownership en C++
boost smart-pointers (3)
El lenguaje de programación del sistema Rust utiliza el paradigma de propiedad para garantizar en tiempo de compilación con costo cero para el tiempo de ejecución cuando se debe liberar un recurso (consulte "Libro de óxido sobre propiedad" ).
En C ++ comúnmente utilizamos punteros inteligentes para lograr el mismo objetivo de ocultar la complejidad de administrar la asignación de recursos. Sin embargo, hay un par de diferencias:
- En Rust siempre hay un solo propietario, mientras que C ++ shared_ptr puede fugar fácilmente la propiedad.
- En Rust podemos tomar prestadas referencias que no nos pertenecen, mientras que C ++ unique_ptr no se puede compartir de manera segura mediante weak_ptr y lock ().
- El recuento de referencias de shared_ptr es costoso.
Mi pregunta es: ¿cómo podemos emular el paradigma de propiedad en C ++ dentro de las siguientes limitaciones:
- Solo un dueño en cualquier momento
- Posibilidad de tomar prestado un puntero y usarlo temporalmente sin temor a que el recurso salga del alcance ( observer_ptr es inútil para esto)
- La mayor cantidad de verificaciones en tiempo de compilación posible.
Editar: dados los comentarios hasta el momento, podemos concluir:
- No hay soporte en tiempo de compilación para esto (esperaba algo de magia de decltype / template desconocido para mí) en los compiladores. ¿Sería posible usar el análisis estático en otro lugar (contaminación?)
- No hay forma de obtener esto sin contar la referencia.
- No hay implementación estándar para distinguir shared_ptrs con poseer o tomar prestado semántica
Podría lanzar el suyo creando tipos de contenedor alrededor de shared_ptr y weak_ptr:
- owned_ptr: non-copyable, move-semántica, encapsula shared_ptr, accede a borrowed_ptr
- borrowed_ptr: copiable, encapsula weak_ptr, método de bloqueo
- locked_ptr: non-copyable, move-semántica, encapsula shared_ptr del bloqueo weak_ptr
Creo que puede obtener algunos de los beneficios de Rust mediante el cumplimiento de algunas convenciones de codificación estrictas (que es, después de todo, lo que tendría que hacer de todos modos, ya que no hay forma de "magia de plantilla" para decirle al compilador que no compile el código que no lo hace). no use dicha "magia"). Por la parte superior de mi cabeza, lo siguiente podría hacerte ... bueno ... un poco cerca, pero solo para aplicaciones de un solo hilo:
- Nunca use
new
directamente; en su lugar, usemake_unique
. Esto contribuye en parte a garantizar que los objetos asignados en el montón sean "propiedad" de una manera similar a la herrumbre. - "Préstamo" siempre debe representarse mediante parámetros de referencia para llamadas de función. Las funciones que toman una referencia nunca deben crear ningún tipo de puntero al objeto referido. (En algunos casos puede ser necesario utilizar un puntero sin formato como un parámetro en lugar de una referencia, pero debe aplicarse la misma regla).
- Tenga en cuenta que esto funciona para objetos en la pila o en el montón; la función no debería importar.
- La transferencia de propiedad, por supuesto, se representa a través de referencias de valores R (
&&
) y / o referencias de valores R aunique_ptr
s.
Desafortunadamente, no se me ocurre ninguna forma de hacer cumplir la regla de Rust de que las referencias mutables solo pueden existir en cualquier parte del sistema cuando no existen otras referencias existentes.
Además, para cualquier tipo de paralelismo, necesitarías comenzar a lidiar con vidas, y la única forma en que puedo pensar para permitir la gestión de vida útil entre hilos (o administración de vida de proceso cruzado usando memoria compartida) sería implementar tu propio " ptr-with-lifetime "envoltorio. Esto podría implementarse usando shared_ptr
, porque aquí, el recuento de referencias sería realmente importante; Sin embargo, sigue siendo un poco innecesario, porque los bloques de recuento de referencias tienen dos contadores de referencia (uno para todos los shared_ptr
apuntan al objeto, otro para todos los weak_ptr
). También es un poco ... extraño , porque en un escenario shared_ptr
, todos los que tienen un shared_ptr
tienen una shared_ptr
"igual", mientras que en un escenario " shared_ptr
with lifetime", solo un subproceso / proceso debería "poseer" la memoria.
No puede hacer esto con cheques en tiempo de compilación en absoluto. El sistema de tipo C ++ no tiene forma de razonar cuando un objeto sale del alcance, se mueve o se destruye, y mucho menos se convierte en una restricción de tipo.
Lo que podría hacer es tener una variante de unique_ptr
que mantenga un contador de cuántos "préstamos" están activos en tiempo de ejecución. En lugar de get()
devolver un puntero sin formato, devolvería un puntero inteligente que incrementa este contador en la construcción y lo disminuye en la destrucción. Si el unique_ptr
se destruye mientras el conteo no es cero, al menos usted sabe que alguien en alguna parte hizo algo mal.
Sin embargo, esta no es una solución infalible. Independientemente de cuánto intente evitarlo, siempre habrá formas de obtener un puntero sin formato para el objeto subyacente, y luego se acabó el juego, ya que ese puntero sin formato puede sobrevivir fácilmente al puntero inteligente y al unique_ptr
. Incluso a veces será necesario obtener un puntero sin procesar para interactuar con una API que requiere punteros sin formato.
Además, la propiedad no se trata de indicadores . Box
/ unique_ptr
permite asignar un objeto unique_ptr
, pero no cambia nada sobre propiedad, tiempo de vida, etc. en comparación con poner el mismo objeto en la pila (o dentro de otro objeto, o en cualquier otro lugar realmente). Para obtener el mismo rendimiento en un sistema de este tipo en C ++, tendría que crear contenedores de "contar prestados" para todos los objetos en todas partes, no solo para unique_ptr
s. Y eso es bastante poco práctico.
Así que revisemos la opción de tiempo de compilación. El compilador de C ++ no puede ayudarnos, pero quizás las latas sí? Teóricamente, si implementa toda la parte de tiempo de vida del sistema de tipo y agrega anotaciones a todas las API que usa (además de su propio código), eso puede funcionar.
Pero requiere anotaciones para todas las funciones utilizadas en todo el programa. Incluyendo la función de ayuda privada de bibliotecas de terceros. Y aquellos para los cuales no hay código fuente disponible. Y para aquellos cuya implementación es demasiado complicada para que lo entiendan (a partir de la experiencia de Rust, a veces la razón por la cual algo es seguro es demasiado sutil para expresarse en el modelo estático de vidas y tiene que escribirse de forma ligeramente diferente para ayudar al compilador). Para los dos últimos, el linter no puede verificar que la anotación sea correcta, por lo que ha vuelto a confiar en el programador. Además, algunas API (o más bien, las condiciones para cuando son seguras) realmente no se pueden expresar muy bien en el sistema de por vida, ya que Rust lo usa.
En otras palabras, un filtro completo y prácticamente útil para esto sería una investigación original sustancial con el riesgo asociado de falla.
Tal vez hay un terreno intermedio que obtiene el 80% de los beneficios con el 20% del costo, pero como quiere una garantía dura (y sinceramente, me gustaría eso también), mala suerte. Las "buenas prácticas" existentes en C ++ ya van un largo camino para minimizar los riesgos, esencialmente pensando (y documentando) la forma en que lo hace un programador de Rust, solo sin la ayuda del compilador. No estoy seguro de si se puede mejorar mucho teniendo en cuenta el estado de C ++ y su ecosistema.
tl; dr Solo usa Rust ;-)
Puede usar una versión mejorada de unique_ptr
(para imponer un único propietario) junto con una versión mejorada de observer_ptr
(para obtener una buena excepción en tiempo de ejecución para los punteros colgantes, es decir, si el objeto original mantenido a través de unique_ptr
fuera del alcance). El paquete Trilinos implementa este mejorado observer_ptr
, lo llaman Ptr
. Implementé la versión mejorada de unique_ptr
aquí (la llamo UniquePtr
): https://github.com/certik/trilinos/pull/1
Finalmente, si quiere que el objeto se asigne en pila, pero aún así pueda pasar referencias seguras, necesita usar la clase Viewable
, vea mi implementación inicial aquí: https://github.com/certik/trilinos/pull/2
Esto debería permitirle usar C ++ como Rust para los punteros, excepto que en Rust obtienes un error de tiempo de compilación, mientras que en C ++ obtienes una excepción de tiempo de ejecución. Además, debe tenerse en cuenta que solo obtiene una excepción de tiempo de ejecución en el modo de depuración. En el modo Release, las clases no hacen estas comprobaciones, por lo que son tan rápidas como en Rust (esencialmente tan rápido como los punteros sin formato), pero luego pueden segfault. Entonces uno debe asegurarse de que todo el conjunto de pruebas se ejecute en modo de depuración.