Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Aritmética de punteros y matrices dinámicas

Universidad Nacional de Río Negro

Prerrequisitos: punteros, arreglos, sizeof y memoria dinámica básica.

Objetivos: 1. Calcular el desplazamiento válido de un puntero dentro de un arreglo. 2. Representar una matriz dinámica con su estrategia de liberación.

Comprobación de salida: justificá por qué sumar uno a int * no equivale a sumar un byte.

Aritmética de punteros y matrices dinámicas

Introducción

Este apunte explora conceptos avanzados de memoria dinámica en C, construyendo sobre las bases presentadas en Modelo de memoria y pila y Punteros. Aquí profundizamos en el manejo de Estructuras y Tipos Compuestos que contienen punteros, problemas comunes de gestión de memoria, y técnicas para trabajar con matrices dinámicas.

Problemas Comunes de Memoria Dinámica

Funciones Adicionales de Gestión de Memoria

Más allá de malloc y free, C proporciona funciones adicionales para manipular memoria dinámica. Estas se detallan completamente en Memoria dinámica: propiedad y ciclo de vida.

calloc: Asignación con Inicialización
1
void *calloc(size_t cantidad, size_t tamaño);

Asigna memoria para un arreglo de cantidad elementos, cada uno de tamaño bytes. Crucialmente, inicializa toda la memoria a cero.

Comparación con malloc:

1
2
3
4
5
6
// Usando malloc
int *arr1 = malloc(10 * sizeof(int));
// arr1[i] contiene basura
// Usando calloc
int *arr2 = calloc(10, sizeof(int));
// arr2[i] == 0 para todo i
realloc: Redimensionar Bloques
1
void *realloc(void *bloque, size_t nuevo_tamaño);

Cambia el tamaño de un bloque de memoria existente. Esta función es fundamental para implementar arrays redimensionables.

Comportamiento de realloc

Table 2:Casos de realloc

Condición

Comportamiento

Notas

bloque == NULL

Equivalente a malloc(nuevo_tamaño)

Útil para simplificar código

nuevo_tamaño == 0

Equivalente a free(bloque)

Devuelve NULL

nuevo_tamaño > tamaño_original

Expande el bloque

Memoria adicional no inicializada

nuevo_tamaño < tamaño_original

Reduce el bloque

Datos más allá de nuevo_tamaño se pierden

Uso Correcto de realloc

Patrón correcto:

1
2
3
4
5
6
7
8
int *temp = realloc(arr, nuevo_tamaño * sizeof(int));
if (temp == NULL)
{
    // realloc falló, arr sigue válido
    // Manejar error (liberar arr si es necesario)
    return ERROR;
}
arr = temp; // Éxito: actualizar puntero
¿Por Qué realloc Puede Mover el Bloque?

Si no hay espacio contiguo para expandir el bloque en su ubicación actual, realloc:

  1. Asigna un nuevo bloque más grande en otra ubicación

  2. Copia los datos del bloque original al nuevo

  3. Libera el bloque original

  4. Retorna la dirección del nuevo bloque

Proceso de realloc cuando debe mover el bloque a una nueva ubicación.

Figure 2:Proceso de realloc cuando debe mover el bloque a una nueva ubicación.

memset: Relleno de Memoria
1
void *memset(void *destino, int valor, size_t count);

Rellena los primeros count bytes de destino con valor (convertido a unsigned char).

Usos comunes:

1
2
3
4
5
6
7
// Inicializar array a cero
int arr[100];
memset(arr, 0, sizeof(arr));
// Limpiar buffer sensible
char password[64];
// ... usar password ...
memset(password, 0, sizeof(password)); // Borrar rastros
memcpy: Copia de Memoria
1
void *memcpy(void *destino, const void *origen, size_t count);

Copia count bytes desde origen a destino. Las regiones no deben solaparse.

Ejemplo:

1
2
3
4
int src[5] = {1, 2, 3, 4, 5};
int dst[5];
memcpy(dst, src, sizeof(src));
// dst == {1, 2, 3, 4, 5}

Arreglos de Largo Variable (VLA)

Los VLA (Variable Length Arrays) son arreglos cuyo tamaño se determina en tiempo de ejecución, no en compilación.

1
2
3
4
void funcion(int cantidad)
{
    int arreglo[cantidad]; // <-- VLA: tamaño determinado en runtime
}
¿Por Qué No Son Parte del Camino Principal?
1. Asignación en el Stack

Los VLAs se crean en el stack, no en el heap (ver Comparación Stack vs Heap). El stack tiene tamaño limitado (típicamente 1-8 MB).

1
2
3
4
5
void procesar(int n)
{
    int datos[n]; // VLA en el stack
    // Si n es grande (por ejemplo, 1,000,000), esto causa stack overflow
}
2. No Hay Mecanismo de Error

A diferencia de malloc, que retorna NULL si falla, un VLA cuya dimensión excede la capacidad del stack en tiempo de ejecución simplemente provoca un desbordamiento del stack (stack overflow) y crashea el programa de manera irrecuperable:

1
2
3
4
5
6
7
8
9
10
11
12
13
int *heap_arr = malloc(n * sizeof(int));
if (heap_arr == NULL)
{
    // Podemos manejar el error
    fprintf(stderr, "Memoria insuficiente\n");
    return ERROR;
}
// vs
void procesar_con_vla(int n)
{
    int stack_arr[n]; // VLA: Si n es muy grande, el programa abortará sin que
    podamos interceptar el fallo.
}
3. Problemas de Portabilidad

El límite del stack varía entre plataformas y configuraciones. Código que funciona en una máquina puede crashear en otra.

Alternativa Correcta: Memoria Dinámica
1
2
3
4
5
6
7
8
9
10
11
void funcion(int cantidad)
{
    int *arreglo = malloc(cantidad * sizeof(int));
    if (arreglo == NULL)
    {
        // Manejar error
        return;
    }
    // Usar arreglo...
    free(arreglo);
}
Ejercicios de Autoevaluación (Funciones de Gestión y VLAs)

Compará estas dos funciones y explicá cuándo preferirías cada una:

void suma_vla(size_t n, const int a[n], int *r);
int *suma_heap(size_t n, const int *a);
Solución

La primera recibe almacenamiento cuyo ciclo de vida pertenece al llamador; el VLA puede vivir en la pila y su tamaño está limitado por la implementación. La segunda reserva el resultado en el heap, devuelve NULL ante un fallo y transfiere al llamador la responsabilidad de free. En material portable se debe tratar el soporte de VLA como una decisión de implementación, no como una garantía universal del estándar C moderno.


Doble Indirección (Puntero a Puntero)

Una variable puntero es un tipo de dato que almacena una dirección de memoria. Sin embargo, al ser una variable en sí misma, también reside en una dirección de memoria física específica del sistema. La doble indirección consiste en utilizar un puntero que almacena la dirección de otra variable puntero, declarándose mediante el operador de doble asterisco (**).

1
2
3
int valor = 42;
int *p = &valor; // Puntero simple (indirección simple)
int **pp = &p;   // Doble puntero (doble indirección)

En este esquema:

Desreferenciar pp una vez (*pp) evalúa al puntero p (obteniendo la dirección de valor). Desreferenciar pp dos veces (**pp) accede directamente al contenido de valor (42).

Representación en stack y heap de la doble indirección con pp, p y valor.

Figure 3:Representación en stack y heap de la doble indirección con pp, p y valor.

Paso de Punteros por Referencia

En el lenguaje C, todos los argumentos de una función se transmiten estrictamente por valor (copia). Esto significa que la función trabaja con copias locales de los parámetros recibidos.

Si necesitás que una función modifique un tipo de dato básico (como un int), debés pasar un puntero a esa variable (int *) para simular un paso por referencia. De forma análoga, si una función necesita modificar una variable puntero (por ejemplo, para asignarle memoria dinámica o reubicarla), se debe pasar la dirección del puntero, lo que requiere un doble puntero (int **).

El Error Común: Pasar un Puntero Simple

Considerá la siguiente función que intenta asignar memoria para un entero:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
// Intento incorrecto de asignar memoria
void inicializar_incorrecto(int *ptr)
{
    ptr = malloc(sizeof(int)); // Modifica la copia local
    if (ptr != NULL)
    {
        *ptr = 10;
    }
}
int main(void)
{
    int *mi_puntero = NULL;
    inicializar_incorrecto(mi_puntero);
    // ERROR: mi_puntero sigue siendo NULL en main
    // Además, se generó un memory leak de la memoria asignada en la función.
    return 0;
}

Al invocar inicializar_incorrecto(mi_puntero), el valor de mi_puntero (que es NULL) se copia en el parámetro local ptr. Cuando la función ejecuta malloc, almacena la dirección de la memoria reservada en la variable local ptr. Al retornar la función, ptr se destruye en el stack y mi_puntero en main permanece inalterado, provocando una fuga de memoria física.

La Solución: Doble Indirección

Para modificar el puntero original de la función invocadora, se debe enviar su dirección de memoria (&mi_puntero). La función receptora utilizará un parámetro de doble indirección para acceder y modificar el puntero original mediante desreferencia:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>
#include <stdlib.h>
// Forma correcta utilizando doble indirección
void inicializar_correcto(int **ptr)
{
    if (ptr == NULL || *ptr != NULL)
    {
        return; // Evita desreferenciar un puntero nulo o reasignar memoria
    }
    *ptr = malloc(sizeof **ptr); // Modifica el puntero del llamador
    if (*ptr != NULL)
    {
        **ptr = 42; // Modifica el valor entero apuntado
    }
}
int main(void)
{
    int *mi_puntero = NULL;
    // Pasamos la dirección del puntero
    inicializar_correcto(&mi_puntero);
    if (mi_puntero != NULL)
    {
        printf("Valor asignado: %d\n", *mi_puntero);
        free(mi_puntero);
        mi_puntero = NULL;
    }
    return 0;
}
Patrón Práctico: Creación y Destrucción Modular

Este enfoque es el estándar en C para construir interfaces limpias de Tipos Abstractos de Datos (TAD), garantizando que las funciones que modifican la estructura interna o el estado de los punteros del cliente lo hagan de forma segura y controlada.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct
{
    char *nombre;
    int id;
} recurso_t;
typedef enum
{
    RECURSO_EXITO = 0,
    RECURSO_ERR_PARAMETROS,
    RECURSO_ERR_MEMORIA,
    RECURSO_ERR_PRECONDICION
} recurso_status_t;
// Constructor que inicializa el puntero del llamador
recurso_status_t recurso_crear(recurso_t **recurso_out, const char *nombre,
                               int id)
{
    if (recurso_out == NULL || nombre == NULL)
    {
        return RECURSO_ERR_PARAMETROS;
    }
    if (*recurso_out != NULL)
    {
        return RECURSO_ERR_PRECONDICION; // Ya tiene memoria asignada
    }
    recurso_t *nuevo = malloc(sizeof(recurso_t));
    if (nuevo == NULL)
    {
        return RECURSO_ERR_MEMORIA;
    }
    nuevo->nombre = malloc(strlen(nombre) + 1);
    if (nuevo->nombre == NULL)
    {
        free(nuevo);
        return RECURSO_ERR_MEMORIA;
    }
    strcpy(nuevo->nombre, nombre);
    nuevo->id = id;
    *recurso_out = nuevo; // Retornamos el recurso creado por referencia
    return RECURSO_EXITO;
}
// Destructor defensivo que libera memoria y pone el puntero en NULL
void recurso_destruir(recurso_t **recurso_out)
{
    if (recurso_out == NULL || *recurso_out == NULL)
    {
        return;
    }
    free((*recurso_out)->nombre);
    free(*recurso_out);
    *recurso_out = NULL; // Evita punteros colgantes en el llamador
}

Matrices Dinámicas

Las variantes tridimensionales, VLA y las técnicas de representación especializadas quedan fuera del camino principal de este apunte.

Una matriz (arreglo bidimensional) puede implementarse de varias formas en memoria dinámica. Cada enfoque tiene trade-offs en complejidad, eficiencia de memoria y acceso.

Como se explica en El Montón (Heap), la memoria dinámica nos permite crear estructuras de tamaño arbitrario. Las matrices dinámicas extienden este concepto a dos dimensiones.

Enfoque 1: Matriz “Dentada” (Array de Punteros)

Este enfoque crea un arreglo de punteros, donde cada puntero apunta a una fila (otro arreglo). Se llama “dentada” (jagged array) porque cada fila puede tener largo diferente (aunque típicamente usamos filas del mismo tamaño).

Representación de una matriz dentada: array de punteros a arrays.

Figure 4:Representación de una matriz dentada: array de punteros a arrays.

Asignación
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int **matriz;
int filas = 3, columnas = 4;
// Paso 1: Array de punteros a filas
matriz = malloc(filas * sizeof(int *));
if (matriz == NULL)
{
    return NULL;
}
// Paso 2: Cada fila
for (int i = 0; i < filas; i++)
{
    matriz[i] = malloc(columnas * sizeof(int));
    if (matriz[i] == NULL)
    {
        // Error: liberar lo ya asignado
        for (int j = 0; j < i; j++)
        {
            free(matriz[j]);
        }
        free(matriz);
        return NULL;
    }
}
Acceso

El acceso es natural con la sintaxis estándar de C:

1
2
matriz[i][j] = 42;
int valor = matriz[i][j];
Liberación

Siguiendo el principio “de adentro hacia afuera” (Destrucción de Estructuras Dinámicas):

1
2
3
4
5
6
7
// 1. Liberar cada fila
for (int i = 0; i < filas; i++)
{
    free(matriz[i]);
}
// 2. Liberar el array de punteros
free(matriz);
Ventajas y Desventajas

Ventajas:

Desventajas:

Enfoque 2: Bloque Único (Simulación Manual)

Este enfoque asigna toda la matriz como un único bloque contiguo en memoria. Es más eficiente pero requiere calcular índices manualmente.

Matriz almacenada como bloque contiguo: todas las filas consecutivas en memoria.

Figure 5:Matriz almacenada como bloque contiguo: todas las filas consecutivas en memoria.

Asignación
1
2
3
4
5
6
7
int *matriz;
int filas = 3, columnas = 4;
matriz = malloc(filas * columnas * sizeof(int));
if (matriz == NULL)
{
    return NULL;
}
Acceso Manual

No podés usar matriz[i][j] directamente porque matriz es int *, no int **. Debés calcular el índice lineal:

1
2
3
4
// Acceso: fila i, columna j
int valor = matriz[i * columnas + j];
// Asignación
matriz[i * columnas + j] = 42;

Explicación del cálculo:

Mapeo entre la representación lógica 2D y la memoria lineal contigua.

Figure 6:Mapeo entre la representación lógica 2D y la memoria lineal contigua.

Liberación

Solo una llamada a free:

1
free(matriz);
Función de Acceso Helper

Para mejorar la legibilidad, podés crear una función:

1
2
3
4
5
6
7
8
9
10
11
12
static inline int matriz_get(int *matriz, int fila, int col, int num_cols)
{
    return matriz[fila * num_cols + col];
}
static inline void matriz_set(int *matriz, int fila, int col, int num_cols,
                              int valor)
{
    matriz[fila * num_cols + col] = valor;
}
// Uso:
matriz_set(matriz, i, j, columnas, 42);
int val = matriz_get(matriz, i, j, columnas);
Ventajas y Desventajas

Ventajas:

Desventajas:

Enfoque 3: Bloque Único con Puntero a Array

Este enfoque combina lo mejor de ambos mundos: memoria contigua del Enfoque 2 con la sintaxis natural del Enfoque 1, mediante un puntero a un array. Es fundamental aclarar que, para evitar la definición de tipos modificados dinámicamente en tiempo de ejecución (que constituyen una forma de VLA prohibida), las dimensiones de las columnas deben ser constantes conocidas en tiempo de compilación.

Asignación con Puntero a Array
1
2
3
4
5
6
7
8
#define COLUMNAS 4
int filas = 3;
// Puntero a un array de 'COLUMNAS' enteros (tamaño constante)
int (*matriz)[COLUMNAS] = malloc(sizeof(int) * COLUMNAS * filas);
if (matriz == NULL)
{
    return NULL;
}
Acceso Natural

Ahora podés usar la sintaxis estándar:

1
2
matriz[i][j] = 42;
int valor = matriz[i][j];
Aritmética de Punteros en el Direccionamiento Bidimensional

Para comprender cómo el compilador desreferencia la sintaxis matriz[i][j] en el Enfoque 3, debemos analizarla desde la aritmética de punteros.

Si declaramos int (*matriz)[COLUMNAS], el tipo de matriz es “puntero a un array de COLUMNAS enteros”. Por lo tanto, el tamaño del elemento al que apunta es sizeof(int)×COLUMNAS\text{sizeof}(int) \times \text{COLUMNAS} bytes.

El acceso matriz[i][j] es equivalente a *(*(matriz + i) + j). El compilador realiza el cálculo de la dirección física de la siguiente manera:

  1. Desplazamiento de Fila (matriz + i): Al sumar i al puntero matriz, el compilador avanza i elementos del tipo apuntado. La dirección resultante es:

    Dir(matriz[i])=Dir(matriz)+i×COLUMNAS×sizeof(int)\text{Dir}(matriz[i]) = \text{Dir}(matriz) + i \times \text{COLUMNAS} \times \text{sizeof}(int)
  2. Desplazamiento de Columna (*(matriz + i) + j): La expresión *(matriz + i) evalúa al array de la fila i. Por la regla de decaimiento (array decay), este decae a un puntero al primer entero de dicha fila (tipo int *). Al sumar j, avanzamos j enteros:

    Dir(matriz[i][j])=Dir(matriz[i])+j×sizeof(int)\text{Dir}(matriz[i][j]) = \text{Dir}(matriz[i]) + j \times \text{sizeof}(int)
  3. Dirección Final Combinada: Sustituyendo la primera ecuación en la segunda, la dirección de memoria exacta del elemento es:

    Dir(matriz[i][j])=Dir(matriz)+(i×COLUMNAS+j)×sizeof(int)\text{Dir}(matriz[i][j]) = \text{Dir}(matriz) + (i \times \text{COLUMNAS} + j) \times \text{sizeof}(int)

Este cálculo de desplazamiento en bytes coincide exactamente con la simulación manual del Enfoque 2, con la ventaja de que el compilador realiza la multiplicación y escala los índices de forma transparente y eficiente.

Liberación

Solo un free:

1
free(matriz);
Comparación de Declaraciones
1
2
3
4
5
6
// Enfoque 1: Array de punteros
int **matriz1; // Puntero a puntero a int
// Enfoque 2: Puntero simple
int *matriz2; // Puntero a int
// Enfoque 3: Puntero a array
int (*matriz3)[COLUMNAS]; // Puntero a array de COLUMNAS ints
El Enfoque 3 y los Tipos Modificados

Para mantener una dimensión de columnas fija y evitar tipos modificados se utiliza:

1
2
#define COLUMNAS 4
int (*matriz)[COLUMNAS] = malloc(sizeof(int) * COLUMNAS * filas);
Ventajas y Desventajas

Ventajas:

Desventajas:

Comparación de Enfoques

Table 3:Comparación de Implementaciones de Matrices

Aspecto

Enfoque 1 (Dentada)

Enfoque 2 (Bloque Manual)

Enfoque 3 (Bloque + Cast)

Sintaxis de acceso

matriz[i][j] ✅

matriz[i*cols + j] ⚠️

matriz[i][j] ✅

Asignaciones malloc

filas + 1 ⚠️

1 ✅

1 ✅

Overhead de memoria

filas * sizeof(int*) ⚠️

0 ✅

0 ✅

Localidad de cache

Baja ⚠️

Alta ✅

Alta ✅

Fragmentación

Alta ⚠️

Ninguna ✅

Ninguna ✅

Filas de tamaño variable

Sí ✅

No ⚠️

No ⚠️

Complejidad código

Media

Media

Alta ⚠️


Ejercicios de Autoevaluación (Doble Indirección y Matrices)

Implementá crear_matriz para reservar un bloque contiguo de filas * columnas enteros y dejar *salida en NULL si la operación falla. ¿Qué overflow debés evitar?

Solución
#include <stdint.h>
#include <stdlib.h>

int crear_matriz(size_t filas, size_t columnas, int **salida)
{
    if (salida == NULL || filas == 0 || columnas == 0 ||
        filas > SIZE_MAX / columnas ||
        filas * columnas > SIZE_MAX / sizeof **salida)
        return 0;
    int *p = malloc(filas * columnas * sizeof *p);
    if (p == NULL) return 0;
    *salida = p;
    return 1;
}

La doble indirección permite modificar el puntero del llamador. El producto se valida antes de calcular el tamaño en bytes; al finalizar se libera con free(*salida).


Conexión con el Siguiente Tema

Dominando la gestión avanzada de memoria dinámica, tenés las herramientas para implementar estructuras de datos complejas: listas enlazadas, árboles, grafos, hash tables. Pero construir estas estructuras correctamente requiere algo más que conocimiento técnico de punteros.

El apunte TAD, Pilas y Colas introduce el concepto de Tipos Abstractos de Datos (TADs):

Un TAD bien diseñado permite cambiar completamente la implementación interna (por ejemplo, de matriz dentada a bloque único) sin afectar al código cliente. Esta separación de concerns es fundamental para escribir software mantenible y escalable.

Los punteros y la memoria dinámica son las herramientas de bajo nivel; los TADs son los principios arquitecturales que guían su uso profesional.

Pregunta puente: Una lista enlazada y un array dinámico implementan la misma interfaz abstracta (secuencia de elementos). ¿Cómo decidir cuál usar? ¿Cómo diseñar la interfaz para que sea independiente de la implementación? El análisis de TADs responde estas preguntas.

Ejercicios de Autoevaluación

Glosario

Aritmética de Punteros
Operaciones matemáticas realizadas sobre direcciones de memoria.
Puntero a Puntero
Puntero que almacena la dirección de otro puntero.
Matriz Dinámica
Arreglo bidimensional asignado en tiempo de ejecución en el heap.
VLA (Arreglo de Largo Variable)
Arreglo cuyo tamaño se determina en tiempo de ejecución en el stack (no recomendado).

Síntesis y Resumen

Conceptos Clave

Este apunte explora patrones avanzados de memoria dinámica en C, construyendo sobre los fundamentos de Modelo de memoria y pila y Punteros.

Referencias y Lecturas de Aritmética de Punteros

Textos Fundamentales sobre Memoria Dinámica

Gestión de Memoria y Debugging
Matrices y Estructuras Multidimensionales
Optimización y Performance
Patrones de Diseño con Memoria Dinámica
Herramientas de Análisis
References
  1. Kernighan, B. W., & Ritchie, D. M. (2014). C Programming Language, 2nd Edition.
  2. King, K. N. (2008). C Programming: A Modern Approach (2nd ed.). W. W. Norton & Company.
  3. Gustedt, J. (2019). Modern C. Manning Publications. https://modernc.gforge.inria.fr/
  4. Seacord, R. C. (2013). Secure Coding in C and C++ (2nd ed.). Addison-Wesley.
  5. van der Linden, P. (1994). Expert C Programming: Deep C Secrets. Prentice Hall.
  6. Bryant, R. E., & O’Hallaron, D. R. (2015). Computer Systems: A Programmer’s Perspective (3rd ed.). Pearson.
  7. Warren, H. S. (2012). Hacker’s Delight (2nd ed.). Addison-Wesley.
  8. Hanson, D. R. (1996). C Interfaces and Implementations: Techniques for Creating Reusable Software. Addison-Wesley.
  9. Plauger, P. J. (1992). The Standard C Library. Prentice Hall.