wandres.dev
EXPERTO · Punteros II: avanzado

Punteros a punteros: indirección de segundo orden

El doble puntero como herramienta para modificar el puntero del llamador, la anatomía real de argv como array de cadenas, y el patrón del puntero al enlace que elimina los casos especiales.

⏱ 16 min

Un puntero es una variable, y como toda variable tiene su propia dirección. Ahí nace el doble puntero: no un truco arcano, sino la consecuencia inevitable de que en C todo se pasa por valor, punteros incluidos. Dominarlo cambia cómo escribes APIs de asignación, cómo lees argv y cómo se te disuelven la mitad de los casos especiales de una lista enlazada.

🎯 Al terminar esta lección sabrás
  • Entender por qué un puntero necesita a su vez una dirección.
  • Modificar el puntero del llamador desde una función con char **.
  • Diseccionar argv como array de punteros a cadenas.
  • Aplicar el patrón del puntero al enlace para borrar casos especiales.

El puntero también vive en memoria

Cuando declaras int *p, reservas una variable cuyo contenido es una dirección. Esa variable ocupa memoria y tiene su propia dirección, que obtienes con &p y cuyo tipo es int **. No hay magia: solo un nivel más de la misma indirección.

int    n = 42;
int   *p = &n;      // p contiene la direccion de n
int  **q = &p;      // q contiene la direccion de p

printf("%d\n", n);      // 42
printf("%d\n", *p);     // 42
printf("%d\n", **q);    // 42
*q = nullptr;           // modifica p, no n

Léelo siempre de derecha a izquierda desde el identificador: int **q es “q es un puntero a un puntero a int”. Cada * que aplicas quita exactamente un nivel de indirección, y cada & añade uno.

flowchart LR
Q[q de tipo int doble asterisco] --> P[p de tipo int asterisco]
P --> N[n con valor 42]
style Q fill:#cba6f7,color:#11111b
style P fill:#89b4fa,color:#11111b
style N fill:#a6e3a1,color:#11111b

Modificar el puntero del llamador

C pasa siempre por valor. Cuando pasas int *p, la función recibe una copia de la dirección: puede cambiar el objeto apuntado, pero nunca a dónde apunta p en el llamador. Si lo que debe cambiar es el puntero mismo, hay que pasar su dirección.

// MAL: la asignacion se pierde al volver
void reservar_mal(int *destino, size_t n) {
    destino = malloc(n * sizeof *destino);   // solo toca la copia local
}

// BIEN: recibimos la direccion del puntero
bool reservar(int **destino, size_t n) {
    int *tmp = malloc(n * sizeof *tmp);
    if (!tmp) return false;                  // no tocamos *destino si falla
    *destino = tmp;                          // ahora si: modificamos el original
    return true;
}

int *v = nullptr;
if (reservar(&v, 100)) { v[0] = 7; }
free(v);

Fíjate en el detalle profesional: la reserva se hace sobre un temporal y solo se publica en *destino cuando ha tenido éxito. Así una llamada fallida deja el puntero del llamador exactamente como estaba, en vez de dejarlo apuntando a nada. Es la misma disciplina de la que nace el idioma de liberación segura:

void liberar(void **p) {
    free(*p);
    *p = nullptr;      // el llamador se queda sin puntero colgante
}

Este patrón está por todas partes en la librería estándar y en el sistema: getline(char **linea, size_t *n, FILE *f) puede reasignar tu búfer y por eso recibe char **; strtol(const char *s, char **fin, int base) te devuelve por fin dónde se detuvo el análisis.

🎯

Reasignar el búfer

Funciones como getline crecen la memoria del llamador. Solo pueden hacerlo si reciben char **.

📍

Devolver una posición

strtol informa por su parámetro char **fin de dónde terminó de leer, sin tocar el valor de retorno.

🔗

Reenlazar estructuras

Insertar o borrar en una lista significa cambiar un puntero ajeno. El doble puntero es la vía directa.

argv por dentro

int main(int argc, char **argv) es el doble puntero más leído del mundo. argv no es una matriz de caracteres: es un array de punteros, cada uno apuntando a una cadena terminada en cero que vive en otro sitio. La norma garantiza además que argv[argc] es un puntero nulo, así que puedes recorrerlo sin usar argc.

flowchart LR
ARGV[argv de tipo char doble asterisco] --> S0[argv sub 0]
ARGV --> S1[argv sub 1]
ARGV --> S2[argv sub argc igual a NULL]
S0 --> T0[bytes de prog y cero final]
S1 --> T1[bytes de la opcion v y cero final]
style ARGV fill:#cba6f7,color:#11111b
style S2 fill:#f9e2af,color:#11111b
int main(int argc, char **argv) {
    for (int i = 0; i < argc; i++)
        printf("%d: %s\n", i, argv[i]);

    for (char **p = argv; *p != nullptr; p++)   // sin usar argc
        puts(*p);
}

Aquí argv[i] y *(argv + i) son la misma expresión, y argv[i][j] es el byte j de la cadena i. Un char ** y un char [][N] no son intercambiables: el primero es un array de direcciones, el segundo un bloque contiguo. Pasar uno donde se espera el otro es un error clásico y el compilador te avisará.

El patrón del puntero al enlace

Aquí es donde el doble puntero deja de ser mecánica y se vuelve elegancia. Borrar un nodo de una lista enlazada parece exigir un caso especial para la cabeza, porque la cabeza no tiene predecesor:

// version ingenua: dos ramas, una para la cabeza y otra para el resto
void borrar_ingenuo(Nodo **cabeza, int valor) {
    Nodo *actual = *cabeza, *previo = nullptr;
    while (actual && actual->dato != valor) { previo = actual; actual = actual->siguiente; }
    if (!actual) return;
    if (previo) previo->siguiente = actual->siguiente;
    else        *cabeza = actual->siguiente;      // el caso especial
    free(actual);
}

Ahora observa qué ocurre si en vez de recordar el nodo anterior recordamos el puntero que hay que reescribir:

void borrar(Nodo **enlace, int valor) {
    while (*enlace) {
        Nodo *actual = *enlace;
        if (actual->dato == valor) {
            *enlace = actual->siguiente;   // sirve para la cabeza y para el resto
            free(actual);
            return;
        }
        enlace = &actual->siguiente;       // el proximo enlace a reescribir
    }
}

Desaparecen la variable previo, la rama condicional y el caso especial. El bucle no distingue si el enlace que sostiene es la cabeza de la lista o el campo siguiente de un nodo intermedio: ambos son, exactamente, un Nodo * que hay que actualizar.

La indirección correcta borra los casos especiales

Esta transformación es una de las lecciones más citadas del diseño de software en C, y Linus Torvalds la usa como ejemplo canónico de lo que llama buen gusto: el código malo trata la cabeza como una excepción; el bueno se da cuenta de que la cabeza nunca fue una excepción, solo la estábamos mirando desde el nivel de indirección equivocado. Al pasar de “recuerdo el nodo anterior” a “recuerdo el puntero que voy a reescribir”, el caso especial no se resuelve: deja de existir. Generaliza la idea, porque va mucho más allá de las listas. Cuando un algoritmo tuyo tenga una rama para el primer elemento, para el caso vacío o para el borde, pregúntate si no estarás modelando la estructura en el nivel de indirección equivocado. Un nodo centinela, un puntero al enlace o una tabla en lugar de un switch suelen hacer que la rama se evapore. Menos ramas es menos código, menos bugs, menos fallos de predicción de salto y —sobre todo— menos superficie donde esconder un error. La calidad en C rara vez viene de escribir más comprobaciones; viene de elegir la representación que las hace innecesarias.

⚔️ Sube un nivel de indirección
  1. Escribe reservar(int **destino, size_t n) que solo modifique el puntero del llamador si malloc tuvo éxito.
  2. Implementa liberar(void **p) que libere y ponga a nullptr, y comprueba con un sanitizer que elimina el puntero colgante.
  3. Recorre argv sin usar argc, apoyándote en el NULL final que garantiza la norma.
  4. Reescribe el borrado de tu lista enlazada con el patrón del puntero al enlace y cuenta cuántas ramas has eliminado.
  5. Implementa la inserción ordenada con la misma técnica: un solo bucle, sin caso especial para la cabeza.