1. Para qué sirven las funciones
Una función es un fragmento de código con nombre, que recibe datos, hace un trabajo y devuelve un resultado. En el curso de Bash usaste funciones para no repetir código; en C cumplen el mismo papel, y algunos más:
- Dar nombre a una idea.
leer_enterooescribir_todo, de los temas 9 y 10, dicen qué hacen; su código, sin el nombre, habría que leerlo para entenderlo. - Esconder detalles. Quien llama a
escribir_todono necesita saber que adentro hay un bucle que maneja escrituras parciales e interrupciones. - Reutilizar. Una función correcta se escribe y se prueba una vez.
- Formar la interfaz del sistema. La biblioteca de C y las llamadas al sistema son funciones, y la forma en que se declaran, reciben argumentos y devuelven resultados es la interfaz binaria de la que hablamos en el tema 1.
La diferencia con Bash es grande. Una función de Bash recibía sus argumentos como texto en $1, $2, y «devolvía» un código de salida o escribía en la salida estándar. Una función de C recibe argumentos de tipos declarados y devuelve un valor de un tipo declarado, y el compilador comprueba que cada llamada respete esos tipos.
2. Definir una función
La definición de una función tiene una cabecera, con el tipo del resultado, el nombre y la lista de parámetros, y un cuerpo entre llaves. Este programa convierte una cantidad de bytes en un texto como el de ls -lh, repartido en dos funciones:
#include <stdio.h>
/* convierte una cantidad de bytes en un texto legible, como ls -lh */
static double escalar(double bytes, const char **unidad)
{
static const char *unidades[] = {"B", "KiB", "MiB", "GiB", "TiB"};
int i = 0;
while (bytes >= 1024 && i < 4) {
bytes /= 1024;
i++;
}
*unidad = unidades[i];
return bytes;
}
static void mostrar(const char *nombre, double bytes)
{
const char *unidad;
double valor = escalar(bytes, &unidad);
printf("%-12s %8.1f %s\n", nombre, valor, unidad);
}
int main(void)
{
mostrar("hola", 15952);
mostrar("libc.so.6", 2125328);
mostrar("disco", 512110190592.0);
return 0;
}
$ ./tamano
hola 15.6 KiB
libc.so.6 2.0 MiB
disco 476.9 GiB
| Parte | En escalar | Significado |
|---|---|---|
| Especificadores | static | La función solo es visible en este archivo (sección 12). |
| Tipo de retorno | double | El tipo del valor que devuelve. void si no devuelve nada. |
| Nombre | escalar | Sigue las reglas de los identificadores del tema 3. |
| Parámetros | double bytes, const char **unidad | Variables locales que reciben los argumentos. (void) si no hay ninguno. |
| Cuerpo | { … } | Las sentencias, con return para terminar y entregar el resultado. |
escalar muestra además las dos formas en que una función entrega resultados: el número escalado sale por el return, y el nombre de la unidad, por un parámetro que recibe una dirección, &unidad. Las secciones 6 y 8 explican por qué hace falta ese segundo camino. Y mostrar, que solo imprime, es void: no devuelve nada, y su return implícito está en la llave final.
3. Llamar a una función
Una llamada es una expresión: escalar(bytes, &unidad) vale el double que devuelve la función, y puede usarse donde se use un double. Al ejecutar la llamada ocurren cuatro cosas, en orden:
- Se evalúan los argumentos. Cada argumento es una expresión, y se calcula su valor. El orden entre argumentos no está especificado, como vimos en el tema 7. evaluar
- Se convierten y se copian. Cada valor se convierte al tipo del parámetro correspondiente y se copia en él. Los parámetros son variables locales nuevas de la función. copiar
- Se ejecuta el cuerpo, hasta un
returno hasta la llave final. ejecutar - Se vuelve. Las variables locales y los parámetros desaparecen, y el valor de retorno ocupa el lugar de la llamada en la expresión que la contenía. volver
El orden no especificado de la evaluación tiene trampas prácticas. Al escribir fibonacci.c para este tema, la primera versión mostraba el resultado de una función recursiva y, en el mismo printf, un contador global que esa función incrementaba: printf("…", fib_recursivo(n), llamadas). Mostró «0 llamadas», porque gcc leyó llamadas antes de llamar a la función. La solución fue guardar el resultado en una variable antes del printf: cuando un argumento tiene efectos secundarios que otro argumento observa, la llamada se separa en dos sentencias.
El paso 2 y la desaparición del paso 4 son el tema 12, que muestra cómo se organizan en la pila los parámetros y las variables de cada llamada.
4. Declaraciones y prototipos
C lee el archivo de arriba hacia abajo, y exige que una función esté declarada antes de llamarla. Si la definición viene después del uso, el compilador no sabe nada de ella:
$ gcc -std=c17 -Wall -Wextra -o antes antes.c
antes.c: In function ‘main’:
antes.c:5:20: error: implicit declaration of function ‘doble’ [-Wimplicit-function-declaration]
5 | printf("%d\n", doble(21));
| ^~~~~
Hay dos soluciones. Una es ordenar el archivo de modo que cada función esté definida antes de usarse, con main al final, que es lo que hicimos en todos los ejemplos del curso. La otra es escribir antes una declaración de la función, sin cuerpo, terminada en punto y coma, llamada prototipo:
#include <stdio.h>
int doble(int x); /* prototipo: la declaración */
int main(void)
{
printf("%d\n", doble(21));
printf("%d\n", doble(3.9)); /* se convierte a int: 3 */
printf("%d\n", doble(21, 1)); /* un argumento de más */
return 0;
}
int doble(int x) /* la definición */
{
return 2 * x;
}
El prototipo dice todo lo que el compilador necesita para compilar una llamada: el tipo de retorno, y la cantidad y el tipo de los parámetros. Los nombres de los parámetros son opcionales en un prototipo, int doble(int); es válido, pero conviene ponerlos, porque documentan qué es cada uno. Es la misma relación que vimos en el tema 3 entre la declaración de printf en stdio.h y su definición en la biblioteca: un encabezado no es más que un archivo lleno de prototipos, y el tema 13 enseña a escribir los propios.
La declaración y la definición tienen que coincidir. Si no, el compilador lo detecta cuando ve las dos en el mismo archivo:
$ gcc -std=c17 -Wall -Wextra -c conf.c
conf.c:2:6: error: conflicting types for ‘doble’; have ‘long int(int)’
2 | long doble(int x)
| ^~~~~
conf.c:1:5: note: previous declaration of ‘doble’ with type ‘int(int)’
1 | int doble(int x);
| ^~~~~
5. Lo que comprueba un prototipo
Con el prototipo a la vista, el compilador controla cada llamada:
$ gcc -std=c17 -Wall -Wextra -o prototipo prototipo.c
prototipo.c: In function ‘main’:
prototipo.c:9:20: error: too many arguments to function ‘doble’; expected 1, have 2
9 | printf("%d\n", doble(21, 1)); /* un argumento de más */
| ^~~~~ ~
prototipo.c:3:5: note: declared here
3 | int doble(int x); /* prototipo: la declaración */
| ^~~~~
$ sed -i '/doble(21, 1)/d' prototipo.c
$ gcc -std=c17 -Wall -Wextra -Wconversion -o prototipo prototipo.c
prototipo.c:8:26: warning: conversion from ‘double’ to ‘int’ changes value from ‘3.8999999999999999e+0’ to ‘3’ [-Wfloat-conversion]
$ ./prototipo
42
6
La cantidad de argumentos tiene que coincidir exactamente: de más o de menos es un error. Y cada argumento se convierte al tipo de su parámetro, con las mismas reglas de la asignación del tema 7: doble(3.9) recibe 3, y devuelve 6. Sin -Wconversion, esa pérdida pasa en silencio, como cualquier conversión al asignar. La comprobación de cantidad es la razón de escribir (void) para una función sin parámetros, como vimos con main en el tema 3: con void saludar(void), la llamada saludar(1) da too many arguments to function ‘saludar’; expected 0, have 1; con void saludar(), hasta C17, se aceptaba sin queja.
6. El paso por valor
En C, todo argumento se pasa por valor: la función recibe una copia, en una variable propia, y lo que haga con ella no afecta a la variable original. Este programa lo muestra con direcciones:
#include <stdio.h>
static void intentar_cambiar(int n)
{
printf(" dentro: n = %d, en %p\n", n, (void *) &n);
n = 99;
printf(" dentro: n = %d después de asignar\n", n);
}
static void intercambiar_mal(int a, int b)
{
int tmp = a;
a = b;
b = tmp;
}
static void intercambiar(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
int main(void)
{
int valor = 5;
printf(" fuera: valor = %d, en %p\n", valor, (void *) &valor);
intentar_cambiar(valor);
printf(" fuera: valor = %d al volver\n", valor);
int x = 1, y = 2;
intercambiar_mal(x, y);
printf("intercambiar_mal: x = %d, y = %d\n", x, y);
intercambiar(&x, &y);
printf("intercambiar: x = %d, y = %d\n", x, y);
return 0;
}
$ ./copia
fuera: valor = 5, en 0x7ffe9370c42c
dentro: n = 5, en 0x7ffe9370c40c
dentro: n = 99 después de asignar
fuera: valor = 5 al volver
intercambiar_mal: x = 1, y = 2
intercambiar: x = 2, y = 1
Las direcciones son distintas: n es otra variable, en otro lugar de la pila, que empezó con una copia del 5. Por eso intercambiar_mal no intercambia nada: intercambia sus copias, que desaparecen al volver. Para que una función modifique una variable de quien la llama, tiene que recibir su dirección, que es lo que hace intercambiar: con &x le pasamos dónde está x, y con *a = … la función escribe en esa dirección. La dirección también se pasa por valor, es una copia de un número, pero una copia de una dirección sigue apuntando al mismo lugar.
Así funcionan scanf("%d", &n), stat(ruta, &info) y el &unidad de tamano.c. El operador *, los punteros y su sintaxis son el bloque 4, que empieza en el tema 15; por ahora alcanza con el patrón: &variable al llamar, tipo *parametro en la definición, y *parametro para leer o escribir la variable original. El paso por valor tiene una ventaja que conviene apreciar: al llamar a una función con f(x), sabemos con certeza que x no cambió; solo puede cambiar si escribimos f(&x).
7. El valor de retorno
return expresión; termina la función y entrega el valor, convertido al tipo de retorno. Una función void usa return; sin expresión, o simplemente llega al final. Una función que no es void tiene que devolver algo en todos los caminos posibles, y olvidarlo en uno es comportamiento indefinido, salvo en main, como vimos en el tema 3:
#include <stdio.h>
static int signo(int x)
{
if (x > 0)
return 1;
if (x < 0)
return -1;
} /* ¿y si x es 0? */
int main(void)
{
printf("signo(5) = %d, signo(-5) = %d, signo(0) = %d\n",
signo(5), signo(-5), signo(0));
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o sinreturn sinreturn.c
sinreturn.c: In function ‘signo’:
sinreturn.c:9:1: warning: control reaches end of non-void function [-Wreturn-type]
9 | } /* ¿y si x es 0? */
| ^
$ ./sinreturn
signo(5) = 1, signo(-5) = -1, signo(0) = -41837080
$ gcc -std=c17 -O2 -o sinreturn sinreturn.c 2>/dev/null && ./sinreturn
signo(5) = 1, signo(-5) = -1, signo(0) = 0
Para 0, la función llega a la llave final sin return, y quien la llamó recibe lo que haya quedado en el registro del resultado, como pasaba con void main en el tema 3: un número absurdo sin optimizar, 0 con -O2. -Wall lo advierte con -Wreturn-type, y es una de esas advertencias que siempre señalan un error real.
El que llama puede ignorar el valor de retorno: printf devuelve un número que casi nadie mira. Pero para algunas funciones, ignorarlo es casi siempre un error, y glibc las marca con un atributo que hace que el compilador avise:
$ gcc -std=c17 -Wall -Wextra -O2 -c ign.c
ign.c:4:5: warning: ignoring return value of ‘write’ declared with attribute ‘warn_unused_result’ [-Wunused-result]
Después del tema 9 sabemos por qué: ignorar lo que devuelve write es ignorar las escrituras parciales y los errores. El atributo __attribute__((warn_unused_result)) de gcc, o [[nodiscard]] en C23, se puede poner también en funciones propias cuyo resultado no deba descartarse.
8. Las convenciones del sistema
Una función de C devuelve un solo valor. Cuando tiene que entregar un resultado y además informar si funcionó, hace falta una convención, y las funciones del sistema siguen unas pocas, que conviene reconocer y también imitar:
| Convención | Éxito | Falla | Ejemplos |
|---|---|---|---|
| Estado | 0 | −1, y el motivo en errno | close, stat, unlink |
| Cantidad | Un número ≥ 0 | −1, y errno | read, write, open (un descriptor) |
| Puntero | Una dirección válida | NULL | fopen, malloc, getenv, fgets |
| Error directo | 0 | El código de error, como valor de retorno | Las funciones de hilos, pthread_create |
| Sí o no | Distinto de 0 | 0 | isatty, isdigit, leer_entero |
Cuando el resultado útil no cabe en el valor de retorno, porque este ya se usa para el estado, sale por un parámetro de salida: un puntero a una variable del llamador donde la función escribe. Es lo que hacen stat(ruta, &info), que devuelve 0 o −1 y deja la información en info; strtol(texto, &fin, 10), que devuelve el número y deja en fin dónde terminó; y leer_entero(pregunta, &edad), del tema 10, que devuelve si lo logró y deja el número en edad.
Al escribir funciones propias, la regla es elegir una convención por función, documentarla en un comentario y respetarla. El error más común es mezclarlas: una función que devuelve 0 en éxito llamada como if (funcion()), que vimos en el tema 9, o una que devuelve un tamaño que puede ser 0 y también −1. La sección RETURN VALUE del manual, del tema 2, existe justamente para esto.
9. Arreglos como parámetros
Los arreglos son la excepción aparente al paso por valor: una función no recibe una copia del arreglo, sino la dirección de su primer elemento. Por eso puede modificar sus elementos, como hacía texto_permisos en el tema 8. Y por eso mismo pierde una información crucial: el tamaño.
#include <stdio.h>
static long sumar_mal(int datos[10])
{
long total = 0;
size_t n = sizeof datos / sizeof datos[0]; /* error */
printf(" en sumar_mal, sizeof datos = %zu, n = %zu\n", sizeof datos, n);
for (size_t i = 0; i < n; i++)
total += datos[i];
return total;
}
static long sumar(const int datos[], size_t n)
{
long total = 0;
for (size_t i = 0; i < n; i++)
total += datos[i];
return total;
}
int main(void)
{
int valores[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
size_t n = sizeof valores / sizeof valores[0];
printf("en main, sizeof valores = %zu, n = %zu\n", sizeof valores, n);
printf("sumar_mal: %ld\n", sumar_mal(valores));
printf("sumar: %ld\n", sumar(valores, n));
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o arreglo arreglo.c
arreglo.c: In function ‘sumar_mal’:
arreglo.c:6:23: warning: ‘sizeof’ on array function parameter ‘datos’ will return size of ‘int *’ [-Wsizeof-array-argument]
6 | size_t n = sizeof datos / sizeof datos[0]; /* error */
| ^~~~~
…
$ ./arreglo
en main, sizeof valores = 40, n = 10
en sumar_mal, sizeof datos = 8, n = 2
sumar_mal: 3
sumar: 55
En main, sizeof valores es 40, los diez int. Dentro de sumar_mal, aunque el parámetro está escrito int datos[10], sizeof datos es 8: el tamaño de una dirección. Un parámetro declarado como arreglo es en realidad un puntero, y el 10 de la declaración es solo decorativo; el compilador lo ignora. La función calculó que había 2 elementos y sumó 1 + 2. El compilador lo advirtió con un mensaje muy explícito.
La regla que sigue todo C, incluida la biblioteca, es pasar el tamaño como un parámetro aparte, como hace sumar. Es el par que ya vimos en fgets(buf, sizeof buf, …), snprintf(buf, sizeof buf, …) y write(fd, datos, n). Por qué un arreglo se convierte en un puntero al pasarlo, y qué relación exacta hay entre ambos, es el tema 17.
10. const en los parámetros
En sumar(const int datos[], size_t n), el const es una promesa: la función no va a modificar los elementos del arreglo. El compilador la hace cumplir dentro de la función, y le dice a quien llama que puede pasarle sus datos sin riesgo. Es la misma promesa que hacen strlen(const char *s) o printf(const char *formato, …) en sus prototipos, y la que hace escribir_todo(int fd, const char *datos, size_t n).
En cambio, const aplicado a un parámetro que se pasa por valor, como int f(const int x), no promete nada a quien llama, porque la función trabaja sobre una copia de todos modos; solo impide que la propia función modifique su copia. Por eso casi no se usa. La regla práctica: const en todo parámetro que sea una dirección a datos que la función solo lee. El tema 19 explica las distintas posiciones de const con punteros.
11. La recursión y sus límites
Una función recursiva se llama a sí misma. Es la forma natural de expresar un problema que se resuelve a partir de una versión más chica del mismo problema, y siempre tiene dos partes: uno o más casos base, que se resuelven directamente, y un caso recursivo, que reduce el problema y se llama de nuevo.
#include <stdio.h>
/* máximo común divisor, por el algoritmo de Euclides */
static unsigned long mcd(unsigned long a, unsigned long b)
{
if (b == 0)
return a; /* caso base */
return mcd(b, a % b); /* caso recursivo, con un problema menor */
}
/* muestra un número en cualquier base de 2 a 16 */
static void mostrar_en_base(unsigned long n, unsigned int base)
{
if (n >= base)
mostrar_en_base(n / base, base); /* primero los dígitos de la izquierda */
putchar("0123456789abcdef"[n % base]);
}
int main(void)
{
printf("mcd(1071, 462) = %lu\n", mcd(1071, 462));
printf("493 en base 2: ");
mostrar_en_base(493, 2);
printf("\n493 en base 8: ");
mostrar_en_base(493, 8);
printf("\n493 en base 16: ");
mostrar_en_base(493, 16);
printf("\n");
return 0;
}
$ ./recursion
mcd(1071, 462) = 21
493 en base 2: 111101101
493 en base 8: 755
493 en base 16: 1ed
mostrar_en_base resuelve con elegancia un problema real: el último dígito de un número es n % base, pero hay que imprimirlo al final, después de los demás. La recursión lo hace naturalmente, porque cada llamada imprime su dígito después de que vuelve la llamada que imprime los anteriores. 493 es el 0755 de los permisos del tema 8. El mismo patrón sirve para recorrer estructuras que contienen versiones de sí mismas, como un árbol de directorios, en el que cada subdirectorio es otro árbol: así trabajan ls -R, find y du.
La recursión tiene dos límites que hay que conocer. El primero es la pila: cada llamada en curso ocupa un espacio propio para sus parámetros y variables locales, y la pila de un proceso tiene un tamaño máximo, normalmente 8 MiB en Linux. Este programa se llama a sí mismo n veces, con un arreglo local de 100 bytes en cada nivel:
$ ulimit -s
8192
$ ./profundo 10000
profundidad 10000: resultado -4856
$ ./profundo 50000
profundidad 50000: resultado -21720
$ ./profundo 60000
profundidad 60000: Segmentation fault
$ ulimit -s 1024; ./profundo 50000
profundidad 50000: Segmentation fault
Con 8192 KiB de pila, 50000 niveles caben y 60000 no: cada nivel ocupa unos 140 bytes, y 60000 de ellos superan los 8 MiB. Al pasarse, el programa toca memoria fuera de la pila y el núcleo lo termina con SIGSEGV, el famoso desbordamiento de pila (stack overflow). Con ulimit -s 1024, que reduce el límite a 1 MiB, ya no caben 50000. En un programa de sistemas, una recursión cuya profundidad depende de los datos, como un árbol de directorios con enlaces que forman un ciclo, puede agotar la pila; por eso las versiones robustas limitan la profundidad o usan un bucle con una estructura propia. El tema 12 muestra qué hay exactamente en cada nivel.
El segundo límite es el trabajo repetido. La definición de Fibonacci es recursiva, fib(n) = fib(n−1) + fib(n−2), y traducirla tal cual da una función correcta pero desastrosa:
$ ./fibonacci 20
iterativo: fib(20) = 6765
recursivo: fib(20) = 6765, con 21891 llamadas
$ ./fibonacci 30
iterativo: fib(30) = 832040
recursivo: fib(30) = 832040, con 2692537 llamadas
$ time ./fibonacci 40
iterativo: fib(40) = 102334155
recursivo: fib(40) = 102334155, con 331160281 llamadas
real 0m0.641s
fib hace dos más, y los mismos valores se calculan una y otra vez. El árbol crece casi al doble con cada n.Para fib(40), la versión recursiva hizo más de 331 millones de llamadas y tardó más de medio segundo; la iterativa, 39 vueltas de un bucle, instantánea. Cada valor se recalcula muchísimas veces, como muestra el árbol de fib(4). Para fib(90), que la versión iterativa calcula al instante, la recursiva tardaría siglos. La recursión es la herramienta adecuada cuando refleja la forma del problema y cada llamada trabaja sobre una parte distinta, como en mcd o al recorrer un árbol; cuando las llamadas se superponen, un bucle es la respuesta.
12. Funciones static e inline
Como las variables globales del tema 6, una función declarada static tiene enlace interno: solo es visible en su archivo. Todas las funciones auxiliares de los ejemplos del curso son static, por dos motivos. Uno es evitar choques de nombres cuando el programa tenga varios archivos, como veremos en el tema 13. El otro es que le da al compilador más libertad:
$ gcc -std=c17 -O2 -S -fno-asynchronous-unwind-tables -o - inl.c | sed -n '/^suma_de_cuadrados:/,/ret/p'
suma_de_cuadrados:
endbr64
imull %edi, %edi
imull %esi, %esi
leal (%rdi,%rsi), %eax
ret
$ gcc -c -O0 inl.c && nm inl.o
0000000000000000 t cuadrado
0000000000000013 T suma_de_cuadrados
$ gcc -c -O2 inl.c && nm inl.o
0000000000000000 T suma_de_cuadrados
Con -O2, el compilador copió el cuerpo de cuadrado en el lugar de cada llamada: las dos multiplicaciones están en suma_de_cuadrados, sin ningún call. Es la expansión en línea (inlining), que ahorra el costo de la llamada. Y como cuadrado es static, el compilador sabe que nadie fuera del archivo puede llamarla, y la eliminó del objeto: nm ya no la muestra. Sin optimizar, sigue ahí, con la t minúscula de los símbolos locales. Una función static que nadie usa produce además la advertencia defined but not used, que ayuda a limpiar código muerto.
La palabra clave inline le sugiere al compilador que expanda una función en línea, pero hoy es casi solo una indicación: con optimización, el compilador decide por su cuenta, y lo hace bien. Su uso real es con funciones pequeñas definidas en un encabezado, como static inline, un patrón muy común en el núcleo Linux que veremos en el tema 13. Lo importante es la consecuencia práctica: escribir funciones pequeñas y bien nombradas no cuesta rendimiento, porque el compilador las disuelve.
13. Argumentos variables
printf recibe una cantidad variable de argumentos: su prototipo es int printf(const char *formato, ...), y los tres puntos indican «y luego cualquier cantidad de argumentos de cualquier tipo». Una función propia puede hacer lo mismo con las macros de stdarg.h. El uso más común en la programación de sistemas es una función de mensajes de error con formato, como esta:
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
static const char *nombre_programa = "avisar";
/* como fprintf(stderr, ...), con el nombre del programa adelante */
__attribute__((format(printf, 1, 2)))
static void avisar(const char *formato, ...)
{
va_list args;
fprintf(stderr, "%s: ", nombre_programa);
va_start(args, formato);
vfprintf(stderr, formato, args);
va_end(args);
fputc('\n', stderr);
}
int main(int argc, char *argv[])
{
nombre_programa = argv[0];
avisar("se recibieron %d argumentos", argc - 1);
if (argc > 1)
avisar("no se puede abrir '%s'", argv[1]);
avisar("tamaño: %d", sizeof(long));
return EXIT_FAILURE;
}
$ gcc -std=c17 -Wall -Wextra -o avisar avisar.c
avisar.c: In function ‘main’:
avisar.c:26:22: warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘long unsigned int’ [-Wformat=]
26 | avisar("tamaño: %d", sizeof(long));
| ~^ ~~~~~~~~~~~~
| | |
| int long unsigned int
| %ld
$ ./avisar datos.txt
./avisar: se recibieron 1 argumentos
./avisar: no se puede abrir 'datos.txt'
./avisar: tamaño: 8
Una función variádica no sabe por sí misma cuántos argumentos recibió ni de qué tipo: lo deduce de los parámetros fijos, aquí del formato. va_list es el cursor sobre los argumentos variables; va_start lo inicializa a partir del último parámetro fijo; y en lugar de leerlos uno por uno con va_arg, avisar se los entrega completos a vfprintf, la versión de fprintf que recibe un va_list. Cada función de la familia de printf tiene su versión con v, justamente para esto. va_end cierra el cursor.
La línea __attribute__((format(printf, 1, 2))) es una extensión de gcc que le dice al compilador que el parámetro 1 es un formato de printf y los argumentos empiezan en el 2. Gracias a ella, avisar recibe las mismas comprobaciones que printf: la advertencia del %d con un size_t vino de ahí. Sin el atributo, el error habría pasado en silencio. Funciones como esta, con el nombre del programa adelante, son las que producen mensajes como ls: cannot access 'x': No such file or directory; glibc ofrece una ya hecha, error, y BSD las funciones err y warn.
14. Errores frecuentes
| Error | Qué pasa | Corrección |
|---|---|---|
| Llamar a una función antes de declararla | Error: implicit declaration of function. | Un prototipo arriba, o definirla antes de usarla. |
| Prototipo y definición distintos | Error: conflicting types; o, en archivos distintos, comportamiento indefinido. | Un solo prototipo, en un encabezado (tema 13). |
int f() para «sin parámetros» | Hasta C17, no se comprueban los argumentos. | int f(void). |
| Esperar que la función modifique un argumento | Modifica su copia. | Pasar la dirección: f(&x). |
Olvidar el return en un camino | Comportamiento indefinido: un valor basura. | -Wall avisa: -Wreturn-type. |
Ignorar el resultado de write, read, fclose | Errores y escrituras parciales inadvertidos. | Comprobar siempre; el compilador avisa con algunas. |
sizeof de un parámetro arreglo | Da el tamaño de un puntero, 8. | Pasar el tamaño como otro parámetro. |
Olvidar const en un parámetro de solo lectura | No se puede pasar un dato const, y la interfaz no documenta la intención. | const char *, const int datos[]. |
| Recursión sin caso base, o que no se acerca a él | Recursión infinita: Segmentation fault por desbordamiento de pila. | Comprobar que cada llamada reduce el problema. |
| Recursión con profundidad dependiente de los datos | Puede agotar la pila con entradas grandes. | Limitar la profundidad, o usar un bucle. |
| Recursión con llamadas superpuestas | Tiempo exponencial, como fib. | Un bucle, o guardar los resultados ya calculados. |
| Un argumento que lee lo que otro modifica | Resultado según el orden de evaluación, no especificado. | Separar en sentencias. |
15. Resumen
- Una función tiene tipo de retorno, nombre, parámetros y cuerpo;
voidindica que no devuelve nada, y(void), que no recibe nada. - Al llamar, los argumentos se evalúan en un orden no especificado, se convierten a los tipos de los parámetros y se copian en ellos.
- Una función debe estar declarada antes de usarse. Un prototipo es una declaración sin cuerpo; un encabezado es un archivo de prototipos.
- Con el prototipo, el compilador comprueba la cantidad de argumentos y convierte cada uno al tipo del parámetro.
- Todo se pasa por valor: la función trabaja sobre copias. Para modificar una variable del llamador, recibe su dirección.
- Toda función no
voiddebe devolver un valor en todos los caminos;-Wreturn-typelo advierte. - Las funciones del sistema devuelven 0 o −1 con
errno, una cantidad o −1, un puntero oNULL; los resultados extra salen por parámetros de salida. - Un arreglo se pasa como la dirección de su primer elemento, y la función pierde su tamaño: se pasa aparte.
consten un parámetro puntero promete no modificar los datos apuntados.- Una función recursiva necesita un caso base y un caso recursivo que reduzca el problema. Cada nivel ocupa pila, cuyo límite suele ser 8 MiB; superarlo es un desbordamiento de pila.
- La recursión con llamadas superpuestas, como Fibonacci, repite trabajo de forma exponencial.
- Las funciones
staticson locales al archivo; el compilador puede expandirlas en línea y eliminarlas. - Las funciones variádicas usan
stdarg.h; convfprintfy__attribute__((format))se escribe una función de mensajes con las comprobaciones deprintf.
16. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué diferencia hay entre la declaración y la definición de una función?
La declaración, o prototipo, presenta la función: su tipo de retorno, su nombre y los tipos de sus parámetros, sin cuerpo, terminada en punto y coma. Es lo que el compilador necesita para comprobar y compilar una llamada. La definición incluye además el cuerpo, el código que se ejecuta. Una función puede declararse muchas veces, pero se define una sola.
2. En prototipo.c, ¿por qué doble(3.9) devuelve 6?
Porque el prototipo dice que el parámetro es int, y al llamar el argumento se convierte a ese tipo con las reglas de la asignación: 3,9 se trunca a 3. La función recibe 3 y devuelve 6. -Wconversion advierte de la pérdida.
3. ¿Por qué intercambiar_mal(x, y) no intercambia x e y?
Porque los argumentos se pasan por valor: a y b son variables nuevas, con copias de los valores de x e y. La función intercambia las copias, que desaparecen al volver, y x e y no se tocan. Para modificarlas hay que pasar sus direcciones, como hace intercambiar(&x, &y).
4. ¿Qué devuelve signo(0) en sinreturn.c, y por qué cambia con -O2?
Nada definido: para 0, la función llega al final sin ejecutar ningún return, lo que es comportamiento indefinido. El llamador recibe lo que haya quedado en el registro del resultado, que sin optimizar fue un número absurdo y con -O2 fue 0. -Wall lo advierte con control reaches end of non-void function.
5. ¿Cómo devuelve stat la información de un archivo, si su valor de retorno ya indica éxito o error?
Por un parámetro de salida: recibe la dirección de una variable struct stat del llamador y escribe en ella la información. El valor de retorno, 0 o −1, queda libre para indicar si funcionó, y en caso de error el motivo queda en errno.
6. ¿Por qué sizeof datos vale 8 dentro de sumar_mal, si el parámetro se declaró como int datos[10]?
Porque un parámetro declarado como arreglo es en realidad un puntero al primer elemento: la función recibe una dirección, no una copia del arreglo, y el 10 de la declaración se ignora. sizeof da el tamaño de la dirección, 8 bytes. El tamaño del arreglo hay que pasarlo como un parámetro aparte.
7. ¿Qué promete el const en sumar(const int datos[], size_t n), y por qué no tendría sentido en size_t n?
Promete que la función no modificará los elementos del arreglo, que pertenecen al llamador; el compilador lo hace cumplir. En size_t n, que se pasa por valor, la función trabaja sobre su propia copia, de modo que modificarla no afecta al llamador y un const no le promete nada.
8. ¿Por qué mostrar_en_base imprime los dígitos en el orden correcto, si calcula primero el último?
Porque la llamada recursiva, que imprime los dígitos de la izquierda, ocurre antes del putchar que imprime el dígito actual. Cada nivel espera a que vuelvan los niveles más profundos antes de imprimir su propio dígito, de modo que el primero en imprimirse es el del nivel más profundo: el dígito más significativo.
9. ¿Por qué ./profundo 60000 termina con Segmentation fault, y qué cambia ulimit -s?
Porque cada llamada en curso ocupa espacio en la pila, unos 140 bytes en este programa, y 60000 llamadas superan el límite de 8 MiB que Linux da a la pila por omisión. Al pasarse, el programa accede a memoria fuera de la pila y el núcleo lo termina con SIGSEGV. ulimit -s muestra y cambia ese límite: con 1 MiB, ya no caben ni 50000 llamadas.
10. ¿Por qué el Fibonacci recursivo hizo 331 millones de llamadas para fib(40)?
Porque cada llamada hace dos llamadas más, y las dos ramas recalculan los mismos valores: fib(38) se calcula en ambas, fib(37) tres veces, y así sucesivamente. La cantidad de llamadas crece casi al doble con cada n. La versión iterativa calcula cada valor una sola vez, en 39 vueltas.
11. ¿Por qué nm no muestra cuadrado en inl.o compilado con -O2?
Porque el compilador expandió su cuerpo en línea dentro de suma_de_cuadrados, y como la función es static, sabe que nadie fuera del archivo puede llamarla. Sin llamadas pendientes, no hace falta generar su código, y la eliminó.
12. ¿Para qué sirve __attribute__((format(printf, 1, 2))) en avisar?
Le indica al compilador que el parámetro 1 es una cadena de formato de printf y que los argumentos a formatear empiezan en el 2. Con eso, el compilador comprueba cada llamada a avisar como si fuera printf, y advierte de formatos que no coinciden con sus argumentos, como el %d con un size_t.
Para practicar
- Agrega a
tamano.cuna funciónformatear(double bytes, char *destino, size_t tam)que escriba el texto en un arreglo del llamador consnprintf, en lugar de imprimirlo. Úsala para alinear una tabla de cinco archivos. - Escribe
dividir(int a, int b, int *cociente, int *resto)que devuelva 0 si pudo dividir y −1 sibes 0, siguiendo la convención del sistema. - Escribe una función
maximo(const int datos[], size_t n, int *resultado)que devuelva 0 en éxito y −1 si el arreglo está vacío. Pruébala con un arreglo de un elemento y con uno vacío. - Escribe una versión recursiva y una iterativa de una función que cuente los dígitos de un número en una base dada. Compara su comportamiento con
ULONG_MAXen base 2. - Mide hasta qué profundidad llega
profundo.cconulimit -sen 2048, 4096 y 16384. Luego cambia el tamaño derellenoa 1000 y vuelve a medir. ¿Es proporcional? - Agrega a
fibonacci.cuna versión recursiva que guarde los valores ya calculados en un arreglo estático de 100 elementos, y cuenta sus llamadas para fib(90). - Extiende
avisar.ccon una funciónmorir(const char *formato, ...)que muestre el mensaje comoavisary luego termine el programa conexit(EXIT_FAILURE). Ponle el atributoformaty tambiénnoreturn(_Noreturnen C11,[[noreturn]]en C23).