Profundidad de la madriguera del conejo o entrevista de C ++ en PVS-Studio

Entrevista en C ++ en PVS-Studio

Autores: Andrey Karpov, khandeliantsPhilip Handelyants.

Me gustaría compartir una situación interesante cuando la pregunta que utilizamos en la entrevista resultó ser más complicada de lo que pretendía su autor. Con el lenguaje y los compiladores de C ++, siempre debe estar atento. No te aburras.

Como en cualquier otra compañía de programación, tenemos una serie de preguntas para entrevistar para vacantes de desarrolladores en C ++, C # y Java. Tenemos muchas preguntas con doble o triple fondo. Para preguntas sobre C # y Java, no podemos decir con certeza, ya que tienen otros autores. Pero muchas de las preguntas compiladas por Andrei Karpov para entrevistas en C ++ fueron concebidas de inmediato para investigar la profundidad del conocimiento de las características del lenguaje.

Estas preguntas pueden recibir una respuesta simple y correcta. Puedes ir más y más profundo. Dependiendo de esto, durante la entrevista, determinamos cuánto está familiarizada una persona con los matices del lenguaje. Esto es importante para nosotros, ya que estamos desarrollando un analizador de código y deberíamos entender muy bien las sutilezas y los "chistes" del lenguaje.

Hoy contaremos una breve historia sobre cómo una de las primeras preguntas formuladas en la entrevista resultó ser aún más profunda de lo que planeamos. Entonces, mostramos este código:

void F1()
{
  int i = 1;
  printf("%d, %d\n", i++, i++);
}

y pregunte: "¿Qué se imprimirá?"

Buena pregunta. Inmediatamente puedo decir mucho sobre el conocimiento. No se considerarán los casos en que una persona no pueda responderle. Estos se eliminan mediante pruebas preliminares en el sitio web de HeadHunter (hh.ru). Aunque, no, son una mierda. En nuestra memoria, había un par de personalidades únicas que respondieron algo en el espíritu:

este código imprimirá al principio un porcentaje, luego d, luego otro porcentaje, d, luego una varita mágica, ny luego dos unidades.

Claramente, en tales casos, la entrevista termina rápidamente.

Entonces, ahora de vuelta a la entrevista normal :). A menudo responden así:

se imprimirán 1 y 2.

Esta es la respuesta del nivel interno. Sí, tales valores pueden, por supuesto, imprimirse, pero estamos esperando aproximadamente la siguiente respuesta:

Esto no quiere decir qué imprimirá exactamente este código. Este es un comportamiento no especificado (o indefinido). El orden en que se calculan los argumentos no está definido. Todos los argumentos deben evaluarse antes de ejecutar el cuerpo de la función llamada, pero el orden en que esto sucederá queda a discreción del compilador. Por lo tanto, el código puede muy bien imprimir "1, 2" y viceversa "2, 1". En general, escribir dicho código es altamente indeseable, si va a ser construido por al menos dos compiladores, es posible "disparar en el pie". Y muchos compiladores darán una advertencia aquí.

De hecho, si usa Clang , puede obtener "1, 2".

Y si usa GCC , puede obtener "2, 1".

Érase una vez que probamos el compilador MSVC, y también produjo "2, 1". No hay signos de problemas.

Recientemente, para un propósito general de terceros, nuevamente fue necesario compilar este código usando Visual C ++ moderno y ejecutarlo. Montado en la configuración de lanzamiento con / O2 optimización habilitada . Y, como dicen, encontraron la aventura en su propia cabeza :). ¿Qué crees que pasó? ¡Decir ah! Esto es lo que: "1, 1".

Así que piensa lo que quieras. Resulta que la pregunta es aún más profunda y más confusa. Nosotros mismos no esperábamos que esto sucediera.

Como el estándar C ++ no regula el cálculo de argumentos de ninguna manera, el compilador interpreta este tipo de comportamiento no especificado de una manera muy peculiar. Echemos un vistazo al código de ensamblador generado por el compilador MSVC 19.25 (Microsoft Visual Studio Community 2019, Versión 16.5.1), la versión distintiva del estándar de idioma es '/ std: c ++ 14':


Formalmente, el optimizador convirtió el código anterior en lo siguiente:

void F1()
{
  int i = 1;
  int tmp = i;
  i += 2;
  printf("%d, %d\n", tmp, tmp);
}

Desde el punto de vista del compilador, dicha optimización no cambia el comportamiento observado del programa. Mirando esto, comienzas a entender que, por una buena razón, el estándar C ++ 11, además de los punteros inteligentes, también agregó la función "mágica" make_shared (y C ++ 14 también agregó make_unique ). Un ejemplo tan inofensivo, y también puede "romper leña":

void foo(std::unique_ptr<int>, std::unique_ptr<double>);

int main()
{
  foo(std::unique_ptr<int> { new int { 0 } },
      std::unique_ptr<double> { new double { 0.0 } });
}

Un compilador astuto puede convertir esto en el siguiente orden de cálculos (el mismo MSVC , por ejemplo):

new int { .... };
new double { .... };
std::unique_ptr<int>::unique_ptr
std::unique_ptr<double>::unique_ptr

Si la segunda llamada al nuevo operador arroja una excepción, entonces tenemos una pérdida de memoria.

Pero volviendo al tema original. A pesar de que todo estaba bien desde el punto de vista del compilador, todavía estábamos seguros de que el resultado "1, 1" se consideraba incorrectamente el comportamiento esperado por el desarrollador. Y luego intentamos compilar el código fuente con el compilador MSVC con el indicador de versión estándar '/ std: c ++ 17'. Y todo comienza a funcionar como se esperaba, y se imprime "2, 1". Echa un vistazo al código del ensamblador:


Todo es justo, el compilador pasó los valores 2 y 1. Como argumentos, pero ¿por qué ha cambiado todo tan dramáticamente? Resulta que lo siguiente se agregó al estándar C ++ 17:

la expresión postfix se secuencia antes de cada expresión en la lista de expresiones y cualquier argumento predeterminado. La inicialización de un parámetro, que incluye cada cálculo de valor asociado y efecto secundario, se secuencia indeterminadamente con respecto a cualquier otro parámetro.

El compilador todavía tiene derecho a calcular argumentos en un orden arbitrario, pero ahora, a partir del estándar C ++ 17, tiene derecho a comenzar a calcular el siguiente argumento y sus efectos secundarios solo desde el momento en que se completan todos los cálculos y efectos secundarios del argumento anterior.

Por cierto, si se compila el mismo ejemplo con punteros inteligentes con el 'std / C ++ 17' bandera, entonces todo se vuelve buena allí también - usando std :: make_unique es ahora opcional.

Aquí hay otra medida de profundidad en la pregunta que resultó. Hay una teoría, pero hay práctica en la forma de un compilador específico o una interpretación diferente del estándar :). El mundo de C ++ siempre es más complejo e inesperado de lo que parece.

Si alguien puede explicar con mayor precisión lo que está sucediendo, díganos en los comentarios. ¡Finalmente debemos entender la pregunta para al menos conocer la respuesta en la entrevista! :)

Aquí hay una historia tan informativa. Esperamos que haya sido interesante, y usted comparte su opinión sobre este tema. Y recomendamos utilizar el estándar de lenguaje más moderno tanto como sea posible, para no sorprenderse de que los compiladores de optimización actuales puedan hacerlo. Mejor aún, no escriba este código en absoluto :).

PD: Podemos decir que "encendimos la pregunta", y ahora habrá que eliminarla del cuestionario. No vemos el punto en esto. Si una persona no es demasiado perezosa para estudiar nuestras publicaciones antes de una entrevista, lee este material y luego lo usa, entonces está bien hecho y con mérito recibirá un signo más :).


Si desea compartir este artículo con una audiencia de habla inglesa, utilice el enlace a la traducción: Andrey Karpov, Phillip Khandeliants. Cuán profundo es la madriguera del conejo, o entrevistas de trabajo en C ++ en PVS-Studio .

All Articles