El puntero nulo: NULL, nullptr y el peligro del Undefined Behavior

Un puntero es una variable que almacena la dirección de memoria de otra variable. Sin embargo, no siempre apuntamos a algo útil. El puntero nulo es un valor especial que indica que un puntero no es válido o no apunta a ningún objeto en la memoria direccionable. En el estándar C11, esto se representa mediante la macro NULL, definida en cabeceras como <stddef.h> o <stdio.h>, que habitualmente se expande a ((void*)0) o simplemente 0. A diferencia de un entero 0 común, NULL está diseñado específicamente para ser tratado como una dirección de memoria nula.

A partir de C23, se introduce la palabra clave nullptr, que es una constante con su propio tipo (nullptr_t). Esto resuelve una ambigüedad histórica en C donde un 0 podía ser interpretado indistintamente como un entero o un puntero, permitiendo al compilador realizar comprobaciones de tipos mucho más estrictas.

¿Por qué existe este valor? Se utiliza como un centinela para indicar errores, la ausencia de datos o para inicializar punteros de forma segura. Debes usarlo siempre que declares un puntero que aún no tiene una dirección válida asignada o cuando una función (como malloc) no pueda cumplir su objetivo. Si ignoras esta convención y desreferencias un puntero nulo (intentar acceder al valor en la dirección 0), entras en el terreno del Comportamiento Indefinido (Undefined Behavior o UB).

Aunque en la práctica un puntero nulo suele provocar un error de segmentación (SIGSEGV) que detiene el proceso, el estándar C no garantiza esto. El riesgo real es más sutil: si el compilador detecta que has desreferenciado un puntero nulo, puede asumir que ese puntero nunca es nulo en el resto del programa y, por optimización, eliminar tus comprobaciones de seguridad if (p != NULL), dejando tu código vulnerable y errático.

#include <stdio.h>
#include <stdlib.h>
#include <stddef.h> // Para NULL

typedef struct {
    int id;
    char *nombre;
} Persona;

/* 
 * Simula la creación de una persona. 
 * Retorna un puntero a la estructura o NULL si la asignación falla.
 */
Persona* crear_persona(int id, const char* nombre_input) {
    // Intentamos reservar memoria para la estructura principal
    Persona *p = malloc(sizeof(Persona));
    if (p == NULL) {
        return NULL; // Error de memoria: malloc falló
    }

    p->id = id;
    p->nombre = malloc(32);

    // Si la segunda asignación falla, debemos liberar la primera 
    // para evitar una fuga de memoria (memory leak).
    if (p->nombre == NULL) {
        free(p);
        return NULL;
    }

    // Copia de seguridad simple de la cadena
    for (int i = 0; i < 31 && nombre_input[i] != '\0'; i++) {
        p->nombre[i] = nombre_input[i];
        p->nombre[i+1] = '\0';
    }

    return p;
}

/* 
 * Función que consume el puntero. 
 * Aplica el patrón de defensa contra punteros nulos.
 */
void mostrar_persona(Persona *p) {
    // Validación crítica: nunca desreferencies un puntero sin comprobarlo primero.
    if (p == NULL) {
        printf("Error: Intentaste acceder a una persona que no existe (NULL).\n");
        return;
    }

    // Validamos también la integridad de los miembros internos
    if (p->nombre == NULL) {
        printf("Error: La persona existe pero su nombre es nulo.\n");
        return;
    }

    printf("Persona [ID: %d, Nombre: %s]\n", p->id, p->nombre);
}

int main(void) {
    // 1. Inicialización segura: Evita "punteros basura" (wild pointers).
    Persona *usuario = NULL;

    // 2. Caso de uso exitoso
    usuario = crear_persona(1, "Carlos");
    if (usuario != NULL) {
        mostrar_persona(usuario);

        // Limpieza: liberar memoria y resetear el puntero
        free(usuario->nombre);
        free(usuario);
        usuario = NULL; 
    }

    // 3. Caso de error: Mostrar un puntero que es explícitamente NULL
    mostrar_persona(NULL);

    return 0;
}

Desglose del funcionamiento

En el ejemplo, malloc es la función clave que interactúa con el sistema operativo para reservar memoria. Si el sistema se queda sin recursos, malloc devuelve NULL. Fíjate en la función crear_persona: si la segunda asignación de p->nombre falla, es imperativo llamar a free(p) antes de retornar NULL; de lo contrario, la memoria de la estructura Persona quedaría bloqueada para siempre (fuga de memoria).

En mostrar_persona, la instrucción if (p == NULL) es nuestra línea de defensa. Si no existiera, la expresión p->id intentaría leer la dirección 0x0, lo cual es una operación ilegal en la arquitectura de la CPU y dispararía una excepción de protección de memoria.

En main, la inicialización Persona *usuario = NULL; es una práctica esencial. Si simplemente hiciéramos Persona *usuario;, el puntero contendría “basura” (una dirección aleatoria que existía en el stack). Intentar usar ese puntero “errante” es tan peligroso como usar un NULL, pero mucho más difícil de depurar porque el programa podría no colapsar de inmediato, sino corromper datos de otras variables de forma silenciosa.

El error frecuente

Un error clásico de principiante es desreferenciar un puntero inmediatamente después de comprobarlo, o confiar en que un puntero desasignado es seguro.

int *p = NULL;
// ... algún código ...
*p = 10; // ¡ERROR! Desreferencia de puntero nulo (UB)

Este código es un desastre. Aunque el compilador te dé un Segmentation fault, el problema real ocurre con las optimizaciones: si el compilador detecta que p ha sido desreferenciado en una línea anterior, puede decidir que la comprobación if (p != NULL) que escribas después es redundante y eliminarla por completo, permitiendo que el error se propague de formas impredecibles. Para detectar esto durante el desarrollo, utiliza siempre AddressSanitizer compilando con -fsanitize=address.

44

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio