← Programación en C para Sistemas Operativos Bloque 2 · Tipos, operadores y control · Tema 7 de 42

Tema 7

Operadores y expresiones

Sumar, restar, comparar: los operadores de C se parecen a los de cualquier lenguaje, y la mayor parte del tiempo hacen lo que uno espera. Este tema trata de los casos en que no. En C, 7 / 2 vale 3, -1 < 1u es falso, multiplicar dos números de 32 bits y guardar el resultado en uno de 64 puede perder datos, y una comprobación de desbordamiento correcta en apariencia puede desaparecer al optimizar. Todos esos casos tienen una explicación precisa, en las reglas de conversión, de precedencia y de orden de evaluación del lenguaje, y conocerlas es lo que separa a un programa que funciona de uno que funciona casi siempre.

1. Expresiones

Una expresión es una combinación de valores, variables, llamadas a funciones y operadores que produce un resultado. a + b * 2 es una expresión, pero también lo son a, 42, printf("hola") o x = 5. En C, toda expresión tiene dos cosas:

Algunas expresiones, además, tienen un efecto secundario: cambian algo además de producir un valor. La asignación cambia una variable; printf escribe en la salida. Una sentencia simple de C es casi siempre eso: una expresión seguida de ;, que se evalúa por su efecto y cuyo valor se descarta. x = 5; es una expresión de asignación, que vale 5, convertida en sentencia.

2. Los operadores aritméticos

OperadorOperaciónEjemploObservaciones
+Suma7 + 2 → 9Puede desbordar.
-Resta, y negación con un operando7 - 2 → 5, -x-INT_MIN desborda.
*Multiplicación7 * 2 → 14Desborda con facilidad.
/División7 / 2 → 3, 7.0 / 2 → 3,5Entre enteros, descarta los decimales.
%Resto de la división entera7 % 2 → 1Solo con enteros.

No hay operador de potencia: 2 ^ 3 compila, pero ^ es el «o exclusivo» de bits del tema 8 y vale 1. Para potencias de números reales está pow, de math.h; para potencias de 2 enteras, el desplazamiento 1 << n, también del tema 8. Y hay un detalle que separa a C de Bash: en $(( )) todas las cuentas eran con enteros de 64 bits, mientras que en C cada operación se hace en el tipo que resulta de sus operandos, como veremos en las secciones 8 y 9.

3. La división entera

Cuando los dos operandos son enteros, / hace una división entera: el resultado es entero y los decimales se descartan. Desde C99, el descarte es siempre hacia cero, también con negativos, y el resto conserva el signo del dividendo:

division.c
#include <stdio.h>

int main(void)
{
    printf(" 7 / 2 = %2d    7 %% 2 = %2d\n", 7 / 2, 7 % 2);
    printf("-7 / 2 = %2d   -7 %% 2 = %2d\n", -7 / 2, -7 % 2);
    printf(" 7 / -2 = %d    7 %% -2 = %d\n", 7 / -2, 7 % -2);
    printf(" 7 / 2.0 = %.1f\n", 7 / 2.0);
    printf(" 1 / 3 * 3 = %d,  1 * 3 / 3 = %d\n", 1 / 3 * 3, 1 * 3 / 3);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o division division.c && ./division
 7 / 2 =  3    7 % 2 =  1
-7 / 2 = -3   -7 % 2 = -1
 7 / -2 = -3    7 % -2 = 1
 7 / 2.0 = 3.5
 1 / 3 * 3 = 0,  1 * 3 / 3 = 1

Tres consecuencias prácticas. Primera: -7 / 2 es −3 y no −4, porque se trunca hacia cero, y -7 % 2 es −1. Comprobar si un número es impar con n % 2 == 1 falla con los negativos; lo correcto es n % 2 != 0. Segunda: basta con que un operando sea de punto flotante para que la división lo sea, como en 7 / 2.0. Tercera: el orden de las operaciones importa, porque cada división entera pierde información en el momento: 1 / 3 * 3 vale 0, mientras que 1 * 3 / 3 vale 1. Al calcular porcentajes con enteros, se multiplica primero y se divide al final: parte * 100 / total.

Dividir un entero por cero, en cambio, no es un detalle: es comportamiento indefinido, y en Linux sobre x86-64 el procesador lo detecta y el núcleo termina el proceso con una señal:

terminal · porcero.c: divide 100 por el número que recibe, o por 0
$ ./porcero 4
dividiendo 100 entre 4
resultado: 25
$ ./porcero
dividiendo 100 entre 0
Floating point exception
$ echo $?
136
$ kill -l 8
FPE

El nombre del mensaje, «excepción de punto flotante», es engañoso: la división fue entera. Es el nombre histórico de la señal SIGFPE, la número 8, que el núcleo envía ante un error aritmético. El código de salida lo confirma: 136 es 128 + 8, la convención de la shell para «terminado por la señal 8» que mencionamos en el tema 3. Hay un segundo caso que produce la misma señal y que pocos recuerdan: INT_MIN / -1, cuyo resultado, 2147483648, no cabe en un int. Con números de punto flotante, en cambio, dividir por cero no detiene nada: da infinito, como vimos en el tema 5. La regla es comprobar el divisor antes de dividir, cuando venga de datos externos.

4. Asignación, incremento y decremento

El operador = guarda un valor en una variable, y la expresión completa vale lo que se guardó. Por eso p = q = r = 0 funciona: se evalúa de derecha a izquierda, r = 0 vale 0, que se asigna a q, y así sigue. Cada operador aritmético, y cada operador de bits, tiene además una versión de asignación compuesta: total += 5 es total = total + 5.

Los operadores ++ y -- suman o restan 1, y tienen dos formas que se distinguen por el valor de la expresión:

incremento.c
#include <stdio.h>

int main(void)
{
    int a = 5, b = 5;
    int x = a++;                 /* usa 5, después a vale 6 */
    int y = ++b;                 /* b vale 6, después usa 6 */

    printf("x = %d, a = %d\n", x, a);
    printf("y = %d, b = %d\n", y, b);

    int total = 10;
    total += 5;                  /* total = total + 5 */
    total *= 2;                  /* total = total * 2 */
    total %= 7;                  /* total = total % 7 */
    printf("total = %d\n", total);

    int p, q, r;
    p = q = r = 0;               /* la asignación también es una expresión */
    printf("p = %d, q = %d, r = %d\n", p, q, r);
    return 0;
}
terminal
$ ./incremento
x = 5, a = 6
y = 6, b = 6
total = 2
p = 0, q = 0, r = 0

El postincremento a++ vale el valor anterior de a, y el preincremento ++b vale el nuevo. Las dos formas incrementan la variable; solo difieren en lo que entregan a la expresión que las rodea. Cuando se usan solas, como en el i++ de un for, son equivalentes. La recomendación es no usarlas dentro de expresiones más grandes salvo en modismos muy conocidos, porque es fácil equivocarse, y porque usar la misma variable dos veces en una expresión que la modifica es comportamiento indefinido, como veremos en la sección 13.

Recuerda también el error del tema 4: como la asignación es una expresión con valor, if (x = 0) compila, asigna 0 y nunca entra. -Wall lo advierte.

5. Comparaciones y operadores lógicos

Los operadores relacionales, <, <=, >, >=, == y !=, comparan dos valores y producen un int: 1 si la comparación es cierta, 0 si no. Los operadores lógicos combinan condiciones: && (y), || (o) y ! (no). Para ellos, cualquier valor distinto de cero es verdadero, y también devuelven 0 o 1. Pero && y || tienen una propiedad fundamental, la evaluación en cortocircuito: evalúan el operando de la izquierda primero, y si con él ya se conoce el resultado, el de la derecha no se evalúa.

logicos.c
#include <stdio.h>

static int comprobar(const char *nombre, int resultado)
{
    printf("  se evaluó %s\n", nombre);
    return resultado;
}

int main(void)
{
    printf("5 > 3 vale %d, 5 < 3 vale %d, !7 vale %d\n", 5 > 3, 5 < 3, !7);

    printf("falso && ...:\n");
    if (comprobar("A", 0) && comprobar("B", 1))
        printf("  entró\n");

    printf("verdadero || ...:\n");
    if (comprobar("C", 1) || comprobar("D", 1))
        printf("  entró\n");

    int divisor = 0;
    if (divisor != 0 && 100 / divisor > 5)
        printf("cociente grande\n");
    else
        printf("división evitada\n");
    return 0;
}
terminal
$ ./logicos
5 > 3 vale 1, 5 < 3 vale 0, !7 vale 0
falso && ...:
  se evaluó A
verdadero || ...:
  se evaluó C
  entró
división evitada

B y D nunca se evaluaron: si el primer operando de && es falso, el resultado es falso sin mirar el segundo; si el primero de || es verdadero, el resultado es verdadero. El último if muestra por qué esto importa: divisor != 0 && 100 / divisor > 5 nunca divide por cero, porque la división solo se evalúa si la primera condición se cumplió. Es el mismo principio de && y || en Bash, y el patrón que usaremos sin parar con los punteros: p != NULL && p->valor > 0.

No hay que confundir && con &, ni || con |. Los simples son operadores de bits, del tema 8: operan bit a bit, no hacen cortocircuito, y 1 & 2 vale 0 aunque los dos sean «verdaderos».

6. El operador condicional y la coma

El operador condicional condición ? a : b es el único de C con tres operandos: vale a si la condición es verdadera y b si no, y solo evalúa uno de los dos. Es útil para elegir un valor dentro de una expresión, como un plural:

terminal · tern.c: el operador condicional y la coma
$ cat tern.c
#include <stdio.h>
int main(void)
{
    int n = 3;
    printf("%d archivo%s\n", n, n == 1 ? "" : "s");
    int a = (n++, n * 2);
    printf("%d %d\n", n, a);
    return 0;
}
$ ./tern
3 archivos
4 8

El operador coma evalúa su operando izquierdo, descarta el valor, y luego evalúa el derecho, que es el valor de toda la expresión: (n++, n * 2) incrementa n y vale 8. Su único uso habitual es en un for con dos variables, como for (i = 0, j = n - 1; i < j; i++, j--). No hay que confundirlo con las comas que separan argumentos de una función o variables de una declaración, que no son operadores.

7. El desbordamiento

Ya vimos dos veces que el desbordamiento de un entero con signo es comportamiento indefinido: en el tema 1 con INT_MAX + 1, y en el tema 4 al hablar de la optimización. Ahora podemos ver el caso que hace famosa a esta regla. Esta función intenta comprobar si a x se le puede sumar 1 sin desbordar, con una idea que parece razonable: si la suma da la vuelta, el resultado será menor que x.

desborde.c
#include <stdio.h>
#include <limits.h>

/* ¿cabe x + 1 en un int? (una comprobación incorrecta) */
int cabe_uno_mas(int x)
{
    int siguiente = x + 1;

    return siguiente > x;
}

int main(void)
{
    printf("cabe_uno_mas(5)       = %d\n", cabe_uno_mas(5));
    printf("cabe_uno_mas(INT_MAX) = %d\n", cabe_uno_mas(INT_MAX));
    return 0;
}
terminal · el mismo programa, sin optimizar y con -O2
$ gcc -std=c17 -Wall -Wextra -O0 -o desborde desborde.c && ./desborde
cabe_uno_mas(5)       = 1
cabe_uno_mas(INT_MAX) = 0
$ gcc -std=c17 -Wall -Wextra -O2 -o desborde desborde.c && ./desborde
cabe_uno_mas(5)       = 1
cabe_uno_mas(INT_MAX) = 1
$ gcc -std=c17 -O2 -S -fno-asynchronous-unwind-tables -o - desborde.c | sed -n '/^cabe_uno_mas:/,/ret/p'
cabe_uno_mas:
	endbr64
	movl	$1, %eax
	ret

Sin optimizar, la comprobación «funciona»: la suma da la vuelta a un número negativo, y la función responde 0. Con -O2, responde 1, y el ensamblador muestra por qué: la función entera se redujo a «devolver 1». El razonamiento del compilador es impecable desde las reglas del lenguaje: el desbordamiento con signo no puede ocurrir en un programa correcto, de modo que x + 1 es siempre mayor que x, y la comparación es siempre verdadera. Si se escribe directamente return x + 1 > x;, sin la variable intermedia, gcc la simplifica incluso con -O0. No hay ninguna advertencia. Es la razón por la que errores de este tipo han producido vulnerabilidades reales: la comprobación de seguridad estaba en el fuente, pero no en el ejecutable.

Hay varias herramientas para tratar con esto, y conviene conocerlas todas:

HerramientaQué haceCon desborde.c
-fsanitize=undefinedDetecta el desbordamiento al ejecutar y lo informa.runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
-ftrapvTermina el programa si una operación con signo desborda.Aborted, código de salida 134.
-fwrapvCambia el lenguaje: define que el desbordamiento con signo da la vuelta, como el sin signo.Devuelve 0 también con -O2. El núcleo Linux usa -fno-strict-overflow, que en gcc equivale a esta opción.
Comprobar antes de operarComparar con los límites sin desbordar: if (x > INT_MAX - 1).La solución portable, válida en cualquier versión de C.
ckd_add, ckd_sub, ckd_mulC23, en stdckdint.h: operan y avisan si hubo desbordamiento.La solución moderna, sin trucos.

Las dos últimas son las que se usan para escribir código correcto. Este programa muestra ambas:

seguro.c
#include <stdio.h>
#include <limits.h>
#include <stdckdint.h>

int main(void)
{
    int a = INT_MAX - 5;
    int resultado;

    /* forma 1: comprobar antes de operar */
    if (a > INT_MAX - 10)
        printf("a + 10 se desbordaría\n");

    /* forma 2: C23, ckd_add devuelve true si hubo desbordamiento */
    if (ckd_add(&resultado, a, 10))
        printf("ckd_add: desbordamiento, resultado truncado = %d\n", resultado);
    if (!ckd_add(&resultado, a, 3))
        printf("ckd_add: a + 3 = %d\n", resultado);
    return 0;
}
terminal · compilado como C23
$ gcc -std=c23 -Wall -Wextra -o seguro seguro.c && ./seguro
a + 10 se desbordaría
ckd_add: desbordamiento, resultado truncado = -2147483644
ckd_add: a + 3 = 2147483645

La comprobación previa a > INT_MAX - 10 no puede desbordar, porque INT_MAX - 10 es un valor válido; es la forma algebraicamente equivalente de «a + 10 > INT_MAX», reordenada para que ninguna operación se salga del rango. ckd_add recibe la dirección donde guardar el resultado, que en caso de desbordamiento contiene el valor «dado la vuelta», y devuelve verdadero si hubo desbordamiento. En compiladores anteriores a C23, gcc y clang ofrecen lo mismo como __builtin_add_overflow.

8. La promoción entera

Los procesadores no suman bytes sueltos: trabajan con registros de al menos 32 bits. C refleja eso con una regla que se aplica antes de casi cualquier operación: la promoción entera. Todo operando de un tipo más chico que int, como char, short o bool, se convierte primero a int.

terminal · cp.c y la primera línea de conversiones.c
$ ./cp                                 # sizeof c y sizeof(c + 0), con char c
1 4
$ ./conversiones | head -n 1         # unsigned char a = 200, b = 100
a + b = 300, y sizeof(a + b) = 4

Un char ocupa 1 byte, pero c + 0 ya es un int, de 4. Y a + b, con dos unsigned char de 200 y 100, vale 300, aunque 300 no cabe en un byte: la suma se hizo en int. El desbordamiento solo aparecería al guardar el resultado de nuevo en un unsigned char, que lo dejaría en 44. La promoción es casi siempre una ventaja, porque evita desbordamientos en los cálculos intermedios, pero tiene un efecto sorprendente con los operadores de bits, que veremos en el tema 8: ~ aplicado a un unsigned char de valor 0 da −1, un int con todos los bits encendidos, y no 255.

9. Las conversiones aritméticas habituales

Cuando un operador recibe dos operandos de tipos distintos, C los lleva a un tipo común antes de operar, siguiendo las conversiones aritméticas habituales. Simplificadas, son estas, en orden:

  1. Si uno es de punto flotante, el otro se convierte a ese tipo, y gana el más grande: long double, luego double, luego float. Así, 7 / 2.0 es una división de double. flotante
  2. Si no, se aplica la promoción entera a los dos: los tipos chicos pasan a int. promoción
  3. Si los dos tienen el mismo signo, el de menor rango se convierte al de mayor: int con long da long. mismo signo
  4. Si uno tiene signo y el otro no, y el sin signo es de rango igual o mayor, el que tiene signo se convierte a sin signo. Si el tipo con signo es más grande y puede representar todos los valores del otro, gana el con signo. mezcla de signos

La cuarta regla es la que produce las sorpresas. -1 < 1u compara un int con un unsigned int, del mismo rango, de modo que −1 se convierte a sin signo: 4294967295, como vimos en el tema 5. Y la combinación más común de todas en la programación de sistemas es un entero con un size_t, sin signo y de 8 bytes. Este programa reúne varios casos:

conversiones.c
#include <stdio.h>
#include <stdint.h>
#include <string.h>

int main(void)
{
    unsigned char a = 200, b = 100;
    printf("a + b = %d, y sizeof(a + b) = %zu\n", a + b, sizeof(a + b));

    int votos = 7, total = 2;
    double promedio = votos / total;
    printf("promedio = %.2f\n", promedio);
    printf("promedio = %.2f\n", (double) votos / total);

    uint32_t ancho = 100000, alto = 100000;
    uint64_t pixeles = ancho * alto;
    printf("pixeles = %llu\n", (unsigned long long) pixeles);
    pixeles = (uint64_t) ancho * alto;
    printf("pixeles = %llu\n", (unsigned long long) pixeles);

    const char *vacia = "";
    printf("strlen(vacia) - 1 = %zu\n", strlen(vacia) - 1);

    int negativo = -1;
    unsigned int uno = 1;
    printf("-1 < 1u es %s\n", negativo < uno ? "verdadero" : "falso");
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o conversiones conversiones.c
conversiones.c: In function ‘main’:
conversiones.c:26:40: warning: comparison of integer expressions of different signedness: ‘int’ and ‘unsigned int’ [-Wsign-compare]
…
$ ./conversiones
a + b = 300, y sizeof(a + b) = 4
promedio = 3.00
promedio = 3.50
pixeles = 1410065408
pixeles = 10000000000
strlen(vacia) - 1 = 18446744073709551615
-1 < 1u es falso

Cada línea es un error clásico:

El mismo problema de size_t aparece en un bucle que cuenta hacia atrás. El compilador, por suerte, lo advierte:

terminal · bucle.c: for (size_t i = 2; i >= 0; i--)
$ gcc -std=c17 -Wall -Wextra -o bucle bucle.c
bucle.c: In function ‘main’:
bucle.c:6:26: warning: comparison of unsigned expression in ‘>= 0’ is always true [-Wtype-limits]
    6 |     for (size_t i = 2; i >= 0; i--)
      |                          ^~

Un size_t siempre es mayor o igual que cero: cuando i vale 0 y se decrementa, da la vuelta a un número enorme, y el bucle sigue, ahora leyendo fuera del arreglo. La forma correcta de recorrer hacia atrás con un índice sin signo es for (size_t i = n; i > 0; i--), usando datos[i - 1] dentro.

10. Conversiones al asignar

Al asignar un valor a una variable de otro tipo, C lo convierte al tipo del destino, sin preguntar. Si el valor cabe, no hay problema; si no cabe, depende de los tipos:

asignar.c
#include <stdio.h>

int main(void)
{
    int grande = 70000;
    short corto = grande;              /* no cabe en 16 bits */
    unsigned char byte = -1;           /* negativo en un sin signo */
    int truncado = 3.99;               /* de double a int */
    int negativo = -3.99;

    printf("corto = %d, byte = %d, truncado = %d, negativo = %d\n",
           corto, byte, truncado, negativo);
    return 0;
}
terminal · sin advertencias con -Wall -Wextra; con -Wconversion, cuatro
$ gcc -std=c17 -Wall -Wextra -o asignar asignar.c && ./asignar
corto = 4464, byte = 255, truncado = 3, negativo = -3
$ gcc -std=c17 -Wall -Wextra -Wconversion -o asignar asignar.c 2>&1 | grep warning
asignar.c:6:19: warning: conversion from ‘int’ to ‘short int’ may change value [-Wconversion]
asignar.c:7:26: warning: unsigned conversion from ‘int’ to ‘unsigned char’ changes value from ‘-1’ to ‘255’ [-Wsign-conversion]
asignar.c:8:20: warning: conversion from ‘double’ to ‘int’ changes value from ‘3.9900000000000002e+0’ to ‘3’ [-Wfloat-conversion]
asignar.c:9:20: warning: conversion from ‘double’ to ‘int’ changes value from ‘-3.9900000000000002e+0’ to ‘-3’ [-Wfloat-conversion]
ConversiónQué pasa si no cabeEn asignar.c
A un entero sin signoDefinido: se toma el valor módulo 2n, es decir, se conservan los bits bajos.−1 → 255.
A un entero con signoDefinido por la implementación; en gcc, se conservan los bits bajos.70000 → 4464 (70000 − 65536).
De punto flotante a enteroSe descarta la parte decimal, hacia cero. Si la parte entera no cabe, es comportamiento indefinido.3,99 → 3; −3,99 → −3.
De entero a punto flotantePuede perder precisión si el entero es muy grande.Como 16777217 en un float, en el tema 5.

Estas conversiones silenciosas son la razón de ser de -Wconversion, que vimos en el tema 4. Con código nuevo vale la pena usarla, y resolver cada advertencia con una conversión explícita cuando la pérdida es intencional. Fíjate en que el redondeo no es automático: 3,99 se convierte en 3, no en 4. Para redondear está round, de math.h.

11. Conversiones explícitas

Una conversión explícita, o cast, se escribe con el tipo destino entre paréntesis delante de la expresión: (double) votos. Hace la misma conversión que haría una asignación, pero en el lugar exacto donde se la pone, y deja claro que es intencional. Ya la usamos varias veces: (void *) con %p en el tema 1, (intmax_t) con los tipos del sistema en el tema 5, y en conversiones.c para arreglar los dos errores:

Una conversión explícita no comprueba nada: le dice al compilador que el programador sabe lo que hace. Si el valor no cabe, el resultado es el mismo que en la sección anterior, y con punto flotante puede ser comportamiento indefinido:

terminal · dbl.c: (int) 3e10, sin y con el sanitizador
$ ./dbl
-2147483648
$ gcc -std=c17 -Wall -Wextra -fsanitize=float-cast-overflow -o dbl dbl.c && ./dbl
dbl.c:5:5: runtime error: 3e+10 is outside the range of representable values of type 'int'
-2147483648

Por eso los casts deben ser pocos y estar justificados. Muchos casts en un programa suelen indicar que los tipos de las variables están mal elegidos; y uno que «arregla» una advertencia sin entenderla puede esconder un error real.

12. Precedencia y asociatividad

Cuando una expresión tiene varios operadores, la precedencia decide cuáles se aplican primero, y la asociatividad, el orden entre operadores de la misma precedencia. C tiene quince niveles:

NivelOperadoresAsociatividad
1() llamada, [], ->, ., x++, x--izquierda a derecha
2++x, --x, +x, -x, !, ~, (tipo), *p, &x, sizeofderecha a izquierda
3*, /, %izquierda a derecha
4+, -izquierda a derecha
5<<, >>izquierda a derecha
6<, <=, >, >=izquierda a derecha
7==, !=izquierda a derecha
8&izquierda a derecha
9^izquierda a derecha
10|izquierda a derecha
11&&izquierda a derecha
12||izquierda a derecha
13?:derecha a izquierda
14=, +=, -=, *=, /=, %=, <<=, >>=, &=, ^=, |=derecha a izquierda
15,izquierda a derecha

La mayor parte de la tabla coincide con la intuición matemática: multiplicar antes que sumar, comparar antes de combinar con &&, asignar al final. La asociatividad de izquierda a derecha hace que 20 - 5 - 3 sea (20 - 5) - 3, 12; la de derecha a izquierda de la asignación hace funcionar p = q = r = 0. Pero hay dos lugares donde la tabla traiciona:

precedencia.c
#include <stdio.h>

int main(void)
{
    int a = 2, b = 3, c = 4;
    int permisos = 6;

    printf("a + b * c       = %d\n", a + b * c);
    printf("(a + b) * c     = %d\n", (a + b) * c);
    printf("20 - 5 - 3      = %d\n", 20 - 5 - 3);
    printf("1 << 2 + 1      = %d\n", 1 << 2 + 1);
    if (permisos & 4 == 4)
        printf("tiene lectura\n");
    else
        printf("no tiene lectura (?)\n");
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o precedencia precedencia.c
precedencia.c: In function ‘main’:
precedencia.c:11:40: warning: suggest parentheses around ‘+’ inside ‘<<’ [-Wparentheses]
   11 |     printf("1 << 2 + 1      = %d\n", 1 << 2 + 1);
      |                                        ^~
precedencia.c:12:18: warning: suggest parentheses around comparison in operand of ‘&’ [-Wparentheses]
   12 |     if (permisos & 4 == 4)
      |                  ^
$ ./precedencia
a + b * c       = 14
(a + b) * c     = 20
20 - 5 - 3      = 12
1 << 2 + 1      = 8
no tiene lectura (?)
Dos árboles de expresión. A la izquierda, a + b * c: la suma es la raíz y la multiplicación su hijo derecho, de modo que primero se calcula b * c. A la derecha, permisos & 4 == 4: el & es la raíz y la comparación 4 == 4 su hijo derecho, de modo que primero se compara 4 con 4, que da 1, y luego se calcula permisos & 1 + a * b c & permisos == 4 4 2 + (3 * 4) = 14 6 & (4 == 4) = 6 & 1 = 0
La precedencia decide la forma del árbol: los operadores de más abajo se calculan primero. A la derecha, el árbol que construye C no es el que el programador tenía en mente.

1 << 2 + 1 vale 8 y no 5: la suma tiene más precedencia que el desplazamiento, y la expresión es 1 << 3. Y permisos & 4 == 4 no comprueba si el bit de lectura está encendido: == tiene más precedencia que &, de modo que primero se compara 4 == 4, que da 1, y luego se calcula 6 & 1, que da 0. Es un defecto reconocido del diseño de C, heredado de cuando & también se usaba como «y» lógico, y es exactamente el tipo de comprobación de permisos o de banderas que escribiremos en el tema 8. Lo correcto es (permisos & 4) != 0. -Wall advierte de los dos casos.

Nadie recuerda la tabla entera, y no hace falta. La regla práctica es memorizar lo básico, aritmética antes que comparaciones, comparaciones antes que && y ||, asignación al final, y poner paréntesis en todo lo demás, en especial cuando aparecen operadores de bits o desplazamientos. Los paréntesis no cuestan nada y le ahorran al lector consultar la tabla.

13. El orden de evaluación

La precedencia dice cómo se agrupan los operandos, pero no en qué orden se calculan. En f() + g(), la precedencia dice que se suman los dos resultados; el estándar no dice cuál de las dos funciones se llama primero. Es no especificado: el compilador elige, y puede elegir distinto en cada caso.

orden.c
#include <stdio.h>

static int f(const char *nombre, int valor)
{
    printf("[%s]", nombre);
    return valor;
}

int main(void)
{
    int i = 1;

    int suma = f("izquierda", 1) + f("derecha", 2);
    printf("  suma = %d\n", suma);

    i = i++ + 1;
    printf("i = %d\n", i);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o orden orden.c
orden.c: In function ‘main’:
orden.c:16:7: warning: operation on ‘i’ may be undefined [-Wsequence-point]
   16 |     i = i++ + 1;
      |     ~~^~~~~~~~~
$ ./orden
[izquierda][derecha]  suma = 3
i = 2

Aquí gcc llamó primero a la función de la izquierda, pero un programa que dependa de eso no es correcto. La suma no cambia, pero el orden de los mensajes sí: si las funciones tuvieran efectos que interactúan, como leer de un archivo, el resultado dependería del compilador. Lo mismo vale para los argumentos de una función: en printf("%d %d", f(), g()), el orden de las llamadas no está definido.

La segunda línea es más grave. i = i++ + 1 modifica i dos veces, con la asignación y con el ++, sin que el estándar ordene esas dos modificaciones entre sí. Eso no es solo no especificado: es comportamiento indefinido, y -Wall lo advierte con -Wsequence-point. El resultado, 2, es uno entre varios posibles. La regla para no caer nunca en esto es simple: en una misma expresión, si una variable se modifica, no se la usa ni se la modifica en ningún otro lugar.

Hay excepciones que el estándar sí ordena, y que justamente por eso son seguras: &&, ||, el operador coma y ?: evalúan siempre su operando izquierdo, o la condición, antes que el resto. Por eso divisor != 0 && 100 / divisor > 5 es correcto.

14. Errores frecuentes

ExpresiónQué pasaCorrección
double p = a / b; con enterosLa división es entera; se pierden los decimales.(double) a / b
parte / total * 100La división entera da 0 si parte < total.parte * 100 / total
n % 2 == 1 para imparesFalla con negativos: -3 % 2 es −1.n % 2 != 0
x / y con y que puede ser 0SIGFPE: el programa muere.Comprobar el divisor antes.
if (x + 1 > x) para detectar desbordamientoEl compilador la elimina: siempre es verdadera.if (x < INT_MAX), o ckd_add.
uint64_t t = a32 * b32;La multiplicación se hace en 32 bits y desborda.(uint64_t) a32 * b32
strlen(s) - 1Con la cadena vacía, da un número enorme.Comprobar strlen(s) > 0 antes.
for (size_t i = n - 1; i >= 0; i--)Bucle infinito: un size_t nunca es negativo.for (size_t i = n; i > 0; i--) y usar i - 1.
if (flags & MASK == MASK)== se evalúa antes que &.if ((flags & MASK) == MASK)
i = i++;, a[i] = i++;Comportamiento indefinido.Separar en dos sentencias.
2 ^ 10 como potencia^ es el o exclusivo: vale 8.1 << 10, o pow.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cuánto valen -7 / 2 y -7 % 2 en C, y por qué?

−3 y −1. Desde C99, la división entera trunca hacia cero, de modo que −3,5 se convierte en −3; y el resto cumple (a / b) * b + a % b == a, por lo que tiene el signo del dividendo: −7 − (−6) = −1.

2. ¿Por qué ./porcero termina con «Floating point exception» y código 136, si la división es entera?

Porque dividir un entero por cero hace que el procesador genere una excepción, y el núcleo la convierte en la señal SIGFPE, la número 8, que termina el proceso. El nombre es histórico. El código 136 es 128 + 8, la forma en que la shell indica que un proceso murió por una señal.

3. ¿Qué valores tienen x y a después de int a = 5; int x = a++;? ¿Y con ++a?

Con a++, x vale 5 y a vale 6: el postincremento entrega el valor anterior. Con ++a, los dos valen 6: el preincremento incrementa primero y entrega el valor nuevo.

4. ¿Por qué divisor != 0 && 100 / divisor > 5 nunca divide por cero?

Porque && evalúa en cortocircuito: si el operando izquierdo es falso, el resultado ya es falso y el derecho no se evalúa. Cuando divisor es 0, la división nunca se ejecuta. Además, el estándar garantiza que el operando izquierdo se evalúa primero.

5. En desborde.c, ¿por qué cabe_uno_mas(INT_MAX) devuelve 0 sin optimizar y 1 con -O2?

Sin optimizar, el procesador hace la suma, que da la vuelta a un número negativo, y la comparación da falso. Con -O2, el compilador razona que el desbordamiento con signo no puede ocurrir en un programa correcto, de modo que x + 1 siempre es mayor que x, y reemplaza la función por «devolver 1». Como el desbordamiento es comportamiento indefinido, las dos respuestas son igualmente «válidas».

6. ¿Cómo se comprueba correctamente si a + b desbordaría, siendo b positivo?

Comparando sin hacer la suma: if (a > INT_MAX - b), que no puede desbordar porque INT_MAX - b es un valor válido si b es positivo. En C23 se puede usar ckd_add(&resultado, a, b), que devuelve verdadero si hubo desbordamiento.

7. ¿Por qué a + b vale 300 si a y b son unsigned char con 200 y 100?

Por la promoción entera: antes de sumar, los dos operandos se convierten a int, y la suma se hace en int, donde 300 cabe sin problema. El resultado es un int de 4 bytes. Solo si se lo guarda en un unsigned char se pierde, y queda 44.

8. ¿Por qué double promedio = votos / total; da 3,00 con 7 y 2, y cómo se corrige?

Porque la expresión se calcula según el tipo de sus operandos, no el del destino: votos / total es una división entre int, que da 3, y recién ese 3 se convierte a double. Se corrige convirtiendo un operando antes de dividir: (double) votos / total.

9. ¿Qué tiene de malo uint64_t pixeles = ancho * alto; con ancho y alto de tipo uint32_t?

Que la multiplicación se hace en 32 bits, el tipo de los operandos, y si el resultado no cabe da la vuelta antes de guardarse en la variable de 64 bits. Con 100000 × 100000 se obtiene 1410065408 en lugar de 10000000000. Se corrige convirtiendo un operando: (uint64_t) ancho * alto.

10. ¿Por qué permisos & 4 == 4 da 0 con permisos igual a 6?

Porque == tiene más precedencia que &, y la expresión se agrupa como permisos & (4 == 4), es decir, 6 & 1, que vale 0. Lo correcto es (permisos & 4) != 0 o (permisos & 4) == 4.

11. ¿Qué diferencia hay entre «no especificado» y «comportamiento indefinido», con f() + g() e i = i++ + 1 como ejemplos?

En f() + g(), el orden en que se llaman las funciones no está especificado: el compilador elige uno de los órdenes posibles, y el programa sigue siendo válido, aunque no debe depender de ese orden. i = i++ + 1 modifica i dos veces sin un orden definido, y eso es comportamiento indefinido: el estándar no promete ningún resultado.

12. ¿Por qué for (size_t i = 2; i >= 0; i--) no termina nunca?

Porque size_t es sin signo y nunca es negativo: cuando i vale 0 y se decrementa, da la vuelta al mayor valor posible, y i >= 0 sigue siendo verdadero. -Wextra lo advierte: comparison of unsigned expression in ‘>= 0’ is always true.

Para practicar

  1. Escribe una función es_par(int n) correcta para negativos, y otra modulo(int a, int b) que devuelva siempre un resto entre 0 y b - 1, aunque a sea negativo. Pruébalas con −7, −1, 0 y 7.
  2. Modifica porcero.c para que compruebe el divisor y, si es 0, informe el error por stderr y termine con EXIT_FAILURE. Prueba también ./intmin con −1 y protégelo.
  3. Escribe suma_segura(int a, int b, int *resultado) que devuelva 0 si la suma desbordaría y 1 si no, sin usar ckd_add. Cubre también el caso de b negativo. Compárala con ckd_add compilando con -std=c23.
  4. Compila desborde.c con -O1, -O2 y -O2 -fwrapv, y mira el ensamblador de cabe_uno_mas en cada caso. ¿En qué nivel desaparece la suma?
  5. Calcula el tamaño en bytes de una imagen de 70000 × 70000 píxeles de 4 bytes cada uno, primero con int y después con size_t. Compila con -Wall -Wextra -Wconversion y anota qué advierte el compilador y qué no.
  6. Agrega paréntesis a precedencia.c hasta que compile sin advertencias y cada línea haga lo que su texto sugiere.
  7. Escribe un bucle que recorra un arreglo de 5 enteros de atrás hacia adelante usando un índice size_t, sin advertencias y sin salirse del arreglo.