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
- Entender cómo se almacenan los arrays en memoria
- Dominar la sintaxis y uso de punteros
- Conocer la aritmética de punteros y su relación con arrays
- Diferenciar entre stack y heap (básico)
- Identificar punteros nulos, dangling pointers y memory bugs comunes
- Usar punteros a punteros correctamente
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:
- Tamaño fijo determinado en compile-time
- Almacenados en el stack (memoria automática)
- No hay overhead de gestión de memoria
- Muy rápidos, pero inflexibles
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
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:
- Referencias indirectas: Múltiples punteros pueden apuntar al mismo dato
- Memoria dinámica: Asignar memoria en el heap con
new - Eficiencia: Pasar objetos grandes por referencia sin copiarlos
- Estructuras de datos: Listas enlazadas, árboles, grafos
- Interoperabilidad: Trabajar con APIs de C y bajo nivel
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 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 elementosptr1 - ptr2: Distancia entre punteros (en elementos)ptr++,ptr--: Incremento/decrementoptr1 == 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) 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 , 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 - 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! 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;
} 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
- Entiendo que los arrays almacenan elementos contiguos en memoria
- Sé la diferencia entre
&(address-of) y*(dereference) - Entiendo que
ptr + 1avanzasizeof(type)bytes - Conozco la relación entre arrays y punteros (
arr[i] == *(arr+i)) - Siempre verifico punteros contra
nullptrantes de dereferencia - Sé que los punteros en 64-bit ocupan 8 bytes
- Entiendo la diferencia básica entre stack y heap
- Paso el tamaño del array como parámetro separado en funciones
Preguntas para reflexionar
¿Por qué ptr + 2 avanza 8 bytes para int* pero solo 2 bytes para char*?
La aritmética de punteros hace scaling automático según el tipo:
int*:ptr + 1avanzasizeof(int) = 4byteschar*:ptr + 1avanzasizeof(char) = 1byte
Entonces ptr + 2 avanza 2 × sizeof(tipo):
int* ptr:2 × 4 = 8byteschar* ptr:2 × 1 = 2bytes
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;?
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?
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?
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];?
| Aspecto | Stack (int arr[5]) | Heap (new int[5]) |
|---|---|---|
| Ubicación | Stack | Heap |
| Lifetime | Automático (se destruye al salir del scope) | Manual (debes hacer delete[]) |
| Velocidad | Muy rápido | Más lento |
| Tamaño | Limitado (~1-8 MB) | Grande (GB) |
Usa stack cuando puedas; usa heap cuando necesites tamaño grande o lifetime dinámico.
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.