Escritura adecuada: el aspecto subestimado del código limpio

Hola colegas

No hace mucho tiempo, nuestra atención fue atraída por el libro casi terminado de Manning Publishing House "Programación con tipos", que detalla la importancia de escribir correctamente y su papel en la escritura de código limpio y duradero.



Al mismo tiempo, en el blog del autor, encontramos un artículo escrito, aparentemente, en las primeras etapas del trabajo en el libro y que permite dejar una impresión de su material. Sugerimos discutir lo interesantes que son las ideas del autor y, potencialmente, todo el libro.

Orbitador climático de Marte

La nave espacial Mars Climate Orbiter se estrelló durante el aterrizaje y se derrumbó en la atmósfera marciana, porque el componente de software Lockheed dio el valor de impulso, medido en libras-seg., Mientras que el otro componente desarrollado por la NASA tomó el valor de impulso en Newtons- seg

Puedes imaginar el componente desarrollado por la NASA en aproximadamente la siguiente forma:

//    ,  >= 2 N s void trajectory_correction(double momentum) { if (momentum < 2 /* N s */) { disintegrate(); } /* ... */ } 

También puede imaginar que el componente Lockheed llamó al código anterior de esta manera:

 void main() { trajectory_correction(1.5 /* lbf s */); } 

Libra-fuerza-segundo (lbfs) es aproximadamente 4.448222 newtons por segundo (Ns). Por lo tanto, desde el punto de vista de Lockheed, pasar 1.5 lbfs a la trajectory_correction debería ser perfectamente normal: 1.5 lbfs es aproximadamente 6.672333 Ns, muy por encima del umbral de 2 Ns.

El problema es la interpretación de datos. Como resultado, el componente de la NASA compara lbfs con Ns sin conversión e interpreta erróneamente la entrada a lbfs como entrada a Ns. Como 1.5 es menor que 2, el orbitador colapsó. Este es un antipatrón bien conocido llamado obsesión primitiva.

Obsesión con los primitivos.

Una fijación en las primitivas se manifiesta cuando usamos un tipo de datos primitivos para representar un valor en un dominio problemático y permitir situaciones como las descritas anteriormente. Si representa los códigos postales como números, los números de teléfono como cadenas, Ns y lbfs como números de doble precisión, esto es exactamente lo que sucede.

Sería mucho más seguro definir un tipo simple de Ns :

 struct Ns { double value; }; bool operator<(const Ns& a, const Ns& b) { return a.value < b.value; } 

Del mismo modo, puede definir un tipo simple de lbfs :

 struct lbfs { double value; }; bool operator<(const lbfs& a, const lbfs& b) { return a.value < b.value; } 

Ahora puede implementar una variante de tipo seguro de trajectory_correction :

 //  ,   >= 2 N s void trajectory_correction(Ns momentum) { if (momentum < Ns{ 2 }) { disintegrate(); } /* ... */ } 

Si llama a esto con lbfs , como en el ejemplo anterior, entonces el código simplemente no se compila debido a la incompatibilidad de tipos:

 void main() { trajectory_correction(lbfs{ 1.5 }); } 

Observe cómo la información de tipo de valor, que generalmente se indica en los comentarios, ( 2 /*Ns */, /* lbfs */ ) ahora se dibuja en el sistema de tipos y se expresa en el código: ( Ns{ 2 }, lbfs{ 1.5 } ) .

Por supuesto, es posible proporcionar una reducción de lbfs a Ns en forma de un operador explícito:

 struct lbfs { double value; explicit operator Ns() { return value * 4.448222; } }; 

Armado con esta técnica, puede llamar a trajectory_correction usando un reparto estático:

 void main() { trajectory_correction(static_cast<Ns>(lbfs{ 1.5 })); } 

Aquí la corrección del código se logra multiplicando por un coeficiente. Una conversión también se puede realizar implícitamente (usando la palabra clave implícita), en cuyo caso la conversión se aplicará automáticamente. Como regla empírica, puede usar uno de los coans de Python aquí:
Explícito es mejor que implícito
La moraleja de esta historia es que, aunque hoy tenemos mecanismos de verificación de tipos muy inteligentes, aún necesitan proporcionar suficiente información para detectar este tipo de error. Esta información ingresa al programa si declaramos tipos teniendo en cuenta los detalles de nuestra área temática.

Espacio de estado

El problema ocurre cuando un programa termina en un mal estado . Los tipos ayudan a reducir el campo para su aparición. Intentemos tratar el tipo como el conjunto de valores posibles. Por ejemplo, bool es el conjunto {true, false} , donde una variable de este tipo puede tomar uno de estos dos valores. Del mismo modo, uint32_t es el conjunto {0 ...4294967295} . Considerando los tipos de esta manera, podemos definir el espacio de estado de nuestro programa como el producto de los tipos de todas las variables vivas en un determinado momento.

Si tenemos una variable de tipo bool y una variable de tipo uint32_t , nuestro espacio de estado será {true, false} X {0 ...4294967295} . Simplemente significa que ambas variables pueden estar en cualquier estado posible para ellos, y dado que tenemos dos variables, el programa puede terminar en cualquier estado combinado de estos dos tipos.

Todo se vuelve mucho más interesante si consideramos las funciones que inicializan los valores:

 bool get_momentum(Ns& momentum) { if (!some_condition()) return false; momentum = Ns{ 3 }; return true; } 

En el ejemplo anterior, tomamos Ns por referencia e inicializamos si se cumple alguna condición. La función devuelve true si el valor se inicializó correctamente. Si la función por alguna razón no puede establecer el valor, entonces devuelve false .

Considerando esta situación desde el punto de vista del espacio de estado, podemos decir que el espacio de estado es un producto de bool X Ns . Si la función devuelve verdadero, significa que el impulso se ha establecido y es uno de los posibles valores de Ns . El problema es este: si la función devuelve false , significa que el impulso no se configuró. De una forma u otra, el impulso pertenece al conjunto de valores posibles de Ns, pero no es un valor válido. A menudo hay errores en los que el siguiente estado inaceptable comienza a extenderse accidentalmente:

 void example() { Ns momenum; get_momentum(momentum); trajectory_correction(momentum); } 

En cambio, simplemente tenemos que hacer esto:

 void example() { Ns momentum; if (get_momentum(momentum)) { trajectory_correction(momentum); } } 

Sin embargo, hay una mejor manera en que esto se puede hacer a la fuerza:

 std::optional<Ns> get_momentum() { if (!some_condition()) return std::nullopt; return std::make_optional(Ns{ 3 }); } 

Si usa optional , entonces el espacio de estado de esta función disminuirá significativamente: en lugar de bool X Ns obtenemos Ns + 1 . Esta función devolverá un nullopt Ns o nullopt válido para indicar que no hay valor. Ahora, simplemente no podemos tener un Ns inválido que se propague en el sistema. Además, ahora se hace imposible olvidar comprobar el valor de retorno, ya que opcional no se puede convertir implícitamente a Ns , tendremos que descomprimirlo especialmente:

 void example() { auto maybeMomentum = get_momentum(); if (maybeMomentum) { trajectory_correction(*maybeMomentum); } } 

Básicamente, nos esforzamos por que nuestras funciones devuelvan un resultado o error, en lugar de un resultado y un error. Por lo tanto, excluimos las condiciones en las que tenemos errores, y también estamos a salvo de resultados inaceptables, que luego podrían filtrarse en otros cálculos.

Desde este punto de vista, lanzar excepciones es normal, ya que corresponde al principio descrito anteriormente: una función devolverá un resultado o arrojará una excepción.

RAII

RAII significa que la adquisición de recursos es la inicialización, pero en mayor medida este principio está asociado con la liberación de recursos. El nombre apareció por primera vez en C ++, sin embargo, este patrón puede implementarse en cualquier lenguaje (ver, por ejemplo, IDisposable from .NET). RAII proporciona limpieza automática de recursos.

¿Qué son los recursos? Aquí hay algunos ejemplos: memoria dinámica, conexiones de bases de datos, descriptores del sistema operativo. En principio, un recurso es algo tomado del mundo exterior y sujeto a devolución después de que ya no lo necesitemos. Devolvemos el recurso utilizando la operación adecuada: liberarlo, eliminarlo, cerrarlo, etc.

Dado que estos recursos son externos, no se expresan explícitamente en nuestro sistema de tipos. Por ejemplo, si seleccionamos un fragmento de memoria dinámica, obtendremos un puntero por el cual tendremos que llamar a delete :

 struct Foo {}; void example() { Foo* foo = new Foo(); /*  foo */ delete foo; } 

Pero, ¿qué sucede si nos olvidamos de hacer esto o algo nos impide llamar a delete ?

 void example() { Foo* foo = new Foo(); throw std::exception(); delete foo; } 

En este caso, ya no llamamos a delete y obtenemos una fuga de recursos. En principio, dicha limpieza manual de recursos es indeseable. Para la memoria dinámica, tenemos unique_ptr para ayudarnos a administrarla:

 void example() { auto foo = std::make_unique<Foo>(); throw std::exception(); } 

Nuestro unique_ptr es un objeto de pila, por lo tanto, si unique_ptr alcance (cuando la función produce una excepción o cuando la pila se desenrolla cuando se produce una excepción), se llama a su destructor. Es este destructor el que implementa la llamada de delete . En consecuencia, ya no tenemos que administrar el recurso de memoria: transferimos este trabajo al contenedor, que lo posee y es responsable de su lanzamiento.

Existen envoltorios similares (o se pueden crear) para cualquier otro recurso (por ejemplo, OS HANDLE de Windows se puede envolver en un tipo, en cuyo caso su destructor llamará a CloseHandle ).

La conclusión principal en este caso es nunca hacer una limpieza manual de los recursos; Utilice el contenedor existente o si no hay un contenedor adecuado para su escenario específico, lo implementaremos nosotros mismos.

Conclusión

Comenzamos este artículo con un ejemplo bien conocido que demuestra la importancia de escribir, y luego examinamos tres aspectos importantes del uso de tipos para ayudar a escribir código más seguro:

  • Declarando y usando tipos más fuertes (en oposición a la obsesión con los primitivos).
  • Reducir el espacio de estado, devolver un resultado o error, no un resultado o error.
  • RAII y gestión automática de recursos.

Por lo tanto, los tipos ayudan mucho para hacer que el código sea más seguro y adaptarlo para su reutilización.

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


All Articles