Memoria & Bajo Nivel

Capítulo 6: Arrays y Punteros

Arrays y punteros son fundamentales en C++. Te permiten trabajar con colecciones de datos y manipular memoria directamente. Entender cómo funcionan a bajo nivel es crucial para escribir código eficiente y seguro. Este capítulo te dará las bases para dominar la gestión de memoria en C++.

Objetivos

Arrays estáticos

Un array es una colección de elementos del mismo tipo almacenados en posiciones consecutivas de memoria. Esta contigüidad es crucial: permite acceso rápido (O(1)) a cualquier elemento mediante aritmética simple de punteros. El compilador calcula la dirección de arr[i] como dirección_base + (i × sizeof(tipo)).

¿Por qué arrays y no variables individuales? Imagina necesitar 100 números. Declarar int num1, num2, ..., num100; sería impráctico. Los arrays agrupan datos relacionados bajo un nombre, permitiéndote procesarlos con loops. Además, al estar contiguos en memoria, son extremadamente eficientes para el cache del procesador.

Características de arrays estáticos:

Declaración y acceso

#include <iostream>

int main() {
    // Array de 5 enteros (stack)
    int numbers[5] = {10, 20, 30, 40, 50};

    std::cout << "Primer elemento: " << numbers[0] << "
";  // 10
    std::cout << "Tercer elemento: " << numbers[2] << "
";  // 30

    // Modificar elemento
    numbers[2] = 99;
    std::cout << "Nuevo tercer elemento: " << numbers[2] << "
";  // 99

    return 0;
}

Representación en Memoria de un Array

flowchart LR
    subgraph "int numbers[5] = {10, 20, 30, 40, 50}"
    A["10"] ---|0x1000| B["20"]
    B ---|0x1004| C["30"]
    C ---|0x1008| D["40"]
    D ---|0x100C| E["50"]
    end

    F["Elementos CONTIGUOS en memoria"] -.-> A
    G["Cada int ocupa 4 bytes"] -.-> B
    H["numbers[0] → 0x1000<br/>numbers[1] → 0x1004<br/>numbers[2] → 0x1008"] -.-> C

    style A fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style B fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style C fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style D fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style E fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
Arrays tienen tamaño fijo

A diferencia de std::vector, los arrays estáticos tienen un tamaño fijo en tiempo de compilación que no puede cambiar:

int arr[5];  // [OK] OK: tamaño conocido

int n = 10;
int arr2[n];  // [X] Error en C++ estándar (válido en C99 como VLA)

const int SIZE = 10;
int arr3[SIZE];  // [OK] OK: constante conocida en compile-time

Tamaño de arrays

int arr[5] = {1, 2, 3, 4, 5};

// Tamaño del array completo
std::cout << sizeof(arr) << "
";  // 20 bytes (5 * 4)

// Tamaño de un elemento
std::cout << sizeof(arr[0]) << "
";  // 4 bytes

// Número de elementos
int length = sizeof(arr) / sizeof(arr[0]);  // 5
¿Por qué sizeof(arr) funciona aquí pero no en funciones? Intermedio

Cuando declaras un array en el scope local, el compilador conoce su tamaño y sizeof(arr) devuelve el tamaño total en bytes.

Pero cuando pasas un array a una función, decae a un puntero. El compilador pierde la información del tamaño, y sizeof(arr) dentro de la función devuelve el tamaño del puntero (8 bytes en 64-bit), no del array.

void foo(int arr[]) {
    // sizeof(arr) aquí es 8 (tamaño del puntero), no 20!
}

Por eso siempre debes pasar el tamaño como parámetro separado.

Punteros básicos

Un puntero es una variable que almacena una dirección de memoria en lugar de un valor directo. Es como tener la dirección de una casa en lugar de la casa misma. Los punteros son una de las características más poderosas (y peligrosas) de C++, permitiendo manipulación directa de memoria.

¿Por qué necesitamos punteros? Los punteros permiten:

Conceptos clave: Cada variable en memoria tiene una dirección (su ubicación) y un valor (su contenido). Los punteros almacenan direcciones. El operador & ("address-of") obtiene la dirección de una variable, y el operador * ("dereference") accede al valor en esa dirección.

Sintaxis y operadores

#include <iostream>

int main() {
    int x = 42;
    int* ptr = &x;  // ptr almacena la DIRECCIÓN de x

    std::cout << "Valor de x: " << x << "
";       // 42
    std::cout << "Dirección de x: " << &x << "
";  // 0x7fff5fbff5ac (ejemplo)
    std::cout << "Valor de ptr: " << ptr << "
";   // 0x7fff5fbff5ac
    std::cout << "Valor apuntado: " << *ptr << "
";  // 42 (dereferencia)

    // Modificar a través del puntero
    *ptr = 100;
    std::cout << "Nuevo valor de x: " << x << "
";  // 100

    return 0;
}

Anatomía de un Puntero

flowchart TB
    X["x = 42<br/>Dirección: 0x7fff5fbff5ac"]
    PTR["ptr = 0x7fff5fbff5ac<br/>Dirección: 0x7fff5fbff5b0"]

    PTR -->|"apunta a"| X

    OP["Operadores:"] --> OP1["& (address-of): &x devuelve la dirección de x"]
    OP --> OP2["* (dereference): *ptr accede al valor apuntado"]
    OP --> OP3["* (declaración): int* ptr declara un puntero a int"]

    style X fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style PTR fill:#fff3e0,stroke:#f57c00,stroke-width:2px
Tamaño de los Punteros

En un sistema de 64 bits, todos los punteros ocupan 8 bytes, independientemente del tipo al que apunten:

int* ptr1;     // 8 bytes
double* ptr2;  // 8 bytes
char* ptr3;    // 8 bytes

// En 32 bits, todos ocuparían 4 bytes

El tamaño del puntero es el tamaño de una dirección de memoria en tu arquitectura.

Aritmética de punteros

int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr;  // ptr apunta al primer elemento

std::cout << *ptr << "
";        // 10
std::cout << *(ptr + 1) << "
";  // 20
std::cout << *(ptr + 2) << "
";  // 30

// Iterar con aritmética de punteros
for (int i = 0; i < 5; i++) {
    std::cout << *(ptr + i) << " ";
}
// Output: 10 20 30 40 50

Aritmética de Punteros: Scaling

flowchart LR
    subgraph "int arr[5] = {10, 20, 30, 40, 50}"
    A["10"] --- B["20"] --- C["30"] --- D["40"] --- E["50"]
    end

    PTR["ptr"] -.->|"apunta a"| A
    PTR1["ptr+1"] -.->|"apunta a"| B

    F["ptr + 1 NO suma 1 byte,<br/>sino sizeof(int) bytes!"] -.-> B
    G["Si ptr = 0x1000:<br/>ptr + 0 = 0x1000 (arr[0])<br/>ptr + 1 = 0x1004 (arr[1]) ← Suma 4, no 1!<br/>ptr + 2 = 0x1008 (arr[2]) ← Suma 8, no 2!"] -.-> C
    H["El compilador multiplica<br/>automáticamente por sizeof(int)"] -.-> D

    style A fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style B fill:#fff3e0,stroke:#f57c00,stroke-width:3px
    style C fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
Operaciones válidas con punteros Avanzado

Operaciones permitidas:

  • ptr + n, ptr - n: Mover n elementos
  • ptr1 - ptr2: Distancia entre punteros (en elementos)
  • ptr++, ptr--: Incremento/decremento
  • ptr1 == ptr2, ptr1 < ptr2: Comparaciones

Operaciones NO permitidas:

  • ptr1 + ptr2 ❌ (no tiene sentido sumar direcciones)
  • ptr * 2 ❌ (no puedes multiplicar punteros)
  • ptr / 2 ❌ (no puedes dividir punteros)

Relación array-puntero

int arr[5] = {10, 20, 30, 40, 50};

// Estas son equivalentes:
arr[2]           // 30
    * (arr + 2)  // 30
    2 [arr]      // 30 (¡sí, esto compila!)

    // ¿Por qué? Porque arr[i] se traduce a *(arr + i)
    // Y la suma es conmutativa: *(arr + 2) == *(2 + arr)
Arrays y punteros NO son lo mismo

Aunque están relacionados, hay diferencias importantes:

Array Puntero
sizeof(arr) = tamaño total sizeof(ptr) = 8 bytes (64-bit)
No se puede reasignar Se puede reasignar
Se almacena en stack Puede apuntar a stack o heap
int arr[5];
arr = something;  // [X] Error: arr es constante

int* ptr;
ptr = arr;  // [OK] OK: ptr puede cambiar

Null pointers

#include <iostream>

int main() {
    int* ptr = nullptr;  // C++11: puntero nulo

    if (ptr == nullptr) {
        std::cout << "Puntero es nulo
";
    }

    // [X] PELIGRO: dereferencia de puntero nulo
    // *ptr = 42;  // CRASH: Segmentation Fault

    // [OK] Siempre verifica antes de dereferencia
    if (ptr != nullptr) {
        *ptr = 42;
    }

    return 0;
}

Hay diferentes formas de representar un puntero nulo

Evolución: C++98 usaba NULL (macro que se expande a 0), C++11 introdujo nullptr (keyword con tipo propio).

nullptr resuelve ambigüedades en sobrecarga de funciones. Con NULL, llamar a func(NULL) podría coincidir con func(int) o func(int*), pero nullptr siempre llama a la versión de puntero.

Regla: Siempre usa nullptr en C++ moderno.

, pero nullptr es la forma correcta en C++ moderno.

Punteros a punteros

int x = 42;
int* ptr = &x;      // ptr apunta a x
int** pptr = &ptr;  // pptr apunta a ptr

std::cout << "Valor de x: " << x << "
";          // 42
std::cout << "Valor de ptr: " << *ptr << "
";     // 42
std::cout << "Valor de pptr: " << **pptr << "
";  // 42 (doble dereferencia)

// Modificar x a través de pptr
**pptr = 100;
std::cout << "Nuevo valor de x: " << x << "
";  // 100

Visualización de Puntero a Puntero

flowchart TB
    X["x = 42<br/>Dirección: 0x1000"]
    PTR["ptr = 0x1000<br/>Dirección: 0x2000"]
    PPTR["pptr = 0x2000<br/>Dirección: 0x3000"]

    PPTR -->|"apunta a"| PTR
    PTR -->|"apunta a"| X

    INFO["*pptr = 0x1000 (valor de ptr)<br/>**pptr = 42 (valor de x)"]

    style X fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style PTR fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style PPTR fill:#ffebee,stroke:#c62828,stroke-width:2px
¿Cuándo usar punteros a punteros?
  • Arrays dinámicos 2D: int** matriz
  • Modificar un puntero dentro de una función
  • Listas enlazadas y estructuras de datos complejas
  • APIs de C que requieren modificar punteros (ej: strtok_r)

Stack vs heap (introducción)

#include <iostream>

int main() {
    // STACK: asignación automática, rápida, limitada
    int stack_var = 42;
    int stack_arr[100];  // 400 bytes en el stack

    // HEAP: asignación manual, lenta, grande
    int* heap_ptr = new int(42);       // 1 entero en el heap
    int* heap_arr = new int[1000000];  // 4MB en el heap

    std::cout << "Stack variable: " << stack_var << "
";
    std::cout << "Heap variable: " << *heap_ptr << "
";

    // ⚠️ IMPORTANTE: liberar memoria del heap
    delete heap_ptr;
    delete[] heap_arr;

    return 0;
}  // stack_var y stack_arr se destruyen automáticamente

Stack vs Heap

flowchart TB
    subgraph RAM["MEMORIA RAM"]
    subgraph STACK["STACK (pila)"]
    S1["Tamaño limitado (~1-8 MB)"]
    S2["Asignación/liberación automática"]
    S3["Muy rápido (solo mover puntero)"]
    S4["Variables locales, parámetros"]
    end

    subgraph HEAP["HEAP (montículo)"]
    H1["Tamaño grande (varios GB)"]
    H2["Asignación/liberación manual<br/>(new/delete)"]
    H3["Más lento<br/>(búsqueda de espacio libre)"]
    H4["Datos grandes, lifetime dinámico"]
    end
    end

    style STACK fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
    style HEAP fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style RAM fill:#e3f2fd,stroke:#1976d2,stroke-width:3px
¿Cuándo usar stack vs heap? Intermedio

Usa STACK cuando:

  • Sabes el tamaño en compile-time
  • El tamaño es pequeño (< 1 MB típicamente)
  • El lifetime está limitado al scope actual
  • Quieres máxima velocidad

Usa HEAP cuando:

  • El tamaño no se conoce hasta runtime
  • El tamaño es grande (> 1 MB)
  • Necesitas que sobreviva más allá del scope
  • Quieres compartir ownership entre funciones

Errores comunes con punteros

// [X] Error 1: Array out of bounds
int arr[5] = {1, 2, 3, 4, 5};
std::cout << arr[10] << "
";  // ¡Undefined Behavior!

// [X] Error 2: Dangling pointer
int* ptr;
{
    int x = 42;
    ptr = &x;
}  // x se destruye aquí
std::cout << *ptr << "
";  // ¡ptr apunta a memoria inválida!

// [X] Error 3: Puntero no inicializado
int* ptr2;   // Contiene basura (dirección aleatoria)
*ptr2 = 42;  // ¡CRASH!

// [X] Error 4: Usar array después de delete
int* arr2 = new int[5];
delete[] arr2;
arr2[0] = 10;  // ¡Undefined Behavior!
Undefined Behavior = 💀

Los errores con punteros causan Undefined Behavior (UB), lo que significa que el programa puede:

  • ✓ Crashear inmediatamente (mejor caso)
  • ✓ Funcionar aparentemente bien
  • ✓ Corromper datos silenciosamente
  • ✓ Crashear 10 minutos después en código no relacionado
  • ✓ Funcionar en debug pero crashear en release

¡No hay garantías! Por eso los punteros requieren extremo cuidado.

Pasar arrays a funciones

void printArray(int* arr, int size) {
    // arr es un puntero, no un array
    for (int i = 0; i < size; i++) {
        std::cout << arr[i] << " ";
    }
    std::cout << "
";
}

int main() {
    int numbers[5] = {1, 2, 3, 4, 5};

    // El array "decae" a puntero al pasarlo
    printArray(numbers, 5);

    return 0;
}
Array Decay

Cuando pasas un array a una función, decae a un puntero al primer elemento. La función no sabe el tamaño del array:

void foo(int arr[5]) {
    // arr es realmente int*, el [5] es ignorado
    sizeof(arr);  // 8 bytes (puntero), NO 20!
}

// Estas 3 declaraciones son IDÉNTICAS:
void foo(int arr[5]);
void foo(int arr[]);
void foo(int* arr);

Por eso siempre debes pasar el tamaño como parámetro separado.

Checklist

Preguntas para reflexionar

¿Por qué ptr + 2 avanza 8 bytes para int* pero solo 2 bytes para char*?
Respuesta:

La aritmética de punteros hace scaling automático según el tipo:

  • int*: ptr + 1 avanza sizeof(int) = 4 bytes
  • char*: ptr + 1 avanza sizeof(char) = 1 byte

Entonces ptr + 2 avanza 2 × sizeof(tipo):

  • int* ptr: 2 × 4 = 8 bytes
  • char* ptr: 2 × 1 = 2 bytes

Esto permite iterar arrays sin pensar en tamaños: arr[i] siempre accede al i-ésimo elemento, independientemente del tipo.

¿Qué diferencia hay entre int* ptr; y int *ptr;?
Respuesta:

Ninguna diferencia semántica, ambas declaran un puntero a int. Es solo estilo:

  • int* ptr: Enfatiza el tipo ("ptr es de tipo int*")
  • int *ptr: Enfatiza la variable ("*ptr es un int")

El problema con int* ptr es que puede confundir en declaraciones múltiples:

int* a, b; // a es int*, b es int (¡no puntero!)

Por eso muchos prefieren int *a, *b; o declarar un puntero por línea.

¿Por qué sizeof(arr) funciona dentro de main() pero no en una función?
Respuesta:

Cuando declaras un array localmente, el compilador conoce su tipo completo:

int arr[5];
sizeof(arr); // 20 bytes (5 × 4)

Pero cuando lo pasas a una función, decae a un puntero:

void foo(int arr[5]) {
  sizeof(arr); // 8 bytes (tamaño del puntero)
}

El [5] en la firma de la función es ignorado por el compilador. Es equivalente a int* arr.

Solución: pasar el tamaño explícitamente: void foo(int* arr, int size)

¿Qué sucede si hago delete ptr; dos veces?
Respuesta:

Undefined Behavior (double-free). Puede causar:

  • Crash inmediato
  • Corrupción de heap
  • Comportamiento impredecible

Solución: establece el puntero a nullptr después de delete:

delete ptr;
ptr = nullptr; // Ahora delete ptr es seguro (no hace nada)

En C++ moderno, usa smart pointers (unique_ptr, shared_ptr) que hacen esto automáticamente.

¿Cuál es la diferencia entre int* arr = new int[5]; y int arr[5];?
Respuesta:
AspectoStack (int arr[5])Heap (new int[5])
UbicaciónStackHeap
LifetimeAutomático (se destruye al salir del scope)Manual (debes hacer delete[])
VelocidadMuy rápidoMás lento
TamañoLimitado (~1-8 MB)Grande (GB)

Usa stack cuando puedas; usa heap cuando necesites tamaño grande o lifetime dinámico.

Próximo Capítulo

En el Capítulo 7 aprenderás sobre strings y manejo de memoria. Verás la diferencia entre C-style strings y std::string, cómo gestionar memoria dinámica con new/delete, qué son los memory leaks, y una introducción a RAII y smart pointers para escribir código más seguro.

Siguiente capítulo: Strings y Memoria →