Perché i riferimenti circolari sono considerati dannosi?

Perché è un cattivo design per un object riferirsi a un altro object che rimanda al primo?

Le dipendenze circolari tra classi non sono necessariamente dannose. Anzi, in alcuni casi sono desiderabili. Ad esempio, se la tua applicazione riguarda gli animali domestici ei loro proprietari, ti aspetteresti che la class Pet abbia un metodo per ottenere il proprietario dell’animale e che la class Proprietario abbia un metodo che restituisca l’elenco degli animali domestici. Certo, questo può rendere la gestione della memoria più difficile (in una lingua non GC). Ma se la circolarità è inerente al problema, provare a sbarazzarsene probabilmente porterà a maggiori problemi.

D’altro canto, le dipendenze circolari tra i moduli sono dannose. È generalmente indicativo di una struttura del modulo scarsamente studiata e / o incapacità di attenersi alla modularizzazione originale. In generale, una base di codice con dipendenze incrociate incontrollate sarà più difficile da comprendere e più difficile da gestire rispetto a una con una struttura di modulo pulita e stratificata. Senza moduli decenti, può essere molto più difficile prevedere gli effetti di un cambiamento. E questo rende più difficile la manutenzione e porta a un “decadimento del codice” dovuto a patching mal concepiti.

(Inoltre, build strumenti come Maven non gestirà i moduli (artefatti) con dipendenze circolari.)

I riferimenti circolari non sono sempre dannosi – ci sono alcuni casi d’uso in cui possono essere abbastanza utili. Mi vengono in mente liste con collegamenti concreti, modelli grafici e grammatiche linguistiche. Tuttavia, come pratica generale, ci sono diversi motivi per cui si consiglia di evitare riferimenti circolari tra gli oggetti.

  1. Coerenza dei dati e dei grafici L’aggiornamento di oggetti con riferimenti circolari può creare sfide nel garantire che in tutti i punti del tempo le relazioni tra gli oggetti siano valide. Questo tipo di problema si verifica spesso nelle implementazioni di modellazione relazionale dell’object, dove non è raro trovare riferimenti circolari bidirezionali tra quadro.

  2. Garantire operazioni atomiche. Garantire che le modifiche a entrambi gli oggetti in un riferimento circolare siano atomiche può diventare complicato, in particolare quando è coinvolto il multithreading. Garantire la coerenza di un grafo di oggetti accessibile da più thread richiede speciali strutture di sincronizzazione e operazioni di blocco per garantire che nessun thread veda una serie incompleta di modifiche.

  3. Sfide di separazione fisica. Se due diverse classi A e B si fanno riferimento in modo circolare, può diventare difficile separare queste classi in assembly indipendenti. È certamente ansible creare un terzo assieme con le interfacce IA e IB implementate da A e B; consentendo a ciascuno di fare riferimento all’altro attraverso tali interfacce. È anche ansible utilizzare riferimenti debolmente tipizzati (ad esempio l’object) come modo per interrompere la dipendenza circolare, ma non è ansible accedere facilmente al metodo e alle proprietà di tale object, il che può vanificare lo scopo di avere un riferimento.

  4. Applicare riferimenti circolari immutabili. Lingue come C # e VB forniscono parole chiave per permettere che i riferimenti all’interno di un object siano immutabili (readonly). Riferimenti immutabili consentono a un programma di garantire che un riferimento faccia riferimento allo stesso object per la durata dell’object. Sfortunatamente, non è facile usare il meccanismo di immutabilità imposta dal compilatore per garantire che i riferimenti circolari non possano essere modificati. Può essere fatto solo se un object istanzia l’altro (vedi esempio C # sotto).

     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. Leggibilità del programma e manutenibilità I riferimenti circolari sono intrinsecamente fragili e facili da rompere. Ciò deriva in parte dal fatto che leggere e comprendere il codice che include riferimenti circolari è più difficile del codice che li evita. Garantire che il codice sia facile da capire e gestire aiuta a evitare bug e consente di apportare modifiche in modo più semplice e sicuro. Gli oggetti con riferimenti circolari sono più difficili da testare in quanto non possono essere testati separatamente l’uno dall’altro.

  6. Gestione a vita dell’object. Mentre il garbage collector di .NET è in grado di identificare e gestire i riferimenti circolari (e di disporre correttamente di tali oggetti), non tutti i linguaggi / ambienti possono. Negli ambienti che utilizzano il conteggio dei riferimenti per il loro schema di garbage collection (ad esempio VB6, Objective-C, alcune librerie C ++) è ansible che i riferimenti circolari generino perdite di memoria. Dal momento che ogni object rimane attaccato all’altro, i loro conteggi di riferimento non raggiungeranno mai lo zero e quindi non diventeranno mai candidati per la raccolta e la pulizia.

Perché ora sono davvero un unico object. Non puoi testare nessuno dei due in isolamento.

Se ne modifichi uno, è probabile che influenzi anche il suo compagno.

Da Wikipedia:

Le dipendenze circolari possono causare molti effetti indesiderati nei programmi software. La maggior parte del problema dal punto di vista della progettazione del software è l’accoppiamento stretto dei moduli reciprocamente dipendenti che riduce o rende imansible il riutilizzo separato di un singolo modulo.

Le dipendenze circolari possono causare un effetto domino quando una piccola modifica locale in un modulo si diffonde in altri moduli e ha effetti globali indesiderati (errori di programma, errori di compilazione). Le dipendenze circolari possono anche causare ricorsioni infinite o altri errori imprevisti.

Le dipendenze circolari possono anche causare perdite di memoria impedendo ad alcuni garbage collector automatici molto primitivi (quelli che usano il conteggio dei riferimenti) di deallocare oggetti inutilizzati.

Un object di questo tipo può essere difficile da creare e distruggere, perché per non-atomicamente devi violare l’integrità referenziale per crearne prima / distruggerne uno, poi l’altro (ad esempio, il tuo database SQL potrebbe bloccarlo). Potrebbe confondere il tuo garbage collector. Perl 5, che usa un semplice conteggio dei riferimenti per la garbage collection, non può (senza aiuto) una perdita di memoria. Se i due oggetti sono di classi diverse ora sono strettamente accoppiati e non possono essere separati. Se si dispone di un gestore di pacchetti per installare tali classi, la dipendenza circolare si diffonde su di essa. Deve sapere installare entrambi i pacchetti prima di testarli, che (parlando come manutentore di un sistema di costruzione) è un PITA.

Detto questo, questi possono essere superati e spesso è necessario disporre di dati circolari. Il mondo reale non è composto da grafici diretti e curati. Molti grafici, alberi, inferno, una lista a doppio legame sono circolari.

Fa male leggibilità del codice. E dalle dipendenze circolari al codice spaghetti c’è solo un piccolo passaggio.

Ecco alcuni esempi che possono aiutare a illustrare il motivo per cui le dipendenze circolari sono negative.

Problema 1: cosa viene inizializzato / costruito prima?

Considera il seguente esempio:

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

Quale costruttore viene chiamato prima? Non c’è davvero modo di esserne sicuri perché è completamente ambiguo. Uno o l’altro dei metodi DoSomething verrà chiamato su un object non inizializzato, con conseguente comportamento scorretto e molto probabile che venga sollevata un’eccezione. Ci sono modi per aggirare questo problema, ma sono tutti brutti e richiedono tutti gli inizializzatori non di costruzione.

Problema n. 2:

In questo caso, sono passato a un esempio C ++ non gestito perché l’implementazione di .NET, in base alla progettazione, nasconde il problema a te. Tuttavia, nel seguente esempio il problema diventerà abbastanza chiaro. Sono ben consapevole del fatto che .NET non utilizza realmente il conteggio dei riferimenti sotto la cappa per la gestione della memoria. Lo sto usando qui solo per illustrare il problema principale. Nota anche che ho dimostrato qui una ansible soluzione al problema n. 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 prima vista, si potrebbe pensare che questo codice sia corretto. Il codice di conteggio dei riferimenti è piuttosto semplice e diretto. Tuttavia, questo codice provoca una perdita di memoria. Quando A è costruito, inizialmente ha un conteggio di riferimento di “1”. Tuttavia, la variabile myB incapsulata incrementa il conteggio dei riferimenti, dandogli un conteggio di “2”. Quando viene rilasciato localA, il conteggio viene decrementato, ma torna a “1”. Quindi, l’object rimane sospeso e non viene mai cancellato.

Come ho detto sopra, .NET non usa realmente il conteggio dei riferimenti per la sua garbage collection. Ma usa metodi simili per determinare se un object è ancora in uso o se va bene cancellarlo, e quasi tutti questi metodi possono essere confusi da riferimenti circolari. Il garbage collector .NET afferma di essere in grado di gestirlo, ma non sono sicuro di fidarmi perché questo è un problema molto spinoso. Go, d’altra parte, aggira il problema semplicemente non permettendo affatto riferimenti circolari. Dieci anni fa avrei preferito l’approccio .NET per la sua flessibilità. In questi giorni, mi trovo a preferire l’approccio Go per la sua semplicità.

È del tutto normale avere oggetti con riferimenti circolari, ad esempio in un modello di dominio con associazioni bidirezionali. Un ORM con un componente di accesso ai dati correttamente scritto può gestirlo.

Fare riferimento al libro di Lakos, nel design del software C ++, la dipendenza fisica ciclica non è desiderabile. Ci sono diversi motivi:

  • Li rende difficili da testare e impossibili da riutilizzare indipendentemente.
  • Li rende difficili da comprendere e da mantenere.
  • Aumenterà il costo del link-time.

I riferimenti circolari sembrano essere uno scenario di modellazione di dominio legittimo. Un esempio è Hibernate e molti altri strumenti ORM incoraggiano questa associazione incrociata tra quadro per abilitare la navigazione bidirezionale. Esempio tipico in un sistema di aste online, un’ quadro venditore può mantenere un riferimento all’elenco delle quadro che sta vendendo. E ogni articolo può mantenere un riferimento al venditore corrispondente.

Il garbage collector .NET è in grado di gestire riferimenti circolari in modo da non temere perdite di memoria per le applicazioni che lavorano nel framework .NET.