Works-as-implemented
Publicado el 26 de octubre de 2022
Llevo obsesionado con la idea de
works-as-implemented
desde que vi a Sean Parent presentarla en
su charla de la CppCon de 2021
, publicada hace unos meses. Es un concepto mucho más profundo y con más ramificaciones de lo que podría parecer, y creo que merece la pena dedicar un texto a indagar en qué estamos diciendo y sobre qué estamos hablando cuando decimos que un trozo de código funciona como está implementado. Seguidme pues en este artículo en el que hablaremos sobre semántica, diseño por contrato, la ley de Hyrum y código que cambia a lo largo del tiempo.
Lo primero, decir que evidentemente todo el código que existe funciona como está implementado. Y si no lo hace, hay un bug en el compilador o intérprete, pero no es problema del código. Esa es la definición del código. Es una especificación formal e inambigua de un programa. Por lo tanto, afirmar que un programa funciona como está implementado es, en el mejor de los casos, una evidencia, una afirmación carente de información de la misma forma que lo es afirmar que
unos cereales no llevan amianto
. Esto nos lleva a pensar que
works-as-implemented
no es una propiedad estrictamente del código, porque esto no significaría nada.
Tenemos que buscar pues en otro sitio. En su charla, Sean Parent habla sobre
std::find
. Y no habla tanto sobre la implementación de
find
como sobre la especificación de
find
. Cita el estándar y se centra en un bug que hay en la especificación que deja a algunas funciones, como
find
, en un limbo de ambigüedad. El estándar es incapaz de proveer de ciertas garantías sobre el comportamiento de esta función, que sin embargo existen en las implementaciones. Sin embargo, sería posible escribir versiones de
find
distintas que satisfagan la especificación pero tengan una implementación distinta, y sus garantías serían distintas. Por eso afirma Parent que
std::find
funciona como está implementado. Sus garantías, de las que dependen miles de líneas de código, están en implementaciones concretas de la función, no en su especificación. Esto significa que hay mucho código incorrectamente atado a una implementación concreta de la función, y que se rompería si la implementación cambiara. Analizando esto podemos concluir que
works-as-implemented
no es una propiedad del código, sino que es una propiedad de la relación entre el código y su especificación.
El diseño por contrato es una filosofía de diseño de software que consiste en especificar, para cada función, sus precondiciones y poscondiciones. Estos conceptos vienen de la lógica de Hoare. Las precondiciones son las condiciones que tienen que ser verdad para poder llamar a la función. Las poscondiciones son las condiciones que la función garantiza que serán verdad tras terminar de ejecutarse, si se ha llamado respetando las precondiciones. Consideramos una llamada a una función que garantiza las precondiciones una llamada que respeta el contrato. Un programa es correcto si todas las llamadas se hacen respetando el contrato de sus respectivas funciones y todas las funciones son capaces de respetar el contrato garantizando las poscondiciones.
A veces es posible detectar cuándo una función está siendo llamada violando el contrato. En esos casos se puede detener el programa y pedirle al desarrollador responsable que arregle el error. El método más común para esto suele ser
assert
. La función, antes de empezar a calcular, se asegura de que todas las precondiciones se cumplen o termina el programa. Sin embargo, hay precondiciones que son muy costosas o incluso imposibles de comprobar. Por ejemplo, la búsqueda binaria es un algoritmo con complejidad logarítmica, que depende de que la secuencia sobre la que se busca esté ordenada, pero comprobar si está ordenada tendría una complejidad lineal y sería inaceptablemente lento. En cuanto a las condiciones imposibles de comprobar, es imposible saber con total garantía si un pointer apunta a un objeto válido o si dos iteradores definen una secuencia válida.
A veces, el resultado de una función expone propiedades que no están especificadas en el contrato. El contrato es accidentalmente o deliberadamente ambiguo. Por ejemplo, en una tabla de hash, el orden de iteración de los elementos no está especificado. Esto permite a la implementación sustituir la función de hash por una mejor sin alterar el contrato.
En el primer caso, el programa continúa ejecutándose a pesar de la violación del contrato. En el segundo, el programa puede terminar dependiendo accidentalmente de propiedades observables que no están garantizadas por el contrato. En ambos casos, el programa está dependiendo de comportamiento que no está especificado por el contrato de la función. Puede que el programa funcione, y que funcione correctamente, pero ese funcionamiento correcto depende de detalles de la implementación que no están especificados. Hay dos situaciones en las que esto es importante.
La primera es cuando se está usando una biblioteca que tiene múltiples implementaciones de la misma especificación, como por ejemplo la biblioteca estándar de un lenguaje, POSIX, bibliotecas de gráficos como OpenGL o Vulkan… Depender de detalles concretos de la implementación concreta que se está usando puede impedir cambiar de implementación, porque esos detalles pueden no ser verdad en otras implementaciones. Esto significa que el mismo código puede funcionar en Linux, pero no en Windows, porque usa distintas implementaciones de la misma biblioteca en cada sistema operativo, y depende de detalles de implementación no especificados que sólo existen en algunas.
La segunda es cuando se está usando una biblioteca en desarrollo, que recibirá cambios y tendrá versiones futuras, y se desea actualizar a esas versiones futuras. Depender de detalles no especificados de una versión puede ser peligroso si versiones futuras cambian la implementación manteniendo el contrato. Aquí es muy relevante el ejemplo del orden de iteración de la tabla de hash. El desarrollo de funciones de hash eficientes y con buena distribución estadística es un campo de desarrollo activo hoy en día, y el cambio de una función a otra puede causar diferencias significativas en la velocidad de inserción y búsqueda. Por eso, es normal que los desarrolladores de tablas de hash se reserven el derecho a cambiar de función en el futuro, pero eso requiere que los usuarios no dependan de factores como el orden de iteración, la capacidad de la tabla o la frecuencia con la que crece.
Un código que depende de comportamiento no especificado, ya sea por violaciones de contrato no detectables o por depender de propiedades no especificadas del comportamiento de otro código, está en el terreno gris de
works-as-implemented
. Su corrección depende de detalles de implementación concretos de los que no tiene ninguna garantía. Esta ausencia de garantías se manifiesta al intentar portar el código o actualizarlo a versiones nuevas de sus dependencias.
El diseño por contrato es muy importante porque es necesario establecer un marco de reglas entre bibliotecas y programas sobre qué es aceptable cambiar. Una biblioteca que no cambia nunca está condenada a quedarse obsoleta, y es una dependencia muy arriesgada para cualquier proyecto que quiera ser mantenido durante mucho tiempo. Es especialmente peligroso en el caso de las vulnerabilidades de seguridad. Un programa debería asegurarse de que sus dependencias garanticen que arreglarán rápido las vulnerabilidades que se encuentren en ellas. Por otro lado, una biblioteca que puede cambiar cualquier cosa es muy difícil de actualizar, porque cada actualización puede potencialmente romper el código del usuario. Por ello, es necesario encontrar un punto intermedio en el que la biblioteca es capaz de ofrecer algunas garantías y reservarse el derecho a cambiar algunas cosas. De la misma forma, el usuario tiene que hacer un uso disciplinado de la biblioteca que dependa sólo del comportamiento garantizado y nunca de detalles de implementación no garantizados por el contrato.
La
ley de Hyrum
es un proverbio que afirma que, con un número suficiente de usuarios, no importa lo que especifique el contrato. Para todo comportamiento observable de un sistema habrá
alguien que dependa de ello
. La ley de Hyrum es la observación de que en el mundo existe mucho código que funciona como está implementado.
Una última observación sobre el diseño por contrato es que a menudo los contratos están definidos en función del significado de las operaciones, no de pasos concretos a llevar a cabo. Cojamos el operador = de
std::vector
como caso de estudio. El contrato dice que
'a = b'
, siendo
'a'
y
'b'
dos vectores, sustituye el contenido de
'a'
con una copia del contenido de
'b'
. Una implementación posible del operador = sería en función del destructor y el constructor de copia. Podemos destruir
'a'
y construir sobre
'a'
un nuevo vector que es una copia de
'b'
. Otra posible implementación sería comprobar primero si
'a'
tiene suficiente memoria para contener los elementos de
'b'
, y en ese caso reutilizar la memoria, en lugar de liberarla y adquirir un nuevo bloque. En ese caso, a veces el operador = no necesitaría liberar y reservar memoria e iría más rápido en esas situaciones. Sin embargo, esta diferencia en comportamiento es observable. Si tenemos punteros a los elementos de
'a'
, y sabemos que
'a'
es más grande que
'b'
, en la segunda implementación los nuevos elementos de
'a'
, que son copias de los de
'b'
, se construirían en la misma posición en memoria que los antiguos, y por lo tanto los punteros seguirían siendo válidos. Sin embargo, si
'a'
tuviera que liberar su memoria y reservar un bloque nuevo, esos punteros se invalidarían. Por supuesto, el contrato dice que asignar un vector invalida todos los punteros a los elementos que contiene, pero en la práctica y dependiendo de la implementación un programa podría depender de este comportamiento y funcionar correctamente.
Un uso correcto de vector dependería únicamente del significado de asignar. Es decir, después de asignar, el valor de
'a'
es igual al valor de
'b'
. Y no haría ninguna otra asunción sobre las partes del estado de
'a'
que no son parte de su valor, como la capacidad del bloque reservado o la dirección en memoria de los elementos. En general, es frecuente que los contratos de la mayoría de funciones se definan en función de su significado, de los valores que consumen y producen, no de los pasos concretos que llevan a cabo para obtener este resultado. Esto da más libertad al autor del código para mejorar la implementación en el futuro. Por ello, es importante entender qué partes del estado de un tipo de dato representan su valor y cuáles no, y depender únicamente de ello.
Decíamos antes que
works-as-implemented
describe una relación entre el código y su especificación. Ahora que hemos hablado de significado, podemos completar su definición. Un código que funciona como está implementado es un código cuyo significado es su implementación. Es decir, no existe otra implementación posible para este código que la que tiene. El significado del código consiste en la sucesión concreta de operaciones que lo forman en el orden en el que están escritas, y nada más. No hay un significado abstracto que deducir a partir de estas operaciones. Suele resultar además que el código que cae en esta categoría suele ser muy difícil de especificar, ya que su implementación es su especificación. Por ello, muchas veces el código que funciona como está implementado es el código que no tiene especificación ni documentación de ningún tipo, por lo que la implementación es la única definición del código que existe.
A menudo, el código que funciona como está implementado es código en el que no se ha razonado mucho sobre su significado, y por lo tanto está muy atado a la secuencia concreta de pasos que lo forman. Qué código da más problemas a la hora de ser portado o se rompe con frecuencia al actualizar las dependencias puede darnos una buena pista sobre qué código tiene su significado poco definido y consiste más bien en una secuencia arbitraria de pasos que resulta que generan un resultado, en vez de en una transformación de datos con sentido. De la misma forma, ser capaz de identificar este código puede ayudarnos preventivamente a encontrar las partes del programa que con más probabilidad se romperán si se intenta portar ese código o actualizar sus dependencias. Para alguien desarrollando una biblioteca, es importante prestar especial atención al diseño del contrato de cada uno de los elementos de la interfaz, de forma que el contrato aporte todas las garantías necesarias para que la biblioteca sea usable y asimismo deje suficiente margen al autor para mejorar el código en el futuro.
Logo of RSS.