Saltar a contenido

Sobrecarga de Operadores en C++: Guía de Reversing

Índice


Introducción

La sobrecarga de operadores en C++ permite definir cómo funcionan los operadores estándar (+, -, *, ++, etc.) con tipos personalizados (estructuras, clases). Al igual que las funciones pueden sobrecargarse según sus parámetros, los operadores también se pueden adaptar a diferentes tipos.

Objetivo: Entender cómo el compilador convierte operadores sobrecargados a funciones, identificar patrones en ensamblador, y reconocer problemas comunes (como copia superficial con punteros).


Parte 1: Conceptos Fundamentales

¿Qué es la Sobrecarga de Operadores?

En C++, muchos operadores ya están sobrecargados:

  • El operador + suma enteros, flotantes, concatena strings...
  • El operador * multiplica números O desreferencia punteros

C++ permite que nosotros definamos comportamiento personalizado para nuestros tipos.

Sintaxis Básica

Prototipo:

<tipo_retorno> operator<operador>(<argumentos>);

Definición:

<tipo_retorno> operator<operador>(<argumentos>) {
    // Implementación
}

Ejemplo:

complejo operator +(complejo a, complejo b);  // Prototipo

Reglas y Limitaciones

Característica Descripción
No se pueden sobrecargar ., .*, ::, ?:
Solo en clases =, [], ->, (), new, delete
Requisito de argumentos Al menos uno debe ser struct/union/class/enum
Número de argumentos Predeterminado por el operador (binario=2, unitario=1)
No cambia precedencia Los operadores mantienen su precedencia original

Nota importante del video: Aunque =, [], etc. solo pueden sobrecargarse dentro de clases, fuera de clases se puede usar una función normal para reemplazar su funcionalidad.


Parte 2: Operadores Binarios

Los operadores binarios toman dos operandos. El ejemplo clásico es la suma de números complejos.

Ejemplo: Suma de Complejos

struct complejo {
    float a;  // Parte real
    float b;  // Parte imaginaria
};

// Suma de dos complejos
complejo operator +(complejo x, complejo y) {
    complejo temp = {x.a + y.a, x.b + y.b};
    return temp;
}

// Suma de complejo + float (solo afecta parte real)
complejo operator +(complejo x, float f) {
    complejo temp = {x.a + f, x.b};
    return temp;
}

// Suma arbitraria: complejo + int → devuelve int
int operator +(complejo x, int n) {
    return int(x.b) + n;
}

Uso:

complejo x = {10, 32};
complejo y = {21, 12};

complejo z = x + y;      // Llama a operator+(complejo, complejo)
complejo w = x + 5.0f;   // Llama a operator+(complejo, float)
int r = x + 3;           // Llama a operator+(complejo, int)

Cómo el Compilador Elige la Versión

Durante la compilación, el compilador analiza:

  1. Tipos de operandos: complejo + complejo, complejo + float, etc.
  2. Firma de funciones disponibles: Busca operator+ que coincida
  3. Selecciona la función correcta

En ensamblador (suma de complejos):

; x + y → operator+(complejo, complejo)
; Pasar argumentos (los dos complejos)
movsd   xmm0, QWORD PTR x[rsp]     ; Parte real de x
movsd   xmm1, QWORD PTR y[rsp]     ; Parte real de y
addsd   xmm0, xmm1                 ; Sumar partes reales
movsd   xmm1, QWORD PTR x[rsp+8]   ; Parte imaginaria de x
addsd   xmm1, QWORD PTR y[rsp+8]   ; Sumar partes imaginarias
; Guardar resultado en temp
movsd   QWORD PTR temp[rsp], xmm0
movsd   QWORD PTR temp[rsp+8], xmm1

Nota: La sobrecarga permite flexibilidad, pero el compilador decide en tiempo de compilación (no hay overhead runtime).


Parte 3: Operadores Unitarios - Prefijo vs Sufijo

Los operadores de incremento (++) y decremento (--) tienen dos formas:

Forma Sintaxis Comportamiento
Prefijo ++x Incrementa y devuelve el valor ya incrementado
Sufijo x++ Devuelve el valor actual, luego incrementa

Diferencia Crítica

El valor de retorno es diferente:

int x = 5;
int a = ++x;  // a = 6, x = 6
int b = x++;  // b = 6, x = 7

Sobrecarga de Prefijo

complejo operator ++(complejo &c) {
    c.a++;
    return c;  // Devuelve el objeto ya incrementado
}

Características: - Recibe el objeto por referencia (para modificarlo) - Incrementa directamente - Devuelve el objeto incrementado

Sobrecarga de Sufijo

Problema: Ambos operadores (++x y x++) se llaman operator++. ¿Cómo distinguirlos?

Solución: El sufijo lleva un parámetro int dummy (no se usa, solo sirve para diferenciar).

complejo operator ++(complejo &c, int) {  // int dummy
    complejo temp = {c.a, c.b};  // Guardar copia del valor original
    c.a++;                        // Incrementar
    return temp;                  // Devolver valor SIN incrementar
}

Características: - Parámetro int que no se usa (solo para firma) - Crea copia temporal del valor original - Incrementa el objeto - Devuelve la copia (valor previo al incremento)

Ejemplo Completo

complejo x = {10, 32}, y = {21, 12};
complejo z = x + y;   // z = (31, 44)

Mostrar(++z);         // Incrementa a (32, 44), muestra (32, 44)
Mostrar(z++);         // Muestra (32, 44), luego incrementa a (33, 44)
Mostrar(z);           // Muestra (33, 44)

Salida:

z = (x + y) = (31,44)
++z = (32,44)
z++ = (32,44)
z = (33,44)

En Ensamblador - Prefijo

; ++z → operator++(complejo&)
lea     rcx, [rsp+z_offset]        ; Dirección de z (referencia)
call    operator++(complejo&)      ; Llamar prefijo

; Dentro de operator++:
mov     rax, rcx                   ; RAX = dirección de z
movss   xmm0, DWORD PTR [rax]      ; Cargar z.a (parte real)
addss   xmm0, 1.0                  ; z.a++
movss   DWORD PTR [rax], xmm0      ; Guardar z.a incrementado
; Devuelve z (ya modificado)

En Ensamblador - Sufijo

; z++ → operator++(complejo&, int)
lea     rcx, [rsp+z_offset]        ; Dirección de z
xor     edx, edx                   ; EDX = 0 (int dummy)
call    operator++(complejo&, int) ; Llamar sufijo

; Dentro de operator++:
; 1. Crear copia temporal
movsd   xmm0, QWORD PTR [rcx]      ; temp.a = z.a
movsd   xmm1, QWORD PTR [rcx+8]    ; temp.b = z.b
movsd   QWORD PTR temp[rsp], xmm0
movsd   QWORD PTR temp[rsp+8], xmm1

; 2. Incrementar z
movss   xmm2, DWORD PTR [rcx]      ; z.a
addss   xmm2, 1.0                  ; z.a++
movss   DWORD PTR [rcx], xmm2      ; Guardar

; 3. Devolver temp (valor anterior)
movsd   xmm0, QWORD PTR temp[rsp]

Clave en reversing: El sufijo siempre crea una copia temporal antes del incremento.


Parte 4: El Problema del Operador de Asignación

El Problema: Copia Superficial vs Copia Profunda

Cuando una estructura contiene punteros a memoria dinámica, el operador de asignación por defecto causa problemas.

Ejemplo del curso:

struct tipo {
    int *mem;
};

int main() {
    tipo a, b;

    a.mem = new int[10];  // Alocación de memoria
    for (int i = 0; i < 10; i++) a.mem[i] = 0;

    b = a;  // ⚠️ PROBLEMA: Solo copia el puntero

    b.mem[2] = 1;  // Modifica también a.mem[2] (¡mismo buffer!)

    delete[] a.mem;  // Liberar memoria
    delete[] b.mem;  // ⚠️ ERROR: Doble liberación (mismo puntero)
}

¿Por Qué Ocurre?

El operador = por defecto hace copia byte a byte (shallow copy):

b = a;  // Equivale a: memcpy(&b, &a, sizeof(tipo));

Resultado:

Antes:
a.mem → [Dirección 0x1000]
b.mem → [Sin asignar]

Después de b = a:
a.mem → [Dirección 0x1000]
b.mem → [Dirección 0x1000]  ← ¡Misma dirección!

Consecuencias:

  1. Modificar b.mem[i] modifica a.mem[i] (misma memoria)
  2. Doble liberación: delete[] a.mem y delete[] b.mem liberan la misma dirección

Solución 1: No se Puede Sobrecargar operator= Fuera de Clases

Nota crítica del video: El operador = solo puede sobrecargarse dentro de una clase. Fuera de clases, se debe usar una función personalizada.

Solución 2: Función de Asignación Personalizada

void asignar(tipo &dst, tipo &src) {
    // 1. Verificar autoasignación
    if (&dst == &src) return;

    // 2. Liberar memoria previa de dst
    if (dst.mem) delete[] dst.mem;

    // 3. Alocar nueva memoria para dst
    dst.mem = new int[10];

    // 4. Copiar valores (deep copy)
    for (int i = 0; i < 10; i++) {
        dst.mem[i] = src.mem[i];
    }
}

Uso:

tipo a, b;
a.mem = new int[10];
b.mem = 0;

asignar(b, a);  // Deep copy

b.mem[2] = 1;   // Solo modifica b, no afecta a

delete[] a.mem;  // OK
delete[] b.mem;  // OK (direcciones diferentes)

Resultado:

Después de asignar(b, a):
a.mem → [Dirección 0x1000] → [0,0,0,...]
b.mem → [Dirección 0x2000] → [0,0,0,...]  ← Nueva dirección


Parte 5: Ejemplo Completo - Análisis Detallado

Código Completo

#include <iostream>
using namespace std;

struct tipo {
    int *mem;
};

void asignar(tipo&, tipo&);

int main() {
    tipo a, b;

    a.mem = new int[10];
    b.mem = 0;

    for (int i = 0; i < 10; i++) a.mem[i] = 0;

    asignar(b, a);

    cout << "b: ";
    for (int i = 0; i < 10; i++) cout << b.mem[i] << ",";
    cout << endl;

    b.mem[2] = 1;

    cout << "a: ";
    for (int i = 0; i < 10; i++) cout << a.mem[i] << ",";
    cout << endl;
    cout << "b: ";
    for (int i = 0; i < 10; i++) cout << b.mem[i] << ",";
    cout << endl;

    delete[] a.mem;
    delete[] b.mem;
    return 0;
}

void asignar(tipo &a, tipo &b) {
    if (&a != &b) {
        if (a.mem) delete[] a.mem;
        a.mem = new int[10];
        for (int i = 0; i < 10; i++) a.mem[i] = b.mem[i];
    }
}

Salida esperada:

b: 0,0,0,0,0,0,0,0,0,0,
a: 0,0,0,0,0,0,0,0,0,0,
b: 0,0,1,0,0,0,0,0,0,0,

Nota: a no cambia cuando modificamos b.mem[2] porque son buffers diferentes.


Análisis en Ensamblador - main()

Stack Layout

RSP+0x00: [Shadow Space (32 bytes)]
RSP+0x20: [Padding/Variables]
RSP+0x28: i$1 (loop 1)
RSP+0x2C: i$2 (loop 2)
RSP+0x30: b (estructura tipo, 8 bytes)
RSP+0x38: a (estructura tipo, 8 bytes)
RSP+0x40: $T5 (temporal para new)
RSP+0x48: $T6 (temporal para delete a.mem)
RSP+0x50: $T7 (temporal para delete b.mem)

Nota: tipo ocupa 8 bytes (un puntero en x64).

Paso 1: Alocación de a.mem

Código:

a.mem = new int[10];

Ensamblador:

mov     ecx, 40                    ; 00000028H (10 * 4 = 40 bytes)
call    operator new[]             ; Llamar a new[]
mov     QWORD PTR $T5[rsp], rax    ; Guardar dirección en temporal
mov     rax, QWORD PTR $T5[rsp]    ; Cargar dirección
mov     QWORD PTR a$[rsp], rax     ; a.mem = dirección

Explicación: 1. new int[10] aloca 40 bytes (10 enteros × 4 bytes) 2. operator new[] devuelve dirección en RAX 3. Se guarda en a.mem (offset a$[rsp])

Paso 2: Inicializar b.mem a 0

mov     QWORD PTR b$[rsp], 0       ; b.mem = nullptr

Paso 3: Loop - Inicializar a.mem a 0

Código:

for (int i = 0; i < 10; i++) a.mem[i] = 0;

Ensamblador:

; i = 0
mov     DWORD PTR i$1[rsp], 0
jmp     SHORT $LN4@main

$LN2@main:
    mov     eax, DWORD PTR i$1[rsp]    ; i++
    inc     eax
    mov     DWORD PTR i$1[rsp], eax

$LN4@main:
    cmp     DWORD PTR i$1[rsp], 10     ; i < 10?
    jge     SHORT $LN3@main

    ; a.mem[i] = 0
    movsxd  rax, DWORD PTR i$1[rsp]    ; RAX = i (con signo extendido)
    mov     rcx, QWORD PTR a$[rsp]     ; RCX = a.mem (dirección base)
    mov     DWORD PTR [rcx+rax*4], 0   ; a.mem[i] = 0
    jmp     SHORT $LN2@main

$LN3@main:

Explicación: - movsxd rax, i$1[rsp]: Extender i (32 bits) a 64 bits con signo - [rcx+rax*4]: Dirección = base + i * sizeof(int) = base + i * 4 - mov DWORD PTR [rcx+rax*4], 0: Escribir 0 en a.mem[i]

Paso 4: Llamar a asignar(b, a)

Código:

asignar(b, a);

Ensamblador:

lea     rdx, QWORD PTR a$[rsp]     ; RDX = dirección de a (2º arg)
lea     rcx, QWORD PTR b$[rsp]     ; RCX = dirección de b (1º arg)
call    asignar                    ; Llamar

Nota importante del video: Los nombres de parámetros en asignar están cruzados:

void asignar(tipo &a, tipo &b)  // a es dst, b es src

Pero se llama:

asignar(b, a);  // Pasar b como 1º arg (dst), a como 2º arg (src)

Resultado: En asignar, el parámetro a corresponde al objeto b original, y viceversa. Esto es confuso y el video lo menciona como un error pedagógico.

Paso 5: Imprimir b

Ensamblador (simplificado):

; cout << "b: "
lea     rdx, OFFSET FLAT:$SG35304   ; "b: "
mov     rcx, QWORD PTR std::cout
call    std::operator<<

; Loop para imprimir b.mem[i]
mov     DWORD PTR i$2[rsp], 0
jmp     SHORT $LN7@main

$LN5@main:
    mov     eax, DWORD PTR i$2[rsp]
    inc     eax
    mov     DWORD PTR i$2[rsp], eax

$LN7@main:
    cmp     DWORD PTR i$2[rsp], 10
    jge     SHORT $LN6@main

    ; cout << b.mem[i]
    movsxd  rax, DWORD PTR i$2[rsp]
    mov     rcx, QWORD PTR b$[rsp]     ; RCX = b.mem
    mov     edx, DWORD PTR [rcx+rax*4] ; EDX = b.mem[i]
    mov     rcx, QWORD PTR std::cout
    call    std::ostream::operator<<   ; cout << edx

    ; cout << ","
    lea     rdx, OFFSET FLAT:$SG35305  ; ","
    mov     rcx, rax
    call    std::operator<<

    jmp     SHORT $LN5@main

$LN6@main:
    ; cout << endl
    lea     rdx, OFFSET FLAT:std::endl
    mov     rcx, QWORD PTR std::cout
    call    std::ostream::operator<<

Paso 6: Modificar b.mem[2] = 1

Código:

b.mem[2] = 1;

Ensamblador:

mov     eax, 4                     ; sizeof(int) = 4
imul    rax, rax, 2                ; RAX = 4 * 2 = 8 (offset)
mov     rcx, QWORD PTR b$[rsp]     ; RCX = b.mem
mov     DWORD PTR [rcx+rax], 1     ; b.mem[2] = 1

Explicación: - imul rax, rax, 2: Calcula offset = 2 * 4 = 8 bytes - [rcx+rax] = b.mem + 8 = dirección de b.mem[2] - Escribe 1 en esa dirección

Paso 7: Imprimir a y b de nuevo

Similar al paso 5, pero imprime primero a y luego b. Veremos que a sigue con todos 0, y b tiene un 1 en la posición 2.

Paso 8: Liberar Memoria

Código:

delete[] a.mem;
delete[] b.mem;

Ensamblador:

; delete[] a.mem
mov     rax, QWORD PTR a$[rsp]
mov     QWORD PTR $T6[rsp], rax
mov     rcx, QWORD PTR $T6[rsp]
call    operator delete[]

; delete[] b.mem
mov     rax, QWORD PTR b$[rsp]
mov     QWORD PTR $T7[rsp], rax
mov     rcx, QWORD PTR $T7[rsp]
call    operator delete[]

Explicación: - Se pasa la dirección del buffer en RCX - operator delete[] libera la memoria - Como son buffers diferentes (gracias a asignar), no hay doble liberación


Análisis en Ensamblador - asignar()

Stack Layout

RSP+0x00: [Shadow Space (32 bytes)]
RSP+0x20: i$1 (loop)
RSP+0x28: $T2 (temporal para delete)
RSP+0x30: $T3 (temporal para new)
RSP+0x50: a$ (dirección del 1º arg - dst)
RSP+0x58: b$ (dirección del 2º arg - src)

Nota: Los parámetros se pasan en RCX y RDX, y se guardan en el stack.

Paso 1: Guardar Parámetros

mov     QWORD PTR [rsp+16], rdx    ; Guardar b$ (2º arg)
mov     QWORD PTR [rsp+8], rcx     ; Guardar a$ (1º arg)
sub     rsp, 72                    ; Reservar espacio en stack

Recordatorio: En x64 Windows, los primeros 4 argumentos van en RCX, RDX, R8, R9.

Paso 2: Verificar si son el Mismo Objeto

Código:

if (&a != &b) {

Ensamblador:

mov     rax, QWORD PTR b$[rsp]     ; RAX = dirección de b
cmp     QWORD PTR a$[rsp], rax     ; Comparar a$ con b$
je      SHORT $LN5@asignar         ; Si iguales, salir

Explicación: - Compara las direcciones de los objetos, no los contenidos - Si apuntan al mismo objeto, no hace nada (prevenir autoasignación)

Paso 3: Liberar Memoria Previa de a.mem (si existe)

Código:

if (a.mem) delete[] a.mem;

Ensamblador:

mov     rax, QWORD PTR a$[rsp]     ; RAX = dirección de a
cmp     QWORD PTR [rax], 0         ; a.mem == nullptr?
je      SHORT $LN6@asignar         ; Si nullptr, saltar

; Liberar a.mem
mov     rax, QWORD PTR a$[rsp]
mov     rax, QWORD PTR [rax]       ; RAX = a.mem
mov     QWORD PTR $T2[rsp], rax    ; Guardar en temporal
mov     rcx, QWORD PTR $T2[rsp]
call    operator delete[]

$LN6@asignar:

Explicación: - QWORD PTR [rax]: Desreferenciar a para obtener a.mem - Si a.mem != 0, liberar esa memoria antes de reasignar

Paso 4: Alocar Nueva Memoria para a.mem

Código:

a.mem = new int[10];

Ensamblador:

mov     ecx, 40                    ; 00000028H (10 * 4 bytes)
call    operator new[]
mov     QWORD PTR $T3[rsp], rax    ; Guardar dirección en temporal
mov     rax, QWORD PTR a$[rsp]     ; RAX = dirección de a
mov     rcx, QWORD PTR $T3[rsp]    ; RCX = nueva dirección
mov     QWORD PTR [rax], rcx       ; a.mem = nueva dirección

Explicación: - new int[10] aloca 40 bytes - Se guarda la dirección en a.mem (campo del objeto a)

Paso 5: Copiar Valores de b.mem a a.mem

Código:

for (int i = 0; i < 10; i++) {
    a.mem[i] = b.mem[i];
}

Ensamblador:

; i = 0
mov     DWORD PTR i$1[rsp], 0
jmp     SHORT $LN4@asignar

$LN2@asignar:
    mov     eax, DWORD PTR i$1[rsp]    ; i++
    inc     eax
    mov     DWORD PTR i$1[rsp], eax

$LN4@asignar:
    cmp     DWORD PTR i$1[rsp], 10     ; i < 10?
    jge     SHORT $LN3@asignar

    ; a.mem[i] = b.mem[i]
    movsxd  rax, DWORD PTR i$1[rsp]    ; RAX = i
    mov     rcx, QWORD PTR b$[rsp]     ; RCX = dirección de b
    mov     rcx, QWORD PTR [rcx]       ; RCX = b.mem

    movsxd  rdx, DWORD PTR i$1[rsp]    ; RDX = i
    mov     r8, QWORD PTR a$[rsp]      ; R8 = dirección de a
    mov     r8, QWORD PTR [r8]         ; R8 = a.mem

    mov     eax, DWORD PTR [rcx+rax*4] ; EAX = b.mem[i]
    mov     DWORD PTR [r8+rdx*4], eax  ; a.mem[i] = EAX

    jmp     SHORT $LN2@asignar

$LN3@asignar:

Explicación: 1. Cargar b.mem[i]: - mov rcx, QWORD PTR b$[rsp]: RCX = dirección de b - mov rcx, QWORD PTR [rcx]: RCX = b.mem (desreferenciar) - mov eax, DWORD PTR [rcx+rax*4]: EAX = b.mem[i]

  1. Guardar en a.mem[i]:
  2. mov r8, QWORD PTR a$[rsp]: R8 = dirección de a
  3. mov r8, QWORD PTR [r8]: R8 = a.mem
  4. mov DWORD PTR [r8+rdx*4], eax: a.mem[i] = EAX

Nota crítica: Este loop hace deep copy (copia elemento por elemento), no solo copia el puntero.

Paso 6: Retornar

$LN5@asignar:
    add     rsp, 72
    ret     0

Parte 6: Notación Funcional

Los operadores sobrecargados son simplemente funciones con sintaxis especial. Se pueden llamar explícitamente:

complejo x = {10, 32}, y = {21, 12};

complejo z1 = x + y;              // Sintaxis de operador
complejo z2 = operator+(x, y);    // Notación funcional (equivalente)

En ensamblador, ambas son llamadas a función:

; Ambos son lo mismo:
call    operator+(complejo, complejo)

Utilidad en reversing: Si ves una función llamada operator+, sabes que es un operador sobrecargado.


Resumen: Lo Más Importante

1. Sobrecarga de Operadores

  • Permite definir comportamiento personalizado para operadores (+, -, ++, etc.) con tipos propios
  • El compilador elige la versión correcta en tiempo de compilación
  • Son funciones con sintaxis especial: operator+, operator++, etc.

2. Operadores Binarios

  • Toman dos operandos: x + y
  • Se pueden sobrecargar para diferentes combinaciones de tipos
  • En ensamblador, son llamadas a funciones normales

3. Prefijo vs Sufijo

Característica Prefijo (++x) Sufijo (x++)
Firma operator++(T&) operator++(T&, int)
Parámetro int No Sí (dummy)
Comportamiento Incrementa y devuelve Devuelve, luego incrementa
Temporal No necesita Crea copia previa

4. Problema del Operador = por Defecto

  • Copia superficial: Solo copia bytes, no duplica memoria dinámica
  • Consecuencia: Múltiples objetos apuntan a la misma memoria
  • Solución: Función personalizada con deep copy (fuera de clases)

5. Deep Copy vs Shallow Copy

Tipo Descripción Cuándo usar
Shallow Copy Copia punteros (misma dirección) Tipos simples sin punteros
Deep Copy Aloca nueva memoria y copia valores Estructuras con punteros/memoria dinámica

6. Patrón de Asignación Segura

void asignar(tipo &dst, tipo &src) {
    if (&dst == &src) return;           // 1. Prevenir autoasignación
    if (dst.mem) delete[] dst.mem;      // 2. Liberar memoria previa
    dst.mem = new int[10];              // 3. Alocar nueva memoria
    for (int i = 0; i < 10; i++)        // 4. Copiar valores
        dst.mem[i] = src.mem[i];
}

7. Reconocimiento en Ensamblador

Operador sobrecargado:

call    operator+(complejo, complejo)   ; Nombre incluye "operator"

Sufijo (temporal):

; Crear copia antes de incrementar
movsd   xmm0, QWORD PTR [rcx]
movsd   QWORD PTR temp[rsp], xmm0
; Incrementar original
addss   xmm1, 1.0
movss   DWORD PTR [rcx], xmm1
; Devolver temporal

Deep copy:

; Loop copiando elemento por elemento
mov     eax, DWORD PTR [src+rax*4]    ; Leer src[i]
mov     DWORD PTR [dst+rax*4], eax    ; Escribir dst[i]

8. Errores Comunes

Error Causa Consecuencia
Doble liberación Dos punteros a la misma memoria Crash al hacer delete dos veces
Fuga de memoria No liberar antes de reasignar Memoria no recuperable
Modificación inesperada Shallow copy con punteros Cambios afectan múltiples objetos