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.
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.
- Entender por qué un puntero necesita a su vez una dirección.
- Modificar el puntero del llamador desde una función con
char **. - Diseccionar
argvcomo 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.
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.
- Escribe
reservar(int **destino, size_t n)que solo modifique el puntero del llamador simalloctuvo éxito. - Implementa
liberar(void **p)que libere y ponga anullptr, y comprueba con un sanitizer que elimina el puntero colgante. - Recorre
argvsin usarargc, apoyándote en elNULLfinal que garantiza la norma. - Reescribe el borrado de tu lista enlazada con el patrón del puntero al enlace y cuenta cuántas ramas has eliminado.
- Implementa la inserción ordenada con la misma técnica: un solo bucle, sin caso especial para la cabeza.