|
|
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
Déclarer vos opérateurs de transtypage avec le mot clé C# explicit
N’implémenter pas d’interfaces sur vos structures
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.
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.
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).
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
}
}
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
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);
}
}
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
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 );
...
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 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.
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;
}
}
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'
}
}
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
|
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).
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. |