Memoria & Bajo Nivel

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

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)

Peligros de C-Strings
// ❌ 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
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[]

Regla de Oro: new = delete, new[] = delete[]

Cada new debe tener exactamente UN delete correspondiente:

  • newdelete
  • new[]delete[]
  • Nunca delete dos 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
Manual Memory Management = 💀

La gestión manual de memoria con new/delete es extremadamente propensa a errores:

  • Memory leaks (olvidas delete)
  • Double-free (llamas delete dos 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++:

  1. Adquirir recurso en el constructor (memoria, file handle, lock, etc.)
  2. Liberar recurso en el destructor
  3. 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);
Cuándo usar unique_ptr

Usa unique_ptr cuando:

  • Tienes ownership exclusivo de un recurso
  • Quieres evitar delete manual
  • 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).

unique_ptr vs shared_ptr
Aspecto unique_ptr shared_ptr
Ownership Exclusivo Compartido
Copia No (solo move)
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 no posee la memoria

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_view queda dangling
  • Nunca retornes string_view de funciones que crean strings temporales

Checklist

Preguntas para reflexionar

¿Qué sucede si hago delete ptr; en lugar de delete[] ptr; para un array?
Respuesta:

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: newdelete, new[]delete[]

¿Por qué std::string es más seguro que char*?
Respuesta:

Gestión automática de memoria con RAII:

  • std::string asigna/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?
Respuesta:

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?
Respuesta:

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?
Respuesta:

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.

Próximo Capítulo

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.

Siguiente capítulo: Funciones →