La función invalida involuntariamente el parámetro de referencia: ¿qué salió mal?

54

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: Bpasa 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 keyes 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:

  • Ano tiene forma de saber que se keyrefiere a lo que está a punto de destruir.
  • Bpodría haber hecho una copia antes de pasarla A, 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?

Nikolai
fuente

Respuestas:

35

Ano tiene forma de saber que se keyrefiere a lo que está a punto de destruir.

Si bien esto es cierto, Asabe las siguientes cosas:

  1. Su propósito es destruir algo .

  2. Toma un parámetro que es exactamente del mismo tipo de la cosa que destruirá.

Ante estos hechos, es posible para Adestruir su propio parámetro si se toma el parámetro como un puntero / referencia. Este no es el único lugar en C ++ donde tales consideraciones deben abordarse.

Esta situación es similar a cómo la naturaleza de un operator=operador de asignación significa que es posible que deba preocuparse por la autoasignación. Esa es una posibilidad porque el tipo thisy el tipo del parámetro de referencia son los mismos.

Cabe señalar que esto solo es problemático porque Amás tarde tiene la intención de usar el keyparámetro después de eliminar la entrada. Si no fuera así, estaría bien. Por supuesto, entonces se vuelve fácil tener todo funcionando perfectamente, luego alguien cambia Ade uso keydespués de que potencialmente haya sido destruido.

Ese sería un buen lugar para un comentario.

¿Hay alguna regla que no pudimos seguir?

En C ++, no puede operar bajo el supuesto de que si sigue ciegamente un conjunto de reglas, su código será 100% seguro. No podemos tener reglas para todo .

Considere el punto # 2 arriba. Apodría haber tomado algún parámetro de un tipo diferente de la clave, pero el objeto en sí podría ser un subobjeto de una clave en el mapa. En C ++ 14, findpuede tomar un tipo diferente del tipo de clave, siempre que haya una comparación válida entre ellos. Entonces, si lo hace m.erase(m.find(key)), puede destruir el parámetro aunque el tipo de parámetro no sea el tipo de clave.

Por lo tanto, una regla como "si el tipo de parámetro y el tipo de clave son iguales, tómalos por valor" no te salvará. Necesitarías más información que solo eso.

En última instancia, debe prestar atención a sus casos de uso específicos y ejercer un juicio, informado por la experiencia.

Nicol Bolas
fuente
10
Bueno, podría tener la regla "nunca compartir estado mutable" o es dual "nunca mutar estado compartido", pero entonces tendría dificultades para escribir c ++ identificable
Caleth
77
@Caleth Si quieres usar esas reglas, C ++ probablemente no sea el lenguaje para ti.
user253751
3
@Caleth ¿Estás describiendo a Rust?
Malcolm
1
"No podemos tener reglas para todo". Si podemos. cstheory.stackexchange.com/q/4052
Ouroborus
23

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, Ase 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 :

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

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.

Jerry Coffin
fuente
3
Bueno, esa es una observación válida, se aplica solo muy estrictamente a este caso. Hay muchos ejemplos en los que se respeta el SRP y todavía hay problemas de la función que potencialmente invalida su propio parámetro.
Ben Voigt
55
@BenVoigt: solo invalidar su parámetro no causa ningún problema. Continúa usando el parámetro después de que se invalida, lo que genera problemas. Pero en última instancia sí, tiene razón: si bien lo habría salvado en este caso, indudablemente hay casos en los que es insuficiente.
Jerry Coffin
3
Al escribir un ejemplo simplificado, debe omitir algunos detalles, y a veces resulta que uno de esos detalles era importante. En nuestro caso, en Arealidad se buscó keyen dos mapas diferentes y, si se encuentra, eliminó las entradas más una limpieza adicional. Entonces, no está claro que nuestro ASRP violado. Me pregunto si debería actualizar la pregunta en este momento.
Nikolai
2
Para ampliar el punto de @BenVoigt: en el ejemplo de Nicolai, m.erase(key)tiene la primera responsabilidad y cout << "Erased: " << keytiene 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.
sdenham
10

¿Hay alguna regla que no pudimos seguir?

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:

Si un argumento a una función tiene un valor no válido (como un valor fuera del dominio de la función o un puntero no válido para su uso previsto), el comportamiento no está definido.

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í mismo

Ben Voigt
fuente
"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". En la práctica, los compiladores hacen casi todo lo que quieren a lo largo de la función una vez que se invoca UB. Esto puede conducir a un comportamiento realmente extraño si el programador no capta el UB.
@snowman aunque interesante, el reordenamiento de UB no tiene relación alguna con lo que discuto en esta respuesta, que es la responsabilidad de garantizar la validez (para que UB nunca suceda).
Ben Voigt
que es exactamente mi punto: la persona que escribe el código debe ser responsable de evitar UB para evitar todo un agujero de conejo lleno de problemas.
@Snowman: No hay "una persona" que escriba todo el código en un proyecto. Esa es una razón por la que la documentación de la interfaz es tan importante. Otra es que las interfaces bien definidas reducen la cantidad de código que debe razonarse al mismo tiempo: para cualquier proyecto no trivial, simplemente no es posible que alguien sea "responsable" de pensar en la corrección de cada declaración.
Ben Voigt
Nunca dije que una persona escriba todo el código. En un momento dado, un programador puede estar mirando una función o escribiendo código. Todo lo que estoy tratando de decir es que quien esté mirando el código debe tener cuidado porque en la práctica, UB es infeccioso y se propaga desde una línea de código a ámbitos más amplios una vez que el compilador está involucrado. Esto se remonta a su punto de violar el contrato de una función: estoy de acuerdo con usted, pero declaro que puede convertirse en un problema aún mayor.
2

¿Hay alguna regla que no pudimos seguir?

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.

  1. Advertencias del compilador
  2. Análisis estático (versión extendida de las advertencias)
  3. Binarios de prueba instrumentados
  4. Binarios de producción endurecidos

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 -fsanitizebandera que instrumenta los programas que compila para verificar una variedad de problemas. -fsanitize=undefinedpor ejemplo, se pondrá al día entero de desbordamiento / desbordamiento, pasando por una cantidad demasiado alta, etc ... En su caso específico, -fsanitize=addressy -fsanitize=memoryque habría sido probable que recoger en el tema ... siempre que cuente con una prueba llamada a la función. Para completar, -fsanitize=threadvale 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, valgrindaunque 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:

  • siempre falla cuando pisas la mina terrestre
  • falla inmediatamente , señalando el error en lugar de dañar la memoria al azar, etc.

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.

Matthieu M.
fuente
0

@note: esta publicación solo agrega más argumentos además de la respuesta de Ben Voigt .

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 la clave se refiere a lo que está a punto de destruir.
  • B podría haber hecho una copia antes de pasarla a A, pero ¿no es el trabajo del destinatario decidir si tomar los parámetros por valor o por referencia?

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:

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

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.

utnapistim
fuente
-4

¿Cómo podríamos haber evitado este error en primer lugar?

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.

BЈовић
fuente
1
Esto es una completa tontería. Hay no sólo una manera de evitar errores. Si bien es trivialmente cierto que la única forma de evitar por completo la existencia de errores es nunca escribir código, también es cierto (y mucho más útil) que existen varios procedimientos de ingeniería de software que puede seguir, tanto al escribir código inicialmente como al probarlo, eso puede reducir significativamente la presencia de errores. Todo el mundo sabe acerca de la fase de prueba, pero el mayor impacto a menudo se puede obtener al menor costo siguiendo prácticas de diseño y modismos responsables al escribir el código en primer lugar.
Cody Gray