¿Por qué las referencias circulares se consideran dañinas?

¿Por qué es un mal diseño para un objeto referirse a otro objeto que hace referencia al primero?

Las dependencias circulares entre clases no son necesariamente dañinas. De hecho, en algunos casos son deseables. Por ejemplo, si su aplicación trata con mascotas y sus dueños, esperaría que la clase Pet tenga un método para obtener el dueño de la mascota, y la clase Owner tenga un método que devuelva la lista de mascotas. Claro, esto puede dificultar la administración de la memoria (en un lenguaje no GC). Pero si la circularidad es inherente al problema, entonces intentar deshacerse de él probablemente genere más problemas.

Por otro lado, las dependencias circulares entre módulos son perjudiciales. En general, es indicativo de una estructura de módulo poco pensada y / o no se adhiere a la modularización original. En general, una base de código con dependencias cruzadas no controladas será más difícil de entender y más difícil de mantener que una estructura de módulos limpia y en capas. Sin módulos decentes, puede ser mucho más difícil predecir los efectos de un cambio. Y eso hace que el mantenimiento sea más difícil y conduce a la “descomposición del código” como resultado de un parche mal concebido.

(Además, las herramientas de comstackción como Maven no manejarán módulos (artefactos) con dependencias circulares).

Las referencias circulares no siempre son perjudiciales ; hay algunos casos de uso en los que pueden ser bastante útiles. Aparecen listas de enlaces dobles, modelos de gráficos y gramáticas de lenguaje informático. Sin embargo, como práctica general, hay varias razones por las que es posible que desee evitar las referencias circulares entre los objetos.

  1. Consistencia de datos y gráficos. La actualización de objetos con referencias circulares puede crear problemas para garantizar que en todo momento las relaciones entre los objetos sean válidas. Este tipo de problema a menudo surge en implementaciones de modelado relacional de objetos, donde no es raro encontrar referencias circulares bidireccionales entre entidades.

  2. Asegurar operaciones atómicas. Asegurar que los cambios a ambos objetos en una referencia circular sean atómicos puede ser complicado, particularmente cuando se trata de multihilo. Para garantizar la coherencia de un gráfico de objeto al que se puede acceder desde varios subprocesos, se requieren estructuras de sincronización y operaciones de locking especiales para garantizar que ningún subproceso vea un conjunto incompleto de cambios.

  3. Desafíos de separación física. Si dos clases diferentes A y B se referencian entre sí de forma circular, puede ser un desafío separar estas clases en ensamblajes independientes. Ciertamente es posible crear un tercer conjunto con las interfaces IA e IB que implementan A y B; permitiendo que cada uno haga referencia al otro a través de esas interfaces. También es posible utilizar referencias débilmente tipadas (por ejemplo, objeto) como una forma de romper la dependencia circular, pero luego no se puede acceder fácilmente al acceso al método y las propiedades de dicho objeto, lo que puede frustrar el propósito de tener una referencia.

  4. Aplicando referencias circulares inmutables. Los idiomas como C # y VB proporcionan palabras clave para permitir que las referencias dentro de un objeto sean inmutables (de solo lectura). Las referencias inmutables permiten que un progtwig garantice que una referencia se refiera al mismo objeto durante la vida del objeto. Desafortunadamente, no es fácil usar el mecanismo de inmutabilidad forzado por el comstackdor para asegurar que las referencias circulares no puedan ser modificadas. Solo se puede hacer si un objeto instancia el otro (vea el ejemplo de C # a continuación).

     class A { private readonly B m_B; public A( B other ) { m_B = other; } } class B { private readonly A m_A; public A() { m_A = new A( this ); } } 
  5. Lectibilidad y mantenimiento del progtwig. Las referencias circulares son inherentemente frágiles y fáciles de romper. Esto se debe en parte al hecho de que leer y comprender el código que incluye referencias circulares es más difícil que el código que los evita. Asegurar que su código sea fácil de entender y mantener ayuda a evitar errores y permite que los cambios se realicen de manera más fácil y segura. Los objetos con referencias circulares son más difíciles de probar por unidad porque no se pueden probar de forma aislada unos de otros.

  6. Gestión de vida del objeto. Si bien el recolector de elementos no utilizados de .NET es capaz de identificar y tratar con referencias circulares (y eliminar correctamente dichos objetos), no todos los lenguajes / entornos pueden hacerlo. En entornos que utilizan el recuento de referencias para su esquema de recolección de basura (por ejemplo, VB6, Objective-C, algunas librerías C ++) es posible que las referencias circulares produzcan pérdidas de memoria. Dado que cada objeto se aferra al otro, sus recuentos de referencia nunca llegarán a cero y, por lo tanto, nunca serán candidatos para la recolección y la limpieza.

Porque ahora son realmente un solo objeto. No puedes probar ninguno de forma aislada.

Si modifica uno, es probable que también afecte a su compañero.

De la Wikipedia:

Las dependencias circulares pueden causar muchos efectos no deseados en los progtwigs de software. Lo más problemático desde el punto de vista del diseño del software es el estrecho acoplamiento de los módulos mutuamente dependientes que reduce o imposibilita la reutilización separada de un único módulo.

Las dependencias circulares pueden causar un efecto dominó cuando un pequeño cambio local en un módulo se propaga a otros módulos y tiene efectos globales no deseados (errores de progtwig, errores de comstackción). Las dependencias circulares también pueden dar como resultado recursiones infinitas u otras fallas inesperadas.

Las dependencias circulares también pueden causar pérdidas de memoria al impedir que ciertos recolectores de basura automáticos muy primitivos (los que usan recuento de referencias) desasifiquen los objetos no utilizados.

Tal objeto puede ser difícil de crear y destruir, porque para hacerlo de forma no atómica tiene que violar la integridad referencial para crear / destruir primero uno, luego el otro (por ejemplo, su base de datos SQL podría resistirse a esto). Puede confundir a tu recolector de basura. Perl 5, que utiliza el recuento de referencia simple para la recolección de basura, no puede (sin ayuda) por lo que es una pérdida de memoria. Si los dos objetos son de diferentes clases, ahora están estrechamente acoplados y no se pueden separar. Si tiene un administrador de paquetes para instalar esas clases, la dependencia circular se extiende a él. Debe saber instalar ambos paquetes antes de probarlos, lo cual (hablando como mantenedor de un sistema de comstackción) es un PITA.

Dicho esto, todos pueden superarse y a menudo es necesario tener datos circulares. El mundo real no está formado por gráficos directos ordenados. Muchos gráficos, árboles, infierno, una lista de doble enlace es circular.

Daña la legibilidad del código. Y de las dependencias circulares al código spaghetti hay solo un pequeño paso.

Aquí hay un par de ejemplos que pueden ayudar a ilustrar por qué las dependencias circulares son malas.

Problema n. ° 1: ¿qué se inicializa / construye primero?

Considere el siguiente ejemplo:

 class A { public A() { myB.DoSomething(); } private B myB = new B(); } class B { public B() { myA.DoSomething(); } private A myA = new A(); } 

¿Qué constructor se llama primero? Realmente no hay forma de estar seguro porque es completamente ambiguo. Se va a invocar uno u otro de los métodos de DoSomething en un objeto que no está inicializado, lo que da como resultado un comportamiento incorrecto y muy probablemente una excepción. Hay formas de evitar este problema, pero todas son feas y todas requieren inicializadores que no sean de constructor.

Problema n. ° 2:

En este caso, cambié a un ejemplo de C ++ no administrado porque la implementación de .NET, por diseño, oculta el problema. Sin embargo, en el siguiente ejemplo, el problema será bastante claro. Soy consciente de que .NET realmente no usa el recuento de referencias debajo del capó para la administración de la memoria. Lo estoy usando aquí solo para ilustrar el problema central. Tenga en cuenta también que he demostrado aquí una posible solución al problema # 1.

 class B; class A { public: A() : Refs( 1 ) { myB = new B(this); }; ~A() { myB->Release(); } int AddRef() { return ++Refs; } int Release() { --Refs; if( Refs == 0 ) delete(this); return Refs; } B *myB; int Refs; }; class B { public: B( A *a ) : Refs( 1 ) { myA = a; a->AddRef(); } ~B() { myB->Release(); } int AddRef() { return ++Refs; } int Release() { --Refs; if( Refs == 0 ) delete(this); return Refs; } A *myA; int Refs; }; // Somewhere else in the code... ... A *localA = new A(); ... localA->Release(); // OK, we're done with it ... 

A primera vista, uno podría pensar que este código es correcto. El código de conteo de referencia es bastante simple y directo. Sin embargo, este código da como resultado una pérdida de memoria. Cuando se construye A, inicialmente tiene un recuento de referencia de “1”. Sin embargo, la variable myB encapsulada incrementa el recuento de referencia, lo que le da un recuento de “2”. Cuando se libera localA, el recuento disminuye, pero solo vuelve a “1”. Por lo tanto, el objeto se deja colgando y nunca se elimina.

Como mencioné anteriormente, .NET realmente no usa el recuento de referencias para su recolección de basura. Pero sí utiliza métodos similares para determinar si un objeto aún se está utilizando o si está bien para eliminarlo, y casi todos estos métodos pueden confundirse con referencias circulares. El recolector de basura de .NET afirma ser capaz de manejar esto, pero no estoy seguro de que confíe en él porque este es un problema muy espinoso. Ir, por otro lado, soluciona el problema simplemente no permitiendo referencias circulares. Hace diez años hubiera preferido el enfoque .NET por su flexibilidad. Estos días, me encuentro prefiriendo el enfoque Go por su simplicidad.

Es completamente normal tener objetos con referencias circulares, por ejemplo, en un modelo de dominio con asociaciones bidireccionales. Un ORM con un componente de acceso a datos correctamente escrito puede manejar eso.

Consulte el libro de Lakos, en el diseño del software C ++, la dependencia física cíclica es indeseable. Hay varias razones:

  • Los hace difíciles de probar e imposibles de reutilizar de forma independiente.
  • Les hace difícil a las personas comprender y mantener.
  • Aumentará el costo del tiempo de enlace.

Las referencias circulares parecen ser un escenario de modelado de dominio legítimo. Un ejemplo es Hibernate y muchas otras herramientas ORM fomentan esta asociación cruzada entre entidades para permitir la navegación bidireccional. Ejemplo típico en un sistema de Subasta en línea, una entidad Vendedor puede mantener una referencia a la Lista de entidades que Vende. Y cada artículo puede mantener una referencia a su vendedor correspondiente.

El recolector de elementos no utilizados .NET puede manejar referencias circulares por lo que no hay temor de memory leaks para las aplicaciones que trabajan en .NET Framework.