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 corranmakepara compilar los ejemplos ymake testpara comprobarlos. Los comandos de este capítulo se corren desde ahí. Los programas deerrors/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
voidy 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 conassert” 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:
- Se calcula el valor de lo que va entre paréntesis: 5.
- La ejecución salta a
square, y el parámetroxrecibe el 5. - Corre el cuerpo:
resultvale 25, yreturnlo entrega. - La ejecución vuelve al punto de la llamada.
square(n)vale 25 en ese lugar, y se guarda enanswer.
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:
xenint square(int x). - El argumento (argument) es el valor que se pasa en la llamada: el valor de
nensquare(n), o el 2 ensquare(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 0va después del ciclo, porque “no está” solo se sabe cuando ya se miraron todas las cifras. Con unelsedentro del ciclo, la función decide con la primera cifra, ygccno 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 |


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.
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:
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
scanfniprintf.
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 escribeabcno es un error del programa, sino algo que el programa debe manejar con unify un mensaje. Además, conNDEBUGesa revisión desaparecería. - Nunca pongan dentro de un
assertalgo que deba ocurrir siempre. Conassert(scanf("%d", &n) == 1), siassertse 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
-gguarda 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.-O0le pide agccque 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.gccya 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 congdb.
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,nextentra en código de la biblioteca de C, cuyas fuentes no están instaladas, y da avisos extraños. Para terminar, usencontinue. - Si el programa lee datos y corren solo
run, se queda esperando. Escriban los datos, o usenrun < archivo. - Si hacen
quitcon el programa todavía detenido a medias,gdbpregunta si quieren matarlo. Respondany.
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.
voidcomo 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.
returntermina la función. Una función que no esvoiddebe devolver un valor en todos los caminos: usar el valor de una que llegó al final sinreturnes 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
scanfyprintf: una función que solo calcula se puede probar. assertcomprueba una condición y detiene el programa si es falsa. Prueben casos normales, bordes, cero, negativos y cada camino.NDEBUGlo apaga: nunca lo usen para validar entradas.- Para depurar: reproducir, formular una hipótesis, comprobarla y corregir.
gdbnecesita-g -O0, y sus comandos básicos sonbreak,run,next,step,printybacktrace.
Ejercicios
Los ejercicios usan ciclos y funciones, pero no arreglos. Todo programa revisa su scanf.
-
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; } -
Encuentren el problema de cada fragmento. Con
-Wall -Wextra, ¿gccda 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) -
Escriban en pseudocódigo y en C
int max_of_four(int a, int b, int c, int d)usandomax_of_two, en un programa con al menos cuatroassert, entre ellos uno donde el mayor es negativo y uno con empates. -
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 conassertpara -1, 0, 20, 21 y una nota normal. -
Escriban
int power(int base, int exponent)con un ciclo, para unexponentque no sea negativo. Pruébenla conpower(2, 10),power(7, 1),power(5, 0)ypower(0, 3). ¿Qué caso es el borde? ¿Qué pasa conpower(2, 40), según la semana 3? -
El máximo común divisor de dos enteros positivos se puede calcular así: mientras
bno sea 0, se reemplaza(a, b)por(b, a mod b), y al final el resultado esa. Escríbanlo en pseudocódigo y como funciónint gcd(int a, int b). Síganla a mano congcd(48, 18)en una tabla, y luego escriban sus pruebas. -
Corran
digit_sum_bugengdbcon un archivo de entrada que tenga una sola cifra, como 5, y el punto de parada endigit_sum. ¿Cuántas veces corre el cuerpo del ciclo? ¿Qué valor tienesumen la línea delreturn? ¿Cómo lo explica la hipótesis de este capítulo? -
En
count_digits_bug.c, cambien de lugar losassertpara que el de-42quede antes que el de0. 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? -
Escriban
int roll_two_dice(void), usandoroll_die, y reescriban con ella la simulación de la semana 6 que estima la probabilidad de sumar 7. ¿Por qué no se puede probarroll_dieconassert(roll_die() == 3)? ¿Qué sí se puede comprobar de sus resultados con unassert? -
Escriban
int days_in_month(int month, int year)usando suis_leap_yeardel laboratorio. Debe devolver 0 si el mes no existe. Elijan los casos de prueba, y expliquen qué error descubriría cada uno.