Pièges en C# – Épisode 1

Le C# a beau être un langage très agréable à utiliser, il comporte tout de même des pièges. Je parle de choses auxquelles on ne fait pas forcément attention, qui ne font pas d’erreur de compilation, mais qui aboutissent à un comportement imprévu ou lent. Voici donc une liste de pièges sur lesquels j’ai pu tomber, ainsi que la façon d’éviter le problème.

Commençons par un lot de 10 pièges.

Equals() entre deux structs

Le problème

Lorsqu’on appelle la méthode Equals() entre deux struct, le test est lent.

La raison

En C#, une structure représente une valeur, contrairement à une classe. On ne peut donc pas comparer les références. La comparaison va donc utiliser de la réflexion pour accéder aux différents champs des deux structures et les comparer. Or la réflexion est lente.

De plus, la méthode Equals() prend en paramètre un object. Si une structure est castée en object, on fait du boxing lorsqu’on transmet la valeur et de l’unboxing quand on fait la comparaison. Ces deux étapes prennent également du temps.

La solution

Il suffit de surcharger la méthode Equals(object) pour éviter la réflexion, et implémenter l’interface IEquatable<T> pour éviter le boxing/unboxing. N’oubliez pas également de surcharger GetHashCode().

Equals() et GetHashCode()

Le problème

On surcharge la méthode Equals() d’une classe. On crée deux objets qui doivent être considérés comme identiques. Tout semble se passer pour le mieux. Puis on utilise une classe se basant sur le HashCode (HashSet&lt;T>, Dictionary<TKey, TValue>…) et là ça ne marche plus comme prévu.

La raison

Par défaut, dans une classe, GetHashCode() se base sur l’emplacement mémoire de l’objet. Si la référence est différente, le HashCode est donc différent. La règle la plus importante du HashCode et que deux objets identiques doivent avoir le même HashCode. Si la classe utilise le HashCode, à partir du moment où deux HashCodes sont différents, les objets sont considérés comme différents.

La solution

Quand on surcharge Equals(), il faut toujours surcharger GetHashCode() et se baser sur les champs / propriétés que l’on utilise dans la surcharge d’Equals().

GetHashCode() et modifications de l’objet

Le problème

On crée une instance d’une classe, avec la méthode GetHashCode() qui se base sur une propriété. On l’ajoute à une collection se basant sur un HashCode, on modifie la propriété, et la méthode Contains() renvoie false.

La raison

Le calcul du HashCode se fait au moment de l’ajout à la collection. Modifier la propriété modifie donc le prochain calcul du HashCode. Il ne trouve donc pas le nouveau HashCode dans les HashCodes des éléments contenus dans la collection.

La solution

Sur une structure, le problème n’est pas censé se poser car si l’on suit les bonnes pratiques C#, les structures sont immutables. On ne peut pas les modifier. Il est assez rare d’avoir à surcharger Equals() et GetHashCode() sur une classe. Si on le fait, il faut juste vérifier que GetHashCode() ne se base que sur des champs non modifiables (généralement Id, Code, Name…).

La valeur d’un enum

Le problème

MaMethode prend en paramètre orientation. Pourtant, sa valeur ne correspond ni à Vertical ni à Horizontal.

La raison

Un enum n’est réellement qu’un int dont les valeurs sont nommées. Ce qui signifie qu’on peut caster n’importe quel int en enum, même un qui n’a pas de valeur nommée.

La solution

Lorsqu’une méthode reçoit un paramètre enum, il faut tester toutes les valeurs (et non toutes sauf une), et lever une exception si l’enum ne correspond à aucune valeur nommée. On garantit ainsi que seule une « vraie » valeur est passée en paramètre.

Random et boucles

Le problème

On cherche à afficher 10 nombres aléatoires, or ils sont tous identiques !

La raison

Random permet d’obtenir un nombre pseudo-aléatoire, c’est-à-dire qui semble aléatoire, mais qui en réalité correspond à une suite de nombres déterministe. Mais pour créer cette suite de nombres, cette classe a besoin d’un entier, le plus aléatoire possible. Cet entier (seed), peut être fourni en paramètre du constructeur de Random. Si l’on utilise le constructeur sans paramètre, ça revient à fournir Environment.TickCount comme seed, c’est-à-dire le nombre de millisecondes écoulées depuis le démarrage du système. Ce nombre est relativement indéterminé, donc c’est une bonne valeur de seed. Sauf qu’un programme, c’est très rapide. La boucle ci-dessus se fait en moins d’une milliseconde. Donc chaque new Random() va se baser sur la même valeur de TickCount, donc va fournir la même suite de nombres.

La solution

C’est très simple, il suffit de sortir l’objet Random de la boucle. En ne le créant qu’une seule fois, on ne se base qu’une fois sur la seed, et donc on génère correctement nos nombres pseudo-aléatoires.

Random et threads

Le problème

Tout fonctionne très bien jusqu’à un moment, où toutes les valeurs renvoyées par random.Next() sont égales à 0. (Lancer le code, puis mettre un point d’arrêt ligne 7. Quand on s’y arrête, retirer le point d’arrêt, en mettre un ligne 10 et reprendre le programme. On ne passe jamais dans le nouveau point d’arrêt.)

La raison

La classe Random n’est pas thread-safe. Le comportement lors de l’utilisation simultanée d’une seule instance de cet objet dans des threads différents peut être imprévisible.

La solution

Le plus simple est d’avoir un objet par thread. Pour ça, il existe deux moyens tout-faits en .net : l’attribut [ThreadStatic] (lien MSDN) et la classe ThreadLocal<T> (lien MSDN). Attention cependant : si les constructeurs des objets en question sont appelés en même temps, on se retrouve dans la même situation que le point Random et boucles. Une solution est alors de créer une variable basée sur Environment.TickCount que l’on passera au constructeur de Random, mais en l’incrémentant à chaque fois de 1, afin de garantir que la seed sera unique.

Const dans un autre assembly

Le problème

J’ai l’assembly A qui contient :

Et l’assembly B qui utilise cette constante :

Ça marche bien, le projet est en production. Dans une nouvelle version, on modifie la valeur de MaConstante à 12. On modifie donc l’assembly A. On compile la solution, on teste, ça marche : maValeur vaut maintenant 12. On livre l’assembly A en production, et là, maValeur vaut toujours 10

La raison

Une constante n’est pas une variable. À ce titre, le compilateur la traite différemment. Là où on l’utilise, au lieu d’aller chercher, au runtime, la valeur de la constante, le compilateur remplace toute référence à la constante par sa valeur. Remplacer, dans l’assembly B, MaClasse.MaConstante par 10 n’aurait rien changé au code généré. Mettre à jour la constante dans l’assembly A permet de mettre à jour toutes les références à cette constante dans tous les assemblies qu’on recompile. Ceci signifie que l’assembly B a aussi été modifié.

La solution

Il existe trois solutions :

  • Soit on ne change jamais la valeur d’une constante..
  • Soit on relivre tous les assemblies dans lesquels cette constante était utilisée.
  • Soit on remplace const par static readonly. En revanche on perd en performance puisqu’on fait un accès mémoire.

Null-conditional operator et méthodes d’extension

Le problème

Puisque les méthodes d’extension ne sont que de simples méthodes statiques avec l’objet en premier paramètre, rien n’interdit l’objet d’être null. On peut donc faire une méthode qui renvoie la collection donnée en paramètre, ou une nouvelle collection vide si le paramètre est null :

Nouveauté du C# 6, le null-conditional operator (ou safe navigation operator), qui s’écrit ?., permet de n’accéder à un membre d’un objet que s’il n’est pas null. Dans le cas contraire il renvoie null. Ainsi, on évite d’avoir à tester manuellement la nullité de l’objet avant d’accéder à un de ses membres.

Maintenant, mixons les deux :

L’objectif étant que collection ne soit jamais null. Pourtant, si monObjet est null, collection aussi.

La raison

Le null-conditional operator arrête l’évaluation du reste de l’expression de navigation vers les membres à partir du moment où l’objet sur lequel il se trouve est null. En l’occurrence, si monObject est null, il ne fait plus rien sur la ligne.

La solution

Il suffit donc de sortir EmptyIfNull() de l’expression de navigation. Trois façons de faire :

  • Faire deux instructions : var collection = monObjet?.MaCollection; collection = collection.EmptyIfNull();
  • Ne pas utiliser la syntaxe d’extension : var collection = IEnumerableExtensions.EmptyIfNull(monObjet?.MaCollection);
  • Ajouter simplement des parenthèses : var collection = (monObjet?.MaCollection).EmptyIfNull();

Ordre des champs statiques

Le problème

J’ai ce code mais derrière, j’ai utilisé une extension Visual Studio qui réorganise les membres des classes, et notamment trie les champs par ordre alphabétique. Et maintenant, au lieu de valoir 15, deuxiemeChamp vaut 10, alors que je n’ai ni erreur de compilation ni warning.

La raison

Lorsque l’initialisation des champs se trouve avec la déclaration, celle-ci se fait dans l’ordre. Si deuxiemeChamp se trouve avant premierChamp, premierChamp ne sera pas encore initialisé, et vaudra donc default(int) soit 0.

Si les champs sont répartis dans plusieurs fichiers d’une classe partielle, l’initialisation respecte l’ordre alphabétique des fichiers.

La solution

Si un champ statique doit se baser sur la valeur d’un autre champ statique, il faut faire l’initialisation dans le constructeur statique de la classe.

Structures et classes

Le problème

Le résultat affiche « Hello ». Pourtant, si je change, ligne 1, struct par class, le résultat devient « World ».

La raison

À la ligne 10, lorsqu’on déclare monObjet, si MonType est une classe, monObjet sera une référence (un pointeur vers une zone mémoire). Si MonType est une structure, monObjet sera une valeur. Lorsqu’on transmet une variable en paramètre (ligne 12), la méthode reçoit une copie.

Si MonType est une classe, on modifie la zone mémoire référencée par monObjetParameter, qui est une copie de monObjet. La modification se fait sur la même zone mémoire, ce qui explique qu’à la ligne 13, on obtienne la valeur modifiée.

Si MonType est une structure, on modifie monObjetParameter, qui est une copie de monObjet. Modifier la copie ne modifie cependant pas l’original. monObjet n’ayant jamais été modifié, on obtient toujours la même valeur à la ligne 13.

La solution

Voici les bonnes pratiques concernant les structures en .net :

  • Elles doivent représenter une valeur
  • Elles doivent faire au maximum 16 octets
  • Elles doivent être immutables (non modifiables une fois définies)

Or si une structure est immutable, la ligne 18 refusera de compiler dans le cas d’une structure. Ainsi, on évite toute ambigüité sur ce genre de code : si on peut modifier la valeur, on est sûrs que c’est une classe et donc que l’objet a été modifié, quelle que soit la référence dessus qu’on utilise.

One thought on “Pièges en C# – Épisode 1

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.