¿Qué son las Referencias Circulares?
Una referencia circular ocurre cuando dos o más clases se referencian mutuamente: la clase Caller conoce a la clase Service, y la clase Service también conoce a la clase Caller.
En C++ esto es perfectamente compilable. Supón que tienes las clases ShipController y MissionSystem. El controlador de la nave necesita saber en qué misión está para, por ejemplo, habilitar ciertas acciones. Y el sistema de misiones necesita saber qué nave está en juego para actualizar su estado.
La forma en que C++ permite esto es combinando includes en los .cpp con declaraciones anticipadas (forward declarations) en los .h:
// ShipController.h
#pragma once
// Declaración anticipada: le decimos al compilador que MissionSystem existe,
// sin necesidad de incluir su header completo aquí.
class MissionSystem;
class ShipController
{
public:
void SetMissionSystem(MissionSystem* InMissionSystem);
void OnLanding();
private:
MissionSystem* MissionSystemRef = nullptr;
};
// MissionSystem.h
#pragma once
class ShipController;
class MissionSystem
{
public:
void SetShipController(ShipController* InShip);
void CompleteLandingObjective();
private:
ShipController* ShipRef = nullptr;
};
// ShipController.cpp
#include "ShipController.h"
#include "MissionSystem.h" // Aquí sí incluimos el header completo
void ShipController::SetMissionSystem(MissionSystem* InMissionSystem)
{
MissionSystemRef = InMissionSystem;
}
void ShipController::OnLanding()
{
if (MissionSystemRef)
{
MissionSystemRef->CompleteLandingObjective();
}
}
// MissionSystem.cpp
#include "MissionSystem.h"
#include "ShipController.h" // Y aquí también incluimos el header completo
void MissionSystem::SetShipController(ShipController* InShip)
{
ShipRef = InShip;
}
void MissionSystem::CompleteLandingObjective()
{
if (ShipRef)
{
// Leer estado de la nave para decidir si el objetivo se completó
}
}
La clave del truco: la declaración anticipada en el .h le dice al compilador "esta clase existe" sin necesidad de conocer su tamaño ni sus métodos. El .cpp sí incluye el header completo porque ahí es donde realmente se usan los métodos.
¿Qué hay de Malo en las Referencias Circulares?
Que el compilador lo permita no significa que sea buena idea. Las referencias circulares traen tres problemas reales.
1. Las Pruebas Unitarias se Complican
Para hacer una prueba unitaria de MissionSystem en aislamiento, normalmente creas una instancia y la pruebas. Pero si MissionSystem tiene una referencia a ShipController, ahora tienes que crear también un ShipController para que funcione. Y si ShipController tiene dependencias propias, también las necesitas. El grafo crece y la prueba unitaria deja de serlo.
Con referencias circulares, no puedes compilar ni instanciar una clase sin cargar también la otra. Esto complica los mocks, aumenta el tiempo de compilación en las pruebas y hace que los fallos sean más difíciles de aislar.
2. Las Dependencias se Vuelven Difusas
En un proyecto sano, el grafo de dependencias es un DAG (grafo acíclico dirigido): los módulos de alto nivel dependen de los de bajo nivel, pero no al revés. Con una referencia circular, ese grafo tiene un ciclo. Ahora los dos módulos forman un bloque indivisible: no puedes reutilizar uno sin llevar el otro, no puedes actualizar uno sin preocuparte por el otro, y no puedes moverlos a paquetes o módulos separados.
3. El Código se Vuelve Más Difícil de Leer
El lector tiene que mantener ambos archivos en su cabeza al mismo tiempo para entender el comportamiento de cualquiera de los dos. Esto no es sólo incomodidad: es deuda cognitiva acumulada que ralentiza a todo el equipo.
Por ejemplo, considera este par de clases con una referencia circular más elaborada:
// ShipController.h
#pragma once
class MissionSystem;
class ShipController
{
public:
void SetMissionSystem(MissionSystem* MS) { MissionRef = MS; }
void OnLanding(); // Informa al sistema de misiones que aterrizamos
void RequestMissionUpdate();// Pide al sistema de misiones el objetivo actual
void ForceLand(); // Fuerza un aterrizaje de emergencia
void SetFuelCritical(); // Marca el estado de combustible como crítico
float GetFuelLevel() const { return FuelLevel; }
FString GetCurrentPlanet() const { return CurrentPlanet; }
private:
MissionSystem* MissionRef = nullptr;
float FuelLevel = 100.f;
FString CurrentPlanet = "Luna";
};
// MissionSystem.h
#pragma once
class ShipController;
class MissionSystem
{
public:
void SetShipController(ShipController* Ship) { ShipRef = Ship; }
void EvaluateLanding(); // Decide si el aterrizaje cuenta como objetivo
FString GetCurrentObjective();// Devuelve el objetivo activo según el estado de la nave
void OnFuelCritical(); // Reacciona a una alerta de combustible
private:
ShipController* ShipRef = nullptr;
FString CurrentObjective = "Land on Luna";
bool bEmergencyActive = false;
};
Ahora mira lo que ocurre cuando un lector intenta seguir el flujo de OnLanding:
// ShipController.cpp
void ShipController::OnLanding()
{
// 1. Le avisamos a MissionSystem que aterrizamos → salto a MissionSystem.cpp
MissionRef->EvaluateLanding();
// 6. Pedimos el siguiente objetivo tras aterrizar → salto a MissionSystem.cpp
// ⚠ El resultado depende de si EvaluateLanding activó la emergencia (pasos 3-5).
// Desde aquí es imposible saberlo sin leer MissionSystem.cpp completo.
FString NextObjective = MissionRef->GetCurrentObjective();
UE_LOG(LogTemp, Log, TEXT("Siguiente objetivo: %s"), *NextObjective);
}
void ShipController::ForceLand()
{
FuelLevel = 0.f;
// 4. Notificamos la emergencia → salto a MissionSystem.cpp
MissionRef->OnFuelCritical();
}
// MissionSystem.cpp
void MissionSystem::EvaluateLanding()
{
// 2. Para evaluar necesitamos datos de la nave → salto a ShipController.h
float Fuel = ShipRef->GetFuelLevel();
if (Fuel < 10.f)
{
bEmergencyActive = true;
// 3. Decidimos forzar un aterrizaje de emergencia → salto a ShipController.cpp
ShipRef->ForceLand(); // ← dispara ForceLand(), que llama de vuelta a OnFuelCritical()
}
}
void MissionSystem::OnFuelCritical()
{
bEmergencyActive = true; // ← este flag cambia el comportamiento de GetCurrentObjective
CurrentObjective = "Emergency landing";
// 5. Marca el estado de la nave → salto a ShipController.cpp
ShipRef->SetFuelCritical();
}
FString MissionSystem::GetCurrentObjective()
{
// Si OnFuelCritical ya corrió (pasos 4-5), devolvemos la emergencia sin más saltos.
// Si no corrió, necesitamos preguntarle a la nave.
if (bEmergencyActive)
return CurrentObjective; // "Emergency landing"
// 7. El objetivo depende del planeta actual → salto a ShipController.h
FString Planet = ShipRef->GetCurrentPlanet();
return FString::Printf(TEXT("Aterrizar en %s"), *Planet);
}
El lector que quiere entender qué pasa cuando la nave aterriza tiene que saltar entre los dos archivos siguiendo esta cadena:
ShipController::OnLanding →(1) MissionSystem::EvaluateLanding →(2) ShipController::GetFuelLevel →(3) ShipController::ForceLand →(4) MissionSystem::OnFuelCritical →(5) ShipController::SetFuelCritical ... regresa a OnLanding →(6) MissionSystem::GetCurrentObjective →(7) ShipController::GetCurrentPlanet
Siete saltos entre dos archivos para entender una sola acción. Y hay dos trampas invisibles desde OnLanding:
-
El ciclo de llamadas:
OnLandingtermina llamando indirectamente aSetFuelCriticalsobre la misma nave que inició el aterrizaje. -
La dependencia de orden implícita: el resultado de
GetCurrentObjectiveen el paso 6 depende de siOnFuelCriticalmodificóbEmergencyActiveen el paso 4. Si los pasos 3-5 no se ejecutaron (combustible normal), devuelve el planeta actual. Si sí se ejecutaron, devuelve"Emergency landing". Esto es completamente invisible leyendo sóloShipController.cpp.
Ambas cosas son exactamente el tipo de bug que aparece a las 11pm antes de una demo.
Estrategias Para Evitarlas
No existe una solución universal. La mejor estrategia depende de la arquitectura que ya tienes y de la naturaleza de la dependencia. Aquí hay cuatro herramientas concretas.
1. Agregación
La agregación es la más directa cuando la referencia circular existe porque una clase usa sólo una parte de la otra.
La idea: Si Service necesita acceder a Caller sólo para usar ciertos datos o comportamientos, hay que extraer esa parte en una tercera clase SharedData. Una vez hecho el cambio, Caller le pasará SharedData a Service, y Service usará SharedData directamente en lugar de Caller.
Resultado: Caller referencia a Service y a SharedData. Service referencia sólo a SharedData. Sin ciclo.
Ejemplo: Supón que MissionSystem necesita de ShipController sólo para leer las estadísticas de vuelo (velocidad, combustible, posición). En lugar de darle una referencia a todo el controlador, extraemos esos datos en una clase propia:
// FlightStats.h
#pragma once
struct FlightStats
{
float Speed = 0.f;
float Fuel = 0.f;
float Altitude = 0.f;
};
// ShipController.h
#pragma once
#include "FlightStats.h"
class MissionSystem;
class ShipController
{
public:
void SetMissionSystem(MissionSystem* InMissionSystem);
void OnLanding();
const FlightStats& GetFlightStats() const { return CurrentStats; }
private:
MissionSystem* MissionSystemRef = nullptr;
FlightStats CurrentStats;
};
// MissionSystem.h
#pragma once
#include "FlightStats.h"
// Ya no necesita conocer ShipController en absoluto
class MissionSystem
{
public:
void EvaluateLanding(const FlightStats& Stats);
};
Ahora MissionSystem ni siquiera sabe que ShipController existe. Se puede probar en total aislamiento pasándole un FlightStats construido a mano.
Cuándo usarla: Cuando la dependencia circular existe porque Service realmente sólo necesita una parte de Caller. La agregación tiene el efecto secundario de separar responsabilidades, lo cual suele ser positivo. Si no hay una responsabilidad clara que separar y estás creando SharedData sólo para romper el ciclo, probablemente una de las otras estrategias sea más limpia.
2. Usar la Jerarquía de Componentes
Esta estrategia aplica cuando Caller y Service son componentes dentro de una estructura jerárquica (como los UActorComponent en Unreal Engine) y Caller es el owner o padre de Service en esa jerarquía.
La idea abstracta: En lugar de que Service tenga una referencia directa a Caller, Service navega la jerarquía en tiempo de ejecución (GetOwner(), GetComponent()) para encontrar el servicio que necesita. Si lo que necesita está en una interfaz o componente bien definido SharedData, Service sólo depende de su clase base y de SharedData, no de Caller.
En Unreal Engine específicamente: Si Service es un UActorComponent y necesita datos que viven en el Actor Caller, lo correcto es extraer esos datos a un nuevo UActorComponent SharedData, añadir SharedData al Actor, y desde Service hacer:
// MissionTrackerComponent.h (este es "Service")
#pragma once
#include "Components/ActorComponent.h"
#include "MissionTrackerComponent.generated.h"
// No incluimos ShipController. Solo necesitamos FlightStatsComponent.
class UFlightStatsComponent;
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class SELENE_API UMissionTrackerComponent : public UActorComponent
{
GENERATED_BODY()
public:
virtual void BeginPlay() override;
void EvaluateLanding();
private:
// Obtenida en BeginPlay, no inyectada desde fuera
UFlightStatsComponent* FlightStats = nullptr;
};
// MissionTrackerComponent.cpp
#include "MissionTrackerComponent.h"
#include "FlightStatsComponent.h" // Solo depende de SharedData
#include "GameFramework/Actor.h"
void UMissionTrackerComponent::BeginPlay()
{
Super::BeginPlay();
// Navega la jerarquía para encontrar el componente que necesita
FlightStats = GetOwner()->FindComponentByClass<UFlightStatsComponent>();
}
void UMissionTrackerComponent::EvaluateLanding()
{
if (FlightStats)
{
float Speed = FlightStats->GetSpeed();
// lógica de evaluación...
}
}
El grafo de dependencias resultante: AShipController → UMissionTrackerComponent y UFlightStatsComponent. UMissionTrackerComponent → UFlightStatsComponent. Sin ciclos.
Cuándo usarla: Cuando ya estás en una arquitectura de componentes (Unreal, Unity, etc.) y la referencia circular es entre un Actor y uno de sus componentes, o entre dos componentes del mismo Actor. Es la extensión natural de la agregación dentro del framework.
3. Callbacks
Un callback es una función que Caller le pasa a Service para que Service la llame cuando ocurra algo. Service no necesita saber quién es Caller; sólo sabe que tiene una función que puede invocar.
En C++ estándar con std::function:
// MissionSystem.h — no incluye ShipController
#pragma once
#include <functional>
class MissionSystem
{
public:
using LandingCallback = std::function<void(float /* speed */, float /* fuel */)>;
void SetOnLandingCallback(LandingCallback Callback)
{
OnLanding = std::move(Callback);
}
void EvaluateLanding(float Speed, float Fuel)
{
if (OnLanding) OnLanding(Speed, Fuel);
}
private:
LandingCallback OnLanding;
};
// ShipController.cpp — sólo este archivo conoce a MissionSystem
#include "ShipController.h"
#include "MissionSystem.h"
void ShipController::Initialize(MissionSystem* InMissionSystem)
{
InMissionSystem->SetOnLandingCallback(
[this](float Speed, float Fuel)
{
// Reaccionar al evento de aterrizaje
HandleMissionLanding(Speed, Fuel);
}
);
}
En Unreal Engine con TFunction o delegates dinámicos:
// MissionSystem.h
#pragma once
#include "CoreMinimal.h"
DECLARE_DELEGATE_TwoParams(FOnLandingDelegate, float /*Speed*/, float /*Fuel*/);
class SELENE_API UMissionSystem
{
public:
FOnLandingDelegate OnLanding;
void EvaluateLanding(float Speed, float Fuel)
{
OnLanding.ExecuteIfBound(Speed, Fuel);
}
};
En TypeScript:
// missionSystem.ts — no importa ShipController
type LandingCallback = (speed: number, fuel: number) => void;
class MissionSystem {
private onLanding: LandingCallback | null = null;
setOnLandingCallback(callback: LandingCallback): void {
this.onLanding = callback;
}
evaluateLanding(speed: number, fuel: number): void {
this.onLanding?.(speed, fuel);
}
}
// shipController.ts
import { MissionSystem } from './missionSystem';
class ShipController {
constructor(private missionSystem: MissionSystem) {
this.missionSystem.setOnLandingCallback((speed, fuel) => {
this.handleMissionLanding(speed, fuel);
});
}
private handleMissionLanding(speed: number, fuel: number): void { /* ... */ }
}
Ventajas: Los callbacks son ideales para pruebas unitarias. Para probar MissionSystem en aislamiento, simplemente le pasas una función lambda de prueba y verificas que se llame con los argumentos correctos. No necesitas instanciar nada más.
Desventaja importante: Si MissionSystem guarda el callback como parte de su estado interno (como en el ejemplo anterior), sólo puede haber un suscriptor a la vez. Si ShipController establece su callback y luego UIManager intenta establecer el suyo, el primero se pierde. Esto se vuelve una fuente de bugs difíciles de detectar cuando el sistema crece. Para múltiples suscriptores, los eventos son la solución correcta.
4. Eventos
Los eventos extienden la idea del callback para permitir múltiples suscriptores. En lugar de una sola función almacenada, hay una lista de suscriptores que son notificados cuando ocurre algo. El emisor del evento no conoce a ninguno de sus oyentes.
En Unreal Engine con DECLARE_MULTICAST_DELEGATE:
// MissionSystem.h
#pragma once
#include "CoreMinimal.h"
// Un delegate multicast puede tener múltiples suscriptores
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnObjectiveCompleted, FName /*ObjectiveName*/, bool /*bSuccess*/);
class SELENE_API UMissionSystem
{
public:
FOnObjectiveCompleted OnObjectiveCompleted;
void CompleteObjective(FName Name, bool bSuccess)
{
// Notifica a todos los suscriptores
OnObjectiveCompleted.Broadcast(Name, bSuccess);
}
};
// ShipController.cpp
#include "ShipController.h"
#include "MissionSystem.h"
void AShipController::BeginPlay()
{
Super::BeginPlay();
if (MissionSystem)
{
MissionSystem->OnObjectiveCompleted.AddUObject(
this, &AShipController::OnMissionObjectiveCompleted
);
}
}
void AShipController::OnMissionObjectiveCompleted(FName ObjectiveName, bool bSuccess)
{
// Reaccionar al evento
}
En C# con event:
// MissionSystem.cs
public class MissionSystem
{
public event Action<string, bool> OnObjectiveCompleted;
public void CompleteObjective(string name, bool success)
{
OnObjectiveCompleted?.Invoke(name, success);
}
}
// ShipController.cs
public class ShipController
{
public ShipController(MissionSystem missionSystem)
{
missionSystem.OnObjectiveCompleted += HandleObjectiveCompleted;
}
private void HandleObjectiveCompleted(string name, bool success) { /* ... */ }
}
En TypeScript con EventEmitter:
// missionSystem.ts
import { EventEmitter } from 'events';
class MissionSystem extends EventEmitter {
completeObjective(name: string, success: boolean): void {
this.emit('objectiveCompleted', name, success);
}
}
// shipController.ts
import { MissionSystem } from './missionSystem';
class ShipController {
constructor(missionSystem: MissionSystem) {
missionSystem.on('objectiveCompleted', (name: string, success: boolean) => {
this.handleObjectiveCompleted(name, success);
});
}
private handleObjectiveCompleted(name: string, success: boolean): void { /* ... */ }
}
Ventajas: Los eventos desacoplan completamente al emisor de sus oyentes. Múltiples sistemas pueden reaccionar al mismo evento (ShipController, UIManager, AchievementSystem) sin que MissionSystem sepa nada de ninguno de ellos. Esto produce un grafo de dependencias unidireccional incluso cuando el flujo de información es complejo.
Desventaja: Los eventos asincrónicos son notoriamente difíciles de probar. Si el evento se dispara en un frame diferente, en un hilo separado o después de un timer, la prueba unitaria necesita algún mecanismo para esperar la notificación (promesas, futuros, o frameworks de prueba especializados). Los eventos síncronos son fáciles de probar; los asíncronos, no.
Además, cuando el sistema crece y hay decenas de eventos, puede volverse difícil seguir el flujo de un comportamiento concreto sin herramientas específicas (logging de eventos, visualizadores).
Resumen
| Estrategia | Cuándo usarla | Ventaja principal | Desventaja |
|---|---|---|---|
| Agregación |
Service sólo necesita parte de Caller
|
Separa responsabilidades, fácil de probar | Requiere identificar una responsabilidad real que extraer |
| Jerarquía de componentes |
Caller y Service son componentes, Caller es owner de Service
|
Natural en Unreal/Unity, sin acoplamiento extra | Sólo aplica en arquitecturas de componentes |
| Callbacks | Un único suscriptor conocido al momento del diseño | Muy fácil de probar con lambdas | Un solo suscriptor posible; puede causar bugs si varios objetos quieren registrarse |
| Eventos | Múltiples suscriptores, o el emisor no debe conocer a nadie | Desacoplamiento total del emisor | Eventos asíncronos son difíciles de probar |
Las referencias circulares rara vez son necesarias. Cuando aparecen, suelen ser una señal de que dos responsabilidades que deberían estar separadas terminaron mezcladas en las mismas clases. Elegir la estrategia correcta no sólo elimina el ciclo, sino que deja el código más fácil de entender, probar y cambiar.
Top comments (0)