Salut Habr!
L'autre jour, j'ai à nouveau obtenu le code de type
if(someParameter.Volatilities.IsEmpty()) { // We have to report about the broken channels, however we could not differ it from just not started cold system. // Therefore write this case into the logs and then in case of emergency IT Ops will able to gather the target line Log.Info("Channel {0} is broken or was not started yet", someParameter.Key) }
Il y a une caractéristique assez importante dans le code: le destinataire aimerait beaucoup savoir ce qui s'est réellement passé. En effet, dans un cas, nous avons des problèmes avec le système, et dans l'autre, nous nous réchauffons. Cependant, le modèle ne nous donne pas cela (pour faire plaisir à l'expéditeur, qui est souvent l'auteur du modèle).
De plus, même le fait «peut-être que quelque chose ne va pas» vient du fait que la collection Volatilities
vide. Ce qui dans certains cas peut être correct.
Je suis sûr que la plupart des développeurs expérimentés du code ont vu des lignes qui contenaient des connaissances secrètes dans le style "si cette combinaison de drapeaux est définie, alors on nous demande de faire A, B et C" (bien que cela ne soit pas visible par le modèle lui-même).
De mon point de vue, de telles économies sur la structure des classes ont un impact extrêmement négatif sur le projet à l'avenir, le transformant en un ensemble de hacks et de béquilles, transformant progressivement un code plus ou moins pratique en héritage.
Important: dans l'article, je donne des exemples utiles pour des projets dans lesquels plusieurs développeurs (et non un), plus qui seront mis à jour et développés pendant au moins 5-10 ans. Tout cela n'a aucun sens si le projet a un développeur pendant cinq ans, ou si aucun changement n'est prévu après la sortie. Et il est logique, si le projet n'est nécessaire que pour quelques mois, il est inutile d'investir dans un modèle de données clair.
Cependant, si vous jouez longtemps - bienvenue au chat.
Utiliser le modèle de visiteur
Souvent, le même champ contient un objet qui peut avoir différentes significations sémantiques (comme dans l'exemple). Cependant, pour sauvegarder les classes, le développeur ne laisse qu'un seul type, en lui fournissant des drapeaux (ou des commentaires dans le style "s'il n'y a rien ici, alors rien n'a été compté"). Une approche similaire peut masquer une erreur (ce qui est mauvais pour le projet, mais pratique pour l'équipe qui fournit le service, car les bogues ne sont pas visibles de l'extérieur). Une option plus correcte, qui permet même à l'extrémité du fil de savoir ce qui se passe réellement, est d'utiliser l'interface + les visiteurs.
Dans ce cas, l'exemple de l'en-tête se transforme en code du formulaire:
class Response { public IVolatilityResponse Data { get; } } interface IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) } class VolatilityValues : IVolatilityResponse { public Surface Data; TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } class CalculationIsBroken : IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } interface IVolatilityResponseVisitor<TInput, TOutput> { TOutput Visit(VolatilityValues instance, TInput input); TOutput Visit(CalculationIsBroken instance, TInput input); }
Avec ce type de traitement:
- Nous avons besoin de plus de code. Hélas, si nous voulons exprimer plus d'informations dans le modèle, cela devrait être plus.
- En raison de ce type d'héritage, nous ne pouvons plus sérialiser la
Response
à json
/ protobuf
, car les informations de type y sont perdues. Nous devrons créer un conteneur spécial qui fera cela (par exemple, vous pouvez créer une classe qui contient un champ séparé pour chaque implémentation, mais un seul d'entre eux sera rempli). - L'extension du modèle (c'est-à-dire l'ajout de nouvelles classes) nécessite d'étendre l'
IVolatilityResponseVisitor<TInput, TOutput>
, ce qui signifie que le compilateur le forcera à être pris en charge dans le code. Le programmeur n'oubliera pas de traiter le nouveau type, sinon le projet ne sera pas compilé. - En raison de la saisie statique, nous n'avons pas besoin de stocker la documentation quelque part avec des combinaisons possibles de champs, etc. Nous avons décrit toutes les options possibles dans du code compréhensible à la fois pour le compilateur et pour la personne. Nous n'aurons pas de désynchronisation entre la documentation et le code, car nous pouvons nous passer du premier.
À propos de la restriction de l'héritage dans d'autres langues
Un certain nombre d'autres langues (par exemple, Scala
ou Kotlin
) ont des mots clés qui vous permettent d'interdire l'héritage d'un certain type, sous certaines conditions. Ainsi, au stade de la compilation, nous connaissons tous les descendants possibles de notre type.
En particulier, l'exemple ci-dessus peut être réécrit dans Kotlin
comme ceci:
class Response ( val data: IVolatilityResponse ) sealed class VolatilityResponse class VolatilityValues : VolatilityResponse() { val data: Surface } class CalculationIsBroken : VolatilityResponse()
Il s'est avéré un peu moins que le code, mais maintenant dans le processus de compilation, nous savons que tous les VolatilityResponse
possibles sont dans le même fichier avec lui, ce qui signifie que le code suivant ne sera pas compilé, car nous n'avons pas parcouru toutes les valeurs possibles de la classe.
fun getResponseString(response: VolatilityResponse) = when(response) { is VolatilityValues -> data.toString() }
Cependant, il convient de rappeler que ces vérifications ne fonctionnent que pour les appels fonctionnels. Le code ci-dessous se compilera sans erreur:
fun getResponseString(response: VolatilityResponse) { when(response) { is VolatilityValues -> println(data.toString()) } }
Tous les types primitifs ne signifient pas la même chose
Considérons un développement relativement typique d'une base de données. Très probablement, quelque part dans le code, vous aurez des identificateurs d'objet. Par exemple:
class Group { public int Id { get; } public string Name { get; } } class User { public int Id { get; } public int GroupId { get; } public string Name { get; } }
Cela ressemble à un code standard. Les types correspondent même à ceux de la base de données. Cependant, la question est: le code ci-dessous est-il correct?
public bool IsInGroup(User user, Group group) { return user.Id == group.Id; } public User CreateUser(string name, Group group) { return new User { Id = group.Id, GroupId = group.Id, name = name } }
La réponse est très probablement non, car nous comparons l' Id
utilisateur et l' Id
groupe dans le premier exemple. Et dans le second, nous avons défini par erreur l' id
de Group
comme l' id
de User
.
Curieusement, c'est assez simple à corriger: obtenez simplement les types GroupId
, UserId
et ainsi de suite. Ainsi, la création d' User
ne fonctionnera plus, car vos types ne convergeront pas. Ce qui est incroyablement cool, car vous pouvez informer le compilateur du modèle.
De plus, les méthodes avec les mêmes paramètres fonctionneront correctement pour vous, car maintenant elles ne seront plus répétées:
public void SetUserGroup(UserId userId, GroupId groupId) { /* some sql code */ }
Revenons cependant à l'exemple de comparaison des identifiants. C'est un peu plus compliqué, car vous devez empêcher le compilateur de comparer l'incomparable pendant le processus de construction.
Et vous pouvez le faire comme suit:
class GroupId { public int Id { get; } public bool Equals(GroupId groupId) => Id == groupId?.Id; [Obsolete("GroupId can be equal only with GroupId", error: true)] public override bool Equals(object obj) => Equals(obj as GroupId) public static bool operator==(GroupId id1, GroupId id2) { if(ReferenceEquals(id1, id2)) return true; if(ReferenceEquals(id1, null) || ReferenceEquals(id2, null)) return false; return id1.Id == id2.Id; } [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(object _, GroupId __) => throw new NotSupportedException("GroupId can be equal only with GroupId") [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(GroupId _, object __) => throw new NotSupportedException("GroupId can be equal only with GroupId") }
En conséquence:
- Nous avions encore besoin de plus de code. Hélas, si vous voulez donner plus d'informations au compilateur, vous devez souvent écrire plus de lignes.
- Nous avons créé de nouveaux types (nous parlerons des optimisations ci-dessous), ce qui peut parfois dégrader légèrement les performances.
- Dans notre code:
- Nous avons interdit de confondre les identifiants. Le compilateur et le développeur voient maintenant clairement qu'il est impossible de
GroupId
champ GroupId
dans le champ GroupId
- Il nous est interdit de comparer l'incomparable. Je
IEquitable
que le code de comparaison n'est pas complètement terminé (il est également souhaitable d'implémenter l'interface IEquitable
, vous devez également implémenter la méthode GetHashCode
), donc l'exemple n'a pas seulement besoin d'être copié dans le projet. Cependant, l'idée elle-même est claire: nous avons explicitement interdit au compilateur d'exprimer quand les mauvais types ont été comparés. C'est-à-dire au lieu de dire "ces fruits sont-ils égaux?" le compilateur voit maintenant "est une poire égale à une pomme?".
Un peu plus sur SQL et ses limites
Souvent, dans nos demandes de types, des règles supplémentaires sont introduites et faciles à vérifier. Dans le pire des cas, un certain nombre de fonctions ressemblent à ceci:
void SetName(string name) { if(name == null || name.IsEmpty() || !name[0].IsLetter || !name[0].IsCapital || name.Length > MAX_NAME_COLUMN_LENGTH) { throw .... } /**/ }
Autrement dit, la fonction prend un type d'entrée assez large, puis exécute les vérifications. Ce n'est généralement pas le cas puisque:
- Nous n'avons pas expliqué au programmeur et au compilateur ce que nous voulons ici.
- Dans une autre fonction similaire, vous devrez copier les chèques.
- Lorsque nous avons reçu une
string
qui indiquera le name
, nous ne sommes pas tombés immédiatement, mais pour une raison quelconque, l'exécution a continué de tomber sur quelques instructions de processeur plus tard.
Le comportement correct:
- Créez un type distinct (dans notre cas, apparemment,
Name
). - Dans ce document, effectuez toutes les validations et vérifications nécessaires.
- Enveloppez la
string
dans Name
aussi rapidement que possible pour obtenir une erreur aussi rapidement que possible.
En conséquence, nous obtenons:
- Moins de code, car nous avons vérifié les vérifications de
name
dans le constructeur. - Stratégie Fail Fast - maintenant, ayant reçu un nom problématique, nous tomberons immédiatement, au lieu d'appeler quelques méthodes supplémentaires, mais tomberons toujours. De plus, au lieu d'une erreur d'une base de données de type type trop volumineux, nous découvrons immédiatement que cela n'a même aucun sens de commencer même à traiter de tels noms.
- Il est déjà plus difficile pour nous de mélanger les arguments si la signature de la fonction est:
void UpdateData(Name name, Email email, PhoneNumber number)
. Après tout, nous passons maintenant non pas trois string
identiques, mais trois entités différentes différentes.
Un peu de casting
En introduisant un typage assez strict, il ne faut pas non plus oublier que lors du transfert de données vers Sql, nous avons encore besoin d'obtenir un véritable identifiant. Et dans ce cas, il est logique de mettre à jour légèrement les types qui encapsulent une string
:
- Ajouter une implémentation d'une interface de l'interface de formulaire
interface IValueGet<TValue>{ TValue Wrapped { get; } }
interface IValueGet<TValue>{ TValue Wrapped { get; } }
. Dans ce cas, dans la couche de traduction en SQL, nous pouvons obtenir la valeur directement - Au lieu de créer un tas de types plus ou moins identiques dans le code, vous pouvez créer un ancêtre abstrait et en hériter le reste. Le résultat est un code de la forme:
interface IValueGet<TValue> { TValue Wrapped { get; } } abstract class BaseWrapper : IValueGet<TValue> { protected BaseWrapper(TValue initialValue) { Wrapped = initialValue; } public TValue Wrapped { get; private set; } } sealed class Name : BaseWrapper<string> { public Name(string value) :base(value) { /*no necessary validations*/ } } sealed class UserId : BaseWrapper<int> { public UserId(int id) :base(id) { /*no necessary validations*/ } }
Performances
En parlant de créer un grand nombre de types, vous pouvez souvent rencontrer deux arguments dialectiques:
- Plus il y a de types, d'imbrication et de code, plus le logiciel est lent, car il est plus difficile pour jit d'optimiser le programme. Par conséquent, ce type de frappe stricte entraînera de sérieux freins dans le projet.
- Plus il y a de wrappers, plus l'application mange de mémoire. Par conséquent, l'ajout de wrappers augmentera considérablement les besoins en RAM.
À strictement parler, les deux arguments sont souvent présentés sans faits, cependant:
- En fait, dans la plupart des applications sur le même java, les chaînes (et les tableaux d'octets) occupent la mémoire principale. Autrement dit, il est peu probable que la création de wrappers soit perceptible par l'utilisateur final. Cependant, en raison de ce type de frappe, nous obtenons un avantage important: lors de l'analyse d'un vidage de mémoire, vous pouvez évaluer la contribution de chacun de vos types à la mémoire. Après tout, vous ne voyez pas seulement une liste anonyme de lignes réparties sur le projet. Au contraire, nous pouvons comprendre quels types d'objets sont plus grands. De plus, étant donné que seuls les wrappers contiennent des chaînes et d'autres objets massifs, il est plus facile pour vous de comprendre la contribution de chaque type d'encapsuleur particulier à la mémoire partagée.
- L'argument de l'optimisation jit est en partie vrai, mais il n'est pas complètement complet. En effet, du fait d'un typage strict, votre logiciel commence à se débarrasser de nombreux contrôles à l'entrée des fonctions. Tous vos modèles sont vérifiés pour l'adéquation dans leur conception. Ainsi, dans le cas général, vous aurez moins de contrôles (il suffit de simplement demander le bon type). De plus, du fait que les chèques sont transférés au constructeur, et non étalés par du code, il devient plus facile de déterminer lesquels d'entre eux prennent vraiment du temps.
- Malheureusement, dans cet article, je ne peux pas donner un test de performance à part entière, qui compare le projet avec un grand nombre de microtypes et avec le développement classique, en utilisant uniquement
int
, string
et autres types primitifs. La raison principale est que pour cela, vous devez d'abord faire un projet audacieux typique pour le test, puis justifier que ce projet particulier est typique. Et avec le deuxième point, tout est compliqué, car dans la vraie vie les projets sont vraiment différents. Cependant, il sera plutôt étrange de faire des tests synthétiques, car, comme je l'ai déjà dit, la création d'objets microtypes dans les applications Enterprise, selon mes mesures, a toujours laissé des ressources négligeables (au niveau de l'erreur de mesure).
Comment pouvez-vous optimiser un code composé d'un grand nombre de ces microtypes.
Important: vous ne devez gérer de telles optimisations que lorsque vous recevez des faits garantis que ce sont les microtypes qui ralentissent l'application. D'après mon expérience, une telle situation est plutôt impossible. Avec une probabilité plus élevée, le même enregistreur vous ralentira , car chaque opération attend un vidage sur le disque (tout était acceptable sur l'ordinateur du développeur avec SSD M.2, mais un utilisateur avec un ancien disque dur voit des résultats complètement différents).
Cependant, les astuces elles-mêmes:
- Utilisez des types significatifs au lieu de types de référence. Cela peut être utile si Wrapper fonctionne également avec des types importants, ce qui signifie qu'en théorie, vous pouvez transmettre toutes les informations nécessaires à travers la pile. Bien qu'il ne faut pas oublier que l'accélération ne sera que si votre code souffre vraiment de GC fréquents précisément à cause des microtypes.
struct
dans .Net peut provoquer des boxes / unboxing fréquentes. Et en même temps, de telles structures peuvent nécessiter plus de mémoire dans les collections Dictionary
/ Map
(car les tableaux sont alloués avec une marge).inline
types en inline
de Kotlin / Scala ont une applicabilité limitée. Par exemple, vous ne pouvez pas y stocker plusieurs champs (ce qui peut parfois être utile pour mettre en cache la valeur ToString
/ GetHashCode
).- Un certain nombre d'optimiseurs sont capables d'allouer de la mémoire sur la pile. En particulier, .Net le fait pour les petits objets temporaires , et GraalVM en Java peut allouer un objet sur la pile, mais le copier ensuite dans le tas s'il devait être retourné (adapté au code riche en conditions).
- Utilisez l'internement d'objets (c'est-à-dire, essayez de prendre des objets prêts à l'emploi, pré-créés).
- Si le constructeur a un argument, vous pouvez simplement créer un cache où la clé est cet argument et la valeur est l'objet créé précédemment. Ainsi, si la variété des objets est assez petite, vous pouvez simplement réutiliser ceux prêts à l'emploi.
- Si un objet a plusieurs arguments, vous pouvez simplement créer un nouvel objet, puis vérifier s'il se trouve dans le cache. S'il y en a un similaire, il vaut mieux retourner celui déjà créé.
- Un tel schéma ralentit le travail des concepteurs, car
Equals
/ GetHashCode
doit être effectué pour tous les arguments. Cependant, cela accélère également les comparaisons futures d'objets, si vous mettez en cache la valeur du hachage, car dans ce cas, s'ils sont différents, les objets sont différents. Et les objets identiques auront souvent un seul lien. - Cependant, cette optimisation accélérera le programme, en raison du
GetHashCode
/ Equals
plus rapide (voir le paragraphe ci-dessus). De plus, la durée de vie des nouveaux objets (qui sont cependant dans le cache) diminuera considérablement, de sorte qu'ils n'entreront que dans la génération 0.
- Lors de la création de nouveaux objets, vérifiez les paramètres d'entrée et ne les ajustez pas. Malgré le fait que ces conseils vont souvent dans le paragraphe sur le style de codage, en fait, cela vous permet d'augmenter l'efficacité du programme. Par exemple, si votre objet nécessite une chaîne avec uniquement de GRANDES LETTRES, alors deux approches sont souvent utilisées pour vérifier: soit rendre
ToUpperInvariant
partir de l'argument, soit vérifier dans une boucle que toutes les lettres sont grandes. Dans le premier cas, une nouvelle ligne est garantie d'être créée, dans le second, l'itérateur maximum est créé. En conséquence, vous économisez de la mémoire (cependant, dans les deux cas, chaque caractère sera toujours vérifié, de sorte que les performances n'augmenteront que dans le contexte d'un garbage collection plus rare).
Conclusion
Encore une fois, je répéterai le point important du titre: tout ce qui est décrit dans l'article a du sens dans les grands projets qui ont été développés et utilisés pendant des années. Dans ceux où il est significatif de réduire le coût du support et de réduire le coût de l'ajout de nouvelles fonctionnalités. Dans d'autres cas, il est souvent plus raisonnable de fabriquer un produit le plus rapidement possible sans se soucier des tests, des modèles et du «bon code».
Cependant, pour les projets à long terme, il est raisonnable d'utiliser le typage le plus strict, où dans le modèle nous pouvons décrire strictement quelles valeurs sont en principe possibles.
Si votre service peut parfois renvoyer un résultat non fonctionnel, exprimez-le dans le modèle et montrez-le explicitement au développeur. N'ajoutez pas mille indicateurs avec des descriptions dans la documentation.
Si vos types peuvent être les mêmes dans le programme, mais qu'ils sont différents dans leur essence, définissez-les exactement comme différents. Ne les mélangez pas, même si les types de leurs champs sont les mêmes.
Si vous avez des questions sur la productivité, appliquez la méthode scientifique et faites un test (ou mieux, demandez à une personne indépendante de vérifier tout cela). Dans ce scénario, vous accélérerez réellement le programme et ne perdrez pas seulement le temps de l'équipe. Cependant, l'inverse est également vrai: s'il y a un soupçon que votre programme ou bibliothèque est lent, faites un test. Pas besoin de dire que tout va bien, il suffit de le montrer en chiffres.