1. Introducción
En cierto sentido, suscribirse a un evento en C# es como suscribirse a la lista de memes de un amigo: las novedades siguen llegando hasta que dices "basta" y te das de baja. En programación esto es especialmente importante, porque una suscripción olvidada no es solo "otro meme", sino una fuga de memoria.
Imagínate que tienes un formulario en la aplicación (por ejemplo, una ventana de configuración). Se suscribe a un evento de la ventana principal para reaccionar a cambios. El usuario cierra el formulario pensando que se destruyó, ¡pero el manejador sigue suscrito! El formulario sigue vivo en memoria porque el objeto principal mantiene una referencia a él a través del evento.
Conclusión: Si el objeto-suscriptor se suscribió a un evento del publicador y "olvidó" darse de baja, el garbage collector no lo eliminará de la memoria mientras el publicador esté vivo.
Repasemos el operador += y mostremos -=
- += — suscripción: añade el manejador a la lista de invocación del evento.
- -= — baja: elimina el manejador de la lista de invocación.
Se ve más o menos así:
worker.WorkCompleted += handler; // suscripción
worker.WorkCompleted -= handler; // baja
Si el manejador se añadió dos veces, hay que eliminarlo tantas veces como se añadió para que desaparezca definitivamente de la lista de invocación.
Un poco de "detrás de escenas"
Detrás de las cámaras, un evento en C# es un campo-delegado (o una lista de delegados), y el operador += en realidad llama a Delegate.Combine, mientras que -= llama a Delegate.Remove. El objeto que se suscribe al evento pasa a formar parte del grafo de referencias. Por eso una suscripción olvidada = fuga de memoria.
2. Fugas de memoria vía eventos: cómo funciona
Situación clásica
class Window
{
public event EventHandler Updated;
public void SimulateUpdate()
{
// Simulación: notificamos a todos los suscriptores
Updated?.Invoke(this, EventArgs.Empty);
}
}
class SettingsForm
{
public void OnWindowUpdated(object sender, EventArgs e)
{
Console.WriteLine("SettingsForm reacciona a la actualización de la ventana");
}
}
Vamos paso a paso:
var window = new Window();
var settingsForm = new SettingsForm();
window.Updated += settingsForm.OnWindowUpdated;
window.SimulateUpdate(); // SettingsForm reacciona
// El usuario cerró el formulario. Perdemos todas las referencias a él:
settingsForm = null;
// Pero el objeto SettingsForm NO será eliminado por el garbage collector mientras window esté vivo,
// porque window.Updated todavía guarda la referencia al método OnWindowUpdated,
// y por tanto también al propio objeto SettingsForm.
¿Qué hacer?
Darse de baja:
// Para esto necesitamos conservar la referencia al manejador o al objeto:
window.Updated -= settingsForm.OnWindowUpdated;
settingsForm = null; // Ahora el objeto podrá ser recolectado
Tabla: quién mantiene referencia a quién
| Acción | Quién guarda la referencia | ¿Se puede liberar la memoria? |
|---|---|---|
| Suscripción al evento (+=) | El publicador al suscriptor | No, mientras el publicador esté vivo |
| Baja (-=) | No | Sí, después de eliminar todas las referencias externas |
| Sin suscripción | No | Sí |
3. Cómo organizar correctamente la baja
Eliminación explícita del manejador
Esto se puede hacer, por ejemplo, en el momento de cerrar la ventana o el formulario:
class SettingsForm
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void Close()
{
_window.Updated -= OnWindowUpdated; // ¡nos damos de baja!
// aquí va el código de cierre (por ejemplo, Dispose, GC.SuppressFinalize, etc.)
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// Manejo del evento
}
}
Si SettingsForm se destruye mediante un botón "cerrar", es importante no olvidar llamar al método donde se realiza la baja (por ejemplo, Close()).
Uso de la interfaz IDisposable
Para objetos complejos que se suscriben a eventos y controlan su propio ciclo de vida, es conveniente implementar la interfaz IDisposable. En el método Dispose() se hacen todas las bajas necesarias.
class SettingsForm : IDisposable
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_window.Updated -= OnWindowUpdated;
// Aquí también liberamos otros recursos
}
}
Ahora SettingsForm se puede usar dentro de un bloque using, llamar explícitamente a Dispose() o automatizar la liberación de recursos (por ejemplo, mediante GC.SuppressFinalize en tipos finalizables).
4. Interacción con expresiones lambda: peligros y trucos
Si te suscribes a un evento usando una expresión lambda pero no guardas la lambda en una variable, ¡no podrás darte de baja!
// Suscripción — lambda anónima
window.Updated += (s, e) => Console.WriteLine("¡Lambda invocada!");
// ¿Cómo darte de baja ahora? — ¡Imposible!
window.Updated -= (s, e) => Console.WriteLine("¡Lambda invocada!"); // ¡Este es otro delegado!
¿Qué hacer?
Guarda la lambda en una variable-delegado:
EventHandler handler = (s, e) => Console.WriteLine("¡Lambda invocada!");
window.Updated += handler;
// ... ¡ahora sí puedes darte de baja!
window.Updated -= handler;
5. Matices útiles
Particularidades del ciclo de vida de objetos y eventos
Otro problema común son las referencias cruzadas vía eventos entre dos objetos "de larga vida". Por ejemplo, una ventana suscrita a eventos de otra; ambos siguen en uso, no se eliminan — y la memoria crece.
Recomendación: Procura siempre recordar quién se suscribe a quién y cuándo es necesario darse de baja. Si la suscripción tiene el mismo ciclo de vida que el publicador — perfecto. Si el suscriptor puede vivir menos que el publicador, implementa una baja explícita.
Regla universal: "Si te suscribiste — date de baja"
- Para publicadores de larga vida (por ejemplo, globales, singletons, ventanas principales) — siempre implementa la baja en los suscriptores.
- Para objetos temporales (por ejemplo, notificaciones puntuales o casos donde el suscriptor vive más que el publicador) — puedes relajarte un poco, pero controla siempre el contexto.
- Usa enfoques como WeakEvent (eventos débiles) o frameworks especializados si no quieres gestionar las bajas manualmente.
6. Errores típicos al trabajar con la baja
Baja incorrecta: el método manejador debe ser el mismo
Es muy importante que al darte de baja indiques exactamente el mismo manejador que usaste para suscribirte. Si no, la baja no funcionará.
Error:
window.Updated += settingsForm.OnWindowUpdated;
// ...
window.Updated -= new SettingsForm().OnWindowUpdated; // ¡No funcionará! Es otra instancia y otro delegado.
Correcto:
window.Updated -= settingsForm.OnWindowUpdated;
Si al suscribirte usas una lambda anónima sin guardar la referencia al delegado, darte de baja es imposible porque será otra instancia de delegado:
// Suscripción
window.Updated += (s, e) => Console.WriteLine("¡Lambda!");
// Intento de baja — no funcionará
window.Updated -= (s, e) => Console.WriteLine("¡Lambda!");
"Baja olvidada"
Muy a menudo simplemente se olvida darse de baja, especialmente cuando el suscriptor vive más que el publicador o cuando el desarrollador no entiende del todo cómo funcionan los eventos. Como resultado, los objetos-suscriptores permanecen en memoria más tiempo del necesario, lo que provoca fugas de memoria y problemas de rendimiento.
GO TO FULL VERSION