Hoy descubrimos la causa de un error desagradable que solo ocurrió de manera intermitente en ciertas plataformas. En resumen, nuestro código se veía así:
class Foo {
map<string,string> m;
void A(const string& key) {
m.erase(key);
cout << "Erased: " << key; // oops
}
void B() {
while (!m.empty()) {
auto toDelete = m.begin();
A(toDelete->first);
}
}
}
El problema puede parecer obvio en este caso simplificado: B
pasa una referencia a la clave A
, que elimina la entrada del mapa antes de intentar imprimirla. (En nuestro caso, no se imprimió, sino que se usó de una manera más complicada) Esto es, por supuesto, un comportamiento indefinido, ya que key
es una referencia pendiente después de la llamada a erase
.
Arreglar esto fue trivial: simplemente cambiamos el tipo de parámetro de const string&
a string
. La pregunta es: ¿cómo podríamos haber evitado este error en primer lugar? Parece que ambas funciones hicieron lo correcto:
A
no tiene forma de saber que sekey
refiere a lo que está a punto de destruir.B
podría haber hecho una copia antes de pasarlaA
, pero ¿no es el trabajo del destinatario decidir si tomar los parámetros por valor o por referencia?
¿Hay alguna regla que no pudimos seguir?
Yo diría que sí, hay una regla bastante simple que rompiste que te habría salvado: el principio de responsabilidad única.
En este momento,
A
se le pasa un parámetro que usa para eliminar un elemento de un mapa y hacer otro proceso (impresión como se muestra arriba, aparentemente algo más en el código real). Combinar esas responsabilidades me parece una gran parte del origen del problema.Si tenemos una función que simplemente elimina el valor del mapa, y otra que solo procesa un valor del mapa, tendríamos que llamar a cada una desde un código de nivel superior, por lo que terminaríamos con algo como esto :
De acuerdo, los nombres que he usado indudablemente hacen que el problema sea más obvio que los nombres reales, pero si los nombres son significativos, es casi seguro que dejarán en claro que estamos tratando de continuar usando la referencia después de ha sido invalidado El simple cambio de contexto hace que el problema sea mucho más obvio.
fuente
A
realidad se buscókey
en dos mapas diferentes y, si se encuentra, eliminó las entradas más una limpieza adicional. Entonces, no está claro que nuestroA
SRP violado. Me pregunto si debería actualizar la pregunta en este momento.m.erase(key)
tiene la primera responsabilidad ycout << "Erased: " << key
tiene la segunda responsabilidad, por lo que la estructura del código que se muestra en esta respuesta en realidad no es diferente de la estructura del código en el ejemplo, aún en En el mundo real se pasó por alto el problema. El principio de responsabilidad única no hace nada para garantizar, o incluso hacer que sea más probable, que secuencias contradictorias de acciones individuales aparezcan en proximidad en el código del mundo real.Sí, no pudo documentar la función .
Sin una descripción del contrato de paso de parámetros (específicamente la parte relacionada con la validez del parámetro, es al comienzo de la llamada a la función o durante), es imposible saber si el error está en la implementación (si el contrato de la llamada es que el parámetro es válido cuando se inicia la llamada, la función debe hacer una copia antes de realizar cualquier acción que pueda invalidar el parámetro) o en la persona que llama (si el contrato de la llamada es que el parámetro debe permanecer válido durante la llamada, la persona que llama no puede pasar una referencia a los datos dentro de la colección que se está modificando).
Por ejemplo, el estándar C ++ en sí mismo especifica que:
pero no puede especificar si esto se aplica solo al instante en que se realiza la llamada o durante la ejecución de la función. Sin embargo, en muchos casos está claro que solo lo último es posible, es decir, cuando el argumento no puede mantenerse válido haciendo una copia.
Hay bastantes casos del mundo real en los que esta distinción entra en juego. Por ejemplo, agregando un
std::vector<T>
a sí mismofuente
Sí, no pudo probarlo correctamente. No estás solo y estás en el lugar correcto para aprender :)
C ++ tiene una gran cantidad de comportamiento indefinido, el comportamiento indefinido se manifiesta de manera sutil y molesta.
Probablemente nunca pueda escribir código C ++ 100% seguro, pero ciertamente puede disminuir la probabilidad de introducir accidentalmente Comportamiento indefinido en su base de código empleando una serie de herramientas.
En su caso, dudo que (1) y (2) hubieran ayudado mucho, aunque en general les recomiendo usarlos. Por ahora concentrémonos en los otros dos.
Tanto gcc como Clang presentan una
-fsanitize
bandera que instrumenta los programas que compila para verificar una variedad de problemas.-fsanitize=undefined
por ejemplo, se pondrá al día entero de desbordamiento / desbordamiento, pasando por una cantidad demasiado alta, etc ... En su caso específico,-fsanitize=address
y-fsanitize=memory
que habría sido probable que recoger en el tema ... siempre que cuente con una prueba llamada a la función. Para completar,-fsanitize=thread
vale la pena usarlo si tiene una base de código multiproceso. Si no puede implementar el binario (por ejemplo, tiene bibliotecas de terceros sin su fuente), también puede usarlo,valgrind
aunque en general es más lento.Los compiladores recientes también presentan posibilidades de fortalecimiento de la riqueza . La principal diferencia con los binarios instrumentados es que las comprobaciones de endurecimiento están diseñadas para tener un bajo impacto en el rendimiento (<1%), lo que las hace adecuadas para el código de producción en general. Las más conocidas son las comprobaciones de CFI (Control Flow Integrity) que están diseñadas para frustrar los ataques que rompen la pila y el punteo virtual de punteros entre otras formas de subvertir el flujo de control.
El punto de ambos (3) y (4) es transformar una falla intermitente en una falla determinada : ambos siguen el principio de falla rápida . Esto significa que:
La combinación de (3) con una buena cobertura de prueba debería detectar la mayoría de los problemas antes de que lleguen a la producción. Usar (4) en producción puede ser la diferencia entre un error molesto y un exploit.
fuente
@note: esta publicación solo agrega más argumentos además de la respuesta de Ben Voigt .
Ambas funciones hicieron lo correcto.
El problema está dentro del código del cliente, que no tuvo en cuenta los efectos secundarios de llamar a A.
C ++ no tiene forma directa de especificar efectos secundarios en el lenguaje.
Esto significa que depende de usted (y de su equipo) asegurarse de que cosas como los efectos secundarios sean visibles en el código (como documentación) y se mantengan con el código (probablemente debería considerar documentar las condiciones previas, las condiciones posteriores y las invariantes también, por razones de visibilidad también).
Cambio de código:
A partir de este momento, tiene algo en la parte superior de la API que le indica que debe realizarle una prueba unitaria; También le dice cómo usar (y no usar) la API.
fuente
Solo hay una forma de evitar errores: dejar de escribir código. Todo lo demás falló de alguna manera.
Sin embargo, probar el código en varios niveles (pruebas unitarias, pruebas funcionales, pruebas de integración, pruebas de aceptación, etc.) no solo mejorará la calidad del código, sino que también reducirá la cantidad de errores.
fuente