← Programación en C para Sistemas Operativos Bloque 3 · Funciones y organización · Tema 11 de 42

Tema 11

Funciones

Usamos funciones desde el primer tema, propias y de la biblioteca, sin detenernos en ellas. Este tema, que abre el bloque 3, las estudia en serio. En C, una función tiene un tipo para cada parámetro y para su resultado, el compilador comprueba cada llamada contra su declaración, y los argumentos llegan siempre copiados: una función no puede cambiar las variables de quien la llama, salvo que reciba su dirección. Veremos cómo se declaran y se definen, qué pasa con los arreglos que se pasan como parámetros, qué convenciones siguen las funciones del sistema para informar errores, cómo funciona la recursión y dónde están sus límites, y cómo escribir una función que, como printf, recibe una cantidad variable de argumentos.

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:

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:

tamano.c
#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;
}
terminal
$ ./tamano
hola             15.6 KiB
libc.so.6         2.0 MiB
disco           476.9 GiB
ParteEn escalarSignificado
EspecificadoresstaticLa función solo es visible en este archivo (sección 12).
Tipo de retornodoubleEl tipo del valor que devuelve. void si no devuelve nada.
NombreescalarSigue las reglas de los identificadores del tema 3.
Parámetrosdouble bytes, const char **unidadVariables 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:

  1. 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
  2. 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
  3. Se ejecuta el cuerpo, hasta un return o hasta la llave final. ejecutar
  4. 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:

terminal · antes.c: main llama a doble, que está definida más abajo
$ 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:

prototipo.c
#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:

terminal · conf.c: el prototipo dice int, la definición dice long
$ 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:

terminal · prototipo.c, tal como está y sin la línea del argumento de más
$ 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:

copia.c
#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;
}
terminal
$ ./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
El paso por valor: la variable valor de main, con 5, está en la dirección 0x7ffe9370c42c; al llamar a intentar_cambiar, su valor se copia en el parámetro n, otra variable en la dirección 0x7ffe9370c40c, que luego pasa a valer 99 sin afectar a valor main valor 5 0x7ffe9370c42c intentar_cambiar n 5 → 99 0x7ffe9370c40c se copia Dos variables, dos direcciones: cambiar n no toca a valor, y al volver n desaparece.
El argumento y el parámetro son variables distintas. La llamada solo copia el valor de una en la otra.

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:

sinreturn.c
#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;
}
terminal · sin optimizar y con -O2
$ 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:

terminal · ign.c: write(1, "hola\n", 5); sin mirar el resultado
$ 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ÉxitoFallaEjemplos
Estado0−1, y el motivo en errnoclose, stat, unlink
CantidadUn número ≥ 0−1, y errnoread, write, open (un descriptor)
PunteroUna dirección válidaNULLfopen, malloc, getenv, fgets
Error directo0El código de error, como valor de retornoLas funciones de hilos, pthread_create
Sí o noDistinto de 00isatty, 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.

arreglo.c
#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;
}
terminal
$ 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.

recursion.c
#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;
}
terminal
$ ./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:

terminal · profundo.c, con distintas profundidades
$ 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:

terminal · fibonacci.c: la versión recursiva y la iterativa
$ ./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
El árbol de llamadas de fib(4): fib(4) llama a fib(3) y fib(2); fib(3) llama a fib(2) y fib(1); cada fib(2) llama a fib(1) y fib(0). fib(2) se calcula dos veces, y fib(1) tres fib(4) fib(3) fib(2) fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) 9 llamadas para fib(4); fib(2) se calcula dos veces
Cada llamada a 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:

terminal · inl.c: una función static cuadrado usada por suma_de_cuadrados
$ 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:

avisar.c
#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;
}
terminal
$ 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

ErrorQué pasaCorrección
Llamar a una función antes de declararlaError: implicit declaration of function.Un prototipo arriba, o definirla antes de usarla.
Prototipo y definición distintosError: 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 argumentoModifica su copia.Pasar la dirección: f(&x).
Olvidar el return en un caminoComportamiento indefinido: un valor basura.-Wall avisa: -Wreturn-type.
Ignorar el resultado de write, read, fcloseErrores y escrituras parciales inadvertidos.Comprobar siempre; el compilador avisa con algunas.
sizeof de un parámetro arregloDa el tamaño de un puntero, 8.Pasar el tamaño como otro parámetro.
Olvidar const en un parámetro de solo lecturaNo 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 élRecursión infinita: Segmentation fault por desbordamiento de pila.Comprobar que cada llamada reduce el problema.
Recursión con profundidad dependiente de los datosPuede agotar la pila con entradas grandes.Limitar la profundidad, o usar un bucle.
Recursión con llamadas superpuestasTiempo exponencial, como fib.Un bucle, o guardar los resultados ya calculados.
Un argumento que lee lo que otro modificaResultado según el orden de evaluación, no especificado.Separar en sentencias.

15. Resumen

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

  1. Agrega a tamano.c una función formatear(double bytes, char *destino, size_t tam) que escriba el texto en un arreglo del llamador con snprintf, en lugar de imprimirlo. Úsala para alinear una tabla de cinco archivos.
  2. Escribe dividir(int a, int b, int *cociente, int *resto) que devuelva 0 si pudo dividir y −1 si b es 0, siguiendo la convención del sistema.
  3. 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.
  4. 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_MAX en base 2.
  5. Mide hasta qué profundidad llega profundo.c con ulimit -s en 2048, 4096 y 16384. Luego cambia el tamaño de relleno a 1000 y vuelve a medir. ¿Es proporcional?
  6. Agrega a fibonacci.c una versión recursiva que guarde los valores ya calculados en un arreglo estático de 100 elementos, y cuenta sus llamadas para fib(90).
  7. Extiende avisar.c con una función morir(const char *formato, ...) que muestre el mensaje como avisar y luego termine el programa con exit(EXIT_FAILURE). Ponle el atributo format y también noreturn (_Noreturn en C11, [[noreturn]] en C23).