DEV Community

luis enrique vargas azcona
luis enrique vargas azcona

Posted on

Referencias Circulares: Qué Son, Por Qué Evitarlas y Cómo Hacerlo

¿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;
};
Enter fullscreen mode Exit fullscreen mode
// MissionSystem.h
#pragma once

class ShipController;

class MissionSystem
{
public:
    void SetShipController(ShipController* InShip);
    void CompleteLandingObjective();

private:
    ShipController* ShipRef = nullptr;
};
Enter fullscreen mode Exit fullscreen mode
// 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();
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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ó
    }
}
Enter fullscreen mode Exit fullscreen mode

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";
};
Enter fullscreen mode Exit fullscreen mode
// 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;
};
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
// 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);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. El ciclo de llamadas: OnLanding termina llamando indirectamente a SetFuelCritical sobre la misma nave que inició el aterrizaje.
  2. La dependencia de orden implícita: el resultado de GetCurrentObjective en el paso 6 depende de si OnFuelCritical modificó bEmergencyActive en 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ólo ShipController.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;
};
Enter fullscreen mode Exit fullscreen mode
// 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;
};
Enter fullscreen mode Exit fullscreen mode
// MissionSystem.h
#pragma once
#include "FlightStats.h"

// Ya no necesita conocer ShipController en absoluto
class MissionSystem
{
public:
    void EvaluateLanding(const FlightStats& Stats);
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode
// 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...
    }
}
Enter fullscreen mode Exit fullscreen mode

El grafo de dependencias resultante: AShipControllerUMissionTrackerComponent y UFlightStatsComponent. UMissionTrackerComponentUFlightStatsComponent. 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;
};
Enter fullscreen mode Exit fullscreen mode
// 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);
        }
    );
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
};
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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 { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
};
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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 { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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)