Sobrecarga de Operadores en C++: Guía de Reversing¶
Índice¶
- Introducción
- Parte 1: Conceptos Fundamentales
- Parte 2: Operadores Binarios
- Parte 3: Operadores Unitarios - Prefijo vs Sufijo
- Parte 4: El Problema del Operador de Asignación
- Parte 5: Ejemplo Completo - Análisis Detallado
- Parte 6: Notación Funcional
- Resumen: Lo Más Importante
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:
Definición:
Ejemplo:
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:
- Tipos de operandos:
complejo + complejo,complejo + float, etc. - Firma de funciones disponibles: Busca
operator+que coincida - 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:
Sobrecarga de Prefijo¶
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:
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):
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:
- Modificar
b.mem[i]modificaa.mem[i](misma memoria) - Doble liberación:
delete[] a.memydelete[] b.memliberan 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:
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:
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¶
Paso 3: Loop - Inicializar a.mem a 0¶
Código:
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:
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:
Pero se llama:
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:
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:
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:
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:
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:
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:
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]
- Guardar en
a.mem[i]: mov r8, QWORD PTR a$[rsp]:R8= dirección deamov r8, QWORD PTR [r8]:R8=a.memmov 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¶
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:
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:
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 |