Disponible la nueva versión "donationware" 7.3 de OrganiZATOR
Descubre un nuevo concepto en el manejo de la información.
La mejor ayuda para sobrevivir en la moderna jungla de datos la tienes aquí.

Curso C++

[Home]  [Inicio]  [Índice]


4.9.0a  Evaluación de expresiones:  asociatividad y precedencia de operadores

"The answer is, it's awfully hard to figure out what goes wrong in your application if you don't understand what is going on in your application". Brett McLaughlin en "Mastering Ajax"   www-128.ibm.com.

§1  Presentación

Recordemos que el lenguaje C++ está compuesto por cinco tipos de elementos: palabras-clave; identificadores; constantes; operadores y signos de puntuación ( 3). Con estos elementos se construyen las sentencias, cuya ejecución es secuencial, excepto cuando aparecen elementos que modifican el flujo "natural" del programa. Son las sentencias de selección. Por ejemplo, if..else ( 4.10.2); las de iteración ( 4.10.3), del tipo for..., o do... y las de salto ( 4.10.4) como return o break.

Considerando las sentencias como unidades de ejecución, resulta relativamente fácil a cualquier estudiante conocer con exactitud el orden que seguirá la ejecución del programa en sus diversas bifurcaciones e iteraciones, así como la naturaleza de las operaciones involucradas. Sin embargo, la facilidad no es tanta cuando se considera cuales son las operaciones que se llevan a cabo "dentro" de las sentencias, y su orden.

El asunto es importante porque muchas veces el resultado, o incluso la aparición de errores de ejecución y/o compilación, depende justamente de estos detalles. Con los agravantes de que muchas de ellas son "ocultas", en el sentido que son realizadas automáticamente por el compilador sin que tengamos constancia de su ocurrencia, y de que se siguen procesos distintos en compilación y en ejecución (por supuesto todos ellos pueden dar lugar a errores).

En el presente capítulo incluiremos algunas observaciones respecto a este asunto al que, en mi opinión, no se suele prestar la debida atención a pesar de que en ciertos casos, su desconocimiento puede perfectamente mandarnos a limpiar cochineras a Carolina del Norte ( 1 ). Naturalmente no nos referimos a expresiones sencillas del tipo y = 2+x; sino a expresiones más complejas. Por ejemplo:

String S = s1+c1.*c1.pachar2+'C';

§2  Evaluación de expresiones

En general, salvo que se relacionen con las mencionadas sentencias modificadoras del flujo, las palabras-clave señalan al compilador aspectos complementarios que no alteran el orden de ejecución dentro de la propia sentencia. Este orden viene determinado por cuatro condicionantes:

1. Presencia de paréntesis que obligan a un orden de evaluación específico.

2. Naturaleza de los operadores involucrados en la expresión (asociatividad).

3. Orden en que están colocados (precedencia).

4. Providencias (impredecibles) del compilador relativas a la optimización del código.

En cuanto al primero, aunque el paréntesis es un signo de puntuación ( 3.2.6), podría considerarse como el operador de precedencia más alta. Si existen paréntesis, el compilador los evalúa en primer lugar. El segundo es especialmente importante, porque como veremos a continuación, es precisamente su naturaleza la que establece dos propiedades importantes de los operadores: la asociatividad y la precedencia (§3 ).  El punto tercero es influyente porque a igualdad de precedencia, unos operadores se ejecutan en el orden en que aparecen escritos en el código (de izquierda a derecha), y en otros casos es al contrario (dependiendo de su asociatividad). A su vez el punto cuarto encierra decisiones que son dependientes de la plataforma. Se refieren a medidas del compilador tendentes a la optimización del código de la sentencia, que resultan incontrolables para el programador a no ser que adopte medidas específicas. Estas medidas suelen consistir en no simplificar demasiado las expresiones, y obtener resultados intermedios, que solo son necesarios para obligar a una forma determinada de obtener el resultado.


§2.1  La precedencia indica cual es el orden de ejecución de los operadores cuando existen varios. Por ejemplo, en la expresión:

a * b + c++;      // L.1

la precedencia determina que se ejecutará primero el operador postincremento ++ sobre c. A continuación se aplicará el operador de multiplicación * entre a y b. Finalmente se aplicará el operador suma + entre los resultados anteriores. Así pues, la expresión es equivale a:

(a * b) + (c++);  // L.2

Este orden "natural" del compilador no necesita paréntesis de forma que las sentencias L1 y L2 producen el mismo resultado. Cualquier otro debe ser forzado específicamente mediante la utilización de los paréntesis correspondientes. Por ejemplo:

a * (b + c)++;    // L.3


§2.2  La asociatividad indica el orden de ejecución cuando en una expresión existen diversos operadores de igual precedencia. Puede ser de dos tipos: izquierda (→) o derecha (←) [1]. Por ejemplo, la suma binaria + tiene asociatividad izquierda, lo que significa que en una expresión como:

a + b + c + d;

la ejecución seguirá el orden:

(((a + b) + c) + d);

  Los operadores unarios y el de asignación (=), tienen asociatividad derecha (←). Todos los demás la tienen izquierda (→). En consecuencia, si @ representa un operador binario, ante una expresión como:

a @ b @ c @ d;

el orden de evaluación es desde la izquierda hacia la derecha. Pero si la expresión es del tipo:

(...) @ (...) @ (...) @ (...);

el orden de evaluación de los paréntesis es indefinido. Aunque una vez obtenidos todos los resultados parciales, la computación sigue el orden indicado en el punto anterior.

Si existen paréntesis anidados se procede desde dentro hacia fuera:

(((.1.)@(.1.)2) @ (.2.));

§3  Precedencia y asociatividad de los operadores C++

En C++ existen 18 categorías de precedencia (ver tabla adjunta), algunas de las cuales contienen solo un operador. El orden en la tabla muestra las precedencias en orden descendente (misma línea igual precedencia). Se ha incluido un número indicativo, los números más bajos tiene mayor precedencia que los más altos.

Cuando existen operadores duplicados en la tabla, la primera ocurrencia se refiere al operador unitario, la segunda al binario (por ejemplo, los casos de +, * y &).   Esto significa que en la gramática del C++, cuando un operador tiene dos formas, unaria y binaria, la primera tiene mayor precedencia que la segunda.

Nota: el paréntesis es un signo de puntuación y no se incluye (el que se muestra en la tabla es el operador de invocación de función), pero podría considerarse como el operador de precedencia más alta (precedencia -1).

Precedencia

Operador

Asociatividad

0 ::

Operador de resolución de ámbito

 
1 ( ) [ ] -> ++ --

.

typeid

 

→ 

2 ! ~ + - ++ -- & * sizeof new delete ← 
3 .* ->*

Selección de miembro

→ 
4 * / %

Operadores aritméticos: multiplicación, división y módulo

→ 
5 + -

Suma y resta binarias

→ 
6 << >>

Manejo de bits: desplazamientos izquierdo y derecho

→ 
7 < <= > >=

Operadores relacionales, mayor-que, menor-que, etc.

→ 
8 == !=

Operadores relacionales igual y desigual

→ 
9 & Manejo de bits:  AND lógico → 
10 ^ Manejo de bits:  XOR → 
11 | Manejo de bits:  OR → 
12 && Operador Y lógico → 
13 || Operador O lógico ← 
14 ?: Operador condicional ternario → 
15 = *= /= %= += -= &= ^= |= <<= >>= ← 
16 throw Operador de lanzamiento de excepción  
17 , Operador coma → 

Nota  Post-operador incremento y decremento unitario.    Pre-operador incremento y decremento unitario.


Observe que los operadores de bits & ^ |  (líneas 8, 9 y 10) tienen una precedencia menor que la igualdad == y desigualdad != (línea 7), lo que supone que expresiones de comprobación de bits como:

if ((x & MASK) == 0) {...}

deben ser adecuadamente parentizadas para garantizar que los resultados sean correctos.

Ejemplos
  • ++ptr->x;

    Debido a que la precedencia del preincremento es menor que la del selector indirecto de miembro, la expresión anterior equivale a:

    ++(ptr->x);

    El resultado es que se incrementa el miembro x, no el valor del puntero ptr.

  • *ptr->x;

    por lo mismo, esta indirección equivale a

    *(ptr->x);

    El resultado depende del tipo de miembro x. Por ejemplo, si x es un int, se obtendría un error de compilación (no puede obtenerse la indirección de un int). Por el contrario, si es un puntero, se obtendría el objeto señalado por él.

§4  Orden de evaluación

Las reglas anteriores no definen completamente el orden de evaluación de las expresiones, de forma que dejan algunos aspectos a criterio del compilador (son dependientes de la plataforma y de las decisiones que esta quiera tomar tendentes a la optimización). Por ejemplo, en la expresión:

(a + b) + ( c + d);

es evidente que las subexpresiones (a + b) y (c + d) se resolverán antes que la suma central, pero no está definido cual de las dos se ejecutará en primer lugar [2]. El Estándar señala al efecto:  "Con excepción de los casos señalados, no está especificado el orden de evaluación de los operandos de operadores individuales y subexpresiones de expresiones individuales, así como el orden en que ocurren los efectos laterales de las expresiones". En consecuencia, la mayoría de compiladores C++ no garantizan el orden en que se evalúan los operandos de una expresión (con excepción de los operadores &&, ||, ?:  y , ).

Por lo general los compiladores tratan de arreglar las expresiones para mejorar la calidad general del código, así que no puede especificarse el orden en que serán evaluados realmente los operandos a menos que algún operador lo establezca específicamente. Por esta razón hay que tener especial cuidado con expresiones en las que un valor sea modificado más de una vez. Por lo general deben evitarse expresiones que simultáneamente modifiquen y usen el valor de algún objeto. Por ejemplo, considere la expresión:

i = v[i++];

donde i es indefinido cualquiera que sea su valor previo, porque su valor resultante depende de que sea incrementado antes o después de la asignación.

De forma similar en:

int total = 0;
sum = (total = 3) + (++total);

los valores sum y total son ambiguos  (es sum = 4 ó sum = 7 ?). La solución es rediseñar la expresión utilizando una variable temporal:

int temp, total = 0;
temp = ++total;
sum = (total = 3) + temp;

en cuyo caso la sintaxis fuerza un orden de evaluación.

Ejemplo:

int x = 5;
cout << x++ + 5 << "; x = " << x << endl;    // L.1
x = 5;
cout << ++x + 5 << "; x = " << x << endl;    // L.2
x = 5;
cout << x-- - x++ << "; x = " << x << endl;  // L.3
x = 5;
cout << x-- - ++x << "; x = " << x << endl;  // L.4
x = 5;
cout << ++x - x-- << "; x = " << x << endl;  // L.5

Salidas:

Borland C++ Builder 5.6.4 y 6.0 para Win32

10; x = 5
11; x = 5
1; x = 5
0; x = 5
0; x = 5

GNU C++ 3.3.1-20030804-1 para Win32.

10; x = 6
11; x = 6
0; x = 5
0; x = 5
0; x = 5

Para entender los resultados es preciso considerar que los operadores incremento y decremento ++ y --, en sus versiones "pre" y "post", tienen mayor precedencia que las suma y resta binarias, por lo que se ejecutan primero. Pero los efectos laterales de estos últimos (postincremento y postdecremento), solo tienen efecto después que la expresión ha sido evaluada. Esto puede evidenciarse incluyendo paréntesis en las expresiones anteriores y comprobando que se obtienen los mismos resultados:

int x = 5;
cout << (x++) + 5 << "; x = " << x << endl;
x = 5;
cout << ( ++x) + 5 << "; x = " << x << endl;
x = 5;
cout << (x--) - (x++) << "; x = " << x << endl;
x = 5;
cout << (x--) - ( ++x) << "; x = " << x << endl;
x = 5;
cout << ( ++x) - (x--) << "; x = " << x << endl;


En L.1, el valor 5 de x es sumado con la constante numérica 5, y el resultado es 10.  Una vez que se ha completado la expresión, se produce el postincremento, con lo que x tiene el valor 6.

En L.2 el proceso es análogo, con la salvedad de que ahora el incremento de x se realiza antes de que la expresión sea evaluada, con lo que al valor 6 de x se le suma la constante 5 y el resultado es 11.

En ambos casos, el valor de x después de ejecutadas las sentencias L.1 y L.2 es 6. La aparente contradicción en los valores de x mostrados por ambos compiladores, se deben a que el compilador Borland evalúa las expresiones del tipo

cout << A << B << endl;

de derecha a izquierda, mientras que GNU lo hace de izquierda a derecha [4]. Que el valor de x es 6 en ambos casos, puede ser comprobado añadiendo una sentencia inmediatamente después de L.1 y L.2:

cout << "El valor de x es ahora " << x << endl;

Aquí el resultado obtenido con ambos compiladores es 6. Observe que en las tres salidas restantes, el valor resultante de x es 5 cualquiera que sea el orden de ejecución de la sentencia, ya que el incremento y decremento se anulan entre sí.

Las salidas L.4 y L.5 (en las que concuerdan ambos compiladores) son 0 porque, en ambos casos se efectúa primero el preincremento. Al valor 6 de x se le resta el valor actual 6, con lo que el resultado es 0. Una vez evaluada la expresión, tienen efecto los resultados laterales. Es decir, el postdecremento de x, con lo que su valor vuelve a ser 5.

La razón de la discrepancia de las salidas obtenidas en L.3, es de naturaleza análoga a la encontrada para los valores de x en las L.1 y L.2; la libertad que concede el Estándar respecto al orden de a la evaluación de expresiones del tipo:

(...) @ (...) @ (...) @ (...);

En este caso no existe precedencia para decidir cual de las expresiones x++ o x-- se evaluará primero. Aparentemente el compilador Borland precede de derecha a izquierda y además de forma errónea, dado que los resultados laterales solo debían manifestarse "después" de evaluada la expresión. El resultado 0 de GNU es evidentemente el correcto. Al valor actual 5 de x, se le resta el valor actual (5) y el resultado es cero. A continuación se producen el postdecremento y postincremento, cuyo resultado vuelve a ser 5 cualquiera que sea el orden de ejecución adoptado.

Nota: el "bug" de esta versión de Borland parece debido a que, en este caso, el compilador procede de derecha a izquierda, y el resultado (postincremento) tiene lugar "antes" que la resta, con lo que al valor resultante 6 de x, se le resta el (todavía valor anterior 5). Esta teoría y el error se confirman cor el resultado proporcionado por:

x = 5;
cout << x++ - x-- << "; x = " << x << endl;  // -> -1; x = 5


§4.1  El siguiente ejemplo ofrece una idea de los procesos involucrados en la evaluación de expresiones.

void func(int x, int y, int z) {  // L1:
  ...
  int r = x + y + (z + 'A');      // L2:
}

Para ejecutar L2, el compilador intenta en primer lugar resolver la expresión del Rvalue, donde se encuentra con diversos identificadores y operadores. Antes que nada, durante la fase de compilación, se realiza un "Name-lookup" ( 1.2.1) para averiguar "que cosa" son las etiquetas x, y, z  (ya sabe que 'A' es una entidad tipo const char y que r es un int que se declara en la misma sentencia). A continuación, suponiendo que ha encontrado en L1, que son tipos int, procede a codificar el resultado. Como el paréntesis es el operador de mayor precedencia debe ejecutarlo en primer lugar, para lo cual intenta evaluar z + 'A'.

Estamos ante un operador binario suma (+) con dos operandos: izquierdo y derecho. Aquí debemos señalar ( 4.9.18) que esta expresión es otra forma sintáctica de la invocación z.operator+('A'). Es la invocación de un método cuyo prototipo es:

int int::operator+(int i);

El método operator+ de la clase de los enteros devuelve un entero y recibe un entero, así que el argumento actual 'A', un tipo const char, debe ser promovido al tipo del argumento formal, un tipo int ( 4.4.6). Suponiendo que esto sea posible, que lo es ( 2.2.5), se anotaría el resultado (int) devuelto por la función en un objeto temporal (lo llamaremos T1) y se procedería con el resto de la expresión.

Para resolver x + y + T1, en la que todos los operadores + tienen igual precedencia, el compilador procede según su asociatividad, que en este caso es izquierda. En primer lugar, resuelve x + y; el nuevo parcial (lo llamaremos T2) es alojado en un objeto creado al efecto. Finalmente, se realiza la operación T2 + T1 con un nuevo resultado parcial T3, y seguiría la ejecución del resto de la sentencia. En este caso, la expresión int r = T3; supone la invocación del constructor-copia ( 4.11.2d4) de la clase int.


Supongamos ahora un caso exactamente análogo al anterior en el que los objetos x, y y z no sean tipos básicos sino tipos abstractos pertenecientes a una clase C diseñada por el usuario:

void func(C x, C y, C z) {    // L1:
  ...
  C r = x + y + (z + 'A');    // L2:
}

Suponemos que hemos definido adecuadamente el operador suma binaria para los miembros de la clase C y que disponemos de un conversor adecuado por el que el tipo const char puede ser convertido a tipo C, de forma que puede efectuarse la operación z + 'A'. Por ejemplo, proporcionando un constructor-conversor en la clase ( 4.11.2d1). En este caso, las cosas funcionan de forma paralela a la descrita y el programa funciona sin dificultad [3].

Estamos tan contentos de lo bien que funciona nuestra clase, cuando un poco más adelante se nos presenta una sentencia "muy" parecida a la anterior:

C r2 = x + ('B' + y) + z;

pero descubrimos con sorpresa que ahora el compilador lanza un extraño error: Illegal structure operation in.... Dependiendo de nuestro estado anímico ese día, podemos tardar más o menos en descubrir que el problema radica precisamente en la computación del paréntesis (que aparentemente había funcionado correctamente en otras sentencias). La cosa puede ser aun más desconcertante si sabemos que el compilador dispone (y lo ha demostrado) de recursos para convertir en caso necesario un const char a C. Además, en el contexto de la expresión resulta evidente que el resultado del paréntesis "debe" ser un tipo C, y para más INRI, el Rvalue (y) es también de este tipo...

El problema es que (en el estado actual) el compilador no es aún lo suficientemente inteligente.  Al intentar evaluar la subexpresión 'B' + y  no tiene en cuenta el contexto, e intenta encontrar una definición de la asignación que corresponda al prototipo:

const char char::operator+(C c);

Como evidentemente este método no está definido para los tipos char, el compilador lanza una advertencia anunciando que no puede realizar la suma en el orden solicitado. Si no podemos alterar el orden de los sumandos (porque nuestra definición de suma para operandos de tipo C no sea conmutativa) la única posibilidad es forzar nosotros mismos la conversión, para lo que existen dos alternativas:

C r = C('B');                   // Ok. conversión implícita
C r2 = x + (r + y) + z;         // Opción-1
 
C r2 = x + (static_cast<String>('B') + y) + z;  // Opción-2


§4.2  A continuación se muestra otro ejemplo funcional de los insidiosos errores que pueden cometerse C++ cuando se olvida algún "pequeño" detalle. Considere el sencillo programa adjunto y sus sorpresivos resultados:


#include <iostream>
using namespace std;


int main() {         // ===============
  int ai[] = {2, 3};
  int z0 = ai[0] + ai[1];     // suma 2 + 3
  cout << "Resultado z0 == " << z0 << endl;

  int* pt1 = ai;
  int z1 = *pt1;
  cout << "Resultado z1 == " << z1 << endl;

  int* pt2 = ai;
  int z2 = *(++pt2);
  cout << "Resultado z2 == " << z2 << endl;

  int* pt3 = ai;
  int z3 = *pt3 + *(++pt3);
  cout << "Resultado z3 == " << z3 << endl;

  int* pt4 = ai;
  int z4 = *(++pt4) + *pt4;
  cout << "Resultado z4 == " << z4 << endl;
}

Salida  BC++ Builder 5.5, BC++ Builder 6, MS Visual C++ 6:

Resultado z0 == 5
Resultado z1 == 2
Resultado z2 == 3
Resultado z3 == 6
Resultado z4 == 6

Salida  Dev-C++ 4.9.9  GNU Compiler Collection G++ 3.3.1-20030804-1

Resultado z0 == 5
Resultado z1 == 2
Resultado z2 == 3
Resultado z3 == 5
Resultado z4 == 6

Comentario

Los resultados z0 a z2 son los esperados, sin embargo, z3 y z4 son aparentemente erróneos. La primera explicación plausible es suponer que, en este caso, la evaluación empieza de derecha a izquierda, pero precisamente el resultado  z4 parece desmentirlo.

La segunda explicación sería suponer un error en el compilador. Sin embargo, las salidas anteriores son exactamente las mismas con BC++ Builder 5.5 que con MS Visual C++ 6.0 ( 7). Lo que ocurre realmente es que en ambos casos (z3 y z4), cualquiera que sea su posición, derecha o izquierda, el compilador resuelve primero la expresión *(++pt3), y después *pt3, con lo que al primer resultado (3) se le suma el valor de *pt3, pero entonces el puntero ya ha sido modificado, con lo que su valor es también 3.

En cuanto a la salida del compilador GNU Cpp para Windows, parece evidente que su ejecución, al menos en estas expresiones, es siempre de izquierda a derecha.


§4.3
  El lenguaje tampoco se garantiza el orden en que se evalúan los argumentos de una función. Por ejemplo:

printf ("%d %d\n", ++n, power(2, n));  // Incorrecto !!

puede producir diferentes resultados con diferentes compiladores. La solución es:

++n;
printf ("%d %d\n", n, power(2, n));


§4.4  Aunque hemos señalado que, para mejorar la calidad del código, C++ reagrupa las expresiones reordenando los operadores asociativos y conmutativos con independencia de paréntesis, en las expresiones con coma el valor no queda en ningún caso afectado por la reordenación. Por ejemplo:

sum = (i = 3, i++, i++);     // OK: sum = 4, i = 5

§5  Errores y desbordamientos (overflow)

Durante la evaluación de una expresión pueden encontrarse situaciones problemáticas, como división por cero o valores fraccionarios fuera de rango. El desbordamiento de enteros es ignorado (C usa una aritmética de base 2n en registros de n-bits), pero los errores detectados por las funciones de las librerías matemáticas pueden ser controlados mediante rutinas estándar o de usuario. Ver _matherr  y signal.

§6  Expresiones con coma

Las expresiones con coma son conjuntos de subexpresiones separadas por coma y agrupadas por paréntesis. Cada subexpresión se evalúa una a continuación de otra empezando por la izquierda, el resultado es el valor de la última. Ver ( 4.10.5)

  Inicio.


[1]   Tal como señalan las flechas, en este contexto izquierda (→) y derecha (←) son abreviaturas de izquierda-a-derecha y derecha-a-izquierda respectivamente.

[2]  En Borland C++ 5.5 se efectúa primero el paréntesis derecho y a continuación el izquierdo.

[3]  En la Librería de Ejemplos se muestra el diseño de una clase String que cumple las condiciones descritas en este supuesto ( 9.3.2).

[4]  Esta característica del compilador GNU C++ para Win32, se confirma en otro ejemplo que se incluye a continuación.