Precedencia y asociatividad: la tabla que de verdad importa
C tiene quince niveles de precedencia y tres asociatividades, pero solo un puñado de reglas causa el noventa por ciento de los bugs. La jerarquía real, los errores históricos que Ritchie admitió, las trampas famosas y el criterio profesional para poner paréntesis aunque el compilador no los pida.
La precedencia de operadores no es una ley matemática: es una decisión de diseño tomada en Bell Labs a principios de los setenta, con restricciones de compilador que hace medio siglo que no existen. Algunas de esas decisiones fueron reconocidas como errores por su propio autor y no se pudieron corregir porque ya había demasiado código escrito. Conocer la tabla completa es innecesario; conocer sus grietas es obligatorio, porque son las que convierten una expresión legible en una expresión que hace otra cosa.
- Reconstruir la jerarquía real de precedencia por bloques, no de memoria.
- Distinguir asociatividad izquierda, derecha y su efecto sobre el árbol de la expresión.
- Identificar las trampas históricas: bits frente a comparación, desplazamiento frente a suma, prefijo frente a sufijo.
- Aplicar un criterio explícito de cuándo poner paréntesis aunque no hagan falta.
La jerarquía por bloques
Nadie memoriza quince filas. Lo que sí se puede interiorizar son seis bloques ordenados de más fuerte a más débil, porque dentro de cada bloque los errores son raros y entre bloques es donde vive el peligro.
El bloque más fuerte es el sufijo: [], la llamada a función, ., -> y el incremento o decremento pospuesto. Después el prefijo y unario: !, ~, el incremento o decremento antepuesto, el signo, la indirección, la toma de dirección, el molde de tipo, sizeof y alignof. A continuación el bloque aritmético, en el orden esperado: producto y cociente, luego suma y resta. Luego los desplazamientos. Después las relaciones, primero las de orden y luego las de igualdad. Después los bits, en el orden &, ^, |. Después los lógicos, && antes que ||. Y por último, más débiles que todo lo anterior, el condicional ?:, la asignación en todas sus formas y el operador coma.
/* Lectura correcta de expresiones densas, por bloques */
*p++ /* sufijo antes que unario: es *(p++) */
*p.campo /* error: se lee *(p.campo); querias (*p).campo */
a + b << 2 /* aritmetica antes que desplazamiento: (a + b) << 2 */
a & b == c /* igualdad antes que bits: a & (b == c) */
x = y = 0 /* asignacion asociativa por la derecha */
Si solo retienes un hecho de toda la tabla, que sea este: los operadores de bits &, ^ y | tienen menos precedencia que las comparaciones. Es contraintuitivo porque visualmente & parece un operador aritmético y == parece un separador. Dennis Ritchie explicó el motivo: cuando se diseñó el lenguaje todavía no existían && ni ||, y & cumplía ambos papeles, así que se colocó por debajo de la igualdad para que a == b & c == d funcionase. Cuando se añadieron los operadores lógicos, cambiar la precedencia habría roto miles de líneas ya escritas. Vivimos con esa deuda desde entonces.
Asociatividad: el otro eje
La precedencia decide qué operador se lleva los operandos cuando compiten dos distintos. La asociatividad decide qué ocurre cuando compiten dos del mismo nivel, y se olvida con mucha más facilidad.
Prácticamente todos los binarios asocian por la izquierda: a - b - c es (a - b) - c, y a / b / c es (a / b) / c. Asocian por la derecha tres familias, y las tres son exactamente las que generan confusión: los operadores unarios de prefijo, el condicional ?: y todas las asignaciones.
/* Izquierda: el orden importa en resta y division */
100 - 20 - 5 /* 75, no 85 */
/* Derecha: la cadena de asignaciones se resuelve del final al principio */
int a, b, c;
a = b = c = 0; /* c primero, luego b, luego a */
/* Derecha: el condicional anidado se lee como una cadena si-si no */
const char *nivel = n > 90 ? "alto"
: n > 50 ? "medio"
: "bajo";
Que el condicional asocie por la derecha es lo que permite encadenar ternarios como una escalera de casos, un idioma legítimo y legible cuando se alinea así. Que la asignación asocie por la derecha es lo que hace posible a = b = c, y también lo que explica por qué a = b == c asigna un booleano en vez de comparar: la igualdad, más fuerte, se lleva b y c antes de que la asignación vea nada.
flowchart TD E[Expresion flags AND MASK comparada con cero] --> A[Nodo raiz operador AND de bits] A --> F[Operando izquierdo flags] A --> C[Operando derecho comparacion de igualdad] C --> M[MASK] C --> Z[cero] C --> R[Resultado cero o uno y no la mascara esperada] style C fill:#f38ba8,color:#11111b style R fill:#f38ba8,color:#11111b
Las trampas famosas
Estas son las que aparecen una y otra vez en revisiones de código, informes de seguridad y erratas de libros. Vale la pena reconocerlas de un vistazo.
Bits frente a igualdad
if (flags & MASK == 0) se agrupa como flags & (MASK == 0), es decir, flags & 0 o flags & 1. Casi siempre da cero y la condición nunca se cumple. Es el bug canónico de C.
Desplazamiento frente a suma
x << 1 + 2 es x << 3, no (x << 1) + 2. La aritmética gana al desplazamiento, algo que sorprende a quien lee << como una operación de bajo nivel, más primitiva.
Indirección frente a acceso a miembro
*p.campo es *(p.campo). El sufijo . gana al unario *. Cuando p es puntero necesitas (*p).campo o, mejor, p->campo, que existe justo para evitar este paréntesis.
Comparaciones encadenadas
a < b < c compila sin avisos y no significa lo que parece: se agrupa como (a < b) < c, comparando un cero o un uno con c. C no tiene comparación encadenada; escribe a < b && b < c.
Hay tres más que conviene tener presentes. sizeof x + 1 es (sizeof x) + 1, porque sizeof es unario y gana a la suma. !x & y es (!x) & y, con el mismo mecanismo. Y el operador coma, el más débil de todos, hace que return 1, 2; devuelva 2 sin que el compilador se inmute, algo que en un for es idiomático y en cualquier otro sitio suele ser un error de puntuación.
size_t total = sizeof cabecera + carga; /* (sizeof cabecera) + carga */
int ok = !error & mascara; /* (!error) & mascara: casi nunca es lo que quieres */
int v = (calcular(), respaldo); /* descarta el primero, devuelve el segundo */
/* El condicional es mas debil que casi todo lo que lleva dentro */
int m = a > b ? a : b; /* correcto: las relaciones se agrupan primero */
int t = flag ? 1 : 2 + 3; /* es flag ? 1 : (2 + 3), no (flag ? 1 : 2) + 3 */
/* Asignacion dentro de condicion: legal, casi siempre un typo */
if (x = y) { } /* asigna y evalua; el compilador avisa */
if ((x = y) != 0) { } /* si era intencional, hazlo evidente */
El caso del ternario con la suma es especialmente instructivo. Como ?: está por debajo de la aritmética, la rama falsa absorbe toda la expresión que pueda hasta chocar con algo aún más débil. Y como el ternario está por encima de la asignación, x = c ? a : b funciona como esperas, pero c ? x = a : x = b no compila con la lectura ingenua, porque la segunda asignación queda fuera del ternario. Es otro caso donde el paréntesis no es opcional sino estructural.
La familia GCC y Clang implementa -Wparentheses, activado por -Wall, que avisa precisamente de las combinaciones históricamente peligrosas: bits mezclados con comparaciones, && dentro de ||, asignación dentro de una condición. Añade -Wshift-op-parentheses y -Wbitwise-op-parentheses en Clang para cubrir el resto. Un aviso de esta familia no es ruido pedante que silenciar: es el compilador diciéndote que ha detectado una agrupación que estadísticamente casi nunca es la intención del autor. Trátalos como errores con -Werror=parentheses.
Paréntesis por diseño, no por duda
La regla popular —pon paréntesis cuando dudes— es insuficiente, porque el problema es justo que no dudas cuando deberías. Un criterio profesional es más concreto y se puede aplicar mecánicamente en revisión de código.
Pon paréntesis siempre que en una misma expresión mezcles operadores de bloques distintos cuya interacción no sea la aritmética escolar. Es decir: bits con comparaciones, desplazamientos con aritmética, lógicos entre sí, y cualquier cosa con el condicional o la asignación. No los pongas dentro del bloque aritmético puro, donde la precedencia coincide con lo que todo el mundo aprendió en el colegio y añadirlos solo genera ruido.
/* Ruido: la precedencia aritmetica es universal y no sorprende a nadie */
int mal = ((a * b) + (c * d));
int bien = a * b + c * d;
/* Necesario: mezcla de bloques con interaccion contraintuitiva */
if ((flags & MASK) == 0) { }
uint32_t v = (base << 8) | indice;
bool ok = (a && b) || (c && d);
size_t n = (total + tam - 1) / tam;
El segundo criterio es la macro. En una macro, cada parámetro va entre paréntesis y el cuerpo entero también, porque la expansión textual coloca tu expresión dentro de un contexto que no controlas y donde la precedencia externa puede partirla por la mitad.
#define CUADRADO(x) ((x) * (x)) /* sin los internos, CUADRADO(a + 1)
se expandiria a a + 1 * a + 1 */
El error conceptual que arrastra casi todo el mundo es tratar la tabla de precedencia como si fuera aritmética: algo objetivo, descubierto, universal. No lo es. Es una lista de decisiones tomadas por personas concretas, en un contexto concreto, con compiladores que cabían en unas pocas decenas de kilobytes, y al menos una de esas decisiones —los operadores de bits por debajo de las comparaciones— fue calificada de error por el propio Ritchie, que explicó que no se pudo revertir porque el coste de romper el código existente ya era prohibitivo. Java, C# y C++ heredaron la misma tabla por compatibilidad cultural; Go rompió con ella y colocó & al nivel de la multiplicación precisamente para cerrar esa herida. Interiorizar esto cambia tu postura frente al código. Dejas de leer una expresión preguntándote qué significa según la tabla y empiezas a preguntarte qué creerá el próximo lector que significa, que es una pregunta distinta y mucho más útil. Un paréntesis redundante no cuesta un ciclo de CPU: el compilador construye exactamente el mismo árbol y genera exactamente el mismo código. Lo único que cambia es que un humano en una revisión de código a las once de la noche no tiene que reconstruir mentalmente quince niveles de jerarquía para verificar una condición de seguridad. El código no se escribe para el compilador, que nunca se equivoca al aplicar la tabla; se escribe para el humano, que se equivoca siempre.
- Escribe
if (flags & MASK == 0), compílalo con-Wall -Werrory explica el mensaje exacto que devuelve el compilador. - Evalúa
x << 1 + 2conxigual a1y razona el valor antes de ejecutarlo. - Comprueba que
a < b < ccompila y encuentra valores dea,bycdonde el resultado contradiga la lectura ingenua. - Reescribe una cadena de tres ternarios anidados como
ifyelsey verifica que la asociatividad por la derecha produce la misma estructura. - Define
CUADRADOsin paréntesis internos, expándelo con-EsobreCUADRADO(a + 1)y muestra la expresión resultante.