Bonnes pratiques de programmation avec C# par Patrick Smacchia (patrick@smacchia.com)

Ne pas inclure un même module contenant des types dans  deux assemblages différents 

Une instance d’une classe peut accéder aux membres privés d’une autre instance de cette même classe 

Ne créer pas de références croisées avec des  initialiseurs.

Eviter les appels aux méthodes virtuelles, au sein de constructeurs de types non finalisés (non-sealed)

Déclarer vos opérateurs de transtypage avec le mot clé C# explicit

N’implémenter pas d’interfaces sur vos structures.

Boxing et optimisation.

Bien que de type référence, les chaînes de caractères se comparent selon l’égalité.

Passer en référence une instance de type référence a un sens.

Comment le compilateur C# traduit les mots clé lock, delegate et la syntaxe de tableau ? 

Obliger vos clients à utiliser vos interfaces plutôt que vos implémentations.

Comment utiliser explicitement dans son code un type défini dans un assemblage chargé explicitement (i.e non référencé à la compilation) ?

 

Ne pas inclure un même module contenant des types dans  deux assemblages différents

En effet, si les deux assemblages se retrouvent chargés dans le même domaine d’application, le CLR considérera qu’il y a deux modules différents. En plus d’une perte de performance due au double chargement du même module, votre domaine d’application contiendra des types avec des noms similaires. De plus, les champs statiques seront eux aussi dupliqués.

 

Aussi, plutôt que d’inclure un même module contenant des types dans deux assemblages différents, nous vous conseillons de créer un assemblage bibliothèque (.dll) contenant ces types. Cet assemblage bibliothèque doit, bien entendu, être référencé à la compilation par les deux assemblages qui consomment ces types.

 

Une instance d’une classe peut accéder aux membres privés d’une autre instance de cette même classe

Puisque les membres privés d’une classe A ne sont accessibles qu’au sein de la classe, cela signifie qu’une instance I1 de la classe A peut accéder aux membres privés d’une instance I2 de la classe A. Soyez conscient de cette possibilité. En effet ceci n’a rien de logique et découle d’un choix arbitraire fait lors de la spécification du langage C#. Ce choix, qui est aussi appliqué en C++ et en Java, n’est pas forcément le même dans d’autres  langages orientés objet (par exemple dans SmallTalk).

 

Ne créer pas de références croisées avec des  initialiseurs

Les champs peuvent être initialisés directement durant leurs déclarations au sein de la classe. L’initialisation des champs (statiques ou non) est un sujet subtil. En effet, lorsque la classe est créée, les champs statiques sont d’abord initialisés à leurs valeurs par défaut. Puis ils sont initialisé avec leur valeur d’initialisation (pour ceux qui en ont une), dans leur ordre d’apparition dans la classe. Enfin le constructeur statique (voir plus loin) est appelé. Il en est de même pour les champs non statiques et les constructeurs non statiques. Tout ceci permet de faire des références croisées comme ci-dessous, et cette pratique est fortement déconseillée :

 

using System;

 

public class Prog

{

static int a = b+2;  // a référence b pour son initialisation

static int b = a+3;  // b référence a pour son initialisation

public static void Main()

{

       Console.WriteLine("a = {0}",a);  // a = 2

       Console.WriteLine("b = {0}",b);  // b = 5

}

}

 

Le cas de figure suivant porte aussi à confusion, d’ailleurs le résultat est différent du précédent. En effet, la construction de la classe CB se fait au milieu de la construction de la classe CA, puisque CA est appelée en premier mais CA utilise CB !

 

using System;

 

public class  CA

{

static public int a = CB.b+2;

}

public class CB

{

static public int b = CA.a+3;

}

public class Prog

{

public static void Main()

{

       Console.WriteLine("CA.a = {0}",CA.a);  // CA.a = 5

       Console.WriteLine("CB.b = {0}",CB.b);  // CB.b = 3

}

}

 

Eviter les appels aux méthodes virtuelles, au sein de constructeurs de types non finalisés (non-sealed)

En effet, contrairement au C++ et à l’instar de Java, en C# l’objet garde toujours le même type lors de la construction. De plus, les initialiseurs des champs de la classe dérivée sont appelés avant les initialiseurs des champs de la classe de base. Au contraire, le corps du constructeur de la classe de base est exécuté avant le corps du constructeur de la classe dérivée. Ainsi, un appel vers une méthode virtuelle dans le constructeur de la classe de base est dispatché vers une méthode de la classe dérivée. Une méthode de la classe dérivée est alors appelée avant le constructeur de cette même classe, et ceci est clairement à proscrire pour des raisons d’initialisation et de lisibilité du code.

 

Tout ceci est exposé par l’exemple suivant :

 

using System;

 

class CBase

{

int a = aInitialiseur();

protected CBase()

{

       Console.WriteLine("CBase.CBase()");

       f();

}

public virtual void f()

{

       Console.WriteLine("CBase.f()");

}

static int aInitialiseur()

{

       Console.WriteLine("CBase.aInitialiseur()");

       return 1;

}

}

 

class CDérivée : CBase

{

int b = bInitialiseur();

int i = 0;

public CDérivée()

{    

       i = 1;

       Console.WriteLine("CDérivée.CDérivée() i vaut {0}",i);

}

public override void f()

{

       Console.WriteLine("CDérivée.f() i vaut {0}",i);

}

static int bInitialiseur()

{

       Console.WriteLine("CDérivée.bInitialiseur()");

       return 2;

}

}

 

class Prog

{

public static void Main()

{

       CDérivée Foo = new CDérivée();

}

}

 

Ce programme affiche :

 

CDérivée.bInitialiseur()

CBase.aInitialiseur()

CBase.CBase()

CDérivée.f() i vaut 0

CDérivée.CDérivée() i vaut 1

 

Déclarer vos opérateurs de transtypage avec le mot clé C# explicit

Il est toujours préférable de déclarer ses opérateurs de transtypage comme explicites. Dans le cas contraire votre code risque d’être permissif, c’est-à-dire que le compilateur aura à faire des choix. Au mieux il produira une erreur de compilation car il ne saura pas quel choix faire. Au pire, il ne donnera pas d’avertissements et fera le choix contraire à votre attente. Dans le programme suivant, une instance de la classe Distance peut être convertie implicitement en une instance du type  double ou une instance de la classe string. Quel choix de transtypage le compilateur doit-il faire lorsque l’on veut afficher cet objet avec la méthode Console.WriteLine(object) ?

 

using System;

 

public class Distance

{

public double m_Mesure = 0.0;

public Distance(double d){ m_Mesure = d; }

public static implicit operator double(Distance D)

{

       return D.m_Mesure;

}

public static implicit operator string(Distance D)

{

       return string.Format("Distance:{0:##.##} mètres",D.m_Mesure);

}

}

 

class Prog

{

static void Main(string[] args)

{

       Distance D1 = new Distance(5.3);

       // Erreur de compilation: Opérateur de transtypage ambiguë.

       // Doit-on transtyper D1 en un double ou un string ?

       Console.WriteLine(D1);

       // OK pas d'ambiguïté, mais il vaut mieux déclarer les

       // opérateurs de transtypage comme explicite.

       Console.WriteLine((string)D1);

}

}

  

N’implémenter pas d’interfaces sur vos structures

Voici un problème classique qui survient lorsqu’une structure implémente une interface. Si vous essayez d’accéder aux membres de l’interface implémentés par la structure à partir d’une référence vers l’interface, vous n’obtiendrez certainement pas le résultat escompté. En effet, lors du transtypage de la structure vers l’interface, une opération de boxing est implicitement réalisée car l’interface à besoin d’une référence. Pour vous en convaincre, voyez l’exemple suivant :

 

using System;

 

interface IInterface

{

void SetState(int i);

int  GetState();

}

 

struct Struct : IInterface

{

private int i;

public void SetState(int i)

{

       this.i = i;

}

public int GetState()

{

       return i;

}

}

 

class Prog

{

static void Main(string[] args)

{

       Struct S = new Struct();

       // Ici un boxing de la structure est réalisé implicitement.

       IInterface I = (IInterface) S;

       S.SetState(10);

       I.SetState(20);

       Console.WriteLine("Retour de S.GetState():"+S.GetState());

       Console.WriteLine("Retour de I.GetState():"+I.GetState());

}

}

 

Ce programme affiche :

Retour de S.GetState():10

Retour de I.GetState():20

 

En règle générale il vaut mieux ne pas implémenter d’interface dans les structures. Si vous n’avez pas d’autres possibilités, il vaut mieux appeler les méthodes de l’interface à partir de l’implémentation, contrairement à ce qui est conseillé pour les classes. Pour les plus sceptiques, l’analyse avec ildasm.exe du code CIL généré pour la méthode Main() montre clairement qu’il y a une opération de boxing réalisée. Notez que les deux autres opérations de boxing sont nécessaires pour pouvoir afficher l’état l’entier retourné par la méthode GetState():

 

.method private hidebysig static void  Main(string[] args) cil managed

{

  .entrypoint

  // Code size       86 (0x56)

  .maxstack  2

  .locals ([0] valuetype Struct S,

           [1] class IInterface I)

  IL_0000:  ldloca.s   S

  IL_0002:  initobj    Struct

  IL_0008:  ldloc.0

  IL_0009:  box        Struct  // <- c’est ici que l’opération de boxing

                               //    de la structure a implicitement lieu.

  IL_000e:  stloc.1

  IL_000f:  ldloca.s   S

  IL_0011:  ldc.i4.s   10

  IL_0013:  call       instance void Struct::SetState(int32)

  IL_0018:  ldloc.1

  IL_0019:  ldc.i4.s   20

  IL_001b:  callvirt   instance void IInterface::SetState(int32)

  IL_0020:  ldstr      "Retour de S.GetState():"

  IL_0025:  ldloca.s   S

  IL_0027:  call       instance int32 Struct::GetState()

  IL_002c:  box        [mscorlib]System.Int32

  IL_0031:  call       string [mscorlib]System.String::Concat(object,object)

  IL_0036:  call       void [mscorlib]System.Console::WriteLine(string)

  IL_003b:  ldstr      "Retour de I.GetState():"

  IL_0040:  ldloc.1

  IL_0041:  callvirt   instance int32 IInterface::GetState()

  IL_0046:  box        [mscorlib]System.Int32

  IL_004b:  call       string [mscorlib]System.String::Concat(object,object)

  IL_0050:  call       void [mscorlib]System.Console::WriteLine(string)

  IL_0055:  ret

} // end of method Prog::Main

 

Boxing et optimisation

L’opération de boxing a un coût non négligeable, aussi il peut être tentant d’essayer de l’optimiser. Par exemple, le premier des deux programmes suivants n’utilise qu’une seule fois le boxing sur la variable i alors que le second boxe la variable i deux fois. Cependant, ces programmes ne sont pas équivalents (le premier affiche "Références différentes" alors que le second affiche "Même références").

 

using System;

 

class Prog

{

static void f(object o1,object o2)

{

       if(o1==o2)

             Console.WriteLine("Même références");

       else

             Console.WriteLine("Références différentes");

}

public static void Main( String[] argv )

{

       int i = 9 ;

       f(i,i); /* On a deux boxing de i donc deux références différents vers i */

}

}

 

using System;

 

class Prog

{

static void f(object o1,object o2)

{

       if(o1==o2)

             Console.WriteLine("Même références");

       else

             Console.WriteLine("Références différentes");

}

public static void Main( String[] argv )

{

       int i = 9 ;

      object o = i;

       f(o,o); /* pas de boxing car o est déjà une référence */

}

}

 

Il vaut donc mieux ne pas essayer d’optimiser l’utilisation du boxing. Cependant, il existe un cas précis où l’optimisation du boxing peut se révéler très payante. Lors du parcours des éléments d’un dictionnaire avec la syntaxe foreach, une instance de la structure DictionaryEntry est unboxée lors de chaque itération. Pour vous en convaincre, observez le programme suivant ainsi que son analyse par ildasm.exe.

 

using System;

using System.Collections;

 

class Prog

{

public static void Main()

{

       // Capacité initiale de 100.

       Hashtable Tbl = new Hashtable(100);

       Tbl.Add("France","20 Rue Arson");

       Tbl.Add("Francis","90 Rue Barberis");

       foreach( DictionaryEntry DE in Tbl)

             Console.WriteLine(DE.Key + " : " + DE.Value );

}

}

 

...

  IL_0031:  ldloc.2

  IL_0032:  callvirt   instance object

               [mscorlib]System.Collections.IEnumerator::get_Current()

  IL_0037:  unbox      [mscorlib]System.Collections.DictionaryEntry

  IL_003c:  ldobj      [mscorlib]System.Collections.DictionaryEntry

...

 

Le code suivant, strictement équivalent, rend les opérations de unboxing pour chaque structure DictionaryEntry, plus évidentes :

 

...

foreach( Object obj in Tbl)

{

       // La ligne suivante implique une opération de unboxing

       DictionaryEntry DE = (DictionaryEntry) obj;

       Console.WriteLine(DE.Key + " : " + DE.Value );

}

...

 

Pour éviter ces opérations de boxing sur la structure DictionaryEntry, il faut utiliser le code suivant. Il est moins convivial, puisqu’il n’utilise pas les mots-clés foreach et in, mais il est plus performant :

 

...

IDictionaryEnumerator E = Tbl.GetEnumerator();

while( E.MoveNext() )

       Console.WriteLine(E.Key + " : " + E.Value );

...

 

Bien que de type référence, les chaînes de caractères se comparent selon l’égalité 

Cela vaut la peine d’être noté, dans la mesure où la classe string est de type référence.

 

class Prog

{

public static void Main()

{

       string s1 = "hello";

       string s2 = "hel";

       string s3 = "lo";

       string s4 = s2+s3;

       bool b = (s1 == s4);

       // Ici, bien que s1 et s4 référencent à l'évidence

       // deux objets différents, b vaut true.

}

}

 

Passer en référence une instance de type référence a un sens

Passer un argument de type référence par référence permet à la méthode appelée d’agir directement sur la référence. Concrètement, la méthode appelée peut modifier l’objet qui est référencé par la référence passée. Pour les programmeurs C/C++, cela ne sera pas sans rappeler l’utilisation de double pointeurs. Ceci est illustré dans l’exemple suivant : 

 

public class Article

{

public int Prix = 0;

}

class Prog

{

static void Main(string[] args)

{

       Article ArticleA = null;

       Article ArticleB = null;

       // Article est un type référence

       fct(  ArticleA , ref ArticleB);

       // Ici,

       // ArticleA ne référence aucun objet,

       // c’est toujours une référence nulle.

       // ArticleB référence le second objet alloué dans fct()

}

static void fct(  Article A , ref Article B)

{

       if( A == null )

             A = new Article();

       if( B == null )

             B = new Article();

}

}

 

A l’instar de cet exemple, on utilise souvent cette technique pour déléguer l’allocation d’un ou plusieurs objets, dans une méthode. Dans ce cas, il est préférable d’utiliser la technique d’argument out, exposée un peu plus loin.

 

Comment le compilateur C# traduit les mots clé lock, delegate et la syntaxe de tableau ?

Le mot clé C# lock provoque l’utilisation de la classe System.Threading.Monitor. Les trois programmes suivants sont ainsi équivalents. Vous pouvez aussi vérifier avec l’outil ildasm.exe que l’utilisation du mot clé lock provoque aussi la génération d’un bloc try/finally :

class Prog

{

static int i = 0;

static void Main()

{

       try

       {

             System.Threading.Monitor.Enter( typeof(Prog) );

             i++ ;

       }

       finally

       {

             System.Threading.Monitor.Exit( typeof(Prog) );

       }

}

}

 

class Prog

{

static int i = 0;

static void Main()

{

       lock( typeof(Prog) )

       {

             i++ ;

       }

}

}

 

class Prog

{

static int i = 0;

static void Main()

{

       lock( typeof(Prog) )

             i++ ;

}

}

 

Le mot clé delegate provoque la création d’une classe qui dérive de la classe System.MulticastDelegate. Notez que le compilateur C# interdit d’utiliser directement la classe System.MulticastDelegate. Voici un exemple de programme utilisant un délégué, ainsi que son analyse avec l’outil ildasm.exe :

 

class Prog

{

delegate string D(string s);

static void Main()

{

       D d = new D(f);

       string s = d("Foo");

}

static string f(string s)

{

       return s+s;

}

}

Il est intéressant de noter que les méthodes BeginInvoke() EndInvoke() (utilisée pour les appels asynchrones) ainsi que la méthode Invoke() (utilisée pour les appels synchrones) du type de délégué D, ont été générées à la compilation, car elles dépendent de la signature du de la délégation. Ainsi, vous pouvez utiliser ces méthodes dans votre code, mais l’outil intellisense qui parse les types avant la compilation ne les connaît pas.

 

La déclaration de tableaux en C# avec la syntaxe qui utilise les crochets, oblige le CLR à synthétiser des types durant l’exécution. Ces types ont tous la particularité commune de dériver de la classe System.Array. Ainsi, vous pouvez utiliser les nombreuses méthodes (bien pratiques) de la classe System.Array sur vos tableaux. Par exemple :

 

class Prog

{

static double [] Tab = {1.1,2.1,3.1};

static void Main()

{

       int i = Tab.GetLength(0);

}

}

 

Un certain niveau de compatibilité existe entre ces types (dépendant du nombre de dimensions et des types des éléments compatibles), mais il vaut mieux ne pas compter sur cette compatibilité pour transtyper un tableau. Par exemple, le programme suivant compile et s’exécute correctement :

 

class CFoo

{

public CFoo(double d){this.d=d;}

public double d;

}

 

class Prog

{

static CFoo [] Tab1 = {new CFoo(1.1),new CFoo(2.1),new CFoo(3.1)};

static object [] Tab2;

static void Main()

{

       Tab2 = Tab1;

}

}

 

Obliger vos clients à utiliser vos interfaces plutôt que vos implémentations.

Le langage C# offre une possibilité inédite par rapport aux langages C++ et Java. Vous pouvez forcer les clients de vos classes à utiliser une abstraction (i.e une référence sur une interface) plutôt qu’une implémentation (i.e une référence vers une instance d’une classe qui implémente l’interface). Cette possibilité se révèle extrêmement utile pour les concepteurs de bibliothèques de classe. Ils ont une technique qui fait partie intégrante du langage, pour obliger le code de leurs clients à être découplé des implémentations. Pour les architectes logiciels, le découplage des classes doit être l’objectif principal avec la cohérence du code. 

 

Voici un exemple. La méthode fct2() ne peut être appelée à partir d’une référence de type CClass mais peut être appelée à partir d’une référence de type IInterface. Dans un cas réel, on aurait sûrement forcé l’utilisation de l’interface sur toutes les méthodes présentées par l’interface.

 

using System;

 

interface IInterface

{

void fct1();

void fct2();

}

 

public class CClass : IInterface

{

public void fct1() {Console.WriteLine("fct1 appelée");}

void IInterface.fct2() {Console.WriteLine("fct2 appelée");}

}

 

public class Prog

{

public static void Main()

{

       CClass Impl = new CClass();

       IInterface Abst = (IInterface) Impl;

       Abst.fct1();  // Compilateur OK

       Abst.fct2();  // Compilateur OK

       Impl.fct1();  // Compilateur OK

       Impl.fct2();  // Compilateur KO : Le message d’erreur est:

       // 'CClass' does not contain a definition for 'fct2'

}

}

 

Comment utiliser explicitement dans son code un type défini dans un assemblage chargé explicitement (i.e non référencé à la compilation) ?

Lorsque vous utilisez explicitement dans le code l’assemblage A un type défini dans un assemblage B, il faut que A référence B à la compilation (option /r ou référence dans Visual Studio). Dans le cas contraire, le compilateur produit une erreur ‘undefined type’.

Or, il n’est pas toujours souhaitable qu’un assemblage en référence un autre par exemple pour les raisons suivantes :

·         L’assemblage à référencer n’est pas disponible à la compilation.

·         Un assemblage .exe ne peut référencer un autre assemblage .exe.

·         Vous souhaitez contrôler le moment du chargement d’un assemblage, par exemple pour des raisons de performance (juste avant une opération critique). Or, un assemblage référencé est chargé implicitement par le CLR, à un moment indéterminé avant l’utilisation de celui ci (je précise à ce sujet qu’avant une opération critique, il est aussi intéressant de déclancher explicitement une collecte du ramasse-miettes à l’aide de la méthode statique Collect() de la classe System.GC).

Bref, supposons que vous souhaitiez charger l’assemblage B explicitement à partir de l’assemblage A, par exemple en utilisant une des méthodes Assembly.Load(), Assembly.LoadFrom(), Assembly.LoadWithPartialName(), AppDomain.ExecuteAssembly() ou AppDomain.Load(). Il est clair que vous ne pouvez pas utiliser explicitement des types de l’assemblage B dans le code de l’assemblage A, puisque à la compilation de A ces types ne sont pas définis. En revanche, vous pouvez toujours définir les membres de ces types dans des interfaces, définies dans un troisième assemblage C. Bien entendu les types de B implémentent ces interfaces et C est référencé à la compilation de A et de B. Récapitulons tout ceci en présentant un exemple :

 

C.cs

public interface IFoo

{

void Hello();

}

B.cs

using System;

class CFoo : IFoo

{

public void Hello()

{

       Console.WriteLine("Hello");

}

}

A.cs

using System;

using System.Reflection;

class Prog

{

static public void Main()

{

       Assembly AssemblyB = Assembly.Load("B");

       Type CFooType = AssemblyB.GetType("CFoo");

       IFoo aFoo = (IFoo) Activator.CreateInstance(CFooType);

       // utilisation explicite

       aFoo.Hello();

}

}

 

Compilation :

csc.exe /target:library C.cs

csc.exe /target:library B.cs /r:C.dll

csc.exe /target:exe A.cs /r:C.dll

 

Nous profitons aussi de cette remarque pour vous conseiller d’utiliser les méthodes Assembly.Load() et AppDomain.Load() pour charger explicitement un assemblage. En effet, les méthodes Assembly.LoadFrom() et AppDomain.ExecuteAssembly() ne sont pas dans l’esprit .NET car elles ne provoquent pas le déclenchement de l’algorithme de localisation des assemblages à charger du CLR. Cependant la méthode AppDomain.ExecuteAssembly() peut se révéler intéressante du fait qu’elle évite la consultation des métadonnées pour trouver et invoquer la méthode Main(). Enfin, n’utilisez jamais la méthode Assembly.LoadWithPartialName() qui peut provoquer un comportement non déterministe quand au choix de l’assemblage à charger.

  

 

Auteur : Patrick Smacchia

Copyright © Juin 2003

 

Qui est Patrick Smacchia ?

Patrick Smacchia assure de nombreuses formations sur .NET, à la fois dans l’industrie et dans le milieu universitaire (à l’IUT de Nice). Passionné par l’architecture logicielle, il aide les entreprises à concevoir et à développer leurs applications. Ingénieur diplômé de l’ENSEEIHT, il a notamment collaboré avec AMADEUS et avec les divisions espace et téléphonie mobile d’ALCATEL. Son site www.smacchia.com expose plus en détail ses activités. Ses compétences ont été reconnues par Microsoft France, ce qui lui a valu la distinction MVP .NET (Most Valuable Professional sur les technologies .NET).

 

 L’ouvrage Pratique de .NET et C# (O’Reilly 2003)

Après avoir construit plusieurs applications d’entreprises avec C++/win32/COM/COM+/DCOM et Java/J2EE, il s’est tout naturellement intéressé à .NET. De cette rencontre est né l’ouvrage Pratique de .NET et C# (O’Reilly 2003) qui couvre la plupart des aspects du développement sous .NET avec le langage C# (architecture .NET sous jacente ; langage C# ; bibliothèques ADO.NET, XML, WinForm, GDI+… ; architectures distribuées avec COM+, .NET Remoting et ASP.NET). Cet ouvrage contient de nombreux rappels pour le rendre accessible aux étudiants et aux débutants. Les développeurs confirmés pourront quant à eux rapidement exploiter les subtiles possibilités proposées par .NET, que sont par exemple la réflexion, la programmation orientée aspect ou le mécanisme d’attribut.