Los peligros de los diseñadores.

Hola Habr! Les presento la traducción del artículo "Los peligros de los constructores" de Aleksey Kladov.


Una de mis publicaciones de blog favoritas de Rust es Things Rust Shipped Without de Graydon Hoare . Para mí, la falta de cualquier característica en el lenguaje que pueda disparar en la pierna suele ser más importante que la expresividad. En este ensayo ligeramente filosófico, quiero hablar sobre mi característica particularmente favorita que falta en Rust: sobre los constructores.


¿Qué es un constructor?


Los constructores se usan comúnmente en lenguajes OO. La tarea del constructor es inicializar completamente el objeto antes de que el resto del mundo lo vea. A primera vista, esto parece una muy buena idea:


  1. Pones los invariantes en el constructor.
  2. Cada método se encarga de la conservación de invariantes.
  3. Juntas, estas dos propiedades significan que puedes pensar en los objetos como invariantes, y no como estados internos específicos.

El constructor aquí desempeña el papel de una base de inducción, siendo la única forma de crear un nuevo objeto.


Desafortunadamente, hay un agujero en estos argumentos: el diseñador mismo observa el objeto en un estado inacabado, lo que crea muchos problemas.


Este valor


Cuando el constructor inicializa el objeto, comienza con un estado vacío. Pero, ¿cómo define este estado vacío para un objeto arbitrario?


La forma más fácil de hacer esto es establecer todos los campos a sus valores predeterminados: falso para bool, 0 para números, nulo para todos los enlaces. Pero este enfoque requiere que todos los tipos tengan valores predeterminados e introduce el nulo infame en el lenguaje. Este es el camino que tomó Java: al comienzo de la creación del objeto, todos los campos son 0 o nulos.


Con este enfoque, será muy difícil deshacerse de nulo después. Un buen ejemplo para aprender es Kotlin. Kotlin utiliza tipos no anulables de forma predeterminada, pero se ve obligado a trabajar con semánticas JVM preexistentes. El diseño del lenguaje esconde bien este hecho y es aplicable en la práctica, pero es insostenible . En otras palabras, usando constructores, es posible evitar las verificaciones nulas en Kotlin.


La característica principal de Kotlin es el fomento de la creación de los llamados "constructores primarios" que declaran simultáneamente un campo y le asignan un valor antes de ejecutar cualquier código personalizado:


class Person( val firstName: String, val lastName: String ) { ... } 

Otra opción: si el campo no se declara en el constructor, el programador debe inicializarlo inmediatamente:


 class Person(val firstName: String, val lastName: String) { val fullName: String = "$firstName $lastName" } 

Intentar usar un campo antes de la inicialización se niega estáticamente:


 class Person(val firstName: String, val lastName: String) { val fullName: String init { println(fullName) // :     fullName = "$firstName $lastName" } } 

Pero con un poco de creatividad, cualquiera puede sortear estos controles. Por ejemplo, una llamada al método es adecuada para esto:


 class A { val x: Any init { observeNull() x = 92 } fun observeNull() = println(x) //  null } fun main() { A() } 

También agarrar esto con un lambda (que se crea en Kotlin de la siguiente manera: {args -> body}) también es adecuado:


 class B { val x: Any = { y }() val y: Any = x } fun main() { println(B().x) //  null } 

Ejemplos como estos parecen poco realistas en la realidad (y lo es), pero encontré errores similares en el código real (regla de probabilidad de Kolmogorov 0-1 en el desarrollo de software: en una base de datos suficientemente grande, casi cualquier pieza de código está casi garantizada de existir, al menos si no prohibido estáticamente por el compilador; en este caso, es casi seguro que no existe).


La razón por la que Kotlin puede existir con esta falla es la misma que con las matrices covariantes Java: las comprobaciones aún se producen en tiempo de ejecución. Al final, no quisiera complicar el sistema de tipo Kotlin para hacer que los casos anteriores sean incorrectos en la etapa de compilación: teniendo en cuenta las limitaciones existentes (semántica JVM), la relación precio / beneficio de las validaciones en tiempo de ejecución es mucho mejor que la de las estáticas.


Pero, ¿qué pasa si el idioma no tiene un valor predeterminado razonable para cada tipo? Por ejemplo, en C ++, donde los tipos definidos por el usuario no son necesariamente referencias, no puede simplemente asignar nulo a cada campo y decir que esto funcionará. En cambio, C ++ usa una sintaxis especial para establecer valores iniciales para los campos: listas de inicialización:


 #include <string> #include <utility> class person { person(std::string first_name, std::string last_name) : first_name(std::move(first_name)) , last_name(std::move(last_name)) {} std::string first_name; std::string last_name; }; 

Como se trata de una sintaxis especial, el resto del lenguaje no funciona a la perfección. Por ejemplo, es difícil incluir operaciones arbitrarias en las listas de inicialización, ya que C ++ no es un lenguaje orientado a la expresión (que es normal en sí mismo). Para trabajar con excepciones que ocurren en las listas de inicialización, debe usar otra característica oscura del lenguaje .


Métodos de llamada desde el constructor


Como sugieren los ejemplos de Kotlin, todo se rompe en chips tan pronto como intentamos llamar a un método desde el constructor. Básicamente, los métodos esperan que el objeto accesible a través de esto ya esté completamente construido y sea correcto (consistente con los invariantes). Pero en Kotlin o Java, nada le impide invocar métodos del constructor, y de esta manera podemos operar accidentalmente en un objeto semi-construido. El diseñador promete establecer invariantes, pero al mismo tiempo este es el lugar más fácil para su posible violación.


Suceden cosas particularmente extrañas cuando el constructor de la clase base llama a un método anulado en una clase derivada:


 abstract class Base { init { initialize() } abstract fun initialize() } class Derived: Base() { val x: Any = 92 override fun initialize() = println(x) //  null! } 

Piénselo: ¡el código de una clase arbitraria se ejecuta antes de llamar a su constructor! Un código C ++ similar conducirá a resultados aún más interesantes. En lugar de llamar a la función de la clase derivada, se llamará a la función de la clase base. Esto tiene poco sentido porque la clase derivada aún no se ha inicializado (recuerde, no podemos decir que todos los campos son nulos). Sin embargo, si la función en la clase base es puramente virtual, su llamada conducirá a UB.


Firma del diseñador


La violación de invariantes no es el único problema para los diseñadores. Tienen una firma con un nombre fijo (vacío) y un tipo de retorno (la clase en sí). Esto hace que las sobrecargas de diseño sean difíciles de entender para las personas.


Pregunta de relleno: ¿a qué corresponde std :: vector <int> xs (92, 2)?

a. Vector de dos longitudes 92

b. [92, 92]

c. [92, 2]

Los problemas con el valor de retorno surgen, por regla general, cuando es imposible crear un objeto. ¡No puede devolver simplemente Result <MyClass, io :: Error> o nulo desde el constructor!


Esto a menudo se usa como argumento a favor del hecho de que usar C ++ sin excepciones es difícil, y que usar constructores también te obliga a usar excepciones. Sin embargo, no creo que este argumento sea correcto: los métodos de fábrica resuelven estos dos problemas, porque pueden tener nombres arbitrarios y devolver tipos arbitrarios. Creo que el siguiente patrón a veces puede ser útil en lenguajes OO:


  • Cree un constructor privado que tome los valores de todos los campos como argumentos y simplemente los asigne. Por lo tanto, dicho constructor funcionaría como una estructura literal en Rust. También puede verificar cualquier invariante, pero no debe hacer nada más con argumentos o campos.


  • Se proporcionan métodos de fábrica públicos para la API pública con nombres y tipos de retorno apropiados.



Un problema similar con los constructores es que son específicos y, por lo tanto, no pueden generalizarse. En C ++, "hay un constructor predeterminado" o "hay un constructor de copia" no puede expresarse de manera más simple que "ciertas sintaxis funcionan". Compare esto con Rust, donde estos conceptos tienen firmas adecuadas:


 trait Default { fn default() -> Self; } trait Clone { fn clone(&self) -> Self; } 

La vida sin diseñadores


Rust tiene solo una forma de crear una estructura: proporcionar valores para todos los campos. Las funciones de fábrica, como las nuevas generalmente aceptadas, desempeñan el papel de constructores, pero, lo más importante, no le permiten llamar a ningún método hasta que tenga al menos una instancia más o menos correcta de la estructura.


La desventaja de este enfoque es que cualquier código puede crear una estructura, por lo que no hay un lugar único, como un constructor, para mantener invariantes. En la práctica, esto se resuelve fácilmente mediante la privacidad: si los campos de la estructura son privados, esta estructura solo se puede crear en el mismo módulo. Dentro de un módulo, no es difícil adherirse al acuerdo "todos los métodos para crear una estructura deben usar el nuevo método". Incluso puede imaginar una extensión de lenguaje que le permita marcar algunas funciones con el atributo # [constructor], de modo que la sintaxis literal de la estructura solo esté disponible en las funciones marcadas. Pero, una vez más, los mecanismos lingüísticos adicionales me parecen redundantes: seguir las convenciones locales requiere poco esfuerzo.


Personalmente, creo que este compromiso se ve exactamente igual para la programación de contratos en general. Los contratos como "no nulo" o "valor positivo" se codifican mejor en tipos. Para invariantes complejos, simplemente escribir aserción! (Self.validate ()) en cada método no es tan difícil. Entre estos dos patrones hay poco espacio para las condiciones # [pre] y # [post] implementadas a nivel de lenguaje o basadas en macros.

¿Qué hay de Swift?


Swift es otro lenguaje interesante que vale la pena echar un vistazo a los mecanismos de diseño. Al igual que Kotlin, Swift es un lenguaje nulo y seguro. A diferencia de Kotlin, los controles nulos de Swift son más fuertes, por lo que el lenguaje utiliza trucos interesantes para mitigar el daño causado por los constructores.


Primero , Swift usa argumentos con nombre, y ayuda un poco con "todos los constructores tienen el mismo nombre". En particular, dos constructores con los mismos tipos de parámetros no son un problema:


 Celsius(fromFahrenheit: 212.0) Celsius(fromKelvin: 273.15) 

En segundo lugar , para resolver el problema "el constructor llama al método virtual de la clase del objeto que aún no se ha creado completamente" Swift utiliza un protocolo de inicialización de dos fases bien pensado. Aunque no hay una sintaxis especial para las listas de inicialización, el compilador comprueba estáticamente que el cuerpo del constructor tiene la forma correcta y segura. Por ejemplo, los métodos de llamada solo son posibles después de que se inicializan todos los campos de la clase y sus descendientes.


En tercer lugar , a nivel de lenguaje, hay soporte para constructores, cuya llamada puede fallar. El constructor se puede designar como anulable, lo que hace que el resultado de llamar a la clase sea una opción. El constructor también puede tener un modificador throws, que funciona mejor con la semántica de la inicialización de dos fases en Swift que con la sintaxis de las listas de inicialización en C ++.


Swift logra cerrar todos los agujeros en los constructores de los que me quejé. Sin embargo, esto tiene un precio: el capítulo de inicialización es uno de los más grandes del libro Swift.


Cuando realmente se necesitan constructores


Contra todo pronóstico, puedo encontrar al menos dos razones por las cuales los constructores no pueden ser reemplazados por literales de estructura, como en Rust.


Primero , la herencia, en un grado u otro, obliga al lenguaje a tener constructores. Puede imaginar una extensión de la sintaxis de estructuras con soporte para clases base:


 struct Base { ... } struct Derived: Base { foo: i32 } impl Derived { fn new() -> Derived { Derived { Base::new().., foo: 92, } } } 

¡Pero esto no funcionará en un diseño de objeto típico de un lenguaje OO con herencia simple! Típicamente, un objeto comienza con un título seguido de campos de clase, desde la base hasta el más derivado. Por lo tanto, el prefijo del objeto de la clase derivada es el objeto correcto de la clase base. Sin embargo, para que un diseño de este tipo funcione, el diseñador necesita asignar memoria para todo el objeto a la vez. No puede asignar memoria solo para la clase base y luego adjuntar campos derivados. Pero tal asignación de memoria en partes es necesaria si queremos usar la sintaxis para crear una estructura en la que podamos especificar un valor para la clase base.


En segundo lugar , en contraste con la sintaxis literal de la estructura, los diseñadores tienen un ABI que funciona bien con la colocación de subobjetos de objetos en la memoria (ABI fácil de colocar). El constructor trabaja con un puntero a esto, que apunta al área de memoria que debe ocupar el nuevo objeto. Lo más importante, un constructor puede pasar fácilmente un puntero a los constructores de subobjetos, lo que permite la creación de árboles de valores complejos "en su lugar". En contraste, en Rust, construir estructuras semánticamente incluye bastantes copias, y aquí esperamos la gracia del optimizador. ¡No es casualidad que Rust aún no tenga una propuesta de trabajo aceptada con respecto a la colocación de subobjetos en la memoria!


Upd 1: reparado un error tipográfico. Reemplazado el "escribir literal" con "estructura literal".

Source: https://habr.com/ru/post/460831/


All Articles