Apuntes · Capítulo 5Programación en C

Ciclos

Ciclos

La semana pasada los programas aprendieron a elegir. Hoy aprenden a repetir. Ya vieron el problema varias veces: to_binary.c de la semana 3 repite dos líneas ocho veces, y adder4.c de la semana 4 repite la misma cuenta para cada columna. Además, ante un dato malo, nuestros programas solo sabían avisar y terminar, porque volver a pedirlo requiere repetir.

Un ciclo (loop) es una instrucción que repite un bloque. C tiene tres: while, for y do while. En este capítulo los vemos en ese orden, y aprendemos a razonar sobre ellos con tres preguntas: cómo empieza, cuándo para y qué avanza en cada vuelta. Luego vemos cuatro patrones que van a usar todo el curso: contador, acumulador, centinela y buscar el mayor. Al final leemos un archivo byte por byte, hasta que se acabe, con getchar.

Lectura. El código de este capítulo está en code/week-05. Dentro del entorno del curso, entren a esa carpeta y corran make para compilar los ejemplos y make test para comprobarlos. Los comandos de este capítulo se corren desde ahí. Los programas de errors/ tienen errores a propósito y se compilan a mano, como se muestra en cada caso.

Ruta de lectura.

  • Antes de clase (lectura obligatoria, 15 a 20 minutos): la introducción, “Repetir en pseudocódigo y en diagramas de flujo” y “while en C”. Es lo mínimo para seguir la clase.
  • En clase: “for”, “Salirse por uno”, “do while”, “Cuatro patrones” y “Leer hasta el fin de la entrada con getchar”.
  • Después de clase: para el laboratorio, “El laboratorio, paso a paso”; para repasar, “Razonar antes de ejecutar”, “Resumen” y “Ejercicios”.

Repetir en pseudocódigo y en diagramas de flujo

En la semana 1 escribieron mientras para contar monedas. Este es el algoritmo:

algoritmo monedas_de_cinco()
    leer vuelto
    monedas ← 0
    mientras vuelto ≥ 500 hacer
        vuelto ← vuelto - 500
        monedas ← monedas + 1
    fin mientras
    escribir monedas, vuelto
fin algoritmo

Recuerden la regla: se pregunta, y si la respuesta es sí, se ejecuta el bloque una vez y se vuelve a preguntar. Cada pasada por el bloque se llama vuelta (también se dice iteración). Con un vuelto de S/ 17.80, es decir, 1780 céntimos:

Vuelta ¿vuelto ≥ 500? vuelto al final de la vuelta monedas al final de la vuelta
antes de empezar 1780 0
1 sí 1280 1
2 sí 780 2
3 sí 280 3
no, y se sale 280 3

En el diagrama de flujo de la semana 1, mientras es un rombo con una flecha que regresa: después del último paso del bloque, la flecha sube otra vez hasta el rombo. La semana pasada vieron que un si abre dos caminos que más abajo se juntan. En un ciclo, en cambio, uno de los caminos vuelve atrás. Esa flecha hacia atrás es lo que hace que sea un ciclo.

Tres preguntas

Para entender cualquier ciclo, hagan estas tres preguntas.

Pregunta En monedas_de_cinco
Inicio: ¿qué valores tienen las variables antes de la primera pregunta? vuelto es el dato; monedas vale 0
Parada: ¿cuándo se deja de repetir? cuando vuelto es menor que 500
Avance: ¿qué cambia en cada vuelta para acercarse a la parada? vuelto baja 500

Si no pueden contestar la tercera, probablemente tienen un ciclo infinito (infinite loop), un ciclo que nunca termina.

Idea clave. Un ciclo se entiende con tres preguntas: inicio, parada y avance. Háganlas antes de ejecutar el programa, no después.

para: repetir con un contador

Muchas veces sabemos de antemano cuántas vueltas queremos. Para eso está para, que usaron en la semana 3. Esta es la suma de los enteros de 1 a n, primero con mientras y luego con para:

algoritmo suma_hasta_n()
    leer n
    suma ← 0
    i ← 1
    mientras i ≤ n hacer
        suma ← suma + i
        i ← i + 1
    fin mientras
    escribir suma
fin algoritmo
algoritmo suma_hasta_n_con_para()
    leer n
    suma ← 0
    para i desde 1 hasta n hacer
        suma ← suma + i
    fin para
    escribir suma
fin algoritmo

Hacen exactamente lo mismo. para se ocupa solo de dar el valor inicial a i y de aumentarlo en 1. Fíjense en que hasta incluye el último valor: para i desde 1 hasta n da una vuelta con i igual a n. Y si n es menor que 1, no da ninguna.

while en C

while es mientras. Las monedas, en coins.c:

while (change >= 500) {
    change = change - 500;
    coins = coins + 1;
}

La forma es while (condición) { ... }. Se evalúa la condición, y si es verdadera, se ejecuta el bloque y se vuelve a evaluar. Igual que en if, 0 es falso y cualquier otro número es verdadero. Y como en if, todo while lleva llaves en este curso.

$ echo 1780 | build/coins
3 monedas de S/ 5; quedan 280 céntimos
$ echo 300 | build/coins
0 monedas de S/ 5; quedan 300 céntimos

Con 300 la condición es falsa desde el inicio, así que el bloque no se ejecuta ni una vez. while puede dar cero vueltas, y eso casi siempre es lo correcto.

Un contador y un acumulador

Este es el algoritmo suma_hasta_n, en sum_to_n.c:

int sum = 0;
int i = 1;
while (i <= n) {
    sum = sum + i;
    i = i + 1;
}

Sigámoslo con n = 4.

Pregunta sum después de la vuelta i después de la vuelta
antes de empezar 0 1
¿1 <= 4? sí 1 2
¿2 <= 4? sí 3 3
¿3 <= 4? sí 6 4
¿4 <= 4? sí 10 5
¿5 <= 4? no, y se sale 10 5

Hubo 4 vueltas y 5 preguntas. La condición siempre se evalúa una vez más que las vueltas, porque la última pregunta es la que dice que no. Por eso, al salir, i vale 5 y no 4.

$ echo 4 | build/sum_to_n
La suma de 1 a 4 es 10
$ echo 0 | build/sum_to_n
La suma de 1 a 0 es 0

Con n = 0 no hay vueltas y la suma queda en 0. Es el caso que hay que probar siempre: qué pasa cuando el ciclo no da ninguna vuelta.

Formas cortas de asignar

i = i + 1 se escribe tanto que C tiene formas cortas. No hacen nada nuevo; solo se escriben menos.

Forma corta Equivale a
i++ i = i + 1
i-- i = i - 1
sum += x sum = sum + x
n -= x n = n - x
w /= 2 w = w / 2

También existen *= y %=. En este curso, i++ e i-- van solos, como una instrucción aparte o en la tercera parte de un for. No los metan dentro de otras expresiones, porque se leen mal y algunas de esas combinaciones son comportamiento indefinido.

El ciclo infinito

Si nada cambia la condición, el ciclo no termina. Aquí, errors/no_update.c:

int n = 3;

while (n > 0) {
    printf("%d\n", n);
}

n vale 3 siempre, así que la pregunta siempre responde sí. gcc no avisa nada, porque es C válido. Si lo corren en la terminal, llena la pantalla de números hasta que lo detengan con Ctrl+C, como vieron en la semana 1.

Para ver solo el comienzo usamos head -n 3, un comando que deja pasar las primeras 3 líneas de lo que recibe y termina. Cuando head termina, el programa que le estaba escribiendo también se detiene.

$ gcc -Wall -Wextra -std=c17 errors/no_update.c -o build/no_update
$ build/no_update | head -n 3
3
3
3

Error común. Cuando un programa parece colgado, casi siempre es un ciclo infinito. Córtenlo con Ctrl+C y hagan la tercera pregunta: ¿qué línea acerca la condición a ser falsa? Si necesitan ver qué pasa, agreguen un printf dentro del ciclo.

El acumulador puede dar overflow

Una suma que crece en cada vuelta puede pasarse del mayor int, 2147483647, como vieron en la semana 3. Esa suma es comportamiento indefinido: el estándar de C no dice qué pasa. make compila nuestros programas con -g -fsanitize=address,undefined: -fsanitize agrega revisiones que corren junto con el programa, y -g hace que el aviso diga el archivo y la línea. Probemos con 70000. Esto no es un error de compilación, sino un aviso del sanitizer mientras el programa corre, y sin esas opciones no aparecería:

$ echo 70000 | build/sum_to_n
sum_to_n.c:15:13: runtime error: signed integer overflow: 2147450880 + 65536 cannot be represented in type 'int'
La suma de 1 a 70000 es -1844932296

El aviso es el dato importante: en esa vuelta, la suma ya no cabe. El número negativo de la última línea salió en esta corrida y no significa nada; no pueden contar con él en otra computadora ni con otras opciones de compilación. La solución es limitar el dato antes de entrar al ciclo, o elegir un problema cuyo resultado quepa.

for

Casi todos los ciclos con contador tienen la misma forma: dar un valor inicial, preguntar y avanzar. for junta esas tres partes en una sola línea.

for (inicio; condición; avance) {
    cuerpo
}

Se comporta como este while, que es equivalente:

inicio;
while (condición) {
    cuerpo
    avance;
}

Léanlo con cuidado, porque el orden es la clave:

  1. inicio se ejecuta una sola vez, antes de todo.
  2. Se evalúa condición. Si es falsa, el ciclo termina.
  3. Se ejecuta el cuerpo.
  4. Se ejecuta avance, después del cuerpo.
  5. Se vuelve al paso 2.

Comprobémoslo. for_trace.c usa un for, y for_as_while.c el while equivalente:

int i;

for (i = 0; i < 10; i += 3) {
    printf("i vale %d\n", i);
}

printf("Al salir, i vale %d\n", i);
i = 0;
while (i < 10) {
    printf("i vale %d\n", i);
    i += 3;
}

Los dos escriben lo mismo:

$ build/for_trace
i vale 0
i vale 3
i vale 6
i vale 9
Al salir, i vale 12
$ build/for_as_while
i vale 0
i vale 3
i vale 6
i vale 9
Al salir, i vale 12
i ¿i < 10? Qué pasa
0 sí escribe y i pasa a 3
3 sí escribe y i pasa a 6
6 sí escribe y i pasa a 9
9 sí escribe y i pasa a 12
12 no se sale

Cuatro vueltas. Al salir, i vale 12, el primer valor que hace falsa la condición, y no el 9 que se escribió al final.

Animación de for_trace.c sobre un diagrama con cinco partes: inicio i = 0, condición i < 10 en un rombo, cuerpo printf, avance i += 3 y, debajo del rombo, fuera del for. A la derecha están la variable i y la salida. Primero corre el inicio, una sola vez, e i vale 0. Se evalúa la condición, 0 < 10 es verdadero. El cuerpo escribe i vale 0. El avance deja i en 3 y una flecha roja regresa a la condición, no al inicio. La vuelta se repite con 3, luego con 6 y luego con 9, y en cada una se agrega una línea a la salida. Cuando i vale 12, 12 < 10 es falso, se sale del for y se escribe Al salir, i vale 12Animación de for_trace.c sobre un diagrama con cinco partes: inicio i = 0, condición i < 10 en un rombo, cuerpo printf, avance i += 3 y, debajo del rombo, fuera del for. A la derecha están la variable i y la salida. Primero corre el inicio, una sola vez, e i vale 0. Se evalúa la condición, 0 < 10 es verdadero. El cuerpo escribe i vale 0. El avance deja i en 3 y una flecha roja regresa a la condición, no al inicio. La vuelta se repite con 3, luego con 6 y luego con 9, y en cada una se agrega una línea a la salida. Cuando i vale 12, 12 < 10 es falso, se sale del for y se escribe Al salir, i vale 12
El inicio corre una vez. Después se repiten condición, cuerpo y avance, y la última condición es la que dice que no.

De para a for

La traducción de para a for es directa. Lo único que hay que cuidar es el hasta.

Pseudocódigo C Valores de i
para i desde 1 hasta n hacer for (int i = 1; i <= n; i++) 1, 2, …, n
para i desde 0 hasta n - 1 hacer for (int i = 0; i < n; i++) 0, 1, …, n - 1

Como hasta incluye el último valor, se traduce con <=; y hasta n - 1 se escribe mejor con < n. Nuestro para siempre avanza de 1 en 1. Si necesitan otro paso, como en for_trace.c, en pseudocódigo se escribe con mientras.

Noten el int dentro del paréntesis. Se puede declarar el contador en la primera parte del for, y entonces solo existe dentro de ese for: es la misma regla de los bloques de la semana pasada. Conviene hacerlo así, salvo que necesiten el valor al salir. Por eso for_trace.c lo declara antes.

La tabla de multiplicar

algoritmo tabla_de_multiplicar()
    leer n
    para i desde 1 hasta 12 hacer
        escribir n, "x", i, "=", n * i
    fin para
fin algoritmo

En C, en mult_table.c. El programa rechaza antes los números muy grandes, para que los productos quepan en un int:

for (int i = 1; i <= 12; i++) {
    printf("%d x %2d = %d\n", n, i, n * i);
}

Recuerden que un número entre % y d es el ancho mínimo: %2d completa con un espacio a la izquierda los números de una cifra, y así las columnas quedan alineadas.

$ echo 7 | build/mult_table | head -n 3
7 x  1 = 7
7 x  2 = 14
7 x  3 = 21

Un binario sin repetir líneas

to_binary.c de la semana 3 repite dos líneas ocho veces y guarda ocho variables, porque el primer bit que sale de dividir entre 2 es el último que se escribe. Con un ciclo hay una forma más directa para un número de 8 bits: se recorren los pesos de mayor a menor. Si el número alcanza para ese peso, el bit es 1 y se le resta el peso.

algoritmo binario_de_ocho_bits()
    leer n
    peso ← 128
    mientras peso ≥ 1 hacer
        si n ≥ peso entonces
            escribir 1
            n ← n - peso
        sino
            escribir 0
        fin si
        peso ← peso div 2
    fin mientras
fin algoritmo
for (int weight = 128; weight >= 1; weight /= 2) {
    if (n >= weight) {
        printf("1");
        n -= weight;
    } else {
        printf("0");
    }
}

El programa de esta semana también revisa que el número esté entre 0 y 255. Sigamos el 202. El avance de este for divide entre 2: después de 1 viene 1 / 2, que en enteros es 0, y ahí termina el ciclo.

weight n antes ¿n >= weight? Escribe n después
128 202 sí 1 74
64 74 sí 1 10
32 10 no 0 10
16 10 no 0 10
8 10 sí 1 2
4 2 no 0 2
2 2 sí 1 0
1 0 no 0 0
$ echo 202 | build/to_binary
11001010
$ echo 300 | build/to_binary
Número fuera de rango

Error común. Un ; justo después del paréntesis termina el for: su cuerpo queda vacío. El bloque de abajo se ejecuta una sola vez, al final. Aquí, con gcc avisando, se ve un solo número, y es el valor con el que se salió del ciclo:

$ gcc -Wall -Wextra -std=c17 errors/semicolon_for.c -o build/semicolon_for
errors/semicolon_for.c: In function ‘main’:
errors/semicolon_for.c:7:5: warning: this ‘for’ clause does not guard... [-Wmisleading-indentation]
    7 |     for (i = 1; i <= 3; i++);
      |     ^~~
errors/semicolon_for.c:8:5: note: ...this statement, but the latter is misleadingly indented as if it were guarded by the ‘for’
    8 |     {
      |     ^
$ build/semicolon_for
4

Con while (n > 0); es peor: si n es positivo, el programa se queda preguntando para siempre, sin escribir nada.

Salirse por uno

El error más común con ciclos es dar una vuelta de más o una de menos. Se llama salirse por uno (off-by-one). Este programa quería escribir del 1 al 5:

for (int i = 1; i < 5; i++) {
$ gcc -Wall -Wextra -std=c17 errors/off_by_one.c -o build/off_by_one
$ build/off_by_one
1
2
3
4

gcc no avisa: i < 5 es C válido, solo que no es lo que se quería. La forma de evitarlo es revisar a mano la primera vuelta y la última vuelta, sin ejecutar nada.

Ciclo Valores de i Vueltas (con n ≥ 1)
for (int i = 0; i < n; i++) 0 a n - 1 n
for (int i = 1; i <= n; i++) 1 a n n
for (int i = 0; i <= n; i++) 0 a n n + 1
for (int i = 1; i < n; i++) 1 a n - 1 n - 1

Para contar, sirve esta regla. De a hasta b, incluyendo los dos, avanzando de 1 en 1, hay b - a + 1 valores. Del 3 al 7 hay 5 números, no 4. Los dos primeros ciclos de la tabla dan n vueltas y son las formas que conviene usar. i < n empezando en 0 será la forma habitual con arreglos, más adelante.

Idea clave. Prueben un ciclo con los bordes: una entrada que da cero vueltas, una que da una sola, y una normal. Es la misma idea de los bordes de la semana pasada.

Mejor < que !=

Con un paso distinto de 1, != es peligroso. En for (i = 0; i != 10; i += 3), i toma los valores 0, 3, 6, 9, 12, y salta el 10. La condición nunca se hace falsa e i sigue creciendo hasta el overflow, que es comportamiento indefinido. Con i < 10, el ciclo termina aunque no caiga justo en 10.

No controlen un ciclo con double

Esto es más traicionero. En la semana 3 vieron que 0.1 no se puede guardar exactamente. Este programa quiere avanzar de 0.1 en 0.1 hasta llegar a 1:

for (double x = 0.0; x != 1.0; x += 0.1) {

Pide 17 decimales con %.17f, para ver lo que de verdad hay guardado. Esta salida es la misma en toda máquina que use el formato habitual de double, el de la semana 3.

$ gcc -Wall -Wextra -std=c17 errors/double_never_equal.c -o build/double_never_equal
$ build/double_never_equal | head -n 12
0.00000000000000000
0.10000000000000001
0.20000000000000001
0.30000000000000004
0.40000000000000002
0.50000000000000000
0.59999999999999998
0.69999999999999996
0.79999999999999993
0.89999999999999991
0.99999999999999989
1.09999999999999987

Después de diez sumas, x no es 1.0 sino un poco menos, y la siguiente suma ya se pasó. La condición x != 1.0 nunca se hace falsa. El programa no termina solo; aquí lo detiene head.

Dos rectas. La de arriba, a escala, tiene tres puntos: 0.89999999999999991, 0.99999999999999989 y 1.09999999999999987, y a esa escala parece que x llega a 1.0. La de abajo, ampliada y sin escala, separa dos puntos: 0.99999999999999989, que es x después de diez sumas, y un círculo vacío en 1.0, el valor que x nunca toma. Una flecha roja marcada x += 0.1 pasa por encima del 1.0 y llega a 1.09999999999999987Dos rectas. La de arriba, a escala, tiene tres puntos: 0.89999999999999991, 0.99999999999999989 y 1.09999999999999987, y a esa escala parece que x llega a 1.0. La de abajo, ampliada y sin escala, separa dos puntos: 0.99999999999999989, que es x después de diez sumas, y un círculo vacío en 1.0, el valor que x nunca toma. Una flecha roja marcada x += 0.1 pasa por encima del 1.0 y llega a 1.09999999999999987
De lejos parece que x llega a 1.0. De cerca, pasa junto a él sin tocarlo, y por eso != nunca da falso.

Cambiar != por < hace que termine, pero tampoco es lo que queríamos. Como 0.99999… es menor que 1.0, hay una vuelta de más:

$ gcc -Wall -Wextra -std=c17 errors/double_extra_turn.c -o build/double_extra_turn
$ build/double_extra_turn
Vueltas: 11

Lo correcto es contar con un entero y calcular el double en cada vuelta, como en double_steps.c. La cantidad de vueltas no depende de ningún redondeo:

for (int i = 0; i <= 10; i++) {
    double x = i / 10.0;
    printf("%.1f\n", x);
}
$ build/double_steps | head -n 2
0.0
0.1

do while

while pregunta antes de entrar. do while pregunta después del cuerpo: por eso el cuerpo se ejecuta al menos una vez. La forma es esta, y lleva ; al final:

do {
    cuerpo
} while (condición);

El programa while_vs_do.c lo muestra con n en 5. Las dos condiciones dicen n < 5, falsa desde el inicio:

int n = 5;

while (n < 5) {
    printf("while: entré\n");
    n++;
}

do {
    printf("do while: entré\n");
    n++;
} while (n < 5);
$ build/while_vs_do
do while: entré
n vale 6
Dos diagramas de flujo lado a lado, cada uno con n en 5. En while, el primer paso es un rombo con n < 5: si la respuesta es sí, va al cuerpo y regresa al rombo; si es no, una flecha roja lo salta y va a lo que sigue, así que el cuerpo no corre y hay cero vueltas. En do while, el primer paso es el cuerpo, y después el rombo con n < 5: si es sí, regresa al cuerpo; si es no, sigue. La flecha roja pasa por el cuerpo una vez, aunque n < 5 sea falso, y n queda en 6Dos diagramas de flujo lado a lado, cada uno con n en 5. En while, el primer paso es un rombo con n < 5: si la respuesta es sí, va al cuerpo y regresa al rombo; si es no, una flecha roja lo salta y va a lo que sigue, así que el cuerpo no corre y hay cero vueltas. En do while, el primer paso es el cuerpo, y después el rombo con n < 5: si es sí, regresa al cuerpo; si es no, sigue. La flecha roja pasa por el cuerpo una vez, aunque n < 5 sea falso, y n queda en 6
Es la misma pregunta en otro lugar. Por eso while puede dar cero vueltas, y do while da al menos una.

Validar una entrada

El uso típico es pedir un dato hasta que sea válido, porque primero hay que leer y recién después se puede preguntar. Por fin podemos pedirlo de nuevo, algo que el programa read_in_range.c de la semana pasada no podía hacer.

do while es una herramienta de C. En pseudocódigo escribimos lo mismo con mientras, y se nota que la lectura aparece dos veces:

algoritmo pedir_mes()
    leer mes
    mientras mes < 1 o mes > 12 hacer
        escribir "Mes fuera de rango, escriba otro"
        leer mes
    fin mientras
    escribir mes
fin algoritmo

En read_month.c, con do while, la lectura aparece una sola vez:

do {
    if (scanf("%d", &month) != 1) {
        printf("Entrada inválida\n");
        return 1;
    }
    if (month < 1 || month > 12) {
        printf("Mes fuera de rango, escriba otro\n");
    }
} while (month < 1 || month > 12);

Fíjense en el primer if. scanf puede fallar en cada vuelta, así que se revisa dentro del ciclo. Hay dos fallas distintas, y en las dos el programa termina:

$ echo 13 0 7 | build/read_month
Mes fuera de rango, escriba otro
Mes fuera de rango, escriba otro
Mes elegido: 7
$ echo abc | build/read_month; echo $?
Entrada inválida
1
$ echo 13 | build/read_month; echo $?
Mes fuera de rango, escriba otro
Entrada inválida
1

En el último caso, la entrada se acabó sin un mes válido. Sigamos el primer caso.

Vuelta scanf lee ¿Devuelve 1? month ¿Fuera de rango?
1 13 sí 13 sí, se repite
2 0 sí 0 sí, se repite
3 7 sí 7 no, se sale

Sin revisar scanf, no termina

Si no revisamos scanf, un dato malo produce un ciclo infinito. %d no puede leer abc: no lo consume, lo deja donde está y devuelve 0. La siguiente vuelta encuentra lo mismo, falla igual, y así para siempre. Cuando se acaba la entrada pasa lo mismo: scanf sigue devolviendo EOF. Este programa, errors/read_forever.c, empieza con month en 0 y nunca lo cambia:

do {
    scanf("%d", &month);
    printf("Mes fuera de rango\n");
} while (month < 1 || month > 12);
$ gcc -Wall -Wextra -std=c17 errors/read_forever.c -o build/read_forever
$ echo abc | build/read_forever | head -n 3
Mes fuera de rango
Mes fuera de rango
Mes fuera de rango

Error común. Un scanf dentro de un ciclo sin revisar es un ciclo infinito esperando un dato malo. El ciclo debe terminar si scanf no devuelve lo pedido. Más adelante, en un ejercicio, verán cómo descartar el dato malo para volver a preguntar.

Error común. Una variable declarada dentro del bloque del do ya no existe en la condición, porque la condición está fuera de las llaves. Declárenla antes del do.

$ gcc -Wall -Wextra -std=c17 errors/do_scope.c -o build/do_scope
errors/do_scope.c: In function ‘main’:
errors/do_scope.c:10:14: error: ‘month’ undeclared (first use in this function)
   10 |     } while (month < 1 || month > 12);
      |              ^~~~~
errors/do_scope.c:10:14: note: each undeclared identifier is reported only once for each function it appears in

¿Cuál ciclo elegir?

Cualquiera de los tres se puede escribir con otro, así que nunca es obligatorio uno en particular. Se elige el que deja el programa más claro.

Situación Conviene Ejemplo
Se conoce la cantidad de vueltas antes de empezar for la tabla del 1 al 12
Se repite mientras algo se cumpla, y puede no dar ninguna vuelta while las monedas de un vuelto
El cuerpo tiene que correr antes de poder preguntar do while pedir un dato hasta que sea válido

En C. Existen dos instrucciones más. break, que vieron en switch, también sale de un ciclo. continue salta a la siguiente vuelta. (Con continue, un for y su while equivalente se comportan distinto, porque el while se saltaría el avance.) En este curso no las necesitamos: preferimos que la condición del ciclo diga cuándo se termina. Si un switch está dentro de un ciclo, un break en el switch sale solo del switch.

Cuatro patrones

Casi todos los ciclos de un primer curso son combinaciones de cuatro patrones. Conviene reconocerlos, porque cada uno tiene su propio error típico.

Contador

Un contador es una variable que empieza en 0 y suma 1 cada vez que pasa algo. Aquí contamos cuántas notas aprueban, con 11 o más.

algoritmo contar_aprobados()
    total ← 0
    aprobados ← 0
    mientras queden datos hacer
        leer nota
        total ← total + 1
        si nota ≥ 11 entonces
            aprobados ← aprobados + 1
        fin si
    fin mientras
    escribir aprobados, total
fin algoritmo

¿Cómo sabe C que quedan datos? Por lo que devuelve scanf. Pero hay que leer antes de preguntar, así que se lee una vez antes del ciclo y otra vez al final de cada vuelta. En status queda lo último que devolvió scanf. Así es count_passed.c:

int status = scanf("%d", &grade);
while (status == 1) {
    total++;
    if (grade >= 11) {
        passed++;
    }
    status = scanf("%d", &grade);
}

if (status != EOF) {
    printf("Entrada inválida\n");
    return 1;
}

El if de después del ciclo distingue las dos formas de salir. Si status es EOF, la entrada se acabó, que es lo normal. Si no, scanf encontró algo que no es un número. Recuerden de la semana 2 que EOF es un valor negativo, y que en la terminal se indica el fin de la entrada con Ctrl+D. Aquí la entrada viene del archivo grades.txt, con <, como en la semana 2; contiene 14, 9, 11, 20 y 10, uno por línea.

$ build/count_passed < grades.txt
Aprobaron 3 de 5
$ echo 14 abc | build/count_passed; echo $?
Entrada inválida
1

Error común. Escribir while (scanf("%d", &n) != EOF). Parece correcto, pero con abc scanf devuelve 0, que es distinto de EOF. El ciclo sigue, el dato malo sigue ahí, y no termina nunca. Pregunten siempre si devolvió lo que se pidió, == 1.

$ gcc -Wall -Wextra -std=c17 errors/not_eof.c -o build/not_eof
$ echo abc | build/not_eof | head -n 3
Llevo 1 datos; el último es 0
Llevo 2 datos; el último es 0
Llevo 3 datos; el último es 0

Acumulador

Un acumulador es una variable que va juntando un resultado. Ya vieron uno: sum en sum_to_n.c. Su valor inicial es el resultado que corresponde a “ningún dato todavía”: 0 para una suma, 1 para un producto.

Error común. Olvidar darle el valor inicial: int sum; y luego sum += x. Leer una variable sin valor es comportamiento indefinido, como vieron en la semana 2. El programa puede parecer correcto en una corrida y fallar en otra. Otro error frecuente es poner sum = 0 dentro del ciclo, con lo que la suma se reinicia en cada vuelta.

Centinela

A veces no se sabe cuántos datos vienen. Una forma de avisar que se acabaron es un valor especial, que no es un dato: el centinela (sentinel). Imaginen la caja de una tienda. Se marcan precios en céntimos, y un 0 indica que la compra terminó.

algoritmo total_de_compra()
    total ← 0
    productos ← 0
    leer precio
    mientras precio ≠ 0 hacer
        total ← total + precio
        productos ← productos + 1
        leer precio
    fin mientras
    escribir productos, total
fin algoritmo

Lean el orden con cuidado: se lee, se pregunta, se procesa y se vuelve a leer. Así, el 0 nunca se suma ni se cuenta. En sentinel_sum.c hay además que atender la falla de scanf:

int status = scanf("%d", &price);
while (status == 1 && price != 0) {
    total += price;
    items++;
    status = scanf("%d", &price);
}

if (status != 1) {
    printf("Entrada inválida\n");
    return 1;
}

La condición usa el cortocircuito de la semana pasada: si status no es 1, price no tiene un dato nuevo y no se llega a mirar. Hay tres formas de terminar: llega el 0, la entrada se acaba o hay un dato inválido.

scanf lee status price ¿Sigue? total items
1250 1 1250 sí 1250 1
300 1 300 sí 1550 2
4050 1 4050 sí 5600 3
0 1 0 no 5600 3
$ echo 1250 300 4050 0 | build/sentinel_sum
3 productos: 56.00 soles
$ echo 0 | build/sentinel_sum
0 productos: 0.00 soles
$ echo 1250 300 | build/sentinel_sum; echo $?
Entrada inválida
1

El centinela debe ser un valor que nunca sea un dato real. El 0 sirve para precios, pero no serviría para temperaturas.

Buscar el mayor

Para buscar el mayor, se guarda el mayor visto hasta ahora y se compara cada dato nuevo con él. La duda es con qué valor empieza. Respuesta: con el primer dato.

algoritmo mayor_de_n()
    leer n
    para i desde 0 hasta n - 1 hacer
        leer temperatura
        si i = 0 o temperatura > mayor entonces
            mayor ← temperatura
        fin si
    fin para
    escribir mayor
fin algoritmo

En max_of_n.c, el programa lee primero cuántas temperaturas vienen:

int max = 0;  // Este 0 nunca se usa: la primera vuelta lo reemplaza.
for (int i = 0; i < n; i++) {
    int temperature;
    if (scanf("%d", &temperature) != 1) {
        printf("Entrada inválida\n");
        return 1;
    }
    if (i == 0 || temperature > max) {
        max = temperature;
    }
}

En el pseudocódigo, como en C, cuando i = 0 ya es cierto no se mira mayor, que todavía no tiene valor. temperature se declara dentro del bloque, así que existe solo durante cada vuelta y no conserva su valor de una vuelta a la siguiente. i == 0 || es otra vez el cortocircuito: en la primera vuelta es verdadero sin comparar nada. Sigamos -3 -7 -1.

i temperature ¿i == 0? ¿temperature > max? max
0 -3 sí no se evalúa -3
1 -7 no no -3
2 -1 no sí -1
$ echo 3 -3 -7 -1 | build/max_of_n
La mayor es -1
$ echo 3 8 -7 12 | build/max_of_n
La mayor es 12

Error común. Empezar el mayor en 0. Con datos positivos parece funcionar, pero 0 no es un dato. Con temperaturas de una noche de helada, el programa responde algo que nadie midió:

$ gcc -Wall -Wextra -std=c17 errors/max_from_zero.c -o build/max_from_zero
$ echo 3 -3 -7 -1 | build/max_from_zero
La mayor es 0

Leer hasta el fin de la entrada con getchar

scanf lee datos con un formato. A veces queremos algo más simple: mirar la entrada byte por byte, sea lo que sea. Para eso está getchar(), de stdio.h. Cada llamada devuelve el siguiente byte de la entrada, incluidos los espacios y los saltos de línea. Cuando ya no quedan, devuelve EOF.

El patrón es el de siempre: leer antes de preguntar, y volver a leer al final de la vuelta.

algoritmo contar_bytes()
    cuenta ← 0
    mientras queden bytes hacer
        leer c
        cuenta ← cuenta + 1
    fin mientras
    escribir cuenta
fin algoritmo

En count_bytes.c:

int count = 0;

int c = getchar();
while (c != EOF) {
    count++;
    c = getchar();
}

El archivo cities.txt tiene tres líneas: Puno, Cusco y Piura. Son 14 letras y 3 saltos de línea. wc -c, de la semana 3, cuenta lo mismo.

$ build/count_bytes < cities.txt
17 bytes
$ wc -c cities.txt
17 cities.txt
$ echo ñ | build/count_bytes
3 bytes

La última línea no es un error. Recuerden que en UTF-8 la ñ ocupa 2 bytes, y echo agrega un salto de línea. getchar cuenta bytes, no letras.

Dos filas de casillas numeradas. La primera es cities.txt: P, u, n, o, salto de línea, C, u, s, c, o, salto de línea, P, i, u, r, a, salto de línea, numeradas del 1 al 17, y después una casilla EOF, que no es un byte del archivo sino lo que getchar devuelve cuando ya no quedan. count llega a 17, con 14 letras y 3 saltos de línea. La segunda es echo ñ: una ñ que ocupa dos casillas, la 1 y la 2, un salto de línea en la 3 y EOF. count llega a 3Dos filas de casillas numeradas. La primera es cities.txt: P, u, n, o, salto de línea, C, u, s, c, o, salto de línea, P, i, u, r, a, salto de línea, numeradas del 1 al 17, y después una casilla EOF, que no es un byte del archivo sino lo que getchar devuelve cuando ya no quedan. count llega a 17, con 14 letras y 3 saltos de línea. La segunda es echo ñ: una ñ que ocupa dos casillas, la 1 y la 2, un salto de línea en la 3 y EOF. count llega a 3
getchar entrega un byte en cada llamada, y el salto de línea es un byte más. EOF llega después del último.

Sin <, el programa lee del teclado y espera. Se termina la entrada con Ctrl+D al inicio de una línea, como en la semana 2.

Procesar lo que se lee

Junto a getchar está putchar(c), que escribe un solo byte. Con los dos podemos transformar un texto. to_upper.c cambia las minúsculas por mayúsculas:

int c = getchar();
while (c != EOF) {
    if (c >= 'a' && c <= 'z') {
        c = c - 'a' + 'A';
    }
    putchar(c);
    c = getchar();
}

c - 'a' es la posición de la letra en el alfabeto ASCII, y al sumarle 'A' se obtiene la mayúscula. Esto funciona porque en ASCII las letras están seguidas. Y solo sirve para ASCII:

$ echo hola mundo | build/to_upper
HOLA MUNDO
$ echo ñandú | build/to_upper
ñANDú

Los bytes de ñ y de ú no están en el rango de 'a' a 'z', así que pasan sin cambios.

La forma corta, y dos trampas

Verán este ciclo en casi todo programa de C. Es el mismo patrón, en una línea. Así es copy.c, que copia la entrada en la salida:

while ((c = getchar()) != EOF) {
    putchar(c);
}

¿No dijimos la semana pasada que asignar dentro de una condición era el error clásico? Aquí es a propósito. Se lee de adentro hacia afuera: getchar() trae un byte, c = ... lo guarda, y el valor de esa asignación se compara con EOF. La condición es una comparación completa, así que gcc no avisa.

$ build/copy < cities.txt
Puno
Cusco
Piura

Los paréntesis interiores son indispensables. Como = tiene la menor precedencia, sin ellos primero se compara, y c recibe el resultado de la comparación, que es 1, y no el byte. Por eso este programa nunca ve un salto de línea:

$ gcc -Wall -Wextra -std=c17 errors/getchar_no_parens.c -o build/getchar_no_parens
errors/getchar_no_parens.c: In function ‘main’:
errors/getchar_no_parens.c:8:12: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
    8 |     while (c = getchar() != EOF) {
      |            ^
$ build/getchar_no_parens < cities.txt
0 saltos de línea

La segunda trampa es el tipo. getchar devuelve un int, y c debe ser int, nunca char. La razón es que tiene que poder devolver los 256 valores posibles de un byte y además EOF, y eso no cabe en un char. Lo que pasa con char c depende de la máquina, porque el estándar no dice si char tiene signo, como vieron en la semana 3:

  • Donde char no tiene signo, como en Linux sobre ARM, c nunca puede ser negativo, así que nunca es igual a EOF y el ciclo no termina. Con -Wextra, gcc lo avisa allí con comparison is always true due to limited range of data type.
  • Donde char tiene signo, como en Linux sobre x86-64, el programa parece funcionar y gcc no avisa. Pero un byte 0xFF en la entrada se confunde con EOF, y la lectura se detiene antes de tiempo.

Razonar antes de ejecutar

Antes de correr un ciclo, repasen esta lista. Toma un minuto, y evita la mayoría de los errores de este capítulo.

Pregunta Qué revisar
¿Inicio? Toda variable que se lee en la condición o en el cuerpo tiene valor antes de entrar
¿Parada? La condición llega a ser falsa, también con entradas inválidas o si la entrada se acaba
¿Avance? Alguna línea del cuerpo acerca la condición a ser falsa
¿Primera vuelta? Los valores son los que se querían
¿Última vuelta? Ni una de más ni una de menos
¿Cero vueltas? El resultado sigue teniendo sentido. Por ejemplo, no se divide entre 0
¿Puede dar overflow? El acumulador cabe en su tipo para los datos permitidos

El laboratorio, paso a paso

El laboratorio tiene cuatro programas. Los archivos de partida están en code/week-05/lab. En cada uno, el comentario inicial dice exactamente qué debe escribir. make test-lab los comprueba, y falla hasta que terminen. Si un programa de ustedes se queda en un ciclo infinito, la prueba lo corta y falla. Aquí están las ideas, no el código.

Suma, promedio y mayor hasta un 0

Es un centinela, con un acumulador, un contador y la búsqueda del mayor, todo en el mismo ciclo. Tomen como base sentinel_sum.c. Tres cuidados:

  • Sin datos. Si el primer número es 0, el contador queda en 0, y el promedio sería una división entre 0. El enunciado pide Sin datos.
  • El promedio. Es (double) sum / count, con %.2f, como en la semana 2. Sin el cast, sería división entera.
  • El mayor. Puede haber solo negativos. Si usan el contador para saber que es el primer dato, actualicen el contador después de esa comparación.

Prueben con 10 15 5 0, con 0, con -5 -2 -9 0, con 4 abc y con 4 5 (sin centinela).

¿Es primo?

Un primo es un entero mayor que 1 que solo se divide exactamente entre 1 y entre sí mismo. Se prueban divisores desde 2 y se deja de buscar en cuanto aparece uno. Para eso, usen una variable que recuerde la respuesta (prime), que empieza en “sí” y cambia a “no” si aparece un divisor, y pongan esa variable en la condición del ciclo. Decidan aparte lo que pasa con 0, 1 y los negativos, que no son primos. El 2 sí lo es, y no entra a ninguna vuelta.

No hace falta llegar hasta n. Si n es a * b, uno de los dos factores es a lo sumo la raíz cuadrada de n. Por lo tanto, basta probar d mientras d * d no pase de n. Pero con cuidado: el enunciado incluye 2147483647, el mayor int, y ahí d * d da overflow cuando d llega a 46341. El sanitizer se los va a mostrar. Escriban la condición sin multiplicar: d <= n / d dice lo mismo con enteros positivos, y no hace overflow.

Prueben 0, 1, 2, 3, 4, 9, 25, 49, 97, -7 y 2147483647. Los cuadrados perfectos son el borde: si la condición fuera d < n / d, 49 saldría como primo.

Contar líneas

Se cuenta cada '\n' que entrega getchar, hasta EOF. El archivo viene de la entrada, con <. Hay tres archivos para probar en lab/. El comando wc -l también cuenta saltos de línea:

$ wc -l lab/three_lines.txt
3 lab/three_lines.txt
$ wc -l lab/no_final_newline.txt
2 lab/no_final_newline.txt
$ wc -l lab/empty.txt
0 lab/empty.txt

no_final_newline.txt tiene tres renglones, pero el último no termina en salto de línea. El enunciado pide contar igual que wc -l, así que da 2. Recuerden que c debe ser int.

Validar una nota

Es read_month.c con otros límites y otros mensajes. Usen do while, revisen scanf dentro del ciclo y terminen si falla. El programa no escribe ningún mensaje antes de leer, porque sus pruebas comparan la salida exacta. Prueben con 25 -1 20, con 0, con abc y con 25 seguido del fin de la entrada.

Resumen

  • Un ciclo repite un bloque. Se entiende con tres preguntas: inicio, parada y avance.
  • while pregunta antes de cada vuelta y puede dar cero vueltas.
  • for (inicio; condición; avance) se comporta como un while con esas tres partes. avance corre después del cuerpo. Al salir, el contador tiene el primer valor que hizo falsa la condición.
  • do while pregunta después del cuerpo, que corre al menos una vez. Sirve para validar una entrada.
  • para i desde a hasta b incluye b: se traduce con <=. De a a b hay b - a + 1 valores.
  • Salirse por uno: revisen la primera vuelta, la última y el caso de cero vueltas.
  • i++, i--, +=, -= y /= son formas cortas de asignar.
  • Un scanf dentro de un ciclo se revisa dentro del ciclo. La entrada inválida y el fin de la entrada deben terminar el ciclo. Compárenlo con == 1, no con != EOF.
  • Patrones: contador (empieza en 0), acumulador (empieza en el resultado de “ningún dato”), centinela (no se procesa) y mayor (empieza en el primer dato).
  • Un acumulador int que se pasa del límite es comportamiento indefinido.
  • No controlen un ciclo con un double: cuenten con un entero.
  • getchar devuelve un int, con el siguiente byte o EOF. Guárdenlo en un int. Cuenta bytes, no letras.

Ejercicios

Los ejercicios no necesitan arreglos ni funciones propias. Todo programa revisa su scanf.

  1. Sigan a mano este ciclo con una tabla, y luego compruébenlo con un programa. ¿Qué valores toma i? ¿Cuántas vueltas da? ¿Cuánto vale i al salir?

    int i;
    for (i = 10; i > 0; i -= 3) { printf("%d\n", i); }
  2. ¿Cuántas líneas escribe cada fragmento? Respondan sin compilar y luego compruébenlo.

    a) for (int i = 0; i <= 5; i++) { printf("x\n"); }
    b) for (int i = 1; i < 5; i++) { printf("x\n"); }
    c) for (int i = 5; i < 5; i++) { printf("x\n"); }
    d) int i = 5; do { printf("x\n"); i++; } while (i < 5);
  3. Reescriban for (int i = 5; i >= 1; i--) { printf("%d\n", i); } con while y con do while. Luego cambien el valor inicial de i a 0 en las tres versiones. ¿Cuál se comporta distinto, y por qué?

  4. Escriban en pseudocódigo y en C un programa que lea n, entre 1 y 12, y escriba n!, el producto de los enteros de 1 a n. Dibujen su diagrama de flujo en una hoja. ¿Con qué valor empieza el acumulador? Después quiten el límite y prueben con 13. ¿Qué dice el sanitizer? ¿Por qué no sirve el número que escribe?

  5. Escriban un programa que lea un entero no negativo y escriba la suma de sus dígitos: con 1234 escribe 10. Usen % 10 y / 10. Sigan a mano el caso 1234 con una tabla. ¿Qué escribe con 0?

  6. Lean notas hasta el centinela -1, sin procesarlo. Escriban cuántos aprobaron (11 o más) y cuántos desaprobaron. Si un dato no es un número, si está fuera de 0 a 20, o si la entrada se acaba antes del -1, el programa avisa y termina con return 1. ¿Por qué aquí el 0 no serviría de centinela?

  7. Modifiquen max_of_n.c para que escriba también la menor temperatura y la diferencia entre la mayor y la menor. Pruébenlo con una sola temperatura y con todas negativas.

  8. Escriban un menú con do while. El programa lee una opción con scanf y la atiende con switch: 1 escribe Hola, 2 escribe Chau, y 0 termina. Otra opción escribe Opción inválida y vuelve a leer. Si scanf falla, avisa y termina con return 1. Pruébenlo con echo 1 9 2 0 | ....

  9. Con getchar, cuenten cuántos dígitos de '0' a '9' y cuántas minúsculas ASCII tiene la entrada. Pruébenlo con echo año 2026 | .... ¿Por qué la ñ no cuenta como minúscula?

  10. Mejoren el laboratorio de contar líneas para que también cuente un último renglón que no termina en \n. Pista: guarden el byte anterior en otra variable int. El resultado debe ser 3 con lab/no_final_newline.txt, 3 con lab/three_lines.txt y 0 con lab/empty.txt.

  11. Modifiquen read_month.c para que, ante un dato que no es un número, vuelva a preguntar en vez de terminar. Si scanf devuelve 0, descarten con getchar todo hasta el salto de línea, o hasta EOF. Si scanf devuelve EOF, terminen con return 1. Inicien month en 0, porque si el primer scanf falla, la condición del do while leería una variable sin valor. Comprueben con el comando de abajo que la última línea es Mes elegido: 7. ¿Qué pasaría sin descartar? ¿Y si el descarte no se detuviera en EOF?

    printf 'abc\n13\n7\n' | build/read_month_retry

    (printf también es un comando de la terminal: \n es el salto de línea, como en C.)

  12. Sucesión de Collatz. Lean un entero n de 1 a 1000. Mientras n no sea 1: si es par, divídanlo entre 2; si es impar, cámbienlo por 3 * n + 1. Escriban cuántos pasos hizo. Con 27 son 111. Sigan a mano el caso 6. ¿n baja en cada vuelta? Hasta donde se sabe, nadie ha demostrado que este ciclo termine para todo entero positivo.

  13. Reto. adder4.c de la semana pasada repite la cuenta por cada columna. Reescríbanlo con un for de cuatro vueltas. Sin arreglos, lean los dos números con %d, de modo que 0101 se lee como el entero 101, y saquen cada bit con % 10 y / 10, de derecha a izquierda. Vayan armando la suma con un peso que se multiplica por 10 en cada vuelta, y escríbanla con %04d, que rellena con ceros hasta 4 cifras. Con 0101 0011 debe escribir 1000 y con 1000 1000, 0000 y un acarreo final de 1.