Modèle d'itérateur - pourquoi est-il important de ne pas exposer la représentation interne?

24

Je lis les éléments essentiels du modèle de conception C # . Je lis actuellement sur le modèle d'itérateur.

Je comprends parfaitement comment mettre en œuvre, mais je ne comprends pas l'importance ou voir un cas d'utilisation. Dans le livre, un exemple est donné où quelqu'un a besoin d'obtenir une liste d'objets. Ils auraient pu le faire en exposant une propriété publique, comme IList<T>ou un Array.

Le livre écrit

Le problème est que la représentation interne dans ces deux classes a été exposée à des projets extérieurs.

Quelle est la représentation interne? Le fait que ce soit un arrayou IList<T>? Je ne comprends vraiment pas pourquoi c'est une mauvaise chose pour le consommateur (le programmeur qui l'appelle) de savoir ...

Le livre dit ensuite que ce modèle fonctionne en exposant sa GetEnumeratorfonction, afin que nous puissions appeler GetEnumerator()et exposer la «liste» de cette façon.

Je suppose que ces modèles ont une place (comme tous) dans certaines situations, mais je ne vois pas où et quand.

MyDaftQuestions
la source
1
Pour en savoir plus: en.m.wikipedia.org/wiki/Law_of_Demeter
Jules
4
Parce que l'implémentation peut vouloir passer de l'utilisation d'un tableau à l'utilisation d'une liste liée sans nécessiter de modifications dans la classe de consommateurs. Cela serait impossible si le consommateur connaît la classe exacte retournée.
2015
11
Ce n'est pas une mauvaise chose pour le consommateur de savoir , c'est une mauvaise chose pour le consommateur de compter . Je n'aime pas le terme masquage d'informations, car il vous amène à croire que les informations sont "privées" comme votre mot de passe au lieu de privées comme l'intérieur d'un téléphone. Les composants internes sont cachés parce qu'ils ne sont pas pertinents pour l'utilisateur, pas parce qu'ils sont une sorte de secret. Tout ce que vous devez savoir, c'est composer ici, parler ici, écouter ici.
Captain Man
Supposons que nous ayons une belle "interface" Fqui me donne des connaissances (méthodes) de a, bet c. Tout va bien et bien, il peut y avoir beaucoup de choses différentes mais juste Fpour moi. Considérez la méthode comme des «contraintes» ou des clauses d'un contrat qui Fengage les classes à faire. Supposons que nous ajoutions un dbien, car nous en avons besoin. Cela ajoute une contrainte supplémentaire, chaque fois que nous faisons cela, nous imposons de plus en plus sur le Fs. Finalement (le pire des cas), une seule chose peut être un Fdonc nous pouvons aussi bien ne pas l'avoir. Et Fcontraint tellement il n'y a qu'une seule façon de le faire.
Alec Teal du
@captainman, quel merveilleux commentaire. Oui, je vois pourquoi «abstraire» beaucoup de choses, mais la torsion de la connaissance et de la confiance est ... Franchement ... Brillante. Et une distinction importante que je n'ai pas considérée avant de lire votre post.
MyDaftQuestions

Réponses:

56

Le logiciel est un jeu de promesses et de privilèges. Ce n'est jamais une bonne idée de promettre plus que ce que vous pouvez offrir ou plus que ce dont votre collaborateur a besoin.

Cela s'applique particulièrement aux types. Le point d'écrire une collection itérable est que son utilisateur peut itérer dessus - ni plus, ni moins. L'exposition du type concretArray crée généralement de nombreuses promesses supplémentaires, par exemple que vous pouvez trier la collection par une fonction de votre choix, sans parler du fait qu'une normale Arraypermettra probablement au collaborateur de modifier les données qui y sont stockées.

Même si vous pensez que c'est une bonne chose ("Si le moteur de rendu remarque que la nouvelle option d'exportation est manquante, il peut simplement la corriger directement! Neat!"), Dans l'ensemble, cela diminue la cohérence de la base de code, ce qui la rend plus difficile à raisonner - et rendre le code facile à raisonner est le principal objectif du génie logiciel.

Maintenant, si votre collaborateur a besoin d'accéder à un certain nombre de trucs pour qu'ils soient assurés de n'en manquer aucun, vous implémentez une Iterableinterface et exposez uniquement les méthodes déclarées par cette interface. De cette façon, l'année prochaine, lorsqu'une structure de données massivement meilleure et plus efficace apparaîtra dans votre bibliothèque standard, vous serez en mesure de changer le code sous-jacent et d'en bénéficier sans réparer votre code client partout . Il y a d'autres avantages à ne pas promettre plus que ce qui est nécessaire, mais celui-ci seul est si grand qu'en pratique, aucun autre n'est nécessaire.

Kilian Foth
la source
12
Exposing the concrete type Array usually creates many additional promises, e.g. that you can sort the collection by a function of your own choosing...- Brillant. Je n'y ai tout simplement pas pensé. Oui, cela ramène une «itération» et seulement, une itération!
MyDaftQuestions
18

Masquer l'implémentation est un principe de base de la POO et une bonne idée dans tous les paradigmes, mais il est particulièrement important pour les itérateurs (ou quel que soit leur nom dans ce langage spécifique) dans les langages qui prennent en charge l'itération paresseuse.

Le problème avec l'exposition du type concret d'itérables - ou même des interfaces comme IList<T>- n'est pas dans les objets qui les exposent, mais dans les méthodes qui les utilisent. Par exemple, supposons que vous ayez une fonction pour imprimer une liste de Foos:

void PrintFoos(IList<Foo> foos)
{
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
    }
}

Vous ne pouvez utiliser cette fonction que pour imprimer des listes de foo - mais seulement si elles implémentent IList<Foo>

IList<Foo> foos = //.....
PrintFoos(foos);

Mais que se passe-t-il si vous souhaitez imprimer chaque élément indexé de la liste? Vous devrez créer une nouvelle liste:

IList<Foo> everySecondFoo = new List<T>();
bool isIndexEven = true;
foreach (foo; foos)
{
    if (isIndexEven)
    {
        everySecondFoo.Add(foo);
    }
    isIndexEven = !isIndexEven;
}
PrintFoos(everySecondFoo);

C'est assez long, mais avec LINQ, nous pouvons le faire sur une seule ligne, qui est en fait plus lisible:

PrintFoos(foos.Where((foo, i) => i % 2 == 0).ToList());

Maintenant, avez-vous remarqué .ToList()la fin? Cela convertit la requête paresseuse en liste, afin que nous puissions la transmettre à PrintFoos. Cela nécessite une allocation d'une deuxième liste, et deux passes sur les éléments (un sur la première liste pour créer la deuxième liste, et un autre sur la deuxième liste pour l'imprimer). Et si nous avons ceci:

void Print6Foos(IList<Foo> foos)
{
    int counter = 0;
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
        ++ counter;
        if (6 < counter)
        {
            return;
        }
    }
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0).ToList());

Et si foosa des milliers d'entrées? Nous devrons les parcourir tous et allouer une énorme liste juste pour en imprimer 6!

Entrez Enumerators - la version C # du modèle d'itérateur. Au lieu que notre fonction accepte une liste, nous la faisons accepter Enumerable:

void Print6Foos(Enumerable<Foo> foos)
{
    // everything else stays the same
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0));

Print6FoosPeut maintenant paresseusement parcourir les 6 premiers éléments de la liste, et nous n'avons pas besoin de toucher le reste.

Ne pas exposer la représentation interne est la clé ici. Lorsque Print6Foosnous avons accepté une liste, nous avons dû lui donner une liste - quelque chose qui prend en charge l'accès aléatoire - et nous avons donc dû allouer une liste, car la signature ne garantit pas qu'elle ne fera que l'itérer. En masquant l'implémentation, nous pouvons facilement créer un Enumerableobjet plus efficace qui ne prend en charge que ce dont la fonction a réellement besoin.

Idan Arye
la source
6

Exposer la représentation interne n'est pratiquement jamais une bonne idée. Cela rend non seulement le code plus difficile à raisonner, mais aussi plus difficile à maintenir. Imaginez que vous avez choisi n'importe quelle représentation interne - disons un IList<T>- et exposé cette interne. Toute personne utilisant votre code peut accéder à la liste et le code peut s'appuyer sur la représentation interne étant une liste.

Pour une raison quelconque, vous décidez de modifier la représentation interne IAmWhatever<T>à un moment ultérieur. Au lieu de simplement changer les internes de votre classe, vous devrez changer chaque ligne de code et méthode en fonction de la représentation interne de type IList<T>. Ceci est fastidieux et sujet à des erreurs au mieux, lorsque vous êtes le seul à utiliser la classe, mais cela peut casser le code d'autres personnes utilisant votre classe. Si vous venez d'exposer un ensemble de méthodes publiques sans exposer aucun élément interne, vous auriez pu modifier la représentation interne sans aucune ligne de code en dehors de votre classe, en travaillant comme si rien n'avait changé.

C'est pourquoi l'encapsulation est l'un des aspects les plus importants de toute conception de logiciel non triviale.

Paul Kertscher
la source
6

Moins vous en dites, moins vous devez répéter.

Moins vous en demandez, moins vous devez être informé.

Si votre code expose uniquement IEnumerable<T>, qui ne prend en charge que GetEnumrator()celui-ci, il peut être remplacé par tout autre code pouvant le prendre en charge IEnumerable<T>. Cela ajoute de la flexibilité.

Si votre code l'utilise uniquement, IEnumerable<T>il peut être pris en charge par tout code qui implémente IEnumerable<T>. Encore une fois, il y a une flexibilité supplémentaire.

Tous les linq-to-objets, par exemple, ne dépendent que de IEnumerable<T>. Bien qu'il accélère certaines implémentations particulières, IEnumerable<T>il le fait d'une manière test et utilisation qui peut toujours se résumer à une simple utilisation GetEnumerator()et à l' IEnumerator<T>implémentation qui revient. Cela lui donne beaucoup plus de flexibilité que s'il était construit sur des tableaux ou des listes.

Jon Hanna
la source
Belle réponse claire. Très approprié en ce que vous êtes bref et suivez donc vos conseils dans la première phrase. Bravo!
Daniel Hollinrake
0

Un libellé que j'aime utiliser dans les miroirs Commentaire de @CaptainMan: "C'est bien pour un consommateur de connaître les détails internes. Il est mauvais pour un client d' avoir besoin de connaître les détails internes."

Si un client doit taper arrayou IList<T>dans son code, lorsque ces types étaient des détails internes, le consommateur doit les obtenir correctement. S'ils se trompent, le consommateur peut ne pas compiler ou même obtenir des résultats erronés. Cela donne les mêmes comportements que les autres réponses de «toujours masquer les détails de l'implémentation car vous ne devriez pas les exposer», mais commence à creuser les avantages de ne pas exposer. Si vous n'exposez jamais un détail d'implémentation, votre client ne pourra jamais se mettre en difficulté en l'utilisant!

J'aime y penser sous cet angle car cela ouvre le concept de masquer l'implémentation à des nuances de sens, plutôt qu'à un draconien "cachez toujours vos détails d'implémentation". Il existe de nombreuses situations où l'exposition de l'implémentation est totalement valide. Prenons le cas des pilotes de périphériques en temps réel, où la possibilité de sauter des couches d'abstraction et de pousser des octets aux bons endroits peut faire la différence entre faire un timing ou non. Ou considérez un environnement de développement où vous n'avez pas le temps de créer de «bonnes API» et devez utiliser les choses immédiatement. Souvent, exposer des API sous-jacentes dans de tels environnements peut faire la différence entre fixer ou non un délai.

Cependant, à mesure que vous laissez ces cas spéciaux derrière vous et que vous regardez les cas plus généraux, il devient de plus en plus important de masquer l'implémentation. Exposer les détails de l'implémentation est comme une corde. Utilisé correctement, vous pouvez faire toutes sortes de choses avec, comme escalader de hautes montagnes. Utilisé incorrectement, il peut vous attacher à un nœud coulant. Moins vous en savez sur la façon dont votre produit sera utilisé, plus vous devez être prudent.

L'étude de cas que je vois maintes et maintes fois est celle où un développeur crée une API qui ne fait pas très attention à masquer les détails de la mise en œuvre. Un deuxième développeur, utilisant cette bibliothèque, constate qu'il manque à l'API certaines fonctionnalités clés qu'il souhaiterait. Plutôt que d'écrire une demande de fonctionnalité (qui pourrait avoir un délai d'exécution de plusieurs mois), ils décident plutôt d'abuser de leur accès aux détails d'implémentation cachés (ce qui prend quelques secondes). Ce n'est pas mauvais en soi, mais souvent le développeur d'API n'a jamais prévu que cela fasse partie de l'API. Plus tard, lorsqu'ils améliorent l'API et modifient ces détails sous-jacents, ils constatent qu'ils sont liés à l'ancienne implémentation, car quelqu'un l'a utilisée dans le cadre de l'API. La pire version est celle où quelqu'un a utilisé votre API pour fournir une fonctionnalité centrée sur le client, et maintenant votre entreprise » chèque de paie est lié à cette API défectueuse! Simplement en exposant les détails de l'implémentation alors que vous ne saviez pas assez sur vos clients s'est avéré être assez de corde pour attacher un nœud coulant autour de votre API!

Cort Ammon - Rétablir Monica
la source
1
Re: "Ou considérez un environnement de développement où vous n'avez pas le temps de créer de" bonnes API "et devez utiliser les choses immédiatement": si vous vous trouvez dans un tel environnement, et pour une raison quelconque, vous ne voulez pas quitter ( ou ne savez pas si vous le faites), je recommande le livre Death March: The Complete Software Developer's Guide to Surviving 'Mission Impossible' Projects . Il a d'excellents conseils sur le sujet. (De plus, je recommande de changer d'avis pour ne pas arrêter.)
ruakh
@ruakh J'ai trouvé qu'il est toujours important de comprendre comment travailler dans un environnement où vous n'avez pas le temps de faire de bonnes API. Franchement, même si nous aimons l'approche de la tour d'ivoire, le vrai code est ce qui paie réellement les factures. Le plus tôt nous pouvons admettre que, le plus tôt nous pourrons commencer à aborder le logiciel comme un équilibre, en équilibrant la qualité de l'API avec le code productif, pour correspondre à notre environnement exact. J'ai vu trop de jeunes développeurs pris au piège dans un gâchis d'idéaux, ne réalisant pas qu'ils devaient les assouplir. (J'ai aussi été ce jeune développeur .. toujours en convalescence après 5 ans de conception d'une "bonne API")
Cort Ammon - Réinstalle Monica
1
Le vrai code paie les factures, oui. Une bonne conception de l'API est la façon dont vous vous assurez de pouvoir continuer à générer du vrai code. Le code merdique cesse de se payer quand un projet devient impossible à gérer et qu'il devient impossible de corriger les bogues sans en créer de nouveaux. (Et de toute façon, c'est un faux dilemme. Ce qu'un bon code requiert, ce n'est pas tant le temps que l' épine dorsale .)
ruakh
1
@ruakh Ce que j'ai trouvé, c'est qu'il est possible de faire du bon code sans "bonnes API". Si l'occasion se présente, de bonnes API sont agréables, mais les traiter comme une nécessité provoque plus de conflits que cela n'en vaut la peine. Considérez: l'API pour les corps humains est horrible, mais nous pensons toujours qu'ils sont de très bons petits exploits d'ingénierie =)
Cort Ammon - Rétablir Monica
L'autre exemple que je donnerais est celui qui a été l'inspiration pour l'argument de faire le timing. L'un des groupes avec lesquels je travaille avait des éléments de pilote de périphérique qu'ils devaient développer, avec des exigences difficiles en temps réel. Ils avaient 2 API avec lesquelles travailler. L'un était bon, l'autre moins. La bonne API n'a pas pu faire de synchronisation car elle a caché l'implémentation. L'API moindre a exposé l'implémentation et, ce faisant, vous a permis d'utiliser des techniques spécifiques au processeur pour créer un produit fonctionnel. La bonne API a été poliment mise sur l'étagère et elle a continué à respecter les délais.
Cort Ammon - Rétablir Monica
0

Vous ne savez pas nécessairement quelle est la représentation interne de vos données - ou pourrait devenir à l'avenir.

Supposons que vous ayez une source de données que vous représentez sous forme de tableau, vous pouvez itérer dessus avec une boucle for régulière.

Maintenant, dites que vous voulez refactoriser. Votre patron a demandé que la source de données soit un objet de base de données. En outre, il aimerait avoir la possibilité d'extraire des données d'une API, ou d'une table de hachage, ou d'une liste liée, ou d'un tambour magnétique en rotation, ou d'un microphone, ou d'un décompte en temps réel des copeaux de yak trouvés en Mongolie extérieure.

Eh bien, si vous n'en avez qu'une pour la boucle, vous pouvez facilement modifier votre code pour accéder à l'objet de données de la manière à laquelle il s'attend à y accéder.

Si 1000 classes essaient d'accéder à l'objet de données, vous avez maintenant un gros problème de refactoring.

Un itérateur est un moyen standard d'accéder à une source de données basée sur une liste. C'est une interface commune. Son utilisation rendra votre source de données de code agnostique.

superluminaire
la source