Operadores incremento/decremento en prefijo y postfijo.

Iniciado por K-YreX, 2 Octubre 2019, 14:46 PM

0 Miembros y 1 Visitante están viendo este tema.

Serapis

Cita de: YreX-DwX en  3 Octubre 2019, 21:37 PM
Ya he visto que es mejor separar los incrementos/decrementos en líneas diferentes
pero quiero terminar de entenderlos. He realizado otra prueba...
Y el resultado es: numeros = {1,2,-3, 4,5}. Por lo que saco (en claro) que...

Sin embargo, en Java si probamos a hacer lo mismo:
El resultado es: numeros = {1,2,3,-3,5}. Por lo que aquí parece que...
Cada lenguaje tiene su sintaxis y su especificación. Esperar resultados idénticos es pecar de ingenuo (precisamente porque la mayor parte de las veces así sucederá, teniendo ambos lenguajes un origen común).

Ese "Quiero terminar de entenderlo", me suena a perder el tiempo...
En vez de hacer pruebas, lo que tienes que hacer es consultar la especificación del lenguaje (de uno y otro)... para ver cuál es el orden de precedencia de la asignación. Ya está, 1 minuto de lectura, no varios días haciendo pruebas... si no, aprender se te hará eterno.
Si la especificación del lenguaje es ambigua en tal punto, entonces ten por cierto que cada compilador tiene vía libre para hacer como le dé la gana. Ahí, si procede hacer pruebas para ver como se comporta el compilador que estés usando.

K-YreX

Cita de: RayR en  2 Octubre 2019, 19:33 PM
En realidad, como curiosidad, y contrario a la engañosa creencia antes mencionada, en algunos casos el postincremento/postdecremento (con tipos básicos) en teoría podría ser más eficiente que el preincremento, ya que el preincremento genera una dependencia de datos que no existe con el post, por lo que en este último caso podría haber un mejo aprovechamiento de los pipelines del procesador. Eso sí, en la práctica yo nunca lo he visto, pero he leído de dos programadores que lo afirman.
No había procesado esto bien y la verdad nunca habría pensado que eso fuera así ya que yo creía que el operador que mantiene una dependencia es el postfijo que necesita usar otro registro (como se veía en los ensambladores de MAFUS y engel lex) para almacenar el valor todavía sin incrementar. Sin embargo, me parece curioso aunque se me escape de las manos el entenderlo.

Entonces para resumir: el estándar C++17 especifica que primero se evalúa la parte de la derecha y después la de la izquierda pero los efectos colaterales siguen sin estar definidos; a diferencia de Java que evalúa de izquierda a derecha y especifica que los efectos colaterales se resuelven inmediatamente después.

Cita de: NEBIRE en  4 Octubre 2019, 09:41 AM
Cada lenguaje tiene su sintaxis y su especificación. Esperar resultados idénticos es pecar de ingenuo (precisamente porque la mayor parte de las veces así sucederá, teniendo ambos lenguajes un origen común).

Ese "Quiero terminar de entenderlo", me suena a perder el tiempo...
En vez de hacer pruebas, lo que tienes que hacer es consultar la especificación del lenguaje (de uno y otro)... para ver cuál es el orden de precedencia de la asignación. Ya está, 1 minuto de lectura, no varios días haciendo pruebas... si no, aprender se te hará eterno.
Si la especificación del lenguaje es ambigua en tal punto, entonces ten por cierto que cada compilador tiene vía libre para hacer como le dé la gana. Ahí, si procede hacer pruebas para ver como se comporta el compilador que estés usando.
Es posible que algunas personas lo vean como una pérdida de tiempo pero me interesaba saber cuáles eran las diferencias entre estos dos lenguajes en este aspecto y al final parece que lo estoy consiguiendo con las aportaciones del resto de miembros y sobre todo con las explicaciones de RayR, que agradezco.
También es verdad que consultando las especificaciones de cada lenguaje acabaría antes pero entre que no sé dónde localizarlas ni hasta donde llegaría a entender pues... supongo que dentro de mis posibilidades haré lo que pueda y en este caso lo que podía hacer eran pruebas para intentar sacar alguna conclusión de forma práctica. He visto que no llegaba a nada con pruebas pues entonces he probado en el foro.
Si todos nos tuviésemos que buscar la vida yendo a las fuentes oficiales de cada tema, no servirían de nada los foros porque lo que cualquiera pueda afirmar en un foro tendrá que estar apoyado por algún tipo de documentación oficial.
Código (cpp) [Seleccionar]

cout << "Todos tenemos un defecto, un error en nuestro código" << endl;

Loretz

Me meto en la conversación un poco tarde...

Yo creo estamos todos de acuerdo que es mala idea escribir
eros[j+1] = -numeros[j--];
Y se van dando distintas razones que vamos entendiendo, o más o menos. Lo bueno es que está claro que no se hace. La definición sólida de por qué no se hace está en "la documentación": Order of Evaluation (https://en.cppreference.com/w/cpp/language/eval_order)

Puse eso de "la documentación" entre comillas porque es lo más parecido que conozco a una documentación de consulta eficiente, y que para mí es suficiente. Para cosas más peludas siempre queda la documentación oficial, el documento del estándar C++17; lo que establece qué es C++ y qué no lo es. La ley: (http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4659.pdf)

En este caso, me parece que no es necesario ser tan drástico y que el artículo de "la documentación" es más que suficiente; sobre todo cuando dice que este tipo de expresiones se califican como UB, Undefined Behavior, que no quiere decir "comportamiento indefinido", o que algo no está del todo bien, es una categoría específica que invalida al programa enteramente; no es que alguna operación puede dar un resultado diferente a la misma operación en otros lenguajes, o que en un compilador pase una cosa y en otro otra; es bastante más drástico que eso: una UB convierte al programa en una pila de basura. Ver: [https://en.cppreference.com/w/cpp/language/ub]

Y con respecto al asunto este del pre-incremento o post-incremento, algunas veces dará lo mismo usar uno u otro y otras no, es porque hacen cosas diferentes. Por ejemplo:
    int i = 1;
    int j = ++i; // incrementa el valor de i y lo devueve.

    int k = i++; // incrementa el valor de i y devuelve 
                 // una copia de i antes de ser incrementado


En general los compiladores son más inteligentes y saben hacerlo mejor que yo, pero no pueden escapar a la realidad de que el post-incremento hace más cosas que el pre-incremento. En un ciclo for, donde se usa para incrementar la variable de control, el compilador puede lucirse con poco, usa la versión más eficiente sin preguntar. Sin trucos ++i hace menos cosas que i++.






RayR

Cita de: YreX-DwX
No había procesado esto bien y la verdad nunca habría pensado que eso fuera así ya que yo creía que el operador que mantiene una dependencia es el postfijo que necesita usar otro registro (como se veía en los ensambladores de MAFUS y engel lex) para almacenar el valor todavía sin incrementar. Sin embargo, me parece curioso aunque se me escape de las manos el entenderlo.

Es que de ese código no se puede sacar ninguna conclusión. Primero, porque fue compilado sin optimizaciones, y eso por sí sólo lo descarta para este objetivo, ya que el código generado no es realista, y los compiladores hacen cosas que normalmente no harían, por ejemplo, pueden tomar ciertas decisiones que empeoran el rendimiento, pero facilitan la depuración, etc. Además, el ensamblador que pusieron sólo aplica para GCC, pues si compilamos el código con Visual C++, genera la misma cantidad de instrucciones para ambas. Y es que GCC no necesitaba generar 4 instrucciones para el int n = b++. Pudo haber hecho simplemente esto:

Código (asm) [Seleccionar]
        mov     eax, DWORD PTR [rbp-8]
        add     DWORD PTR [rbp-8], 1
        mov     DWORD PTR [rbp-16], eax


Y ya está. No hay ninguna razón que obligue a que la forma postfija requiera más instrucciones. Lo que vimos en el ejemplo original sin optimizar es simplemente lo que GCC decicidió hacer (por las razones que sea) en este caso concreto. Pero aún en el caso en el que GCC generó 4 instrucciones, usó lea en la suma. Esa instrucción no es para operaciones aritméticas, pero es una "optimización" común usarla para eso. Lo pongo entre comillas porque depende del procesador, pero en algunos casos, esa operación podría salir casi "gratis" (se podría ejecutar en paralelo con otras) por lo que, para efectos de tiempo de ejecución, sería prácticamente como si no existiera. Como había dicho antes, contar instrucciones no es suficiente.

De cualquier forma, nunca se debería un ejemplo tan simple, ya que los compiladores son bastante inteligentes (casi siempre) y no traducen instrucción por instrucción sino que analizan bloques de código y a partir de ahí deciden. Por eso es muy importante el uso, el contexto, sobre todo porque los compiladores de C/C++ tienen permitido hacer muchísimas modificaciones a nuestro código, siempre que no modifiquen su comportamiento observable. Porque si compilamos el código original de MAFUS con optimizaciones, como es debido, veremos que tampoco es de utilidad, ya que todos los compiladores modernos se darán cuenta de que no hace nada, así que eliminarán todas las instrucciones y simplemente harán que main retorne 0.

Cita de: YreX-DwX
No había procesado esto bien y la verdad nunca habría pensado que eso fuera así ya que yo creía que el operador que mantiene una dependencia es el postfijo que necesita usar otro registro (como se veía en los ensambladores de MAFUS y engel lex) para almacenar el valor todavía sin incrementar. Sin embargo, me parece curioso aunque se me escape de las manos el entenderlo.

Lo que puse de la dependencia tómalo con muchas reservas, ya que, como dije, no sé realmente qué tanta diferencia pueda hacer en la práctica. Y ten en cuenta que en la mayoría de arquitecturas, incluidos procesadores de Intel/AMD, muchas operaciones no se pueden realizar directamente en la memoria, por lo que es forzoso usar registros para valores intermedios. Esto lo menciono porque se pudiera pensar que el incremento/decremento prefijo nos podemos ahorrar alguna instrucción, pero en general, no es así. Aclarado eso, ambas versiones del operador requieren dos cosas: incrementar el valor de la variable y retornar "algo". En el postfijo, el valor original, y en el prefijo el nuevo. La cuestión es si hay interdependencia entre esas dos acciones. Esto es importante porque la mayoría de los procesadores modernos pueden ejecutar más de una instrucción de forma concurrente, siempre que una no dependa de la otra. Teniendo en cuenta que en general necesitamos una copia temporal, con el operador posfijo la asignación y el incremento se pueden hacer simultáneamente, pero en el prefijo el incremente debo ir antes. En realidad esto es casi meramente teórico, ya que en la realidad estos operadores prácticamente nunca son intercambiables, y los casos en los que los son, seguramente el compilador los optimizará de cualquier manera.

No hay nada de malo en preguntar. C++ es un lenguaje muy complejo, y creo que muy pocas cosas en él se podrían considerar "simples". Cosas como la de este tema, no lo son, por supuesto, y encontrar la respuesta tampoco. El estándar es muy complicado, independientemente de cuánta experiencia tengamos. Realmente está dirigido, sobre todo, a creadores de compiladores. No me gusta consultarlo, ya que en realidad no siempre lo entiendo, pero a veces no hay opción, ya que es la única fuente 100% confiable. Por ejemplo, en un vistazo rápido al artículo Order of evaluation de cppreference, no encuentro nada que resuelva el asunto concreto de este hilo. Y por ejemplo, esto:

Citar8) The side effect (modification of the left argument) of the built-in assignment operator and of all built-in compound assignment operators is sequenced after the value computation (but not the side effects) of both left and right arguments, and is sequenced before the value computation of the assignment expression (that is, before returning the reference to the modified object)

es, como mínimo incompleto, al menos en C++17, ya que la parte en negrita (que no aparece en la especificación del estándar) implica que es posible que la asignación suceda antes que los efectos colaterales del lado derecho, lo cual no es cierto. Si lo fuera, el resultado de esto:

Código (cpp) [Seleccionar]
int n = 5;
numeros[n] = n++;


seguiría indefinido. Sería posible que termináramos modificando numeros[5], ya que, si al momento de la asignación no se ha producido el efecto colateral del operando derecho, por definición, n sigue valiendo 5. Esto no es así. En C++17, siempre modificaremos numeros[6].

K-YreX

Cita de: Loretz en  5 Octubre 2019, 04:01 AM
En un ciclo for, donde se usa para incrementar la variable de control, el compilador puede lucirse con poco, usa la versión más eficiente sin preguntar. Sin trucos ++i hace menos cosas que i++.
Con lo que está en negrita, quieres decir que tanto si se usa el postincremento como el preincremento, el compilador lo optimizará al mismo nivel? Es decir, que daría igual usar uno que otro? Lo digo porque yo era de los que empezó a programar y a hacer los for con i++ hasta que me dijeron que era mejor usar ++i, pero claro, eso me ha chocado con el tema de que el preincremento sí es mejor pero cuando se trata de objetos y no de tipos primitivos.

Cita de: RayR en  5 Octubre 2019, 05:28 AM
Porque si compilamos el código original de MAFUS con optimizaciones, como es debido, veremos que tampoco es de utilidad, ya que todos los compiladores modernos se darán cuenta de que no hace nada, así que eliminarán todas las instrucciones y simplemente harán que main retorne 0.
Cierto. Sí que se me ocurrió añadir las opciones -O1 y -O2 y se ve eso que dices. En el primer caso lo que se obtiene es esto:
Código (asm) [Seleccionar]

mov     eax, 0
ret

Y en el segundo caso lo que se obtiene es:
Código (asm) [Seleccionar]

xor     eax, eax
ret

Por lo que es cierto que el compilador ve que no se usan las variables para nada y entonces omite las sentencias.
Código (cpp) [Seleccionar]

cout << "Todos tenemos un defecto, un error en nuestro código" << endl;

Loretz

Citar
Cita de: Loretz en Ayer a las 04:01
... usa la versión más eficiente sin preguntar. Sin trucos ++i hace menos cosas que i++.
CitarCon lo que está en negrita, quieres decir que tanto si se usa el postincremento como el preincremento, el compilador lo optimizará al mismo nivel? Es decir, que daría igual usar uno que otro? Lo digo porque yo era de los que empezó a programar y a hacer los for con i++ hasta que me dijeron que era mejor usar ++i, pero claro, eso me ha chocado con el tema de que el preincremento sí es mejor pero cuando se trata de objetos y no de tipos primitivos.

En general los compiladores reescriben tu código como les place, la única restricción es que se preserve el "observable behavior"; o sea: el compilador es libre de pasarse tu código por donde le plazca y poner en su lugar otra cosa, sólo está obligado a generar un ejecutable que haga lo mismo. Aprovechando que ya tuve la delicadeza de poder el link al docuemento del Estándar C++17, puedes consultar: parágrafo 4.6 Program execution - Nota 5.

De modo que aunque el compilador no esté obligado, casi seguro que lo hará, eso o algo diferente, a su criterio.

Con los tipos nativos no sólo es más fácil sino que además es muy difícil que notemos la diferencia, normalmente no suele haber un cuello de botella en i++ cuando i es un int. Ahora, si i es de tipo "Clase_con_copy_constructor_vicioso", la diferencia será notable, a no dudarlo.

Ah, ¿por qué la diferencia va a estar en el copy_constructor_vicioso? Por lo dicho, el postincremento devuelve una copia del objeto original sin incrementar.