Pourquoi les références circulaires sont-elles considérées comme nuisibles?

Pourquoi est-ce une mauvaise conception pour un object de se référer à un autre object qui renvoie au premier?

Les dépendances circulaires entre les classes ne sont pas nécessairement nuisibles. En effet, dans certains cas, ils sont souhaitables. Par exemple, si votre application traite des animaux de compagnie et de leurs propriétaires, la classe Pet doit avoir une méthode pour obtenir le propriétaire de l’animal et la classe Owner une méthode qui renvoie la liste des animaux de compagnie. Bien sûr, cela peut rendre la gestion de la mémoire plus difficile (dans un langage non-GC). Mais si la circularité est inhérente au problème, alors essayer de s’en débarrasser va probablement entraîner d’autres problèmes.

Par contre, les dépendances circulaires entre les modules sont nuisibles. Cela indique généralement une structure de module mal conçue et / ou un échec à respecter la modularisation originale. En général, une base de code avec des dépendances croisées incontrôlées sera plus difficile à comprendre et plus difficile à gérer qu’avec une structure de module propre et en couches. Sans modules décents, il peut être beaucoup plus difficile de prévoir les effets d’un changement. Et cela rend la maintenance plus difficile et conduit à une «dégradation du code» résultant de corrections mal conçues.

(De plus, les outils de construction comme Maven ne gèrent pas les modules (artefacts) avec des dépendances circulaires.)

Les références circulaires ne sont pas toujours nuisibles – il y a des cas d’utilisation où elles peuvent être très utiles. On pense à des listes à double lien, à des modèles de graphique et à des grammaires de langage informatique. Cependant, en règle générale, vous pouvez éviter les références circulaires entre les objects pour plusieurs raisons.

  1. Cohérence des données et des graphiques. La mise à jour des objects avec des références circulaires peut créer des difficultés pour garantir à tout moment que les relations entre les objects sont valides. Ce type de problème se pose souvent dans les implémentations de modélisation object-relationnelle, où il n’est pas rare de trouver des références circulaires bidirectionnelles entre entités.

  2. Assurer les opérations atomiques. S’assurer que les modifications apscopes aux deux objects dans une référence circulaire sont atomiques peut devenir compliquée, en particulier lorsque le multithreading est impliqué. Garantir la cohérence d’un graphe d’objects accessible à partir de plusieurs threads nécessite des structures de synchronisation et des opérations de locking spéciales pour garantir qu’aucun thread ne voit un ensemble de modifications incomplet.

  3. Défis de séparation physique. Si deux classes différentes A et B se réfèrent de manière circulaire, il peut être difficile de séparer ces classes en assemblages indépendants. Il est certainement possible de créer un troisième ensemble avec les interfaces IA et IB implémentées par A et B; permettant à chacun de faire référence à l’autre à travers ces interfaces. Il est également possible d’utiliser des références faiblement typées (par exemple un object) comme moyen de rompre la dépendance circulaire, mais l’access à la méthode et aux propriétés d’un tel object ne pouvait pas être facilement accessible – ce qui peut empêcher la référence.

  4. Appliquer des références circulaires immuables. Les langages tels que C # et VB fournissent des mots-clés permettant aux références d’un object d’être immuables (en lecture seule). Les références immuables permettent à un programme de s’assurer qu’une référence fait référence au même object pour la durée de vie de l’object. Malheureusement, il n’est pas facile d’utiliser le mécanisme d’immuabilité du compilateur pour s’assurer que les références circulaires ne peuvent pas être modifiées. Cela ne peut être fait que si un object instancie l’autre (voir l’exemple C # ci-dessous).

     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. Lisibilité du programme et maintenabilité. Les références circulaires sont insortingnsèquement fragiles et faciles à casser. Cela découle en partie du fait que la lecture et la compréhension du code qui inclut des références circulaires est plus difficile que le code qui les évite. Veiller à ce que votre code soit facile à comprendre et à gérer permet d’éviter les bogues et de rendre les modifications plus faciles et plus sûres. Les objects avec des références circulaires sont plus difficiles à tester car ils ne peuvent pas être testés séparément.

  6. Gestion de la durée de vie des objects. Alors que le ramasse-miettes de .NET est capable d’identifier et de traiter les références circulaires (et de disposer correctement de tels objects), toutes les langues / tous les environnements ne le peuvent pas. Dans les environnements qui utilisent le comptage de références pour leur schéma de récupération de place (par exemple, VB6, Objective-C, certaines bibliothèques C ++), il est possible que des références circulaires provoquent des memory leaks. Étant donné que chaque object s’accroche à l’autre, leur nombre de références n’atteindra jamais zéro et ne deviendra donc jamais candidat à la collecte et au nettoyage.

Parce que maintenant ils sont vraiment un seul object. Vous ne pouvez pas tester l’un ou l’autre de manière isolée.

Si vous en modifiez un, il est probable que vous affectiez également son compagnon.

De Wikipedia:

Les dépendances circulaires peuvent entraîner de nombreux effets indésirables dans les programmes logiciels. Du sharepoint vue de la conception logicielle, le plus problématique est le couplage étroit des modules mutuellement dépendants, ce qui réduit ou rend impossible la réutilisation séparée d’un seul module.

Les dépendances circulaires peuvent provoquer un effet domino lorsqu’un petit changement local dans un module se propage dans d’autres modules et produit des effets globaux indésirables (erreurs de programme, erreurs de compilation). Les dépendances circulaires peuvent également entraîner des récurrences infinies ou d’autres défaillances inattendues.

Les dépendances circulaires peuvent également provoquer des memory leaks en empêchant certains ramasseurs automatiques très primitifs (ceux qui utilisent le comptage de références) de libérer des objects inutilisés.

Un tel object peut être difficile à créer et à détruire, car pour faire soit de manière non atomique, il faut violer l’intégrité référentielle pour d’abord créer / détruire l’un, puis l’autre (par exemple, votre firebase database SQL pourrait reculer). Cela pourrait perturber votre ramasse-miettes. Perl 5, qui utilise un simple comptage de référence pour le nettoyage de la mémoire, ne peut pas (sans aide) s’écouler comme une fuite de mémoire. Si les deux objects sont de classes différentes, ils sont étroitement liés et ne peuvent pas être séparés. Si vous avez un gestionnaire de paquets pour installer ces classes, la dépendance circulaire s’étend à elle. Il doit savoir installer les deux packages avant de les tester, ce qui (parlant en tant que responsable d’un système de build) est un PITA.

Cela dit, tout cela peut être surmonté et il est souvent nécessaire d’avoir des données circulaires. Le monde réel n’est pas constitué de graphes ordonnés bien ordonnés. De nombreux graphiques, arbres, enfer, une liste à double lien est circulaire.

Cela nuit à la lisibilité du code. Et des dépendances circulaires au code spaghetti, il n’y a qu’un petit pas.

Voici quelques exemples qui peuvent aider à comprendre pourquoi les dépendances circulaires sont mauvaises.

Problème n ° 1: Qu’est-ce qui est initialisé / construit en premier?

Prenons l’exemple suivant:

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

Quel constructeur est appelé en premier? Il n’y a vraiment aucun moyen d’être sûr car c’est complètement ambigu. L’une ou l’autre des méthodes DoSomething va être appelée sur un object non initialisé, ce qui entraîne un comportement incorrect et très probablement une exception. Il y a des moyens de contourner ce problème, mais ils sont tous laids et ils nécessitent tous des initialiseurs non constructeurs.

Problème n ° 2:

Dans ce cas, je suis passé à un exemple C ++ non géré car l’implémentation de .NET, de par sa conception, cache le problème. Cependant, dans l’exemple suivant, le problème deviendra assez clair. Je suis bien conscient que .NET n’utilise pas vraiment le comptage de références sous le capot pour la gestion de la mémoire. Je l’utilise uniquement pour illustrer le problème principal. Notez également que j’ai démontré ici une solution possible au problème 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 ... 

À première vue, on pourrait penser que ce code est correct. Le code de comptage de référence est assez simple et direct. Cependant, ce code entraîne une fuite de mémoire. Lorsque A est construit, il a initialement un compteur de références de “1”. Cependant, la variable myB encapsulée incrémente le compte de référence, en lui atsortingbuant un nombre de “2”. Lorsque localA est libéré, le nombre est décrémenté, mais seulement de nouveau à “1”. Par conséquent, l’object est laissé en suspens et jamais supprimé.

Comme je l’ai mentionné ci-dessus, .NET n’utilise pas vraiment le comptage de références pour sa récupération de place. Mais il utilise des méthodes similaires pour déterminer si un object est toujours utilisé ou s’il convient de le supprimer, et presque toutes ces méthodes peuvent être perturbées par des références circulaires. Le ramasse-miettes .NET prétend être capable de gérer cela, mais je ne suis pas sûr de lui faire confiance car il s’agit d’un problème très épineux. Go, en revanche, contourne le problème en ne permettant tout simplement pas de références circulaires. Il y a dix ans, j’aurais préféré l’approche .NET pour sa flexibilité. Ces jours-ci, je trouve que je préfère l’approche de Go pour sa simplicité.

Il est tout à fait normal d’avoir des objects avec des références circulaires, par exemple dans un modèle de domaine avec des associations bidirectionnelles. Un ORM avec un composant d’access aux données correctement écrit peut gérer cela.

Référez-vous au livre de Lakos, dans la conception de logiciel C ++, la dépendance physique cyclique est indésirable. Il existe plusieurs raisons:

  • Cela les rend difficiles à tester et impossible à réutiliser indépendamment.
  • Cela les rend difficiles à comprendre et à entretenir.
  • Cela augmentera le coût de liaison.

Les références circulaires semblent être un scénario de modélisation de domaine légitime. Un exemple est Hibernate et de nombreux autres outils ORM encouragent cette association croisée entre entités pour permettre une navigation bidirectionnelle. Exemple typique dans un système de vente aux enchères en ligne, un vendeur peut conserver une référence à la liste des entités qu’il vend. Et chaque article peut conserver une référence à son vendeur correspondant.

Le garbage collector .NET peut gérer des références circulaires, il n’y a donc aucune crainte de fuite de mémoire pour les applications travaillant sur le framework .NET.