wandres.dev
COMPETENTE · Operadores y expresiones

Puntos de secuencia y orden de evaluación

El orden en que C evalúa los operandos de una expresión no está garantizado, y modificar dos veces el mismo objeto entre dos puntos de secuencia es comportamiento indefinido. El modelo de secuenciación de C11 y C23, la diferencia crucial entre indefinido y no especificado, y las reglas prácticas para escribir expresiones inmunes.

⏱ 17 min

Casi todo programador cree que una expresión se evalúa de izquierda a derecha. C nunca prometió tal cosa. El estándar define un orden parcial, no total, sobre las evaluaciones dentro de una expresión, y deja libertad al compilador para reordenar, intercalar y elegir. Cuando escribes código que depende de un orden que el estándar no fija, no obtienes un resultado arbitrario pero razonable: obtienes comportamiento indefinido, con licencia total del optimizador para asumir que ese caso nunca ocurre.

🎯 Al terminar esta lección sabrás
  • Entender el modelo de secuenciación de C11 y C23 que reemplazó a los puntos de secuencia clásicos.
  • Distinguir comportamiento indefinido de comportamiento no especificado, y por qué la diferencia es operativa.
  • Reconocer los patrones canónicos que producen indefinición al modificar dos veces el mismo objeto.
  • Escribir expresiones inmunes y activar los avisos que detectan las violaciones.

De puntos de secuencia a relaciones de secuenciación

Hasta C99, el estándar hablaba de puntos de secuencia: instantes en la ejecución donde todos los efectos laterales anteriores se han completado y ninguno de los posteriores ha empezado. El modelo era intuitivo pero insuficiente para describir la concurrencia, así que C11 lo reformuló en términos de una relación llamada secuenciado antes, un orden parcial estricto sobre las evaluaciones. C23 conserva ese modelo sin cambios sustanciales.

Toda evaluación tiene dos componentes que conviene separar mentalmente: el cálculo del valor, que produce el resultado, y el efecto lateral, que modifica un objeto, un fichero o el entorno. Dos evaluaciones pueden estar secuenciadas una antes de otra, secuenciadas de forma indeterminada —ocurren en algún orden, pero sin intercalarse— o simplemente no secuenciadas, que es el caso peligroso, porque el compilador puede intercalar sus pasos libremente.

De ahí sale la regla que lo gobierna todo. Si sobre un mismo objeto escalar hay dos efectos laterales no secuenciados entre sí, o hay un efecto lateral no secuenciado respecto a un cálculo de valor que usa ese mismo objeto, el comportamiento es indefinido.

⚙️

Fin de la expresión completa

El punto y coma, la condición de un if o un while, el inicializador completo. Todo lo anterior está secuenciado antes de lo siguiente. Es la frontera más fiable.

🔀

Los cortocircuitos y la coma

&&, || y el operador coma garantizan que el operando izquierdo se evalúa completo, efectos laterales incluidos, antes de tocar el derecho. Son islas de orden dentro del caos.

El condicional ternario

En c ? a : b, la condición está secuenciada antes que la rama elegida, y la rama no elegida no se evalúa en absoluto. Mismo contrato que el cortocircuito.

📞

Antes de la llamada

Los argumentos y el designador de función se evalúan completos antes de entrar en la función. Pero el orden entre los argumentos no está especificado.

Los ejemplos que son indefinidos

Estos patrones no son curiosidades académicas: aparecen en código real, compilan sin errores y producen resultados distintos según el compilador, la arquitectura y el nivel de optimización.

int i = 0, a[10];

i = i++;             /* UB: dos efectos laterales sobre i, no secuenciados */
i = ++i + 1;         /* UB: igual, la asignacion y el incremento compiten  */
a[i] = i++;          /* UB: el efecto sobre i no esta secuenciado respecto
                        al calculo del valor de i que indexa a             */
i = i++ + ++i;       /* UB clasico de examen, y triplemente mal            */
printf("%d %d", i++, i++);   /* UB: dos efectos sobre i entre argumentos   */
f(i, i++);           /* UB: el argumento que lee i no esta secuenciado
                        respecto al que lo modifica                        */

Todos comparten la misma estructura: el objeto i recibe una modificación cuyo momento no está fijado respecto a otro acceso al mismo objeto. Compara con las versiones correctas, que resultan estar bien por razones muy concretas del estándar:

i = i + 1;           /* correcto: un solo efecto lateral sobre i          */
i++;  i = a[i];      /* correcto: el punto y coma separa las expresiones  */
i = (i++, i);        /* correcto: la coma secuencia el izquierdo antes    */
a[i++] = 0;          /* correcto: un solo acceso y un solo efecto sobre i */
⚠️
Probarlo no lo valida

La tentación al encontrarse con i = i++ es compilarlo, ver que da cero en tu máquina y concluir que ya se sabe qué hace. Es exactamente el razonamiento equivocado. El comportamiento indefinido no significa que el resultado sea uno de varios valores plausibles: significa que el estándar no impone ninguna restricción, y que el optimizador tiene permiso explícito para asumir que ese código jamás se ejecuta. Un compilador puede eliminar la rama entera, colapsar el bucle que la contiene o generar código que solo falla cuando el registro asignado cambia por una modificación tres funciones más arriba. Un programa que hoy da cero puede dar otra cosa mañana al subir una versión menor del compilador, sin que nadie haya tocado esa línea.

Indefinido frente a no especificado

Esta distinción es la que separa una lectura superficial del estándar de una lectura profesional, y tiene consecuencias prácticas directas sobre qué código es aceptable y cuál no.

Comportamiento indefinido significa que el estándar retira todas las garantías sobre el programa entero, no solo sobre esa expresión. Comportamiento no especificado significa que hay un conjunto finito de posibilidades válidas, el compilador elige una y no está obligado a documentarla ni a ser consistente, pero el programa sigue siendo un programa correcto en el resto de sus aspectos.

/* NO ESPECIFICADO: el orden de las llamadas no esta fijado,
   pero no se intercalan y el programa es valido            */
int total = leer_sensor() + leer_reloj();

/* INDEFINIDO: dos efectos laterales sobre el mismo objeto
   sin secuenciacion entre ellos                            */
int x = contador++ + contador++;

En el primer caso, si ambas funciones tienen efectos laterales sobre un estado global compartido, el resultado depende del orden elegido, y ese orden es asunto del compilador. El programa no es incorrecto en sentido formal, pero sí es no determinista, lo que en un sistema que debe ser reproducible equivale a un defecto. En el segundo caso el programa no tiene ningún significado y cualquier cosa puede ocurrir.

flowchart TD
A[Dos accesos al mismo objeto escalar en una expresion] --> B[Hay al menos un efecto lateral]
B --> C[Estan secuenciados por punto y coma coma cortocircuito o ternario]
B --> D[No estan secuenciados entre si]
C --> E[Comportamiento definido y determinista]
D --> F[Comportamiento indefinido y el optimizador puede asumir que no ocurre]
style E fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b

Merece la pena señalar un contraste histórico. C++17 endureció sus reglas: fijó el orden de los operandos de << y >>, del subíndice y de la asignación, y dejó el orden de los argumentos de una llamada como no especificado pero sin intercalación. C no ha adoptado esos cambios, ni en C17 ni en C23. Código que un compilador de C++ moderno acepta con semántica definida sigue siendo indefinido al compilarlo como C, y esa asimetría atrapa con regularidad a quien alterna entre ambos lenguajes.

Cómo escribir código inmune

La defensa no consiste en memorizar la lista de patrones prohibidos, sino en adoptar tres hábitos que hacen imposible construirlos.

El primero es la regla del efecto único: como máximo un efecto lateral por expresión completa. Si una expresión contiene un ++, un -- o una asignación, que sea el único, y que el objeto modificado no aparezca en ningún otro lugar de esa misma expresión. Es una regla mecánica, verificable de un vistazo en revisión de código.

El segundo es separar en sentencias. Una línea que hace dos cosas se convierte en dos líneas que hacen una cada una, y el punto y coma introduce la secuenciación que el estándar sí garantiza. El coste en legibilidad es nulo o negativo, y el coste en rendimiento es exactamente cero: el compilador genera el mismo código, porque la reordenación que tú te prohíbes escribir él la sigue teniendo permitida internamente mientras el resultado observable no cambie.

/* Antes: densidad que oculta la indefinicion */
buffer[indice] = indice++;

/* Despues: dos sentencias, orden garantizado, intencion evidente */
buffer[indice] = indice;
indice++;

El tercero es activar los avisos. GCC implementa -Wsequence-point, incluido en -Wall; Clang implementa -Wunsequenced, más agresivo y con mejor cobertura de casos con punteros. Ninguno detecta todos los casos —el problema es indecidible en general, sobre todo a través de punteros o llamadas—, pero cazan la práctica totalidad de los patrones escritos a mano.

Queda un cuarto frente que los avisos no cubren: las macros. Una macro que use su parámetro dos veces evalúa dos veces la expresión que le pases, y si esa expresión tiene un efecto lateral el resultado deja de ser el que el autor de la macro imaginó.

#define MAX(a, b)  ((a) > (b) ? (a) : (b))

int m = MAX(i++, limite);   /* i puede incrementarse una o dos veces */

Aquí no hay comportamiento indefinido —el ternario secuencia correctamente— pero sí un número de incrementos que depende de qué rama gane, lo cual es igual de indeseable. La defensa clásica es evaluar cada parámetro una sola vez en una variable temporal usando typeof, que C23 estandariza precisamente para esto, y la defensa mejor es sustituir la macro por una función static inline, donde los argumentos se evalúan exactamente una vez por definición del lenguaje.

💡
Una lista de comprobación de tres preguntas

En revisión de código, ante cualquier expresión densa, basta con hacerse tres preguntas en orden. Primera: ¿hay más de un ++, -- o asignación en esta expresión completa? Segunda: ¿el objeto que se modifica aparece en algún otro lugar de la misma expresión? Tercera: ¿hay una llamada a función con efectos laterales junto a otro acceso al mismo estado? Si alguna respuesta es sí y no hay de por medio un punto y coma, una coma, un cortocircuito o un ternario, la expresión es sospechosa y debe partirse. Es un procedimiento mecánico que no exige recordar el modelo formal y que atrapa todos los patrones de esta lección.

El orden de evaluación es libertad del compilador, y esa libertad es el motivo de que C sea rápido

Aquí hay una inversión de perspectiva que casi nadie hace y que explica todo lo demás. La pregunta natural es por qué el estándar no fija simplemente el orden de izquierda a derecha y acaba con el problema. La respuesta es que ese orden no especificado no es un descuido: es el precio de la velocidad. Al no obligar a un orden concreto, C permite al compilador reordenar la evaluación para ajustarse a lo que la máquina real prefiere: cargar primero el operando que ya está en un registro, agrupar los accesos a memoria que caen en la misma línea de caché, rellenar los huecos de una unidad de ejecución superescalar, evitar un desbordamiento de registros que forzaría un volcado a pila. En arquitecturas con muchos registros y ejecución fuera de orden, esa libertad vale un porcentaje real de rendimiento en los caminos calientes. C hizo aquí el mismo trato que hace en todas partes: te da el rendimiento del hardware a cambio de que tú asumas la responsabilidad de no depender de lo que no se garantiza. Y observa la elegancia de la contrapartida: los puntos donde el estándar sí fija el orden —el punto y coma, los cortocircuitos, la coma, el ternario— son exactamente aquellos donde el programador necesita el orden para expresar lógica, y donde fijarlo apenas cuesta optimización. No es una tabla arbitraria de excepciones: es una frontera negociada con precisión entre lo que el humano necesita garantizado y lo que la máquina necesita libre. Entender esa frontera es entender por qué C sigue siendo el lenguaje de los sistemas cincuenta años después.

⚔️ Rompe y repara la secuenciación
  1. Compila i = i++; con GCC y con Clang, en -O0 y en -O2, y documenta los cuatro resultados junto con los avisos emitidos.
  2. Escribe printf con dos argumentos que incrementen la misma variable y comprueba el orden real de evaluación de argumentos en tu plataforma.
  3. Explica con la definición de secuenciado antes por qué a[i++] = 0; es correcto pero a[i] = i++; no lo es.
  4. Construye un caso de comportamiento no especificado con dos funciones con efectos laterales y demuestra que el resultado cambia según el compilador.
  5. Reescribe las cinco expresiones indefinidas de esta lección aplicando la regla del efecto único y verifica que los avisos desaparecen.