Apuntes · Capítulo 7Programación en C

Funciones y pruebas

Funciones y pruebas

Hace tres semanas escribimos adder4.c, y la misma cuenta se repetía cuatro veces, una por cada columna. En las semanas 5 y 6, con los ciclos, los programas crecieron: todo quedó dentro de main, una sola pieza larga. Si la pieza falla, hay que buscar el error en todas partes. Y para comprobar un cálculo hay que correr el programa completo.

Hoy aprendemos a partir un programa en funciones: pedazos con nombre que reciben datos y devuelven un resultado. Después vemos qué pasa de verdad en una llamada: el paso por valor, el alcance de las variables y la pila de llamadas. Con funciones ya podemos probar cada cálculo por separado con assert. Y cuando una prueba falla, buscamos el error con método y con una herramienta nueva, el debugger gdb.

Lectura. El código de este capítulo está en code/week-07. 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, “Para qué sirven las funciones”, “Definir y llamar una función” y “Funciones void y funciones sin parámetros”. Es lo mínimo para seguir la clase.
  • En clase: “Prototipos: anunciar antes de definir”, “return: varias salidas y todos los caminos”, “Paso por valor”, “Alcance de una variable”, “La pila de llamadas”, “Separar los cálculos de la entrada y salida”, “Probar con assert” y “Depurar con método”.
  • Después de clase: para el laboratorio, “El laboratorio, paso a paso” y “La Práctica 2”; para repasar, “Resumen” y “Ejercicios”.

Para qué sirven las funciones

Ya usan funciones desde la primera semana. main es una función. printf y scanf también, y sqrt de la semana 2, y rand de la semana pasada. Alguien las escribió, les puso un nombre, y ustedes las llaman sin mirar cómo funcionan por dentro. Ahora vamos a escribir las nuestras. Sirven para tres cosas.

Para qué Qué significa
No repetir Una cuenta se escribe una vez y se usa muchas. Si tiene un error, se corrige en un solo lugar.
Dar nombres is_passing(grade) dice lo que hace. grade >= 11 obliga a pensar qué quiso decir el autor.
Probar por partes Una función pequeña se puede comprobar sola, sin correr todo el programa.

Idea clave. Una buena función hace una sola cosa, y su nombre dice cuál. Si para nombrarla necesitan la palabra “y”, probablemente son dos funciones.

Definir y llamar una función

En pseudocódigo

En la semana 1 vieron mayor_de_dos, que escribía el mayor. Una versión más útil lo devuelve. La palabra devolver termina el algoritmo y le entrega un valor a quien lo pidió. Los datos que van entre paréntesis son los que el algoritmo recibe.

algoritmo mayor_de_dos(a, b)
    si a > b entonces
        devolver a
    sino
        devolver b
    fin si
fin algoritmo

Otro algoritmo lo puede usar como si fuera una operación más: m ← mayor_de_dos(a, b).

En C

Empecemos con la función más simple que vale la pena escribir, en square.c:

int square(int x) {
    int result = x * x;
    return result;
}

Esas líneas son la definición de la función. Cada pieza tiene un nombre.

Pieza En square Qué dice
Tipo de retorno (return type) int El tipo del valor que la función devuelve
Nombre square Con él se llama, y sigue las reglas de cualquier identificador
Parámetro (parameter) int x Una variable que recibe su valor de quien llama
Cuerpo lo que está entre { y } Lo que se ejecuta en cada llamada
return return result; Termina la función y entrega ese valor

Una función con varios parámetros los separa con comas, y cada uno lleva su tipo: int max_of_two(int a, int b).

Definir una función no la ejecuta. Solo le enseña al compilador qué hacer cuando se la llame. La llamada se escribe con el nombre y los paréntesis:

int answer = square(n);

Si n vale 5, pasa lo siguiente:

  1. Se calcula el valor de lo que va entre paréntesis: 5.
  2. La ejecución salta a square, y el parámetro x recibe el 5.
  3. Corre el cuerpo: result vale 25, y return lo entrega.
  4. La ejecución vuelve al punto de la llamada. square(n) vale 25 en ese lugar, y se guarda en answer.
Dos cajas de código. A la izquierda, main, con int answer = square(n), donde n vale 5. A la derecha, la función square. Cuatro números siguen los pasos: 1, se calcula el argumento; 2, una flecha roja salta de main a square y x recibe 5; 3, corre el cuerpo y result vale 25; 4, una flecha verde vuelve de square al punto de la llamada, donde square(n) vale 25 y se guarda en answerDos cajas de código. A la izquierda, main, con int answer = square(n), donde n vale 5. A la derecha, la función square. Cuatro números siguen los pasos: 1, se calcula el argumento; 2, una flecha roja salta de main a square y x recibe 5; 3, corre el cuerpo y result vale 25; 4, una flecha verde vuelve de square al punto de la llamada, donde square(n) vale 25 y se guarda en answer
La ejecución va a la función y regresa al mismo lugar, con un valor. Entre las dos, solo viajan el argumento y lo que devuelve return.

Una llamada es una expresión, igual que 2 + 3. Por eso puede ir dentro de otra expresión, y lo que se le pasa también puede ser una expresión:

printf("%d\n", square(2) + square(n + 1));
$ echo 5 | build/square
5 al cuadrado es 25
40

Con n = 5, la segunda línea es square(2) + square(6), o sea, 4 + 36.

Ojo con lo que no cabe. Como vieron en la semana 3, el overflow de un int es comportamiento indefinido, y una función no lo evita. El programa se compiló con el sanitizer, que avisa mientras corre. Esta salida está recortada para mostrar solo ese aviso:

$ echo 50000 | build/square
square.c:5:9: runtime error: signed integer overflow: 50000 * 50000 cannot be represented in type 'int'

Parámetro y argumento

Son dos palabras para dos lados de la misma llamada.

  • El parámetro es la variable de la definición: x en int square(int x).
  • El argumento (argument) es el valor que se pasa en la llamada: el valor de n en square(n), o el 2 en square(2).

Los argumentos se asignan a los parámetros por posición: el primero al primero, y así. Los nombres no importan. El argumento puede llamarse igual que el parámetro, o distinto, o no ser una variable.

Una función puede llamar a otra

En la semana 4, max_of_three necesitó un if dentro de otro, con cuatro caminos. Con una función de apoyo, en max_of_three.c, se reduce a una línea:

int max_of_two(int a, int b) {
    if (a > b) {
        return a;
    }
    return b;
}

int max_of_three(int a, int b, int c) {
    return max_of_two(max_of_two(a, b), c);
}

Sigamos max_of_three(3, 9, 5). Lo que está más adentro se calcula primero.

Llamada a b ¿a > b? Devuelve
max_of_two(3, 9) 3 9 no 9
max_of_two(9, 5) 9 5 sí 9
$ echo 3 9 5 | build/max_of_three
9

Fíjense en max_of_two: no hay else. Tampoco hace falta. return termina la función en ese instante, así que a la última línea solo se llega si a > b fue falso.

Lo que el compilador revisa y lo que no

Si la cantidad de argumentos no coincide con la de parámetros, no compila:

$ gcc -Wall -Wextra -std=c17 errors/wrong_count.c -o build/wrong_count
errors/wrong_count.c: In function ‘main’:
errors/wrong_count.c:12:20: error: too few arguments to function ‘max_of_two’
   12 |     printf("%d\n", max_of_two(5));
      |                    ^~~~~~~~~~
errors/wrong_count.c:3:5: note: declared here
    3 | int max_of_two(int a, int b) {
      |     ^~~~~~~~~~

Si el tipo no coincide, en cambio, C convierte el valor sin preguntar, como en una asignación. Con la conversión de double a int se pierden los decimales, y gcc no avisa, ni siquiera con -Wall -Wextra:

printf("%d\n", square(2.5));
$ gcc -Wall -Wextra -std=c17 errors/double_argument.c -o build/double_argument
$ build/double_argument
4

square recibió un 2, no un 2.5. Si una función trabaja con decimales, sus parámetros deben ser double.

Funciones void y funciones sin parámetros

No toda función devuelve algo. El tipo de retorno void significa “no devuelve nada”. Estas funciones se usan por lo que hacen, por ejemplo, escribir. En pseudocódigo es simplemente un algoritmo sin devolver, como el mayor_de_dos de la semana 1.

En print_line.c:

void print_line(int width) {
    for (int i = 0; i < width; i++) {
        printf("-");
    }
    printf("\n");
}

void print_header(void) {
    print_line(20);
    printf("Boleta de notas\n");
    print_line(20);
}

Una función void termina al llegar a la llave final. También puede salir antes con return;, sin valor. Y print_header muestra lo otro: una función sin parámetros lleva void entre los paréntesis, como ya hacían con main(void). Al llamarla, los paréntesis van igual, vacíos: print_header();.

$ build/print_line
--------------------
Boleta de notas
--------------------
Promedio: 14
-----

Una función void no tiene un valor que se pueda guardar: con int y = print_twice(4); en errors/void_value.c, gcc da el error void value not ignored as it ought to be.

Lo contrario sí compila, y es peor: llamar a una función que devuelve algo y no usar el resultado. El valor se pierde y gcc no dice nada.

square(a);
$ gcc -Wall -Wextra -std=c17 errors/ignored_result.c -o build/ignored_result
$ build/ignored_result
5

a sigue en 5. square no cambia a: calcula 25 y lo devuelve, y nadie lo recibe. Lo correcto es a = square(a);. Más adelante, en la sección del paso por valor, veremos por qué no puede ser de otra forma.

Una función sin parámetros también puede ser útil

El dado de la semana pasada queda mejor con nombre. En roll_die.c:

int roll_die(void) {
    return 1 + rand() % 6;
}

No recibe nada y, sin embargo, cada llamada puede dar un valor distinto, porque rand avanza en cada llamada. Como main llama a srand((unsigned) time(NULL)), la siguiente salida cambia de una corrida a otra, salvo dentro del mismo segundo. Esto salió en esta corrida:

$ build/roll_die
4 3 4 2 1

(void) y () no son lo mismo

En C17, unos paréntesis vacíos en una definición también dicen “sin parámetros”, pero gcc no los usa para revisar las llamadas. Este programa pasa tres argumentos a una función que no recibe ninguno. Eso es comportamiento indefinido, y gcc no se queja. Esto salió en esta corrida:

int get_year() {
printf("%d\n", get_year(1, 2, 3));
$ gcc -Wall -Wextra -std=c17 errors/empty_parens.c -o build/empty_parens
$ build/empty_parens
2026

Con (void), el mismo error no compila: en errors/void_parens.c, gcc responde too many arguments to function ‘get_year’.

En este curso, una función sin parámetros siempre lleva (void).

Prototipos: anunciar antes de definir

gcc lee el archivo de arriba hacia abajo. Cuando encuentra una llamada, ya tiene que saber qué tipo devuelve la función y qué parámetros recibe. Si main va primero y usa una función definida más abajo, no compila:

$ gcc -Wall -Wextra -std=c17 errors/no_prototype.c -o build/no_prototype
errors/no_prototype.c: In function ‘main’:
errors/no_prototype.c:5:20: error: implicit declaration of function ‘cube’ [-Wimplicit-function-declaration]
    5 |     printf("%d\n", cube(3));
      |                    ^~~~

Es el mensaje de la semana 1, cuando se escribía mal el nombre de una función. Hay dos soluciones. La primera es definir las funciones antes de usarlas, y main al final: así están ordenados casi todos los programas de este capítulo. La segunda es escribir un prototipo (prototype), que anuncia la función sin dar el cuerpo. En prototype.c:

int cube(int x);

int main(void) {
    printf("%d\n", cube(3));
    return 0;
}

int cube(int x) {
    return x * x * x;
}
Prototipo Definición
Forma int cube(int x); int cube(int x) { ... }
Qué le dice al compilador Cómo se llama la función: tipo, nombre y parámetros Además, qué hace

Eso es lo que hay en stdio.h: el prototipo de printf. Por eso #include <stdio.h> va arriba. La definición de printf está en la biblioteca de C.

return: varias salidas y todos los caminos

Una función puede tener varios return, y el primero que se ejecuta la termina. Esto es muy útil en una búsqueda: en cuanto se encuentra lo buscado, no hay nada más que mirar.

algoritmo tiene_cifra(n, cifra)
    mientras n > 0 hacer
        si n mod 10 = cifra entonces
            devolver 1
        fin si
        n ← n div 10
    fin mientras
    devolver 0
fin algoritmo

En has_digit.c:

int has_digit(int n, int digit) {
    while (n > 0) {
        if (n % 10 == digit) {
            return 1;
        }
        n = n / 10;
    }
    return 0;
}

Esta función responde una pregunta: devuelve 1 si la respuesta es sí y 0 si es no. Por la regla de la semana 4, se puede usar directamente como condición:

if (has_digit(n, digit)) {

Sigamos has_digit(2026, 6) y has_digit(26, 2):

Llamada n n % 10 ¿Coincide? Qué pasa
has_digit(2026, 6) 2026 6 sí devuelve 1, sin mirar más
has_digit(26, 2) 26 6 no n pasa a 2
2 2 sí devuelve 1

Error común. El return 0 va después del ciclo, porque “no está” solo se sabe cuando ya se miraron todas las cifras. Con un else dentro del ciclo, la función decide con la primera cifra, y gcc no avisa:

if (n % 10 == digit) {
    return 1;
} else {
    return 0;
}
$ gcc -Wall -Wextra -std=c17 errors/has_digit_wrong.c -o build/has_digit_wrong
$ echo 26 2 | build/has_digit_wrong
No aparece

Llegar al final sin return

Una función que promete un int debe devolverlo en todos los caminos. Esta se olvida de uno, en errors/missing_return.c:

int is_passing(int grade) {
    if (grade >= 11) {
        return 1;
    }
}

¿Qué devuelve con una nota de 5? No está definido. Usar el valor de una función que llegó al final sin return es comportamiento indefinido. gcc da una advertencia, pero genera el ejecutable, y el sanitizer no vigila este caso en C. Esto salió en esta corrida, y en otra computadora o con otras opciones de compilación puede salir otra cosa:

$ gcc -Wall -Wextra -std=c17 errors/missing_return.c -o build/missing_return
errors/missing_return.c: In function ‘is_passing’:
errors/missing_return.c:8:1: warning: control reaches end of non-void function [-Wreturn-type]
    8 | }
      | ^
$ echo 5 | build/missing_return
5

La respuesta “5” no significa nada: la función debía dar 1 o 0. Y main es la única excepción: si llega al final sin return, equivale a return 0. Igual lo escribimos siempre.

Paso por valor

Esta es la idea más importante del capítulo: en C, un parámetro siempre recibe una copia del valor del argumento. Se llama paso por valor (pass by value). La función trabaja con su copia, y nunca toca la variable de quien llama. Miren by_value.c, donde a propósito las dos variables se llaman x:

void add_one(int x) {
    x = x + 1;
    printf("En add_one, x vale %d\n", x);
}
int x = 1;

add_one(x);
printf("En main, x vale %d\n", x);
$ build/by_value
En add_one, x vale 2
En main, x vale 1
Después de plus_one, x vale 2

Son dos variables distintas que comparten el nombre. Paso a paso:

Línea que corre x de main x de add_one
int x = 1; 1 no existe
se llama add_one(x): se copia el 1 1 1
x = x + 1; 1 2
printf dentro de add_one 1 2
termina add_one 1 ya no existe
printf en main 1
Animación de by_value.c con los marcos de la pila a la izquierda y la salida a la derecha. Primero está solo el marco de main, con x en 1. Al llamar a add_one, aparece encima otro marco, con su propio x, y una flecha roja marcada copia lleva el 1 hasta él. La línea x = x + 1 deja en 2 el x de add_one; el de main sigue en 1. Se escribe En add_one, x vale 2. Al terminar la función, su marco queda punteado, con la nota ya no existe. Por último, se escribe En main, x vale 1Animación de by_value.c con los marcos de la pila a la izquierda y la salida a la derecha. Primero está solo el marco de main, con x en 1. Al llamar a add_one, aparece encima otro marco, con su propio x, y una flecha roja marcada copia lleva el 1 hasta él. La línea x = x + 1 deja en 2 el x de add_one; el de main sigue en 1. Se escribe En add_one, x vale 2. Al terminar la función, su marco queda punteado, con la nota ya no existe. Por último, se escribe En main, x vale 1
Hay dos variables x, en dos marcos. add_one cambia la suya, y la de main nunca se toca.

Para que main vea el cambio, la función devuelve el nuevo valor, y main decide guardarlo con x = plus_one(x);. De ahí sale la última línea de la salida del programa, Después de plus_one, x vale 2:

int plus_one(int x) {
    return x + 1;
}

Un intercambio que no intercambia

En la semana 2 intercambiaron dos variables con una auxiliar. Parece natural convertirlo en función:

void try_swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
    printf("En try_swap: a = %d, b = %d\n", a, b);
}
$ build/try_swap
En try_swap: a = 8, b = 3
En main:     a = 3, b = 8

Dentro de la función, el intercambio funciona, pero sobre las copias. Al volver, las copias desaparecen. Y return no ayuda, porque una función devuelve un solo valor.

¿Se puede hacer de otra forma? Sí, y ya lo han visto sin notarlo. Recuerden scanf("%d", &n): scanf necesita cambiar n, así que no le pasamos su valor sino su dirección de memoria con &. La copia de una dirección sigue señalando el mismo lugar. Volveremos a esa idea cuando pasemos arreglos a funciones, en la semana 10. Por ahora, sus funciones reciben valores y devuelven un valor.

Idea clave. Una función no puede cambiar las variables de quien la llama a través de sus parámetros. La información entra por los parámetros y sale por return.

Alcance de una variable

Una variable declarada dentro de una función se llama local. Su alcance (scope) es la parte del código donde se puede usar su nombre: desde su declaración hasta que se cierra el bloque donde está. Los parámetros se comportan igual, y su alcance es toda la función.

Por eso pueden repetir nombres sin problema. En scope.c hay tres variables result, y ninguna tiene que ver con las otras:

int twice(int x) {
    int result = x * 2;
    return result;
}

int triple(int x) {
    int result = x * 3;
    return result;
}

int main(void) {
    int result = twice(5) + triple(5);

Y una función no puede ver las variables de otra. Si main intenta usar el result de twice, como en errors/local_not_visible.c, no compila: gcc dice ‘result’ undeclared, igual que en la semana 4.

La regla es la misma de los bloques de la semana 4. También vale para for (int i = 0; ...): esa i solo existe dentro del for.

Además, una variable local nace en cada llamada y muere al terminar. No recuerda nada de la llamada anterior. Esta función, en count_calls.c, intenta contar sus llamadas y no lo logra:

int count_calls(void) {
    int calls = 0;
    calls = calls + 1;
    return calls;
}
$ build/count_calls
1
1
1

Tampoco tiene valor inicial por sí sola: una local sin inicializar sigue siendo el error de la semana 2.

En C. Existen las variables globales, declaradas fuera de toda función, que todas las funciones ven. En este curso no se usan: si cualquier función puede cambiar un dato, un error puede estar en cualquier parte. Pasen los datos por parámetros.

La pila de llamadas

¿Cómo sabe la computadora a dónde volver al terminar una función? ¿Y dónde están las copias? Una buena forma de pensarlo es la pila de llamadas (call stack). Es como una pila de platos: el último que se pone es el primero que se saca.

  • Cada llamada agrega encima un marco (stack frame): un espacio con sus parámetros, sus variables locales y el punto al que debe volver.
  • Al terminar la función, su marco se retira, y sigue la función que quedó arriba.

Tomemos call_chain.c:

int square(int x) {
    int result = x * x;
    return result;
}

int sum_of_squares(int a, int b) {
    int first = square(a);
    int second = square(b);
    return first + second;
}

int main(void) {
    int total = sum_of_squares(3, 4);

Este es el dibujo de la pila cuando square(3) ya calculó result. Se lee como la pila de platos: el marco más reciente está arriba.

Tres marcos apilados. Arriba, el más reciente, square, con x en 3 y result en 9. En el medio, sum_of_squares, con a en 3, b en 4, y first y second todavía sin valor. Abajo, el más antiguo, main, con total todavía sin valor. Una flecha roja sube de la casilla a a la casilla x y dice que se copió el valor de aTres marcos apilados. Arriba, el más reciente, square, con x en 3 y result en 9. En el medio, sum_of_squares, con a en 3, b en 4, y first y second todavía sin valor. Abajo, el más antiguo, main, con total todavía sin valor. Una flecha roja sube de la casilla a a la casilla x y dice que se copió el valor de a
Cada llamada tiene su marco y sus variables. La x de square es una casilla aparte, y solo recibió una copia.

Cada marco tiene sus propias variables. Eso es el paso por valor visto de otra forma: x es una casilla aparte en otro marco, donde se copió el valor de a. Y así cambia la pila a lo largo de todo el programa:

Siete columnas de izquierda a derecha, sobre una flecha de tiempo, y cada una es la pila en un momento. Uno, empieza el programa: solo main. Dos, main llama a sum_of_squares(3, 4): main y sum_of_squares, con a en 3 y b en 4. Tres, se llama a square(3): encima aparece square, con x en 3. Cuatro, square devuelve 9: se retira su marco y first vale 9. Cinco, se llama a square(4): aparece encima un marco nuevo, con otro x, que vale 4. Seis, square devuelve 16: se retira su marco y second vale 16. Siete, sum_of_squares devuelve 25: solo queda main, con total en 25Siete columnas de izquierda a derecha, sobre una flecha de tiempo, y cada una es la pila en un momento. Uno, empieza el programa: solo main. Dos, main llama a sum_of_squares(3, 4): main y sum_of_squares, con a en 3 y b en 4. Tres, se llama a square(3): encima aparece square, con x en 3. Cuatro, square devuelve 9: se retira su marco y first vale 9. Cinco, se llama a square(4): aparece encima un marco nuevo, con otro x, que vale 4. Seis, square devuelve 16: se retira su marco y second vale 16. Siete, sum_of_squares devuelve 25: solo queda main, con total en 25
La pila crece con cada llamada y baja con cada retorno. Las dos llamadas a square usan marcos distintos.

Este dibujo es un modelo para razonar, y funciona muy bien, pero tiene un límite. El estándar de C describe qué debe hacer el programa, no dónde guarda cada cosa. No garantiza en qué dirección de memoria queda una local, ni hacia dónde crece la pila. El compilador incluso puede guardar una variable en otro lugar, o eliminarla. Lo que sí garantiza el lenguaje es lo que el modelo predice: cada llamada tiene sus propias variables, y al volver se sigue donde se quedó. Más abajo, gdb nos va a mostrar la pila de un programa real.

Separar los cálculos de la entrada y salida

Volvamos a la tienda de la semana 4, con su descuento de 10 % desde 100 soles. Esta versión, en discount_mixed.c, está partida en funciones, pero mal partida:

void discount_and_print(int total) {
    if (total >= 10000) {
        total = total - total / 10;
    }
    printf("A pagar: %d.%02d soles\n", total / 100, total % 100);
}

El nombre ya lleva una “y”. ¿Cómo comprobamos si calcula bien? No hay un valor que mirar. Solo se puede correr el programa, escribir datos y leer la pantalla. Tampoco se puede reutilizar: si ahora queremos sumar dos compras con descuento, la función escribe algo que nadie pidió.

Mejor es separar. Primero, en pseudocódigo, el cálculo:

algoritmo precio_con_descuento(total)
    si total ≥ 10000 entonces
        devolver total - total div 10
    fin si
    devolver total
fin algoritmo

Y en discount.c, tres piezas con un trabajo cada una:

int price_after_discount(int total) {
    if (total >= 10000) {
        return total - total / 10;
    }
    return total;
}

void print_price(int cents) {
    printf("A pagar: %d.%02d soles\n", cents / 100, cents % 100);
}

main lee, revisa y llama:

print_price(price_after_discount(total));
$ echo 12050 | build/discount
A pagar: 108.45 soles
$ echo abc | build/discount
Entrada inválida

Una función como price_after_discount, que solo mira sus parámetros y devuelve un resultado, siempre da lo mismo con los mismos datos. Eso es justamente lo que hace falta para probarla.

Idea clave. Lean y escriban en pocas funciones. Calculen en funciones que reciben parámetros y devuelven un valor, sin scanf ni printf.

Probar con assert

Una prueba (test) es un programa que comprueba otro: llama a una función con un dato cuyo resultado ya conocemos, y compara. C trae una herramienta mínima para eso en assert.h.

assert(condición); no hace nada si la condición es verdadera. Si es falsa, escribe qué falló y termina el programa en ese mismo instante. El programa discount_tests.c prueba el descuento. Como cada programa de esta semana está en un solo archivo, trae una copia de la función; repartir un programa en varios archivos se aprende en cursos posteriores.

#include <assert.h>
#include <stdio.h>
assert(price_after_discount(12050) == 10845);   // caso normal con descuento
assert(price_after_discount(8000) == 8000);     // caso normal sin descuento
assert(price_after_discount(9999) == 9999);     // último valor sin descuento
assert(price_after_discount(10000) == 9000);    // primer valor con descuento
assert(price_after_discount(0) == 0);           // el menor total válido

printf("Todas las pruebas pasaron\n");

Si nada falla, se llega al printf final, y eso es lo que escribe este programa. Son los mismos datos que elegimos a mano en la semana 4, pero ahora no hay que volver a escribirlos: la prueba los recuerda, y se puede correr después de cada cambio.

Una prueba que encuentra un bug

Queremos contar las cifras de un número. Hay una idea simple: mientras quede algo, se quita una cifra y se cuenta.

algoritmo contar_cifras(n)
    cuenta ← 0
    mientras n > 0 hacer
        cuenta ← cuenta + 1
        n ← n div 10
    fin mientras
    devolver cuenta
fin algoritmo

Antes de creer que funciona, escribimos las pruebas. Hay que decidir también lo que la función debe hacer: decidimos que el 0 tiene una cifra y que el signo no cuenta. En count_digits_bug.c:

assert(count_digits(7) == 1);
assert(count_digits(2026) == 4);
assert(count_digits(0) == 1);
assert(count_digits(-42) == 2);

La salida depende en parte del sistema. La primera línea la escribe assert. Aborted y el 134 que da echo $? los da el shell de este entorno, porque el programa terminó de forma abrupta:

$ build/count_digits_bug
count_digits_bug: count_digits_bug.c:17: main: Assertion `count_digits(0) == 1' failed.
Aborted
$ echo $?
134

El mensaje dice todo lo necesario: el archivo, la línea, la función que contiene el assert, y la condición que resultó falsa. Las dos primeras pruebas pasaron, y el programa se detuvo en la primera que falló. La de -42 ni siquiera corrió, y también fallaría, porque -42 > 0 es falso.

La causa está en la condición del ciclo: con 0 o con un negativo, el cuerpo nunca se ejecuta. Lo corregimos en count_digits.c. En pseudocódigo se agrega un si n = 0 entonces devolver 1 al inicio, y el ciclo sigue mientras n ≠ 0, lo que sirve también para los negativos, porque la división entera se acerca a 0.

int count_digits(int n) {
    if (n == 0) {
        return 1;
    }

    int count = 0;
    while (n != 0) {
        count = count + 1;
        n = n / 10;
    }
    return count;
}
$ build/count_digits
Todas las pruebas pasaron

Qué casos elegir

No se puede probar todo, así que hay que elegir con intención. Cada prueba debería poder descubrir un error distinto.

Tipo de caso Qué busca En count_digits
Normal Que la idea funcione 2026
Borde, justo a cada lado de un cambio Errores de < por <= y de salirse por uno 9 y 10
El menor valor posible, o el cero Ciclos que no llegan a correr 0
Negativos, si la función los acepta Suposiciones que no se dijeron -42
Cada camino Partes del código que nunca se ejecutaron 0 y cualquier otro

Pasar todas las pruebas no demuestra que no haya errores. Demuestra que no los hay en esos casos. Por eso importa tanto escoger bien. Y conviene escribir las pruebas antes o justo después de la función, cuando todavía tenemos en la cabeza qué debe hacer, no qué hace.

Error común. Probar la función solo con el ejemplo que ya funcionó. Si las pruebas se escriben mirando lo que el programa da, solo confirman lo que hace. El valor esperado se calcula a mano, o se saca de la definición del problema.

Con double, recuerden la semana 3: no se compara con == un resultado calculado. La prueba es con tolerancia, como en la semana 4: assert(fabs(x - 0.3) < 0.000001);, con math.h.

assert se puede apagar

assert existe para encontrar errores del programador, y se puede desactivar. Si al compilar está definido el nombre NDEBUG, todos los assert desaparecen del programa. La opción -D de gcc define un nombre, así que -DNDEBUG define NDEBUG. Este programa, ndebug_demo.c, tiene una condición evidentemente falsa:

assert(1 + 1 == 3);
printf("Llegué al final\n");
$ gcc -Wall -Wextra -std=c17 -DNDEBUG ndebug_demo.c -o build/ndebug_off
$ build/ndebug_off
Llegué al final

De aquí salen dos reglas.

  • No validen la entrada del usuario con assert. Un usuario que escribe abc no es un error del programa, sino algo que el programa debe manejar con un if y un mensaje. Además, con NDEBUG esa revisión desaparecería.
  • Nunca pongan dentro de un assert algo que deba ocurrir siempre. Con assert(scanf("%d", &n) == 1), si assert se apaga, también desaparece la lectura.

Depurar con método

Una prueba dice que algo falla. Encontrar dónde y por qué es la depuración (debugging). Lo peor que se puede hacer es cambiar cosas al azar hasta que parezca funcionar. El método tiene cuatro pasos.

Paso Qué se hace
1. Reproducir Conseguir un dato pequeño con el que el error ocurra siempre
2. Formular una hipótesis Decir qué creen que pasa, de forma que se pueda comprobar
3. Comprobarla Mirar el programa mientras corre. ¿Pasa lo que la hipótesis predice?
4. Corregir y volver a probar Arreglar la causa, y correr todas las pruebas

Antes de seguir, ubiquemos las herramientas que ya tienen, porque cada una ve cosas distintas:

Herramienta Cuándo actúa Qué encuentra
-Wall -Wextra Al compilar Código sospechoso o inválido, sin correr nada
-fsanitize=address,undefined Mientras corre Algunos errores de memoria y comportamiento indefinido, si ese camino se ejecuta
assert Mientras corre Resultados distintos de lo que ustedes esperaban
gdb Cuando ustedes lo piden Nada por sí solo: les deja mirar qué hace el programa

Un programa puede pasar los tres primeros y seguir mal, porque el error está en la lógica. Es el caso del ejemplo que sigue.

El caso: la suma de las cifras

El programa digit_sum_bug.c debería sumar las cifras de un número. Compila sin advertencias, y el sanitizer no se queja. Pero 4 + 0 + 7 es 11.

int digit_sum(int n) {
    int sum = 0;
    while (n > 9) {
        int last = n % 10;
        sum = sum + last;
        n = n / 10;
    }
    return sum;
}

Reproducir. Probemos con datos fáciles de calcular a mano.

$ echo 407 | build/digit_sum_bug
La suma de las cifras de 407 es 7
$ echo 23 | build/digit_sum_bug
La suma de las cifras de 23 es 3
$ echo 5 | build/digit_sum_bug
La suma de las cifras de 5 es 0

Formular una hipótesis. En 407 faltó el 4. En 23 faltó el 2. En 5 faltó el 5. Parece que siempre falta la cifra de más a la izquierda. Esa hipótesis predice algo concreto: cuando el ciclo termina con 407, n todavía vale 4, y ese 4 nunca se sumó.

Comprobarla. Podríamos seguirlo a mano o agregar printf, y ambas cosas son válidas. Pero aprendamos a mirarlo con el debugger.

gdb

Un debugger es un programa que ejecuta otro bajo control: lo detiene donde queramos, avanza línea por línea y muestra sus variables. Para usarlo, hay que compilar de otra forma:

$ gcc -Wall -Wextra -std=c17 -g -O0 digit_sum_bug.c -o build/digit_sum_bug_gdb
  • -g guarda en el ejecutable los datos que relacionan el código de máquina con su archivo, sus líneas y los nombres de las variables. No cambia lo que el programa hace.
  • -O0 le pide a gcc que no optimice, es decir, que no reordene ni elimine nada para ganar velocidad. Así, cada línea corre en orden y las variables se pueden ver. gcc ya hace eso si no se le pide otra cosa, pero lo escribimos para dejarlo claro.
  • Sin -fsanitize. El sanitizer agrega código al programa, y aquí solo estorbaría al avanzar paso a paso. Lo usamos para encontrar errores de memoria, no para mirar el programa con gdb.

gdb se maneja con comandos, que se escriben después de su prompt (gdb).

Comando Qué hace
break digit_sum Pone un punto de parada (breakpoint) al entrar a esa función. También acepta un número de línea
run Ejecuta el programa hasta el siguiente punto de parada. run < entrada.txt lee la entrada de un archivo
next Ejecuta la línea actual completa. Si tiene una llamada, la hace sin entrar
step Ejecuta la línea actual, pero si tiene una llamada a una función nuestra, entra en ella
print n Muestra el valor actual de n
backtrace Muestra la pila de llamadas
finish Sigue hasta que termine la función actual, y muestra lo que devolvió
continue Sigue hasta el próximo punto de parada o el final
quit Sale de gdb

En entrada.txt está el 407. Esta es la sesión real. Recortamos la ruta de la línea Starting program. Hay datos que dependen del entorno: las direcciones como 0x830, la ruta de libthread_db y el número de proceso. En otras máquinas también puede aparecer un aviso sobre address space randomization, que no impide seguir. El -q solo evita el mensaje de bienvenida, y $1, $2 numeran cada resultado.

$ gdb -q build/digit_sum_bug_gdb
Reading symbols from build/digit_sum_bug_gdb...
(gdb) break digit_sum
Breakpoint 1 at 0x830: file digit_sum_bug.c, line 5.
(gdb) run < entrada.txt
Starting program: .../build/digit_sum_bug_gdb < entrada.txt
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".

Breakpoint 1, digit_sum (n=407) at digit_sum_bug.c:5
5	    int sum = 0;
(gdb) print n
$1 = 407
(gdb) next
6	    while (n > 9) {
(gdb) next
7	        int last = n % 10;
(gdb) next
8	        sum = sum + last;
(gdb) print last
$2 = 7
(gdb) next
9	        n = n / 10;
(gdb) print sum
$3 = 7
(gdb) break 11
Breakpoint 2 at 0xaaaaaaaa089c: file digit_sum_bug.c, line 11.
(gdb) continue
Continuing.

Breakpoint 2, digit_sum (n=4) at digit_sum_bug.c:11
11	    return sum;
(gdb) print n
$4 = 4
(gdb) print sum
$5 = 7
(gdb) continue
Continuing.
La suma de las cifras de 407 es 7
[Inferior 1 (process 39195) exited normally]
(gdb) quit

Lo más importante al leer gdb: la línea que muestra todavía no se ejecutó, es la que viene. Por eso, parados en la línea 8, last ya vale 7, pero sum aún no lo sumó. Si piden el valor de una variable que todavía no recibió ninguno, verán basura.

Después de mirar la primera vuelta, break 11 puso un segundo punto de parada en el return, y continue corrió hasta allí. Lo que predijo la hipótesis se cumple: el ciclo terminó con n en 4 y sum en 7. Esta tabla, seguida a mano, resume las vueltas:

Vuelta n al entrar last sum al salir n al salir
1 407 7 7 40
2 40 0 7 4
no hay 4: 4 > 9 es falso

Corregir. La condición debe ser n > 0, así el ciclo también procesa la última cifra. Es el típico error de salirse por uno. Está corregido en digit_sum.c, y make test lo comprueba con muchos valores.

$ echo 407 | build/digit_sum
La suma de las cifras de 407 es 11
$ echo 5 | build/digit_sum
La suma de las cifras de 5 es 5
$ echo 0 | build/digit_sum
La suma de las cifras de 0 es 0

Lo correcto sería agregar ahora una prueba con ese dato, para que el bug no vuelva sin que nadie se dé cuenta.

step y backtrace: ver la pila de verdad

Compilemos call_chain.c, el de la sección de la pila, y entremos en las llamadas con step. next habría ejecutado la llamada completa sin entrar.

$ gcc -Wall -Wextra -std=c17 -g -O0 call_chain.c -o build/call_chain_gdb

También aquí recortamos la ruta y quitamos las dos líneas de libthread_db. Las direcciones que empiezan con 0x cambian según el entorno.

$ gdb -q build/call_chain_gdb
Reading symbols from build/call_chain_gdb...
(gdb) break main
Breakpoint 1 at 0x80c: file call_chain.c, line 15.
(gdb) run
Starting program: .../build/call_chain_gdb

Breakpoint 1, main () at call_chain.c:15
15	    int total = sum_of_squares(3, 4);
(gdb) step
sum_of_squares (a=3, b=4) at call_chain.c:9
9	    int first = square(a);
(gdb) step
square (x=3) at call_chain.c:4
4	    int result = x * x;
(gdb) backtrace
#0  square (x=3) at call_chain.c:4
#1  0x0000aaaaaaaa07e0 in sum_of_squares (a=3, b=4) at call_chain.c:9
#2  0x0000aaaaaaaa0818 in main () at call_chain.c:15
(gdb) finish
Run till exit from #0  square (x=3) at call_chain.c:4
0x0000aaaaaaaa07e0 in sum_of_squares (a=3, b=4) at call_chain.c:9
9	    int first = square(a);
Value returned is $1 = 9
(gdb) continue
Continuing.
25
[Inferior 1 (process 39207) exited normally]
(gdb) quit

backtrace es la pila que dibujamos: #0 es el marco más reciente, y cada uno muestra sus argumentos y la línea donde está detenido. Léanlo así: “estoy en square, que fue llamada por sum_of_squares, en su línea 9, y esta por main, en su línea 15”. Cuando un programa falla dentro de una función, esto dice cómo llegó allí.

Tres cuidados al usar gdb:

  • Después del final de main, next entra en código de la biblioteca de C, cuyas fuentes no están instaladas, y da avisos extraños. Para terminar, usen continue.
  • Si el programa lee datos y corren solo run, se queda esperando. Escriban los datos, o usen run < archivo.
  • Si hacen quit con el programa todavía detenido a medias, gdb pregunta si quieren matarlo. Respondan y.

El laboratorio, paso a paso

Los archivos de partida están en code/week-07/lab. En cada uno, el comentario inicial dice exactamente qué se pide. make test-lab los comprueba: falla hasta que terminen. Aquí están las ideas, no el código.

is_prime e is_leap_year con pruebas

Las dos funciones van en lab/functions.c. No leen ni escriben: reciben un int y devuelven 1 o 0. En la semana 5 escribieron un programa que decide si un número es primo; ahora va en una función, y un return dentro del ciclo la hace más simple. Un primo es un entero mayor que 1 cuyos únicos divisores positivos son 1 y él mismo. Escriban primero las pruebas. Estos casos son los mínimos, y cada uno busca un error distinto:

n ¿Primo? Qué error descubre
0 no Un ciclo que no llega a correr y deja la respuesta en “primo”
1 no Lo mismo, y olvidar que 1 no es primo
2 sí Un ciclo que empieza mal y descarta al menor primo
-7 no Olvidar los negativos
13 sí El caso normal
4, 49 no En cuadrados perfectos, parar antes de la raíz los daría como primos

Si quieren evitar mirar todos los divisores, basta con llegar hasta la raíz cuadrada de n. La condición i * i <= n tiene un riesgo con números enormes, porque i * i puede no caber en un int. i <= n / i dice lo mismo sin ese riesgo. Y recuerden el error de has_digit: “es primo” solo se sabe después del ciclo.

Para is_leap_year ya tienen la regla y los cuatro casos de la semana 4: 2024, 2026, 1900 y 2000.

Encontrar los bugs de lab/grades_bug.c

Este programa lee varias notas y escribe el promedio y la cantidad de aprobados. Compila sin advertencias, y tiene cuatro bugs. Empiecen por lo que debería escribir con lab/grades.txt, que el comentario del archivo indica, y compilen para gdb sin tocar nada:

$ gcc -Wall -Wextra -std=c17 -g -O0 lab/grades_bug.c -o build/grades_bug_gdb

Dentro de gdb, usen break main, run < lab/grades.txt, next para ver cómo avanza el ciclo y print para mirar i, total y passing en cada vuelta. Usen step en la línea que llama a count_if_passing para entrar hasta is_passing. Antes de cada comando, predigan qué debería salir: un bug es justo donde la predicción y la realidad se separan. Corrijan un bug a la vez, en el mismo archivo.

Mirar la pila con backtrace

En ese mismo programa, pongan break is_passing, corran con run < lab/grades.txt y pidan backtrace. Dibujen en papel los marcos, con sus variables, y compárenlos con lo que muestra gdb. ¿Cuántos marcos hay? ¿Cuál es el más reciente? Esta parte no la comprueba make test-lab: es para que la vean ustedes.

Reorganizar un programa en funciones

En lab/adder4.c está el sumador de 4 bits de la semana 4. Reorganícenlo con dos funciones: sum_bit y carry_bit. Hacen falta dos porque cada columna produce dos resultados y una función devuelve un solo valor. La salida debe quedar idéntica: make test-lab compara las 256 sumas posibles.

La Práctica 2

Esta semana, la sesión de laboratorio es para la Práctica 2. Las partes del laboratorio de arriba son trabajo autónomo y conviene hacerlas antes. La práctica cubre ciclos, números aleatorios y funciones. El formato y las reglas están en practicas/README.md. Se revisa también la legibilidad: funciones cortas, nombres que dicen lo que hacen y sin código repetido. La próxima semana es el examen parcial, que cubre de la semana 1 a esta.

Resumen

  • Una función tiene tipo de retorno, nombre, parámetros y cuerpo. Definirla no la ejecuta; llamarla sí. El parámetro es la variable de la definición, y el argumento es el valor de la llamada.
  • void como tipo de retorno significa que no devuelve nada. (void) en los parámetros significa que no recibe nada, y es distinto de ().
  • Un prototipo anuncia una función antes de definirla. Sin él, hay que definirla antes de usarla.
  • return termina la función. Una función que no es void debe devolver un valor en todos los caminos: usar el valor de una que llegó al final sin return es comportamiento indefinido.
  • C usa siempre paso por valor: el parámetro es una copia. Lo que cambia la función no le llega a quien llama, salvo lo que devuelve con return.
  • Las variables locales solo se ven en su bloque, y nacen y mueren con cada llamada. No usamos variables globales.
  • La pila de llamadas es el modelo: cada llamada agrega un marco con sus variables, y al terminar se retira.
  • Separen los cálculos de scanf y printf: una función que solo calcula se puede probar.
  • assert comprueba una condición y detiene el programa si es falsa. Prueben casos normales, bordes, cero, negativos y cada camino. NDEBUG lo apaga: nunca lo usen para validar entradas.
  • Para depurar: reproducir, formular una hipótesis, comprobarla y corregir. gdb necesita -g -O0, y sus comandos básicos son break, run, next, step, print y backtrace.

Ejercicios

Los ejercicios usan ciclos y funciones, pero no arreglos. Todo programa revisa su scanf.

  1. Sin compilar, digan qué escribe este fragmento, y dibujen la pila cuando corre g. Luego compruébenlo.

    int g(int y) { y = y * 2; return y + 1; }
    int f(int x) { int a = g(x); x = x + 10; return a + x; }
    int main(void) { int x = 1; int b = f(x); printf("%d %d\n", x, b); return 0; }
  2. Encuentren el problema de cada fragmento. Con -Wall -Wextra, ¿gcc da un error, una advertencia, o nada? Compruébenlo.

    a) void f(int x) { return x + 1; }
    b) int f(int x) { x = x + 1; }
    c) int f(void) { return 1; }   ...   f(2);
    d) int y = 3; twice(y); printf("%d", y);    (con twice de scope.c)
  3. Escriban en pseudocódigo y en C int max_of_four(int a, int b, int c, int d) usando max_of_two, en un programa con al menos cuatro assert, entre ellos uno donde el mayor es negativo y uno con empates.

  4. Escriban int clamp_grade(int grade), que devuelve 0 si la nota es negativa, 20 si pasa de 20, y la misma nota en otro caso. Escriban sus pruebas con assert para -1, 0, 20, 21 y una nota normal.

  5. Escriban int power(int base, int exponent) con un ciclo, para un exponent que no sea negativo. Pruébenla con power(2, 10), power(7, 1), power(5, 0) y power(0, 3). ¿Qué caso es el borde? ¿Qué pasa con power(2, 40), según la semana 3?

  6. El máximo común divisor de dos enteros positivos se puede calcular así: mientras b no sea 0, se reemplaza (a, b) por (b, a mod b), y al final el resultado es a. Escríbanlo en pseudocódigo y como función int gcd(int a, int b). Síganla a mano con gcd(48, 18) en una tabla, y luego escriban sus pruebas.

  7. Corran digit_sum_bug en gdb con un archivo de entrada que tenga una sola cifra, como 5, y el punto de parada en digit_sum. ¿Cuántas veces corre el cuerpo del ciclo? ¿Qué valor tiene sum en la línea del return? ¿Cómo lo explica la hipótesis de este capítulo?

  8. En count_digits_bug.c, cambien de lugar los assert para que el de -42 quede antes que el de 0. Corran. ¿Qué cambia en el mensaje, y qué no? Compilen luego con -DNDEBUG. ¿Qué escribe el programa ahora, y por qué eso no significa que esté bien?

  9. Escriban int roll_two_dice(void), usando roll_die, y reescriban con ella la simulación de la semana 6 que estima la probabilidad de sumar 7. ¿Por qué no se puede probar roll_die con assert(roll_die() == 3)? ¿Qué sí se puede comprobar de sus resultados con un assert?

  10. Escriban int days_in_month(int month, int year) usando su is_leap_year del laboratorio. Debe devolver 0 si el mes no existe. Elijan los casos de prueba, y expliquen qué error descubriría cada uno.