Est-il acceptable de renvoyer une sous-classe de représentation canonique à partir d’une classe de base abstraite?

Edit 2: TL; DR : Y a-t-il un moyen de ne pas enfreindre les meilleures pratiques d’OO tout en satisfaisant la contrainte qu’un tas de choses du même genre doivent être convertibles en une chose canonique de ce genre?

De plus, n’oubliez pas que ma question concerne la situation générale et non l’exemple spécifique. Ce n’est pas un problème de devoirs.


Supposons que vous ayez les éléments suivants:

  • une classe de base abstraite qui implémente des fonctionnalités communes;
  • une classe dérivée concrète qui sert de représentation canonique.

Supposons maintenant que vous souhaitiez que tout héritier de la classe de base soit convertible en représentation canonique. Pour ce faire, une méthode abstraite consiste à inclure dans la classe de base une méthode abstraite destinée à renvoyer une conversion de l’héritier en tant qu’instance de la classe dérivée canonique.

Cependant, il semble généralement accepté que les classes de base ne connaissent aucune de leurs classes dérivées, et dans le cas général, je suis d’accord. Cependant, dans ce scénario, cela semble être la meilleure solution car elle permet à un nombre illimité de classes dérivées, chacune avec leur propre implémentation que nous n’avons pas besoin de savoir, d’être interopérables via la conversion en représentation canonique que chaque fonction dérivée. la classe doit implémenter.

Souhaitez-vous le faire différemment? Pourquoi et comment

Un exemple pour les points géomésortingques:

// an abstract point has no coordinate system information, so the values // of X and Y are meaningless public abstract class AbstractPoint { public int X; public int Y; public abstract ScreenPoint ToScreenPoint(); } // a point in the predefined screen coordinate system; the meaning of X // and Y is known public class ScreenPoint : AbstractPoint { public ScreenPoint(int x, int y) { X = x; Y = y; } public override ScreenPoint ToScreenPoint() => new ScreenPoint(X, Y); } // there can be any number of classes like this; we don't know anything // about their coordinate systems and we don't care as long as we can // convert them to `ScreenPoint`s public class ArbitraryPoint : AbstractPoint { private int arbitraryTransformation; public ArbitraryPoint(int x, int y) { X = x; Y = y; } public override ScreenPoint ToScreenPoint() => new ScreenPoint(X * arbitraryTransformation, Y * arbitraryTransformation); // (other code) } 

Edit 1: La raison pour laquelle AbstractPoint et ScreenPoint ne sont pas de la même classe est sémantique. Un AbstractPoint n’a pas de système de coordonnées défini. Par conséquent, les valeurs de X et Y dans une instance de AbstractPoint n’ont pas de sens. Un système ScreenPoint possède un système de coordonnées défini. Par conséquent, les valeurs de X et Y dans une instance de ScreenPoint ont une signification bien définie.

Si ScreenPoint était la classe de base, alors ArbitraryPoint serait un ScreenPoint , ce qui n’est pas le cas. Un ArbitraryPoint peut être converti en ScreenPoint , mais cela ne signifie pas qu’il s’agisse d’ is-a ScreenPoint .

Si vous n’êtes toujours pas convaincu, considérez qu’un système de coordonnées arbitraire ACS1 peut être défini comme ayant un décalage dynamic par rapport au système de coordonnées d’écran SCS . Cela signifie que le mappage entre les deux systèmes de coordonnées peut varier dans le temps, c’est-à-dire que le point ACS1 (1, 1) peut mapper vers SCS (10, 10) à un moment donné et SCS (42, 877) à un autre moment.

Ce type de conception est généralement une odeur de code. Les classes de base ne doivent pas connaître leurs classes dérivées car cela crée une dépendance circulaire. Et les dépendances circulaires conduisent généralement à des conceptions compliquées où il est difficile de déterminer ce que les classes devraient faire. En Java, les classes de base connaissant leurs classes dérivées peuvent même aboutir à un blocage dans de rares cas (je ne sais pas pour C #).

Cependant, vous pouvez enfreindre les règles générales dans des cas particuliers, lorsque vous savez exactement ce que vous faites, surtout si ce que vous essayez d’accomplir est assez simple.

Et votre cas ici semble être assez simple. Avoir AbstractPoint et ScreenPoint tant que classes différentes est correct. Mais en réalité, ils “travaillent ensemble”: tous les ScreenPoint AbstractPoint devraient pouvoir ScreenPoint à ScreenPoint (c’est peut-être la fonctionnalité la plus importante du contrat de AbstractPoint ?). Étant donné que l’un ne peut exister sans l’autre, il n’ya rien de mal à ce que AbstractPoint connaisse ScreenPoint .

Mettre à jour

Dans une conception différente: Créez une interface appelée CanonicalPoint . AbstractPoint a une méthode appelée ToCanonicalPoint , qui renvoie CanonicalPoint . Toutes les classes dérivées de AbstractPoint doivent l’implémenter et renvoyer CanonicalPoint . ScreenPoint est une classe dérivée de AbstractPoint qui implémente l’interface CanonicalPoint . Vous pourriez même avoir plus d’une classe dérivée qui implémente CanonicalPoint . Remarque: Si AbstractPoint et CanonicalPoint ont des méthodes communes, elles peuvent toutes les deux implémenter une autre interface appelée, par exemple, Pointable , qui déclare toutes ces méthodes.

Je pense que le principe de la responsabilité unique dans SOLID a été négligé. AbstractPoint et son implémentation concrète ScreenPoint stockent essentiellement des données , par exemple X, Y , propres au groupe de classes. De plus, la classe de base AbstractPoint tente d’appliquer ce qui semble être le modèle Factory Method inline (moins l’interface renvoyée). Bien que je pense qu’il soit approprié et nécessaire d’avoir des opérations logiques sur les données des classes AbstractPoint et / ou ScreenPoint ; Je me sens comme une classe ScreenPointFactory distincte implémentant le modèle Factory Method pour instancier ScreenPoint est très utile ici.

Si vous avez besoin de créer des milliers de classes ScreenPoint , les appels de méthodes virtuelles supplémentaires tels que ToScreenPoint() et la taille d’object supplémentaire peuvent entraîner des problèmes de performances. Envisager d’utiliser le modèle de poids massique dans ce scénario pourrait aider les temps de chargement. Pour que le modèle Flyweight réussisse, vous devez utiliser le modèle Méthode d’usine, car ce dernier est utilisé au sein de l’ usine . Comme il y aura des usines, vous aurez besoin d’une interface IPoint et IPoint simplement AbstractPoint de l’interface IPoint . IPoint tiendra simplement X, Y pour le moment. Les types de points restants acquièrent l’interface IPoint via l’inheritance de AbstractPoint . Puisqu’il y aura un groupe de types de points liés, par exemple ArbitraryPoint , ScreenPoint , ils peuvent tous être instanciés via une fabrique abstraite .

La programmation aux interfaces, pas le principe de mise en œuvre sera importante ici. Pour retirer la fonctionnalité de ToScreenPoint() dans ce que je décris, je supprimerais ToScreenPoint() des classes de points et ToScreenPoint() plutôt un object ArbitraryTransformation configuré au démarrage. J’utiliserais Dependency Injection pour placer l’object ArbitraryTransformation dans l’usine appropriée pendant la configuration d’ IOC . Ensuite, lorsque les méthodes Abstract Factory sont appelées pour créer de nouvelles variations de vos points d’écran … elles sont créées avec la transformation arbitraire déjà calculée , car chaque méthode de fabrique indépendante dans AbstractFactory utilisera la transaction ArbitraryTransformation configurée de manière appropriée pour effectuer le calcul.

Faire tout cela mettra moins de stress sur votre conception et gardera vos objects un peu plus légers et faiblement couplés. Je pense que vous avez affaire à des complexités ici pour vous permettre de comprendre ce que je viens de dire ci-dessus simplement avec le langage de modèle GoF . Cependant, si vous ou n’importe qui préférez avoir les échantillons codés, je peux revenir et vous fournir un exemple de solution. Cela ressemble à beaucoup de code à lier, si vous n’avez pas exactement besoin ou ne voulez pas ce que je suggère.

Je suis allé de l’avant et j’ai développé un exemple de solution sur mon GitHub appelée Point Example . Laissez-moi savoir ce que vous pensez.

En outre, pour chaque modèle de conception ajouté ici, cela introduira des couches supplémentaires de complexité dans votre application. Par conséquent, bien que cela puisse être un problème en soi, je pense que cela se prêtera à être utile pour ce dont vous avez besoin.


Mettre à jour

Dans l’exemple de point, j’ai décidé de réaliser une implémentation très simple du modèle de pool d’objects au lieu du Flyweight pattern mentionné ci-dessus, Flyweight pattern . J’ai montré la mise en place d’un modèle d’amélioration de la performance dans le cas où un exemple solide était nécessaire. Bien qu’il existe de meilleurs moyens de donner l’implémentation, le Object Pool pattern utilisé dans ScreenPointFactory est un simple ScreenPointFactory fonctionnel.

J’ai mis à jour le code dans l’ exemple ponctuel pour refléter l’atsortingbution d’un Flyweight pattern de Object Pool pattern . Désolé pour toute confusion. Aussi, voici les réponses aux questions de Miroslav Policki dans la section commentaires ci-dessous:


Question 1:

1. Il semble que votre motivation pour passer au modèle Factory Method est la possibilité d’utiliser le modèle Flyweight. Cependant, si je comprends bien, le modèle Flyweight est une optimisation des performances et ne doit donc pas être appliqué prématurément. Ainsi, dans les situations où le modèle Flyweight n’est pas nécessaire, voulez-vous continuer avec le rest de votre conception et pourquoi? – Miroslav Policki

Réponse: J’ai apporté une correction en faisant référence au Object Pool pattern au lieu du Flyweight pattern au code. Indépendamment de mon problème de dénomination, le Object Pool pattern et le Flyweight pattern sont des optimisations de performances. Cependant, ma motivation pour créer des usines n’est plus une habitude, car il est courant de le faire pour encapsuler une logique de création. Oui, mon implémentation dans l’exemple de code de Object Pool pattern n’a jamais été demandée et il s’agit d’une optimisation de la performance appliquée prématurément dans cette situation. Cependant, j’ai senti que c’était davantage une prime éducative, si quelqu’un se demandait où placer des améliorations de performances dans ce paradigme, sans passer trop de temps. Je n’ai donné à ArbitraryPointFactory aucune amélioration des performances pour montrer la flexibilité des usines.

De plus, oui , j’utiliserais absolument les usines pour créer des variations de points, car à partir de l’exemple donné, il y avait plusieurs types de béton. Garder les usines à proximité gardera les instanciations des objects ponctuels à un seul endroit dans le code, et les usines produiront une seule place dans le code pour modifier la logique de création. Les fabriques sont utiles pour les objects simples et complexes et nous permettent de programmer des interfaces et non des implémentations concrètes. Cependant, comme la plupart des modèles de conception, je ne suggérerais à personne d’incorporer des usines dans leur code, s’il n’y a qu’un seul object ponctuel ou un seul endroit dans leur code qui doit appeler l’opérateur new pour un object. Toutefois, si votre code contient deux exemples dans lesquels le new opérateur est utilisé pour créer des points concrets, etc., il peut être intéressant de disposer d’une usine afin de pouvoir modifier un emplacement dans une usine, sans avoir à effectuer une recherche approfondie. code et courez le risque de ne pas mettre à jour correctement tous les espaces s’il y a des changements dans la manière dont l’object doit être créé.

Question 2:

2. Vous appliquez la transformation sur un point lors de sa création dans la méthode d’usine correspondante. Cependant, ceci est différent de la sémantique de mon exemple, car chaque ArbitraryPoint a un état interne qui peut changer à tout moment, et donne donc différents ScreenPoint . Comment incorporeriez-vous cela dans votre conception? – Miroslav Policki

Réponse: Une partie de moi veut dire que le nom ScreenPoint m’a mis en ScreenPoint et que je cherche une implémentation pertinente. L’autre partie de moi veut dire que je l’aurais conçu de cette façon quand même. Cependant, je sais que vous suggérez que ScreenPoint soit lié aux pixels, etc. Ce n’est pas le cas, c’est juste un autre exemple arbitraire. Je vais d’abord vous donner mon exemple valable. J’envisageais de créer des milliers de milliers de rendus ScreenPoint dans un jeu vidéo, où nous pourrions peut-être également stocker la couleur de chaque pixel par point d’écran, et nous venons de créer un rendu en plein écran ScreenPoint à ScreenPoint objects ScreenPoint . Alors disons qu’il y a un double tampon de points d’écran, où nous dessinons deux rendus d’écran à la fois. Nous ne ferions que calculer le rendu des coordonnées de l’object du jeu en coordonnées du monde réel, puis les associer à un rendu d’écran 2D de 30 à 60 fois et plus par seconde. Dans ce scénario, je ne me soucierais pas de modifier les rendus X,Y existants, plutôt que de simplement créer un nouvel ensemble d’objects ScreenPoint pour chaque rendu supplémentaire. Maintenant, cela peut sembler inutile et le Flyweight pattern pourrait aider à éviter de créer autant de nouveaux objects au moment de l’exécution qu’il n’y aurait qu’un seul object ScreenPoint , et que l’implémentation de chaque X,Y serait encapsulée dans un Array ou un Dictionary interne. Cependant, là encore, j’envisageais également la taille des objects et le nombre de pointeurs de méthode et de méthodes virtuelles que chaque object devait conserver avec les données lors de la création ou de la ScreenPoint très rapide de milliers d’objects ScreenPoint lorsqu’on pensait à ma conception. La conception permet à ScreenPoint d’être purement un object de données, rien de plus.

De retour à votre sémantique, j’ai réalisé le calcul grâce à la composition d’objects via l’object ArbitraryTransformation et les interfaces associées. Rien ne nous empêche de pouvoir composer l’object AbitraryTransformation avec votre object ArbitraryPoint ou NoOpTransformation avec votre ScreenPoint à utiliser dans des propriétés d’object ou des méthodes de mutation pour effectuer le calcul à partir des objects ArbitraryPoint ou ScreenPoint , respectivement. C’est là que Has-A est meilleur Is-A entre en jeu avec la composition d’object. L’usine est un bon endroit pour composer vos objects avec d’autres objects, par exemple Has-A . Nous voudrons peut-être plus tard utiliser un object MochTransformation dans des tests unitaires avec un MochScreenPointFactory pour pouvoir réellement tester les fonctionnalités ou les maths d’un object ScreenPoint . Donc, restr avec ce paradigme permet que cela se produise naturellement dans la configuration (construction du conteneur IOC), en conservant ce qui peut être un algorithme complexe ou un scénario impossible à tester dans son propre object de transformation remplaçable. Encore une fois, rien ne vous empêche de conserver la méthode ToScreenPoint() sur votre object ScreenPoint si vous préférez avoir l’interface là-bas au lieu de laisser la responsabilité à l’usine. Ma réorganisation du lieu du calcul ScreenPoint à garder le ScreenPoint simple et petit. Sa seule responsabilité consistait simplement à suivre le rythme des données.

Je constate également que, dans votre exemple, vous utilisez le new opérateur pour ScreenPoint à deux endroits ou chaque méthode ToScreenPoint . Avec la fabrique injectée appropriée dans chaque point, la méthode factory peut être appelée à partir de la méthode ToScreenPoint() de chaque type de point. Rien n’empêche cela non plus, et il y a un autre point dans lequel le code instanciera le premier ScreenPoint ou AbstractPoint qui pourrait être délégué à une fabrique au lieu d’être configuré à chaque emplacement où un ScreenPoint doit être créé. De plus, je n’aime pas que la classe AbstractPoint renvoie une seule instance concrète de ScreenPoint plutôt qu’une interface. Vous avez simplement incorporé la notion de Abstract Factory pattern pour une famille d’objects, mais chaque famille renvoie une instance concrète d’un type plutôt qu’une interface. Pour cette raison, votre code d’appel devra être sensible au contexte. C’est pourquoi j’ai développé dans mon exemple l’idée que ScreenPoint et ArbitraryPoint dérivent de la même interface, mais ArbitraryPoint possède ses propres données extra / personnalisées. Si je dois effectuer la conversion de point arbitraire en point d’écran, je devais être suffisamment conscient du contexte pour utiliser la méthode Factory correcte afin de créer un nouveau point avec le calcul correct, qui utilise à son tour le calcul ArbitraryTransformation par exemple pour le moment. de la création d’object.

Je pense que là où mon exemple pourrait différer du vôtre, c’est que vous souhaitiez que, dans le IOCConfig.cs crée ScreenPointFactory comme suit new ScreenPointFactory(new ScreenTransformation()) to où au lieu d’être une opération de type No-Op, travail de conversion, et ArbitraryPointFactory avait le No-Op. Je pourrais alors passer dans le X,Y ou peut-être l’interface IPoint au ScreenPointFactory ou à la méthode PointFactory appropriée pour ensuite effectuer une conversion d’ ArbitraryPoint en ce qui pourrait être un ScreenPoint . Donc, dans ce cas, je modifierais les données d’un ArbitraryPoint pouvant être un point sur un modèle de jeu vidéo, puis utiliserais mon ScreenPointFactory pour créer un ScreenPoint partir d’un ArbitraryPoint qui effectue toutes les conversions de coordonnées. Puis, au lieu de modifier directement les ScreenPoint ScreenPoint, je modifierais toujours le modèle de jeu vidéo directement ou le ArbitraryPoint origine, puis jetterais mon ScreenPoint généré et générer un nouveau ScreenPoint en appelant le ScreenPointFactory pour mettre à jour mon emplacement ScreenPoint à partir des valeurs du ArbitraryPoint origine. Traiter avec ces complexités justifiera certainement l’utilisation des techniques que je présente ici avec les usines. Simplifiez également l’utilisation de vos modèles d’object d’une manière qui a du sens pour l’application qui l’utilise.

Quoi qu’il en soit, marquez-moi si une partie de ce que j’ai dit est discutable ou si vous souhaitez que je modifie le code exemple de façon à ce qu’il corresponde à mon dernier paragraphe. La seule autre façon de restr fidèle à ce que vous avez décrit dans votre exemple est d’essayer d’implémenter le modèle d’adaptateur . Ensuite, j’ai la possibilité de passer d’une interface à une autre, par exemple, de ScreenPoint à ArbitraryPoint et vice-versa sans les obliger à dériver de la même interface ou de la même classe de base. Pourtant, fournissant quelques calculs et conversions. Mon code d’implémentation demandait toujours à la classe Adapter d’extraire les données que je souhaite exploiter, puis de les convertir en arrière. Quoi qu’il en soit, je suppose que les principaux principes de conception que j’ai essayé d’exprimer sont (trouvés dans le livre Head First Design Patterns ):

  • Programmez une interface, pas une implémentation.
  • Privilégier la composition à l’inheritance. ( Has-A est meilleur que Is-A )
  • Principe d’inversion de dépendance (dépend d’abstractions. Ne dépend pas de classes concrètes.)
    • Aucune variable ne doit contenir une référence à une classe concrète. (Si vous utilisez new, vous utiliserez une référence à une classe de béton. Utilisez une usine pour résoudre ce problème!)
    • Aucune classe ne doit dériver d’une classe concrète. (Si vous dérivez d’une classe concrète, vous dépendez d’une classe concrète. Dérivez d’une abstraction, telle qu’une interface ou une classe abstraite.)
    • Aucune méthode ne doit remplacer une méthode implémentée de l’une de ses classes de base. (Si vous substituez une méthode implémentée, votre classe de base n’était pas vraiment une abstraction pour commencer.)

      Ce qui est drôle, c’est que le livre cite pour Dependency Inversion Principle : si vous suivez ces trois directives de conception, sans exception, vous ne pourrez jamais écrire un seul programme. Je vais donc vous dire que vous essayez de viser ces principes, mais seulement là où ils ont le meilleur sens. Pour tous les modèles ou principes de conception, il existe des avantages et des inconvénients. Cependant, au moins avec les modèles de conception, ils ressemblent davantage à des modèles permettant de résoudre les problèmes déjà résolus. Branchez-les et adaptez-les là où ils conviennent à votre conception. D’autres seront familiarisés avec votre conception et pourront vous intégrer directement à votre stratégie de nommage et append du code.

Vous avez compliqué les choses sans raison. Si une classe ne peut pas implémenter la spécification de la classe de base, vous violez le principe de substitution de Liskovs.

Une alternative est une interface qui déclare qu’un object arbitraire peut être représenté sous forme de coordonnées:

 public interface IScreenPointProvider { ScreenPoint ToPoint(); } 

Alors, peu importe la nature de l’object ou la gestion interne des coordonnées.

N’oubliez pas non plus que l’inheritance est is-a relation. Si l’une des sous-classes ne prend pas en charge ce que fournit la classe de base, ce n’est pas vraiment is-a relation is-a . Cela indique généralement que la classe de base est réellement une classe d’utilité ou que la hiérarchie définie n’est pas bien conçue.