web-dev-qa-db-fra.com

Faut-il définir des types pour tout?

Récemment, j'ai eu un problème avec la lisibilité de mon code.
J'ai eu une fonction qui a fait une opération et a renvoyé une chaîne représentant l'ID de cette opération pour référence future (un peu comme OpenFile dans Windows renvoyant un handle). L'utilisateur utilisera cet ID plus tard pour démarrer l'opération et pour surveiller sa fin.

L'ID devait être une chaîne aléatoire en raison de problèmes d'interopérabilité. Cela a créé une méthode qui avait une signature très peu claire comme ceci:

public string CreateNewThing()

Cela rend l'intention du type de retour peu claire. J'ai pensé envelopper cette chaîne dans un autre type qui rend son sens plus clair comme ceci:

public OperationIdentifier CreateNewThing()

Le type ne contiendra que la chaîne et sera utilisé chaque fois que cette chaîne est utilisée.

Il est évident que l'avantage de ce mode de fonctionnement est une plus grande sécurité de type et une intention plus claire, mais cela crée également beaucoup plus de code et de code qui n'est pas très idiomatique. D'une part, j'aime la sécurité supplémentaire, mais cela crée également beaucoup d'encombrement.

Pensez-vous que c'est une bonne pratique d'envelopper des types simples dans une classe pour des raisons de sécurité?

144
Ziv

Les primitives, telles que string ou int, n'ont aucune signification dans un domaine métier. Une conséquence directe de cela est que vous pouvez utiliser par erreur une URL lorsqu'un ID de produit est attendu, ou utiliser la quantité lorsque vous attendez un prix .

C'est aussi pourquoi défi Object Calisthenics présente le conditionnement des primitives comme l'une de ses règles:

Règle 3: Envelopper toutes les primitives et chaînes

Dans le langage Java, int est un objet primitif et non un objet réel, il obéit donc à des règles différentes des objets. Il est utilisé avec une syntaxe qui n'est pas orientée objet. Plus important encore, un int à lui seul n'est qu'un scalaire, il n'a donc aucune signification. Lorsqu'une méthode prend un int comme paramètre, le nom de la méthode doit faire tout le travail pour exprimer l'intention. Si la même méthode prend une heure comme paramètre , il est beaucoup plus facile de voir ce qui se passe.

Le même document explique qu'il y a un avantage supplémentaire:

Les petits objets comme Hour ou Money nous donnent également un endroit évident pour mettre un comportement qui aurait autrement été jeté autour d'autres classes .

En effet, lorsque des primitives sont utilisées, il est généralement extrêmement difficile de suivre l'emplacement exact du code lié à ces types, ce qui conduit souvent à une duplication de code sévère . S'il existe une classe Price: Money, Il est naturel de trouver la plage à l'intérieur. Si, à la place, un int (pire, un double) est utilisé pour stocker les prix des produits, qui devrait valider la gamme? Le produit? Le rabais? Le panier?

Enfin, un troisième avantage non mentionné dans le document est la possibilité de changer relativement facilement le type sous-jacent. Si aujourd'hui, mon ProductId a short comme type sous-jacent et que je dois utiliser à la place int à la place, il y a de fortes chances que le code à modifier ne couvre pas toute la base de code.

L'inconvénient - et le même argument s'applique à chaque règle d'exercice de la gymnastique objet - est que s'il devient rapidement trop écrasant pour créer une classe pour tout . Si Product contient ProductPrice qui hérite de PositivePrice qui hérite de Price qui à son tour hérite de Money, ce n'est pas une architecture propre, mais plutôt un gâchis complet où pour trouver une seule chose, un mainteneur devrait ouvrir quelques dizaines de fichiers à chaque fois.

Un autre point à considérer est le coût (en termes de lignes de code) de la création de classes supplémentaires. Si les wrappers sont immuables (comme ils devraient l'être, généralement), cela signifie que, si nous prenons C #, vous devez avoir, au moins dans le wrapper:

  • Le getter immobilier,
  • Son champ d'appui,
  • Un constructeur qui attribue la valeur au champ de support,
  • Une ToString() personnalisée,
  • Commentaires de documentation XML (ce qui fait beaucoup de lignes),
  • Un Equals et un GetHashCode remplacent (également beaucoup de LOC).

et éventuellement, le cas échéant:

  • Un DebuggerDisplay attribut,
  • Un remplacement des opérateurs == Et !=,
  • Éventuellement une surcharge de l'opérateur de conversion implicite pour effectuer une conversion transparente vers et depuis le type encapsulé,
  • Contrats de code (y compris l'invariant, qui est une méthode assez longue, avec ses trois attributs),
  • Plusieurs convertisseurs qui seront utilisés lors de la sérialisation XML, de la sérialisation JSON ou du stockage/chargement d'une valeur vers/depuis une base de données.

Une centaine de LOC pour un emballage simple le rend assez prohibitif, c'est pourquoi vous pouvez être complètement sûr de la rentabilité à long terme d'un tel emballage. La notion de portée expliquée par Thomas Junk est particulièrement pertinente ici. L'écriture d'une centaine de LOC pour représenter un ProductId utilisé partout dans votre base de code semble très utile. Écrire une classe de cette taille pour un morceau de code qui fait trois lignes dans une seule méthode est beaucoup plus discutable.

Conclusion:

  • Enveloppez les primitives dans des classes qui ont un sens dans un domaine métier de l'application lorsque (1) cela aide à réduire les erreurs, (2) réduit le risque de duplication de code ou (3) aide à changer le type sous-jacent ultérieurement.

  • N'encapsulez pas automatiquement toutes les primitives que vous trouvez dans votre code: il existe de nombreux cas où l'utilisation de string ou int est parfaitement adaptée.

En pratique, dans public string CreateNewThing(), le renvoi d'une instance de la classe ThingId au lieu de string peut aider, mais vous pouvez également:

  • Renvoie une instance de la classe Id<string>, C'est-à-dire un objet de type générique indiquant que le type sous-jacent est une chaîne. Vous avez l'avantage de la lisibilité, sans l'inconvénient de devoir maintenir un grand nombre de types.

  • Renvoie une instance de la classe Thing. Si l'utilisateur n'a besoin que de l'ID, cela peut facilement être fait avec:

    var thing = this.CreateNewThing();
    var id = thing.Id;
    
153

J'utiliserais le scope en règle générale: plus l'étendue de la génération et de la consommation de values est étroite, moins vous avez de chances de créer un objet représentant cette valeur.

Disons que vous avez ce qui suit pseudocode

id = generateProcessId();
doFancyOtherstuff();
job.do(id);

alors la portée est très limitée et je ne verrais aucun sens à en faire un type. Mais disons que vous générez cette valeur dans un calque et la transmettez à un autre calque (ou même à un autre objet), il serait alors parfaitement logique de créer un type pour cela.

61
Thomas Junk

Les systèmes de type statique visent à empêcher les utilisations incorrectes des données.

Il existe des exemples évidents de types faisant cela:

  • vous ne pouvez pas obtenir le mois d'un UUID
  • vous ne pouvez pas multiplier deux chaînes.

Il y a des exemples plus subtils

  • vous ne pouvez pas payer quelque chose en utilisant la longueur du bureau
  • vous ne pouvez pas faire de demande HTTP en utilisant le nom de quelqu'un comme URL.

Nous pouvons être tentés d'utiliser double pour le prix et la longueur, ou utiliser un string pour un nom et une URL. Mais cela subvertit notre système de types génial et permet à ces abus de passer les vérifications statiques du langage.

La confusion entre les secondes-livre et les secondes-newton peut avoir résultats médiocres à l'exécution .


C'est particulièrement un problème avec les chaînes. Ils deviennent fréquemment le "type de données universel".

Nous sommes habitués à envoyer principalement des interfaces texte avec des ordinateurs, et nous étendons souvent ces interfaces humaines (IU) à interfaces de programmation (API). Nous considérons 34,25 comme les caractères 34,25. Nous considérons une date comme les caractères 05-03-2015. Nous considérons un UUID comme les caractères 75e945ee-f1e9-11e4-b9b2-1697f925ec7b.

Mais ce modèle mental nuit à l'abstraction de l'API.

Les mots ou la langue, tels qu'ils sont écrits ou parlés, ne semblent jouer aucun rôle dans mon mécanisme de pensée.

Albert Einstein

De même, les représentations textuelles ne devraient jouer aucun rôle dans la conception des types et des API. Attention au string! (et d'autres types "primitifs" trop génériques)


Types communiquer "quelles opérations ont du sens."

Par exemple, j'ai déjà travaillé sur un client vers un HTTP REST. REST, correctement fait, utilise des entités hypermédias, qui ont des hyperliens pointant vers des entités liées. Dans ce client, non seulement les entités étaient (par exemple, utilisateur, compte, abonnement), les liens vers ces entités ont également été saisis (UserLink, AccountLink, SubscriptionLink). Les liens n'étaient guère plus que des wrappers autour de Uri, mais les types séparés rendaient impossible d'essayer pour utiliser un AccountLink pour récupérer un utilisateur. Si tout avait été simple Uris - ou pire encore, string - ces erreurs ne seraient détectées qu'au moment de l'exécution.

De même, dans votre situation, vous disposez de données utilisées dans un seul but: identifier un Operation. Il ne devrait pas être utilisé pour autre chose, et nous ne devrions pas essayer d'identifier les Operation avec des chaînes aléatoires que nous avons créées. La création d'une classe distincte ajoute la lisibilité et la sécurité à votre code.


Bien sûr, toutes les bonnes choses peuvent être utilisées en excès. Considérer

  1. combien de clarté cela ajoute à votre code

  2. combien de fois il est utilisé

Si un "type" de données (au sens abstrait) est utilisé fréquemment, à des fins distinctes, et entre des interfaces de code, c'est un très bon candidat pour être une classe distincte, au détriment de la verbosité.

34
Paul Draper

Je suis généralement d'accord pour dire que plusieurs fois vous devez créer un type pour les primitives et les chaînes, mais comme les réponses ci-dessus recommandent de créer un type dans la plupart des cas, je vais énumérer quelques raisons pour lesquelles/quand ne pas:

  • Performance. Je dois me référer aux langues réelles ici. En C #, si un ID client est court et que vous l'enveloppez dans une classe - vous venez de créer beaucoup de surcharge: la mémoire, car elle est désormais de 8 octets sur un système 64 bits et la vitesse, car elle est désormais allouée sur le tas.
  • Lorsque vous faites des hypothèses sur le type. Si un ID client est court et que vous avez une logique qui le contient d'une manière ou d'une autre - vous faites généralement déjà des hypothèses sur le type. Dans tous ces endroits, vous devez maintenant briser l'abstraction. Si c'est un endroit, ce n'est pas grave, si c'est partout, vous pourriez trouver que la moitié du temps vous utilisez la primitive.
  • Parce que toutes les langues n'ont pas typedef. Et pour les langues qui ne le font pas et le code qui est déjà écrit, faire un tel changement pourrait être une grosse tâche qui pourrait même introduire des bugs (mais tout le monde a une suite de tests avec une bonne couverture, non?).
  • Dans certains cas, il réduit la lisibilité. Comment est-ce que j'imprime ceci? Comment est-ce que je valide ceci? Dois-je vérifier la valeur null? Toutes les questions qui vous obligent-elles à forer dans la définition du type.
10
ytoledano

Pensez-vous que c'est une bonne pratique d'envelopper des types simples dans une classe pour des raisons de sécurité?

Parfois.

C'est l'un de ces cas où vous devez peser les problèmes qui peuvent survenir en utilisant un string au lieu d'un _ plus concret OperationIdentifier. Quelle est leur gravité? Quelle est leur ressemblance?

Ensuite, vous devez considérer le coût d'utilisation de l'autre type. Est-ce douloureux à utiliser? Combien de travail faut-il faire?

Dans certains cas, vous vous épargnerez du temps et des efforts en ayant un type de béton Nice contre lequel travailler. Dans d'autres, cela ne vaudra pas la peine.

En général, je pense que ce genre de chose devrait être fait plus qu’aujourd’hui. Si vous avez une entité qui signifie quelque chose dans votre domaine, il est bon d'avoir cela comme son propre type, car cette entité est plus susceptible de changer/croître avec l'entreprise.

10
Telastyn

Non, vous ne devez pas définir de types (classes) pour "tout".

Mais, comme d'autres réponses l'indiquent, cela souvent est utile pour le faire. Vous devez développer - consciemment, si possible - un sentiment ou un sentiment de trop de friction dû à l'absence d'un type ou d'une classe appropriée, pendant que vous écrivez, testez et maintenez votre code. Pour moi, le début de trop de friction est lorsque je veux consolider plusieurs valeurs primitives en une seule valeur ou lorsque je dois valider les valeurs (c'est-à-dire déterminer laquelle de toutes les valeurs possibles du type primitif correspond à des valeurs valides de la 'type implicite').

J'ai trouvé des considérations telles que ce que vous avez posé dans votre question pour être responsable de trop conception dans mon code. J'ai pris l'habitude d'éviter délibérément d'écrire plus de code que nécessaire. J'espère que vous écrivez de bons tests (automatisés) pour votre code - si vous l'êtes, vous pouvez facilement refactoriser votre code et ajouter des types ou des classes si cela fournit des avantages nets pour le développement continu et maintenance de votre code .

réponse de Telastyn et réponse de Thomas Junk font tous deux un très bon point sur le contexte et l'utilisation du code pertinent. Si vous utilisez une valeur à l'intérieur d'un seul bloc de code (par exemple, méthode, boucle, bloc using), il est préférable d'utiliser simplement un type primitif. Il est même possible d'utiliser un type primitif si vous utilisez un ensemble de valeurs à plusieurs reprises et à de nombreux autres endroits. Mais plus vous utilisez fréquemment et largement un ensemble de valeurs, et moins cet ensemble de valeurs correspond aux valeurs représentées par le type primitif, plus vous devez envisager d'encapsuler les valeurs dans une classe ou un type.

7
Kenny Evitt

Idée derrière l'emballage de types primitifs,

  • Pour établir un langage spécifique au domaine
  • Limitez les erreurs commises par les utilisateurs en transmettant des valeurs incorrectes

Évidemment, ce serait très difficile et pas pratique de le faire partout, mais il est important d'envelopper les types là où c'est nécessaire,

Par exemple, si vous avez la classe Order,

Order
{
   long OrderId { get; set; }
   string InvoiceNumber { get; set; }
   string Description { get; set; }
   double Amount { get; set; }
   string CurrencyCode { get; set; }
}

Les propriétés importantes pour rechercher une commande sont principalement OrderId et InvoiceNumber. Et le montant et le code de devise sont étroitement liés, où si quelqu'un modifie le code de devise sans modifier le montant, la commande ne peut plus être considérée comme valide.

Ainsi, seuls l'habillage OrderId, InvoiceNumber et l'introduction d'un composite pour la devise sont logiques dans ce scénario et probablement l'habillage de la description n'a aucun sens. Donc, le résultat préféré pourrait ressembler,

    Order
    {
       OrderIdType OrderId { get; set; }
       InvoiceNumberType InvoiceNumber { get; set; }
       string Description { get; set; }
       Currency Amount { get; set; }
    }

Il n'y a donc aucune raison de tout emballer, mais des choses qui comptent vraiment.

5

En surface, il semble que tout ce que vous avez à faire est d'identifier une opération.

De plus, vous dites ce qu'une opération est censée faire:

pour démarrer l'opération [et] pour surveiller sa fin

Vous le dites d'une manière comme si c'était "juste comment l'identification est utilisée", mais je dirais que ce sont des propriétés décrivant une opération. Cela ressemble à une définition de type pour moi, il y a même un modèle appelé "modèle de commande" qui correspond très bien. .

Ça dit

le modèle de commande [...] est utilisé pour encapsuler toutes les informations nécessaires pour exécuter une action ou déclencher un événement à une date ultérieure .

Je pense que c'est très similaire par rapport à ce que vous voulez faire avec vos opérations. (Comparez les phrases que j'ai mises en gras dans les deux guillemets) Au lieu de renvoyer une chaîne, renvoyez un identifiant pour un Operation dans le sens abstrait, un pointeur vers un objet de cette classe dans oop par exemple.

Concernant le commentaire

Il serait wtf-y d'appeler cette classe une commande sans avoir la logique à l'intérieur.

Non, ce ne serait pas le cas. N'oubliez pas que les motifs sont très abstraits , si abstraits en fait qu'ils sont quelque peu méta. Autrement dit, ils font souvent abstraction de la programmation elle-même et non d'un concept du monde réel. Le modèle de commande est une abstraction d'un appel de fonction. (ou méthode) Comme si quelqu'un appuyait sur le bouton pause juste après avoir passé les valeurs des paramètres et juste avant l'exécution, pour reprendre plus tard.

Ce qui suit envisage oop, mais la motivation derrière cela devrait être vraie pour tout paradigme. J'aime donner quelques raisons pour lesquelles placer la logique dans la commande peut être considéré comme une mauvaise chose.

  1. Si vous aviez toute cette logique dans la commande, ce serait gonflé et désordonné. Vous penseriez "eh bien, toutes ces fonctionnalités devraient être dans des classes séparées." Vous devez refactoriser la logique hors de la commande.
  2. Si vous aviez toute cette logique dans la commande, il serait difficile de tester et de gêner lors des tests. "Putain de merde, pourquoi dois-je utiliser cette commande absurde? Je veux seulement tester si cette fonction crache 1 ou non, je ne le fais pas je veux l'appeler plus tard, je veux le tester maintenant! "
  3. Si vous aviez toute cette logique dans la commande, cela n'aurait pas trop de sens de faire un rapport une fois terminé. Si vous pensez à une fonction à exécuter de manière synchrone par défaut, cela n'a aucun sens d'être averti lorsqu'elle termine son exécution. Peut-être que vous démarrez un autre thread parce que votre opération consiste en fait à rendre l'avatar au format cinéma (peut-être besoin d'un thread supplémentaire sur Raspberry Pi), peut-être que vous devez récupérer des données à partir d'un serveur, ... quoi que ce soit, s'il y a une raison pour rendre compte une fois terminé, cela pourrait être dû au fait qu'il y a de l'asynchronisme (est-ce un mot?). Je pense qu'exécuter un thread ou contacter un serveur est une logique qui ne devrait pas figurer dans votre commande. Il s'agit en quelque sorte d'un méta-argument et peut-être controversé. Si vous pensez que cela n'a aucun sens, veuillez me le dire dans les commentaires.

Pour récapituler: le modèle de commande vous permet d'envelopper des fonctionnalités dans un objet, à exécuter plus tard. Par souci de modularité (la fonctionnalité existe indépendamment de l'exécution via une commande ou non), la testabilité (la fonctionnalité devrait être testable sans commande) et tous ces autres mots à la mode qui expriment essentiellement la demande d'écrire du bon code, vous ne mettriez pas la logique réelle dans la commande.


Parce que les motifs sont abstraits, il peut être difficile de trouver de bonnes métaphores du monde réel. Voici une tentative:

"Hé grand-mère, pourriez-vous s'il vous plaît appuyer sur le bouton d'enregistrement du téléviseur à 12h pour ne pas manquer les simpsons sur le canal 1?"

Ma grand-mère ne sait pas ce qui se passe techniquement lorsqu'elle frappe ce bouton d'enregistrement. La logique est ailleurs (dans la télé). Et c'est une bonne chose. La fonctionnalité est encapsulée et les informations sont cachées à la commande, c'est un utilisateur de l'API, pas nécessairement une partie de la logique ... oh jeez je babille à nouveau des mots à la mode, je ferais mieux de terminer cette édition maintenant.

5
null

Opinion impopulaire:

Vous ne devriez généralement pas définir de nouveau type!

La définition d'un nouveau type pour encapsuler une classe primitive ou de base est parfois appelée définir un pseudo-type. IBM décrit cela comme une mauvaise pratique ici (ils se concentrent spécifiquement sur l'utilisation abusive des génériques dans ce cas).

Les pseudo-types rendent la fonctionnalité de bibliothèque commune inutile.

Les fonctions mathématiques de Java peuvent fonctionner avec toutes les primitives numériques. Mais si vous définissez une nouvelle classe Percentage (encapsulant un double pouvant être compris entre 0 et 1), toutes ces fonctions sont inutiles et doivent être encapsulées par des classes qui (pire encore) ont besoin de connaître la représentation interne de la classe Percentage .

Les pseudo-types deviennent viraux

Lorsque vous créez plusieurs bibliothèques, vous constaterez souvent que ces pseudotypes deviennent viraux. Si vous utilisez la classe Percentage mentionnée ci-dessus dans une bibliothèque, vous devrez soit la convertir à la limite de la bibliothèque (en perdant toute sécurité/signification/logique/autre raison que vous aviez pour créer ce type), soit vous devrez créer ces classes également accessible à l'autre bibliothèque. Infecter la nouvelle bibliothèque avec vos types où un simple double aurait suffi.

Message à emporter

Tant que le type que vous encapsulez n'a pas besoin de beaucoup de logique métier, je vous déconseille de l'envelopper dans une pseudo-classe. Vous ne devez boucler une classe que s'il existe de sérieuses contraintes commerciales. Dans d'autres cas, le fait de nommer correctement vos variables devrait contribuer grandement à transmettre un sens.

Un exemple:

Un uint peut parfaitement représenter un UserId, nous pouvons continuer à utiliser les opérateurs intégrés de Java pour les uints (tels que ==) et nous n'avons pas besoin de logique métier pour protéger l '"état interne" du identifiant d'utilisateur.

4
Roy T.

Comme pour tous les conseils, savoir quand appliquer les règles est la compétence. Si vous créez vos propres types dans un langage axé sur les types, vous obtenez une vérification de type. Donc, en général, ce sera un bon plan.

NamingConvention est la clé de la lisibilité. Les deux combinés peuvent exprimer clairement l'intention.
Mais /// est toujours utile.

Alors oui, je dirais créer de nombreux types propres lorsque leur durée de vie dépasse les limites de la classe. Pensez également à utiliser à la fois Struct et Class, pas toujours Class.

3
phil soady