Capítulo 7: Strings y Manejo de Memoria
La gestión manual de memoria es una de las fuentes más comunes de bugs en C++: memory leaks, dangling pointers, double-free, use-after-free... En este capítulo aprenderás cómo funciona la memoria dinámica, por qué es peligrosa, y las herramientas modernas (RAII, smart pointers) que te permiten escribir código seguro.
Objetivos
- Entender la diferencia entre C-style strings y
std::string - Dominar operaciones de string (concatenación, substring, búsqueda)
- Saber usar
newydeletecorrectamente - Identificar y prevenir memory leaks
- Comprender el principio RAII
- Usar
unique_ptryshared_ptrpara gestión automática - Conocer string_view y SSO (Small String Optimization)
C-style strings (char*)
Arrays de caracteres
#include <cstring>
#include <iostream>
int main() {
// C-style string: array de char terminado en ' '
char name[6] = {'H', 'e', 'l', 'l', 'o', ' '};
char name2[] = "Hello"; // Equivalente (el compilador añade ' ')
std::cout << name << "
"; // Hello
// Longitud
std::cout << strlen(name) << "
"; // 5 (no cuenta ' ')
// Copiar (⚠️ peligroso sin verificar tamaño)
char buffer[20];
strcpy(buffer, name);
std::cout << buffer << "
"; // Hello
return 0;
} Representación de C-String en Memoria
graph LR
subgraph "char name[] = 'Hello'"
A["[0]<br/>H"] --> B["[1]<br/>e"]
B --> C["[2]<br/>l"]
C --> D["[3]<br/>l"]
D --> E["[4]<br/>o"]
E --> F["[5]<br/>\0"]
end
style F fill:#e74c3c,color:#fff
style A fill:#3498db,color:#fff
style B fill:#3498db,color:#fff
style C fill:#3498db,color:#fff
style D fill:#3498db,color:#fff
style E fill:#3498db,color:#fff
El '\0' (null terminator) marca el final
strlen("Hello") = 5 (no cuenta '\0')
sizeof(name) = 6 bytes (incluye '\0')
Sin '\0', funciones como printf se pasan del límite (Buffer Overflow)
// ❌ Buffer Overflow
char buffer[5] = "Hello"; // ¡No cabe '\0'! Undefined Behavior
// ❌ strcpy sin verificar tamaño
char small[5];
strcpy(small, "This is too long"); // ¡Escribe fuera del buffer!
// ❌ Comparación incorrecta
char* a = "Hello";
char* b = "Hello";
if (a == b) {
...
} // Compara DIRECCIONES, no contenido
// ✅ Comparación correcta
if (strcmp(a, b) == 0) {
...
} std::string (C++)
Operaciones básicas
#include <iostream>
#include <string>
int main() {
std::string name = "Alice";
std::string greeting = "Hello, " + name + "!";
std::cout << greeting << "
"; // Hello, Alice!
std::cout << "Longitud: " << greeting.length() << "
"; // 13
// Acceso por índice
std::cout << greeting[0] << "
"; // H
// Substring
std::string sub = greeting.substr(7, 5); // "Alice"
std::cout << sub << "
";
// Comparación
if (name == "Alice") {
std::cout << "Nombre correcto
";
}
return 0;
} Comparación: c-string vs std::string
// C-style string
char c_str[100];
strcpy(c_str, "Hello"); // ⚠️ Puede overflow
strcat(c_str, " World"); // ⚠️ Puede overflow
if (strcmp(c_str, "Hello World") == 0) {
...
} // Comparación
// std::string
std::string str;
str = "Hello"; // ✅ Seguro
str += " World"; // ✅ Seguro
if (str == "Hello World") {
...
} // Comparación simple
// Tamaño dinámico
str.reserve(1000); // Reserva capacidad sin cambiar tamaño
str.resize(500); // Cambia el tamaño (añade ' ' si crece) ¿Por qué std::string es mejor? Básico
| Aspecto | C-String | std::string |
|---|---|---|
| Gestión de memoria | Manual (propenso a errores) | Automática (RAII) |
| Tamaño dinámico | No | Sí |
| Buffer overflow | Fácil de causar | Protegido |
| Concatenación | strcat() peligroso | + seguro |
| Comparación | strcmp() | == |
Regla: Usa std::string en C++ moderno. Solo usa C-strings para interoperar con APIs de C.
Memoria Dinámica: new y delete
Sintaxis básica
#include <iostream>
int main() {
// Asignar un entero en el heap
int* ptr = new int(42);
std::cout << "Valor: " << *ptr << "
"; // 42
delete ptr; // ¡Liberar memoria!
// Asignar un array en el heap
int* arr = new int[100];
arr[0] = 10;
arr[99] = 20;
delete[] arr; // ¡Usar delete[] para arrays!
// Asignar memoria no inicializada
int* ptr2 = new int; // Contiene basura
std::cout << *ptr2 << "
"; // ⚠️ Undefined (valor aleatorio)
// Asignar con inicialización
int* ptr3 = new int(100); // Inicializado a 100
std::cout << *ptr3 << "
"; // 100
delete ptr2;
delete ptr3;
return 0;
} new vs new[]
flowchart LR
subgraph "Objeto único"
NEW1["new int(42)"] --> DEL1["delete ptr"]
end
subgraph "Array"
NEW2["new int[100]"] --> DEL2["delete[] arr"]
end
subgraph "❌ ERRORES"
ERR1["new int[10]<br/>delete arr<br/>(debe ser delete[])"]
ERR2["new int<br/>delete[] ptr<br/>(debe ser delete)"]
end
style NEW1 fill:#3498db,color:#fff
style DEL1 fill:#27ae60,color:#fff
style NEW2 fill:#3498db,color:#fff
style DEL2 fill:#27ae60,color:#fff
style ERR1 fill:#e74c3c,color:#fff
style ERR2 fill:#e74c3c,color:#fff
new → delete new[] → delete[]
Cada new debe tener exactamente UN delete correspondiente:
new→deletenew[]→delete[]- Nunca
deletedos veces el mismo puntero - Nunca uses un puntero después de
delete
🚰 Memory Leaks
// ❌ Memory Leak: memoria asignada pero nunca liberada
void leaky_function() {
int* ptr = new int(42);
// Falta delete ptr;
} // ptr sale de scope pero la memoria en heap permanece
// Después de llamar esta función 1 millón de veces:
// Has perdido 4 MB de RAM (1M × 4 bytes)
// ✅ Correcto: siempre liberar
void correct_function() {
int* ptr = new int(42);
delete ptr;
}
// ❌ Leak más sutil: excepción antes de delete
void risky_function() {
int* ptr = new int(42);
do_something(); // Si lanza excepción, delete nunca se ejecuta
delete ptr;
} ¿Qué es un memory leak? Básico
Un memory leak ocurre cuando asignas memoria en el heap pero nunca la liberas. La memoria permanece ocupada hasta que el programa termina.
Consecuencias:
- El programa consume cada vez más RAM
- Puede causar Out-of-Memory (OOM) y crash del sistema
- En servidores, puede causar downtime después de días/semanas
Herramientas de detección:
- Valgrind (Linux):
valgrind --leak-check=full ./programa - AddressSanitizer (Clang/GCC): compilar con
-fsanitize=address - Visual Studio (Windows): CRT Debug Heap
Errores comunes con new/delete
// ❌ Error 1: delete vs delete[]
int* arr = new int[10];
delete arr; // ❌ Debe ser delete[]
int* ptr = new int;
delete[] ptr; // ❌ Debe ser delete
// ❌ Error 2: Double delete
int* ptr = new int(42);
delete ptr;
delete ptr; // ❌ Undefined Behavior
// ❌ Error 3: Use after delete
int* ptr = new int(42);
delete ptr;
std::cout << *ptr << "
"; // ❌ Dangling pointer
// ❌ Error 4: Olvidar delete
int* ptr = new int(42);
ptr = new int(100); // ❌ Leak: perdiste la referencia al primer bloque
// ✅ Correcto
int* ptr = new int(42);
delete ptr;
ptr = nullptr; // Evita uso accidental Dangling Pointer
flowchart TB
subgraph "After delete ptr"
PTR["ptr<br/>(dangling pointer)"]
MEM["❌ ???<br/>Memoria liberada<br/>(basura)"]
PTR -.->|"apunta a memoria inválida"| MEM
end
ERR1["*ptr = 100<br/>❌ Undefined Behavior"]
ERR2["delete ptr<br/>❌ Double-free"]
subgraph "Solución"
SOL["delete ptr<br/>ptr = nullptr<br/>✓ Seguro"]
end
style MEM fill:#e74c3c,color:#fff
style PTR fill:#e67e22,color:#fff
style ERR1 fill:#e74c3c,color:#fff
style ERR2 fill:#e74c3c,color:#fff
style SOL fill:#27ae60,color:#fff
int* ptr = new int(42);
delete ptr;
// ptr SIGUE apuntando a la misma dirección,
// pero la memoria ya no es tuya
La gestión manual de memoria con new/delete es extremadamente
propensa a errores:
- Memory leaks (olvidas
delete) - Double-free (llamas
deletedos veces) - Use-after-free (usas puntero después de
delete) - Exception-unsafe (excepción antes de
delete)
Solución: RAII y smart pointers (abajo).
RAII: Resource Acquisition Is Initialization
RAII (Resource Acquisition Is Initialization) es uno de los patrones más importantes en C++ moderno. A pesar de su nombre confuso, la idea es simple: los recursos (memoria, file handles, locks, sockets) se adquieren en el constructor de un objeto y se liberan automáticamente en el destructor cuando el objeto sale de scope.
¿Por qué es revolucionario? Antes de RAII, tenías que recordar manualmente
liberar recursos con delete, fclose(), etc. Era fácil olvidarlo,
especialmente en rutas de error o cuando hay excepciones. RAII garantiza que los recursos se
liberan automáticamente, incluso si ocurre una excepción.
El poder de RAII: Aprovecha el sistema de tipos de C++ y el destructor automático. El compilador garantiza que los destructores se llaman al salir del scope (ya sea por return normal, excepción, o break/continue). No puedes "olvidar" liberar el recurso porque no lo haces tú manualmente - el sistema lo hace por ti.
#include <iostream>
// RAII: Resource Acquisition Is Initialization
class IntArray {
private:
int* data;
size_t size;
public:
// Constructor: adquiere recurso
IntArray(size_t n) : size(n) {
data = new int[n];
std::cout << "Memoria asignada
";
}
// Destructor: libera recurso automáticamente
~IntArray() {
delete[] data;
std::cout << "Memoria liberada
";
}
int& operator[](size_t i) { return data[i]; }
};
int main() {
{
IntArray arr(100);
arr[0] = 42;
} // ¡Destructor se llama automáticamente!
// No memory leak
return 0;
} Principio RAII Intermedio
RAII es un patrón fundamental en C++:
- Adquirir recurso en el constructor (memoria, file handle, lock, etc.)
- Liberar recurso en el destructor
- El compilador llama al destructor automáticamente al salir del scope
Ventajas:
- No puedes olvidar liberar el recurso
- Exception-safe: el destructor se llama incluso si hay excepción
- Código más limpio y seguro
Ejemplos de RAII en la biblioteca estándar: std::string, std::vector,
std::unique_ptr, std::lock_guard, std::fstream.
Smart pointers: unique_ptr
#include <iostream>
#include <memory>
int main() {
// unique_ptr: ownership exclusivo
std::unique_ptr<int> ptr = std::make_unique<int>(42);
std::cout << *ptr << "
"; // 42
// No necesitas delete: se libera automáticamente
// al salir del scope
// Arrays
std::unique_ptr<int[]> arr = std::make_unique<int[]>(100);
arr[0] = 10;
arr[99] = 20;
// ❌ No se puede copiar
// std::unique_ptr<int> ptr2 = ptr; // Error de compilación
// ✅ Se puede mover
std::unique_ptr<int> ptr3 = std::move(ptr);
// Ahora ptr es nullptr, ptr3 tiene ownership
return 0;
} // Memoria liberada automáticamente Ownership de unique_ptr
flowchart TB
subgraph "Inicialización"
PTR1["ptr1<br/>(stack)"] --> |owns| OBJ1["42<br/>(heap)"]
end
subgraph "Después de std::move(ptr1)"
PTR1_NULL["ptr1<br/>= nullptr"]
PTR2["ptr2<br/>(stack)"] --> |owns| OBJ2["42<br/>(heap)"]
end
subgraph "Al salir del scope"
AUTO["ptr2 se destruye<br/>✓ Memoria liberada<br/>automáticamente"]
end
style PTR1 fill:#3498db,color:#fff
style OBJ1 fill:#e67e22,color:#fff
style PTR1_NULL fill:#95a5a6,color:#fff
style PTR2 fill:#3498db,color:#fff
style OBJ2 fill:#e67e22,color:#fff
style AUTO fill:#27ae60,color:#fff
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = std::move(ptr1); Usa unique_ptr cuando:
- Tienes ownership exclusivo de un recurso
- Quieres evitar
deletemanual - Necesitas exception-safety
- Solo un objeto debe "poseer" el recurso a la vez
Regla: Prefiere std::make_unique sobre new.
🤝 Smart pointers: shared_ptr
#include <iostream>
#include <memory>
int main() {
// shared_ptr: ownership compartido con reference counting
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
std::cout << "Referencias: " << ptr1.use_count() << "
"; // 1
{
std::shared_ptr<int> ptr2 = ptr1; // Copia OK
std::cout << "Referencias: " << ptr1.use_count() << "
"; // 2
std::cout << *ptr2 << "
"; // 42
} // ptr2 destruido, referencia decrementada
std::cout << "Referencias: " << ptr1.use_count() << "
"; // 1
return 0;
} // ptr1 destruido, referencia llega a 0, memoria liberada Reference Counting Avanzado
shared_ptr usa reference counting (conteo de referencias):
- Cada copia incrementa el contador
- Cada destrucción decrementa el contador
- Cuando el contador llega a 0, libera la memoria
shared_ptr<int> p1 = make_shared<int>(42); // count = 1
shared_ptr<int> p2 = p1; // count = 2
shared_ptr<int> p3 = p1; // count = 3
p2.reset(); // count = 2
p3.reset(); // count = 1
// p1 destruido → count = 0 → memoria liberada Costo: Overhead de memoria (contador) y performance (atomic operations).
Ciclo de Referencias: Ten cuidado con referencias circulares (usa weak_ptr).
| Aspecto | unique_ptr | shared_ptr |
|---|---|---|
| Ownership | Exclusivo | Compartido |
| Copia | No (solo move) | Sí |
| Overhead | Cero (mismo tamaño que puntero) | Contador de referencias |
| Uso típico | Default para ownership dinámico | Múltiples owners necesarios |
Regla: Usa unique_ptr por defecto. Solo usa shared_ptr cuando realmente necesites ownership compartido.
Gestión de memoria en std::string
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
std::cout << "Tamaño: " << str.size() << "
"; // 5
std::cout << "Capacidad: " << str.capacity() << "
"; // ej: 15
// Reservar capacidad sin cambiar tamaño
str.reserve(100);
std::cout << "Nueva capacidad: " << str.capacity() << "
"; // >= 100
std::cout << "Tamaño: " << str.size() << "
"; // Sigue siendo 5
// Añadir texto: puede causar reallocation
str += " World"; // Si capacity < nuevo tamaño, realoca
// Obtener C-string
const char* c_str = str.c_str(); // "Hello World "
return 0;
} Small String Optimization (SSO) Avanzado
La mayoría de implementaciones de std::string usan SSO:
// Small String Optimization (SSO)
std::string small = "Hi"; // Almacenado DENTRO del objeto string (stack)
std::string large =
"This is a very long string that exceeds the internal buffer";
// Almacenado en HEAP
// Típicamente, strings < 15-23 chars usan SSO (depende de implementación)
// Ventaja: strings pequeños evitan heap allocation
// Desventaja: sizeof(std::string) es más grande (~24-32 bytes) Funcionamiento:
- Strings pequeños (< ~15-23 chars) se almacenan dentro del objeto
string(stack) - Strings grandes se almacenan en el heap
- Evita heap allocations para strings cortos (mejora performance)
Tradeoff: sizeof(std::string) es grande (~24-32 bytes) para almacenar el buffer interno.
👀 string_view (C++17)
#include <iostream>
#include <string_view>
// string_view: referencia no-owning a string (C++17)
void print_string(std::string_view sv) { std::cout << sv << "
"; }
int main() {
std::string str = "Hello";
const char* c_str = "World";
print_string(str); // ✅ No copia
print_string(c_str); // ✅ No copia
print_string("Literal"); // ✅ No copia
// ⚠️ Peligro: dangling reference
std::string_view sv;
{
std::string temp = "Temporary";
sv = temp; // sv apunta a temp
} // temp destruido
// std::cout << sv << "
"; // ❌ Undefined Behavior
return 0;
} string_view es una referencia no-owning a un string.
Es como un puntero + tamaño:
struct string_view {
const char* data;
size_t length;
}; Ventajas:
- No copia el string (eficiente)
- Funciona con
std::string, C-strings, literales
Peligro:
- Si el string original se destruye,
string_viewqueda dangling - Nunca retornes
string_viewde funciones que crean strings temporales
Checklist
- Entiendo por qué C-strings son peligrosos (buffer overflow)
- Uso
std::stringen lugar dechar*cuando sea posible - Sé la diferencia entre
deleteydelete[] - Conozco los 4 errores principales: memory leak, double-free, use-after-free, dangling pointer
- Entiendo el principio RAII y por qué es importante
- Prefiero
unique_ptrsobrenew/delete - Uso
make_uniqueymake_sharedpara crear smart pointers - Sé cuándo usar
string_viewy sus peligros
Preguntas para reflexionar
¿Qué sucede si hago delete ptr; en lugar de delete[] ptr; para un array?
Undefined Behavior. El comportamiento depende de la implementación:
- Puede crashear inmediatamente
- Puede causar memory leak (solo se libera el primer elemento)
- Puede corromper el heap
- Puede parecer funcionar pero fallar más tarde
Regla: new → delete, new[] → delete[]
¿Por qué std::string es más seguro que char*?
Gestión automática de memoria con RAII:
std::stringasigna/libera memoria automáticamente- Crece dinámicamente sin riesgo de buffer overflow
- El destructor libera memoria al salir del scope (no memory leaks)
- Exception-safe: limpia incluso si hay excepción
Con char*, tú eres responsable de todo: new/delete, verificar tamaños, prevenir overflows.
¿Cuál es la diferencia entre unique_ptr y shared_ptr?
unique_ptr: Ownership exclusivo. Solo un puntero puede poseer el recurso. No se puede copiar (solo mover). Cero overhead.
shared_ptr: Ownership compartido. Múltiples punteros pueden poseer el mismo recurso. Usa reference counting. Overhead de memoria y performance.
Regla: Usa unique_ptr por defecto. Solo usa shared_ptr cuando realmente necesites múltiples owners.
¿Qué es RAII y por qué es importante?
RAII = Resource Acquisition Is Initialization.
Principio: Adquirir recursos en el constructor, liberarlos en el destructor.
Beneficios:
- Imposible olvidar liberar recursos (automático al salir del scope)
- Exception-safe: destructores se llaman incluso con excepciones
- Código más limpio y seguro
Ejemplos: std::string, std::vector, unique_ptr, lock_guard.
¿Por qué string_view puede ser peligroso?
string_view no posee la memoria, solo referencia un string existente.
Peligro: Si el string original se destruye, string_view queda dangling:
std::string_view get_name() {
std::string temp = "Alice";
return temp; // ❌ temp se destruye, return value queda dangling
}Regla: Usa string_view solo para parámetros de funciones, no para retorno o almacenamiento.
En el Capítulo 8 aprenderás sobre funciones: declaración vs definición, parámetros por valor/referencia/puntero, sobrecarga, argumentos por defecto, inline functions, recursión y cómo funciona el call stack. Entenderás cómo las funciones interactúan con la memoria a bajo nivel.