Lección 5 de 8: punteros en C. Los punteros tienen fama de difíciles, pero la idea es sencilla: una variable que guarda dónde está otra. Son lo que permite a C ser tan rápido y trabajar tan cerca de la máquina, y también el origen de sus errores más graves. Esta lección los explica desde cero, con esquemas, y enseña a usarlos sin miedo y sin descuidos.
Qué es un puntero
Cada variable vive en algún lugar de la memoria, y ese lugar tiene un número: su dirección. Un puntero es una variable que guarda una dirección. No hay más misterio: es un número que dice dónde está otra cosa. Dos operadores lo hacen todo:
&xda la dirección dex.*pda lo que hay en la dirección que guardap. Se lee «lo apuntado por p».
* se llega a su valor. Esquema: decodigo.com.
#include <stdio.h>
int main(void)
{
int precio = 250;
int *p = &precio;
printf("precio vale %d\n", precio);
printf("*p vale %d\n", *p);
printf("¿p guarda la dirección de precio? %s\n", p == &precio ? "sí" : "no");
printf("Un puntero ocupa %zu bytes\n", sizeof p);
*p = 199;
printf("Tras *p = 199, precio vale %d\n", precio);
return 0;
}salidaprecio vale 250 *p vale 250 ¿p guarda la dirección de precio? sí Un puntero ocupa 8 bytes Tras *p = 199, precio vale 199
En la declaración int *p, el asterisco dice «p es un puntero a int»; en el resto del código, *p significa «ir a donde apunta p». Es el mismo símbolo con dos papeles, y es lo que más confunde al principio. Escribir *p = 199 cambia precio, porque p apunta ahí.
Para ver la dirección se usa el hueco %p de printf. Cambia en cada ejecución, porque Linux coloca los programas en posiciones aleatorias de la memoria para dificultar los ataques (se llama ASLR):
#include <stdio.h>
int main(void)
{
int precio = 250;
printf("%p\n", (void *) &precio);
return 0;
}salida (cambia en cada ejecución)0x7ffcea58934c
El puntero nulo
Un puntero que no apunta a nada vale nulo. En C23 se escribe nullptr; en código anterior verás NULL. Muchas funciones lo devuelven para decir «no hay resultado», como strchr o fopen, así que hay que comprobarlo antes de usar *:
#include <stdio.h>
#include <string.h>
int main(void)
{
const char *correo = "ana.lopez@ejemplo.com";
const char *arroba = strchr(correo, '@');
if (arroba != nullptr) {
printf("Dominio: %s\n", arroba + 1);
}
const char *espacio = strchr(correo, ' ');
if (espacio == nullptr) {
printf("No tiene espacios\n");
}
return 0;
}salidaDominio: ejemplo.com No tiene espacios
El error que te ahorra: usar un puntero nulo
#include <stdio.h>
#include <string.h>
int main(void)
{
const char *correo = "sin-arroba.com";
const char *arroba = strchr(correo, '@');
printf("Primera letra del dominio: %c\n", *(arroba + 1));
return 0;
}salida con error==106==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000001 (pc 0x00000040125b bp 0x7ffcbf5499c0 sp 0x7ffcbf5499a0 T0)
==106==The signal is caused by a READ memory access.
==106==Hint: address points to the zero page.
#0 0x00000040125b in main /curso/nulo_mal.c:8
…
SUMMARY: AddressSanitizer: SEGV /curso/nulo_mal.c:8 in mainSin sanitizadores, el programa muere con un escueto Segmentation fault. Con ellos sabes la línea, que fue una lectura y que la dirección está en la «página cero», la zona de memoria reservada para atrapar los punteros nulos (aquí, nulo más 1). Cada vez que una función pueda devolver nulo, compruébalo.
Punteros como parámetros
En la lección 3 vimos que una función recibe copias y no puede cambiar las variables de quien la llama. Con punteros sí: se le pasa la dirección y la función escribe ahí. Así se arregla el intercambiar que no funcionaba, y así devuelven las funciones de C más de un resultado:
#include <stdio.h>
void intercambiar(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
bool dividir(int total, int partes, int *cociente, int *resto)
{
if (partes == 0) return false;
*cociente = total / partes;
*resto = total % partes;
return true;
}
int main(void)
{
int x = 1, y = 2;
intercambiar(&x, &y);
printf("x=%d y=%d\n", x, y);
int c, r;
if (dividir(17, 5, &c, &r)) {
printf("17 = 5 * %d + %d\n", c, r);
}
if (!dividir(17, 0, &c, &r)) {
printf("No se puede dividir entre 0\n");
}
return 0;
}salidax=2 y=1 17 = 5 * 3 + 2 No se puede dividir entre 0
Es el patrón más común de C: la función devuelve si tuvo éxito y escribe los resultados a través de punteros. strtol(linea, &fin, 10), de la lección 1, hace exactamente eso con fin.
Arrays y punteros
El nombre de un array, usado en una expresión, se convierte en un puntero a su primer elemento. Por eso int *p = datos; funciona, por eso las funciones reciben arrays como punteros, y por eso p[i] y *(p + i) son lo mismo:
#include <stdio.h>
int main(void)
{
int datos[4] = {10, 20, 30, 40};
int *p = datos;
printf("%d %d %d\n", *p, *(p + 1), p[2]);
printf("Entre &datos[3] y &datos[0] hay %td elementos\n", &datos[3] - &datos[0]);
printf("y %td bytes\n", (char *) &datos[3] - (char *) &datos[0]);
for (int *q = datos; q < datos + 4; q++) {
printf("%d ", *q);
}
printf("\n");
return 0;
}salida10 20 30 Entre &datos[3] y &datos[0] hay 3 elementos y 12 bytes 10 20 30 40
Restar dos punteros del mismo array da cuántos elementos hay entre ellos (%td es el hueco para ese tipo, ptrdiff_t). Recorrer un array con un puntero que avanza, como el último bucle, es muy habitual en C y en las bibliotecas estándar. Solo es válido mientras el puntero esté dentro del array o justo una posición después del final: más allá es comportamiento indefinido, aunque no lo leas.
La diferencia que queda entre array y puntero se ve con sizeof: el de un array es su tamaño completo; el de un puntero, 8 bytes, apunte a lo que apunte. Es lo que pasaba en la lección 4 al usar sizeof dentro de una función.
const y los punteros
#include <stdio.h>
#include <ctype.h>
size_t contar_mayusculas(const char *s)
{
size_t n = 0;
for (; *s != '\0'; s++) {
if (isupper((unsigned char) *s)) n++;
}
return n;
}
int main(void)
{
printf("%zu\n", contar_mayusculas("Hola Mundo, Desde C"));
return 0;
}salida4
const char *s es un puntero a caracteres que la función promete no modificar: puede mover s (el s++), pero no escribir *s = 'x'. Al revés, char *const s sería un puntero fijo a datos modificables. La regla para leerlo: const afecta a lo que tiene a su izquierda, o a lo de la derecha si está al principio. Marca con const todo puntero que una función solo lea: documenta la intención y el compilador la vigila.
void * y los punteros a función: qsort
Un void * es un puntero «a cualquier cosa»: guarda una dirección sin decir de qué tipo. No se puede usar * con él directamente; hay que convertirlo antes al tipo real. Y un puntero a función guarda la dirección de una función, para llamarla más tarde. Los dos se juntan en qsort, la función de ordenación de la biblioteca estándar, que ordena cualquier array si le das una función que compare dos elementos:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int por_numero(const void *a, const void *b)
{
int x = *(const int *) a;
int y = *(const int *) b;
return (x > y) - (x < y);
}
int por_texto(const void *a, const void *b)
{
const char *x = *(const char *const *) a;
const char *y = *(const char *const *) b;
return strcmp(x, y);
}
int main(void)
{
int edades[] = {34, 7, 81, 19, 52};
qsort(edades, 5, sizeof edades[0], por_numero);
for (int i = 0; i < 5; i++) printf("%d ", edades[i]);
printf("\n");
const char *frutas[] = {"pera", "manzana", "uva", "kiwi"};
qsort(frutas, 4, sizeof frutas[0], por_texto);
for (int i = 0; i < 4; i++) printf("%s ", frutas[i]);
printf("\n");
int (*comparar)(const void *, const void *) = por_numero;
int a = 3, b = 9;
printf("%d\n", comparar(&a, &b));
return 0;
}salida7 19 34 52 81 kiwi manzana pera uva -1
La función de comparación devuelve un negativo, cero o un positivo. El (x > y) - (x < y) parece rebuscado, pero es la forma correcta: el atajo habitual, return x - y, se desborda con números grandes de signo contrario. La declaración de un puntero a función, int (*comparar)(const void *, const void *), se lee «comparar es un puntero a una función que recibe dos const void * y devuelve int».
Ojo con la seguridad: los punteros colgantes
Un puntero no sabe si lo que apunta sigue existiendo. El caso típico: una función devuelve la dirección de una de sus variables locales, que deja de existir al volver. El puntero queda colgante y usarlo es comportamiento indefinido, de esos que a veces funcionan. GCC lo detecta en los casos sencillos:
#include <stdio.h>
int *crear_contador(void)
{
int contador = 0;
return &contador;
}
int main(void)
{
int *c = crear_contador();
printf("%d\n", *c);
return 0;
}gcc: avisocolgante.c: In function 'crear_contador':
colgante.c:6:12: warning: function returns address of local variable [-Wreturn-local-addr]
6 | return &contador;
| ^~~~~~~~~Para que algo sobreviva a la función que lo crea hace falta memoria dinámica, con malloc, que es justo la lección 6. Y la misma trampa, en versión dinámica (usar memoria ya liberada), es uno de los fallos más explotados de C.
Resumen
- ParaEscribeRecuerda
- Dirección de x
&ximprímela con%py un cast avoid * - Lo apuntado por p
*pcomprueba antes que no sea nulo - Puntero que no apunta a nada
nullptrNULLen código anterior a C23 - Devolver varios resultadosparámetros
int *saliday devolver si hubo éxito - Recorrer un array
p[i]o*(p + i)sin pasar del final - Prometer no modificar
const char *sen todo lo que solo se lee - Ordenar cualquier cosa
qsort(a, n, sizeof a[0], cmp)cmp sin restar
Retos
Reto 1. Escribe void min_max(const int *a, size_t n, int *min, int *max), que deje en *min y *max el menor y el mayor del array.
Ver la solución
#include <stdio.h>
void min_max(const int *a, size_t n, int *min, int *max)
{
*min = *max = a[0];
for (size_t i = 1; i < n; i++) {
if (a[i] < *min) *min = a[i];
if (a[i] > *max) *max = a[i];
}
}
int main(void)
{
int temperaturas[] = {18, 25, 9, 31, 22};
int menor, mayor;
min_max(temperaturas, 5, &menor, &mayor);
printf("Mínima %d, máxima %d\n", menor, mayor);
return 0;
}salidaMínima 9, máxima 31
Una sola pasada por el array y dos resultados por punteros. Una versión más robusta comprobaría también que n no sea 0.
Reto 2. Escribe tu propia versión de strlen usando solo punteros: sin índices.
Ver la solución
#include <stdio.h>
size_t mi_strlen(const char *s)
{
const char *p = s;
while (*p != '\0') {
p++;
}
return (size_t) (p - s);
}
int main(void)
{
printf("%zu %zu %zu\n", mi_strlen("decodigo"), mi_strlen(""), mi_strlen("C23"));
return 0;
}salida8 0 3
Se avanza hasta el nulo y se resta el principio. Así es, a grandes rasgos, la strlen de los libros; la de glibc lee de 8 en 8 bytes para ir más rápido.
Reto 3. ¿Qué escribe? int a[] = {1, 2, 3}; int *p = a; *p++ = 10; printf("%d %d %d %d\n", a[0], a[1], *p, p[1]);
Ver la solución
#include <stdio.h>
int main(void)
{
int a[] = {1, 2, 3};
int *p = a;
*p++ = 10;
printf("%d %d %d %d\n", a[0], a[1], *p, p[1]);
return 0;
}salida10 2 2 3
*p++ escribe en lo apuntado y después avanza el puntero: el ++ se aplica a p, no al valor. Así que a[0] pasa a 10, y p queda apuntando a a[1], por lo que *p es 2 y p[1] es 3.
Todo el código de esta lección se compiló con GCC 15.3 (C23, -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con gcc --version: hace falta GCC 15 o Clang 19 para todo lo de C23.
