← Programación en C para Sistemas Operativos Bloque 4 · Punteros · Tema 16 de 42

Tema 16

Punteros y funciones

En el tema 11 vimos que una función recibe copias de sus argumentos, y que para modificar una variable de quien la llama necesita su dirección. Ahora que sabemos qué es un puntero, podemos estudiar a fondo esa idea, que es la forma en que se comunican casi todas las funciones de sistemas. Veremos qué ocurre exactamente en la memoria cuando una función recibe un puntero, cómo una función entrega varios resultados a la vez, cómo se marcan los parámetros opcionales, qué punteros puede devolver una función sin peligro, y una trampa sutil: qué pasa cuando dos parámetros puntero apuntan al mismo lugar.

1. Por qué hacen falta

Repasemos el punto de partida del tema 11. En C todo argumento se pasa por valor: al llamar a una función, el valor de cada argumento se copia en un parámetro, que es una variable nueva de la función, en su propio marco de la pila (tema 12). La función puede modificar su copia, pero la copia desaparece al volver. Eso impone dos límites:

Los punteros resuelven las dos cosas con un solo mecanismo: en lugar de pasar el valor de una variable, se pasa su dirección. La dirección también se copia, pero una copia de una dirección sigue señalando el mismo lugar, y a través de ella la función puede leer y escribir la variable original. Otros lenguajes llaman a esto paso por referencia; en C no es un mecanismo aparte, sino paso por valor de un puntero.

2. Un parámetro puntero, en la memoria

Este programa muestra las direcciones en juego cuando una función recibe un puntero a una variable de main:

marcos.c
#include <stdio.h>

static void duplicar(int *p)
{
    printf("  en duplicar: p = %p (vive en %p), *p = %d\n", (void *) p, (void *) &p, *p);
    *p = *p * 2;
}

int main(void)
{
    int x = 21;

    printf("en main:       &x = %p, x = %d\n", (void *) &x, x);
    duplicar(&x);
    printf("en main:       x = %d\n", x);
    return 0;
}
terminal
$ ./marcos
en main:       &x = 0x7ffe730fe5e4, x = 21
  en duplicar: p = 0x7ffe730fe5e4 (vive en 0x7ffe730fe5c8), *p = 21
en main:       x = 42
Un parámetro puntero: en el marco de main está x, en 0x7ffe730fe5e4, que pasa de 21 a 42; en el marco de duplicar, más abajo en la pila, está el parámetro p, en 0x7ffe730fe5c8, que contiene la dirección de x y apunta hacia el marco de main marco de main x 21 → 42 0x7ffe730fe5e4 marco de duplicar p 0x7ffe730fe5e4 0x7ffe730fe5c8 *p = *p * 2 escribe en x, en el marco de main
El parámetro p es una variable de duplicar, en su propio marco; su valor es la dirección de x, en el marco de main.

Se ven las dos variables. p vive en el marco de duplicar, más abajo en la pila, y desaparecerá al volver. Pero su valor es la dirección de x, que está en el marco de main, y *p = *p * 2 escribe ahí. Es la única forma en que una función puede alcanzar las variables locales de otra: que esa otra le pase su dirección explícitamente, con &. Y por eso la llamada duplicar(&x) es una advertencia visual para quien lee: x puede cambiar. Una llamada como f(x), en cambio, garantiza que x no cambia.

3. Entrada, salida y entrada y salida

Un parámetro puntero puede cumplir tres papeles distintos, y documentar cuál es forma parte de la interfaz de la función:

PapelLa función…Se declaraEjemplos
EntradaSolo lee el dato apuntado.const T *strlen(const char *s), write(…, const void *buf, …)
SalidaSolo escribe; no le importa el valor previo.T *stat(…, struct stat *info), pipe(int fds[2]), read(…, void *buf, …)
Entrada y salidaLee el valor y lo modifica.T *getline(char **linea, size_t *capacidad, …), duplicar(int *p)

El const es lo único que el compilador entiende de esta tabla, y por eso es valioso: un parámetro const T * es una promesa verificada de que la función no modifica el dato, y quien llama puede pasarle sus datos sin miedo, incluidos datos que son const. Distinguir salida de entrada y salida, en cambio, solo puede hacerlo la documentación: un parámetro de salida puede recibir la dirección de una variable sin inicializar, uno de entrada y salida no. Los manuales de las secciones 2 y 3 lo indican en la descripción de cada parámetro.

4. Devolver varios resultados

El uso más común de los parámetros de salida es entregar varios resultados. La convención habitual en la programación de sistemas, que vimos en el tema 11, es que el valor de retorno indique si la función funcionó, y que los resultados salgan por parámetros puntero, al final de la lista:

estadisticas.c
#include <stdio.h>
#include <stddef.h>

/*
 * Calcula el mínimo, el máximo y el promedio de los n valores de datos.
 * Entrada:  datos (n elementos), n.
 * Salida:   *minimo, *maximo, *promedio.
 * Devuelve 0 si pudo calcularlos, -1 si n es 0.
 */
static int estadisticas(const int datos[], size_t n,
                        int *minimo, int *maximo, double *promedio)
{
    if (n == 0)
        return -1;

    int menor = datos[0], mayor = datos[0];
    long suma = 0;

    for (size_t i = 0; i < n; i++) {
        if (datos[i] < menor)
            menor = datos[i];
        if (datos[i] > mayor)
            mayor = datos[i];
        suma += datos[i];
    }
    *minimo = menor;
    *maximo = mayor;
    *promedio = (double) suma / (double) n;
    return 0;
}

int main(void)
{
    int tiempos[] = {120, 85, 230, 97, 150};
    int min, max;
    double prom;

    if (estadisticas(tiempos, 5, &min, &max, &prom) == 0)
        printf("mínimo %d ms, máximo %d ms, promedio %.1f ms\n", min, max, prom);

    if (estadisticas(tiempos, 0, &min, &max, &prom) != 0)
        printf("sin datos: no hay estadísticas\n");
    return 0;
}
terminal
$ ./estadisticas
mínimo 85 ms, máximo 230 ms, promedio 136.4 ms
sin datos: no hay estadísticas

La función calcula en variables locales, menor, mayor y suma, y escribe los resultados en los parámetros de salida solo al final, cuando sabe que todo salió bien. Es un buen hábito: si la función falla, como con n igual a 0, no deja las variables del llamador a medio modificar. Por eso main solo usa min, max y prom después de comprobar que la función devolvió 0: con −1, siguen sin valor. El comentario de la cabecera documenta el papel de cada parámetro y la convención de retorno, que es lo que alguien necesita para usar la función sin leer su código. La alternativa de devolver una estructura con los tres resultados es del tema 24.

5. Parámetros opcionales

A veces quien llama no necesita todos los resultados. La convención para un parámetro de salida opcional es aceptar NULL en su lugar, y que la función compruebe antes de escribir:

opcional.c
#include <stdio.h>

/*
 * Divide a entre b. Si cociente o resto no son NULL, deja ahí los resultados.
 * Devuelve 0 si pudo dividir, -1 si b es 0.
 */
static int dividir(int a, int b, int *cociente, int *resto)
{
    if (b == 0)
        return -1;
    if (cociente != NULL)
        *cociente = a / b;
    if (resto != NULL)
        *resto = a % b;
    return 0;
}

int main(void)
{
    int c, r;

    if (dividir(17, 5, &c, &r) == 0)
        printf("17 / 5: cociente %d, resto %d\n", c, r);

    if (dividir(4096, 512, NULL, &r) == 0 && r == 0)
        printf("4096 es múltiplo de 512\n");

    if (dividir(1, 0, &c, &r) != 0)
        printf("no se puede dividir por cero\n");
    return 0;
}
terminal
$ ./opcional
17 / 5: cociente 3, resto 2
4096 es múltiplo de 512
no se puede dividir por cero

La segunda llamada solo quiere saber el resto, y pasa NULL como cociente. La biblioteca usa la misma convención en muchos lugares, y ya la vimos sin notarlo: time(NULL), en el tema 5, pedía la hora sin guardarla en ningún lado; y strtol, en el tema 10, acepta NULL en lugar del puntero donde informar dónde terminó de leer. El manual de cada función dice qué parámetros pueden ser NULL; los que no lo dicen, no pueden.

6. Comprobar los punteros recibidos

Una función que recibe un puntero obligatorio, ¿debe comprobar que no sea NULL? Hay dos escuelas, y la biblioteca de C sigue la segunda: un puntero obligatorio es responsabilidad de quien llama, y pasar NULL es un error de programación, como pasar un índice fuera de rango. Así, strlen(NULL) no devuelve un error: termina el programa. Lo que sí puede hacerse es ayudar a detectar el error, y gcc ofrece un atributo para eso:

nonull.c
#include <stdio.h>
#include <string.h>

__attribute__((nonnull(1)))
static size_t contar_espacios(const char *texto)
{
    size_t n = 0;

    for (size_t i = 0; texto[i] != '\0'; i++)
        if (texto[i] == ' ')
            n++;
    return n;
}

int main(void)
{
    printf("%zu\n", contar_espacios("uno dos tres"));
    printf("%zu\n", contar_espacios(NULL));
    printf("%zu\n", strlen(NULL));
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o nonull nonull.c
nonull.c: In function ‘main’:
nonull.c:18:21: warning: argument 1 null where non-null expected [-Wnonnull]
   18 |     printf("%zu\n", contar_espacios(NULL));
      |                     ^~~~~~~~~~~~~~~
…
nonull.c:19:21: warning: argument 1 null where non-null expected [-Wnonnull]
   19 |     printf("%zu\n", strlen(NULL));
      |                     ^~~~~~
In file included from nonull.c:2:
/usr/include/string.h:439:15: note: in a call to function ‘strlen’ declared ‘nonnull’
  439 | extern size_t strlen (const char *__s)
      |               ^~~~~~
$ ./nonull
2
Segmentation fault

El atributo nonnull(1) declara que el primer parámetro no puede ser NULL, y el compilador advierte cuando ve un NULL literal en esa posición. La nota final muestra que glibc declara así a strlen y a muchas otras funciones. El atributo solo detecta los casos evidentes; un puntero que llega a ser nulo en tiempo de ejecución, como el resultado de un getenv no comprobado, pasa sin advertencia y termina en Segmentation fault, como pasó aquí con la segunda llamada.

Para funciones propias, la recomendación práctica es combinar las dos ideas: documentar qué parámetros pueden ser NULL; marcar con nonnull los que no; y comprobar los obligatorios con un assert del tema 14, que detecta el error durante el desarrollo, en el lugar exacto, con un mensaje claro. Un if que devuelve un error tiene sentido para los punteros que vienen de datos externos, no para los que dependen de que el programador haya escrito bien la llamada.

7. Devolver un puntero

Una función también puede devolver un puntero. El tema 12 estableció la regla: nunca la dirección de una variable local. Pero hay varios punteros que una función puede devolver con toda seguridad, y uno de los más útiles es un puntero dentro de los datos que recibió:

ruta.c
#include <stdio.h>

/*
 * Devuelve un puntero al último componente de una ruta, dentro de la
 * misma cadena: "/usr/bin/gcc" -> "gcc". No copia nada.
 */
static const char *ultimo_componente(const char *ruta)
{
    const char *resultado = ruta;

    for (const char *p = ruta; *p != '\0'; p++)
        if (*p == '/' && p[1] != '\0')
            resultado = p + 1;
    return resultado;
}

int main(void)
{
    const char *rutas[] = {"/usr/bin/gcc", "notas.txt", "/etc/", "../src/main.c"};

    for (int i = 0; i < 4; i++) {
        const char *ultimo = ultimo_componente(rutas[i]);
        printf("%-16s -> %-10s (posición %td dentro de la cadena)\n",
               rutas[i], ultimo, ultimo - rutas[i]);
    }
    return 0;
}
terminal
$ ./ruta
/usr/bin/gcc     -> gcc        (posición 9 dentro de la cadena)
notas.txt        -> notas.txt  (posición 0 dentro de la cadena)
/etc/            -> etc/       (posición 1 dentro de la cadena)
../src/main.c    -> main.c     (posición 7 dentro de la cadena)

La función no copia ni crea nada: recorre la cadena con un puntero, p++, y devuelve la dirección del carácter donde empieza el último componente, que está dentro de la misma cadena que recibió. Restar dos punteros al mismo arreglo da la distancia entre ellos, con %td: es la aritmética de punteros del tema 18. El resultado es válido mientras lo sea la cadena original, y por eso el puntero devuelto es const, como el recibido. Así trabajan muchas funciones de string.h, como strchr, que devuelve un puntero al primer carácter buscado, o NULL si no está. Este resumen reúne qué punteros puede devolver una función:

Devolver la dirección de…¿Es seguro?Válida mientras…Ejemplos
Una variable localNoYa no existe al volver.El colgante.c del tema 12.
Algo dentro de los datos recibidosSíExistan los datos originales.strchr, ultimo_componente
El buffer que pasó quien llamaSíExista ese buffer.fgets, strcpy, nombre_senal del tema 12
Una variable static o globalSí, con cuidadoHasta la siguiente llamada, que la pisa.getenv, strerror
Memoria pedida con mallocSíQuien llama la libere con free.strdup (tema 28)

8. Las llamadas al sistema

La interfaz entre un programa y el núcleo está hecha casi enteramente de punteros. Las llamadas al sistema reciben direcciones de la memoria del proceso, y el núcleo lee de ellas o escribe en ellas. Este programa usa cuatro:

sistema.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <time.h>
#include <unistd.h>

int main(void)
{
    /* time: devuelve el instante y, si recibe una dirección, también lo deja ahí */
    time_t guardado;
    time_t devuelto = time(&guardado);
    printf("time devolvió %jd y guardó %jd\n", (intmax_t) devuelto, (intmax_t) guardado);

    /* pipe: llena un arreglo de dos enteros con dos descriptores */
    int extremos[2];
    if (pipe(extremos) != 0) {
        perror("pipe");
        return 1;
    }
    printf("pipe creó los descriptores %d (lectura) y %d (escritura)\n", extremos[0], extremos[1]);

    /* write y read: reciben la dirección de los bytes a escribir o del lugar donde leer */
    const char mensaje[] = "hola por la tubería";
    char recibido[64];
    write(extremos[1], mensaje, sizeof mensaje);
    ssize_t n = read(extremos[0], recibido, sizeof recibido);
    printf("read dejó %zd bytes en recibido: \"%s\"\n", n, recibido);
    return 0;
}
terminal
$ ./sistema
time devolvió 1792108110 y guardó 1792108110
pipe creó los descriptores 3 (lectura) y 4 (escritura)
read dejó 21 bytes en recibido: "hola por la tubería"

Cada llamada usa un parámetro puntero en uno de los papeles de la sección 3. time tiene un parámetro de salida opcional: entrega el resultado de las dos formas. pipe recibe un arreglo de dos enteros y el núcleo escribe en él los descriptores de una tubería nueva, el 3 y el 4, porque 0, 1 y 2 ya estaban ocupados. write recibe un puntero de entrada, const void *, del que el núcleo copia los bytes; read, uno de salida, en el que el núcleo deja los bytes leídos. Que 21 bytes, y no 20, lleguen a destino se explica por la í de dos bytes y el \0 final, que sizeof mensaje incluye.

Esto tiene una consecuencia importante: el núcleo confía en las direcciones que recibe solo hasta cierto punto. Antes de escribir en una dirección del proceso, comprueba que pertenezca a memoria válida y escribible del propio proceso. Si un programa le pasa a read un puntero inválido, el núcleo no muere ni escribe en cualquier lado: la llamada falla con el error EFAULT, «dirección incorrecta», que veremos en el tema 37:

terminal · efault.c: read(0, (void *) 1, 10), con un puntero inválido
$ echo hola | ./efault
-1
read: Bad address

El programa no murió: el núcleo se negó a escribir en la dirección 1 y devolvió −1. Si el mismo programa hubiera escrito en esa dirección con *(char *) 1 = 'x', habría recibido SIGSEGV. La diferencia es quién hace el acceso: el procesador, cuando lo hace el programa, o el núcleo, que comprueba antes. (Al compilar, gcc además advierte que read escribiría en una región «de tamaño 0», porque glibc declara a read con un atributo que describe su parámetro de salida.)

9. Cuando los parámetros se superponen

Una función con dos parámetros puntero suele suponer, sin decirlo, que apuntan a lugares distintos. Si quien llama pasa dos veces la misma dirección, o dos zonas que se superponen, el resultado puede sorprender:

solapar.c
#include <stdio.h>
#include <string.h>

/* intercambio sin variable auxiliar, con XOR: un truco conocido */
static void intercambiar_xor(int *a, int *b)
{
    *a ^= *b;
    *b ^= *a;
    *a ^= *b;
}

int main(void)
{
    int x = 3, y = 7;

    intercambiar_xor(&x, &y);
    printf("x = %d, y = %d\n", x, y);

    intercambiar_xor(&x, &x);          /* los dos parámetros son alias */
    printf("tras intercambiar x consigo misma: x = %d\n", x);

    char texto1[] = "abcdefgh";
    char texto2[] = "abcdefgh";
    memmove(texto1 + 2, texto1, 5);    /* origen y destino se superponen */
    memcpy(texto2 + 2, texto2, 5);     /* lo mismo con memcpy: indefinido */
    printf("memmove: %s\nmemcpy:  %s\n", texto1, texto2);
    return 0;
}
terminal · sin optimizar, con -O2 y con AddressSanitizer
$ gcc -std=c17 -Wall -Wextra -o solapar solapar.c && ./solapar
x = 7, y = 3
tras intercambiar x consigo misma: x = 0
memmove: ababcdeh
memcpy:  ababcdeh
$ gcc -std=c17 -Wall -Wextra -O2 -o solapar solapar.c
…
warning: ‘__builtin_memcpy’ accessing 5 bytes at offsets 2 and 0 overlaps 3 bytes at offset 2 [-Wrestrict]
$ ./solapar | tail -n 2
memmove: ababcdeh
memcpy:  ababcdch
$ gcc -std=c17 -g -fsanitize=address -o solapar solapar.c && ./solapar 2>&1 | grep ERROR
==496==ERROR: AddressSanitizer: memcpy-param-overlap: memory ranges [0x73b5c9ad0062,0x73b5c9ad0067) and [0x73b5c9ad0060, 0x73b5c9ad0065) overlap

El intercambio con XOR funciona con dos variables distintas, pero cuando a y b son la misma, la primera línea hace *a ^= *a, que da 0, y el valor se pierde para siempre. Es un caso extremo de lo que vimos con los alias en el tema 15: una función que escribe por un parámetro y lee por otro no puede suponer que son independientes. El intercambio con variable auxiliar del tema 11 no tiene este problema.

Con las funciones de la biblioteca la distinción está documentada. memmove copia bytes y funciona aunque el origen y el destino se superpongan: aquí copió abcde dos posiciones más adelante, y el resultado es el correcto, ababcdeh. memcpy hace lo mismo pero exige que las zonas no se superpongan, a cambio de poder ser más rápida; con zonas superpuestas, el resultado es indefinido. Sin optimizar dio el resultado correcto por casualidad; con -O2 dio otro, ababcdch, y el compilador lo advirtió. AddressSanitizer lo detecta al ejecutar y muestra los dos rangos que se pisan.

10. El calificador restrict

¿Cómo sabe el compilador que memcpy no admite superposición? Por su prototipo:

terminal · el prototipo de memcpy en glibc
$ grep -n -A1 'extern void \*memcpy' /usr/include/string.h
47:extern void *memcpy (void *__restrict __dest, const void *__restrict __src,
48-		     size_t __n) __THROW __nonnull ((1, 2));

restrict, agregado en C99, es una promesa del programador: mientras dure la función, la memoria a la que se accede a través de ese puntero no se accede a través de ningún otro parámetro. Con esa promesa, el compilador puede optimizar más, por ejemplo cargando muchos bytes de una vez sin preocuparse de que una escritura cambie lo que todavía no leyó. Si quien llama rompe la promesa, el resultado es comportamiento indefinido, que es exactamente lo que vimos con memcpy. Fíjate también en el __nonnull ((1, 2)), la macro de glibc para el atributo de la sección 6.

En el curso no escribiremos funciones con restrict, pero hay que saber leerlo: cuando un prototipo lo tiene, los argumentos no pueden superponerse. Aparece en printf, strcpy, snprintf y casi todas las funciones que copian datos.

11. Olvidar el &

El error más frecuente con los parámetros puntero es pasar el valor de la variable en lugar de su dirección. Por suerte, el compilador lo detecta, y desde gcc 14 es un error:

terminal · olvido.c: duplicar(x) y scanf("%d", edad), sin &
$ gcc -std=c17 -Wall -Wextra -o olvido olvido.c
olvido.c: In function ‘main’:
olvido.c:13:14: error: passing argument 1 of ‘duplicar’ makes pointer from integer without a cast [-Wint-conversion]
   13 |     duplicar(x);                  /* olvidamos el & */
      |              ^
      |              |
      |              int
olvido.c:13:14: note: possible fix: take the address with ‘&’
   13 |     duplicar(x);                  /* olvidamos el & */
      |              ^
      |              &
…
olvido.c:14:13: warning: format ‘%d’ expects argument of type ‘int *’, but argument 2 has type ‘int’ [-Wformat=]
   14 |     scanf("%d", edad);            /* también aquí */

El compilador incluso sugiere la corrección. Con scanf, que recibe argumentos variables, solo puede advertir gracias a su conocimiento de los formatos; con versiones antiguas del compilador, o sin -Wall, el programa compilaba y scanf escribía en la «dirección» que formara la basura guardada en edad, que ni siquiera estaba inicializada, con el resultado previsible. Es otra razón para compilar siempre con advertencias y tratarlas como errores.

12. Cuándo pasar un puntero

Que se pueda pasar un puntero no significa que convenga hacerlo siempre. Una guía práctica:

SituaciónConvienePor qué
Un solo resultado, sin posibilidad de errorDevolverlo con returnEs lo más claro: int doble(int x).
Un resultado y un estadoEstado por return, resultado por punteroLa convención del sistema: stat, leer_entero.
Varios resultadosVarios punteros de salida, o una estructuraUn puntero por resultado, al final de la lista.
Datos pequeños de entradaPasarlos por valorUn int o un double se copian sin costo, y la llamada garantiza que no cambian.
Datos grandes de entradaconst T *Copiar 8 bytes de dirección es más barato que copiar el dato entero.
Un arregloPuntero y tamañoNo hay otra forma: el arreglo se pasa siempre como dirección (tema 17).
Modificar un dato del llamadorT *Es la única forma.

13. Diseñar la interfaz de una función

Con punteros, el prototipo de una función dice mucho más que con tipos simples, y conviene escribirlo con cuidado. Estas convenciones, que siguen la biblioteca de C y el núcleo Linux, hacen que las funciones sean fáciles de usar bien y difíciles de usar mal:

  1. Los datos de entrada primero, los de salida al final. dividir(a, b, &cociente, &resto). Una excepción establecida son las funciones que copian, como memcpy y strcpy, con el destino primero, imitando la asignación. orden
  2. const en todo puntero de entrada. Lo verifica el compilador y documenta la intención. const
  3. Un puntero a datos siempre acompañado de su tamaño. (const char *datos, size_t n), (char *destino, size_t tam). tamaño
  4. El valor de retorno indica el éxito, con una convención clara y documentada: 0 y −1, una cantidad o −1, un puntero o NULL. retorno
  5. Escribir las salidas solo al final, cuando todo salió bien, para no dejar al llamador con datos a medias. atomicidad
  6. Documentar en el encabezado qué parámetros pueden ser NULL, cuáles se leen, cuáles se escriben, y cuánto tiempo es válido un puntero devuelto. documentar

14. Errores frecuentes

ErrorQué pasaCorrección
Pasar x donde se espera &xError de compilación desde gcc 14; con scanf, solo advertencia.Pasar la dirección: &x.
Usar los resultados sin comprobar el retornoVariables sin valor si la función falló.Comprobar el valor de retorno antes de usar las salidas.
Escribir en un parámetro de salida opcional sin comprobar NULLSegmentation fault cuando el llamador pasa NULL.if (p != NULL) *p = …;
Pasar NULL a un parámetro obligatorioSegmentation fault dentro de la función.Comprobar antes de llamar; nonnull y assert en las funciones propias.
Devolver la dirección de una variable localPuntero colgante (tema 12).Devolver datos recibidos, un buffer del llamador o memoria dinámica.
Olvidar const en un parámetro de entradaNo se puede pasar un dato const; la interfaz no dice que no lo modifica.const T *.
Suponer que dos parámetros puntero no se superponenResultados incorrectos si el llamador pasa alias.Documentarlo, o escribir la función para que tolere la superposición.
memcpy con zonas superpuestasComportamiento indefinido: el resultado cambia con la optimización.memmove.
Guardar un puntero devuelto por getenv o strerror y usarlo más tardeOtra llamada puede cambiar o invalidar lo apuntado.Copiar el contenido si se necesita después.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. Si en C todo se pasa por valor, ¿cómo puede duplicar(&x) modificar x?

Porque lo que se pasa por valor es la dirección de x. El parámetro p es una variable nueva con una copia de esa dirección, pero una copia de una dirección apunta al mismo lugar. *p = … escribe en esa dirección, que es la de x, en el marco de main.

2. En marcos.c, ¿por qué p y &p son distintos, y cuál coincide con &x?

p es el valor del parámetro: la dirección de x, que coincide con &x. &p es la dirección del propio parámetro, una variable del marco de duplicar, más abajo en la pila. Son dos variables distintas: una contiene la dirección de la otra.

3. ¿Qué diferencia hay entre un parámetro de salida y uno de entrada y salida, y por qué el compilador no puede distinguirlos?

Uno de salida solo se escribe, de modo que puede recibir la dirección de una variable sin inicializar; uno de entrada y salida se lee antes de modificarse, de modo que la variable debe tener un valor válido. Los dos se declaran igual, T *, y el compilador no sabe qué hace la función con ellos; solo la documentación lo dice.

4. ¿Por qué estadisticas calcula en variables locales y escribe en *minimo, *maximo y *promedio solo al final?

Para que, si la función falla, no deje las variables del llamador modificadas a medias. Escribir las salidas solo cuando todo salió bien hace que el llamador vea o todos los resultados, o ninguno.

5. ¿Cómo se indica que un parámetro de salida es opcional, y qué debe hacer la función?

Por convención, el llamador pasa NULL en lugar de una dirección cuando no necesita ese resultado, y la función comprueba si el puntero es NULL antes de escribir en él. La documentación debe decir qué parámetros admiten NULL.

6. ¿Por qué strlen(NULL) termina el programa en lugar de devolver un error?

Porque la biblioteca considera que pasar NULL a un parámetro obligatorio es un error de programación del llamador, no una situación a informar. strlen no comprueba: lee la dirección 0 y el programa recibe SIGSEGV. glibc la declara nonnull, y el compilador advierte cuando ve un NULL literal en esa posición.

7. ¿Por qué es seguro que ultimo_componente devuelva un puntero, si resultado es una variable local?

Porque devuelve el valor de resultado, que es una dirección dentro de la cadena recibida, no la dirección de la variable resultado. La cadena pertenece al llamador y sigue existiendo después de volver, de modo que el puntero sigue siendo válido mientras ella lo sea.

8. ¿Qué hace el núcleo con el puntero que recibe read, y qué pasa si es inválido?

Es un parámetro de salida: el núcleo copia en esa dirección de la memoria del proceso los bytes leídos. Antes comprueba que la dirección corresponda a memoria escribible del proceso; si no, no escribe nada y la llamada falla con el error EFAULT.

9. ¿Por qué intercambiar_xor(&x, &x) deja x en 0?

Porque a y b apuntan a la misma variable. La primera línea, *a ^= *b, es en realidad x ^= x, que da 0, y las siguientes operan sobre ceros. La función supone sin decirlo que sus dos parámetros apuntan a lugares distintos.

10. ¿Qué diferencia hay entre memcpy y memmove?

Las dos copian n bytes de un origen a un destino. memmove funciona aunque las zonas se superpongan; memcpy exige que no se superpongan, lo que declara con restrict en su prototipo, y a cambio puede ser más rápida. Con zonas superpuestas, memcpy es comportamiento indefinido: en solapar.c, el resultado cambió con -O2.

11. ¿Qué promete restrict en un parámetro?

Que, mientras dure la función, la memoria accedida a través de ese puntero no se accede a través de ningún otro parámetro. El compilador usa la promesa para optimizar. Si quien llama la rompe, pasando zonas superpuestas, el comportamiento es indefinido.

12. ¿Cuándo conviene devolver un resultado con return en lugar de usar un parámetro puntero?

Cuando hay un solo resultado y la función no puede fallar, o el resultado mismo puede indicar el fallo, como un puntero NULL. Es más claro, y la llamada se puede usar dentro de una expresión. Los parámetros puntero se reservan para cuando hay que devolver un estado y un resultado, o varios resultados, o modificar un dato del llamador.

Para practicar

  1. Escribe void ordenar3(int *a, int *b, int *c), que deje los tres valores en orden creciente. Pruébala con todas las permutaciones de 1, 2 y 3, y también con ordenar3(&x, &x, &y).
  2. Agrega a estadisticas un parámetro de salida opcional para la cantidad de valores por encima del promedio. Llámala una vez pidiéndolo y otra pasando NULL.
  3. Escribe int separar_extension(const char *nombre, const char **extension), que deje en *extension un puntero al texto después del último punto, dentro de la misma cadena, y devuelva 1 si encontró una extensión o 0 si no. Pruébala con "datos.tar.gz", "Makefile" y ".bashrc".
  4. Agrega __attribute__((nonnull)) y un assert a ultimo_componente, y comprueba qué detecta cada uno con una llamada con NULL literal y otra con el resultado de un getenv que no existe.
  5. Llama a read con un puntero claramente inválido, como (void *) 1, leyendo de la entrada estándar, y muestra el valor devuelto y errno con perror. Compáralo con escribir directamente en *(char *) 1.
  6. Escribe tu propia versión de memmove que copie byte a byte, eligiendo copiar de adelante hacia atrás o al revés según cómo se superpongan las zonas. Pruébala con los casos de solapar.c.
  7. Revisa las funciones de los temas 9 a 14 que reciben punteros (escribir_todo, leer_entero, formato_tamano) y escribe para cada una un comentario de cabecera que documente el papel de cada parámetro y la convención de retorno.