Aide mémoire sur l’utilisation de “new” et “override”

Si vous connaissez déjà new et override dans les grandes lignes, aller directement au résumé des cas possibles pour vérifier si vous maîtrisez tous les cas de figures.

Même si les mots clés new et override n’ont, à priori, rien à voir, d’une façon générale ils modifient le comportement de membres d’une classe dans le cadre d’un héritage. Même si le cadre général d’utilisation de ces mots clés est connu, quelques cas particuliers peuvent mener à des comportements inattendus.

Override

override est le mot clé permettant de surcharger un membre public ou protected dans une classe dérivée.

Héritage simple

Pour effectuer une surcharge, il faut que le membre de la classe de base soit virtuel c’est-à-dire qu’il utilise le mot clé virtual. virtual autorise, ainsi, les surcharges éventuelles dans les classes dérivées.

Par exemple, si on définit la classe de base:

public class BaseClass 
{ 
    private int innerValue = 7; 
 
    public virtual int InnerValue  
    {  
        get { return this.innerValue; }  
    } 
 
    public int ComputeValue(int value) 
    { 
        return this.AddToInnerValue(value); 
    } 
 
    protected virtual int AddToInnerValue(int value) 
    { 
        return value + this.innerValue; 
    } 
}

La propriété InnerValue et la méthode AddToInnerValue() sont virtuelles et donc surchargeable dans une classe dérivée:

public class DerivedClass : BaseClass 
{ 
    public override int InnerValue 
    {  
        get { return 4; }  
    } 
 
    protected override int AddToInnerValue(int value) 
    { 
        return value + 9; 
    } 
}

override permet de modifier la définition d’un membre public ou protected dans la classe dérivée.

On peut faire appel à la classe de base en utilisant le mot clé base.

Par exemple, dans la classe DerivedClass, on peut appeler la méthode dans la classe BaseClass en faisant:

protected override int AddToInnerValue(int value) 
{ 

    return base.AddToInnerValue(value); 
}

Héritage d’une classe abstraite

override s’utilise aussi pour déclarer un membre public ou protected dans une classe dérivée si ces membres sont abstraits dans la classe de base. Pour indiquer qu’un membre est abstrait dans la classe de base, il doit utiliser le mot clé abstract.

Si une classe comporte au moins un membre abstrait, elle est déclarée abstraite et doit aussi être déclarée en utilisant le mot clé abstract.

Par exemple, si on déclare la classe abstraite:

public abstract class BaseClass 
{ 
    public abstract int InnerValue { get; } 
 
    public int ComputeValue(int value) 
    { 
        return this.AddToInnerValue(value); 
    } 
 
    protected abstract int AddToInnerValue(int value); 
}

On peut surcharger les membres abstraits en utilisant override:

public class DerivedClass : BaseClass 
{ 
    public override int InnerValue 
    {  
        get { return 4; }  
    } 
 
    protected override int AddToInnerValue(int value) 
    { 
        return value + 9; 
    } 
}

Dans la classe abstraite, les membres abstraits sont juste déclarés et n’ont pas d’implémentation. Si la classe dérivée n’est pas abstraite, il est obligatoire de définir tous les membres abstraits.

Si la classe dérivée est abstraite, il n’est pas obligatoire de définir une implémentation pour tous les membres abstraits.

New

new n’est pas un opérateur d’héritage. Il permet d’indiquer au compilateur qu’on souhaite appeler un membre dans une classe dérivée de la même façon qu’un autre membre dans la classe de base.

Par exemple, on définit la classe de base suivante:

public class BaseClass 
{ 
    public int InnerValue(int value) 
    { 
        return value + 5; 
    } 
}

Si on définit la classe dérivée avec le même nom de méthode:

public class DerivedClass : BaseClass 
{ 
    public int AddToInnerValue(int value) 
    { 
        return value + 9; 
    } 
}

A la compilation, on aura le message de “warning”:

Warning 'DerivedClass.AddToInnerValue' hides inherited member 'BaseClass.AddToInnerValue'. Use the new keyword if hiding was intended.

Pour supprimer ce message de “warning”, il suffit d’utiliser le mot clé new dans la méthode de la classe dérivée:

public class DerivedClass : BaseClass 
{ 
    public new int AddToInnerValue(int value) 
    { 
        return value + 9; 
    } 
}
“new” ne modifie pas le comportement lié à l’héritage

Le mot clé new ne modifie pas le comportement d’héritage des classes. La méthode AddToInnerValue() définit dans la classe de base BaseClass n’est pas déclarée “virtuelle” donc la méthode AddToInnerValue() dans la classe dérivée DerivedClass ne surcharge pas la méthode dans la classe de base. Il s’agit d’une nouvelle méthode qui n’a rien à voir avec celle définit dans la classe de base. Ainsi dans tous les cas d’utilisation, c’est la méthode de la classe de base qui sera exécutée sauf si on n’utilise pas le polymorphisme.

Exemples d’utilisation de “new” et “override”

Utilisation de “override”

Si on définit les objets A, B et I de la façon suivante:

public interface I 
{  
    void Method(); 
} 
 
public class A : I 
{ 
    public virtual void Method()  
    { 
        Console.WriteLine("Exécuté dans A"); 
    } 
} 
 
public class B : A 
{ 
    public override void Method() 
    { 
        Console.WriteLine("Exécuté dans B"); 
    } 
}

Différents cas d’utilisation des classes avec “override”

A instance1 = new A(); 
instance1.Method(); // Exécuté dans A 
 
A instance2 = new B(); 
instance2.Method(); // Exécuté dans B 
 
B instance3 = new B(); 
instance3.Method(); // Exécuté dans B 
 
I instance4 = new A(); 
instance4.Method(); // Exécuté dans A 
 
I instance5 = new B(); 
instance5.Method(); // Exécuté dans B

Dans le cas d’un héritage classique, override permet de définir un comportement différent de la méthode dans le classe dérivée.

Si l’objet est du type de la classe dérivée, la méthode exécutée sera toujours celle de la classe dérivée quelque soit le type de la variable référençant l’objet.

Utilisation de “new”

Si on définit les objets A, B et I de la façon suivante:

public interface I 
{  
    void Method(); 
} 
 
public class A : I 
{ 
    public void Method()  
    { 
        Console.WriteLine("Exécuté dans A"); 
    } 
} 
 
public class B : A 
{ 
    public new void Method() 
    { 
        Console.WriteLine("Exécuté dans B"); 
    } 
}

Différents cas d’utilisation des classes avec “new”

A instance1 = new A(); 
instance1.Method(); // Exécuté dans A 
 
A instance2 = new B(); 
instance2.Method(); // Exécuté dans A 
 
B instance3 = new B(); 
instance3.Method(); // Exécuté dans B 
 
I instance4 = new A(); 
instance4.Method(); // Exécuté dans A 
 
I instance5 = new B(); 
instance5.Method(); // Exécuté dans A

new ne permet pas de substituer le lien d’héritage entre les classes: mis à part le cas où on instancie la classe dérivée et qu’on l’utilise dans son type d’origine, dans tous les cas, c’est la méthode dans la classe de base qui sera exécutée.

La méthode exécutée sera toujours celle de la classe de base sauf si l’objet et la variable sont explicitement du type de la classe dérivée.

Résumé des cas possibles

Dans le tableau suivant, on peut voir tous les comportements possibles dans le cas d’utilisation des mots clés new et override:

Définition et instanciation des objets Variable de la classe dérivée
Instanciation de la classe dérivée
Variable de la classe de base
Instanciation de la classe dérivée
Variable avec le type de la classe de base
Instanciation de la classe de base
Variable avec le type d’une interface
Instanciation de la classe de base
Variable avec le type d’une interface
Instanciation de la classe dérivée
B obj
= new B();
obj.M();
A obj
= new B();
obj.M();
A obj
= new A();
obj.M();
I obj
= new A();
obj.M();
I obj
= new B();
obj.M();
Pas de surchage:

public interface I
{
  void M();
}

public class A : I
{
  public void M(){}
}

public class B : A {}
Exécution dans la classe de base (A)
Pas d’utilisation d’opérateur:

public interface I
{
  void M();
}

public class A : I
{
  public void M(){}
}

public class B : A
{
  public void M(){}
}
Exécution dans la classe dérivée (B)
Compilation avec “warning”
Exécution dans la classe de base (A)
Compilation avec “warning”
virtual dans la classe base:

public interface I
{
  void M();
}

public class A : I
{ 
  public virtual void M(){}
}

public class B : A
{
  public void M(){}
}
Exécution dans la classe dérivée (B)
Compilation avec “warning”
Exécution dans la classe de base (A)
Compilation avec “warning”
new dans la classe dérivée:

public interface I
{
  void M();
}

public class A : I
{
  public void M(){}
}

public class B : A
{
  public new void M(){}
}
Exécution dans la classe dérivée (B) Exécution dans la classe de base (A)
  • virtual dans la classe de base
  • new dans la classe dérivée
public interface I
{
  void M();
}

public class A : I
{
  public virtual void M(){}
}

public class B : A
{
  public new void M(){}
}
Exécution dans la classe dérivée (B) Exécution dans la classe de base (A)
Surcharge classique:

  • virtual dans la classe de base
  • override dans la classe dérivée
public interface I
{
  void M();
}

public class A : I
{
  public virtual void M(){}
}

public class B : A
{
  public override void M(){}
}
Exécution dans la classe dérivée (B) Exécution dans la classe dérivée (B) Exécution dans la classe de base (A) Exécution dans la classe de base (A) Exécution dans la classe dérivée (B)

Nombres à virgule flottante en C# en 5 min

Nombres à virgule flottante

En C#, pour stocker des nombres réels on peut utiliser les types:

  • float ou double qui sont des types de nombres binaires à virgule flottante ou
  • decimal qui est un type de nombre décimal à virgule flottante.

Le choix précis d’un de ces types n’est pas anodin et ne doit pas se faire seulement sur le critère de l’intervalle toléré par le type comme ça pourrait être le cas pour les nombres entiers.

Par exemple, le type double est le type qui permet l’intervalle le plus grand pourtant il est le type qui introduit le plus d’erreurs de calcul. Ainsi, il faut avoir en tête toutes les conséquences du choix du type de nombre à virgule flottante pour ne pas être surpris par certains de ces comportements.

Généralités sur les types à virgule flottante

Tous les nombres n’ont pas forcément une représentation exacte dans un type C#. Les nombres entiers ont le plus souvent une représentation exacte mise à part s’ils dépassent l’intervalle de valeurs tolérés par les types entiers (“integral types”):

Alias Type Valeur min Valeur max Taille (bits)
byte System.Byte 0 255 8
sbyte System.SByte -128 127 8
short System.Int16 -32768 32767 16
ushort System.UInt16 0 65535 16
char System.Char 0 65535 16
int System.Int32 –2147483648 2147483647 32
uint System.UInt32 0 4294967295 32
long System.Int64 -9,223272x1018 9.223372x1018 64
ulong System.UInt64 0 18,446744x1018 64

Comme ces intervalles contiennent un nombre fini de valeurs, si le nombre appartient à un des intervalles, il sera représenté de façon exacte dans le type correspondant.

Pour d'autres nombres, trouver une représentation en informatique peut être plus compliqué car:

  • Si le nombre est entier mais n'appartient pas à un des intervalles précédents, il faut pouvoir le représenter avec un nombre fini d'espace mémoire.
  • Certains nombres réels sont irrationnels et ne possède pas un nombre fini de chiffres donc quelque soit le moyen utilisé, mathématiquement il est impossible d'en avoir une
    valeur précise.
  • Sans aller jusqu'au cas des nombres irrationnels, de nombreux nombres rationnels n'ont pas une représentation exacte dans un type informatique parce qu'il faut stocker les chiffres de ce nombre dans un espace mémoire fini et qui est donc limité.
  • Même dans le cas où un nombre décimal a un nombre fini de chiffres, il peut ne pas en exister une représentation exacte car la méthode utilisée pour le stocker dans une variable en C# ne le permet pas.
  • Enfin si ce nombre n'appartient pas à l'intervalle de valeurs d'un des types primitifs en C#, il ne pourra pas être stocké tel quel dans un de ces types.

Pour permettre de stocker le plus grand nombre des nombres réels possibles, on utilise des types à virgule flottante. La virgule flottante permet ainsi de choisir la position de la partie décimale de façon à pouvoir stocker des nombres entiers et des réels non entiers.

Les types à virgule flottante en C# sont:

Alias Type Valeur min Valeur max Chiffres significatifs Taille (bits)
float System.Single -3,4x1038 3,4x1038 7 32
double System.Double -1,7x10308
5x10-324 en valeur absolue
1,7x10308 15 à 16 64
decimal System.Decimal -7,9x1028 ou -(296-1)
10-28 en valeur absolue
7,9x1028 ou 296-1 28 à 29 128

La méthode générale pour stocker les nombres pour ces types est de décomposer un nombre de cette façon:

N = signe x mantisse x 10(exposant)

Par exemple:
87,357 = 87357 x 10-3

La mantisse et l'exposant sont des nombres entiers en sens mathématique. Le signe est 1 ou -1. Suivant les types, la façon de stocker le signe, la mantisse et l'exposant diffère:

  • en fonction de la quantité de mémoire utilisée,
  • la façon de stocker ces nombres en binaire.

Initialisation des types à virgule flottante en C#

Les types à virgule flottante s'initialisent de cette façon:

Type Suffixe Exemple
float f ou F 32.8f
4.246e-15
double d ou D 76d
87.34e121
decimal m ou M 3463.56m

Erreurs possibles

Comparaison

L'erreur la plus facile lorsqu'on utilise des variables avec un type à virgule flottante est de croire qu'il existe systématiquement une comparaison stricte possible entre 2 nombres. Sachant que certains nombres n'ont pas de représentation exacte dans un type à virgule flottante, on peut effectuer des calculs et ne pas avoir des comparaisons strictes possibles à l'issue de ces calculs. L'absence de comporaison stricte est particulièrement vraie pour les types float et double.

Par exemple si on considère la boucle:

for (int i = 0; i < 3000; i++) 
{ 
    if (i == 300) 
    { 
        Console.WriteLine("OK"); 
        break; 
    } 
}

On aura bien le résultat voulu c'est-à-dire que la boucle s'arrête pour i == 300 et qu'on affiche "OK".

Si on exécute:

for (double d = 100; d < 200; d = d + 0.001d) 
{ 
    if (d == 171.714d) 
    { 
        Console.WriteLine("OK"); 
        break; 
    } 
}

On n'aboutira pas à l'affichage de "OK" car les additions introduisent une erreur de calcul. La valeur la plus proche de 171.714 lors de l'exécution de la boucle est 171.714000000342342. L'égalité ne sera donc jamais satisfaite.

Cumul des erreurs de calculs

La 2e erreur possible dans le traitement de types à virgule flottante est le cumul d'erreurs de calculs en dehors des approximations dues aux calculs mathématiques approchées.

Par exemple, en écrivant ce type de boucle, on cumule les erreurs de calculs à chaque multiplication:

double d = 0.00321d; 
while (d < 1e4) 
{ 
    d = d * 10d; 
}

En théorie, cette boucle doit toujours aboutir à un nombre entier. Toutefois, après la première multiplication:

0.00321 x 10

On obtient pas 0.0321 mais 0.032100000000000004.
Au fur et à mesure des multiplications suivantes, les erreurs sont cumulées et on arrivera pas à un résultat entier mais à 32100.000000000007.

Utilisation de chaines de caractères

Dans le cas de conversion entre types à virgule flottante et chaines de caractères, il faut aussi prendre en compte les inexactitudes dans la représentation des nombres.

D'abord on l'a vu dans les exemples précédents, certains nombres n'ont pas de réprésentation exacte:

double d = Double.Parse("0.032100000000000001");

d vaut 0.032100000000000004.

Ensuite, il faut garder en tête que la représentation d'un nombre en chaîne de caractères change en fonction de la culture. Le caractère utilisé pour indiquer la décimal peut être "." ou ",". On peut aussi utiliser "," pour séparer les multiples de 1000: 7,246,876.546.
Il faut donc effectuer les conversions le plus souvent en utilisant la culture invariante:

double d = Double.Parse("2054,43", CultureInfo.InvariantCulture);

Enfin, sachant que les types à virgule flottante comporte un nombre significatif de chiffres, la conversion de chaîne de caractères à type à virgule flottante peut introduire une approximation.

Par exemple:

double d = Double.Parse("0.0000999999998333333343", CultureInfo.InvariantCulture);

d sera égal à 0,00009999999983333333.

Si on fait:

string dAsString = d.ToString();

On aura:
9,99999998333333E-05
Ce qui est différent du nombre d'origine.

Nombres binaires à virgule flottante

Les types binaires à virgule flottante stockent les nombres sous forme de chiffres binaires (0 et 1). Comme indiqué précédemment, le nombre se décompose sous la forme générale:

N = signe x mantisse x 10(exposant)

Stockage des nombres binaires à virgule flottante

La mantisse et l'exposant sont des nombres binaires stockés dans un mot entier comportant 32 bits pour le type float et 64 bits pour le type double:

Type Nombre total de bits Nombre de bits pour le signe Nombre de bits pour la mantisse Nombre de bits pour l'exposant
float 32 1 23 8
double 64 1 52 11

Pour être plus précis, un nombre stocké en double se décompose suivant:

N = (-1)s x 1.m x 2(e - 1023)

Avec:

  • s peut être égale à 0 ou 1.
  • m est une suite de chiffres binaires
  • e est sous sa forme décimal dans la formule toutefois il est bien stocké de façon binaire.

Par exemple:

s m e
1 bit 52 bits 11 bits
0 0011 0011 1101 0011 0000 0111 0000 0000 0000 0000 0000 0000 0000 1000 0000 101
(-1)0 1.0011001111010011000001110000000000000000000000000000 10000000101 en binaire
1029 en décimal
2(1029 - 1023)=26
1 1001100.1111010011000001110000000000000000000000000000 en binaire
76,62657 en décimal

Erreurs possibles

Les types float et double provoquent facilement des erreurs car l'utilisation d'un stockage binaire ne permet pas d'avoir d'avoir une représentation exacte pour beaucoup de nombres. Il arrive fréquemment d'avoir quelques inexactitudes entre l'affectation d'une valeur dans une variable de type double et la valeur effectivement stockée dans la variable.

Comparaison de nombres binaires à virgule flottante

Comme indiqué précédemment, il faut éviter de faire des comporaisons strictes entre binaires à virgule flottante. Il vaut mieux introduire une erreur possible et tenter de calculer la différence entre les nombres et de la comparer avec cette erreur.

Par exemple, au lieu d'écrire une comparaison stricte:

double a = 0.000497d; 
double b = 0.0004971d; 
if (a == b) 
{ 
    // ...
}

On pourrait tenter d'écrire:

double e = 0.00000001; 
if (Math.Abs(a – b) < e) 
{ 
    // ...
}

Le plus difficile est de déterminer la valeur de l'erreur e car elle change suivant l'ordre de grandeur de a et b. Suivant la précision souhaitée, fixer arbitrairement e à "quelques décimales" des double à comparer peut suffire à condition que a et b soient de même ordre de grandeur, par exemple:

if (a > 0 && a < 1) 
{ 
    e = Math.Pow(10, Math.Floor(Math.Log(a)) - 4d); 
}

il n'y a pas de définition absolue de e. Sa valeur varie suivant l'ordre de grandeur de a et b.

Double.Epsilon

Il existe la constante Double.Epsilon qui est la valeur absolue la plus petite entre 2 valeurs de double. Il ne faut pas confondre cette valeur avec les imprécisions de représentation des double. La plupart du temps, Double.Epsilon n'est pas détectable et utiliser cette constante pour tester des égalités entre double aboutira à des comparaisons fausses.

Cas particuliers

Les types float et double peuvent avoir des valeurs particulières qui peuvent mener à des calculs faux. Ces valeurs particulières peuvent résulter d'opérations effectués sur les float ou double.

Dans le cas du type double et du float (System.Single), les valeurs particulières sont:

Nom Explication Valeur Détection
Inférieure à la normale (subnormal) La valeur est très basse et sort de l'intervalle de valeurs du float ou du double . N/A Il n'y a pas de méthode fournie directement par framework.
Infini positif La valeur est très élevée et sort de l'intervalle de valeurs. Double.PositiveInfinity
Single.PositiveInfinity
Double.IsPositiveInfinity()
Single.IsPositiveInfinity()
Infini négatif La valeur sort de l'intervalle de valeurs. Double.NegativeInfinity
Single.NegativeInfinity
Double.IsNegativeInfinity()
Single.IsNegativeInfinity()
NaN "quiet"
(NaN pour Not A Number)
Signifie que le résultat mathématique d'une opération est indéterminé. Double.NaN
Single.NaN
Double.IsNaN()
Single.IsNaN()
NaN "signalling" Signifie que l'opération menée est invalide. Ce type NaN signalle une exception. Double.NaN
Single.NaN
Double.IsNaN()
Single.IsNaN()

Détection d'un binaire à virgule flottante "subnormal":
Sur stackoverflow, une méthode a été proposée qui compare les bits correspondant à l'exposant de façon à vérifier s'ils sont tous à 0:

const long ExponentMask = 0x7FF0000000000000;  // 2047 en décimal 
static bool IsSubnormal(double v) 
{ 
    long bithack = BitConverter.DoubleToInt64Bits(v); 
    if (bithack == 0) return false; 
    return (bithack & ExponentMask ) == 0; 
}
Comparaison Double.NaN == Double.NaN

Le résultat de Double.NaN == Double.NaN est false. Il faut donc plutôt utiliser Double.IsNaN().

Conversion en chaînes de caractères "round-trip"

Il est possible de convertir les double en chaînes de caractères avec un paramètre "round-trip" qui permet de garantir que la valeur numérique convertit en chaîne donnera la même valeur si cette chaîne est parsée pour retrouver la valeur double:

double d1 = 0.82469345678395475; // 17 chiffres  
string s = d1.ToString("R");  
double d2 = double.Parse(s);

Avec l'option "R", d1 est égal à d2.

Dans le cas du double et du float, si une valeur est convertie en string et si la conversion retour de string version double ou float réussit, la valeuir contiendra au maximum 17 chiffres pour les double et 9 chiffres pour les float.

Dans le cas des assemblies compilées en x64 ou en AnyCPU exécutée en 64 bits sur un système 64 bits, la conversion avec l'option "R" peut ne pas réussir, il est préférable d'utiliser l'option "G17".

Plus de détails sur cette option "R" sur MSDN.

Nombre decimal à virgule flottante

Le type decimal en C# stocke un nombre directement sous sa forme décimal de façon à garantir la représentation décimale du nombre et d'éviter les erreurs. Le type decimal est donc plus exacte que les types à virgule flottante binaires. La contrepartie de cette précision est un intervalle de valeurs plus restreint et des performances moins bonnes pour les calculs.

La forme générale pour stocker un nombre est toujours:

N = signe x mantisse x 10(exposant)

Stockage des nombres décimaux à virgule flottante

Pour avoir une forme décimale plus exacte, le type decimal utilise 128 bits répartis de cette façon:

  • 96 bits pour stocker la mantisse. Plus précisemment 3 entiers de 32 bits permettent de stocker cette mantisse, 32 x 3 = 96 bits.
  • 1 bit pour stocker le signe.
  • 5 bits permettent de stocker l'exposant.
  • Les 26 bits restant (1+96+5 = 102 bits) sont à 0.

L'ordre utilisé pour stocker les bits est:

Entier 32 bits Entier 32 bits Entier 32 bits Entier 32 bits
Mantisse Signe Exposant
32 bits 32 bits 32 bits 1 bit Bits 1 à 15 avec une valeur à 0 Bits 16 à 20 Bits 21 à 31 avec une valeur à 0

On peut obtenir les 4 entiers définissant un decimal en faisant Decimal.GetBits().

Le decimal permet d'avoir 28 chiffres significatifs donc même si l'intervalle de valeurs est plus restreint que pour le types binaires à virgule flottante, le decimal permet une plus grande précision.

En effet la mantisse de 96 bits permet de stocker les 28 à 29 chiffres significatifs et l'exposant sur 5 bits permet d'avoir la position de la partie décimale de 0 à 28.
On peut en déduire l'intervalle de valeurs:

  • Le plus petit nombre en valeur absolue est 10-28
  • L'intervalle de valeurs étant de -(296-1) à 296-1.

Erreurs possibles

Le type decimal donne des résultats plus précis que les float et double:

  • les conversions en chaîne de caractères sont plus précises,
  • les comparaisons entre nombres peuvent être strictes et ne nécessitent pas d'introduire une erreur,
  • Il n'existe pas de valeurs particulières comme l'infini ou NaN.

Toutefois, il existe toujours des nombres dont la représentation ne sera pas tout à fait exacte dans un type decimal mais ce cas reste beaucoup plus rare que pour les types float et double. Dans le cas d'une représentation approchée, la valeur est arrondie à la valeur représentable la plus proche. Si 2 valeurs sont aussi proches, la valeur ayant un nombre impair pour le plus petit chiffre significatif est choisie ("banker's rounding").

System.OverflowException

Dans le cas où un nombre est plus petit que la tolérance du type decimal, sa valeur devient 0. Si une opération génère une valeur dépassant l'intervalle de valeurs d'un decimal, une exception du type System.OverflowException est lancée.

Dans le cas des entiers, il est possible d'éviter une exception System.OverflowException lorsqu'une opération dépasse l'intervalle de valeurs de System.Int32, en changeant le comportement du CLR en cas d'un dépassement provoqué par une opération arithmétique ("arithmetic overflow/underflow"). Ce paramêtre se trouve dans:

  1. Propriété du projet (clique droit sur le projet dans Visual Studio)
  2. Onglet "Build"
  3. Cliquer sur "Advanced"
  4. Cocher "Check for arithmetic overflow/underflow".

Si le paramètre est décoché, les dépassements causés par des opérations arithmétiques n'entraîneront pas d'exception.

Ce paramêtre n'a pas d'effets pour le type decimal, le code suivant entraînera toujours une excetion:

decimal first = Decimal.MaxValue;  
decimal second = first + 1;

Les opérations arthmétiques n'entraînent pas d'exceptions pour les types binaires à virgule flottante float et double.

Conclusion

  • Les types float et double ne conviennent pas dans tous les cas pour stocker des nombres même si leur intervalle de valeurs est plus grand que le type decimal.
  • Les float et les double sont moins précis que les decimal mais ils permettent d'effectuer des calculs plus rapidement.
  • Il faut éviter de faire des tests avec des nombres float ou double en effectuant des égalités strictes.
  • La conversion de chaînes de caractères en float ou double peut mener assez souvent à des pertes de précisions.
  • Les variables de type float ou double peuvent contenir des valeurs particulières comme NaN (not a number), une valeur infinie ou un valeur "subnormal" qu'il faut tester avant d'effectuer un calcul.
  • Le type decimal étant plus précis est plus approprié pour les manipulations de nombres dans le domaine financier.
  • Les opérations arithmétiques entraînant des dépassements de l'intervalle du type decimal provoquent des exceptions System.OverflowException. Dans le cas des types float et double, il n'y a pas d'exceptions.

Quelques blogs intéressants

Pour avoir des informations mises à jour toutes les semaines sur la technologie .NET en général:
https://blogs.msdn.microsoft.com/dotnet/

Blogs sur la technologie .NET en général

Software Craftsmanship

Autres

Unicode en 5 min

Chaque langue comporte des caractères particuliers et parfois spécifiques. Pour retranscrire ces caractères dans une chaîne de caractère, l’application générant la chaîne utilise un encodage. Pour que les caractères soient correctement lues, il est souvent nécessaire que l’application lectrice connaisse l’encodage qui est utilisé ou réussisse à le deviner.

L’encodage des caractères dans une chaîne repose sur des conventions dont les caractéristiques dépendent de critères particuliers:

  • Nécessité de traiter la chaîne de caractères en économisant les ressources matérielles de la machine,
  • Nécessité d’économiser la bande passante lors de la transmission de la chaîne de caractères à travers un réseau,
  • Rechercher la compatibilité entre plusieurs encodages.

Les encodages le plus connus sont l’ASCII, l’UTF-8 ou l’UTF-16. Ils présentent des différences qui peuvent mener, très souvent, à un mauvais traitement d’une chaîne de caractères. Le but de ce document est de rappeler les caractéristiques principales de ces encodages.

Editeur héxadécimal de Visual Studio

Pour voir les encodages des fichiers texte en hexadécimal, on peut s’aider de Visual Studio qui possède un éditeur hexadécimal.
Pour ouvrir un fichier avec l’éditeur hexadécimal de Visual Studio:

  1. Open a file
  2. Utiliser la flêche à coté du bouton “Open”
  3. Cliquer sur “Open With”
  4. Sélectionner “Binary Editor”
  5. Cliquer sur OK

1. ASCII

L’ASCII (American Standard Code for Information Interchange) est une norme de codage de caractères incontournable car largement utilisé depuis sa création dans les années 60. Il comporte 128 caractères numérotés de 0 à 127. Chaque numéro correspond à un caractère. Pour encoder une chaîne de caractères, il suffit de traduire chaque caractère dans un code hexadécimal en utilisant le numéro correspondant au caractère.

On peut se reporter à www.asciitable.com pour voir l’encodage des caractères ASCII. Par exemple, la lettre "M" correspond au numéro 77 en décimal et 4D en hexadécimal.

Pour encoder les caractères en binaire, 7 bits (2^7= 128) suffisent, toutefois l’ASCII utilise 8 bits. Le 8e bit permet de définir des caractères numérotés de 128 à 255 mais la norme ASCII laisse la place vacante à d’autres caractères puisqu’elle ne définit des caractères que de 0 à 127.

Exemple d’encodage en ASCII

Par exemple, pour encoder la chaîne "AZERTY", la traduction en hexadécimal en utilisant la table ASCII est:

A Z E R T Y
41 5A 45 52 54 59

Inconvénients de l’ASCII

Si on regarde la table ASCII, on se rends compte que les caractères sont:
Des lettres de la langue anglaise et
Des caractères spéciaux courants.

Il n’y a pas d’autres caractères spécifiques à d’autres langues: é, è, à, ç, etc…

Pages de code

Sachant que l’ASCII utilise un encodage sur 8 bits et ne définit que les caractères de 0 à 127, il reste une place vacante pour les caractères de 128 à 255. Pour définir les caractères spécifiques à chaque langue, les pays ont utilisés cet espace pour les encoder. Chaque pays a donc défini un standard utilisant ASCII pour les premiers caractères et un encodage spécifique pour les caractères suivants. Ces standards s’appelent “pages de code” (code pages).

L’inconvénient majeur des pages de code est qu’il en existe plusieurs et qu’il est nécessaire d’utiliser exactement le même entre l’application générant la chaîne de caractères et l’application la lisant.

Par exemple, la page de code 1252 correspond à un jeu de caractères de l’alphabet latin utilisé par Windows en anglais et pour les principales langues d’Europe de l’Ouest y compris le français.

On peut se reporter à fr.wikipedia.org/wiki/Windows-1252 pour avoir le détail de la table de caractères pour la page de code 1252 ou Windows-1252.

2. Unicode

Comme indiqué plus haut, les gros problèmes des pages de code est qu’il en existe beaucoup et qu’il est compliqué de déchiffrer une chaîne de caractères sans connaitre la page de code avec laquelle elle a été codée.

Unicode vise à normaliser des jeux de caractères pour qu’un seul standard puisse être utilisé pour encoder la grande majorité des caractères possibles. Comme pour ASCII, Unicode définit un identifiant pour un caractère unique. La correspondance entre le caractère et sa valeur numérique est appelée “point de code” (code point).

Au standard Unicode correspond plusieurs encodages de façon à convertir le point de code Unicode en octets. Les encodages les plus connus sont UTF-8, UCS-2 et UTF-16.

Ainsi si on prends le point de code U+23374:

  • En UTF-8: l’encodage correspondant est F0A3 8DB4.
  • En UTF-16: l’encodage correspondant est D84C DF75.

UCS-2

Pour définir plus de caractères que l’ASCII, il faut augmenter l’intervalle de la valeur numérique permettant d’identifier un caractère. L’ASCII était définit sur 8 bits, ainsi UCS-2 est définit sur 16 bits soit 2 octets. Il permet d’identifier 65536 caractères ce qui permet d’adresser un grand nombre de langues avec un seul standard. Les caractères sont numérotés de 0 à 65535 ou 0x0000 à 0xFFFF en hexadécimal.

On peut se reporter à www.columbia.edu/kermit/ucs2.html pour avoir le détail de la table de caractères de l’UCS-2.

Avec un mot de 16 bits, l’UCS-2 permet de coder les caractères dont les points de code se trouvent dans le plan multilingue de base (Basic Multilingual Plan BMP). Toutefois il ne permet pas d’encoder les caractères qui ne se trouvent pas dans le plan multilingue de base. D’où l’apparition du standard UTF-16.

Exemple d’encodage en UCS-2

Par exemple, pour encoder la chaîne "AZERTY éèà" en utilisant UCS-2, on aura:

A Z E R T Y (space) é è à
0041 005A 0045 0052 0054 0059 0020 00E9 00E8 00E0

Ainsi:

  • Les caractères "A", "Z", "E", "R", "T" et "Y" font partie de l’encodage ASCII donc la valeur hexadécimal du point de code est la même que pour celle en ASCII.
  • Idem pour le caractère espace.
  • Les caractères "é", "è" et "à" ne sont pas dans la table ASCII, le point de code utilisé est particulier.
UCS-2 et UTF-16

Souvent UCS-2 et UTF-16 sont confondus, pourtant les 2 encodages sont différents: UCS-2 utilisent strictement 2 octets alors que l’UTF-16 permet d’utiliser 4 octets.

L’inconvénient de coder les caractères sur plus de 1 octet est qu’il faut convenir de l’ordre des octets: quel sera l’octet fort ? Le premier octet ou le 2e octet ?

“Big endian” ou “Little endian”

Comme indiqué plus haut, un inconvénient d’utiliser plusieurs octets pour identifier un caractère est qu’il faut s’entendre sur l’ordre des octets. En effet suivant les architectures matérielles, les processeurs utilisent des conventions différentes concernant le poids des octets.
UCS-2 utilise 2 octets donc il faut pouvoir identifier l’octet fort et l’octet faible:

  • Big endian (BE): l’octet de poids le plus fort est enregistré à l’adresse mémoire la plus petite, le nombre 0xA7E8 est donc rangé:
    0 1
    A7 E8
  • Little endian (LE): l’octet de poids le plus faible est enregistré à l’adresse mémoire la plus petite, le nombre 0xA7E8 est donc rangé:
    0 1
    E8 A7

Pour savoir quelle convention est choisie, il faut convenir d’une façon de l’indiquer dans la chaîne de caractères.

Exemple d’encodage en UCS-2

Par exemple, pour encoder la chaîne "AZERTY éèà" en utilisant UCS-2BE, on aura:

A Z E R T Y (space) é è à
0041 005A 0045 0052 0054 0059 0020 00E9 00E8 00E0

En utilisant UCS-2LE, on aura:

A Z E R T Y (space) é è à
4100 5A00 4500 5200 5400 5900 2000 E900 E800 E000

BOM (byte order mark)

Pour les encodages de plus d’un octet, il est possible de convenir de la façon d’identifier les octets de poids fort et les octets de poids faible.

Le “byte order mark” (BOM) permet d’indiquer la convention choisie. Utiliser un BOM consiste à placer un point de code particulier au début de la chaîne de caractère. La lecture de ce point de code permettra à l’application lectrice, de savoir comment utiliser les points de code suivants correspondant aux caractères à décoder.
Par convention pour l’USC-2:

  • Le point de code U+FEFF correspondant à la valeur 0xFEFF permet d’indiquer que la convention choisie est “big endian”. Il n’y a donc pas de changements à faire sur l’ordre des octets.
  • Le point de code U+FFFE correspondant à la valeur 0xFFFE permet d’indiquer que la convention choisie est “little endian”. Il faut donc inverser l’ordre des octets pour lire la valeur du point de code.

FE FF et FF FE peuvent être confondu avec des caractères toutefois, s’ils sont présents dans l’entête d’une chaîne de caractères, on peut facilement supposer qu’il s’agit d’un BOM.

Exemple d’encodage du BOM

Par exemple, pour encoder la chaîne "AZERTY éèà" en utilisant UCS-2BE avec un BOM, on aura:

A Z E R T Y (space) é è à
FE FF 0041 005A 0045 0052 0054 0059 0020 00E9 00E8 00E0

En utilisant UCS-2LE avec un BOM, on aura:

A Z E R T Y (space) é è à
FF FE 4100 5A00 4500 5200 5400 5900 2000 E900 E800 E000

UTF-16

L’apport d’UTF-16 par rapport à UCS-2 est de permettre de prendre en compte les caractères dont les points de code ne sont pas dans le BMP (Basic Multilingual Plan). Ainsi les 65536 premiers points de code U+0000 à U+FFFF sont les mêmes que pour UCS-2. A partir de U+10000, les points de code sont spécifiques à UTF-16.

Pour permettre d’identifier des caractères dont le point de code a une valeur supérieure à 0xFFFF codée sur 16 bits, il faut utiliser un encodage sur davantages de bits. Ainsi, UTF-16 utilise 20 bits répartis sur 2 mots de 16 bits soit 2 octets. Ces 2 mots sont appelés “surrogate pair”. Chaque mot seul identifie un caractère invalide mais s’ils sont utilisés ensemble, il désigne un caractère particulier.

Ainsi le point de code du caractère sera codé:

  • Sur 2 octets: si la valeur numérique du point de code du caractère est inférieure ou égale à 0xFFFF.
  • Sur 4 octets: si la valeur numérique du point de code est strictement supérieure à 0xFFFF.
Dénomination Unicode

UTF-16 est utilisé par Windows et lorsqu’on parle d’Unicode, on fait généralement allusion à UTF-16.

La même convention “Big endian” ou “Little endian” est utilisée pour UTF-16 car, comme pour UCS-2, le point de code utilise plus d’un octet.

Les 20 bits sont répartis sur les différents octets de cette façon pour UTF-16BE:

  • Sur 2 octets:
    xxxx xxyy yyyy yyyy

    16 bits répartis sur 2 octets comme pour UCS-2BE.

  • Sur 4 octets:
    1101 10ww wwxx xxxx 1101 11yy yyyy yyyy

    20 bits répartis sur 4 octets.

Dans le cas d’UTF-16LE, l’ordre des octets est inversé à l’intérieur des pairs des “surrogate pair”:

  • Sur 2 octets:
    yyyy yyyy xxxx xxyy

    16 bits répartis sur 2 octets comme pour UCS-2LE.

  • Sur 4 octets:
    wwxx xxxx 1101 10ww yyyy yyyy 1101 11yy

    20 bits répartis sur 4 octets.

Comme on peut le voir, la première paire est identifiée avec 110110. La 2e paire est identifiée avec 110111. L’intérêt d’utiliser des bits dont la valeur est fixe pour désigner les paires est qu’il rend possible de faire la différence entre un caractère codé sur 2 octets et un autre caractères codé sur 4 octets. D’autre part, une paire seule ne correspond pas au point de code d’un caractère.

Lorsque le point de code utilise 4 octets, pour obtenir l’encodage correspondant il faut:

  • Soustraire 0x10000 à la valeur numérique du points de code,
  • Séparer les 20 bits en 2 mots de 10 bits,
  • Ajouter 0xD800 (en binaire 1101 1000 0000 0000) à la première paire,
  • Ajouter 0xDC00 (en binaire 1101 1100 0000 0000) à la 2e paire.

Par exemple, pour le caractère U+23374:

  • Si on soustrait 0x10000 à 0x23374, on obtient 0x13374 ce qui correspond à la valeur binaire 0001 0011 0011 0111 0100
  • Si on sépare les 20 bits en 2 mots de 10 bits: 0001 0011 00 puis 11 0111 0100.
  • Si on ajoute 0xD800 à 0x004C (en binaire 0001001100) on obtient 0xD84C.
  • Si on ajoute 0xDC00 à 0x0374 (en binaire 1101110100) on obtient 0xDF74.

L’encodage est donc en UTF-16BE: D8 4C DF 75. Ainsi D84C ne correspond pas à un caractère et de même pour DF75.

Exemple d’encodage en UTF-16

Si on prends l’exemple de la chaîne "AZERTY éèà" suivi du caractère U+23374, l’encodage UTF-16BE sera:

A Z E R T Y (space) é è à
U+0041 U+005A U+0045 U+0052 U+0054 U+0059 U+0020 U+00E9 U+00E8 U+00E0 U+23374
FE FF 0041 005A 0045 0052 0054 0059 0020 00E9 00E8 00E0 D84C DF75

Dans le cas de l’encodage UTF-16LE:

A Z E R T Y (space) é è à
U+0041 U+005A U+0045 U+0052 U+0054 U+0059 U+0020 U+00E9 U+00E8 U+00E0 U+23374
FF FE 4100 5A00 4500 5200 5400 5900 2000 E900 E800 E000 4CD8 75DF

On peut se reporter à www.fileformat.info/info/charset/UTF-16/list.htm pour avoir l’ensemble des caractères de UTF-16.

Inconvénient majeur d’UTF-16

UTF-16 utilise beaucoup d’espace car pour la plupart des caractères correspondant aux langues occidentales, on est obligé de préciser "00" (par exemple pour "é" l’encodage est 00E9) ce qui fait perdre beaucoup d’espace. Pour des transmissions de chaîne de caractères à travers un réseau, ce gaspillage d’espace peut causer des performances moins bonnes que des encodages plus simples.

UTF-16 peut causer une incompatibilité avec des applications utilisant l’ASCII à cause de la présence des "0x00".

UTF-8

Comme indiqué précédemment, le gros intérêt d’UTF-8 est d’éviter le gaspillage d’espace causé par l’utilisation d’UTF-16. Il est utilisé en majorité pour l’encodage de fichiers HTML ou XML.
L’autre avantage d’UTF-8 est d’apporter une compatibilité avec l’encodage ASCII contrairement à UTF-16.

UTF-8 encode les points de code dans des mots de 8 bits:

  • Les points de code de 0x0000 à 0x007F correspondant à des caractères ASCII sont codés sur 8 bits donc 1 octet suffit.
  • Les points de code supérieurs à 0x0080 correspondent à des caractères non-ASCII, ils sont codés sur 2, 3 ou 4 octets.

Pour les points de code nécessitant plusieurs octets, les mots commençant par:

  • "10" en binaire indique que les bits suivant sont des bits utilisés pour encoder le point de code.
  • "11" en binaire indique que le mot doit être utilisé avec les mots suivants pour identifier un caractère.

Ainsi:

  • Les caractères ASCII ont un point de code codé sur 1 octet du type:
    0xxx xxxx

    7 bits sont utilisés comme pour l’ASCII.

  • Les caractères non-ASCII ayant un point de code codé sur 2 octets sont du type:
    110x xxxx 10xx xxxx
  • Les caractères non-ASCII ayant un point de code codé sur 3 octets sont du type:
    1110 xxxx 110x xxxx 10xx xxxx
  • Les caractères non-ASCII ayant un point de code codé sur 4 octets sont du type:
    1111 0xxx 1110 xxxx 110x xxxx 10xx xxxx

On peut se reporter à www.utf8-chartable.de/unicode-utf8-table.pl pour avoir l’ensemble des caractères UTF-8.

Utilisation d’un BOM

Il n’y a pas de problèmes d’ordre des octets avec UTF-8, les octets sont toujours utilisés dans le même ordre. Il n’est pas nécessaire d’utiliser un BOM, toutefois très souvent les chaînes de caractères ou les fichiers codés en UTF-8 sont précédés d’un BOM dont la valeur est "EF BB BF".

Le plus souvent, ce BOM permet d’indiquer simplement qu’il s’agit d’un fichier UTF-8.

Exemple d’encodage en UTF-8

Si on prends l’exemple de la chaîne “AZERTY éèà” suivi du caractère U+23374, l’encodage UTF-8 sans BOM sera:

A Z E R T Y (space) é è à
U+0041 U+005A U+0045 U+0052 U+0054 U+0059 U+0020 U+00E9 U+00E8 U+00E0 U+23374
41 5A 45 52 54 59 20 C3A9 C3A8 C3A0 F0A3 8DB4

L’encodage en UTF-8 avec BOM sera:

A Z E R T Y (space) é è à
U+0041 U+005A U+0045 U+0052 U+0054 U+0059 U+0020 U+00E9 U+00E8 U+00E0 U+23374
EF BB BF 41 5A 45 52 54 59 20 C3A9 C3A8 C3A0 F0A3 8DB4

Comme on peut le voir, les caractères ASCII sont codés directement en ASCII sans octet nul 0x00. Un programme qui ne lit que l’ASCII peut lire directement les caractères. Seuls les caractères faisant partie de l’ASCII seront décodés.

Inconvénients de l’UTF-8

Le défaut d’UTF-8 est que les points de code ayant une valeur numérique faible sur peu d’octets sont réservés aux caractères des langues occidentales. Les caractères spécifiques des langues autres que les langues occidentales sont codés sur plusieurs octets. Plus des octets sont nécessaires pour encoder des caractères et plus les transmissions par réseau prennent du temps ou plus les fichiers sont lourds.
Ainsi UTF-8 est plus performant qu’UTF-16 pour les transmissions de caractères appartenant à des langues occidentales mais cet avantage diminue si il faut encoder des caractères appartenant à d’autres langues.

Le 2e défaut d’UTF-8 est qu’il nécessite plus de ressources matériels pour décoder les caractères. Pour des transmissions de chaînes de caractères par le réseau, on tente d’économise la bande passante en privilégiant un encodage moins gourmant en espace au détriment d’un traitement un peu plus lourd. C’est la raison pour laquelle UTF-8 est l’encodage le plus utlisé sur internet. En revanche, si on veut privilégier les ressources matérielles et que la taille des chaînes est moins critique comme dans un système d’exploitation, on utilisera plutôt UTF-16 comme dans Windows par exemple.

Platform invoke en 5 min

P/Invoke (i.e. Platform Invoke) est une technique qui permet d’appeler du code non managé à partir de code managé. L’utilisation de P/Invoke convient lorsque:

  • du code non managé existe, qu’il est trop couteux de le migrer,
  • si on doit utiliser une API non managé et qu’il n’existe pas de version .NET,
  • si on doit faire appel à des fonctions de l’API Win32.

Cette méthode convient plus particulièrement lorsque le code non managé ne change pas beaucoup ou que l’interface entre le code managé et le code non managé est assez figé. Dans le cas contraire, il est préférable d’utiliser d’autres méthodes plus fléxible et moins sensibles à des erreurs.

A part P/Invoke, d’autres méthodes existent pour appeler du code non managé à partir de code managé:

  • COM interop: cette technique convient si on doit faire appel à des composants COM.
  • C++ interop: il s’agit d’une fonctionnalité spécifique au C++ qui est approprié pour appeler du code non managé particulièrement complexe.
  • C++/CLI: il s’agit d’un langage C++ modifié (CLI pour Common Language Infrastructure). Ce langage permet, par construction, d’appeler du code non managé à partir de code C++. Le grand avantage du C++/CLI par rapport au C++ interop est qu’il est adapté pour manipuler des objets managés ou non managés dans le même langage. De même, les appels managés sont faciles à effectuer vers le code C++/CLI puis vers le code non managé.

Le gros inconvénient de P/Invoke est qu’il faut adapter le code pour utiliser cette technique. Ensuite, les interfaces entre le code managé et le code non managé doivent être définis et implémentées avec soin de façon à éviter les erreurs mais aussi de mauvaises performances lors de la “conversion” des objets managés en objets non managés et inversement.

Lorsqu’on utilise P/Invoke pour appeler du code non managé, il faut être rigoureux dans le choix du type des objets et dans la façon dont ils sont passés en paramètre. Pour effectuer ces choix, il faut avoir en tête le fonctionnement du code suivant le type d’objet et certains mécanisme comme celui du Garbage Collector ou les conventions d’appels de fonctions.

1. Préambule

En préambule, il convient d’effectuer quelques rappels concernant les objets qui sont utilisés en .NET et la façon dont ces objets sont manipulés.

Pointeur et référence

D’une façon générale, un pointeur et une référence possèdent tous les deux, l’adresse mémoire d’un objet. Toutefois ils n’exposent pas les mêmes informations et il n’est pas possible d’effectuer le même type d’opération sur les deux.

Un pointeur est une variable contenant une adresse mémoire. Cette adresse peut pointer vers n’importe quel objet en mémoire, y compris vers des objets dont le type est différent. Le pointeur n’a pas connaissance du type de l’objet vers lequel il pointe. En C#, dans un contexte purement managé, on ne manipule jamais directement des pointeurs.

De même que les pointeurs, les références contiennent une adresse mémoire, toutefois cette adresse n’est pas directement accessible par la code. D’autre part, la référence a une connaissance du type d’objet vers lequel elle pointe. Par construction, il n’est pas possible de modifier le type d’une référence. En C#, en dehors des objets de type valeur, on manipule des références.

Objets de type valeur

Les objets de type valeur correspondent au struct et au enum. La plupart des types primitifs sont des struct et donc sont des objets de type valeur:

  • Les types intégraux: sbyte (signed byte), byte, char, short, ushort (unsigned short), int, uint (unsigned int), long et ulong (unsigned long).
  • Les types à virgule flottante: float et double
  • Le booléen bool.

Il faut noter que ces types primitifs sont des alias qui sont mappés vers des types dans le namespace System. Par exemple, int est un alias pour System.Int32.

Il faut garder en tête que System.String n’est pas un objet de type valeur.

Passage par valeur

Par défaut en C#, les objets de type valeur sont passés par valeur c’est-à-dire que lorsqu’on utilise des variables qui sont des objets de type valeur et qu’ils sont passés en paramètre de fonction, c’est la valeur de ces objets qui passée d’une fonction à l’autre. Ainsi lors du passage de l’objet en argument, c’est sa valeur qui est copiée et l’objet est dupliqué.

Concrêtement, si on considère la fonction:

private int GetAge(int birthYear, DateTime date) 
{ 
  // ... 
}

Et si on effectue l’appel:

int currentBirthYear =  1985; 
DateTime currentDate = DateTime.Now; 
 
var age = GetAge(currentBirthYear, currentDate);

Si on modifie la valeur de birthYear ou de date à l’intérieur de la fonction GetAge(), les valeurs de currentBirthYear et currentDate à l’extérieur de la fonction ne sont pas modifiées.

Passage par référence

En C#, il est possible de passer des objets de type valeur par référence. Dans ce cas, on ne copie pas la valeur de l’objet mais c’est la référence de l’objet qui est passée en argument. Ainsi, c’est le pointeur vers la valeur en mémoire qui est dupliqué lors du passage en argument. Si on modifie la valeur d’un objet de type valeur passé en référence dans le corps d’une fonction, sa valeur sera aussi modifiée à l’extérieur de la fonction.

Par passer un objet de type valeur par référence, on peut utiliser le mot clé ref ou out:

  • ref: il est obligatoire d’initialiser la variable à l’extérieur de la fonction. La fonction n’est pas obligée d’initialiser ou de modifier la valeur de l’argument précédé de ref.
  • out: il n’est pas obligatoire d’initialiser la variable avant d’appeler la fonction. En revanche, la fonction doit au moins affecter une valeur à l’argument précédé de out.

Par exemple, si on considère la méthode:

private void UpdateAge(int birthYear, ref int age, out DateTime date) 
{ 
  currentDate = DateTime.Now; 
  age = date.Year - birthYear; 
}

Si on effectue l’appel:

int currentBirthYear = 1985; 
int age = 69; 
 
DateTime currentDate; 
UpdateAge(currentBirthYear, ref age, out currentDate);

Les valeurs de currentBirthYear et currentDate seront modifiées par UpdateAge().

Objets de type référence

Les objets de type référence sont les classes, les interfaces et les delegates. Ces objets dérivent de System.Object. Par suite, object et string sont des objets de type référence.
Lorsqu’on manipule des objets de type référence, en C# on manipule des références d’objet. 2 variables peuvent contenir la même référence et une modification sur une variable aura pour effet de modifier directement l’objet vers lequel pointe la référence.

Passage par valeur

Par défaut en C#, les objets de type référence sont passés par valeur c’est-à-dire que c’est la valeur du pointeur vers le type qui est passée en paramètre de la fonction. Si on modifie la référence dans le corps de la fonction, il n’y aura pas d’incidence sur la référence à l’extérieur de la fonction. En revanche, on peut modifier directement l’objet pointé par la référence.

Ainsi, si on considère la classe:

class Person  
{ 
  private string Name { get; set; } 
  private int Age { get; set; } 
} 

Si on considère la méthode:

private void ModifyPerson(Person person) 
{ 
  person.Age = 45; 
}

L’argument person est passé par valeur, la valeur du pointeur vers le type est dupliqué mais la référence pointe vers le même objet en mémoire. Donc la modification de person sera visible à l’extérieur de la fonction.

Si on exécute le code suivant:

private void ModifyNewPerson(Person person) 
{ 
  person = new Person { Age = 45, Name = "John" }; 
  person.Age = 54; 
}

La variable person contient une référence mais elle est réinitialisée par une nouvelle affectation et donc une nouvelle référence. Cette nouvelle référence possède un pointeur vers une nouvel objet en mémoire donc la modification sur ce nouvel objet ne modifie l’objet définit à l’extérieur de la fonction. La modification est donc locale à la fonction.

Passage par référence

On peut passer des objets de type référence par référence. Dans ce cas, on passe un pointeur vers un pointeur qui pointe vers un type. Si on modifie la référence à l’intérieur de la fonction, le premier pointeur ne sera pas modifié, en revanche, le 2e pointeur pointera vers un objet différent. Ainsi sachant que le premier pointeur n’est pas modifié, une nouvelle affectation dans le corps de la fonction sera visible à l’extérieur de la fonction.

Le passage par référence d’un objet de type référence se fait en utilisant les mot-clés ref ou out avec les mêmes conditions que pour les objets de type valeur.

Ainsi, si on considère la méthode:

private void ModifyNewPerson(ref Person person) 
{ 
  person = new Person { Age = 45, Name = "John" }; 
}

Et si on exécute le code:

var person = new Person { Age = 15, Name = "Albert" }; 
ModifyNewPerson(ref person);

La valeur de person.age sera 45 et person.Name sera “Albert”.

“System.String” est un objet immutable

Toute modification d’une string entraîne la création d’une nouvelle string. Pour que la modification d’une string soit visible à l’extérieur du corps d’une fonction, il faut que l’argument soit précédé de ref ou out.

Garbage collector

La gestion des objets en mémoire est très différente entre le C++ et le C#. En C++, on manipule des pointeurs qu’il faut penser à libérer ou des smart pointers qui passent par un référencement du nombre d’utilisations de l’objet. Si le nombre d’utilisation atteint zéro, le smart pointer est libéré.

Marquage des objets

En C#, les objets de type référence sont alloués sur un ou plusieurs tas (i.e. heap). Lorsqu’il est nécessaire d’allouer un espace mémoire pour effectuer un traitement, le garbage collector s’exécute pour vérifier si tous les objets dans le tas sont toujours utilisés. Le garbage collector va donc étudier les dépendances entre les objets pour fournir un graphe d’objets à partir duquel il pourra déterminer quels sont les objets qui ne sont plus atteignables. Ces objets peuvent être supprimés du tas car ils ne sont plus utilisés.

Si ces objets ne possèdent pas de finalizers, ils sont supprimés. Dans le cas contraire, pour éviter que le garbage collector ne prenne trop de ressources, ces objets sont, dans un premier temps, marqués comme étant à supprimer. Ils seront supprimés, lors d’une seconde exécution du garbage collector, après avoir exécuté les finalizers

Compactage

Quand les objets sont supprimés de la mémoire, ils laissent des espaces vides qui rendent la mémoire fragmentée. Une mémoire trop fragmentée réduit les performances quand de nouvelles affectations sont effectuées. Une étape du garbage collector consiste à défragmenter la mémoire et à compacter les objets restants dans une zone de la mémoire de façon à favoriser les nouvelles allocations.

Les déplacements d’objets en mémoire peuvent être couteux en ressource, plus les objets sont lourds et plus ce déplacement prendra du temps. D’autant qu’il n’est pas tout le temps nécessaire de déplacer des objets de grande taille si ceux-ci prennent une allocation continue en mémoire et si leurs dépendances ne changent pas durant l’exécution du processus. La gestion des objets de grande taille est donc différente de celle des objets de petite taille ou ceux dont les dépendances changent beaucoup durant l’exécution.

“Large object heap” et générations

Ainsi, d’une part, les objets dont la taille est supérieur à 85 MO sont rangés dans un tas pour les objets de grande taille (i.e. large object heap). D’autre part, les objets sont marqués par une génération de 0 à 2. Les nouveaux objets font partie de la génération 0 et suivant les différentes itérations du garbage collector et si les dépendances des objets ne changent pas, ils sont promus vers la génération 1. Si ces objets de génération 1 sont assez statiques, ils peuvent être promus à la génération 2.

Tous ces mécanismes nécessitent de déplacer des objets en mémoire et donc de modifier leur adresse dans les références. Ce type de modification des adresses mémoire est en opposition aux mécanismes utilisés dans du code natif. En effet, les objets en code natif ne sont jamais déplacés. Il faut donc avoir en tête la différence de traitement entre le code managé et le code non managé puisque certains objets sont partagés.

Convention d’appels

Il s’agit d’une convention sur la façon dont les fonctions sont appelées. Ces conventions permettent de préciser comment les arguments sont passés aux fonctions, comment les résultats sont retournés ou comment les fonctions sont nommées après compilation.

Par défaut, les programmes C et C++ utilisent la convention __cdecl. L’option de compilation est /Gd. Avec cette convention, il est possible d’appeler des fonctions avec un nombre d’arguments différent mais les fichiers compilés sont plus volumineux.

Il existe une convention __stdcall qui est la convention par défaut de l’API Win32. On utilise l’option de compilation /Gz pour cette convention. Les fonctions avec un nombre variable d’arguments ne sont pas permis, toutefois les fichies compilés sont moins volumineux par rapport à la convention précédente.

Du code C et C++ peut aussi utiliser la convention __fastcall avec l’option de compilation /Gr. Cette convention favorise l’utilisation de registres plutôt que celle du tas lors des appels de fonctions car l’accès aux registres est plus rapide que pour le tas.

Unicode/ANSI

A l’origine, les caractères étaient codés en utilisant l’ensemble de caractères ASCII. Cet ensemble comporte 128 caractères qui correspondent aux caractères nécessaires pour un texte en anglais plus des caractères de ponctuation. Ces caractères sont numérotés de 0 à 127. Les caractères spécifiques aux autres langues sont numérotés de 128 à 255. Sachant que les 127 caractères suivants ne suffisent pas pour toutes les langues, chaque langue avait sa variante de ASCII et les caractères 128 à 255 n’était pas standardisés. Les différents mappings s’appellent “code page” (code page russe, code page allemand etc…).

Pour unifier tous ces mappings a émergé un autre standard: Unicode. Unicode est une version plus “grande” que l’ASCII qui affecte à chaque caractère un identifiant unique. Unicode est codé sur plusieurs octets pour permettre d’identifier plus de caractères. La correspondance entre l’identifiant et le caractère est appelé “code point”. Les 65535 code points identifiés avec les 2 premiers octets correspondent à des caractères communs dans la plupart des langues occidentales. Les autres caractères utilisant un identifiant sur plus de 2 octets sont des caractères pour les autres langues.

Au standard Unicode correspond plusieurs encodage qui convertissent le code point Unicode en octets. Les encodages les plus connus sont UTF-8 et UTF-16. UTF-16 permet de coder les caractères sur 4 octets. Le gros inconvénient de cet encodage est que la plupart de temps, on utilise des caractères codés sur 2 octets, ce qui implique que les 2 autres octets sont nuls d’où une perte de 2 octets. Pour palier ce problème, on privilégie d’utiliser UTF-8 qui encode les caractères Unicode sur un seul octet quand c’est possible. D’autre part, UTF-8 est compatible avec l’ASCII historique.

En C#, les chaines de caractères sont encodés en utilisant Unicode UTF-16. Par défaut, elles sont converties et passées au code non managé suivant le standard ANSI c’est-à-dire ASCII.

2. Platform invoke avec “DllImport”

Les appels au code non managé sont possibles grâce à l’attribut DllImport. C’est cet attribut qui va permettre d’importer les fonctions C ou C++ dans le code managé.

Dans un premier temps, l’exemple suivant permet d’illustrer simplement la façon dont on fait un appel P/Invoke. Ensuite, on va expliciter les mécanismes qui interviennent lorsqu’on fait ce type d’appel. Enfin dans la dernière partie, on rentrera davantages dans les détails sur la façon de traiter des cas plus atypiques.

Pour illustrer simplement un appel P/Invoke, on se propose de déclarer la fonction C++ suivante:
Dans le fichier “.h”:

extern "C" { 
    __declspec(dllexport) int __cdecl AddIntegers(int a, int b); 
}

Dans le fichier “.cpp”:

extern int __cdecl AddIntegers(int a, int b)  
{ 
    return a + b; 
}

Si le code C++ est compilé dans le fichier DLL TestPInvoke.dll, l’utilisation de DllImport en C# peut se faire de cette façon:

using System.Runtime.InteropServices; 
 
public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll", CallingConvention = CallingConvention.Cdecl)] 
    public static extern int AddIntegers(int a, int b); 
}

L’appel C# se fait en exécutant:

public class CallCppCode 
{ 
    public int AddIntegersInCPP(int a, int b) 
    { 
        return UnsafeNativeMethods.AddIntegers(a, b); 
    } 
}

Précisions sur le code C++

Dans le code C++:

  • __declspec(dllexport): permet d’indiquer que le linker doit exporter les symboles vers une DLL. Cette déclaration permet d’appeler la fonction à l’extérieur de la DLL.
  • __cdecl: permet de définir la convention d’appel de la fonction. Cette déclaration n’est pas indispensable puisque par défaut le code C ou C++ est compilé en utilisant cette convention.

On peut préciser une autre convention d’appels dans les paramètres du projet C++:
C/C++ ⇒ Advanced puis modifier “Calling Convention” en sélectionnant la valeur __stdcall. Ce paramètre ajoute l’option de compilation /Gz.

On peut aussi changer la déclaration de la fonction:

extern "C" { 
    __declspec(dllexport) int __stdcall AddIntegers(int a, int b); 
}
Convention d’appels par défaut de DllImport

Utiliser la convention d’appels __stdcall permet d’éviter de préciser la convention lors de l’appel C# puisque la convention par défaut de DllImport est __stdcall.
Si la convention d’appels n’est pas la même entre l’appel par DllImport et la DLL native, une erreur du type "PInvokeStackImbalance was detected" survient lors de l’appel.

DependencyWalker

il est possible de voir la méthode définie en utilisant DependencyWalker. La méthode doit donc apparaître sans “décoration” C++ pour être appelable avec P/Invoke.

Précisions sur le code C#

Concernant le code C#, DllImport permet de préciser la nom du fichier DLL où se trouve la fonction à importer “TestPInvoke.dll”.
Dans cet exemple, le nom de la fonction AddIntegers est le même que celui définit en C ou C++. Toutefois, il est possible de définir un nom de méthode spécifique au code C# en utilisant le paramètre Entrypoint:

[DllImport("TestPInvoke.dll",  
   CallingConvention = CallingConvention.Cdecl, 
   EntryPoint="AddIntegers")] 
public static extern int AddIntegersFromCppDll(int a, int b);

L’attribut DllImport s’utilise avec les modificateurs static et extern:

  • extern: permet de déclarer une méthode qui est implémentée extérieurement au code C#.
  • static: les déclarations utilisant l’attribut DllImport sont définies de façon statique.

Le paramètre CallingConvention permet de préciser la convention d’appels et de choisir la même convention que le fichier DLL.

Comme indiqué précédemment, si on choisit la convention d’appels __stdcall pour la compilation du fichier DLL, il n’est pas nécessaire d’utiliser le paramètre CallingConvention de DllImport puisque la convention d’appels par défaut de DllImport est __stdcall.

Plus de détails concernant l’attribut DllImport sur MSDN.

Précisions sur les projets

La DLL compilé par le projet C++ doit se trouver dans le même répertoire que l’assembly .NET qui fera l’appel P/Invoke. Si la DLL native se trouve pas dans le même répertoire, une exception DllNotFoundException sera lancée lors de l’exécution.

Dans les paramètres du projet C++, on peut préciser les paramètres suivants:

  • Configuration Type: Dynamic Library (.dll)
  • Use of MFC: Use Standard Windows Library
  • Use of ATL: Not Using ATL
  • Common Language Runtime Support: No Common Language Runtime Support
  • Whole Program Optimization: Use Link Time Code Generation

3. Marshalling

Dans cette partie, on expliquera plus en détails les mécanismes qui entrent en jeu lorsqu’on fait un appel P/Invoke.

Ainsi dans le cas d’un appel P/Invoke provenant de code managé vers du code non managé, il existe des différences existent entre les deux codes dans les mécanismes de gestion de la mémoire ou la façon d’effectuer les appels de fonctions. Il peut exister, aussi, des différences quant à la représentation des objets en mémoire. Cette différence peut nécessiter d’effectuer une conversion des objets entre le code managé et le code non managé.

Cette conversion est effectuée par le CLR (pour Common Language Runtime) lors des appels au code non managé. Il est effectué pour faire passer des arguments aux fonctions ou pour récupérer le résultat. Ce mécanisme de conversion est le marshalling.

Le plus souvent, le marshalling est effectué implicitement pour le CLR pendant l’appel interop et ne nécessite pas de précisions supplémentaire. Toutefois lorsque la différence est trop importante, il peut être nécessaire de préciser certains attributs pour indiquer comment effectuer le marshalling.

Quand on parle de marshalling dans Platform invoke, on désigne la conversion des objets managés vers des objets non managés dont le type est compatible avec le langage C. Il est possible d’appeler des DLL natives codées en C++ toutefois les conversions d’objets se font sur des types compatibles avec le C.

Règle principale du “marshalling”

Le marshalling paraît être un processus complexe pour lequel il faut suivre des règles précises. D’une façon générale, le marshaller effectue toutes les conversions sans qu’il soit nécessaire d’utiliser d’attributs ou de faire des indications particulières. La plupart du temps, le comportement par défaut est le comportement le plus approprié.

La principale règle qu’il faut suivre dans tous les cas pour que le marshalling soit sûr, est qu’il faut que le type managé occupe la même quantité de mémoire que le type non managé. Si cette règle est observée, le marshalling sera toujours possible même si un marshalling vers un type trop différent peut mener à des comportements indésirables. Par exemple, on est pas forcé d’utiliser strictement un entier signé en managé avec un type signé en non managé, on peut utiliser un entier signé en non managé avec un entier non signé en non managé.

Types “blittables” et “non-blittables”

Les types d’objets qui ont une représentation similaire en mémoire entre le code managé et le code non managé sont des types “blittables”. Ils ne nécessitent pas de précisions particulières et le marshalling de ces types est immédiat. A l’opposé, le marshalling des types non “blittables” n’est pas forcément immédiat car leur représentation en mémoire entre le code managé et le code non managé est différente.

La plupart des types simples c’est-à-dire les types primitifs sont “blittables”. A l’opposé, tous les types complexes sont “non-blittables”.

Types simples

Les types simples sont les types primitifs. La plupart de ces types est “blittable” et la conversion entre le code managé et non managé est directe:

Type C# Type C/C++ Taille en octets Intervalle
bool(1) bool 1, 2 ou 4 “true” ou “false”
char wchar_t (ou char) 2 (ou 1) Unicode BMP(2)
byte unsigned char 1 0 à 255
sbyte char 1 -128 à 127
short short 2 -32768 à 32767
ushort unsigned short 2 0 à 65535
int int 4 -2147483648 à 2147483647
uint unsigned int 4 0 à 4294967295
long __int64 8 -9,2 x 10^18 à 9,2 x 10^18
ulong unsigned __int64 8 0 à 18,4 x 10^18
float float 4 7 chiffres décimaux -3,4 x 10^38 à 3,4 x 10^38
double double 8 15 chiffres décimaux

(1): les booléens sont des cas particuliers. Entre du code C++ et du code C#, les booléens sont des types identiques codés sur 1 octet. La conversion se fait en System.Boolean ou bool. En revanche l’API Win32 considère 2 types de booléen: BOOL et BOOLEAN:

  • BOOL: il s’agit d’un alias pour INT codé sur 4 octets.
  • BOOLEAN: alias pour le type BYTE codé sur 1 octets.

BOOL et BOOLEAN peuvent être convertis en System.Boolean, toutefois BOOL peut être converti en System.Int32. De même BOOLEAN peut aussi être converti en System.Byte.
Pour éviter un mauvais marshalling dans le cas de l’API Win32, il est préférable d’utiliser MarshalAsAttribute avec UnmanagedType.Bool pour cadrer davantage la conversion.

(2): correspond à l’ensemble des caractères Unicode BMP (Basic Multilingual Plane).

Types complexes

Les types complexes sont des types d’objets contenant des membres dont le type est simple ou complexe. Il y a 2 types complexes compatibles avec le langage C avec lesquels il est possible d’effectuer des appels P/Invoke: les structures et les unions. Ces types peuvent être convertis en structures ou en classes en C#.

Les membres d’une structure sont rangés en mémoire à une adresse particulière et ils occupent un ou plusieurs blocs mémoire en fonction de leur type. Ainsi chaque membre aura une adresse mémoire relative à l’adresse de départ de la structure.

Pour que l’équivalence soit possible sans précision particulière entre l’objet managé et l’objet non-managé, il faut:

  • que les membres soient les mêmes,
  • que l’ordre de ces membres soit le même et
  • que le type de ces objets occupe le même nombre de blocs mémoire.

Dans le cas où l’une de ces conditions n’est pas remplie, on peut utiliser des attributs qui vont apporter de indications au marshaller pour effectuer la conversion.

LayoutKind

Cet attribut permet d’indiquer au marshaller la façon dont les membres de l’objet complexe sont placés en mémoire pour qu’il soit utilisable dans le code managé et dans le code non-managé lors d’un appel P/Invoke. 3 valeurs sont possibles:

  • LayoutKind.Auto: il s’agit de la valeur par défaut. Elle empêche au CLR de marshaller un objet complexe décoré de cet attribut vers un objet non-managé. Si on utilise un objet avec cet attribut dans un appel P/Invoke, une exception sera lancée.
  • LayoutKind.Sequential: lors du marshalling, les valeurs sont affectées aux membres de la structure complexe dans l’ordre dans lequel ils sont déclarés dans la structure. Cette valeur convient bien lorsque les membres sont des types simples.
  • LayoutKind.Explicit: cette valeur permet d’indiquer explicitement la position relative du membre par rapport à la première adresse de la structure. Pour indiquer l’adresse relative, on utilise pour tous les membres, l’attribut FieldOffset avec le nombre d’octet d’écart. On peut utiliser le tableau plus haut pour connaître l’espace mémoire occupé par un membre en fonction de son type.

“Marshalling” d’un objet complexe

Pour marshaller un objet complexe, il faut:

  • Créer la struct en C et son équivalent en C#. L’équivalent peut être une structure C# ou une classe.
  • Expliciter les membres de l’objet complexe dans le même ordre.
  • Ajouter l’attribut LayoutKind qui convient en fonction de la façon dont les membres sont déclarés.

Comportement du “marshaller” pour les objets passés en paramètre

Lorsque les appels sont faits à du code non managé, il est possible ou non d’utiliser une représentation commune de l’objet en mémoire pour le code managé et pour le code non managé. Dans cette partie, on se propose d’expliciter les mécanismes du marshaller pour le passage d’objet vers le code non-managé en fonction des attributs utilisés dans la déclaration de la méthode appelée par P/Invoke.

Pour la suite, on parle de comportement par défaut lorsqu’il s’agit du comportement si on n’apporte aucune indication avec les attributs.

In et Out

Dans la suite, lorsqu’on parle de In et Out, on parle des attributs utilisés pour le marshalling: System.Runtime.InteropServices.InAttribute et System.Runtime.InteropServices.OutAttribute. Il ne faut pas confondre ces attributs avec le mot-clé C# out:

  • out: pour les appels P/Invoke out est utilisé pour que des objets de type valeur soient passés en paramétre par référence. out peut être remplacé par ref.
  • System.Runtime.InteropServices.InAttribute: attribut permettant d’indiquer au que le paramètre sera marshallé de la fonction appelante vers la fonction appelée.
  • System.Runtime.InteropServices.OutAttribute: attribut permettant d’indiquer au marshaller que le paramètre sera marshallé de la fonction appelée vers la fonction appelante.

On peut résumer les différents cas de figures dans les tableaux suivants:

Pour les objets de type valeur

Passage des arguments Blittable ? Attributs utilisés lors de l’appel Comportement par défaut Comportement du marshaller
Passage par valeur Blittable IN Oui L’objet est copié et l’argument passé dans le tas de l’appelé est directement la valeur de l’objet.
Non-blittable Même comportement que précédemment. Toutefois lors de la copie, l’objet est marshallé.
Passage par référence Blittable IN et OUT Non (il faut préciser explicitement les attributs) L’objet est copié (l’objet se trouve dans le tas managé et sa copie se trouve dans le tas non managé). L’argument passé est un pointeur vers la copie dans le tas non managé.
Non-blittable Même comportement que précédemment. Toutefois lors de la copie, l’objet est marshallé.

Pour les objets de type référence

Passage des arguments Blittable ? Attributs utilisés lors de l’appel Comportement par défaut Comportement du marshaller
Passage par valeur Non-blittable IN Oui L’objet est copié et marshallé. L’argument passé est un pointeur vers l’objet copié dans le tas non managé.
OUT Non (il faut préciser explicitement l’attribut) L’objet est copié et marshallé en retour de la fonction appelée. L’argument passé est un pointeur vers l’objet dans le tas managé.
IN et OUT Non (il faut préciser explicitement les attributs) 2 copies sont effectuées, lors de l’appel et lors du retour des arguments.
Blittable IN Oui L’objet est fixé en mémoire (i.e. pinned).
IN et OUT Non (il faut préciser explicitement les attributs) Même comportement mais plus sûr si l’appelé doit modifié l’objet passé en argument.
Passage par référence N/A IN Non (il faut préciser explicitement l’ attribut) L’objet est copié et marshallé (l’objet se trouve dans le tas managé et sa copie, dans le tas non managé). L’argument passé est un pointeur vers un pointeur qui pointe vers l’objet dans le tas non managé.
IN et OUT Oui Même comportement que précédemment. En retour l’appelant reçoit un pointeur vers pointeur vers l’objet dans le tas managé.

Lorsque l’objet est commun et qu’il est fixé en mémoire (i.e. pinned), les modifications faites par la fonction appelée sur l’objet sont visibles de la fonction appelante. Ce mécanisme est plus performant qu’une copie puisqu’une seule version de l’objet subsite et est utilisée dans le tas managé. Cette copie est utilisée à la fois par le code managé et par le code non managé. Pour éviter, un déplacement de l’objet par le garbage collector, l’objet est marqué pour ne pas être manipulé lors de l’appel au code managé.

Il est possible de forcer un objet à être fixé pour qu’il soit accesible à partir de code non managé en utilisant GCHandle:

using System; 
using System.Runtime.InteropServices; 
using System.Security.Permissions; 
 
// ... 
public class GcHandleTest 
{ 
    [DllImport("user32.dll")] 
    public static extern bool EnumWindows(CallBack cb, IntPtr param); 
     
    // ... 
    [SecurityPermission(SecurityAction.Demand, UnmanagedCode=true)] 
    public void PinObject(object objectToPin) 
    { 
        GCHandle gcHandle = GCHandle.Alloc(tw); 
        try 
        { 
            // ... 
            // Utilisation de l'objet par le code non managé 
        } 
        finally 
        { 
            gcHandle.Free(); 
        } 
    } 
}
Résumé sur le comportement par défaut

Pour résumer le comportement par défaut lors des appels P/Invoke:

  • Si un objet de type valeur est passé en argument, par défaut, il est passé par valeur. Le marshalling se fait si l’objet n’est pas “blittable”.
  • Si un objet de type valeur est passé en argument par référence, le code managé et le code non managé utilisent des objets différents stockés respectivement dans le tas managé et dans le tas non managé.
  • Si un objet de type référence est passé en argument, par défaut, il est passé par référence en utilisant les attributs In et Out. Le code managé et le code non managé utilisent des pointeurs différents et des copies différentes des objets. Le marshalling se fait si l’objet n’est pas “blittable”.
  • Si un objet de type référence est passé en argument par valeur, par défaut, il est passé en utilisant l’attribut In. Si l’objet est “blittable”, il est fixé en mémoire (i.e. pinned) et le code managé et non managé utilisent le même objet qui se trouve dans le tas managé. Si l’objet est non “blittable”, le code managé et le code non managé utilisent des copies différentes des objets, respectivement dans le tas managé et dans le tas non managé.

Cas particulier des “strings”

Le type string en C# est un type référence, toutefois il n’est pas marshallé de la même façon que les autres objet de type référence. Il existe 2 façons en C# de stocker une chaine de caractères: System.String et System.Text.StringBuilder. La différence essentiel entre ces 2 objets est que System.String est immutable alors que System.Text.StringBuilder autorise les modifications.

Quand des objets de type System.String ou System.Text.StringBuilder sont passés en paramètre d’une fonction avec Platform Invoke, le marshaller se comportent différemment:

Type d’objet Passage des arguments Comportement par défaut Comportement du marshaller
System.String Par référence Non Le marshaller copie les données vers un buffer et passe la référence du buffer à la fonction appelée. En retour de l’appel, le contenu du buffer est copié dans un 2e buffer. Cette étape permet de ne pas altérer la chaine managée.
Par valeur Oui Lorsque la chaine est une chaine Unicode, le marshaller optimise en passant directement à la fonction appelée un pointeur vers la chaine managée au lieu d’effectuer un copie vers un buffer.
System.Text.StringBuilder Par valeur Oui Le marshaller copie les données vers le buffer interne du StringBuilder et passe une référence vers ce buffer interne directement à la fonction appelée. La longueur de la StringBuilder doit être choisie à l’affectation de la chaine dans le code managé.
Par référence Non Le marshaller passe un pointeur vers l’objet StringBuilder en mémoire et non un pointeur vers le buffer interne.

Il est préférable d’utiliser System.String pour des paramètres en entrée d’une fonction appelée qui ne seront pas retournées à la fonction appelante. Dans le cas où on est intéressé par l’utliisation des chaines en sortie de la fonction appelée, System.Text.StringBuilder convient mieux car il permettra de changer la valeur interne de la chaine de caractères.
En revanche, il faut que la longueur de la chaine dans la StringBuilder soit définie par la fonction appelante et qu’elle soit assez grande pour contenir la chaine en retour.
Le point négatif de la StringBuilder est qu’il n’est pas possible d’utiliser ce type dans des types complexes.

L’utilisation d’un objet System.String en sortie d’une fonction suppose que la mémoire coté code natif soit allouée en utilisant CoTaskMemAlloc. Après avoir appelé la fonction et récupéré le résultat, le CLR va libérer la mémoire native en utilisant CoTaskMemFree. Un crash peut se produire si l’allocation n’a pas été effectuée avec CoTaskMemAlloc.

StringBuilder est un type référence, pourtant par défaut, lorsqu’elle est passée par valeur, elle est passée avec les attributs In et Out (contraiement aux autres objets de type référence qui sont passés par défaut seulement avec l’attribut In).

Enfin, il faut avoir en tête que StringBuilder n’est pas utilisable dans un type complexe. Il faut obligatoirement utiliser une objet de type string pour stocker une chaine de caractères.

Détails des appels à l’API Win32

Les appels P/Invoke permettent d’appeler beaucoup de fonctions de l’API Win32. Ces fonctions ne sont pas très différentes des fonctions qu’on déclare dans une DLL native, toutefois il existe certaines spécificités propre à l’API Win32.

Ainsi les types utilisés pour les appels à l’API sont très souvent des alias vers des types C. On peut résumer les types les plus courants dans le tableau suivant, la liste exhaustive de ces types se trouve sur la page Windows Data Types:

Type C# Type Windows Taille en octets
char CHAR 2
byte BYTE 1
sbyte CHAR 1
short SHORT 2
ushort WORD ou USHORT 2
int INT, INT32, LONG et LONG32 4
uint DWORD, DWORD32, UINT et UINT32 4
long INT64, LONGLONG et LONG64 8
ulong DWORDLONG, DWORD64, ULONGLONG et UINT64 8
double FLOAT 8

Une autre règle est que l’API Win32 ne permet pas de passage d’arguments par référence.

La grande majorité des déclarations permettant d’effectuer des appels à l’API Win32 sont explicitées sur pinvoke.net.

4. Précisions sur les appels P/Invoke

Déclaration des méthodes P/Invoke dans des “wrappers”

D’une façon générale, il est conseillé d’encapsuler les déclarations des méthodes P/Invoke dans des classes statiques dont le nom peut être NativeMethods, SafeNativeMethods ou UnsafeNativeMethods.

Encodage des chaines de caractères

Comme précisé plus haut, en C# les chaines de caractères sont encodées en utilisant Unicode UTF-16. Par défaut, elles sont converties et passées au code non managé suivant le standard ANSI. Si on veut convertir dans un ensemble de caractères différents, il faut le préciser dans les attributs de DllImport, par exemple:

[DllImport("TestPInvoke.dll", CharSet = CharSet.Unicode)] 
public static void UseStringParameter([MarshalAs(UnmanagedType::LPStr)]string stringValue);

Les différentes valeurs possibles sont:

  • CharSet.Auto: la valeur change suivant le système d’exploitation. Unicode pour Windows NT, XP et suivant sinon Ansi pour les versions précédentes. Le codage par défaut du langage est prioritaire sur le choix suivant l’OS. Par défaut, pour le C#, c’est un codage en Ansi qui est utilisé.
  • CharSet.Ansi: valeur par défaut en C#.
  • CharSet.Unicode
  • CharSet.None: même comportement que Ansi.

Enfin, pour une chaine de caractères encodée en ANSI, chaque caractère utilise un seul octet en mémoire. La même chaine de caractères encodée en Unicode (UTF-16) nécessite deux fois plus d’espace mémoire puisque chaque caractère occupe 2 octets. Il faut avoir cette caractéristique en tête lorsqu’on réserve un espace pour une chaine de caractères dans un type complexe.

Performances

Avant de rentrer dans le détail d’appels P/Invoke, quelques adaptations peuvent être nécessaires dans le code:

  • Pour éviter un impact trop fort dans les performances dans l’étape de marshalling, il est préférable de privilégier des interfaces fournies et minimiser les appels plutôt que d’utiliser des interfaces qui nécessitent un grand nombre d’appels.
  • Eviter d’éviter des conversions entre ANSI et Unicode dans les chaines de caractères car cette opération est couteuse en performance. Utiliser un pointeur IntPtr plutôt qu’utiliser une chaine explicitement permet d’éviter au marshaller d’effectuer des conversions lui-même.
  • D’une façon générale, utiliser IntPtr et effectuer des étapes de marshalling manuellement en utilisant la classe Marshal est plus rapide que de laisser le marshaller effectuer toutes les conversions, car on peut choisir finement les éléments à marshaller.
  • Les attributs InAttribute et OutAttribute permettent de minimiser les conversions par rapport au comportement par défaut qui peut être amener à effectuer trop d’étapes de marshalling inutiles.

Exemple d’utilisation des attributs de “marshalling”

La déclaration suivante permet d’appeler la fonction ReadConsole() de l’API Win32:

[DllImport("Kernel32.dll", CharSet = CharSet.Unicode)] 
[return: MarshalAs(UnmanagedType.Bool)] 
static extern bool ReadConsole( 
    IntPtr hConsoleInput, 
 
    [param: MarshalAs(UnmanagedType.LPTStr), Out()] 
    StringBuilder lpBuffer, 
 
    [param: MarshalAs(UnmanagedType.U4)] 
    uint nNumberOfCharsToRead, 
 
    [param: MarshalAs(UnmanagedType.U4), Out()] 
    out uint lpNumberOfCharsRead, 
 
    [param: MarshalAs(UnmanagedType.AsAny)] 
    uint lpReserved);

Cette déclaration utilise les attributs:

  • CharSet = CharSet.Unicode: permet d’indiquer que les chaines de caractères ne seront pas converties en utilisant l’ensemble de caractères Ansi, elles seront laissées en Unicode.
  • [param: MarshalAs(UnmanagedType.LPTStr), Out()]: permet d’indiquer que le paramètre est marshallé de la fonction appelée vers la fonction appelante. De plus le marshalling de la chaine de caractères sera fait à partir d’une chaine codée en Unicode.
  • [param: MarshalAs(UnmanagedType.U4), Out()]: l’entier sera codé de la fonction appelée vers la fonction appelante et la conversion se fera à partir d’un entier non signé sur 4 octets. Pour ce paramètre, on utilise out en plus de l’attribut System.Runtime.InteropServices.OutAttribute pour indiquer que le paramètre entier non signé qui est un objet de type valeur soit passé par référence.
  • [param: MarshalAs(UnmanagedType.AsAny)]: permet d’indiquer que le type de l’objet marshallé sera déterminé à l’exécution.

Pour plus de renseignements sur les types de marshalling, se reporter à MSDN.

Exemples de passage d’objets en paramètre

Passage d’objet de type valeur par valeur

C’est le comportement par défaut, il n’y rien à préciser. Par défaut, l’attribut appliqué est IN:

En C++:

typedef struct _PERSON 
{ 
   char* first;  
   char* last;  
} PERSON, *LP_PERSON; 
 
int FunctionUsingPerson(PERSON person);

En C#:

[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)] 
public struct Person 
{ 
    public string first; 
    public string last; 
} 
 
public static class UnsafeNativeMethods  
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern int FunctionUsingPerson(Person person); 
}

L’appel se fait de cette façon:

Person person = new Person(); 
person.first = "Mat"; 
person.last = "Robert"; 
int result = UnsafeNativeMethods.FunctionUsingPerson(person);

Passage d’objet de type valeur par référence

Il faut ajouter le mot clé ref en C#. Par défaut les attributs utilisés sont IN et OUT.
Le code C# devient:

public static class UnsafeNativeMethods  
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern int FunctionUsingPerson(ref Person person); 
}

En C++:

int FunctionUsingPerson(PERSON* person);

Passage d’objet de type référence par référence

C’est le comportement par défaut, il n’y a rien à préciser. L’attribut utilisé par défaut est IN:
En C++:

typedef struct _CUSTOMTIME {  
    WORD wHour;  
    WORD wMinute;  
    WORD wSecond;  
} CUSTOMTIME, *PCUSTOMTIME; 
 
VOID FunctionUsingRefType(CUSTOMTIME* time);

En C#:

[StructLayout(LayoutKind.Sequential)] 
public class CustomTime 
{ 
    public ushort hour; 
    public ushort minute; 
    public ushort second; 
} 
 
public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern void FunctionUsingRefType(CustomTime time); 
}

L’appel se fait simplement:

CustomTime time = new CustomTime{ hour = 12, minute = 45, second = 32 }; 
UnsafeNativeMethods.FunctionUsingRefType(time);

Passage d’objet de type référence par valeur

Il faut préciser les attributs IN et OUT pour que le passage se fasse par valeur.

La déclaration de la méthode en C++:

VOID FunctionUsingRefType(CUSTOMTIME time);

En C#:

public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern void FunctionUsingRefTypeByVal([In,Out] CustomTime time); 
}

Passage de “System.String”

Si le string est en paramètre seulement en entrée:

En C++:

void UseStringParameter(char* stringValue);

En C#:

public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll", CharSet = CharSet.Ansi)] 
    static public void UseStringParameter([MarshalAs(UnmanagedType::LPStr)]string stringValue); 
}

Retour d’une chaine de caractères

Si on doit récupérer un résultat sous forme de chaine de caractères, il vaut mieux utiliser un StringBuilder.

En C++:

void ReturnsAString([out] char* stringValue);

En C#:

public static class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll", CharSet = CharSet.Unicode)] 
    static extern void ReturnsAString( 
        [param: MarshalAs(UnmanagedType.LPTStr), Out()] 
        StringBuilder lpBuffer); 
}

Dans l’appel, il faut fixer la taille de la StringBuilder:

StringBuilder builder = new StringBuilder(256); 
UnsafeNativeMethods.ReturnsAString(builder); 
string result = builder.ToString();

Dans le cas de StringBuilder, on précise l’attribut Out car par défaut, le StringBuilder est passé avec les attributs In et Out.

Structure contenant un pointeur vers une autre structure

En C++:

typedef struct _POINTCOORDINATES 
{ 
    int latitude;  
    int longitude;  
} POINTCOORDINATES, *LP_POINTCOORDINATES; 
 
 
typedef struct _NAMEDPOINT 
{ 
   POINTCOORDINATES* point; 
   char* name;  
} NAMEDPOINT, *LP_NAMEDPOINT; 
 
 
int UsingNamedPoint(NAMEDPOINT* namedPoint);

En C#:

[StructLayout(LayoutKind.Sequential)] 
public struct PointCoordinates 
{ 
    public int latitude; 
    public int longitude; 
} 
 
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)] 
public struct NamedPoint 
{ 
    public IntPtr point; 
    public char* name; 
} 
 
public class UnsafeNativeMethods 
{ 
    [DllImport("TestPInvoke.dll")] 
    public static extern int UsingNamedPoint(ref NamedPoint namedPoint); 
}

L’appel se fait en exécutant:

PointCoordinates pointCoordinates; 
pointCoordinates.latitude = 34; 
pointCoordinates.longitude = 84; 
 
NamedPoint namedPoint; 
namedPoint.name = "point1"; 
 
IntPtr buffer = Marshal.AllocCoTaskMem(Marshal.SizeOf(pointCoordinates)); 
Marshal.StructureToPtr(pointCoordinates, buffer, false); 
 
personAll.point = buffer; 
 
int res = UnsafeNativeMethods.UsingNamedPoint(ref personAll);
Références

Explications générales:

PInvoke tutorial:

Explications Marshalling:

Précisions techniques:

EventViewer en 5 min

L’Event Viewer permet de visualier des erreurs ou des messages provenant du système ou d’applications. C’est un outil intéressant car il centralise tous les messages et que les messages sont facilement consultables.

Dans le cas d’une application qu’on développe et pour laquelle on souhaite indiquer des messages particuliers, l’event viewer peut être intéressant pour donner des indications à l’utilisateur. Il ne remplace par le fichier de log qui est adapté pour les messages de logs de grande taille, toutefois il peut s’avérer utile pour indiquer une erreur facilement consutable ou simplement informer sur le statut d’un processus.

Logger des messages de logs dans l’Event Viewer consomme de l’espace disque. Donc dans le cas où il est nécessaire de logger un grand nombre d’information, il est préférable de privilégier un fichier de log classique et de réserver l’Event Viewer à des messages plus importants.

Ouvrir l’Event Viewer

A partir du panneau de configuration (Control Panel):

  1. Système et sécurité (System and security)
  2. Outils d’administration (Administration tools)
  3. Observateur d’évènements (Event viewer)

A la ligne de commandes, on peut taper:

eventvwr.msc

Accéder aux logs

Dans l’Event viewer, les logs sont disponibles en dépliant les noeud “Windows Logs” et “Application and Services Logs”. Le noeud “Windows Logs” est composé de 5 catégories:

  • “Application”,
  • “Security”,
  • “Setup”,
  • “System” et
  • “Forwarded Events”.

On peut ajouter des logs dans la catégorie “Application” et dans un sous-élément du noeud “Application and Services Logs”.

Ajouter un message de log par programmation

La classe permettant de rajouter des messages de logs est System.Diagnostics.EventLog.

Plus de détails concernant EventLog sur MSDN.

Ajouter un message dans “Application”

Pour ajouter un message dans la catégorie “Application” du noeud “Windows Logs”, on peut exécuter le code suivant:

using System.Diagnostics; 
 
//... 
using (EventLog eventLog = new EventLog())  
{ 
    eventLog.Source = "CustomSource";  
    eventLog.WriteEntry("Message particulier", EventLogEntryType.Warning, 9); 
}

La source permet d’indiquer le nom de l’application d’où provient le message. En outre, il existe plusieurs niveaux de logs:

  • Information
  • SuccessAudit
  • Warning
  • FailureAudit
  • Error

Enfin le code "9" n’a pas de signication particulière. Il permet d’affecter un identifiant à un évènement de façon à facilement le rechercher par la suite.

L’inconvénient d’utiliser ce noeud est que les messages sont noyés avec tous les autres messages des autres applications.

Ajouter un message dans une catégorie particulière

Pour faciliter la recherche des messages de logs et avoir une catégorie particulière, il est possible de créer une source d’évènement particulière qui se trouvera dans le noeud “Applications and Services Logs”. On pourra alors rajouter tous ses messages directement dans cette source.

Créer une source

On peut créer une source qui se rajoutera sous “Applications and Services Logs” en exécutant:

try 
{ 
    if (!EventLog.SourceExists("CustomSource"))  
    {  
        EventLog.CreateEventSource("CustomSource", "CustomApplication");  
    } 
} 
catch (System.Security.SecurityException securityException) 
{ 
    // ... 
} 
catch (Exception occuredException) 
{ 
    // .... 
}

Ce code va créer une catégorie “CustomApplication” sous “Applications and Services Logs” et il sera possible de rajouter des logs en utilisant la source “CustomSource”.

Exécuter avec les droits administrateurs

Pour exécuter ce code, il faut lancer l’exécutable avec les droits administrateurs.
Dans le cadre d’application livrée, l’étape d’ajout d’une source d’évènement doit se faire à l’installation puisque, dans son fonctionnement normal, l’application ne sera pas exécutée avec les droits administrateurs.

Latence lors de l’ajout d’une catégorie

L’ajout d’une catégorie prend un certain temps. Si on utilise tout de suite la source après sa création, il peut y avoir une erreur à l’ajout d’un message de log car il n’a pas encore été ajouté.

Supprimer une source

A l’opposé, pour supprimer une source:

if (EventLog.SourceExists("CustomSource")) 
{ 
    EventLog.DeleteEventSource("CustomSource"); 
}

De la même façon, les droits administrateurs sont nécessaires pour exécuter ce code.

Création d’un message de log

Quand la source est créée, il n’est plus nécessaire d’exécuter l’application avec les droits administrateurs pour rajouter un message de log, le code est le même que précédemment:

using (EventLog eventLog = new EventLog()) 
{ 
    eventLog.Source = "CustomSource";
    eventLog.WriteEntry("Autre message particulier", EventLogEntryType.Error, 10); 
}

Contrairement au cas précédent, ce message sera rajouté dans la catégorie "CustomApplication" et avec le nom de source "CustomSource".

Effacer des messages de log

Il suffit d’exécuter le code suivant:

using (EventLog eventLog = new EventLog()) 
{ 
    eventLog.Source = "CustomSource";  
    eventLog.Clear(); 
}

Supprimer la catégorie

Il faut exécuter avec les droits administrateurs:

if (EventLog.Exists("CustomApplication")) 
{ 
    EventLog.Delete("CustomApplication"); 
}

A l’opposé de l’installation, c’est ce code qu’il faut exécuter à la désintallation de l’application.

Lire le contenu de l’Event Viewer par programmation

On peut lire le contenu d’une source en exécutant:

using (EventLog eventLog = new EventLog()) 
{ 
    eventLog.Log ="CustomApplication"; 
    foreach (EventLogEntry entry in eventLog.Entries) 
    { 
        Console.WriteLine(entry.Message); 
    } 
}

Ajouter des logs avec l’API Win32

Il est possible d’ajouter des logs en utilisant l’API Win32 sans avoir besoin des privilèges administrateurs. Il suffit de faire des appels P/Invoke sur les fonctions déclarées de la façon suivante:

[DllImport("advapi32.dll")] 
public static extern IntPtr RegisterEventSource(string lpUNCServerName, string lpSourceName); 
 
[DllImport("advapi32.dll")] 
public static extern bool DeregisterEventSource(IntPtr hEventLog); 
 
[DllImport("advapi32.dll", EntryPoint = "ReportEventW", CharSet = CharSet.Unicode)] 
public static extern bool ReportEvent(IntPtr hEventLog, ushort wType, 
  ushort wCategory, int dwEventID, IntPtr lpUserSid, ushort wNumStrings, 
  uint dwDataSize, string[] lpStrings, byte[] lpRawData); 
 
public const ushort EVENTLOG_INFORMATION_TYPE = 0x0004; 
public const ushort EVENTLOG_WARNING_TYPE = 0x0002; 
public const ushort EVENTLOG_ERROR_TYPE = 0x0001;

Plus de précisions sur la page suivante: Logging to the Windows EventLog without administrative privileges in .Net.

PerformanceCounter en 5 min

Les compteurs de performance Windows permettent de mesurer l’évolution de certaines données de performance liées à une machine (charge CPU, mémoire utilisée, les entrées/sorties réseau) ou à un ou plusieurs processus (taille des piles, quantité de mémoire managée utilisée etc…).

Cet outil peut être utile pour monitorer des données de performance d’un point de vue général dans des données liées à une machine mais aussi plus spécifiquement à un processus si on souhaite, par exemple, révéler une fuite mémoire ou évaluer les performances de l’exécution d’un processus.

Il existe un certain nombre de compteurs de performance livrés avec Windows mais il est possible de créer ses propres compteurs et de les alimenter avec un processus .NET classique. Sur MSDN, on peut avoir la liste complête des compteurs de performance: Performance Counters in the .NET Framework.

Si on dispose des droits administrateur, on peut créer des catégories de compteurs de performance. Il n’est pas nécessaire d’avoir ces droits pour alimenter les compteurs.

1. Types de compteurs de performance

Les principaux types de compteurs sont:

  • NumberOfItems32: permet d’indiquer une grandeur totale comme le nombre d’éléments ou d’opérations. Ce type permet d’indiquer un nombre entier 32 bits, toutefois l’équivalent NumberOfItems64 existe pour des entiers 64 bits. Une grandeur de ce type est utilisée sans transformation.
  • RateOfCountsPerSecond32: permet d’indiquer une grandeur par seconde. Par exemple le nombre d’opérations par seconde. De même, ce type permet d’indiquer un nombre entier 32 bits, l’équivalent pour un entier 64 bits est RateOfCountsPerSecond64. Une grandeur de ce type est utilisée en prenant en compte le temps en ticks de l’horloge système.
  • AverageTimer32: permet d’indiquer le temps moyen en seconde pour effectuer un processus. Ce type permet d’indiquer un entier 32 bits, l’équivalent pour un entier 64 bits est AverageTimer64. Une grandeur de ce type est utilisée en prenant en compte le temps en ticks de l’horloge système.

D’autres types sont disponibles, des exemples sont précisés sur MSDN.

Précisions sur le type RateOfCountsPerSecond32

Les grandeurs de type RateOfCountsPerSecond32 indiquent un nombre par seconde.
Donc si on considère une valeur V0 lue au temps T0 et une autre valeur V1 lue au temps T1.
La formule: (V1 - V0) / (T1 - T0) permet de calculer le nombre d’éléments traités V1 – V0 pour la durée T1 – T0 en ticks.
Pour ramener ce nombre d’éléments à la durée d’une seconde, il faut utiliser F c’est-à-dire le fréquence de ticks par seconde (1/F étant le nombre de ticks par seconde).
La formule devient pour une seconde: (V1 - V0) / ((T1 - T0) / F).

Dans la pratique, on indique seulement les valeurs Vx et en fonction des temps pendant lesquels on a indiqué chaque valeur, le compteur connait les différentes valeurs Tx. De même F étant connu, le compteur applique la formule précédente pour obtenir le taux par seconde.

Précisions sur le type AverageTimer32

Les grandeurs de type AverageTimer32 indique un nombre moyen par seconde.
Donc si on considère une valeur V0 lue au temps T0 et une autre valeur V1 lue au temps T1.
La formule est: (V1 - V0) / (T1 - T0).

De même que précédemment, la formule (V1 - V0) / (T1 - T0) permet de calculer le nombre d’éléments traités V1 – V0 pour la durée T1 – T0 en ticks.
Si on veut calculer la moyenne d’éléments traités en un seconde, il faut utiliser le nombre de ticks par seconde 1/F (F étant la fréquence de ticks par seconde).
La formule devient pour une seconde: ((V1 - V0) / F) / (T1 - T0).

Dans la pratique, on indique les valeurs Vx mais aussi les valeurs Tx. Les valeurs Vx sont indiquées en utilisant le compteur dont le nom est AverageTimer32. Les valeurs Tx sont indiquées en utilisant le compteur correspondant pour le dénominateur de la formule c’est-à-dire AverageBase.
Les équivalences sont les mêmes pour AverageCount32 et AverageBase.

Utiliser les compteurs de performance

3 méthodes sont possibles:

  • Performance Monitor: outil de monitoring qui permet d’afficher les compteurs de performance sous forme de graphique.
  • Avec Visual Studio: on peut directement créer des catégories avec Visual Studio à condition d’avoir les droits administrateur.
  • Par programmation: on peut créer des catégories mais aussi alimenter des compteurs de performance par code.

2. Performance Monitor

Le performance monitor permet de visualiser les compteurs de performance configurés sur la machine locale ou une machine distante. On peut afficher les compteurs livrés avec Windows ou des compteurs personnalisés.

Pour ouvrir Performance Monitor, il faut taper à la ligne de commandes perfmon.

Visualiser un compteur de performance

Avec Performance Monitor, il suffit d’ajouter un compteur en cliquant sur “Add Counters”.
Il faut généralement affiner l’échelle en se référant à la dernière valeur courante car la courbe est souvent en dehors du graphique.

Profiler une fuite mémoire

Performance monitor permet de facilement visualiser une fuite mémoire en affichant les différentes mémoires utilisées par un processus. La mémoire d’un processus .NET est partagées entre mémoire managée et mémoire non managée.

Grâce à Performance Monitor, il est possible d’afficher la mémoire totale consommée par le processus et la mémoire managée. On peut en déduire la quantité de mémoire non managée utilisée:

  • La mémoire totale du processur .NET: on peut afficher cette grandeur en allant dans la catégorie de compteur “Process” puis en sélectionnant le compteur “Private Bytes”.
  • La mémoire managée: cette grandeur est accessible dans la catégorie “.NET CLR Memory” et en sélectionnant le compteur “# Bytes en all Heaps”.

L’écart entre les 2 grandeurs précédentes permettent de déterminer la quantité de mémoire non-managée utilisée. Ainsi si la mémoire managée reste constante mais que la mémoire totale du processus ne cesse d’augmenter, on peut en déduire qu’il y a bien une fuite mémoire dans la mémoire non managée.

3. Visual Studio

Visual Studio permet de créer des catégories de compteurs de performance et des compteurs de performance.

Création d’une catégorie de compteur de performance

Il faut démarrer Visual Studio avec les droits administrateur:

  1. Cliquer sur le menu “View”,
  2. Cliquer sur “Server-Explorer”.
  3. Dépliant le noeud correspondant à un serveur et déplier “Performance Counters”.
  4. Clique droit sur “Performance Counters”
  5. Cliquer sur “Create New Category”.

Création d’un compteur de performance

Un compteur doit appartenir à une catégorie. Sur le panneau d’une catégorie, on peut ajouter ou supprimer des compteurs de performance. La création d’un compteur de performance se fait en indiquant son type, son nom et une description.

4. Utiliser les compteurs de performance pour programmation

On peut ajouter une nouvelle catégorie par si le processus est exécuté avec les droits administrateur. De même, on peut aussi créer et alimenter les compteurs de performance.

Les classes nécessaires à la manipulation des compteurs de performance se trouvent dans le namespace System.Diagnostics.

Création d’une catégorie de compteur de performance

Un compteur de performance doit obligatoirement se trouver dans une catégorie. Si la catégorie dans laquelle on veut ranger le compteur n’existe pas, il faut la créer. Une fois créée, il n’est pas nécessaire de la recréer pour l’utiliser.

Pour exécuter le code de création d’une catégorie, il faut que le processus soit exécuté avec les droits administrateur sinon une exception survient.

La classe PerformanceCounterCategory permet de vérifier que la catégorie existe et de créer la catégorie ainsi que les compteurs de performance qui s’y trouvent:

if (!PerformanceCounterCategory.Exists("Counter custom category")) 
{ 
    CounterCreationDataCollection counters = new CounterCreationDataCollection(); 
 
    // Creating counter with type NumberOfItems32 
    CounterCreationData totalOperations = new CounterCreationData(); 
    totalOperations.CounterName = "# operations executed"; 
    totalOperations.CounterHelp = "Total number of operations executed"; 
    totalOperations.CounterType = PerformanceCounterType.NumberOfItems32; 
    counters.Add(totalOperations); 
 
    PerformanceCounterCategory.Create("Counter custom category",  
        "Category help message", counters); 
}

Ce code permet de créer un compteur de performance dans le type est NumberOfItems32 et de le ranger dans la catégorie “Counter custom category”. Le nom du compteur “# operations executed” est le seul moyen d’identifier le compteur.
A ce moment le compteur n’est pas alimenté toutefois la catégorie et le compteur sont visibles dans Performance Monitor ou dans Visual Studio.

Alimenter un compteur de performance

Après avoir créé le compteur avec l’étapé précédente, on peut l’alimenter en valeurs avec le code suivant:

var customPerformanceCounter = new PerformanceCounter("Counter custom category", "# operations executed"); 
customPerformanceCounter.ReadOnly = false; 
CustomPerformanceCounter.RawValue = 0;

Cette surcharge permet d’utiliser le compteur de performance “# operations executed” sur la machine locale. On indique ensuite qu’on souhaite l’alimenter avec

customPerformanceCounter.ReadOnly = false;

On peut indiquer qu’on souhaite lire ou écrire les valeurs d’un compteur distant en précisant le nom de la machine:

customPerformanceCounter.MachineName = "FRXXXXXX";

On peut indiquer de nouvelles valeurs en exécutant:

var customPerformanceCounter = new PerformanceCounter("Counter custom category", "# operations executed");  
customPerformanceCounter.ReadOnly = false;  
customPerformanceCounter.RawValue = 0; 
Random random = new Random(); 
while (true) 
{ 
    int newValue = random.Next(0, 5); 
    customPerformanceCounter.IncrementBy(newValue); 
    Thread.Sleep(1000); 
}

Dans cette exemple, on ne précise qu’une seule valeur car avec un compteur de type NumberOfItems32, la valeur est utilisée directement (voir plus haut).

Les méthodes de la classe System.Diagnostics.PerformanceCounter permettant d’ajouter des valeurs sont:

  • Increment(): incrémente la valeur d’une unité.
  • IncrementBy(): incrémente ou décrémente la valeur d’une certaine valeur.
  • Decrement(): décrémente la valeur d’une unité.
  • RawValue: indique une valeur brute.
Utilisation des PerformanceCounters en terme de performances

L’utilisation de PerformanceCounter n’est pas anodin sur les performances du processus. Les méthodes Increment(), IncrementBy() et Decrement() utilisent des “locks” pour éviter les accès concurrents, ce qui ralentit l’exécution.
L’utilisation de PerformanceCounter.RawValue est plus rapide car l’affectation n’utilise pas de “lock”.

Enfin si plusieurs processus alimentent des valeurs dans le compteur de performance en utilisant PerformanceCounter.Increment() ou PerformanceCounter.Decrement(), les différentes contributions de valeurs se cumulent.

RatesOfCountsPerSecond32

Comme indiqué précédemment (voir plus haut), ce type de compteur permet de préciser un nombre d’éléments par seconde toutefois on ne précise qu’une seule valeur.

Si on crée le compteur de cette façon:

CounterCreationData operationsPerSec = new CounterCreationData(); 
operationsPerSec.CounterName = "# operations per second executed"; 
operationsPerSec.CounterHelp = "Number of operations per second executed"; 
operationsPerSec.CounterType = PerformanceCounterType.RatesOfCountsPerSecond32; 
counters.Add(operationsPerSec);

On peut l’utiliser de cette façon:

var operationsPerSecCounter = new PerformanceCounter("Counter custom category",  
    "# operations per second executed");  
operationsPerSecCounter.ReadOnly = false;  
operationsPerSecCounter.RawValue = 0; 
Random random = new Random(); 
while (true) 
{ 
    int newValue = random.Next(0, 5); 
    operationsPerSecCounter.IncrementBy(newValue); 
    Thread.Sleep(50); 
}

AverageTimer32

Comme indiqué précédemment (voir plus haut), les compteurs de type AverageTimer32 sont couplés avec un compteur de type AverageBase contenant les valeurs du dénominateur dans la formule de calcul des valeurs affichées.
On doit donc préciser 2 valeurs en utilisant le compteur de type AverageTimer32 et le compteur de type AverageBase.

Si on crée les compteurs de cette façon:

CounterCreationData averageOperationsPerSec = new CounterCreationData(); 
averageOperationsPerSec.CounterName = "Average operations per second executed"; 
averageOperationsPerSec.CounterHelp = "Average of operations per second executed"; 
averageOperationsPerSec.CounterType = PerformanceCounterType.AverageTimer32; 
counters.Add(averageOperationsPerSec); 

CounterCreationData averageOperationsPerSecBase = new CounterCreationData(); 
averageOperationsPerSecBase.CounterName = "Base average operations per second executed"; 
averageOperationsPerSecBase.CounterHelp = "Base average of operations per second executed"; 
averageOperationsPerSecBase.CounterType = PerformanceCounterType.AverageBase; 
counters.Add(averageOperationsPerSecBase);

On peut les utiliser de cette façon:

var averageOperationsPerSec = new PerformanceCounter("Counter custom category",  
    "Average operations per second executed", false);  
var averageOperationsPerSecBase = new PerformanceCounter("Counter custom category",  
    "Base average operations per second executed", false);  
 
Random random = new Random(); 
while (true) 
{ 
    int newValue = random.Next(0, 5); 
    averageOperationsPerSec.RawValue = newValue; 
    averageOperationsPerSecBase.IncrementBy(10); 
    Thread.Sleep(1000); 
}

Lire un compteur de performance

Pour lire les mesures d’un compteur de performance, il faut définir le compteur comme précédemment et utiliser les fonctions PerformanceCounter.NextSample() ou PerformanceCounter.NextValue():

var customPerformanceCounter = new PerformanceCounter("Counter custom category", "# operations executed");   
while (true)  
{  
    CounterSample sample = customPerformanceCounter.NextSample();  
    Thread.Sleep(1000);  
}

PerformanceCounter.NextSample() permet d’obtenir un objet de type CounterSample contenant des propriétés contenant les valeurs comme CounterSample.RawValue pour obtenir la valeur brute.
On peut aussi obtenir cette valeur directement avec:

Float nextValue = CounterSampler.NextValue();

QueryPerformanceCounter

Utiliser QueryPerformanceCounter plutôt que System.DateTime.Now est plus précis pour mésurer des temps d’exécution. QueryPerformanceCounter est une fonction de l’API Win32.

Pour l’utiliser, il faut faire un appel en utilisant Platform Invoke:

[DllImport("Kernel32.dll")] 
public static extern void QueryPerformanceCounter(ref long ticks); 
 
// ...
 
long startTime = 0; 
long endTime = 0; 
 
// Getting start time 
QueryPerformanceCounter(ref startTime); 
 
// Simulating processing time 
Thread.Sleep(40); 
 
// Getting end time 
QueryPerformanceCounter(ref endTime); 
 
long executionTime = endTime - startTime; 

Synthèse des fonctionnalités du langage C# par version

Pour avoir plus de détails sur la version du langage par rapport aux versions de framework, se reporter à Versions des composants .NET.

C# 6.0

    Visual Studio 2015

  • Version implémentée avec Roslyn.
  • Initialiseurs pour implémenter automatiquement les propriétés:
    public class ItemId
    { 
        public Guid Id { get; } = Guid.NewGuid();
    }
  • Utiliser des directives using static pour importer des membres statiques:
    using static System.Console;
    
    public class Driver
    {
        public static void Main()
        {
            WriteLine("Hello, World!");
        }
    }
  • Filtres d’exceptions:
    try
    {
        ...
    }
    catch (Exception ex) if (ex.Message.Contains("Invalid Operation Exception"))
    {
        ...
    }
    
    try
    {
        ...
    }
    catch (Exception ex) when (ex.Message.Contains("Invalid Operation Exception"))
    {
        ...
    }
  • Interpolation de chaines de caractères:
    En plus de la syntaxe utilisant string.Format() comme:

    class dto
    {
      public const string StringMember = "des mots";
      public const int IntMember = 34;
      public const bool BoolMember = false;
    }
    
    string s = string.Format("Chaine de caractères contenant {0} puis {1} et {2}.", 
      dto.StringMember, dto.IntMember, dto.BoolMember);

    On peut dorénavant utiliser une syntaxe plus claire:

    string s = $"Chaine de caractères contenant {dto.StringMember} puis {dto.IntMember} et {dto.BoolMember}.";
  • Initialisation indexée de membres:
    var indexedMemberDict = new Dictionary<string, string>()
    {
      ["apple"] = "pomme",
      ["tomato"] = "tomate",
      ["water"] = "eau"
    };
  • Possibilité d’utiliser await dans un bloc catch et finally,
  • Opérateur nameof: il permet de retourner le nom d’une variable ou d’une méthode sous forme de chaîne de caractères:
    Si on considère la classe:

    class SimpleClass
    {
      public static string SimpleMember { get; set; }
      public static int SimpleMethod { return -1; }
    }

    On peut obtenir directement le nom du membre SimpleMember en faisant:

    string memberName = nameof(SimpleClass.SimpleMember); // Retourne "SimpleMember"
    string methodName = nameof(SimpleClass.SimpleMethod); // Retourne "SimpleMethod"
  • Opérateur ?.:
    Cet opérateur est un équivalent de:

    string value = person != null ? person.Name : null;

    On peut dorénavant utiliser la syntaxe:

    string value = person?.person.Name;
C# 5.0

    Visual Studio 2012

  • Programmation asynchrone plus facile avec les mot-clés async et await,
  • Attribut permettant de connaître l’appelant:
    public void DoProcessing()
    {
        TraceMessage("Something happened.");
    }
    
    public void TraceMessage(string message, 
        [CallerMemberName] string memberName = "",
        [CallerFilePath] string sourceFilePath = "", 
        [CallerLineNumber] int sourceLineNumber = 0)
    { ... }
C# 4.0

    Visual Studio 2010

  • Typage dynamique avec dynamic,
  • Arguments nommés et facultatifs:
    static void Main()
    {
      MethodExample(status: "KO", size: 5);
      MethodExample(5);
    }
    
    static void MethodExample(int size, string status = "OK")
    { ... }
  • Covariance et contravariance pour les interfaces et les délégués génériques.
C# 3.0

    Visual Studio 2008

  • Ajout des mot-clés select, from et where pour la fonctionnalité LinQ,
  • Expressions Lambda:
    Action<int> deletePerson = (id) => { ... };
    Func<int, int, int> addNumbers = (a, b) => a + b;
  • Méthodes d’extension:
    public static class ListExtensionMethods
    {
        public static string GetString(this List<int> items)
        { ... }
    }
    
    // Utilisation de la méthode d'extension:
    List<int> items = new <int>();
    string listAsString = items.GetString();
  • Arbres d’expressions ("expression trees"):
    // Création d'un arbre d'expressions
    Expression<Func<int, bool>> exprTree = num => num < 5;
  • Type implicite avec var: var idList = new List();.
C# 2.0

    Visual Studio 2005

  • Types génériques:
    public class CustomList<TItem> 
    {...}
  • Méthodes anonymes:
    // Création d'un délégué
    delegate void AddNumbers(int x, int y);
    // Instantiation du délégué en utilisant une méthode anonyme
    AddNumbers d = delegate(int i, int j) { ... };
  • Types nullables: int? = null;
  • Nouvel itérateur avec le mot-clé yield.