Arbre logique et arbre visuel WPF en 2 min

En WPF, les éléments visuels sont organisés hiérarchiquement avec une structure en arbre: des éléments sont ajoutés au contenu d’autres éléments formant des nœuds dans un arbre d’objets. Le developpeur implémente les relations entre les objets formant ainsi un arbre logique (i.e. logical tree). Lorsque les objets sont dessinés puis affichés au runtime, les relations entres les objets peut différer de celles de l’arbre logique, ces relations utilisées pendant l’affichage est l’arbre visuel (i.e. visual tree).

Hiérarchie des classes d’objets

Avant de rentrer dans les détails des arbres d’objets, on peut rappeler la hiérarchie des classes des objets principaux en WPF. Il ne faut pas confondre la hiérarchie des classes avec les arbres logiques et visuels:

  • Hiérarchie des classes: arbre d’héritage des types d’objets.
  • Arbre logique et arbre visuel: structures de composition des objets valables seulement au runtime.

La hiérarchie des classes des objets est:

Les classes se décomposent de la façon suivante:

  • System.Object: toutes les classes dérivent de ce type.
  • System.Windows.Threading.DispatcherObject: ce sont des objets utilisant le Dispatcher WPF (boucle de messages WPF). L’accès à ces objets est limité au thread ayant créé l’objet.
  • System.Windows.DependencyObject: classe de base des objets supportant les i>dependency properties.
  • System.Windows.Freezable: classe de base des objets pouvant être figés dans un état en lecture seule pour des raisons de performance. Ce sont, le plus souvent, primitives graphiques comme les brushes, les pens, les classes géométriques ou les classes d’animation. Lorsque ces objets sont figés, ils peuvent être partagés entre plusieurs threads (contrairement aux objets de type DispatcherObject). Ces objets restent toujours figés et peuvent être cloné si besoin.
  • System.Windows.Media.Visual: classe de base des objets ayant une représentation visuel 2D.
  • System.Windows.UIElement: classe de base des objets 2D supportant les fonctionnalités WPF de routed events, des command bindings, du système de layout et de focus.
  • System.Windows.ContentElement: classe de base similaire à UIElement n’ayant pas un comportement de rendu visuel. Ces objets sont contenus par un objet de type Visual qui peut avoir un rendu graphique. Un objet de type ContentElement a besoin de plusieurs objets Visual pour avoir un rendu graphique complet.
  • System.Windows.FrameworkElement: ajoute au UIElement une gestion des styles, du data binding, des ressources, des mécanismes de tooltip et de menu contextuel.
  • System.Windows.FrameworkContentElement: similaire à FrameworkElement pour des objets ayant un contenu.
  • System.Windows.Controls.Control: classe de base des controls WPF. Cette classe permet de rajouter des propriétés comme Foreground, Background ou FontSize.

Arbre logique

Comme indiqué plus haut, les objets WPF sont organisés dans une structure en arbre c’est-à-dire que les instances d’objets sont créées et rangées dans d’autres objets suivant une relation de composition.

Il y a donc des éléments contenus dans d’autres éléments, une relation d’enfant entre un objet et un autre et inversement une notion de parent.

Le but de l’arbre logique est de gérer de façon uniforme les objets de type FrameworkElement et FrameworkContentElement suivant leurs relations d’appartenance.

Ainsi la notion d’arbre logique est responsable des fonctionnalités:

  • La propriété FrameworkElement.Parent: cette propriété présente dans tous les objets de type FrameworkElement permet d’avoir une relation Parent-Enfant pour tous ces objets, definissant ainsi la relation d’appartenance.
  • Héritage des valeurs des DependencyProperty: certaines propriétés sont héritables comme FontFamily ou DataContext c’est-à-dire que la valeur de ces propriétés est hérité du parent logique lorsqu’il existe (i.e. logical ancestor). L’héritage des valeurs se fait entre les objets de type FrameworkElement ou FrameworkContentElement.
  • Références {DynamicResource}: lorsque le code Xaml comporte une référence {DynamicResource}, la recherche de la ressource se fait suivant l’arbre logique sur les objets possédant une propriété Resources de type ResourceDictionary. Cette recherche se fait sur les “ancêtres logiques” (i.e. logical ancestors). Resources est une propriété des objets de type FrameworkElement ou FrameworkContentElement (mais aussi System.Windows.Application).
  • Recherche du nom: quand il faut chercher le nom d’un élément, par exemple, quand on écrit dans le code Xaml: {Binding ElementName=OtherElement}, la recherche se fait aussi suivant les “ancêtres logiques”.
  • Routed events: les “évènements routés” (i.e. routed events) lorsqu’ils sont déclenchés, suivent l’arbre logique du haut vers le bas pour les évènements “tunnel” (i.e. tunneling routed events) ou du bas vers le haut pour les évènements “bubble” (i.e. bubbling routed events).

L’ajout ou la suppression se fait par l’intermédiaire des fonctions FrameworkElement.AddLogicalChild ou FrameworkElement.RemoveLogicalChild de façon implicite lorsqu’on affecte une valeur aux objets, par exemple, par l’intermédiaire de la propriété ContentControl.Content.

L’ajout d’enfants se ne fait forcement de la même façon pour tous les objets mais varie suivant leurs spécifités:

Namespace Classe Propriétés
System.Windows.Controls AdornedElementPlaceholder Child
ContentControl Content
Decorator Child
Grid Children (hérité de Panel)
Columns
Rows
HeaderedItemsControl Items (hérité de ItemsControl)
Header
ItemsControl Items
Page Content
Panel Children
RichTextBox Document
TextBlock Text
TextBox Text
ViewBox Child
System.Windows.Controls.Primitives DocumentViewerBase Document
Popup Child
System.Windows.Documents PageContent Child
Table RowGroups
Columns
Span Inlines

Arbre visuel

L’arbre visuel est une notion de relations entre les objets lorsqu’ils ont un rendu graphique. Comme pour l’arbre logique, il existe une relation d’appartenance toutefois cette relation se fait sur les objets de type System.Windows.Media.Visual.

L’arbre visuel peut être similaire à l’arbre logique toutefois, d’une façon générale, il comporte plus d’éléments car à un élément de l’arbre logique correspondant un ou plusieurs éléments de l’arbre visuel. L’arbre logique peut ne pas exister mais l’arbre visuel existe toujours.

Par exemple quand on écrit:

<Window> 
    <StackPanel> 
        <Label Content="Label" /> 
        <Button Content="Button" /> 
    </StackPanel> 
</Window>

L’arbre visuel est:

L’arbre logique comporte les éléments écrits dans le code Xaml: Window, StackPanel, Label et Button alors que l’arbre visuel comporte d’autres éléments nécessaires à l’affichage.

Ainsi l’arbre visuel sert à:

  • Effectuer le rendu visuel des éléments,
  • Héritage des valeurs des propriétés: si un élément n’a pas de parent dans l’arbre logique alors la règle d’héritage de la valeur d’une propriété héritable se fait suivant l’arbre visuel au lieu de l’arbre logique.
  • Références {DynamicResource}: de même, s’il n’y a pas d’arbre logique, c’est l’arbre visuel qui est utilisé pour la recherche d’une ressource.
  • Routed events: les “évènements routés” parcourent aussi l’arbre visuel s’il est différent de l’arbre logique.
  • Propagation des propriétés UIElement.Opacity, UIElement.IsEnabled: la recherche de valeur de ces propriétés se fait suivant l’arbre visuel.
  • Transformations: les transformations UIElement.RenderTransform et UIElement.LayoutTransform sont prises en compte dans un élément suivant les transformations appliqués à son parent dans l’arbre visuel.

LogicalTreeHelper et VisualTreeHelper

Il est possible de circuler le long des arbres logiques et visuels en s’aidant des fonctions des classes statiques System.Windows.LogicalTreeHelper et System.Windows.Media.VisualTreeHelper.

La classe System.Windows.LogicalTreeHelper possède les fonctions:

  • GetChildren(): pour chercher les enfants logiques d’un élément de type DependencyObject, FrameworkContentElement ou FrameworkElement.
  • GetParent(): pour trouver le parent logique d’un élément de type DependencyObject.
  • FindLogicalNode(): pour un objet enfant suivant son nom dans l’arbre logique.

De même, le classe System.Windows.Media.VisualTreeHelper possède des fonctions similaires:

  • GetChild(): renvoie l’élément enfant d’un objet de type DependencyObject situé à un index donné dans une collection d’objets enfants.
  • GetChildCount(): renvoie le nombre d’éléments enfant.
  • GetParent(): renvoie le parent d’un objet de type DependencyObject.

Chercher un type particulier parmi les enfants

Pour chercher récursivement parmi les enfants d’un objet de type DependencyObject, un objet suivant son type:

public static T FindChild<T>(DependencyObject parent)  
    where T : DependencyObject 
{ 
    if (parent == null) return null; 
 
    for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) 
    { 
        var child = VisualTreeHelper.GetChild(parent, i); 
 
        var result = (child as T) ?? FindChild<T>(child); 
        if (result != null) return result; 
    } 
    return null; 
}

Cherche un élément avec un nom particulier parmi les enfants

De même on peut effectuer la recherche de façon récursive en cherchant un type et un nom particulier:

public static T FindChild<T>(DependencyObject parent, string childName) 
   where T : DependencyObject 
{     
  if (parent == null) return null; 
 
  T foundChild = null; 
 
  int childrenCount = VisualTreeHelper.GetChildrenCount(parent); 
  for (int i = 0; i < childrenCount; i++) 
  { 
    var child = VisualTreeHelper.GetChild(parent, i); 
 
    T childType = child as T; 
    if (childType == null) 
    { 
      foundChild = FindChild<T>(child, childName); 
 
      if (foundChild != null) break; 
    } 
    else if (!string.IsNullOrEmpty(childName)) 
    { 
      var frameworkElement = child as FrameworkElement; 
      if (frameworkElement != null && frameworkElement.Name == childName) 
      { 
        foundChild = (T)child; 
        break; 
      } 
    } 
    else 
    { 
      foundChild = (T)child; 
      break; 
    } 
  } 
 
  return foundChild; 
}

Chercher tous les enfants ayant un type particulier

Pour chercher tous les descendants ayant un type particulier:

public static IEnumerable<T> FindVisualChildren<T>(DependencyObject parent)  
    where T : DependencyObject 
{ 
    if (parent != null) 
    { 
        for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) 
        { 
            DependencyObject child = VisualTreeHelper.GetChild(parent, i); 
            if (child != null && child is T) 
            { 
                yield return (T)child; 
            } 
 
            foreach (T childChildren in FindVisualChildren<T>(child)) 
            { 
                yield return childChildren; 
            } 
        } 
    } 
}

Débugguer l’arbre visuel

Il est possible de voir l’arbre visuel des objets WPF d’une application au runtime avec des outils particuliers. Par exemple Snoop 2.8.0 est un outil très puissant:

  • Voir les objets de l’arbre visuel,
  • Voir et modifier les propriétés des objets de l’arbre visuel,
  • Voir le trajet des évènement routés dans le sens “tunnel” (généralement préfixés par Preview comme PreviewKeyDown) et dans le sens “bubble” (par exemple KeyDown),
  • Localiser un objet directement sur l’interface de l’application à partir de l’arbre visuel: cette fonctionnalité est particulièrement utile pour débugguer l’héritage des propriétés, l’application de styles…
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Quelques méthodes pour cloner des objets en .NET

Parfois pour certains traitements, il est nécessaire de cloner l’instance d’un objet en une ou plusieurs autres instances distinctes. Suivant la complexité de l’objet ou suivant le nombre de copies nécessaires, cette copie peut être plus ou moins complexe à effectuer. D’autre part, dans le cas où on veut dupliquer un objet en plusieurs instances, il peut être nécessaire d’optimiser la duplication pour qu’elle soit la plus rapide possible. Enfin, suivant le type des objets à dupliquer, on peut vouloir rendre générique le procédé de duplication pour qu’il puisse s’adapter à plusieurs types d’objets.

Le but de cet article est de présenter quelques procédés de duplication d’instances d’un objet.

Quelque soit le procédé choisi pour dupliquer une instance d’un objet:

  • On souhaite que les instances dupliquées comporte des valeurs identiques concernant les champs (données membres privées) et les propriétés. De même que pour les champs, les valeurs des propriétés doivent être identiques même si l’accès en écriture à la propriété est privé.
  • Il faudrait que le procédé de duplication minimise les changements nécessaires à l’objet pour qu’il puisse être dupliqué: présence d’un constructeur sans paramètres, présence de l’attribut Serializable ou données membres accessibles dont l’accès en lecture et écriture est publique.
Choix de la méthode pour effectuer la duplication:

Il n’y a pas de méthodes parfaites pour effectuer la duplication d’objets, la méthode choisie dépend du contexte:

  • Possibilité d’adapter les objets à dupliquer: certaines méthodes nécessitent d’adapter les objets à dupliquer, par exemple, en ajoutant un constructeur sans paramètres ou en ajoutant un attribute particulier. La méthode choisie dépend de la façon dont on peut intervenir sur l’objet pour le rendre duplicable.
  • Nombre d’objets à dupliquer: si on doit dupliquer un grand nombre d’objets, on peut chercher à optimiser la vitesse de duplication.
  • Hétérogénéïté des objets: si les types des objets sont très différents, on peut privilégier une méthode générique plutôt qu’une méthode qui nécessite de modifier les classes à dupliquer.
  • Complexité des objets: suivant les couches s’abstractions des objets à dupliquer, on peut privilégier des méthodes plus génériques.

Dans tous les cas, une bonne approche est d’utiliser des tests pour vérifier le comportement de la méthode choisie.

On distingue 2 familles de méthodes pour dupliquer des objets:

  • Des méthodes nécessitant de modifier les objets à dupliquer: ces méthodes sont généralement faciles à mettre en œuvre. Elles sont possibles si on peut modifier l’implémentation des objets et si les modifications n’impactent pas trop d’objets.
  • Des méthodes génériques: ces méthodes ne nécessitent pas de connaître les objets à dupliquer. Elles sont à privilégier si on ne peut pas modifier l’implémentation des objets ou si la duplication s’effectue sur des objets de type très hétérogènes.

Quelques définitions en préambule

“Shallow copy” et “deep copy”

On distingue 2 types de duplication:

  • Shallow copy (i.e. copie superficielle): ce sont avant tout des copies simples et rapides d’un objet. Dans un contexte C#, ce type de duplication copie les valeurs des membres qui sont de type valeur (struct et enum). Pour les objets de type référence (classes, interfaces et delegates), seule la référence est dupliquée ce qui signifie que la référence d’origine et la référence dupliquée pointe vers le même objet.
    Les objets de type System.String sont des objets de type référence (car ils dérivent de Object). Toutefois sachant que les strings sont immutables (c’est-à-dire qu’il n’est pas possible de les modifier sans créer une nouvelle instance), dans le cadre d’une copie superficielle, elle sont dupliquées complêtement au même titre que les objets de type valeur.
  • Deep copy (i.e. copie en profondeur): tous les membres de l’objet d’origine sont dupliqués vers des valeurs et des instances distinctes quelque soit la complexité du membre.

Interface System.ICloneable

A partir du framework 2.0, l’interface System.ICloneable permet de décorer les objets de façon à ce qu’ils implémentent la méthode Clone():

public interface ICloneable 
{ 
    object Clone(); 
}

Cette interface n’indique pas si la copie est superficielle ou en profondeur, le choix est laissé au soin du développeur. Le 2e inconvénient de cette interface est qu’elle impose que l’objet cloné est de type Object. Il faut donc effectuer un cast sur le résultat de ICloneable.Clone() pour l’utiliser:

ICloneable cloneableObject = new CloneableObject(); 
CloneableObject objectClone = (CloneableObject)cloneableObject.Clone();

Méthodes nécessitant de modifier les objets à dupliquer

Implémenter ICloneable.Clone()

La méthode la plus directe pour dupliquer un objet est de le prévoir dans l’implémentation de cet objet. On peut alors implémenter la méthode ICloneable.Clone().

Par exemple, si on considère la classe:

public class Car 
{ 
    private string brand; 
    private string modelName; 
    private string reference; 
    private decimal price; 
    private Engine engine; 
    private Person owner; 
 
    public string Brand { get { return this.brand; } } 
    public string ModelName { get { return this.modelName; } } 
    public string Reference { get { return this.reference; } } 
    public decimal Price { get { return this.price; } } 
    public Engine Engine { get { return this.engine; } } 
    public Person Owner { get { return this.owner; } } 
 
 
    public Car(string brand, string modelName, string reference,  
        decimal price, Engine engine, Person owner) 
    { 
        this.brand = brand; 
        this.modelName = modelName; 
        this.reference = reference; 
        this.price = price; 
        this.engine = engine; 
        this.owner = owner; 
    } 
}

avec:

public class Engine  
{ 
    private string reference; 
    private string serialNumber; 
 
    public int Power { get; set; } 
    public string Reference { get { return this.reference; } } 
    public string SerialNumber { get { return this.serialNumber; } } 
 
    public Engine(int power, string reference, string serialNumber) 
    { 
        this.Power = power; 
        this.reference = reference; 
        this.serialNumber = serialNumber; 
    } 
}

Et:

public struct Person 
{ 
    public string FirstName { get; set; } 
    public string LastName { get; set; } 
}

On peut implémenter ICloneable.Clone() dans la classe Car:

public class Car : ICloneable 
{ 
    // [...] 
 
    public object Clone() 
    { 
        return new Car(this.brand, this.modelName, this.reference, 
            this.price, this.engine, this.Owner); 
    } 
}

Les membres brand, modelName, reference et price seront bien dupliqués mais pas engine (puisqu’on ne fait que dupliquer les références). Il faut donc implémenter aussi ICloneable.Clone() pour la classe Engine:

public class Engine : ICloneable 
{ 
    // [...] 
 
    public object Clone() 
    { 
        return new Engine(this.power, this.reference, this.serialNumber); 
    } 
}

Et modifier Car.Clone() en conséquence:

public class Car : ICloneable 
{ 
    // [...] 
 
    public object Clone() 
    { 
        return new Car(this.brand, this.modelName, this.reference, this.price,  
            this.engine.Clone() as Engine, this.Owner); 
    } 
}

La structure Person est un objet de type valeur donc lorsqu’on écrit this.Owner, on obtient déjà une copie de l’objet d’origine. Il n’est donc pas nécessaire d’implémenter une duplication explicite.

L’intéret de cette méthode est sa simplicité. Si on rajoute un membre dans une classe et qu’on modifie le constructeur, on sera obligé de modifier l’implémentation de la fonction ICloneable.Clone() correspondante.
En revanche, à chaque modification des membres d’une des classes, reporter la modification dans la fonction ICloneable.Clone() peut s’avérer fastidieux. Les méthodes ICloneable.Clone() impose un cast systématique lorsqu’elles sont utilisées.

Enfin, dans le cas où on implémente des tests pour vérifier la duplication, le moindre changement dans les membres des classes va imposer de modifier aussi les tests des duplications.

Duplication dans le constructeur

Une autre méthode est de prévoir la duplication directement dans le constructeur.

Par exemple, pour les classes Engine et Car:

public class Engine  
{ 
    // [...] 
 
    public Engine(Engine templateEngine) 
        : this(templateEngine.Power, templateEngine.reference, templateEngine.SerialNumber) 
    {  
    } 
} 
 
public class Car 
{ 
    // [...] 
 
    public Car(Car templateCar) : 
        this(templateCar.Brand, templateCar.ModelName, templateCar.Reference, 
        templateCar.Price, new Engine(templateCar.Engine), templateCar.Owner) 
    { 
    } 
}

De même cette méthode est facile à mettre en œuvre et n’impose pas un cast à son utilisation.

Object.MemberwiseClone()

Cette fonction permet d’effectuer une shallow copy d’un objet de type référence. Elle va donc créer une nouvelle instance et y copiée les champs non statiques en effectuant une copie bit à bit des membres de type valeur. Comme indiqué plus haut, une shallow copy ne duplique pas les membres de type référence.
Object.MemberwiseClone() est protected, elle ne peut donc pas être appelée directement à l’extérieur de la classe à dupliquer.

Si on utilise cette méthode pour la classe Car, on peut réimplémenter la méthode ICloneable.Clone():

public class Car : ICloneable 
{ 
    private string brand; 
    private string modelName; 
    private string reference; 
    private decimal price; 
    private Engine engine; 
    private Person owner; 
 
    // [...] 
 
    public object Clone() 
    { 
        return this.MemberwiseClone(); 
    } 
}

Avec MemberwiseClone(), la structure Person est dupliquée au même titre que les autres objets de type valeur. En revanche, la classe Engine n’est pas dupliquée, seule la référence est dupliquée.

Ainsi si on exécute le code suivant:

var car = new Car("Ford", "Mustang", "GT", 3000m, new Engine(300, "BigEngine", "FE34F3"), 
    new Person() { FirstName = "Paul", LastName = "Ryan" }); 
 
var clonedCopy = car.Clone() as Car; // en utilisant MemberwiseClone() 
clonedCopy.Engine.Power = 340; 
 
var originalPower = car.Engine.Power; 
var clonedPower = clonedCopy.Engine.Power;

originalPower et clonedPower ont la même valeur.

L’intérêt de Object.MemberwiseClone() est qu’il n’est pas nécessaire d’implémenter un constructeur sans paramètre pour dupliquer un objet toutefois cette méthode comporte quelques inconvénients:

  • Elle n’effectue qu’une shallow copy, donc on utilise l’implémentation précédente de Car et qu’on exécute Car.Clone(), les membres brand, modelName, reference et price seront correctement dupliqués dans la nouvelle instance. En revanche, les membres Car.engine et Car.owner ne seront pas dupliqués car ce sont des objets de type référence. Ces membres dans la classe dupliquée pointeront vers les mêmes objets que l’objet d’origine.
  • Dans le cas où le développeur ne sait pas que Object.MemberwiseClone() n’effectue qu’une shallow copy et qu’il rajoute des membres de type référence, l’exécution pourra mener à des comportements inattendus car, dans le cas de la classe Car, les membres engine et owner pointent vers les mêmes objets que l’instance d’origine.

Méthodes génériques

Ces méthodes sont plus génériques car elles peuvent être utilisées sans avoir une connaissance des membres des objets à dupliquer à la compilation. La découverte des membres de l’objet à dupliquer se fait directement à l’exécution.

Reflection

On peut utiliser la reflection pour effectuer certains traitements:

  • Vérifier qu’une classe satisfait l’interface ICloneable pour exécuter la méthode ICloneable.Clone() qu’elle contient,
  • Accéder à la méthode Object.MemberwiseClone() d’une classe pour l’exécuter,
  • Pouvoir accéder aux données membres privés d’une classe de façon à en dupliquer la valeur dans la copie.

Les inconvénients de la reflection sont la lenteur d’exécution et il n’est pas autorisé de l’exécuter dans un environnement partial trust.

Instanciation de la copie avec Activator.CreateInstance()

Une première approche consiste à instancier la classe sans utiliser Object.MemberwiseClone() avec System.Activator.CreateInstance(). Le gros inconvénient de cette méthode est qu’elle nécessite d’implémenter un constructeur sans paramètre.

Dans le cadre de la classe Car (définie plus haut), il faudrait modifier l’objet de cette façon:

public class Car 
{ 
    private string brand; 
    private string modelName; 
    private string reference; 
    private decimal price; 
    private Engine engine; 
    private Person owner; 
 
    public string Brand { get { return this.brand; } } 
    public string ModelName { get { return this.modelName; } } 
    public string Reference { get { return this.reference; } } 
    public decimal Price { get { return this.price; } } 
    public Engine Engine { get { return this.engine; } } 
    public Person Owner { get { return this.owner; } } 
 
    public Car() 
    { 
    } 
 
    public Car(string brand, string modelName, string reference, 
        decimal price, Engine engine, Person owner) 
    { 
        this.brand = brand; 
        this.modelName = modelName; 
        this.reference = reference; 
        this.price = price; 
        this.engine = engine; 
        this.owner = owner; 
    } 
}

L’instanciation de la copie se fait en faisant:

var originalCar = new Car(); 
Type carType = originalCar.GetType(); 
object copiiedCar = Activator.CreateInstance(carType);

“Shallow copy” avec Object.MemberwiseClone()

Exécuter la fonction MemberwiseClone() en utilisant la reflection permet d’éviter d’implémenter un constructeur sans paramètres:

MethodInfo memberwiseCloneMethod = typeof(Car).GetMethod("MemberwiseClone", 
    BindingFlags.Instance | BindingFlags.NonPublic); 
car copiiedCar = (Car)memberwiseCloneMethod.Invoke(input, null);

Copie des propriétés

Comme indiqué plus haut Object.MemberwiseClone() effectue une shallow copy d’un objet. Les objets de type référence ne sont pas vraiment dupliqués.

Une solution consiste à utiliser la reflection pour parcourir les membres de la classe:

  • On écarte de ce parcours les membres de type valeur qui sont des types primitifs ainsi que les objets System.String puisqu’ils sont pris en charge par MemberwiseClone(). En revanche on parcourt les structures car même si elles sont des objets de type valeur, elles peuvent contenir des des membres de type référence.
  • On parcourt d’abord les propriétés publiques et protected de la classe et des éventuelles classes mère (en cas d’héritage) pour effectuer la copie.
  • On parcourt ensuite les propriétés privés et les champs pour exécuter récursivement la copie.

Pour vérifier si un objet est de type primitif et qu’il n’est pas une System.String:

public static bool IsPrimitive(Type type) 
{ 
    if (type == typeof(String)) return true; 
    return type.IsValueType & type.IsPrimitive; 
}

Pour parcourir les propriétés publiques et protected de la classe et des classes mère, on utilise les BindingFlags suivant:

  • BindingFlags.Instance: permet de sélectionner des membres instanciables (données membres).
  • Bindings.NonPublic: sélectionner les membres private et protected.
  • Bindings.Public: permet de sélectionner les membres public.
  • Binding.FlattenHierarchy: permet de sélectionner les champs, les méthodes, les évènements et les propriétés public et protected de la classe et des classes mère (dans le cas d’un héritage). Les membres privés et les types encapsulés (i.e. Nested types) ne sont pas retournées.

Avec Binding.FlattenHierarchy, il n’est pas nécessaire de parcourir récursivement les propriétés de la classe et des classes mère car ce paramètre renvoie toutes les propriétés public et protected héritées.

Pour parcourir les données membres privés, on utilise les BindingFlags BindingFlags.Instance et Bindings.NonPublic et on filtre ensuite pour ne garder que les membres privés. Le parcours doit s’effectuer sur la classe mais aussi récursivement sur les membres privés des classes mère.

On peut effectuer le parcours des membres avec les méthodes suivantes:

private static readonly MethodInfo memberwiseCloneMethod = 
    typeof(Object).GetMethod("MemberwiseClone", BindingFlags.NonPublic |  
        BindingFlags.Instance); 
 
public static Object Copy(Object objectToCopy) 
{ 
    return InternalCopy(objectToCopy); 
} 
 
private static Object InternalCopy(Object objectToCopy) 
{ 
    var objectTypeToCopy = objectToCopy.GetType(); 
    var cloneObject = memberwiseCloneMethod.Invoke(objectToCopy, null); 
 
    CopyNonPrivateMembers(objectTypeToCopy, objectToCopy, cloneObject); 
    CopyPrivateFieldsForObjectBaseType(objectTypeToCopy, objectToCopy, cloneObject); 
 
    return cloneObject; 
} 
 
private static void CopyPrivateFieldsForObjectBaseType(Type objectTypeToCopy, 
    object objectToCopy, object objectCopy) 
{ 
    if (objectTypeToCopy.BaseType != null) 
    { 
        CopyPrivateFieldsForObjectBaseType(objectTypeToCopy.BaseType, objectToCopy, objectCopy); 
        CopyPrivateMembers(objectTypeToCopy.BaseType, objectToCopy, objectCopy); 
    } 
} 
 
private static void CopyNonPrivateMembers(Type objectTypeToCopy, 
    object objectToCopy, object objectCopy) 
{ 
    BindingFlags nonPrivateMemberFlags = BindingFlags.Instance | BindingFlags.NonPublic |  
        BindingFlags.Public | BindingFlags.FlattenHierarchy; 
    CopyMembers(objectTypeToCopy, objectToCopy, objectCopy, nonPrivateMemberFlags, false); 
} 
 
private static void CopyPrivateMembers(Type objectTypeToCopy, object objectToCopy, 
    object objectCopy) 
{ 
    BindingFlags privateMemberFlags = BindingFlags.Instance | BindingFlags.NonPublic; 
    CopyMembers(objectTypeToCopy, objectToCopy, objectCopy, privateMemberFlags, true); 
} 
 
private static void CopyMembers(Type objectTypeToCopy, object objectToCopy, 
    object objectCopy, BindingFlags bindingFlags, bool getPrivateMembers) 
{ 
    foreach (FieldInfo fieldInfo in objectTypeToCopy.GetFields(bindingFlags)) 
    { 
        if (getPrivateMembers && !fieldInfo.IsPrivate) continue; 
        var originalFieldValue = fieldInfo.GetValue(objectToCopy); 
        var clonedFieldValue = InternalCopy(originalFieldValue); 
        fieldInfo.SetValue(objectCopy, clonedFieldValue); 
    } 
}
Implémentation complète d’une duplication par reflection

On peut trouver une implémentation complète de la duplication par reflection dans le projet net-object-deep-copy de Burtsev Alexey sur Github.

Sérialisation

La duplication d’objets en utilisant la sérialisation se fait en deux temps:

  • On sérialise l’objet à dupliquer
  • Puis on désérialise l’objet sérialisé.

On obtient alors une copie en profondeur (i.e. deep copy) de l’objet d’origine. Pour utiliser cette méthode, il faut que la classe à dupliquer soit sérialisable et qu’elle soit décorée avec l’attribut: [Serializable].

Il est possible d’utiliser tous les types de “serializer” (binaire, Soap, JSON, XML, etc…). Toutefois pour que la duplication soit rapide et provoque le moins d’erreur possible, utiliser le sérialisation binaire permet d’avoir un bon compromis.

L’intérêt de cette méthode est qu’elle est très simple à mettre en œuvre et son implémentation est rapide. En revanche, c’est la méthode de duplication la plus lente.

Sérialisation simple

L’implémentation la plus simple de la sérialisation consiste à sérialiser tout l’objet.

Une duplication par serialisation binaire peut s’implémenter de cette façon:

using System.IO; 
using System.Runtime.Serialization; 
using System.Runtime.Serialization.Formatters.Binary; 
 
public static T Clone<T>(T objectToCopy) 
{ 
    if (!typeof(T).IsSerializable) 
    { 
        throw new InvalidOperationException("The type shall be serializable."); 
    } 
 
    // Don't serialize a null object, simply return the default for that object 
    if (Object.ReferenceEquals(objectToCopy, null)) 
    { 
        return default(T); 
    } 
 
    IFormatter formatter = new BinaryFormatter(); 
    using (Stream stream = new MemoryStream()) 
    { 
        formatter.Serialize(stream, objectToCopy); 
        stream.Seek(0, SeekOrigin.Begin); 
        return (T)formatter.Deserialize(stream); 
    } 
}

Cette méthode peut s’avérer compliquée à mettre en œuvre si on ne maîtrise pas tous les types des membres de l’objet à dupliquer en particulier s’ils ne sont pas sérialisables.

Serialization surrogate

Comme indiqué plus haut, la sérialisation est parfois impossible car certains membres de l’objet à dupliquer peuvent ne pas être sérialisables. Dans ce cas, on souhaiterait avoir un comportement différent suivant si un membre est sérialisable ou non.

La solution consiste à utiliser un serialization surrogate (i.e. “assistant de sérialisation”). Un serialization surrogate doit satisfaire System.Runtime.Serialization.ISerializationSurrogate:

  • GetObjectData(): permet d’indiquer dans un objet de type SerializationInfo les données nécessaires pour sérialiser l’objet.
  • SetObjectData(): utilise les données présentes dans un objet de type SerializationInfo pour construire l’objet.

Si on considère les objets suivants:

public class Car 
{ 
    public string Brand { get; set; } 
    public string ModelName { get; set; } 
    public string Reference { get; set; } 
    public decimal Price { get; set; } 
    public Engine Engine { get; set; } 
    public Person Owner { get; set; } 
} 
public class Engine 
{ 
    public int Power { get; set; } 
    public string Reference { get; set; } 
    public string SerialNumber { get; set; } 
} 
public class Person 
{ 
    public string FirstName { get; set; } 
    public string LastName { get; set; } 
}

On peut définir des serialization surrogates particuliers pour la classe Car et la classe Engine:
CarSerializationSurrogate: il assiste pour la sérialisation de la classe Car.
EngineSerializationSurrogate: il assiste pour la sérialisation de la classe Engine.
Il n’y a pas de serialization surrogate pour la classe Person. D’autre part, on n’implémente rien de particulier pour cette classe.

L’implémentation des serialization surrogates est:

public class CarSerializationSurrogate : ISerializationSurrogate 
{ 
    private const string BrandValueName = "Brand"; 
    private const string ModelNameValueName = "ModelName"; 
    private const string ReferenceValueName = "Reference"; 
    private const string PriceValueName = "Price"; 
    private const string EngineValueName = "Engine";

    public void GetObjectData(object obj, SerializationInfo info, StreamingContext context) 
    { 
        Car car = (Car)obj; 
        info.AddValue(BrandValueName, car.Brand); 
        info.AddValue(ModelNameValueName, car.ModelName); 
        info.AddValue(ReferenceValueName, car.Reference); 
        info.AddValue(PriceValueName, car.Price); 
        info.AddValue(EngineValueName, car.Engine); 
    } 
 
    public object SetObjectData(object obj, SerializationInfo info, 
       StreamingContext context, ISurrogateSelector selector) 
    { 
        Car car = (Car)obj; 
        car.Brand = info.GetString(BrandValueName); 
        car.ModelName = info.GetString(ModelNameValueName); 
        car.Reference = info.GetString(ReferenceValueName); 
        car.Price = info.GetDecimal(PriceValueName); 
        car.Engine = (Engine)info.GetValue(EngineValueName, typeof(Engine)); 
        return car; 
    } 
} 
 
public class EngineSerializationSurrogate : ISerializationSurrogate 
{ 
    private const string PowerValueName = "Power"; 
    private const string ReferenceValueName = "Reference"; 
    private const string SerialNumberValueName = "SerialNumber";

    public void GetObjectData(object obj, SerializationInfo info, StreamingContext context) 
    { 
        Engine engine = (Engine)obj; 
        info.AddValue(PowerValueName, engine.Power); 
        info.AddValue(ReferenceValueName, engine.Reference); 
        info.AddValue(SerialNumberValueName, engine.SerialNumber); 
    } 
 
    public object SetObjectData(object obj, SerializationInfo info, 
        StreamingContext context, ISurrogateSelector selector) 
    { 
        Engine engine = (Engine)obj; 
        engine.Power = info.GetInt32(PowerValueName); 
        engine.Reference = info.GetString(ReferenceValueName); 
        engine.SerialNumber = info.GetString(SerialNumberValueName); 
        return engine; 
    } 
}

Pour prendre en compte les serialization surrogates lors de la sérialisation:

public class Serializer 
{ 
    public static Car CloneWithSerializationSurrogates(Car carToCopy) 
    { 
        var surrogateSelector = new SurrogateSelector(); 
        surrogateSelector.AddSurrogate(typeof(Car), 
            new StreamingContext(StreamingContextStates.All), 
            new CarSerializationSurrogate()); 
        surrogateSelector.AddSurrogate(typeof(Engine), 
            new StreamingContext(StreamingContextStates.All), 
            new EngineSerializationSurrogate());

        IFormatter formatter = new BinaryFormatter(); 
        formatter.SurrogateSelector = surrogateSelector;

        using (Stream stream = new MemoryStream()) 
        { 
            formatter.Serialize(stream, carToCopy); 
            stream.Seek(0, SeekOrigin.Begin); 
            return (Car)formatter.Deserialize(stream); 
        } 
    }
}

Si on exécute le code suivant:

var originalCar = new Car { 
    Brand = "Ford", ModelName = "Mustang", Reference = "GT",  
    Price = 3000m,  
    Engine = new Engine { Power = 300, Reference = "BigEngine",   
        SerialNumber = "FE34F3" }, 
    Owner = new Person { FirstName = "Paul", LastName = "Ryan" } 
}; 
Car duplicatedCar = Serializer.CloneWithSerializationSurrogates(originalCar);

Tous les membres de la classe originalCar seront sérialisés et correctement dupliqués à l’exception du membre Owner puisqu’on a omis la prise en compte de ce membre dans les fonctions CarSerializationSurrogate.GetObjectData() et CarSerializationSurrogate.SetObjectData().

Serialization surrogate selector

L’implémentation précédente permettait de choisir le serialization surrogate à utiliser suivant le type d’objet à sérialiser. Il est toutefois possible d’avoir une implémentation particulière quant au choix du serialization surrogate à utiliser, ce choix se fait un serialization surrogate selector (i.e. “sélectionneur d’assistant de sérialisation”).

Un serialization surrogate selector doit satisfaire l’interface System.Runtime.Serialization.ISurrogateSelector.

Les surrogate selectors sont organisés en chaîne, c’est-à-dire si un surrogate selector ne peut pas sélectionner un serialization surrogate alors il fournit un autre surrogate selector qui sera sollicité pour sélectionner un serialization surrogate et ainsi de suite. L’interface ISurrogateSelector permet d’organiser cette chaîne:

  • ChainSelector(): permet d’indiquer une instance d’un surrogate selector qui sera le suivant dans la chaine si le surrogate selector actuel ne peut sélectionner un serialization surrogate.
  • GetNextSelector(): retourne le surrogate selector suivant dans la chaîne. S’il n’y a pas de surrogate selector suivant, alors cette fonction renvoie null.
  • GetSurrogate(): fournit le serialization surrogate qui assistera la sérialisation. S’il le surrogate selector ne sélectionne pas de serialization surrogate alors cette fonction renvoie null.

Une implémentation intéressante d’un surrogate selector serait de fournir un serialization surrogate particulier seulement si le membre est sérialisable.

Ainsi, dans le cas où l’objet à sérialiser est de type System.String, un type primitif ou si il est sérialisable, on ne fournit pas de serialization surrogate. Le comportement est normal et les objets sont sérialisés. Dans le cas où les objets ne sont pas sérialisables et s’ils sont des classes ou des structures alors on fournit un serialization surrogate particulier.

L’implémentation du surrogate selector est:

public class SurrogateSelectorForNonSerialiazableType : ISurrogateSelector 
{ 
    private ISurrogateSelector nextSelector; 
    private ISerializationSurrogate serializationSurrogateForNonSerializableType; 
    public SurrogateSelectorForNonSerialiazableType( 
        ISerializationSurrogate serializationSurrogateForNonSerializableType) 
    { 
        this.serializationSurrogateForNonSerializableType = 
            serializationSurrogateForNonSerializableType; 
    } 
    #region ISurrogateSelector members 
    public void ChainSelector(ISurrogateSelector selector) 
    { 
        this.nextSelector = selector; 
    } 
    public ISurrogateSelector GetNextSelector() 
    { 
        return this.nextSelector; 
    } 
    public ISerializationSurrogate GetSurrogate(Type type, StreamingContext context, 
        out ISurrogateSelector selector) 
    { 
        selector = null; 
        ISerializationSurrogate surrogate = null; 
        if (!IsKnownType(type) && (type.IsClass || type.IsValueType)) 
        { 
            selector = this; 
            surrogate = this.serializationSurrogateForNonSerializableType; 
        } 
        return surrogate; 
    } 
    #endregion 
    private static bool IsKnownType(Type type) 
    { 
        return type == typeof(string) || type.IsPrimitive 
            || type.IsSerializable; 
    } 
}

Une implémentation du serialization surrogate pourrait être:

public class SerializationSurrogateForNonSerializableType : ISerializationSurrogate 
{ 
    private static IEnumerable<FieldInfo> GetSerializableFieldInfos(object obj) 
    { 
        return obj.GetType().GetFields(BindingFlags.Instance | 
            BindingFlags.Public | BindingFlags.NonPublic) 
            .Where(fi => fi.FieldType == typeof(string) || fi.FieldType.IsPrimitive || 
                fi.FieldType.IsSerializable || fi.FieldType.IsClass); 
    }

    public void GetObjectData(object obj, SerializationInfo info, StreamingContext context) 
    { 
        foreach (var fi in GetSerializableFieldInfos(obj)) 
        { 
            info.AddValue(fi.Name, fi.GetValue(obj)); 
        } 
    }

    public object SetObjectData(object obj, SerializationInfo info, StreamingContext context, 
        ISurrogateSelector selector) 
    { 
        foreach (var fi in GetSerializableFieldInfos(obj)) 
        { 
            fi.SetValue(obj, info.GetValue(fi.Name, fi.FieldType)); 
        } 
        return obj; 
    } 
}

La duplication est effectuée en exécutant:

public static Car Clone(Car carToCopy) 
{ 
     
    IFormatter formatter = new BinaryFormatter(); 
    formatter.SurrogateSelector = new SurrogateSelector(); 
    formatter.SurrogateSelector.ChainSelector( 
        new SurrogateSelectorForNonSerialiazableType( 
            new SerializationSurrogateForNonSerializableType())); 
    using (Stream stream = new MemoryStream()) 
    { 
        formatter.Serialize(stream, carToCopy); 
        stream.Seek(0, SeekOrigin.Begin); 
        return (Car)formatter.Deserialize(stream); 
    } 
}
Implémentation complête d’une méthode de duplication par sérialisation:

On peut trouver une implémentation plus complète de la duplication par sérialisation en utilisant un surrogate selector dans A Generic Method for Deep Cloning in C# 3.0.

Expression Trees

Il existe plusieurs exemples de code de duplication générique en utilisant des arbres d’expressions (i.e. expression trees).

L’intérêt des expression trees est de pouvoir construire dynamiquement une suite d’instructions. On peut ensuite, compiler cette suite d’instructions en code IL afin de l’exécuter.

La méthode duplication par reflection décrite plus haut s’exécute en découvrant les types et caractéristiques des membres de l’objet à dupliquer. De façon à effectuer une copie en profondeur de l’objet (i.e. deep copy), l’algorithme va parcourir récursivement ses membres afin de les dupliquer. S’il y a des duplications à effectuer sur 10 objets de même type, l’algorithme parcourera l’arbre des 10 objets de la même façon. Parcourir les arbres d’objets par reflection est couteux en performance. Dans le cas où un type d’objet est connu, une optimisation pourrait être de copier la valeur des membres directement avec leur nom et non en parcourant systématiquement tous les membres.

Utiliser les expression trees permet d’optimiser la méthode de duplication par reflection puisqu’on peut générer une expression effectuant la duplication pour chaque type d’objet. On peut ensuite compiler cette expression et l’exécuter à chaque fois qu’on détecte un objet de même type. La duplication est ainsi plus rapide puisqu’on exécute directement du code IL et ensuite, on ne fait un parcours systématique de tous les membres de l’objet. La duplication se fait en copiant les valeurs avec les noms des membres directement.

Les méthodes de duplication par expression tree, sont plus rapides que les méthodes utilisant la reflection pure et la sérialisation à partir du moment où les expressions ont été générées. Ces méthodes sont intéressantes lorsqu’il y a beaucoup d’objets de même type à dupliquer.

La contrepartie de l’utilisation des expression trees est qu’elles sont difficilement débugables et que le code pour générer les expressions est complexes.

Comme pour la méthode par reflection, la première étape de la duplication par expression trees est d’appeler la copie superficielle avec Object.MemberwiseClone(). Ainsi à titre d’exemple, l’exécution de cette méthode par reflection se fait de la façon suivante:

MethodInfo memberwiseCloneMethod = typeof(Object).GetMethod("MemberwiseClone",
    BindingFlags.Instance | BindingFlags.NonPublic); 
 
ExampleClass output = (ExampleClass)memberwiseCloneMethod.Invoke(input, null);

En utilisant les expression trees, l’implémentation devient:

ParameterExpression inputParameter = Expression.Parameter(
    typeof(ExampleClass)); 
ParameterExpression outputParameter = Expression.Parameter(typeof(ExampleClass)); 
 
MethodInfo memberwiseCloneMethod = typeof(Object).GetMethod("MemberwiseClone", 
    BindingFlags.Instance | BindingFlags.NonPublic); 
 
Expression lambdaExpression = Expression.Assign(outputParameter, 
    Expression.Convert(
        Expression.Call(inputParameter, memberwiseCloneMethod), 
        typeof(ExampleClass)));

Ainsi l’intérêt est qu’après compilation de l’expression tree, l’exécution est plus rapide puisqu’il n’y a plus l’étape de reflection pour récupérer les informations sur la méthode Object.MemberwiseClone().

D’une façon générale, tous les traitements effectués pour la méthode de duplication par reflection sont adaptées de la même façon pour utiliser les expression trees. Il faut donc être familié, dans un premier temps, avec les implémentations de duplication par reflection pour comprendre plus facilement les implémentation avec les expression trees.

Quelques implémentations de duplication avec des expression trees:

L’implémentation suivante de la duplication par expression trees: Fast Deep Copy by expression trees (C#) s’inspire de cette méthode de duplication par reflection: https://github.com/Burtsev-Alexey/net-object-deep-copy.

Il existe d’autres implémentations de la duplication par expression trees: FastClone, Fast Deep Cloning ou le projet CloneExtensions sur GitHub.

Tester la méthode de duplication

Avant de procéder à l’implémentation d’une méthode pour dupliquer, une bonne approche est d’implémenter un test qui permettrait de vérifier que:

  • La duplication produit bien une instance distincte de même type,
  • Les données membres de l’objet d’origine sont correctement dupliquées.

Dans le cas d’une méthode générique, il faut vérifier le comportement pour la duplication:

  • De membres privés et publics
  • De membres readonly
  • Des classes et des structures
  • Dans le cas d’héritage
  • Des tableaux d’objets
Références:

Duplication par reflection:
Project net-object-deep-copy sur Github: https://github.com/Burtsev-Alexey/net-object-deep-copy

Duplication par sérialisation binaire:

Duplication avec des expression trees:

Environnement partial trust:

Autres:

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

“Expression trees” en 2 min

Quelques définitions en préambule

Avant de rentrer dans les détails des arbres d’expression (i.e. expression trees), il faut définir quelques termes.

Delegate

Il s’agit du type d’une référence vers une méthode comportant une signature particulière. Le delegate définit donc le type de la référence et non pas la référence elle-même. Par exemple, en C# un delegate peut se définir de cette façon:

public delegate int AddDelegate(int a, int b);

La méthode suivante possède une définition compatible avec le delegate en raison de sa signature:

public static int Add(int a, int b) 
{ 
    return a + b; 
}

On peut donc instancier le delegate et l’exécuter de cette façon:

AddDelegate delegateInstance = Add; 
int result = delegateInstance(3, 5);

A partir du C# 2.0, il est possible d’avoir une notation plus directe pour définir les delegates:

AddDelegate delegateInstance = delegate(int a, int b)  
{
    return a + b; 
};

Expression lambda

Une expression lambda est une notation permettant de créer des types de delegates ou d’arbres d’expression. Les expressions lambda utilise une notation utilisant l’opérateur “=>”.

Si on prends l’exemple précédent, on peut utiliser une expression lambda pour définir le delegate:

AddDelegate delWithLambda = (a, b) => a + b;

Cette notation est un raccourci pour:

AddDelegate delWithLambda = (a, b) => { return a + b; };

Le delegate s’exécute de la même façon que précédemment:

int result = delWithLambda(3, 5);

Les expressions lambda sont apparues avec C# 3.0.

Action<T> et Func<T, TResult>

Il s’agit de delegates prédéfinis pour faciliter l’utilisation de delegates et d’expression lambda. L’inconvénient de l’exemple précédent est qu’il nécessite la définition du delegate AddDelegate:

public delegate int AddDelegate(int a, int b);

Pour éviter de définir des delegates avant d’utiliser des expressions lambda, on peut utiliser Action et Func:

  • Action<T> correspond à des delegates de méthodes (par de type de retour) de 0 ou plusieurs arguments.
  • Func<T, TResult> correspond à des delegates de fonctions de 0 ou plusieurs arguments avec un résultat.

Dans l’exemple précédent, si on utilise Func<T, TResult>:

Func<int, int, int> addWithFunc = (a, b) => a + b;

Une autre notation est équivalente (peu utilisée car plus lourde):

Func<int, int, int> addWithFunc = delegate(a, b) { return a + b; };

Les types Action<T> et Func<T, TResult> sont apparus avec le framework .NET 3.5.

Expression

En C#, le type Expression désigne un objet permettant de représenter une expression lambda sous la forme d’un arbre d’expressions (i.e. expression tree). Ce type se trouve dans le namespace System.Linq.Expressions, il s’utilise sous la forme:
Expression<Func<TResult>> ou Expression<TDelegate>TDelegate est un delegate définit au préalable.

Ainsi Expression<Func<TResult>> correspond à la représentation fortement typée d’une expression lambda, elle ne contient pas seulement sa définition mais aussi toute sa description.
Expression<TDelegate> dérive de la classe abstraite System.Linq.Expressions.LambdaExpression qui correspond à la classe de base pour représenter une expression lambda sous forme d’un arbre d’expressions:

public sealed class Expression<TDelegate> : LambdaExpression
Expression<TDelegate> et Expression

Il ne faut pas confondre les classes Expression<TDelegate> et Expression car Expression<TDelegate> dérive de LambdaExpression et LambdaExpression dérive de Expression.

Intérêt de Expression<Func<TResult>> par rapport à Func<TResult>

L’intérêt de ce type est de pouvoir intervenir sur cette description pour l’utiliser pour comprendre ce que contient l’expression, la modifier ou simplement l’exécuter. A la différence, un delegate Func<TResult> peut seulement être exécuté. Par exemple, il sera plus compliqué de modifier l’implémentation d’un delegate Func<TResult> au runtime alors qu’il est possible de la faire avec Expression<Func<TResult>>.

Il n’est pas facile de comprendre la différence entre Expression<Func<TResult>> et Func<TResult>:

  • Func<TResult> désigne un delegate prédéfini correspondant à un fonction sans arguments et retournant un paramètre de type TResult.
  • Expression<Func<TResult>> désigne toute la description du delegate Func<TResult>.

Par exemple, si on considère l’expression:

Expression<Func<int, int, int>> expression = (a, b) => a + b;

On peut voir que le contenu de l’objet expression par rapport à un Func équivalente.
Par exemple le contenu de:

Func<int, int, int> func = (a, b) => a + b;

est:

On peut voir que func est bien un delegate, il n’y a plus de traces de l’implémentation d’origine: (a, b) => a + b.

Le contenu de l’objet expression est:

Dans cet objet, il y a tout le détail de l’implémentation de l’expression lambda (a, b) => a + b.

Ainsi, Expression<Func<TResult>> décrit ce que fait l’expression lambda alors que Func<TResult> permet seulement de l’exécuter.

Manipulation des Expression<Func<TResult>>

Construction

Il est possible de construire un arbre d’expressions de 2 façons: avec une expression lambda ou avec l’API Expressions.

Par exemple, en utilisant une expression lambda:

Expression<Func<int, int, int>> expression = (a, b) => a + b;

De même en utilisant l’API:

Using System.Linq.Expressions;
 
// ...

// "a" ne sert que pour la documentation
ParameterExpression aParam = Expression.Parameter(typeof(int), "a");  
ParameterExpression bParam = Expression.Parameter(typeof(int), "b"); 
BinaryExpression add = Expression.Add(aParam, bParam); 
Expression<Func<int, int, int>> expression = 
    Expression.Lambda<Func<int, int, int>>(add, new ParameterExpression[] { aParam, bParam });

L’API permet d’implémenter un grand nombre de fonctions avec les fonctions statiques de la classe System.Linq.Expressions.Expression.

Par exemple:

Fonction Permet de définir
Expression.Parameter() Un argument
Expression.Constant() Une constante
Expression.Assign() Affecter une expression à un argument
Expression.Add()
Expression.Multiply()
Expression.Divide()
Expression.Not()
Expression.And()
Expression.Or()
Des opérations sur les arguments
Expression.Label() Un label qui va permettre d’effectuer des sauts dans le code (GOTO)
Expression.Goto() Un saut dans le code (GOTO)
Expression.LessThan()
Expression.GreaterThan()
Expression.LessThanOrEqual()
Expression.GreaterThanOrEqual()
Expression.Equal()
Expression.NotEqual()
Des opérations de comparaison sur les arguments
Expression.Lambda() Permet de construire un delegate
Expression.Block() Permet de construire le corps d’une méthode
Expression.Loop() Permet de définir une boucle de programmation. Par exemple:
Expression.Loop([Expression contenant le code à évaluer], Expression.Break(...))
Expression.Break() Arrête une boucle Loop et effectue un saut à un “Label” particulier.
Expression.IfThen()
Expression.IfThenElse()
Permet de créer une expression conditionnelle
Expression.IsFalse()
Expression.IsTrue()
Evalue une expression
Expression.New() Crée une expression permettant d’effectuer un appel à un constructeur.
Expression.TryCatch()
Expression.TryCatchFinally()
Expression.Finally()
Un bloc try…catch…finally
Etc…

La liste exhaustive des fonctions se trouve sur MSDN.

Convertir un arbre d’expression en delegate

On peut convertir un objet Expression<Func<TResult>> en Func<TResult> avec la méthode Expression.Compile().

Par exemple:

Expression<Func<int, int, int>> expression = (a, b) => a + b; 
Func<int, int, int> funcFromExpression = expression.Compile();

On peut évaluer le delegate avec:

int result = funcFromExpression(3, 9);
Convertir un delegate en arbre d’expressions

Un delegate est une méthode compilée, pour obtenir l’arbre d’expressions correspondant à ce delegate, il faudrait décompiler la méthode et la convertir en arbre d’expressions. Il n’y a pas de méthodes directes pour effectuer ces étapes.

Analyser un arbre d’expressions

Comme indiquer un des intérêts des arbres d’expressions est de pouvoir analyser une expression lambda avec des instructions. On peut ainsi décomposer l’expression lambda en utilisant les propriétés de la classe System.Linq.Expressions.LambdaExpression (la classe Expression<TDelegate> dérive de la classe LambdaExpression):

  • LambdaExpression.Body: pour obtenir une expression contenant le corps de l’expression lambda.
  • LambdaExpression.Parameters: pour obtenir les arguments.

Par exemple:

Expression<Func<int, int, int>> expression = (a, b) => a + b; 
BinaryExpression addOperation = (BinaryExpression)expression.Body; 
ParameterExpression aParam = (ParameterExpression)addOperation.Left; 
ParameterExpression bParam = (ParameterExpression)addOperation.Right;

Manipuler un arbre d’expression avec ExpressionVisitor

Il est possible d’explorer et d’analyser un arbre d’expressions plus facilement qu’en utiliser des propriétés avec un objet de type ExpressionVisitor. Cet objet utilise le design pattern Visiteur.
Pour utiliser cette méthode, il faut dériver de la classe abstraite System.Linq.Expressions.ExpressionVisitor et surcharger les fonctions virtuelles correspondant aux parties de l’expression qu’on souhaite analyser.

Par exemple:

  • ExpressionVisitor.VisitBinary(): sera exécutée pour les nœuds de l’arbre qui sont des opérations (i.e. BinaryExpression).
  • ExpressionVisitor.VisitParameter(): sera exécutée pour les arguments d’une expression (i.e. ParameterExpression).
  • ExpressionVisitor.VisitBlock(): sera exécutée pour les nœuds correspondant au corps d’une fonction (i.e. BlockExpression).
  • Etc…

Se reporter à MSDN pour avoir la liste exhaustive de toutes les méthodes virtuelles.

Pour que les méthodes de la classe soient exécutées, il faut appeler la méthode ExpressionVisitor.Visit().

Par exemple si on dérive de ExpressionVisitor:

public class CustomExpressionVisitor : ExpressionVisitor 
{ 
    protected override Expression VisitBinary(BinaryExpression node) 
    { 
        // ... 
        return node; 
    } 
 
    protected override Expression VisitParameter(ParameterExpression node) 
    { 
        // ... 
        return base.VisitParameter(node); 
    } 
}

Les fonctions surchargées seront exécutées en faisant:

Expression<Func<int, int, int>> addExpression = (a, b) => a + b; 
var expressionVisitor = new CustomExpressionVisitor(); 
expressionVisitor.Visit(addExpression.Body);

L’exécution des méthodes se fera dans l’ordre des expressions dans l’arbre d’expressions.

Par exemple, si on définit le “visiteur”:

public class CustomExpressionVisitor : ExpressionVisitor 
{ 
    protected override Expression VisitBinary(BinaryExpression node) 
    { 
        Console.Write("("); 
        this.Visit(node.Left); 
        Console.Write(" {0} ", node.NodeType); 
        this.Visit(node.Right); 
        Console.Write(")"); 
        return node; 
    } 
 
    protected override Expression VisitParameter(ParameterExpression node) 
    { 
        Console.Write("parameter({0})", node.Name); 
        return base.VisitParameter(node); 
    } 
}

A l’exécution, on aura:

((parameter(a) Add (parameter(b))

Arbre d’expressions immutable

Les arbres d’expressions sont immutables c’est-à-dire qu’il n’est pas possible de le modifier après sa création. Toutefois pour profiter de l’intérêt des arbres, on peut utiliser l’API pour créer des arbres différents suivant les besoins ou utiliser un “visiteur” ExpressionVisitor pour dupliquer un arbre et effectuer des modifications pendant cette duplication.

Par exemple, en utilisant l’API on peut créer plusieurs arbres:

ParameterExpression aParam = Expression.Parameter(typeof(int), "a");  
ParameterExpression bParam = Expression.Parameter(typeof(int), "b");  
BinaryExpression add = Expression.Add(aParam, bParam); 
 
ParameterExpression cParam = Expression.Parameter(typeof(int), "c");  
BinaryExpression divide = Expression.Divide(add, cParam); 
 
Expression<Func<int, int, int>> firstExpression = 
    Expression.Lambda<Func<int, int, int>>(add, new ParameterExpression[] { aParam, bParam }); 
Expression<Func<int, int, float>> secundExpression = 
    Expression.Lambda<Func<int, int, float>>(divide, new ParameterExpression[] { aParam, bParam, cParam });

Comparaison entre Expression<Func<TResult>> et Func<TResult>

La plupart du temps, on utilisera plutôt Func<TResult> car il est rarement nécessaire d’analyser une expression lambda. Toutefois dans certains cas, l’utilisation de Func<TResult> peut avoir des conséquences inattendues.

IEnumerable et IQueryable

Ces 2 types d’objet sont des structures de liste qui conviennent dans des cas assez différents. Ainsi dans le cadre de Entity Framework ou LinQ-to-SQL, certaines méthodes d’extension sur le type IEnumerable possèdent des arguments de type Func<TResult>.

Par exemple la signature de la fonction System.Linq.Enumerable.Where():

public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source,  
   Func<TSource, bool> predicate)

A l’opposé, certaines méthodes d’extension sur IQueryable possèdent des arguments de type Expression<Func<TResult>>, si on compare la signature avec la fonction équivalente System.Linq.Queryable.Where():

public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source,  
    Expression<Func<TSource, bool>> predicate)

Ainsi les méthodes d’extension de IEnumerable utilisent des delegates alors que les méthodes d’extension de IQueryable utilisent des arbres d’expressions.

Dans le cadre de Entity Framework et LinQ-to-SQL, cette différence peut avoir des conséquences non négligeables puisque la conversion de code C# vers des requêtes SQL ne peut se faire qu’à partir d’objets de type Expression. Ce sont ces objets Expression qui seront analysés pour déterminer la requête à exécuter dans la base SQL.

Si on utilise des delegates pour effectuer ces requêtes, il ne peut pas y avoir d’évaluation de ces delegates en base de données. Donc pour effectuer le traitement, il faut récupérer tout le contenu de la table sur laquelle la requête doit être exécutée. Ensuite, le delegate fournit en argument de la fonction Where() sera executé sur toutes les lignes pour obtenir le résultat. La méthode d’extension utilisant de delegate est donc moins efficace que son équivalent utilisant un arbre d’expression puisqu’on devra récupérer tout le contenu de la table.

Sachant que IQueryable dérive de IEnumerable, suivant le type de l’argument utilisé pour les fonctions, des surchages différentes seront exécutées:

  • Avec Expression<Func<TResult>>, les surcharges IQueryable seront exécutées,
  • Func<TResult>, ça sera plutôt les surcharges IEnumeable qui seront exécutées.

Entity Framework

Comme indiqué plus haut, suivant le type d’argument utilisé entre Expression<Func<TResult>> et Func<TResult>, Entity Framework aura un comportement différent:

  • Avec Expression<Func<TResult>>: Entity Framework va analyser l’expression pour en déduire la requête SQL à exécuté en base.
  • Avec Func<TResult>: Entity Framework récupère tout le contenu de la table ou des tables sur lesquelles porte la requête, le place dans le contexte et ensuite, exécute le delegate pour effectuer le tri.

Ainsi si on considère une table PERSON avec 3 colonnes:

  • FirstName
  • LastName
  • Age

En exécutant:

public IEnumerable<Person> Where(DbEntities dbContext, Func<Person, bool> whereClause) 
{ 
    return dbContext.Persons.Where(where); 
}

La requête suivante sera exécutée en base:

SELECT  
[Extent1].[LastName] AS [LastName],  
[Extent1].[FirstName] AS [FirstName],  
[Extent1].[Age] AS [Age] 
FROM [dbo].[Persons] AS [Extent1]

De même, on exécutant:

public IEnumerable<Person> Where(DbEntities dbContext, Expression<Func<Person, bool>> whereClause) 
{ 
    return dbContext.Persons.Where(where); 
} 
 
var results = Where(dbContext, p => p.Age > 25);

La requête comportera une clause WHERE:

SELECT  
[Extent1].[LastName] AS [LastName],  
[Extent1].[FirstName] AS [FirstName],  
[Extent1].[Age] AS [Age] 
FROM [dbo].[Persons] AS [Extent1] 
WHERE [Extent1].[Age] > 25

Sérialisation

Les delegates ne sont pas sérialisables, ainsi les Func<T, TResult> et Action<T> ne sont pas sérialisables. Ceci s’explique par le fait que les delegates sont compilés en instructions IL pour être exécuté, au runtime il ne s’agit pas d’une structure mais d’une suite d’instructions IL.

A l’opposé, un objet de type Expression<Func<T, Result>> n’est pas compilé en instructions IL au runtime. Cet objet reste une structure qui, par construction, a été implémentée pour être sérialisable. Comme indiqué plus haut, pour qu’un objet de type Expression soit exécutée, il faut exécuter l’instruction Expression.Compile(). Cette instruction transformera la structure en fonction anonyme exécutable.

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Documentation du code C# en 1 min

Il est possible d’ajouter de la documentation dans du code C# et de générer cette documentation vers des fichiers XML à la compilation.
Il est souvent difficile de maintenir une documentation du code à jour car très souvent quand on modifie le code, on oublie de répercuter ces modifications dans la documentation. La documentation peut devenir alors assez vite obsolète et induire en erreur sur le fonctionnement réel du code.
Il existe en C# quelques balises qui permettent de garantir une certaine cohérence entre les éléments de documentation et le code.

Générer la documentation en fichiers XML

Paramétrer Visual Studio

Pour générer la documentation à la compilation en utilisant Visual Studio, il suffit d’activer l’option dans les propriétés d’un projet:

  1. Clique droit sur le projet puis cliquer “Properties” pour afficher les propriétés,
  2. Dans l’onglet “Build”,
  3. Dans la partie “Output”, cocher “XML documentation file”.

Par défaut le fichier contenant la documentation sera généré dans le répertoire de sortie, le plus souvent:

/bin/Debug

Pour que ce fichier contienne des informations, il faut explicitement documenter les objets dans le code C#.

Rendre les commentaires obligatoires

Dans Visual Studio, on peut considérer les “warning” comme des erreurs dans:

  1. Clique droit sur le projet puis cliquer “Properties” pour afficher les propriétés,
  2. Dans l’onglet “Build”,
  3. Dans la partie “Treat warnings as errors”, sélectionner “All”.

Avec ce paramétrage, tous les “warnings” provoqués par de la documentation incomplête ou incohérente à la compilation seront considérés comme des erreurs.

Documenter du code C#

D’une façon générale, pour ajouter de la documentation à un objet (classe, interface, propriété, méthode etc…) il suffit de taper dans Visual “///” au dessus de l’objet à documenter:

/// <summary>  
/// Commentaire pour la classes  
/// </summary>  
public class Class1  
{  
}

Pour une méthode:

/// <summary> 
/// Commentaire d'une méthode 
/// </summary> 
/// <param name="comments">Liste de commentaires</param> 
/// <param name="commentIndex">Index du commentaire</param> 
/// <returns>Commentaire</returns> 
public string GetCommentFromInt(List<string> comments, int commentIndex) 
{ 
}

La documentation est donc introduite à l’aide de:

  • <summary> pour le corps du commentaire,
  • <param> pour les arguments de méthodes,
  • <returns> pour le résultat de la méthode.

Ainsi la classe:

/// <summary> 
/// Commentaire pour la classes 
/// </summary> 
public class Class1 
{ 
    /// <summary> 
    /// Property in a class 
    /// </summary> 
    public int ClassProperty { get; private set; } 
 
    /// <summary> 
    /// Commentaire d'une méthode 
    /// </summary> 
    /// <param name="comments">Liste de commentaires</param> 
    /// <param name="commentIndex">Index du commentaire</param> 
    /// <returns>Commentaire</returns> 
    public string GetCommentFromInt(List<string> comments, int commentIndex) 
    { 
        return string.Empty; 
    } 
}

Générera la documentation:

<?xml version="1.0"?> 
<doc> 
    <assembly> 
        <name>CommentsInCode</name> 
    </assembly> 
    <members> 
        <member name="T:CommentsInCode.Class1"> 
            <summary> 
            Commentaire pour la classes 
            </summary> 
        </member> 
        <member name="M:CommentsInCode.Class1.GetCommentFromInt(System.Collections.Generic.List{System.String},System.Int32)"> 
            <summary> 
            Commentaire d'une méthode 
            </summary> 
            <param name="comments">Liste de commentaires</param> 
            <param name="commentIndex">Index du commentaire</param> 
            <returns>Commentaire</returns> 
        </member> 
        <member name="P:CommentsInCode.Class1.ClassProperty"> 
            <summary> 
            Property in a class 
            </summary> 
        </member> 
    </members> 
</doc>

Enrichir la documentation

Un des intérêts de la documentation de code est de pouvoir l’enrichir en ajoutant des références qui seront vérifiées à la compilation. Dans le cas où les références ne sont plus satisfaites, la compilation générera une erreur de compilation.

Référence vers un membre avec <see />

La balise <see cref="member" /> permet d’effectuer une référence vers un objet. Par exemple si on considère la classe:

public class Class1 
{ 
    public List<string> Comments { get; private set; } 
 
    /// <summary> 
    /// Commentaire d'une méthode 
    /// </summary> 
    /// <param name="commentIndex"></param> 
    /// <returns>Commentaire</returns> 
    public string GetCommentFromInt(int commentIndex) 
    { 
        return string.Empty; 
    } 
}

Dans les commentaires de l’argument commentIndex, on peut rajouter une référence vers le membre Class1.Comments en utilisant:

<see cref="Class1.Comments"/>

Ainsi:

/// <summary> 
/// Commentaire d'une méthode 
/// </summary> 
/// <param name="commentIndex">Index permettant de récupérer un commentaire 
/// dans <see cref="Class1.Comments"/></param> 
/// <returns>Commentaire</returns> 
public string GetCommentFromInt(int commentIndex) 
{ 
    return string.Empty; 
}

Dans le cas où la référence contient une erreur, par exemple:

<see cref="Class1.Coments"/>

On aura un warning ou une erreur de compilation (suivant la configuration des “warnings”):

XML comment on ... has cref attribute cref 'Class1.Coments' that could
    not be resolved

Quelques exemples pour ajouter ces références:

  • Référence vers une méthode: <see cref="Class1.GetCommentFromInt" />
  • Référence vers une méthode possédant plusieurs surcharges: il faut ajouter les arguments explicitement: <see cref="Class1.GetCommentFromInt(string,int)" />
  • Référence vers une fonction du framework: <see cref="String.Equals(string, string)" />.
  • Dans le cas où les namespaces ne sont pas indiqués, il faut préciser explicitement les namespaces de la méthode: <see cref="System.String.Equals(string, string)" />.

Cas particulier des génériques

Les génériques sont déclarés avec <T> ce qui peut poser problème car les caractères “<” et “>” sont interprétés en XML. La première solution consiste à remplacer le caractère “<” par &lt;.

Par exemple si on déclare la méthode:

public string GetCommentFromInt(List<string> comments, int commentIndex)

Une référence vers cette méthode s’écrira:

<see cref="Class1.GetCommentFromInt(List&lt;string>, int)" />

Une autre méthode consiste à remplacer les caractères “<” et “>” par respectivement “{” et “}”. En prenant le même exemple que précédemment, on aura:

<see cref="Class1.GetCommentFromInt(List{string}, int)" />

Balise <seealso />

Cette balise permet de rajouter une autre référence vers un objet quand une référence avec <see /> existe déjà. Elle s’utilise de la même façon que <see />.

Par exemple:

/// <summary> 
/// On peut obtenir une valeur avec <see cref="Class1.GetCommentFromInt(List{string}, int)" /> 
/// On peut aussi s'aider de <seealso cref="Class1.GetCommentFromInt(string, int)"/> 
/// </summary>

Balise <para />

Permet de structurer un texte en paragraphe lorsque ce texte se trouve entre des balises <summary/>, <remarks /> ou <returns/>.

Balise <exception />

Permet de documenter une exception. Par exemple, pour indiquer qu’une exception de type System.ArgumentNullException est levée si l’argument comments est nul:

/// <summary> 
///  
/// </summary> 
/// <param name="comments"></param> 
/// <param name="commentIndex"></param> 
/// <returns></returns> 
/// <exception cref="System.ArgumentNullException">Exception levée 
/// si <paramref name="comments"/> est nul.</exception> 
public string GetCommentFromInt(List<string> comments, int commentIndex)

Balises <c/> et <code />

Ces balises permettent de renvoyer du texte à interpréter sous forme de code:

  • <c/> doit être utilisé pour un élément sur une seule ligne et
  • <code /> doit être utilisé lorsque le code doit être affiché sur plusieurs lignes.

Par exemple pour <c/>:

/// <summary> 
/// Cette méthode permet de renvoyer <c>string.Empty</c>. 
/// </summary> 
/// <param name="commentFormat"></param> 
/// <param name="commentIndex"></param> 
/// <returns></returns> 
public string GetCommentFromInt(string commentFormat, int commentIndex) 
{ 
    return string.Empty; 
}

De même pour <code />:

/// <summary> 
/// Cette méthode permet de renvoyer <c>string.Empty</c>. Le corps de cette méthode est: 
/// <code>public string GetCommentFromInt(string commentFormat, int commentIndex) 
/// { 
///     return string.Empty; 
/// }</code>
/// </summary> 
/// <param name="commentFormat"></param> 
/// <param name="commentIndex"></param> 
/// <returns></returns> 
public string GetCommentFromInt(string commentFormat, int commentIndex) 
{ 
    return string.Empty; 
}

Balise <paramref />

Permet de faire référence au paramètre d’une méthode:

<paramref name="name"/>

Par exemple:

/// <summary> 
/// Le paramètre <paramref name="comments"/> est obligatoire. 
/// </summary> 
/// <param name="comments"></param> 
/// <param name="commentIndex"></param> 
/// <returns></returns> 
public string GetCommentFromInt(List<string> comments, int commentIndex)

Balise <remarks />

Ajoute des indications en plus de ce qui se trouve à l’intérieur d’une balise <summary />, par exemple:

/// <summary> 
/// Commentaire de base 
/// </summary> 
/// <param name="comments"></param> 
/// <param name="commentIndex"></param> 
/// <returns></returns> 
/// <remarks>Autre commentaire sur cette méthode</remarks>
public string GetCommentFromInt(List<string> comments, int commentIndex)

Balise <value />

S’utilise pour documenter des valeurs d’une propriété. Cette balise n’est pas rajoutée automatiquement.

Par exemple:

/// <summary> 
/// Renvoie l'index de la structure. 
/// </summary> 
/// <value>La valeur doit être un entier</value>
public int Index 
{ 
    get { return index; } 
    set { index = value; } 
}

Balise <typeparam />

Cette balise permet de documenter le type d’un générique. Elle est automatique ajoutée si la déclaration d’un objet contient des génériques.

Par exemple:

/// <summary> 
///  
/// </summary> 
/// <typeparam name="TReturn"></typeparam>
/// <typeparam name="TIndex"></typeparam>
/// <param name="commentIndex"></param> 
/// <returns></returns> 
public TReturn GetTypedComment<TReturn, TIndex>(TIndex commentIndex) 
{ 
    return default(TReturn); 
}

Balise <typeparamref />

Permet de faire une référence vers un type d’un générique.

Par exemple:

/// <summary> 
/// Le type <typeparamref name="TReturn"/> est le type de retour et  
/// <typeparamref name="TIndex"/> est le type de l'index. 
/// </summary> 
/// <typeparam name="TReturn"></typeparam> 
/// <typeparam name="TIndex"></typeparam> 
/// <param name="commentIndex"></param> 
/// <returns></returns> 
public TReturn GetTypedComment<TReturn, TIndex>(TIndex commentIndex)

Balise <list />

Permet d’indiquer une liste d’éléments. Le type de la liste doit être:

  • “bullet” pour une liste simple
  • “number” pour une liste numérotée
  • “table” pour un tableau.

Pour indiquer une liste, il faut utiliser la syntaxe:

<list type="bullet"> 
    <listheader> 
        <term>term</term> 
        <description>description</description> 
    </listheader> 
    <item> 
        <term>term</term> 
        <description>description</description> 
    </item> 
</list>

Les éléments sont utilisés de cette façon:

  • <listheader/> est utilisé dans le cas d’un tableau pour l’entête. Il peut être omis dans les autres cas. S’il y a plusieurs colonnes dans le tableau, il doit y avoir une succession de plusieurs éléments <listheader/>.
  • <term /> indique le nom d’un élément
  • <description /> indique la description d’une élément.
  • <item/> contient un élément à lister.

Autres balises

Pour avoir une liste complête des balises: Recommended Tags for Documentation Comments.

Outils générant la documentation à partir d’un fichier XML

Sur MSDN, on indique qu’il est possible d’utiliser NDoc ou SandCastle (sur GitHub ou CodePlex.

Pour avoir plus de détails pour utiliser SandCastle, on peut se référer à cette page (pour télécharger SandCastle, il faut utiliser le page CodePlex): Génération de documentation avec Sandcastle Help File Builder

D’autres solutions existent:

Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Principe de développement DRY (Don’t Repeat Yourself)

DRY pour “Don’t Repeat Yourself” est un principe de programmation visant à exercer les développeurs à reconnaître des duplications puis de trouver le moyen de les supprimer. Ces duplications peuvent se produire évidemment dans le code mais aussi à tout niveau de l’application comme par exemple dans son architecture, dans les tests unitaires ou dans les processus de déploiement. DRY est donc un principe beaucoup général que simplement de la duplication de code.

Andy Hunt et Dave Thomas qui ont été les premiers (dans The Pragmatic Programmer) à énoncer ce principe, partent de l’idée que chaque ligne de code d’une application doit être maintenue. D’autre part, le plupart des interventions sur du code ont pour but d’effectuer de la maintenance plutôt que l’écriture de code nouveau. Ainsi pour corriger une logique répétée, il faut:

  • d’abord, intervenir sur les points de l’application pour chercher où se trouve ces répétitions,
  • ensute, intervenir sur chacune de ces répétitions pour corriger la logique implémentée.

Toutes ces étapes sont d’autant plus couteuses si elles sont réalisées par un développeur qui n’a pas réalisé ces duplications ou qui ne connaît pas assez l’application pour trouver où se trouve ces points de duplications.

Ainsi, d’après Andy Hunt et Dave Thomas, DRY nécessite que:

"Dans un système, toute connaissance doit avoir une représentation unique, 
    non-ambiguë, faisant autorité."

Ne pas dupliquer du code

La première source de duplication de logique provient de la duplication directe de code en copiant-collant des parties de code.

Acquérir certains réflexes permet d’abord d’éviter de dupliquer du code soi-même et ensuite de s’exercer à identifier des duplications quand on lit du code. A part les cas évident, ce n’est pas toujours trivial de remarquer que du code est dupliqué.

Magic string

Les magic strings peuvent être des chaines de caractères qui sont des constantes utilisées directement dans le code:

Par exemple:

public string GetDivisionName(int floor) 
{ 
    if (floor == 0) 
    { 
        return "Accueil"; 
    } 
    else if (floor == 1) 
    { 
        return "Comptabilité"; 
    } 
    else if (floor == 2) 
    { 
        return "Marketing"; 
    } 
    else if (floor == 3) 
    { 
        return "Conception"; 
    } 
    else if (floor == 4) 
    { 
        return "Direction"; 
    } 
    else 
    { 
        throw new InvalidOperationException(string.Format( 
            "L'étage '{0}' n'existe pas.", floor); 
    } 
} 

Les chaînes de caractères “Accueil”, “Comptabilité”, “Marketing”, “Conception” et “Direction” sont des magic strings car elles correspondent à des constantes. Utiliser ce type de constantes dans du code poussent à la duplication.

Par exemple, si on veut faire la fonction symétrique de celle-ci, on peut écrire la fonction suivante en dupliquant les chaînes de caractères:

public int GetFloor(string divisionName) 
{ 
    if (divisionName == "Accueil") 
    { 
        return 0; 
    } 
    else if (divisionName == "Comptabilité") 
    { 
        return 1; 
    } 
    else if (divisionName == "Marketing") 
    { 
        return 2; 
    } 
    else if (divisionName == "Conception") 
    { 
        return 3; 
    } 
    else if (divisionName == "Direction") 
    { 
        return 4; 
    } 
    else 
    { 
        throw new InvalidOperationException(string.Format( 
        "La service '{0}' ne correspond à aucun étage", service); 
    } 
}

Pour éviter cette duplication, on peut, par exemple, ranger les chaines des variables statiques dédiées dans la même classe, dans une autre classe ou même dans un fichier séparé suivant leur portée:

private const string receptionDivisionName = "Accueil"; 
private const string accountingDivisionName = "Comptabilité"; 
private const string marketingDivisionName = "Marketing"; 
private const string conceptionDivisionName = "Conception"; 
private const string directionDivisionName = "Direction"; 
 
public string GetDivisionName(int floor) 
{ 
    if (floor == 0) 
    { 
        return receptionDivisionName; 
    } 
    else if (floor == 1) 
    { 
        return accountingDivisionName; 
    } 
    else if (floor == 2) 
    { 
        return marketingDivisionName; 
    } 
    else if (floor == 3) 
    { 
        return conceptionDivisionName; 
    } 
    else if (floor == 4) 
    { 
        return directionDivisionName; 
    } 
    else 
    { 
        throw new InvalidOperationException(string.Format( 
            "L'étage '{0}' n'existe pas.", floor); 
    } 
} 
 
public int GetFloor(string divisionName) 
{ 
    if (divisionName == receptionDivisionName) 
    { 
        return 0; 
    } 
    else if (divisionName == accountingDivisionName) 
    { 
        return 1; 
    } 
    else if (divisionName == marketingDivisionName) 
    { 
        return 2; 
    } 
    else if (divisionName == conceptionDivisionName) 
    { 
        return 3; 
    } 
    else if (divisionName == directionDivisionName) 
    { 
        return 4; 
    } 
    else 
    { 
        throw new InvalidOperationException(string.Format( 
            "La service '{0}' ne correspond à aucun étage", service); 
    } 
}

Magic number

Les magic numbers sont des constantes qui ont un sens particulier mais ce sens reste caché parce que le code ne le dévoile pas assez facilement.
Dans les fonctions précédentes, les entiers correspondants aux étages correspondent à des magic numbers. On pourrait de la même façon les ranger dans des variables dédiés.

En utilisant un exemple différent:

public string GetMobilePhonePriceRange(decimal price) 
{ 
    if (price < 50.0m) 
    { 
        return "Bon marché"; 
    } 
    else if (price >= 50.0m && price < 130.0m) 
    { 
        return "Moyen de gamme"; 
    } 
    else if (price >= 130.0m && price < 300.0m) 
    { 
        return "Haut de gamme"; 
    } 
    else if (price >= 300.0m) 
    { 
        return "Très haut de gamme"; 
    } 
}

Cette fonction devient:

private const decimal cheapPriceRangeLimit = 50.0m; 
private const decimal midPriceRangeLimit = 130.0m; 
private const decimal highPriceRangeLimit = 300.0m; 
 
public string GetMobilePhonePriceRange(decimal price) 
{ 
    if (price < cheapPriceRangeLimit) 
    { 
        return "Bon marché"; 
    } 
    else if (price >= cheapPriceRangeLimit && price < midPriceRangeLimit) 
    { 
        return "Moyen de gamme"; 
    } 
    else if (price >= midPriceRangeLimit && price < highPriceRangeLimit) 
    { 
        return "Haut de gamme"; 
    } 
    else if (price >= highPriceRangeLimit) 
    { 
        return "Très haut de gamme"; 
    } 
} 

On peut aller plus loin en considérant des gammes de prix:

private const decimal cheapPriceRangeLimit = 50.0m; 
private const decimal midPriceRangeLimit = 130.0m; 
private const decimal highPriceRangeLimit = 300.0m; 
 
private const string cheapPriceLabel = "Bon marché"; 
private const string midPriceLabel = "Moyen de gamme"; 
private const string highPriceLabel = "Haut de gamme"; 
private const string veryHighPriceLabel = "Très haut de gamme"; 
 
private readonly Dictionary<string, Tuple<decimal?, decimal?>> priceRanges = 
    new Dictionary<string, Tuple<decimal?, decimal?>> 
    { 
        {  cheapPriceLabel, new Tuple<decimal?, decimal?>(null, cheapPriceRangeLimit) }, 
        {  midPriceLabel, new Tuple<decimal?, decimal?>(cheapPriceRangeLimit, midPriceRangeLimit) }, 
        {  highPriceLabel, new Tuple<decimal?, decimal?>(midPriceRangeLimit, highPriceRangeLimit) }, 
        {  veryHighPriceLabel, new Tuple<decimal?, decimal?>(highPriceRangeLimit, null) } 
    }; 
 
public string GetMobilePhonePriceRange(decimal price) 
{ 
  return priceRanges.First(r =>  
    { 
      var lowerLimit = r.Value.Item1; 
      var upperLimit = r.Value.Item2; 
      return (!lowerLimit.HasValue && upperLimit.HasValue && price < upperLimit.Value) ¦¦ 
        (lowerLimit.HasValue && upperLimit.HasValue && price >= lowerLimit.Value && price >= upperLimit.Value) ¦¦ 
        (lowerLimit.HasValue && !upperLimit.HasValue && price >= upperLimit.Value); 
    }) 
    .Key; 
}

Duplication de clauses “if”

D’une façon générale la répétition de if est révélateur d’une implémentation qui pourrait être améliorée.

Dans l’exemple utilisé précédemment:

private const string receptionDivisionName = "Accueil"; 
private const string accountingDivisionName = "Comptabilité"; 
private const string marketingDivisionName = "Marketing"; 
private const string conceptionDivisionName = "Conception"; 
private const string directionDivisionName = "Direction"; 
 
private const int mainFloor = 0; 
private const int firstFloor = 1; 
private const int secondFloor = 2; 
private const int thirdFloor = 3; 
private const int fourthFloor = 4; 
 
public string GetDivisionName(int floor) 
{ 
    if (floor == mainFloor) 
    { 
        return receptionDivisionName; 
    } 
    else if (floor == firstFloor) 
    { 
        return accountingDivisionName; 
    } 
    else if (floor == secondFloor) 
    { 
        return marketingDivisionName; 
    } 
    else if (floor == thirdFloor) 
    { 
        return conceptionDivisionName; 
    } 
    else if (floor == fourthFloor) 
    { 
        return directionDivisionName; 
    } 
    else 
    { 
        throw new InvalidOperationException(string.Format( 
            "L'étage '{0}' n'existe pas.", floor); 
    } 
}

Cette fonction comporte beaucoup de répétitions dues aux clauses if. Il est possible de les supprimer facilement:

private readonly Dictionary<int, string> floors = new Dictionary<int, string> 
  { 
    {  mainFloor, receptionDivisionName },  
    {  firstFloor, accountingDivisionName },  
    {  secondFloor, marketingDivisionName },  
    {  thirdFloor, conceptionDivisionName },  
    {  fourthFloor, directionDivisionName }  
  }; 
 
public string GetDivisionName(int floor) 
{ 
    var foundFloor = floors.FirstOrDefault(f => f.Key.Equals(floor)); 
    if (foundFloor == null) 
    { 
        throw new InvalidOperationException(string.Format( 
            "L'étage '{0}' n'existe pas.", floor); 
    } 
 
    return foundFloor.Value; 
}

Duplication de logique

On peut sans s’en rendre compte dupliquer de la logique dans le code.

Par exemple, si on considère les objets suivants:

public class Building 
{ 
  private string Name { get private set; } 
 
  public Building() 
  {} 
} 
 
public class Floor 
{ 
  public string Name { get private set; } 
  public int FloorNumber { get private set; } 
 
  public Floor() 
  {} 
} 
 
public class Branch 
{ 
  public string Name { get private set; } 
 
  public Branch() 
  {} 
} 
 
public class Division 
{ 
  public Building building; 
  public Floor floor; 
  public Branch branch; 
 
  public Division() 
  {} 
 
  public string GetDivisionAddress() 
  { ... } 
}

Pour implémenter la fonction Division.GetDivisionAddress() on peut écrire:

public string GetAddress() 
{ 
  string address = string.Empty; 
  if (this.branch != null) 
  { 
    address += string.Format("Branch: {0}", this.branch.Name); 
  } 
  if (this.building != null) 
  { 
    address += string.Format("Building: {0}", this.building.Name); 
  } 
  if (this.floor != null) 
  { 
    address += string.Format("Floor {0}: {1}", this.floor.FloorNumber, 
      this.floor.Name); 
  } 
  return address; 
}

Si on doit créer un nouvel objet représentant un bureau et qu’on souhaite rajouter une adresse dans cet objet, on devra dupliquer certains éléments du code notamment dans la fonction Desk.GetDeskAddress():

public class Desk 
{ 
  public Building building; 
  public Floor floor; 
  public Branch branch; 
  public string area; 
 
  public Desk() 
  {} 
 
  public string GetDeskAddress() 
  { 
    string address = string.Empty; 
    if (this.branch != null) 
    { 
      address += string.Format("Branch: {0}", this.branch.Name); 
    } 
    if (this.building != null) 
    { 
      address += string.Format("Building: {0}", this.building.Name); 
    } 
    if (this.floor != null) 
    { 
      address += string.Format("Floor {0}: {1}", this.floor.FloorNumber,  
        this.floor.Name); 
    } 
    if (!string.IsNullOrEmpty(this.area)) 
    { 
      address += string.Format("Area: {0}", this.area); 
    } 
 
    return address; 
  } 
}

Ranger du code dans des classes statiques

Dans l’exemple précédent, les fonctions Division.GetAddress() et Desk.GetDivision() sont dupliquées. Une méthode pour éviter cette duplication, consiste à ranger le code dupliqué dans des classes statiques Helper. Pour cette exemple, on pourrait isoler le code en créent la fonction statique suivante:

public static class Helper 
{ 
  public static string GetAddress(Branch branch, Building building, Floor floor) 
  { 
    string address = string.Empty; 
    if (branch != null) 
    { 
      address += string.Format("Branch: {0}", branch.Name); 
    } 
    if (building != null) 
    { 
      address += string.Format("Building: {0}", building.Name); 
    } 
    if (floor != null) 
    { 
      address += string.Format("Floor {0}: {1}", floor.FloorNumber, 
        this.floor.Name); 
    } 
 
    return address; 
  } 
}

Les appels se font dans les classes Division et Desk, de cette façon:

public class Division 
{ 
  // ... 
 
 
  public string GetDivisionAddress() 
  { 
    return Helper.GetAddress(this.branch, this.building, this.floor); 
  } 
} 
 
public class Desk 
{ 
  // ... 
 
  public string GetDeskAddress() 
  { 
    string address = Helper.GetAddress(this.branch, this.building, this.floor); 
    if (!string.IsNullOrEmpty(this.area)) 
    { 
      address += string.Format("Area: {0}", this.area); 
    } 
 
    return address; 
  } 
}

Cette méthode présent l’inconvénient de coupler la classe statique Helper aux classes Division et Desk. D’autre part, les objets métier Branch, Building et Floor n’ont pas la faculté de savoir comment ils affichent leur propre emplacement. Cette faculté se trouve dans des objets différents comme Division et Desk. Ainsi cette implémentation a plusieurs inconvénients:

  • Couplage fort avec un autre objet Helper.
  • Elle donne trop de responsabilité aux objets Division et Desk puisqu’ils ont la faculté d’afficher des adresses concernant d’autres objets, ce qui casse le principe “Single Responsibility” de SOLID.
  • En cas de modification des propriétés des objets Branch, Building ou Floor, le code des classes Division et Desk devra être modifié, ce qui casse le principe “ouvert/fermé” de SOLID: ouvert aux extensions et fermé aux modifications.

Rétablir les responsabilités

Pour rétablir les responsabilités concernant l’affichage de l’adresse, on peut charger un autre objet d’avoir cette responsabilité. Ce nouvel objet sera uniquement chargé d’afficher une adresse:

public class AddressDisplayer 
{ 
  public string GetAddress(Branch branch, Building building, Floor floor,  
    string area = "") 
  { 
    string address = string.Empty; 
    if (branch != null) 
    { 
      address += string.Format("Branch: {0}", branch.Name); 
    } 
    if (building != null) 
    { 
      address += string.Format("Building: {0}", building.Name); 
    } 
    if (floor != null) 
    { 
      address += string.Format("Floor {0}: {1}", floor.FloorNumber, 
        this.floor.Name); 
    } 
    if (!string.IsNullOrEmpty(this.area)) 
    { 
      address += string.Format("Area: {0}", this.area); 
    } 
 
    return address; 
  } 
}

En injectant cette classe dans les classes Division et Desk, on supprime une partie de la responsabilité de l’affichage de l’adresse:

public class Division 
{ 
  public Building building; 
  public Floor floor; 
  public Branch branch; 
 
  public AddressDisplayer addressDisplayer; 
 
  public Division(AddressDisplayer addressDisplayer, Building building, Floor floor, 
    Branch branch) 
  { 
    this.addressDisplayer = addressDisplayer; 
 
    this.building = building; 
    this.branch = branch; 
    this.floor = floor; 
  } 
 
  public string GetDivisionAddress() 
  { 
    return this.addressDisplayer.GetAddress(this.branch, this.building, this.floor); 
  } 
}

De même pour la classe Desk:

public class Desk 
{ 
  public Building building; 
  public Floor floor; 
  public Branch branch; 
  public string area; 
 
  public AddressDisplayer addressDisplayer; 
 
  public Desk(AddressDisplayer addressDisplayer, Building building, Floor floor,
    Branch branch, 
    string area) 
  { 
    this.addressDisplayer = addressDisplayer; 
 
    this.building = building; 
    this.branch = branch; 
    this.floor = floor; 
    this.area = area; 
  } 
 
  public string GetDeskAddress() 
  { 
    return this.addressDisplayer.GetAddress(this.branch, this.building, this.floor,
      this.area); 
  } 
}

Le couplage avec la classe statique Helper est supprimée en effectuant une injection de dépendances dans les classes Division et Desk.

Eviter les couplages forts

Pour diminuer davantage le nouveau couplage avec la classe AddressDisplayer, on peut introduire l’interface IAddressDisplayer. De cette façon la dépendance sera vers une interface plutôt qu’une classe:

public interface IAddressDisplayer 
{ 
  string GetAddress(Branch branch, Building building, Floor floor, string area = ""); 
}

On redéfinit alors AddressDisplayer de cette façon:

public class AddressDisplayer : IAddressDisplayer 
{ 
  public string GetAddress(Branch branch, Building building, Floor floor,
    string area = "") 
  { 
    // ... 
  } 
}

Maintenant il suffit d’injecter l’interface IAddressDisplayer plutôt que la classe AddressDisplayer:

public class Division 
{ 
  public Building building; 
  public Floor floor; 
  public Branch branch; 
 
  public IAddressDisplayer addressDisplayer; 
 
  public Division(IAddressDisplayer addressDisplayer, Building building, Floor floor,
    Branch branch) 
  { 
    this.addressDisplayer = addressDisplayer; 
 
    // ... 
  } 
 
  // ... 
}

Et:

public class Desk 
{ 
  public Building building; 
  public Floor floor; 
  public Branch branch; 
  public string area; 
 
  public IAddressDisplayer addressDisplayer; 
 
  public Desk(IAddressDisplayer addressDisplayer, Building building, Floor floor,
    Branch branch, string area) 
  { 
    this.addressDisplayer = addressDisplayer; 
 
    // ... 
  } 
 
  // ... 
}

Pour avoir plus de détails sur l’injection de dépendances: Injection de dépendances en utilisant Unity en 10 min.

L’injection de dépendances facilite les tests unitaires

Le gros intérêt d’introduire un couplage vers une interface plutôt que la classe, est de faciliter les tests unitaires des classes Division et Desk. Il sera ainsi plus facile d’utiliser un “Mock” pour la classe AddressDisplayer (un “Mock” peut être facilement appliqué sur une interface, sur une classe abstraite ou sur une classe dont les fonctions à “moquer” sont virtuelles).

Introduire une abstraction

Les classes Division et Desk restent très couplées aux objets Building, Branch et Floor. On peut encore diminuer ce couplage en introduisant la classe abstraite Location:

public abstract class Location 
{ 
  public string Name { get private set; } 
  public int AddressRank { get private set; } 
 
  public Location(string name, int addressRank) 
  { 
    this.Name = name; 
    this.AddressRank = addressRank; 
  } 
 
  public abstract string GetAddress(); 
}

On redéfinit les classes Building, Branch et Floor:

public class Building : Location 
{ 
  public Building(string name, 2) 
    : base(name) 
  {} 
 
  public override string GetAddress() 
  { 
    if (!string.IsNullOrEmpty(this.Name)) 
    { 
      return string.Format("Building: {0}", this.Name); 
    } 
 
    return string.Empty; 
  } 
} 
 
public class Floor : Location 
{ 
  public int FloorNumber { get private set; } 
 
  public Floor(string name, int floorNumber) 
    : base(name, 3) 
  { 
    this.FloorNumber = floorNumber; 
  } 
 
  public override string GetAddress() 
  { 
    if (!string.IsNullOrEmpty(this.Name)) 
    { 
      return string.Format("Floor {0}: {1}", this.FloorNumber, 
        this.Name); 
    } 
 
    return string.Empty; 
  } 
} 
 
public class Branch : Location 
{ 
  public Branch(string name) 
    : base(name, 1) 
  {} 
 
  if (!string.IsNullOrEmpty(this.Name)) 
  { 
    return string.Format("Branch: {0}", this.Name); 
  } 
}

On peut ensuite facilement introduire la classe Area:

public class Area : Location 
{ 
  public Area(string name, 4) 
    : base(name) 
  {} 
 
  public override string GetAddress() 
  { 
    if (!string.IsNullOrEmpty(this.Name)) 
    { 
      return string.Format("Desk area: {0}", this.Name); 
    } 
 
    return string.Empty; 
  } 
}

L’héritage permet de supprimer les dépendances des classes Division et Desk:

public class Desk 
{ 
  public readonly List<Location> locations; 
  public readonly IAddressDisplayer addressDisplayer; 
 
  public Desk(List<Location> locations, IAddressDisplayer addressDisplayer) 
  { 
    this.locations = locations; 
    this.addressDisplayer = addressDisplayer; 
  } 
 
  public string GetDeskAddress() 
  { 
    return this.addressDisplayer.GetAddress(this.locations); 
  } 
} 
 
public class Division 
{ 
  public readonly List<Location> locations; 
  public readonly IAddressDisplayer addressDisplayer; 
 
  public Division(List<Location> locations, IAddressDisplayer addressDisplayer) 
  { 
    this.locations = locations; 
    this.addressDisplayer = addressDisplayer; 
  } 
 
  public string GetDivisionAddress() 
  { 
    return this.addressDisplayer.GetAddress(this.locations); 
  } 
}

On rédifinit la classe AddressDisplayer et l’interface IAddressDisplayer:

public interface IAddressDisplayer 
{ 
  string GetAddress(IEnumerable<Location> locations); 
} 
 
public class AddressDisplayer : IAddressDisplayer 
{ 
  public string GetAddress(IEnumerable<Location> locations) 
  { 
    string address = string.Empty; 
    foreach (var location in locations.OrderBy(l => l.AddressRank) 
    { 
      string locationAddress = location.GetAddress(); 
      if (!string.IsNullOrEmpty(locationAddress)) 
      { 
        if (!string.IsNullOrEmpty(address)) 
        { 
          address += " "; 
        } 
        address += locationAddress; 
      } 
    } 
 
    return address; 
  } 
}

Le couplage entre les classes est donc fortement réduit. D’autre part cette implémentation permet de mieux respecter les principes “Single responsability” et “Open/Close” de SOLID (http://cdiese.fr/principe-de-developpement-oriente-objet-solid/).

Démarche Software Craftmanship

La démarche Software Craftmanship et la réalisation de Katas permet de s’exercer pour acquérir des réflexes visant à limiter des duplications du code ou à indiquer des méthodes de “refactoring” pour supprimer du code dupliqué.

Utiliser des “design patterns”

L’utilisation de “design patterns” permet aussi d’éviter les duplications de logique dans plusieurs objets.

Par exemple dans l’exemple précédent, si souhaite instancier des objets de type Division et Desk, il faudra écrire le code:

IAddressDisplayer addressDisplayer = new AddressDisplayer(); 
 
var itDivisionLocations = new List<Location>  
{ 
  new Branch("Paris"), new Building("La Défense"), new Floor(3, "IT") 
} 
 
Division division = new Division(itDivisionLocations, addressDisplayer); 
 
var ceoDeskLocations = new List<Location> 
{ 
  new Branch("Paris"), new Building("La Défense"), new Floor(36, "Direction"), 
  new Area("Chief headquarter"), 
} 
 
Desk ceoDesk = new Desk(ceoDeskLocations, addressDisplayer);

Ce qui signifie qu’à chaque instanciation de Division ou de Desk, il faudra aussi instancier certaines classes avec certaines informations. Pour éviter de dupliquer cette logique, on peut s’aider des “design patterns” Factory ou Abstract Factory.

Dans le cas de Factory, il suffit de déporter cette logique dans une classe, par exemple:

public class PlaceFactory 
{ 
  private readonly IAddressDisplayer addressDisplayer; 
 
  public PlaceFactory() 
  { 
    this.addressDisplayer = new AddressDisplayer(); 
  } 
 
  public Division CreateDivision(string branchName, string building,  
    int floorNumber, string floorName) 
  { 
    var divisionLocations = new List<Location>  
    { 
      new Branch(branchName), new Building(building),  
      new Floor(floorNumber, floorName) 
    } 
    return new Division(divisionLocations, this.addressDisplayer); 
  } 
 
  public Desk CreateDesk(string branchName, string building,  
    int floorNumber, string floorName, string deskArea) 
  { 
    var deskLocations = new List<Location>  
    { 
      new Branch(branchName), new Building(building),  
      new Floor(floorNumber, floorName), new Area(deskArea) 
    } 
    return new Desk(deskLocations, this.addressDisplayer); 
  } 
}

De même pour afficher les adresses d’une division ou d’un desk, il faut faire des appels différents suivants le type de classe:

Division division = ... 
string divisionAddress = division.GetDivisionAddress(); 
 
Desk desk = ... 
string deskAddress = desk.GetDeskAddress();

On peut utiliser le pattern Strategy pour obtenir les adresses sans se préoccuper de l’appel de la méthode suivant le type de la classe.

Par exemple, si on définit les classes suivantes:

public interface IPlaceAddressDisplayerStrategy 
{ 
  string GetPlaceAddress(); 
} 
 
public class DivisionAddressDisplayerStrategy : IPlaceAddressDisplayerStrategy 
{ 
  private readonly Division division; 
 
 
  public DivisionAddressDisplayerStrategy(Division division) 
  { 
    this.division = division; 
  } 
 
  public string GetPlaceAddress() 
  { 
    return this.division.GetDivisionAddress(); 
  } 
} 
 
public class DeskAddressDisplayerStrategy : IPlaceAddressDisplayerStrategy 
{ 
  private readonly Desk desk; 
 
 
  public DeskAddressDisplayerStrategy(Desk desk) 
  { 
    this.desk = desk; 
  } 
 
  public string GetPlaceAddress() 
  { 
    return this.desk.GetDeskAddress(); 
  } 
}

Si une classe doit afficher des adresses mais ne doit pas se préoccuper du type des objets qui détiennent une adresse, il suffit qu’elle consomme une classe stratégie, dans l’exemple il s’agira d’une classe satisfaisant IPlaceAddressDisplayer.

Par exemple, si on considère la classe AddressShower:

public class AddressShower 
{ 
  public AddressShower() 
  {} 
 
  public void ShowAddress(IPlaceAddressDisplayerStrategy addressDisplayer) 
  { 
    Console.WriteLine(addressDisplayer.GetPlaceAddress()); 
  } 
}

Cette classe ignore le type des objets Division et Desk et n’a pas besoin de les connaître pour afficher une adresse.
AddressShower peut afficher une adresse de cette façon:

AddressShower addressShower = new AddressShower(); 
 
Division division = ... 
var divisionAddressDisplayerStrategy = new DivisionAddressDisplayerStrategy(division); 
addressShower.ShowAddress(divisionAddressDisplayerStrategy); 
 
Desk desk = ... 
var deskAddressDisplayerStrategy = new DeskAddressDisplayerStrategy(desk); 
addressShower.ShowAddress(deskAddressDisplayerStrategy);

Ainsi le pattern Strategy sert aussi éviter des duplications dans le code puisqu’il va permettre d’implémenter la logique d’appel à la bonne méthode suivant le type de classe dans un seul type d’objets.

Automatiser des processus

Le principe DRY ne vise pas seulement à éviter du dupliquer du code, il concerne aussi des processus au sens large. Ainsi ces processus comprennent le fait d’éviter d’effectuer des tâches répétitives.

Test unitaires

Les tests unitaires participent fortement à réduire les tâches répétitives puisqu’ils vont permettre d’appliquer des tests de façon automatique et éviter de les effectuer manuellement.
Des outils comme MsTest, NUnit, moq ou NSubstitute aident à l’exécution de test unitaires.

Intégration continue

Executer des tests unitaires, des tests d’intégration de façon périodique et notifier les développeurs des éventuelles erreurs liées à ces tests permet aussi de réduire les tâches répétitives.
Des outils comme Jenkins ou Teamcity permet d’apporter une automatisation de toutes ces étapes.

Générateurs de code

On peut aussi utiliser des générateurs de code pour s’éviter de dupliquer des objets à la main. Par exemple on peut utiliser la génération T4 de Visual Studio (T4 pour Text Template Transformation Toolkit).

S’aider d’outils pour éviter les duplications

Enfin il existe des outils pour indiquer quelles sont les parties du code qui sont dupliquées. Ce type d’outils permet de traquer les répétitions dans le code, elles seront moins efficaces pour trouver les répétitions dans la logique.
Des outils comme SonarQube permettent d’analyser le code et de donner des indicateurs quant aux duplications du code.

Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Domain-Driven Design en 5 min

Cet article est un aide mémoire sur le Domain-Driven Design (DDD), il ne vise pas à expliquer le DDD mais simplement à rappeler les concepts clés.

Certains termes sont laissés volontairement en anglais en particulier lorsque leur traduction n’est pas très claire en français ou lorsque que le terme français n’est pas très utilisé.

Définition

La conception pilotée par le domaine (i.e. Domain-Driven Design ou DDD) est une approche de conception logicielle définie par Eric Evans qui vise à accorder de l’importance au domaine métier.En effet, dans la plupart des logiciels, la logique métier qui est implémentée est ce qui constitue la plus grande valeur ajoutée puisque c’est cette logique qui rend le logiciel fonctionnel.

Pourtant très souvent une grande part des développements se concentrent sur d’autres parties comme l’interface graphique, à la persistance des données ou au partage d’informations avec des systèmes externes.

DDD n’est pas une méthode pour concevoir des logiciels mais juste une approche qui permet d’indiquer comment concevoir un logiciel en prenant davantage en compte le domaine métier.

L’intérêt de DDD est:

  • Permettre à l’équipe de créer un modèle et de le communiquer aux experts métier mais aussi à d’autres acteurs de l’entreprise avec les spécifications fonctionnelles, les entités du modèle de données et la modélisation de processus.
  • Le modèle est modulaire et plus facile à maintenir.
  • Il améliore la testabilité et la généricité des objets du domaine métier.

L’approche DDD vise, dans un premier temps, à isoler un domaine métier. Un domaine métier riche comporte les caractéristiques suivantes:

  • Il approfondit les règles métier spécifiques et il est en accord avec le modèle d’entreprise, avec la stratégie et les processus métier.
  • Il doit être isolé des autres domaines métier et des autres couches de l’architecture de l’application.
  • Le modèle doit être construit avec un couplage faible avec les autres couches de l’application
  • Il doit être une couche abstraite et assez séparée pour être facilement maintenue, testée et versionnée.
  • Le modèle doit être concu avec le moins de dépendances possibles avec une technologie ou un framework. Il devrait être constitué par des objets POCO (Plain Old C# Object). Les POCO sont des objets métier disposant de données, de logique de validation et de logiques métier. Il ne doit pas comporter de logique de persistance.
  • Le domaine métier ne doit pas comporter de détails d’implémentation de la persistance.

Comprendre le domaine et le communiquer

Le domaine est connu par les spécialistes du domaine qui sont des experts. Il faut donc rencontrer ces experts pour comprendre toutes les subtilités du domaine. A terme, le logiciel implémenté modélisera le domaine. Toutefois avant que le logiciel ne devienne le reflet du domaine, il faut le comprendre.

Cette compréhension permettra, dans un premier temps, de modéliser le domaine (c’est-à-dire extraire le modèle). Il n’y a pas de méthode pour modéliser le domaine mais on peut s’aider de dessins, diagrammes, simplement un texte écrit etc… Le plus important est de comprendre le domaine pour le communiquer.

La communication du modèle passe par des entrevues entre experts du domaine, concepteurs logiciels et développeurs.

Ubiquitous language

Un frein à la communication du modèle pourrait être l’utilisation d’un langage spécialisé ou technique de la part des experts ou des développeurs. Ce langage spécialisé tend à rendre la compréhension du domaine compliqué pour chacun des acteurs. Il est donc important de se mettre d’accord sur un vocabulaire commun et compréhensible de tous dont le but ultime est un domaine compréhensible et exploitable.

Ce vocabulaire commun est l’ubiquitous language (i.e. “langage omniprésent”), il permet de:

  • Trouver les concepts clés qui définissent le domaine et ensuite la conception
  • Révéler les expressions utilisées dans le domaine
  • Chercher à résoudre les ambiguités et inconnues

L’ubiquitous language ne s’élabore pas en une seule itération, il est le fruit de plusieurs discutions avec les experts du domaine.

Pour l’élaborer, il faut:

  • Eviter d’utiliser des termes que les experts n’ont pas prononcés.
  • Créer un glossaire contenant les termes utilisés par les experts du domaine de façon à les expliquer.
  • Vérifier qu’un terme est utilisé pour un seul concept.
  • Eviter d’utiliser des termes trop proches de solutions techniques ou de “design patterns”. Ces termes peuvent diriger les développeurs vers une solution particulière et ils peuvent être incompréhensible par les experts du domaine.
  • Les termes de l’ubiquitous language doivent se retrouver dans le code pour qualifier des propriétés, des comportements qui implémentent une partie du domaine et surtout les tests. Si l’ubiquitous language est compris, le code sera plus clair.
  • Les évolutions de l’ubiquitous language peuvent se traduire par des modifications des noms ou comportements utilisés dans le code.
  • Il n’est pas nécessaire d’élaborer un ubiquitous language pour toute l’application. Il est préférable de réserver cette démarche aux parties complexes du domaine.

Extraire les domain models à partir du domaine

Le domain model (i.e. modèle du domaine) est une version simplifiée et implémentable du domaine. Le domain model ne contient que la partie du domaine pour laquelle le logiciel doit apporté une solution. Le domain model sera utile s’il représente efficacement la logique complexe du domaine pour permettre la résolution du problème tout en étant compréhensible des experts du domaine.

Dans une approche idéale, et dans une première version, le domain model ne doit pas trop prendre en compte la solution technique mise en œuvre pour l’implémentation. Toutefois, lors des phases suivantes de traduction du domain model, l’équipe de développement va probablement lever des incohérences, des ambiguités ou des points techniques bloquants qui vont remettre en cause le domain model. Après des entrevues avec les experts du domaine, des refactorings successifs permettront d’aboutir à un domain model implémentable tout en étant fidèle au domain dont il est censé être la réduction.

Ainsi, l’approche DDD insiste sur 3 points qui sont essentiels à la bonne marche d’un projet:

  1. La connaissance du domaine.
  2. L’ubiquitous language.
  3. La collaboration entre les développeurs et les experts du domaine.

La représentation du domain model peut se faire en utilisant UML. Pour avoir plus de détails sur l’UML: UML en français.

Plus le domaine est complexe et plus le domain model devra résoudre des problèmes complexes. Dans la pratique, pour un domaine complexe, il faut pas utiliser qu’un seul mais plusieurs domain models qui adresseront chacun un problème différent.

Analysis model

Le domain model permet d’élaborer le modèle d’analyse (i.e. “analysis model”) qui va aider les développeurs à comprendre les problèmes du domaine.

Code model

Le modèle du code (i.e. “code model”) est une version du domaine utilisée pour l’implémentation. Il est différent du domain model puisqu’il n’est compréhensible que par les développeurs. Il est le lien entre le domain model qui est loin des considérations d’implémentation et le code.

Lors de la traduction du domain model en code:

  • Ce sont les développeurs qui effectuent la traduction du modèle en code.
  • Des défauts peuvent apparaître dans l’implémentation, ces défauts doiven faire l’objet de “refactorings”.
  • Plus le code est lié au domain model et à l’ubiquitous language et plus les développeurs comprendront facilement le modèle.
  • Les développeurs doivent faire des feedbacks pouvant mener, éventuellement, à des “refactorings”.
  • Il faut éviter des trop grandes différences entre le code et le domain model sinon le modèle pourrait ne plus être compréhensible en cas de “refactoring” par les autres acteurs que les développeurs.

Model Driven Design

La conception dirigée par le modèle (i.e. “Model Driven Design”) permet d’assurer le lien entre le modèle d’analyse et le modèle de code. Cette approche se focalise davantage sur l’implémentation par rapport à l’approche DDD qui plus des indications sur l’élaboration de l’ubiquitous language, de la collaboration avec les experts du domaine et la connaissance partagée du domaine.

La conception dirigée par le modèle permet à la connaissance du domaine et de l’ubiquitous language d’être incorporés dans le modèle de code. Il devient alors, une interprétation du langage et du modèle mental des experts du domaine.

L’approche de la conception dirigée par le modèle ne convient pas dans tous les cas
  • Des problèmes simples ne nécessitent pas forcément l’approche plus complexe de la conception dirigée par le modèle.
  • Il n’est pas nécessaire de concevoir toute l’application en utilisant la conception dirigée par le modèle.
  • Cette approche convient davantage pour résoudre les problèmes du domaine les plus complexes.

Patterns permettant d’implémenter un domain model

Quelques patterns peuvent convenir pour implémenter un domain model:

  • Domain Model: pattern définit par Martin Fowler dans Patterns of Entreprise Application Architecture. Il permet de concevoir un modèle en utilisant la conception dirigée par le modèle en opposition à la conception dirigée par la base de données.
    En utilisant ce pattern, la persistence des données vient après la conception des objets du domaine. Les objets du domaine sont de type POCO (Plain Old C# Objects). DDD permet d’étoffer ce pattern en préconisant des objets ou des blocs d’objets particuliers.
    Ce pattern impose que les objets du domaine soient décorélés de l’infrastructure technique avec une architecture en couches.
  • Transaction Script: ce pattern est beaucoup plus simple que domain model. Il envisage de tout mettre dans un seul objet : workflow métier, règles métier, règles de validation et persistence en base de données. L’accès à l’implémentation de l’objet se fait de façon procédurale. Ce pattern convient pour les cas très simples où la logique métier est très faible. Toutefois il n’apporte pas de solutions génériques pour certaines problématiques techniques comme les accès concurrents, les problèmes de cohérence entre objets ou la logique de persistence. C’est la raison pour laquelle il doit s’appliquer seulement dans le cas où la logique métier est appliquée de façon procédurale.
  • Table Module: un objet du modèle correspond à un objet en base de données comme une vue ou une table. Chaque objet est responsable de sa persistence en base. Ce pattern ne convient pas forcèment bien dans une approche DDD mais il peut s’avérer facile à implémenter dans le cas simple où un objet métier est très lié à la base de données.
  • Active Record: un object du modèle correspond à une ligne dans un objet en base de données. Ce pattern convient s’il y a un mapping un à un entre le modèle de données et le modèle métier. Chaque objet est responsable de sa persistence en base.
  • Anemic Domain Model: ce pattern correspond au cas où l’objet du modèle ne possède aucune logique, il ne contient que des propriétés. Toute la logique de gestion de ces objets (comme la persistence par exemple) se trouve à l’extérieur de l’objet, dans la couche de service. Même si un domain model implémenté suivant Anemic Domain Model n’est que conteneur sans logique métier, il peut incorporer des éléments de l’ubiquitous language par la seule présence de propriétés.

Les objets ou blocs d’objets préconisés par le DDD

Pour aider à l’élaboration du domain model, DDD préconise un certain nombre d’objets, toutefois elle n’impose pas des les utiliser absolument. Comme dans la plupart des cas, on est libre d’envisager une architecture n’utilisant ces différents objets.

Entité

Les objets entités contiennent une identité:

  • L’identité est identique durant tous les états du logiciel
  • La référence à l’objet est de préférence unique pour assure une certaine cohérence.
  • Il ne devrait pas exister 2 entités avec la même identité sous peine d’avoir un logiciel dans un état incohérent.
  • L’identité peut être un identifiant unique ou une combinaison de plusieurs membres de l’entité.

Value-object

Ce sont des objets n’ayant pas d’identité:

  • Les value-objects n’ont pas d’identité car ils sont utilisés principalement pour les valeurs de leurs membres.
  • Ces objets peuvent facilement créés ou supprimés car il n’y a pas de nécessité de maintenir une identité.
  • L’absence d’identité permet d’éviter de dégrader les performances durant la création et l’utilisation de ces objets par rapport aux entités.
  • Les value-objects peuvent être partagés
  • Dans le cas de partage de value-objects, il faut qu’ils soient immuables c’est-à-dire qu’on ne puisse pas les modifier durant toute leur durée de vie.
  • Les value-objects peuvent contenir d’autres value-objects.

Service

Lorsqu’on définit l’ubiquitous language, le nom des concept-clés permettent de definir les objets qui seront utilisés. Les verbes utilisés qui sont associés aux noms permettront de définir les comportements de l’objet. Ces comportements seront implémentés directement dans l’objet.

Ainsi, lorsque des comportements ne peuvent être associés à un objet, ils doivent être implémentés en dehors de tout objet, dans un service:

  • L’opération dans le service fait référence à un concept du domaine qui n’appartient pas à une entité ou à un value-object.
  • Un service peut effectuer un traitement sur plusieurs entités ou value-objects.
  • Les opérations n’ont pas d’états.
  • Les services ne doivent pas casser la séparation en couche, ainsi un service doit être spécifique à une couche.

Module

Permet de regrouper les classes pour assurer une cohésion:

  • dans les relations entre les objets
  • dans les fonctionnalités gérées par ces objets.

L’intérêt est d’avoir une vue d’ensemble en regardant les modules, on peut ensuite s’intéresser aux relations entre les modules.

Les modules doivent:

  • former un ensemble de concepts cohérents, de façon à réduire le couplage entre les modules.
  • Le couplage faible permet de réduire la complexité et d’avoir des modules sur lesquels on peut réfléchir indépendamment.
  • Etre capable d’évoluer durant la durée de vie du logiciel.
  • Etre nommés suivant des termes de l’ubiquitous language.

Aggregate

Les objets du modèle ont une durée de vie:

  • Ils peuvent être créés, placés en mémoire pour être utilisés puis détruits ensuite.
  • Ils peuvent aussi être persistés en mémoire ou dans une base de données.

La gestion de cette durée de vie n’est pas facile car:

  • Les objets peuvent avoir des relations entre eux : 1 à plusieurs, plusieurs à plusieurs.
  • Il peut exister des contraintes entres les objets au niveau de leur relation : par exemple unidirectionnel ou bidirectionnel.
  • Il peut être nécessaire de maintenir des invariants c’est-à-dire des règles qui sont maintenues même si les données changent.
  • Il faut assurer une cohésion du modèle même dans le cas d’association complexe.

Une méthode est d’utiliser un groupe d’objets comme les agrégats (i.e. “aggregate”). Les agrégats sont des groupes d’objets associés qui sont considérés comme un tout unique vis-à-vis des modifications des données, ainsi:

  • Une frontière sépare l’agrégat du reste des objets du modèle,
  • Chaque agrégat a une racine qui est une entité qui sera le lien entre les objets à l’intérieur et les objets à l’extérieur de l’agrégat.
  • Seule la racine possède une référence vers les autres objets de l’agrégat.
  • L’identité des entités à l’intérieur de l’agrégat doivent être locale et non visible de l’extérieur.
  • La durée de vie des objets de l’agrégat est liée à celle de la racine.
  • La gestion des invariants est plus facile car c’est la racine qui le fait.
  • La racine utilise des références éphémères si elle doit passer des références d’objets internes à des objets externes. L’intégrité de l’agrégat est, ainsi, maintenue.
  • On peut utiliser des copies des value-objects.

Factory

Les fabriques sont inspirées du “design pattern” pour créer des objets complexes:

  • Elles permettent d’éviter que toute la logique de création des objets ne se trouve dans l’agrégat.
  • Permet d’éviter de dupliquer la logique de règles s’appliquant aux relations des objets.
  • Il est plus facile de déléguer à une fabrique la création d’une agrégat de façon atomique.
  • La gestion des identités des entités n’est pas forcément triviale car des objets peuvent être créés à partir de rien, ils peuvent aussi avoir déjà existé (il faut être sûr qu’il n’existe pas encore une autre entité avec le même identifiant) ou il peut être nécessaire d’effectuer des traitements pour récupérer les données de l’entité en base de données par exemple.

L’utilisation de fabriques n’est pas indispensables, on peut privilégier un constructeur simple quand:

  • La construction n’est pas compliquée : pas d’invariants, de contraintes, de relations avec d’autres objets.
  • La création n’implique pas la création d’autres objets et que toutes les données membres soient passées par le constructeur.
  • Il n’y a pas de nécessité de choisir parmi plusieurs implémentations concrètes.

Repository

Dans le cas d’utilisation d’agrégats, si un objet externe veut avoir une référence vers un objet à l’intérieur, il doit passer par la racine et ainsi, avoir une référence vers la racine de l’agrégat, ainsi:

  • Maintenir une liste de références vers des racines d’agrégat peut s’avérer compliqué dans le cas où beaucoup d’objets sont utilisés. Une mise à jour de la référence de la racine auprès de plusieurs objets peut s’avérer couteux.
  • L’accès à des objets de persistance se fait dans la couche infrastructure, les implémentations permettant d’y accéder peuvent se trouver dans plusieurs objets et ainsi être dupliquées.
  • Un objet du modèle ne doit contenir que des logiques du modèle et non les logiques permettant d’accéder à une base de persistance.

Utiliser un repository permet:

  • D’encapsuler la logique permettant d’obtenir des références d’objets.
  • Stocker des objets
  • D’utiliser une stratégie particulière à appliquer pour accéder à un objet.

L’implémentation d’un repository peut se faire dans la couche infrastructure toutefois l’interface de ce repository fait partie du modèle.

Le repository et la fabrique permettent, tout deux, de gérer le cycle de vie des objets du domaine:

  • La fabrique permet de créer les objets
  • Le repository se charge de gérer des objets déjà existants.

Concevoir une architecture compatible avec le DDD

Architecture en couches

DDD préconise de séparer le code en couche pour ne pas diluer la logique métier dans plusieurs endroits. Chaque couche a une fonction particulière qui est utilisable par d’autres couches de façon à:

  • Mutualiser le code suivant une logique
  • Éviter la duplication de code métier

Les 4 couches sont:

  • la couche utilisateur,
  • la couche application,
  • la couche domaine et
  • la couche infrastructure.

La couche utilisateur

Elle présente l’information à l’utilisateur et réceptionne ses commandes. Cette couche peut faire appel à toutes les autres.

La couche application

La couche application (i.e. “application service layer”) sépare la couche utilisateur de la couche domaine:

  • Elle ne contient pas de code métier mais peut être amenée à contenir du code permettant de gérer des changements dans la couche utilisateur.
  • Elle ne doit pas garder l’état des objets métier mais peut stocker l’état d’avancement d’une tâche de l’application.
  • Elle permet d’effectuer la navigation entre les écrans de l’interface graphique et les interactions avec les couches application d’autres systèmes.
  • Elle peut effectuer des validations basiques (non liées à des règles métier) sur les entrées de l’utilisateur avant de les transmettre aux autres couches de l’application.
  • Elle ne doit pas contenir de logique métier ni de logique d’accès aux données.
  • Cette couche peut faire appel à la couche domaine et à la couche infrastructure.

Cette couche peut permettre d’isoler la couche domaine des aspects techniques nécessaires à son fonctionnement. Elle peut contenir les services applicatifs et servir d’intermédiaire entre la couche domaine et les objets qui y font appels. Elle expose les capacités du système en proposant une abstraction à la logique du domaine contenue dans la couche domaine. Elle tends grandement à préserver le domaine en concentrant de nombreux éléments de logique applicative.

Cette couche de services sert aussi d’implémentation concrête à la frontière du contexte borné, elle peut assurer les échanges avec les autres contextes bornés en utilisant, par exemple, des services REST, des web services ou par l’intermédiaire d’un bus de communication.

La couche domaine

Elle contient les informations sur le domaine et la logique métier:

  • Elle détient tous les concepts du modèle métier, les cas d’utilisation et les règles métier.
  • Elle contient l’état des objets métier toutefois elle n’effectue pas directement la persistance des objets métier.
  • Elle peut aussi contenir l’état d’un cas d’utilisation métier si celui-ci est formé de plusieurs requêtes de l’utilisateur.
  • Elle peut contenir des objets de service si leur comportement ne peut être implémenté dans un objet métier. Les services contiennent des comportements métier qui ne peuvent pas faire partie d’un objet du modèle.
  • Cette couche est le coeur du métier, elle doit être isolée des autres couches et ne peut être dépendantes de framework.
  • Cette couche ne peut faire appel qu’à la couche infrastructure.

La couche infrastructure

Elle permet de fournir un lien de communication entre toutes les autres couches. D’autre part, elle contient le code de persistance des objets métier. Cette persistance n’est pas forcément dans une base de données.

Les relations entre les couches peuvent être directes toutefois il est préférable que les relations se fassent des couches hautes (par exemple la couche utilisateur) vers les couches basses (couche infrastructure).

Approche SOA

L’approche SOA (pour “Service-Oriented Architecture”) ne dispense pas d’une réflexion sur le modèle métier. On pourrait croire que cette approche permet d’isoler par construction un modèle métier, pourtant dans le cas où on accorde pas assez d’importance à la conception du modèle, une approche SOA entraînera une implémentation avec les mêmes conséquences que pour architecture classique : une couche de service hypertrophiée (Fat Service Layer) et une modèle métier anémique (Anemic Domain Model).

Dans l’approche SOA, il faut donc accorder le même degré d’effort à la conception d’un domain model:

  • Avant tout, isoler un domain model qui encapsulera la logique métier et les règles métiers des objets
  • Implémenter le service en même temps que la couche application de façon à ce que les composants du service puissent consommer les éléments du modèle métier.
  • Le service deviendra juste un “proxy” pour atteindre le modèle métier.

Préserver la couche domaine

La couche domaine ne doit pas être influencée par des éléments de la logique applicative. La logique applicative comprends la coordination entre le domaine et les services qui se trouvent dans la couche infrastructure.

Cette logique vise à répondre à des sollicitations provenant de la couche utilisateur, de la couche application mais aussi provenant d’autres domain models et de les présenter à la couche domaine. Les sollicitations peuvent aussi provenir de la couche domaine, par exemple pour notifier des changements de l’état du domaine.

Ainsi, pour préserver la couche domaine, cette logique applicative doit se trouver dans les services de la couche application ou infrastructure.

Aspects de conception utiles pour le DDD

Programmation orientée objet

Les concepts de la programmation orientée objet (comme l’héritage, l’encapsulation ou le polymorphisme) sont utiles à la conception d’objets métier car ils permettent d’étendre des comportements ou des états à plusieurs objets ou au contraire à les spécialiser. Les objets utilisés en DDD comportent des états au travers des données membres et de comportements avec les fonctions et méthodes.

Les objets du modèle métier ont besoin de collaborer avec d’autres objets comme les services, les repositories ou les factories. Ces objets ont aussi besoin de gérer des états ou comportements communs comme le suivi, de l’audit, effectuer de la mise en cache ou la gestion de transaction qui sont transverses. La programmation orientée objet permet de ne pas trop alourdir l’implémentation des objets du modèle en fournissant des solutions pour apporter ce type de caractéristiques (appliquer le principe SOLID).

Injection de dépendances

Permet de réduire le couplage entre les objets en injectant les dépendances. La plupart des objets du modèle peuvent avoir besoin d’accéder à d’autres objets comme les repositories ou les services. Ces besoins récurrents pour beaucoup d’objets du domaine peuvent mener à un fort couplage. L’injection de dépendances permet de réduire ce couplage.

Dans le cadre du DDD et avec l’utilisation de l’architecture en couches, il peut être nécessaire d’injecter dans la couche du domaine des objets de la couche infrastructure (par exemple si on veut persister des objets dans une base de données). Or la couche domaine ne doit pas avec de dépendances vers la couche infrastructure. Ainsi l’injection de dépendances va permettre d’effectuer une inversion de dépendances entre la couche domaine et la couche infrastructure pour que la couche domaine puisse accéder à des objets de la couche instructure.

Plus de détails sur l’injection de dépendances dans Injection de dépendances en utilisant Unity en 10 min.

Garder les frontières du domaine

Il faut définir une frontière entre le domaine et le reste de l’application pour éviter que la couche du domaine soit trop facilement corrompue avec des objets qui n’ont pas de liens avec le domaine métier. Les états (les données) et les comportements (les opérations) du domaine doivent être stockés dans les objets du domaine suivant leur nature:

  • Les entités, les value-objects et les agrégats peuvent stocker un état et implémeter un comportement.
  • Les DTO stockent seulement des états.
  • Les services et les repositories implémentent des comportements.

Ainsi, il faut observer certaines règles:

  • Les comportements ne doivent pas dépassés la frontière des objets qui les implémentent.
  • Les entités doivent gérer leur état propre.
  • On doit éviter de pouvoir modifier directement l’état d’un objet : si on veut modifier l’état d’un objet, on en crée un nouveau avec un état modifié.
  • Les agrégats permettent de cacher la collaboration de plusieurs classes à l’objet appelant ce qui permet d’encapsuler la complexité de la gestion des états des classes du domaine.

Persister des données

Pour éviter d’être trop couplé à des technologies de persistance (base de données relationnelles, Big Data, Cloud etc), on peut s’aider de DAO (Data Access Object) et des repositories qui permettent d’encapsuler des opérations CRUD.

Les repositories utilisent l’ubiquitous language et utilisent, le cas échéant, des DAO qui sont proches de la technologie utilisée pour le stockage de données. Les objets du domaine accèdent aux repositories seulement, ce qui permet de rester découpler par rapport aux technologies de persistance. Les repositories peuvent être injectés dans les objets du domaine en utilisant, par exemple, de l’injection de dépendances.

Utiliser des DTO

Les DTO (pour “Data Transfer Object”) peuvent être utilisés pour garantir une séparation entre les différentes couches. Utiliser des DTO permet d’isoler les couches basses (par exemple Infrastructure) des autres couches.

Intégrer les règles métier

Les règles métier font partie d’une des catégories suivantes:

  • Validation de données
  • Transformation de données
  • Prise de décision métier
  • Traitement de workflows

Ces règles sont généralement dépendantes d’un contexte métier. Dans un contexte différent, d’autres règles métiers émergeront.

Les règles métier doivent être implémentées dans la couche du domaine, chaque règle devrait être implémentée dans un objet de type Entité. Si une règle doit être partagée entre plusieurs objets, elle devrait être implémentée dans un objet de type Service.

Pour intégrer des règles métier, des moteurs de règles ne sont pas forcément nécessaires, il faut trouver un moyen pour:

  • Prendre en compte la complexité des règles, un langage comme le C# permet d’implémenter une logique complexe.
  • Rendre flexible la modification, l’ajout ou la suppression des règles en fonction de certaines critères.
  • Tester l’exécution des règles.

On peut éventuellement s’aider de langage scripté comme Dynamic Language Runtime (DLR) pour apporter une solution technique à l’aspect dynamique des règles métier.

Gestion de projet

Les étapes de la conception du modèle sont:

  • Conception et documentation les processus métier.
  • On sélectionne un processus métier et on discute avec les experts métier pour documenter ce processus en utilisant l’ubiquitous language.
  • On identifie tous les services qui sont nécessaires au processus métier. Les services peuvent être consommés de façon unitaire ou couplés avec d’autres services (par exemple en faisant intervenir un workflow).
  • On identifie et on documente les états et comportements des objets utilisés par les services identifiés dans l’étape précédente.

La gestion d’un projet DDD comprends les mêmes étapes qu’un projet de développement logiciel classique:

  • Modélisation du modèle métier
  • Conception
  • Développement
  • Tests unitaires et tests d’intégration
  • Etapes de “refactoring” pour affiner le modèle métier basées sur la conception et le développement (intégration continue).
  • L’étape précédente est répétée en fonction des mises à jour du modèle métier.

La méthodologie agile convient bien à ce type de projet car:

  • Elle préconise des “refactorings” du code pour arriver à une compréhension du domaine,
  • Elle permet aux développeurs de remonter les éventuels problèmes pour encourager les “refactorings”.
  • Elle permet d’éviter les effets tunnel qui peuvent fatals à une équipe de développement dans la maîtrise de ses coûts.

Comment préserver le modèle

Les gros projets sont généralement composés de plusieurs équipes. Pour que le modèle reste cohérent malgré la division en plusieurs équipes séparées:

  • Chaque partie du projet doit être assignée à une équipe
  • Une modification dans une partie du modèle maintenue par une équipe ne doit pas déstabiliser le modèle en le rendant incohérent.
  • Il faut diviser le domaine en plusieurs modèles, définir des frontières entre les modèles et les liaisons entre eux.

Cette partie indique quelques patterns généraux permettant d’organiser une équipe de développement pour préserver le modèle général.

Bounded context

Un domaine s’applique implicitement à un contexte particulier. Diviser le domaine en plusieurs sous-domaine implique d’appliquer un contexte différent à chaque sous-domaine. Chaque sous-domaine devient donc limité à un contexte d’où le contexte borné (i.e. “bounded context”). La division en contexte borné est une des étapes les plus importantes dans un projet DDD.

Ainsi:

  • Les sous-modèles doivent être assez petits pour être applicable à une équipe.
  • Le contexte d’un modèle est l’ensemble des conditions qu’on doit appliquer pour s’assurer que les termes utilisés dans le modèle prennent une sens précis.
  • Définir les limites du contexte permet de préserver l’unité du modèle.
  • Il est plus facile de maintenir un modèle quand son périmètre est connu.
  • Il faut bien délimiter le contexte pour éviter des duplications de logique métier si un sous-modèle empiète sur un autre.
  • Les échanges entre sous-domaine peuvent se faire avec des value-objects par exemples.
  • Un contexte englobe la notion de module.

Les divisions en contexte borné peuvent se faire suivant des critères différents:

  • Si une ambiguité est apparue dans l’ubiquitous language ou dans les concepts métier et qu’elle nécessite d’envisager deux contextes différents.
  • Pour être plus en phase avec l’organisation de plusieurs équipes ou leur emplacement physique.
  • Pour qu’un sous-domaine soit lié à sa fonction métier.
  • Intégrer du code “legacy” ou du code tiers.
  • Si plusieurs langages de programmation ou plusieurs technologies sont utilisés.

Un sous-domaine ne correspond pas forcément précisement à un contexte borné. En effet, un sous-domaine résulte d’une séparation fonctionnelle du domaine. Idéalement il faudrait un domain model pour chaque sous-domaine toutefois un sous-domaine peut contenir plusieurs domain models. Un contexte borné correspond à une implémentation concrète et à une séparation technique qui applique des frontières aux objets du domain model. Ainsi, un sous-domaine comporte un ou plusieurs contextes bornés et un contexte borné comporte un ou plusieurs domain models.

Partage de données entre contextes bornés

Le partage d’objets entre contextes bornés peut mener à des incohérences dans le domain model. En effet des objets provenant d’autres contextes bornés peuvent avoir été concus avec un ubiquitous language différent. Il faut donc éviter de partager directement des données entre contexte borné. Les échanges de données doivent se faire avec des DTO ou par l’intermédiaire d’une couche anticorruption.

Intégration continue

L’intégration continue (i.e. “continuous delivery”) est un outil important dans un projet DDD car il permet de maintenir la cohérence du modèle malgré les changements dans les sous-modèles. Les “refactorings” successifs par les différentes équipes doivent préserver les fonctionnalités. Ainsi l’intégration continue doit:

  • Effectuer une compilation automatique du code de chaque équipe.
  • Notifier des erreurs de compilation aux membres des équipes.
  • Appliquer des tests automatiques.
  • Permettre d’intégrer régulièrement le travail de toutes les équipes.

Context map

Un contexte borné peut correspondre à une équipe toutefois tout le monde doit garder une vue d’ensemble du projet. Ainsi la carte de contexte (i.e. “context map”) permet d’indiquer les liaisons entre les contextes bornés. Sur la carte de contexte, chaque contexte borné possède un nom qui fait partie de l’ubiquitous language.

Quelques patterns pour préserver le modèle

Des problèmes peuvent se poser lors des “refactorings” successifs en particulier si:

  • On utilise un seul domain model pour répondre à toutes les problématiques.
  • Plusieurs équipes sont impliquées dans le projet.
  • La logique métier est particulièrement complexe.
  • Il est nécessaire d’intéger du code “legacy” ou du code provenant de tiers.

Shared kernel

Le noyau partagé (i.e. “shared kernel”) est un pattern qui permet à 2 équipes de se partager un sous-ensemble du modèle, ainsi:

  • Chaque modification doit impliquer les deux équipes.
  • Chaque équipe à un contexte borné mais elles se coordonnent pour une partie du modèle qu’elles ont en commun.
  • Le noyau partagé permet d’éviter d’avoir du code d’une autre équipe à intégrer.
  • Ce pattern permet d’éviter les doublons tout en gardant des contextes séparés.
  • Des tests permettent de garantir que le code partagé ne sera pas cassé lors d’un “refactoring” par une des équipes.

Customer-Supplier

Client-Fournisseur (i.e. “Customer-Supplier”) s’applique lorsqu’une équipe dépend d’une autre et qu’il n’est pas possible d’appliquer le noyau partagé:

  • Cette organisation est plus difficile à maintenir car il est plus difficile d’identifier les éventuelles régressions amenées par l’équipe “fournisseur”.
  • Les tests sont particulièrement importants pour limiter les régressions dues aux “refactorings”.
  • L’équipe cliente doit énoncer des besoins et l’équipe “fournisseur” doit y répondre avec des plans.
  • Une interface définie au préalable permet de coordonner les deux équipes.

Anticorruption layer

Un domain model peut devoir s’interfacer avec d’autres domain models développés par d’autres équipes dans un contexte différent. Sachant que le contexte est différent, les autres équipes ont développés un ubiquitous language qui est peut-être différent. Pour éviter d’introduire des termes de l’ubiquitous language des autres domain models, on peut être amené à developper une couche anticorruption (i.e. “anticorruption layer”). Cette couche permet d’adapter et de convertir les objets nécessaires à l’interface avec d’autres domain models dans un contexte borné différent.
La couche anticorruption peut aussi servir si on doit s’interfacer avec du code “legacy” ou du code tiers qui n’a pas été conçu avec l’ubiquitous language.

Open Host Service

L’Open Host Service (i.e. Service Hôte ouvert) est équivalent à la couche anticorruption. La couche anticorruption sert à adapter les appels vers d’autres contextes en introduisant une couche de traduction, si un contexte doit s’interfacer avec plusieurs autres contextes, il y aura autant de couches anticorruption que d’interfaces entre les contextes. Le pattern Open Host Service sert aussi de couche d’interface entre 2 contextes toutefois il préconise d’utiliser une seule couche pour plusieurs contextes. Ainsi la logique de transformation doit être commune à tous les contextes client. Tous les clients seront alors concernés dans le cas d’un changement de l’interface.
L’intérêt du pattern Open Host Service est d’éviter de dupliquer une logique de transformation pour plusieurs couches en particulier si elle est semblable d’un contexte client à l’autre.

Separate Ways

Lorsque 2 contextes s’interfacent suivant des patterns comme Shared Kernel ou Customer-Provider, il peut être de plus en plus couteux de maintenir une implémentation commune avec un autre contexte ou d’intégrer les nouvelles interfaces de l’autre contexte. Ainsi il peut être plus efficace d’envisager une séparation franche entre les deux contextes. Cette séparation peut avoir des conséquences importantes qui peuvent se traduire par des divergences dans plusieurs couches de haut niveau comme la couche utilisateur. Dans le cas où le pattern Chemin Séparé (i.e. Separate Ways) est adopté par 2 contextes qui veulent se séparer, il faut réfléchir sur toutes les conséquences de la séparation sur le long terme parce qu’il peut être très compliqué de revenir en arrière.

Conformist

Le pattern Conformiste s’applique dans le cas où un contexte consomme des objets d’un autre contexte. Il doit alors se conformer à l’interface du contexte qu’il consomme. L’exemple le plus courant de ce pattern consiste à consommer des données externes provenant d’applications tiers. On a pas le choix de l’interface et il est compliqué d’obliger l’application tiers à adapter son interface à ses besoins. Contrairement au pattern Client-Fournisseur, ce pattern préconise de s’adapter aux interfaces de l’application que l’on consomme.

Erreurs lors de l’implémentation d’un projet DDD

L’approche DDD est complexe à mettre en place tant au niveau de la collaboration avec les experts du domaine et que sur l’aspect plus technique de l’architecture. Ainsi quelques erreurs peuvent être éviter pour se focaliser sur ces points de complexité:

  • Eviter d’utiliser la même architecture pour tous les contextes bornés. Certains contextes sont moins complexes que d’autres.
  • De la même façon, on ne doit pas réutiliser un modèle existant. Dans le cas d’une réutilisation, on essaie peut-être de résoudre une problématique qui a déjà été résolue (appliquer le principe DRY pour “Don’t Repeat Yourself”).
  • Tenter de comprendre les problèmes métier et leur origine plutôt que d’essayer des résoudre une problématique sans chercher à en comprendre l’origine. Une mauvaise compréhension peut mener à une mauvaise définition de l’ubiquitous language, à une mauvaise division en contexte borné etc.
  • Ne pas négliger la carte de contexte qui permet d’aider à comprendre les relations entre contextes bornés.
  • Eviter de se concentrer sur le code ou des aspects techniques plutôt que sur les principes de DDD. Si on ne tient pas assez en compte le modèle alors on risque d’empiéter sur d’autres contextes bornés et résoudre des problématiques appartenant à d’autres contextes bornés.
  • Accorder de l’importance aux limites du contexte et qu’elles soient clairement définies et comprises.
  • Résoudre les problèmes d’ambiguités de l’ubiquitous language car il a un fort impact sur le développement du logiciel. Il faut lever les ambiguités en particulier lorsque la logique métier est complexe.
  • Eviter d’appliquer trop d’abstraction à des endroits où ce n’est pas nécessaire peut rendre l’application difficile à maintenir. DDD n’a pas pour but de rajouter des couches d’abstraction inutile mais d’isoler la couche métier.
  • Eviter d’appliquer le DDD lorsque le domaine est simple ou lorsque les acteurs du métier ne perçoivent pas l’intérêt du DDD. Dans ce cas tout le monde ne s’investira pas dans une démarche d’application du DDD. Il faut appliquer le DDD sur les problèmes métier complexes où la compléxité nécessite une réflexion entre les développeurs et les acteurs du métier.
  • Ne pas sous-estimer le coût pour appliquer une démarche DDD, en effet DDD est coûteux en ressources et en temps car il faut impliquer les acteurs du métier et les développeurs dans l’élaboration de l’ubiquitous language et dans la connaissance du domaine.

Inconvénients du DDD

DDD a quelques inconvénients qui peuvent grandement limiter son application pour un projet.

Difficile de convaincre les acteurs métier

Il peut être difficile de convaincre les experts du domaine de collaborer pour élaborer l’ubiquitous language et pour affiner la connaissance du domaine. La raison est que les acteurs du métier n’ont pas un intérêt direct à participer à ce travail. Ils énoncent des besoins et considère que c’est aux acteurs techniques de résoudre les problèmes pour répondre à ces besoins. Ils peuvent estimer que la compréhension du problème est du ressort des acteurs techniques.

L’intérêt du DDD pour les experts du domaine est de permettre de comprendre davantage leurs problématiques métier. Cette compréhension permet une plus grande maîtrise des processus et solutions qui seront mis en place par la suite par l’implémentation de l’application. Elle permet aussi de mieux comprendre les besoins du métier de façon à mieux anticiper les solutions qui seront mis en place.

Enfin plus les acteurs du métier sont impliqués dans le domain model et plus ils pourront guider efficacement des “refactorings” fonctionnelles éventuels ou des évolutions techniques. L’intérêt est direct puisque les développeurs pourront répondre aux nouveaux besoins plus rapidement.

Apprentissage de l’ubiquitous language et du domaine

Un autre inconvénient de DDD est l’apprentissage de l’ubiquitous language et du domaine pour les nouveaux. Dans tout projet même en dehors de la démarche DDD, il y a une certaine quantité d’information technique et fonctionnelle à accumuler pour un développeur avant de pouvoir intervenir directement sur le code. Dans le cas de DDD, cette apprentissage est presque obligatoire puisque l’ubiquitous language utilise une terminologie spécifique au domain model. Sans une maîtrise de l’ubiquitous language et une bonne compréhension du domain model, le code développé par un nouvel arrivant pourrait ne pas être en phase avec le code existant, ce qui peut se traduire par des ambiguités dans le code, des incompréhensions et au pire des régressions.

L’autre défaut de l’ubiquitous language est qu’il ne s’arrête pas tout à fait à la couche domaine. Par exemple, dans le cas où on doit stocker des objets du domaine dans une base de données. La base de données se trouvant dans la couche infrastructure, en dehors de la couche domaine, on peut être amené à utiliser des termes de l’ubiquitous language dans les noms d’objets de la couche domaine pour leur équivalent dans la couche infrastructure. L’ubiquitous language dépasse donc le périmètre de la couche domaine.

Enfin si un développeur est amené à intervenir sur plusieurs contextes, il peut être confronté à plusieurs ubiquitous languages qui utilisent des termes communs mais ayant un sens différent d’un contexte à l’autre. D’où une source éventuelle d’incompréhensions.

Représenter le domain model

DDD ne donne pas de méthode directe pour représenter le domain model. Sachant que le domain model doit rester compréhensible aux experts du domaine, on doit trouver une façon de la réprésenter pour les experts puissent le comprendre et participer à son élaboration. DDD préconise l’utilisation d’UML pourtant l’apprentissage d’UML n’est pas forcément simple pour des personnes non techniques et en particulier pour des personnes n’étant pas familiarisées avec la conception orientée objet.

De même UML reste proche du modèle de code puisqu’il permet de représenter des relations entre des objets et des utilisations de ces objets. Il rentre donc dans les détails d’implémentation du domain model qui peuvent difficiles à comprendre aux experts du domaine.

Dans une démarche DDD, il faut arriver à trouver une façon de représenter le domain model pour qu’il soit compréhensible par les experts du domaine ce qui n’est pas forcément trivial.

Le domain model doit rester couplé au modèle du code

La tâche la plus compliquée pour une équipe qui entreprend une démarche DDD peut être de garantir que le domain model reste couplé au modèle de code. En effet au fur et à mesure des “refactorings”, le code va évoluer mais il faudra, dans le même temps, faire évoluer aussi le domain model. Dans le cas où le domain model n’est plus couplé au code, les experts du domaine n’auront plus la possibilité de le comprendre puisqu’il n’est plus à jour. La démarche de collaboration avec les experts du domaine peut être largement compromise dans le cas d’un “refactoring” ou d’évolution fonctionnelle.

Pour donner un exemple plus concret, rares sont les projets qui documentent leur code de façon rigoureuse. Il est encore plus rare d’avoir une documentation du code à jour. Au fil du temps, généralement la documentation devient obsolète et découplé du code. Dans la même idée, comment garantir efficacement qu’au fil du temps et des différents développeurs qui interviendront sur le projet, que le domain model restera couplé au modèle du code.

Références:
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Exceptions possibles en utilisant XmlSerializer

Quand on utilise la classe System.Xml.Serialization.XmlSerializer, outre les erreurs classiques de sérialisation/désérialisation il peut survenir des exceptions sans qu’on est fait de changement particulier dans le code. On ne comprends pas toujours facilement l’origine des ces exceptions car le code ne semble pas avoir changé et qu’il a toujours bien fonctionné auparavant.
Ces exceptions sont de 2 natures:

  1. System.IO.FileNotFoundException avec un message du type: Assembly loading failure [Nom de l'exécutable].XmlSerializers
  2. System.InvalidOperationException à cause de problèmes de sérialisation.

Ces 2 erreurs se produisent, le plus souvent, sans vraiment modifier le code mais en changeant l’environnement d’exécution de l’assembly:

  • En mettant à jour le framework .NET de la machine sur laquelle l’assembly sera exécutée.
  • En déployant l’exécutable sur une machine avec un environnement compatible mais pas tout à fait similaire à celui de la machine où l’exécutable a été développé.

Avant de rentrer dans le détail, il faut rappeler que le framework .NET 4.5 n’est pas installé à coté du framework .NET 4.0. A l’installation des frameworks .NET 4.5 et suivants, les assemblies .NET déjà présentes sont remplacées. Les applications compilées avec le framework 4.0 sont toujours compatibles et peuvent être exécutées avec le framework 4.5 parce que les nouvelles assemblies 4.5 assurent une compatibilité des fonctionnalités avec les versions précédentes. Toutefois il peut se produire certains comportements inattendus notamment avec la classe System.Xml.Serialization.XmlSerializer.

Pour avoir plus de détails sur le remplacement des assemblies du framework à l’installation des versions 4.5 et suivantes: Remplacement du Framework 4.0 par la version 4.5.

Pour avoir plus de détails sur les problèmes de compatibilités concernant la classe XmlSerializer: Compatibilité d’applications dans le .NET Framework 4.5 sur MSDN.

System.IO.FileNotFoundException avec un message “Assembly loading failure”

Cette erreur survient lorsqu’on utilise un des constructeurs:

  • XmlSerializer.XmlSerializer(Type)
  • XmlSerializer.XmlSerializer(Type, string)

A l’exécution de ces constructeurs, XmlSerializer essaie de charger une assembly contenant du code pour sérialiser et désérialiser des classes. Les comportements sont différents entre le framework .NET 4.0 et 4.5:

  • Quand le framework 4.0 est installé: le code et l’assembly de sérialisation sont générés à la compilation.
  • Quand le framework 4.5 (ou supérieur) est installé: par défaut, le code et l’assembly de sérialisation sont générés à l’exécution par le CLR.

A partir du framework 4.5, l’assembly de sérialisation est générée à l’exécution de façon à réduire la dépendance de l’exécutable avec le compilateur C#. Avant de générer une assembly, la classe XmlSerializer essaie de charger une assembly de sérialisation. Si l’assembly n’existe pas, une exception est lancée toutefois elle est “catchée” par le CLR directement. Durant une exécution sans debug, cette exception ne sera pas visible. Toutefois si on débuggue, Visual Studio va “catcher” l’exception.

Désactiver les exceptions dans Visual Studio

Pour désactiver le traitement de cette exception par Visual Studio, aller dans:

  1. Le menu “Debug”
  2. Exceptions
  3. Managed Debugging Assistances
  4. Décocher “BindingFailure”

L’exception n’est visible qu’en mode Debug et si l’option dans Visual Studio est activée. Quand le CLR “catche” l’exception, il génère l’assembly dans le répertoire temporaire pour que la classe XmlSerializer puisse sérialiser ou désérialiser des objets.

On peut facilement tester ce comportement en exécutant le code suivant:

[Serializable]
public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    public Person()
    {}
	
    public Person(string firstName, string lastName)
    {
        this.FirstName = firstName;
        this.LastName = lastName;
    }
}

static void Main(string[] args)
{
    string fileName = "person.xml";
    Person objectToSerialize = new Person("Charles", "Lindbergh");

    XmlSerializer serializer = new XmlSerializer(typeof(Person));
    using (FileStream s = new FileStream(fileName, FileMode.Create))
    {
        serialiser.Serialize(s, objectToSerialize);
    }
}
Les autres constructeurs de XmlSerializer peuvent dégrader les performances

C’est possible d’éviter l’exception en utilisant d’autres constructeurs de XmlSerializer mais ils peuvent entraîner des fuites mémoire et dégrader les performances car une nouvelle assembly est générée à chaque utilisation du constructeur. Il ne faut pas les utiliser des façons répétitives dans une application mais de façon très ponctuelle.

Pour avoir plus de détails sur ces constructeurs en allant dans “Dynamically Generated Assemblies” dans la page suivante: la classe XmlSerializer sur MSDN.

Générer les assemblies de sérialisation à la compilation

Une autre solution consiste à générer les assemblies de sérialisation à la compilation avec les étapes suivantes:

  1. Aller dans les propriétés du projet => Onglet “Build” => Dans la partie “Generate serialization assembly” => Sélectionner “On”.
  2. Editer le fichier .csproj du projet avec un éditeur de type Notepad et ajouter le nœud SGenUseProxyTypes:
    <?xml version="1.0" encoding="utf-8"?>
    <Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
      <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
        <!-- ... -->
        <GenerateSerializationAssemblies>On</GenerateSerializationAssemblies>
        <SGenUseProxyTypes>false</SGenUseProxyTypes>
      </PropertyGroup>
    </Project>
    

    Ne pas oublier d’ajouter le nœud SGenUseProxyTypes pour toutes les plateformes cible (i.e. target platform).

  3. Compiler l’assembly en “AnyCPU” (la génération ne fonctionnera pas si la compilation se fait avec les plateformes cible “x86″ ou “x64″).

System.InvalidOperationException à cause de problèmes de sérialisation

Par exemple, une exception du type suivant peut survenir:

System.InvalidOperationException: There was an error generating the XML document.
--> System.InvalidProgramException: Common Language Runtime detected an invalid program 
at Microsoft.Xml.Serialization.GeneratedAssembly.XmlSerializationWriteFrmFrm.Write53_FrmTable(String n, String ns, FrmTable o, Boolean isNullable, Boolean needType)
at Microsoft.Xml.Serialization.GeneratedAssembly.XmlSerializationWriteFrmFrm.Write55_FrmFrm(String n, String ns, FrmTable o, Boolean isNullable, Boolean needType)
at Microsoft.Xml.Serialization.GeneratedAssembly.XmlSerializationWriteFrmFrm.Write56_frm(Object o)
--- End of inner exception stack trace ---
at System.Xml.Serialization.XmlSerializer.Serialize(XmlWriter xmlWriter, Object o, XmlSerializerNamespaces namespaces, String encodingStyle, String id)
at System.Xml.Serialization.XmlSerializer.Serialize(XmlWriter xmlWriter, Object o)

Ce problème est dû à un comportement différent de la classe XmlSerializer durant la sérialisation ou la désérialisation entre les différentes versions de framework. Comme indiqué plus haut, l’implémentation de cette classe a été modifiée entre ces 2 versions car la génération n’est plus faite à la compilation mais durant l’exécution. Elle peut être résolue en ajoutant le paramètre suivant dans le fichier de configuration de l’application:

<configuration>
  <system.xml.serialization>
    <xmlSerializer useLegacySerializerGeneration="true" />
  </system.xml.serialization>
</configuration>

Ce paramètre est à utiliser si on souhaite déployer une application sans la recompiler sur une machine sur laquelle est installé le framework 4.5 (ou supérieur) et que l’application a été développé avec le framework 4.0.

Références:

Explications sur la génération d’assembly de sérialisation:

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

C++/CLI en 10 min: Références

Références

Livres:

Généralités:

Détails des options de compilations:

Marshalling en C++/CLI:

Détails d’implémentation:

Installation Redistributable C++:

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

C++/CLI en 10 min, partie 4: Syntaxe détaillée

Dans cette partie, on détaille la syntaxe d’autres éléments en C++/CLI.

nullptr

En C++/CLI, le pointeur nul est nullptr. Il correspond à 0 ou NULL en C++. On peut utiliser nullptr sur des types managés et sur des types non managés:

ManagedPoint ^point = nullptr;

typedefs

Comme en C++, il est possible de définir des alias et de les utiliser ensuite dans le reste du code.

Par exemple, on peut définir un alias de cette façon:

typedef unsigned int positiveNumber;

Les déclarations dans le code peuvent utiliser directement l’alias:

PositiveNumber a;

namespace

Les namespaces se déclarent de la même façon qu’en C++:

  • Pour indiquer l’utilisation d’un namespace particulier dans un fichier: using namespace System;
  • Pour définir une classe à l’intérieur d’un namespace:
    namespace Namespace1 
    { 
        Namespace Namespace2 
        { 
            // Définition de la classe 
        } 
    }
    
  • Pour utiliser des namespaces, on utiliser "::" entre les noms: Namespace1::Namespace2.

Héritage

En C++/CLI, l’héritage des objets managés est semblable à celui en C#.

Par exemple, il se déclare:

ref class DerivedClass : public BaseClass 
{ 
    // ... 
};

Si on ne précise rien:

ref class DerivedClass : BaseClass 
{ 
    // ... 
};

L’héritage est considéré comme public.
Il n’est pas possible de déclarer private BaseClass. Cette syntaxe est acceptée en C++ avec des objets non managés.

Le multihéritage n’est pas possible avec des objets managés (contrairement au C++).
Concernant les objets de type valeur (déclarés avec value class ou value struct):

  • Ils peuvent seulement hérités d’interfaces.
  • Ils ne peuvent pas hérités de classes.
  • Ils sont implicitement sealed c’est-à-dire qu’on ne peut pas en hériter.

Méthode virtuelle

On utilise le mot clé virtual pour déclarer une méthode virtuelle:

ref class BaseClass   
{  
    public:  
      virtual int GetInt()  
      { 
          return 10; 
      } 
};

Pour surcharger une méthode de la classe héritée, on utilise le mot clé override. En plus de ce mot clé il faut aussi repréciser le mot clé virtual:

ref class DerivedClass : public BaseClass 
{  
    public:  
      virtual int GetInt() override 
      { 
          return 5; 
      } 
};

On peut aussi utiliser le mot clé new pour cache la déinition de la méthode dans la classe dérivée:

ref class DerivedClass : public BaseClass 
{  
    public:  
      virtual int GetInt() new 
      { 
          return 5; 
      } 
};

Sealed

Comme en C#, on peut utiliser sealed dans la déclaration de la méthode pour empêcher qu’elle soit surchargée dans un classe dérivée:

ref class DerivedClass : public BaseClass 
{  
    public:  
      virtual int GetInt() sealed 
      { 
          return 15; 
      } 
};

Le mot clé peut aussi être utilisé au niveau de la déclaration d’une classe pour empêcher d’hériter d’une classe:

ref class InheritingClass sealed: public BaseClass 
{  
    // ... 
};
Ne pas confondre “abstract” et “abstract sealed”

Ne pas confondre le déclaration abstract qui sert à déclarer une classe abstraite et abstract sealed qui permet de déclarer une classe statique.

Méthode statique et classe statique

Une méthode statique se déclare dans une classe avec le mot clé static:

ref class NonStaticClass 
{  
    public: 
      static int GetIntStatically() 
      { 
          return 10; 
      } 
};

Une classe statique se déclare avec les mots clés abstract sealed. Tous les membres de la classe statique étant statique, doivent être déclarés avec le mot clé static:

ref class NonStaticClass abstract sealed 
{  
    public: 
      static int GetIntStatically() 
      { 
          return 10; 
      } 
};

Classe abstraite

Pour déclarer une classe abstraite, on utilise le mot clé abstract placé après le nom de la classe.

Par exemple:

public ref class AbstractBaseClass abstract 
{ 
    // ... 
};

Une méthode abstraite se déclare aussi en utilisant le mot clé abstract après la signature de la méthode:

public ref class AbstractBaseClass abstract 
{ 
    public: 
      virtual void AbstractMethod() abstract; 
};

Une méthode virtuelle pure peut aussi se déclarer avec =0 comme en C++:

public ref class AbstractBaseClass abstract 
{ 
    public: 
      virtual void AbstractMethod() =0; 
};
Classe abstraite implicitement

Si une classe n’implémente pas toutes les méthodes de la classe abstraite dont elle hérite ou toutes les méthodes d’une interface, elle est implicitement abstraite même si elle n’est pas déclarée avec le mot clé abstract.

Interface

Contrairement au C++, on peut déclarer des interfaces comme en C# en utilisant les mots clés interface class ou interface struct. Les 2 déclarations sont équivalentes car tous les membres d’une interface sont publics. Les notations interface class ou interface struct existent par cohérence avec ref class et ref struct, toutefois éviter d’utiliser “interface struct” permettra d”éviter des confusions.

On peut déclarer une interface en écrivant:

interface class IPoint 
{  
    public: 
      bool IsEqual(int x, int y); 
      int GetX(); 
      int GetY(); 
};

Quand une classe satisfait une interface, les méthodes doivent être déclarées avec le mot clé virtual:

ref class Point : public IPoint 
{  
    public: 
      virtual bool IsEqual(int x, int y) 
      {  
          return innerX == x && innerY == y; 
      }; 
 
      virtual int GetX() 
      {  
          return x; 
      }; 
 
      virtual int GetY() 
      {  
          return y; 
      }; 
};

Les objets de type valeur ne peuvent pas dériver de classes, en revanche ils peuvent dériver d’interfaces.

Les chaines de caractères

Les chaines de caractères désignent des objets de type différent en C++/CLI: les chaines de caractères managées et les chaines non managées, chacune ayant des caractéristiques différentes.

Chaines de caractères non managées

Plusieurs types correspondent à des chaines non managées:

  • Les chaines provenant du C: char* pour les chaines ANSI et wchar_t* pour les chaines Unicode.
  • Les chaines utilisant la bibliothèque C++ standard STL (standard template library): std::string pour les chaines ANSI et std::wstring pour les chaines Unicode.

Par exemple:

const char *nativeAnsiString = "Chaine native ANSI"; 
const wchar_t *nativeUnicodeString = L"Chaine native Unicode"; 
std::string nativeAnsiStlString = "Chaine native STL ANSI"; 
std::wstring nativeUnicodeStlString = L"Chaine native STL Unicode";

Les chaines Unicode doivent être préfixé avec "L":
L"Chaine Unicode".

Pour utiliser les chaines provenant de la STL il faut ajouter:

#include <string>;

On peut avoir plus de détails sur la différence entre ANSI et Unicode dans: Unicode en 5 min

Chaines de caractères managés

Ce type est le même que les chaines de caractères en C#, c’est un objet de type référence immutable. Immutable car toutes les affectations de nouvelles chaines de caractères créent un nouvel objet.

Les chaines managées sont alloués obligatoirement dans le tas managé et sont déclarées en utilisant un “handle”.

Par exemple:

Using System::String; 
String ^managedString = L"Chaine managée";

On peut utiliser un constructeur qui permet d’affecter des chaines de caractères natives:

const wchar_t *nativeUnicodeString = L"Chaine native Unicode"; 
String ^managedStringFromNative = gcnew String(nativeUnicodeString); 
 
std::wstring nativeUnicodeStlString = L"Chaine native STL Unicode"; 
String ^managedStringFromStl = gcnew String(nativeUnicodeStlString.c_str());

Conversion d’une chaine de caractères non managée vers une chaine managée
En utilisant la méthode:

static void ClrStringToStdString(std::string &outStr, String ^str) 
{ 
    IntPtr ansiStr = System::Runtime::InteropServices::Marshal::StringToHGlobalAnsi(str); 
    outStr = (const char*)ansiStr.ToPointer(); 
    System::Runtime::InteropServices::Marshal::FreeHGlobal(ansiStr); 
}

On peut utiliser les mêmes méthodes pour convertir les types primitifs en chaine ou inversement:

  • ToString(): pour convertir des nombres en chaine de caractères.
  • int::Parse() par exemple pour convertir une chaine en entier.

Casts

Le C++/CLI permet d’effectuer plusieurs types de “cast” pour effectuer des conversions de type. Aux “casts” C++ s’ajoutent le safe_cast spécifique au C++/CLI.

Les “cast” C++ sont:

  • static_cast<>: pour effectuer des changements de type pour des variables de même “famille”. Le plus souvent cet opérateur permet d’éviter d’avoir un warning de compilation lorsque le “cast” est implicite.
    Par exemple:

    int a = 10; 
    double b = static_cast<double>(a);
    

    Si on manipule des pointeurs, que A* pointe vers un objet de type A et que A est une partie d’un objet B. Effectuer un cast static_cast<B*> permet, sans effectuer de vérifications, d’ajuster l’adresse du pointeur de façon à pointer vers B.

  • const_cast<>: pour supprimer la qualification const sur une variable de type pointeur ou référence, par exemple:
    int i = 8;
    const int &iRef = i; 
    int &iRef2 = const_cast<int&>(iRef); // permet d'enlever la qualification const sur iRef pour qu'elle soit affectée à une référence non constante. 
    
  • dynamic_cast<>: qui permet d’effectuer des changements de type à l’exécution dans la hiérarchie d’héritage de classes. Ce “cast” peut être effectué sur des variables de type pointeur ou référence. Lorsque ce type de cast échoue, le résultat renvoyé est null.
    Par exemple, si on définit les classes:

    class A  
    { 
        public: 
          virtual void f() {} 
    }; 
     
    class B : public A  
    {};
    

    On peut effectuer le cast:

    B bVal; 
    B &bRef = bVal; 
    A &aRef = dynamic_cast<B&>(bVal);
    
  • reinterpret_cast<>: ce type de cast permet de retourner un pointeur comportant le même nombre d’octets en mémoire mais en changeant le type du pointeur. L’adresse du pointeur n’est pas modifiée, seul son type est modifié. Ce type de cast peut mener à des erreurs dans le cas où on effectue un cast entre des types n’occupant pas le même espace en mémoire.

Le C++/CLI ajoute un autre “cast”: safe_cast<> qui permet permet d’effectuer l’équivalent de dynamic_cast<> en lançant une InvalidCastException si le “cast” échoue.

Par exemple, si on considère les classes:

ref class A {}; 
ref class B : A {}; 

Le safe_cast<> peut être utilisé de cette façon:

B ^b = gcnew B(); 
A ^a = b; 
 
try 
{ 
    B ^bWithCast = safe_cast<B ^>(a); 
} 
catch (System::InvalidCastException ^e) 
{ 
    Console::WriteLine("Cast failed"); 
}

Equivalent à typeof()

On peut obtenir le type d’un objet en écrivant:

obj->GetType();

Pour avoir le type d’une classe avec une écriture équivalente à typeof() en C#, on utilise typeid:

ClassName::typeid

initonly

initonly est l’équivalent de readonly en C#, il rends obligatoire l’initialisation d’un membre dans le constructeur de la classe. Avec ce mot-clé, une initialisation après le constructeur provoque une erreur de compilation.

Par exemple, pour utilisation initonly:

initonly int innerX; 
Initonly Point ^point;

literal

Des constantes peuvent être déclarées comme en C++ avec static const dans le scope de la classe. Toutefois en C++/CLI, les constantes déclarées de cette façon ne sont pas reconnues à la compilation si l’objet de la classe est accédé avec #using statement.
Pour atteindre une constante définie dans une classe et en utilisant #using statement, il faut utiliser les mot-clés literal.

La déclaration est directe:

literal int innerX = 34;

pragma

L’utilisation de ce mot-clé permet d’indiquer des parties d’un fichier qui sont managés et d’autres parties qui seront non managées. Si l’option /clr n’est pas utilisée pour la compilation de l’assembly, le compilation ignore les déclarations #pragma.
#pragma doit être utilisé à l’extérieur du corps d’une fonction et après une directive #include.

Il est déconseillé d’utiliser ces mot-clés, il est préférable d’utiliser des fichiers séparés réservés à des objets natifs.

Tout ce qui est entre #pragma unmanaged et #pragma managed est interprété comme du code natif:

#include ... 
 
#pragma unmanaged 
// Code natif 
 
Int NativeFunction() 
{  
    // ... 
} 
 
#pragma managed 
// Code managé

On peut utiliser d’autres types de déclarations pour indiquer du code natif dans un fichier managé:

#pragma managed(push, off) 
// Code natif 
 
#pragma managed(pop) 
// Code managé

Tableaux

Il existe en C++/CLI, une structure de données équivalente aux tableaux en C++ qui permet de stocker des objets managés ou des objets non managés. Cette structure de données est array.
D’autres structures sont disponibles:

  • Des structures provenant du C++ comme les structures fournies par la Standard Template Library (STL): std::vector, std::list, std::map etc…
  • Des structures .NET: listes génériques List<T>, les dictionnaires Dictionary<K,V>, HashSet<T>, etc…

Tableau en C++

Les tableaux C++ classiques peuvent être utilisés, ces tableaux sont très différents des objets .NET: le nom de la variable renvoie au premier élément du tableau. Les autres éléments sont ensuite placés de façon contigue en mémoire à partir du premier élément. Si on souhaite accéder au 3e élément du tableau, connaissant la taille de chaque élément stocké, on applique un décalage à partir du premier élément du tableau pour savoir où il se trouve en mémoire.

Par exemple:
Pour déclarer un tableau de double dont la taille est fixe:

double doubleArray[10]; // ce tableau est alloué sur la pile 
Point *refObjectArray[7]; // tableau de pointeurs vers une classe

On peut initialiser directement le contenu d’un tableau:

int intArray[4] = {1, 2, 4, 8 }; // en précisant la taille 
int intArray[] = {1, 2, 4, 8 }; // sans préciser la taille

On peut accéder aux éléments du tableau de façon classique en utilisant l’index de l’élément:

int element = intArray[2];  
refObjectArray[3] = new Point(4, 9); // même si le tableau est alloué sur la pile, il faut penser à libérer chaque élément avec "delete"

Pour déclarer un tableau à 2 dimensions:

int array2d[4][2];

Les tableaux précédents ont des tailles fixes et connues à la compilation. On peut allouer de façon dynamique dans le tas.
Par exemple:

double *doubleDynArray = new double[10]; // tableau de double 
Point **pointDynArray = new Point*[10]; // tableau de pointeurs

On peut accéder aux éléments de la même façon avec l’index de l’élément:

double doubleValue = doubleDynArray[3]; 
Point *pointValue = pointDynArray[2];
Pour les tableaux alloués dans le tas, il faut utiliser delete[]

Sachant qu’on a alloué des objets dans le tas en utilisant new, il faut les libérer en utilisant delete[] et non delete:

delete[] doubleDynArray; 
delete[] pointDynArray;

Lorsque le tableau est alloué sur la pile, il n’est pas nécessaire d’utiliser delete.

Tableau managé array<T>

L’équivalent aux tableaux C++ est array<T> qui est une structure de données managéé dont la taille est fixe. Comme pour les tableaux C++, la taille d’une array<T> reste fixe et n’est pas augmentée automatiquement (comme les listes génériques par exemple).

Sachant que array<T> est un objet géré par le CLR, on peut y accéder en utilisant un “handle”.

Par exemple, pour initialiser un objet de type array<T>:

array<int> ^intArray; // tableau d'entier 
array<String^> ^stringArray; // tableau de string 
array<Point^> ^pointArray; // tableau de "Point" qui est un objet de type "ref class"

L’initialisation se fait en utilisant gcnew et en précisant la taille:

array<int> ^intArray = gcnew array<int>(5); 
array<String^> ^stringArray = gcnew array<String^>(5); 
array<Point^> ^pointArray = gcnew array<Point^>(3);

Comme tous les objets gérés par le CLR, il n’est pas nécessaire d’utiliser delete pour les libérer.

On peut aussi initialiser un array<T> plus directement:

array<int> ^intArray = gcnew array<int>(3) { 1, 2, 4, 8 }; // en précisant la taille 
array<int> ^intArray = gcnew array<int>() { 1, 2, 4, 8 }; // sans indiquer la taille 
array<int> ^intArray = { 1, 2, 4, 8 }; // Encore plus directement 
 
array<String ^> ^stringArray = gcnew array<String^>(3) { 
    gcnew String("first string"), 
    gcnew String("secund string"), 
    gcnew String("third string") };

L’accès aux objets se faire classiquement en utilisant les index:

intArray[1] = 6; 
pointArray[5] = gcnew Point(2, 8);

Pour déclarer un tableau en 2 dimensions:

array<int, 2> ^arrayIn2d = gcnew array<int, 2>(4, 3);

"2" car il s’agit d’un array à 2 dimensions; "4" car il possède 4 lignes et "3" car il possède 3 colonnes.

Pour initialiser directement ce type de tableau:

array<int, 2> ^arrayIn2d = gcnew array<int, 2>(3, 4) 
    { 
        { 2, 13, 65, 76 }, 
        { 5, 87, 29, 140 }, 
        { 8, 84, 97, 9885 } 
    }; 

On peut accéder à chaque élément de l’array<T> avec une déclaration un peu différent de celle en C++:

arrayIn2d[2, 1] = 5; 

Boucle “for each”

Comme en C#, on peut utiliser une boucle for each pour accéder aux éléments d’une array<T>:

for each (String ^s in stringArray) 
{ 
    String ^arrayElement = s; 
    // ... 
}

Plus généralement, comme en C#, for each est utilisable pour toutes les structures satisfaisant IEnumerator.

Copier une array dans une autre

Il est possible de copier une array dans une autre en utilisant System::Array::Copy:

array<int> ^firstArray = gcnew array<int>(4); 
array<int> ^secundArray = gcnew array<int>(3);

System::Array::Copy(firstArray, 1, secundArray, 0, 2); 

Permet de copier firstArray vers secundArray en commençant à l’index "1" de firstArray et en copiant à partir de l’index "0" de secundArray. 2 éléments seront copiés.

Autres structures .NET

La plupart des structures de données courantes en .NET sont accessibles en C++/CLI:

  • La liste générique: List<T> par exemple List<String^> ^stringList = gcnew List<String^>();
  • Le dictionnaire: Dictionary<K,V> par exemple: Dictionary<String^, Point^> ^dictionary = gcnew Dictionary<String^, Point^>();
  • Une structure FIFO: Queue<T>.
  • Une structure LIFO: Stack<T>.
  • Une liste de pairs clé/valeur ordonnée: SortedList<K,V>.

STL

Les structures de données fournis par la Standard Template Library (STL) sont aussi utilisables: vector, list, map, multimap, set, multiset, queue, deque, stack etc…

Propriétés

On peut déclarer des propriétés dans les classes comme en C#.

Propriétés scalaires

On peut définir directement des propriétés avec le mot clé property.
Par exemple:

ref class Point 
{  
    public: 
      Point(int x, int y, String ^name) 
      { 
          X = x; 
          Y = y; 
          Name = name; 
      } 
 
      property int X; 
      property int Y; 
      property String ^Name; 
};

Les 3 propriétés X, Y et Name possédent implicitement un “getter” et un “setter”.

On peut y accéder classiquement:

Point ^point = gcnew Point(2, 9, "mai point"); 
point->X = 65; 
point->Y = 4; 
point->Name = "new name";

On peut utiliser une implémentation plus explicite du “getter” et du “setter”.
Par exemple:

ref class Point 
{  
    private: 
      int innerX, innerY; 
      String ^name; 
 
 
    public: 
      Point(int x, int y, String ^name) 
      { 
          innerX = x; 
          innerY = y; 
          Name = name; 
      } 
 
      property int X 
      { 
        int get { return innerX; } 
        void set(int x) { innerX = x; } 
      } 
 
      property int Y 
      { 
        int get { return innerY; } 
        void set(int y) { innerY = y; } 
      } 
 
      property String ^Name 
      { 
        String ^get { return name; } 
        void set(String ^n) { name = n; } 
      } 
};

Il est possible d’implémenter une propriété en lecture seule en omettant la déclaration du “setter”. Inversement on peut déclarer une propriété en écriture seule en omettant la déclaration du “getter”.

Propriétés et héritage

Comme les autres membres d’une classe, les propriétés peuvent être surchargées.

Par exemple, si on définit la classe suivante:

ref class NamedObject abstract 
{ 
    public: 
      virtual property String ^Name; 
};

Une classe fille peut surcharger seulement le “getter”:

ref class NamedPoint : NamedObject 
{ 
    public: 
      virtual property String ^Name 
      { 
        String ^get() override  
        { 
          return "unnamed"; 
        } 
      } 
};

Propriétés indexées

Les propriétés indexées permettent d’accéder à un élément au moyen d’un index. L’élément n’est pas forcément dans une structure de données indexée puisqu’on peut implémenter librement le “setter” et le “getter”.

Par exemple, une propriété indexée peut être déclarée simplement avec:

property double IndexedValues[long];

Une implémentation plus explicite du “getter” et “setter” est possible:

property double IndexedValues[long] 
{ 
    double get(long index)  
    { 
      // ...  
    } 
 
    void set(long index, double value)  
    { 
      // ... 
    } 
}

Les exceptions

Les exceptions en C++/CLI sont proches de celles en C#:

  • Une exception C++/CLI est un objet de type référence qui dérive System::Exception.
  • Les exceptions s’utilisent dans des blocs try...catch...finally. finally ayant la même fonction qu’en C# c’est-à-dire exécuté du code après le bloc try...catch dans le cas d’une exception ou non.
  • Sachant qu’il est possible de lancer des exceptions managées en C++/CLI, elles peuvent être attrapées directement dans du code C#.
  • Il est possible de gérer 3 types d’exceptions: les exceptions managées C++/CLI, les exceptions C++ et les Microsoft Windows Structured Exception Handling (SEH) (plus de détails sur les exceptions SEH dans Gestion des “Corrupted State Exceptions” par le CLR.

Try…catch

Par exemple, un bloc try...catch est semblable à du code C#:

try 
{ 
    // Code où une exception peut être lancée 
} 
catch (System::InvalidCastException ^e) 
{ 
    // Traitement InvalidCastException 
} 
catch(System::ArithmeticException ^aex) 
{ 
    // Traitement ArithmeticException 
} 
catch(System::DivideByZeroException ^dex) 
{ 
    // DivideByZeroException 
}

Try…catch…finally

Une clause finally permet d’exécuter du code quelque soit ce qui se passe:

ManagedPoint ^firstPoint = gcnew ManagedPoint(23, 87); 
ManagedPoint ^secundPoint = gcnew ManagedPoint(0, 0); 
 
try 
{ 
    point->GetDistance(secundPoint); 
} 
catch (System::ArgumentException ^e) 
{ 
    Console.WriteLine(e->Message); 
} 
finally 
{ 
    delete firstPoint; 
    delete secundPoint; 
}

Throw

Pour lancer une exception:

throw gcnew System::ArgumentException("Argument null"); 

Définir un type d’exception

Il suffit de dériver de System::Exception comme en C#.

Par exemple:

ref class CustomException : System::Exception 
{ 
    public: 
      int errNo; 
 
      CustomException(String ^msg, int num) : Exception(msg), errNo(num) {} 
};

Delegates

Les déclarations des “delegates” sont proches de celles en C#. Comme un C#, ils permettent de définir la signature d’une fonction.

Par exemple, pour définit un delegate:

delegate int SquareDelegate(int number); 

Pour créer un delegate lié à une fonction statique d’une classe:

ref class SquareCalculator 
{ 
    public: 
      static int GetSquare(int x)  
      {  
          return x*x;  
      } 
};

On lie le “delegate” à la fonction statique Square::GetSquare():

SquareDelegate ^square = gcnew SquareDelegate(&SquareCalculator::GetSquare); 
// Ou 
SquareDelegate ^otherSquare = gcnew SquareDelegate(nullptr, &SquareCalculator::GetSquare); 

Pour exécuter le “delegate”:

int valueSquare = square(5); 

Pour lier un “delegate” à une fonction non statique, il faut ajouter l’instance de la classe à la déclaration du delegate.
Si on déclare le delegate suivant:

delegate int GetPointIndexDelegate(int x, int y); 

Et la classe suivante:

ref class PointSet 
{ 
    private: 
      array<Point^> points; 
 
    public: 
      PointSet() 
      {  
          points = gcnew array<Point^>()  
          {  
              gcnew Point(5, 8), 
              gcnew Point(2, 9), 
              gcnew Point(1, 3), 
          };  
      } 
 
      int GetPointIndex(int x, int y)  
      {  
          for(int i=0; i<3; i++) 
          { 
             Point ^p = points[i]; 
             if (p->X == x && p->Y == y) 
             { 
                return i; 
             } 
          } 
 
          return nullptr; 
      } 
};

On peut créer un “delegate” lié à une fonction non statique par:

PointSet ^pointSet = gcnew Point; 
GetPointIndexDelegate ^getPointIndex = gcnew GetPointIndexDelegate(pointSet, &PointSet::GetPointIndex);  

Pour exécuter la fonction en utilisant le “delegate”:

int index = GetPointIndex->Invoke(5, 8); 

Evènements

L’utilisation des évènements en C++/CLI est très semblable à ce qu’on peut retrouver en C#. On peut déclarer des évènements, s’y abonner puis déclencher l’exécution des fonctions abonnées.

Comme en C#, la création d’un évènement se fait à partir d’un “delegate”.
Par exemple:

delegate void ClickHandler(int, int); // déclaratin du delegate 
event ClickHandler ^OnClick; // déclaration de l'évènement 

Dans une classe:

ref class Point 
{ 
    private: 
      int innerX, innerY; 
 
 
      void RaiseOnCoordinateChanged() 
      { 
        if (OnCoordinateChanged != nullptr) 
        { 
          OnCoordinateChanged(innerX, innerY); // déclenchement de l'évènement 
        } 
      } 
 
    public: 
      Point(int x, int y) 
      { 
        innerX = x; 
        innerY = y; 
      } 
 
      void SetX(int newX) 
      { 
        innerX = newX; 
        RaiseOnCoordinateChanged(); 
      } 
 
      void SetY(int newY) 
      { 
        innerY = newY; 
        RaiseOnCoordinateChanged(); 
      } 
 
      delegate void CoordinateChangedHandler(int, int); 
 
      event CoordinateChangedHandler ^OnCoordinateChanged; 
};

Pour s’abonner à un évènement:

Point->OnCoordinateChanged += gcnew CoordinateChangedHandler(this, &eventHandler); 

Par exemple:

ref class PointSubscriber 
{ 
    private: 
      void PointChanged(int newX, int newY) 
      { 
        // ... 
      } 
 
    public: 
      PointSubscriber(Point ^point) 
      { 
        point->OnCoordinateChanged += gcnew CoordinateChangedHandler(this, &PointChanged);  
      } 
};

Pour se désaboner:

Point->OnCoordinateChanged -= gcnew CoordinateChangedHandler(this, &eventHandler); 

On peut utiliser une implémentation plus explicite lorsque certaines opérations sont effectuées sur un évènement:

  • add: quand une souscription est effectuée,
  • remove: quand une souscription est supprimée,
  • raise: quand l’évènement est déclenché.

Par exemple, en reprenant l’exemple précédent:

ref class Point 
{ 
    private: 
      int innerX, innerY; 
 
      void RaiseOnCoordinateChanged() 
      { 
        if (OnCoordinateChanged != nullptr) 
        { 
          OnCoordinateChanged(innerX, innerY); 
        } 
      } 
 
      event CoordinateChangedHandler ^innerOnCoordinateChanged; 
 
    public: 
      Point(int x, int y) 
      { 
        innerX = x; 
        innerY = y; 
      } 
 
      void SetX(int newX) 
      { 
        innerX = newX; 
        RaiseOnCoordinateChanged(); 
      } 
 
      void SetY(int newY) 
      { 
        innerY = newY; 
        RaiseOnCoordinateChanged(); 
      } 
 
      delegate void CoordinateChangedHandler(int, int); 
 
      event CoordinateChangedHandler ^OnCoordinateChanged 
      { 
        void add(CoordinateChangedHandler ^handler)  
        { 
          innerOnCoordinateChanged += handler; 
        } 
 
        void remove(CoordinateChangedHandler ^handler)  
        { 
           innerOnCoordinateChanged -= handler; 
        } 
 
        void raise(Object ^sender, PageDumpedEventArgs ^ea)  
        { 
          RaiseOnCoordinateChanged(); 
        } 
      } 
};

raise est implicitement protected, on ne peut pas l’atteindre à l’extérieur de la classe.

Une autre implémentation possible:

private: 
  static initonly Object ^pointChanged = gcnew Object(); 
 
public: 
  event CoordinateChangedHandler ^OnCoordinateChanged 
  { 
      void add(CoordinateChangedHandler ^handler)  
      { 
        Component::Events->AddHandler(pointChanged, handler);  
      } 
 
      void remove(CoordinateChangedHandler ^handler)  
      { 
        Component::Events->RemoveHandler(pointChanged, handler);  
      } 
 
      void raise(int x, int y)  
      { 
        CoordinateChangedHandler ^handler = (CoordinateChangedHandler^)Component::Events[pointChanged]; 
        if (handler != nullptr) 
          handler(x, y); 
      }  
  }

Templates et generics

Les “templates” et les “generics” sont des notions semblables et sont toutes les deux supportées par le C++/CLI. Les templates sont des types instanciés à la compilation alors que les “generics” restent génériques jusqu’à l’exécution et sont instanciés par le CLR. Les “generics” permettent de définir des contraintes sur le type des paramètres. Ce type de fonctionnalité n’est pas supporté par les “templates”.

Une classe “template” C++ peut être définie de cette façon:

template<typename xType, typename yType> class NativePoint 
{ 
    public: 
      NativePoint(xType x, yType y) 
      { 
        innerX = x; 
        innerY = y; 
      } 
   
      void SetX(xType x) 
      { 
        innerX = x; 
      } 
 
 
      void SetY(yType y) 
      { 
        innerY = y; 
      } 
 
    private: 
      xType innerX; 
      yType innerY; 
};

Un “generic” équivalent peut être défini de cette façon (le “generic” C++/CLI est la même notion que le “generic” C#):

generic<typename xType, typename yType> ref class GenericPoint 
{ 
    public: 
      GenericPoint(xType x, yType y) 
      { 
        innerX = x; 
        innerY = y; 
      } 
 
      void SetX(xType x) 
      { 
        innerX = x; 
      } 
 
      void SetY(yType y) 
      { 
        innerY = y; 
      } 
 
    private: 
      xType innerX; 
      yType innerY; 
};

Contrainte

Les “generics” peuvent utilisés des contraintes comme en C#.

Par exemple, si on définit l’interface suivante:

interface class INamedObject 
{ 
  void SetName(String ^name); 
};

On définit un objet qui satisfait la classe:

ref class Point : INamedObject 
{ 
    public: 
      void SetName(String ^name) 
      { 
        objectName = name; 
      } 
 
 
    private: 
      String ^objectName; 
};

On peut utiliser un “generic” avec une contrainte pour imposer que le type du “generic” satisfait l’interface:

generic<typename T> where T:INamedObject ref class NamedObjectWrapper 
{ 
    public: 
      void SetName(String ^name) 
      { 
        innerObject = Activator::CreateInstance<T>(); 
        innerObject->SetName(name); 
        delete safe_cast<Object^>(innerObject); 
      } 
 
      void DeleteInnerObject() 
      { 
        delete safe_cast<Object^>(innerObject);
      } 
 
    private: 
      T innerObject; 
};

On peut remarquer l’utilisation de Activator::CreateInstance<T>() pour créer une instance de l’objet à la place de gcnew. De même on utilise delete safe_cast<Object^>(innerObject) pour libérer l’espace alloué pour l’objet à la place d’un simple delete.

Utilisation des templates dans des assemblies mixtes

L’utilisation de templates C++ dans des assemblies mixtes peut mener à des appels managés-non managés involontairement ce qui peut dégrader les performances. Un template C++ compilé dans du code natif et ses membres sont compilés dans du code natif. Si du code utilise ce template et que le code est compilé dans du code managé, les membres du template seront compilés dans du code managé. Le template est donc compilé dans 2 variantes:

  • 1 variante dans du code non managé
  • 1 variante dans du code managé.

Suivant les utilisations du template, le linker choisira d’utiliser l’une ou l’autre des 2 variantes:

  • Pour un appel provenant de code non managé, il utilisera la version non managée
  • Pour un appel provenant de code managé, il utilisera la version managée.
Pour aller plus loin…

Références

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

C++/CLI en 10 min, partie 3: Syntaxe des éléments de base

Cette partie permet d’expliquer la syntaxe des éléments de base du code C++/CLI. La syntaxe d’autres éléments sera détaillée dans la partie suivante.

Définition et instanciation des objets

Comme indiqué plus haut, en C++/CLI, on peut définir et instancier:

  • Des objets managés comme les classes, struct, interfaces et enums.
  • Des objets non managés comme les classes et les struct.

Même si certains objets semblent communs entre code managé et code non managé, ils sont en réalité très différents. Les objets managés sont gérés par le CLR (Common Language Runtime) comme en C#, ils sont alloués sur le tas managé (managed heap) ou la pile (stack). Les objets non managés sont, quant à eux, gérés par le CRT (C/C++ Runtime) et sont alloués sur le tas (heap) ou la pile (stack).

On peut résumer les différents objets utilisables en C++/CLI dans le tableau suivant:

Mot clé Nom Type managé ? Accès des membres par défaut Equivalent Allocation Utilisation
ref class Classe Oui Privé Classe C# Tas managé ou
la pile
Par “handle” ou
par valeur
ref struct Structure Oui Public
value class Classe Oui Privé Struct C# Pile Par valeur
value struct Structure Oui Public
class Classe Non Privé Classe C++ Tas ou pile Par référence, par pointeur ou
par valeur
struct Structure Non Public Struct C++
interface class Interface Oui Public Interface C# Non instanciable Par “handle”
interface struct
enum class Enumération Oui Public Enum C# Pile Par valeur
enum Non Enum C++

Classes non managées

Les classes non managées sont les classes C++ classiques, par exemple:

class NativePoint 
{ 
    public: 
      NativePoint(int x, int y) 
      { 
         innerX = x; 
         innerY = y; 
      } 
 
      bool IsEqual(int x, int y) 
      { 
         return this->innerX == x && this->innerY == y; 
      } 
 
    private: 
      int innerX, innerY; 
};

Pour créer une classe par valeur et l’allouer sur la pile:

NativePoint point(3, 9); 
point.IsEqual(8, 3);

Pour allouer une classe sur le tas:

NativePoint *point = new NativePoint(9, 3); 
Point->IsEqual(2, 54); 
delete point;
Libérer les objets avec delete

Quand un objet non managé est alloué sur le tas avec new, il doit être libéré en utilisant le mot clé delete.

Structures non managées

Les struct C++ sont très semblables des classes C++. Par défaut c’est-à-dire sans opérateur de portée (public, protected ou private), les membres d’une classe sont privés. Pour une struct, par défaut, les membres sont publics.

Une struct C++ se déclare:

struct NativeStruct 
{ 
    // ... 
};

Objets managées de type référence

Comme en C#, on distingue les objets de type référence et les objets de type valeur. Les objets de type référence sont ceux déclarés avec ref class ou ref struct. Ils sont semblables aux classes C# car ils sont alloués sur le tas managé et généralement ils sont instanciés par référence (toutefois on peut les allouer sur le tas). Ils sont gérés par le CLR et par suite il n’est pas nécessaire d’utiliser le mot clé delete pour les libérer car ils sont libérés par le garbage collector. Comme en C#, ces objets dérivent implicitement de System::Object.

Les références vers ces objets sont appelées des “handles” pour les différencier des références classiques vers des classes C++ non managées. Un “handle” est identifié par "^". Les objets managés alloués sur le tas managé sont instanciés avec gcnew (utilisé new provoque une erreur).

Par exemple, si on déclare la classe:

ref class ManagedPoint 
{ 
    public: 
      ManagedPoint(int x, int y) 
      { 
         innerX = x; 
         innerY = y; 
      } 
 
      bool IsEqual(int x, int y) 
      { 
         return this->innerX == x && this->innerY == y; 
      } 
 
    private: 
      int innerX, innerY; 
};

On peut allouer la classe dans le tas managé:

ManagedPoint ^managedPoint = gcnew ManagedPoint(3, 6); 
ManagedPoint->IsEqual(1, 5);

Ou allouer ces objets sur la pile:

ManagedPoint managedPoint(3, 6); 
managedPoint.IsEqual(1, 5);

Handle

Dans l’exemple précédent, la variable managedPoint de type ManagedPoint ^ est appelé “handle”. Il s’agit de l’équivalent des pointeurs en C++ pur toutefois ils sont assez différents car contrairement aux objets natifs, les objets managés peuvent être déplacés par le garbage collector pendant sa phase de “compactage”. Ainsi, l’adresse pointée par un “handle” peut varier au cours de sa vie.
Même en cas de déplacement des objets, le CLR garantit que les “handles” pointeront vers les bons objets en mémoire.

Précisions sur “ref struct”

Il ne faut pas confondre ce type d’objet avec les struct en C#. La différence entre les ref class et les ref struct réside dans la portée des membres par défaut:

  • ref class: ces objets sont semblables aux classes C#, les membres sont privés par défaut.
  • ref struct: les membres sont publics par défaut.

Pour éviter les confusions, on peut éviter d’utiliser ce type d’objets.

Objet de type System::String et array

Les objets de type System::String et array ne peuvent être alloués sur la pile, ils doivent obligatoirement être instanciés en utilisant gcnew.

Objets managés de type valeur

Les objets de type valeur peuvent être déclarés avec value class et value struct. Ils sont alloués sur la pile et généralement ils sont instanciés par valeur (toutefois on peut les instancier par référence avec gcnew). Le passage de ces objets se fait par copie comme pour les struct en C#, ils dérivent implicitement de System::ValueType qui dérive elle-même de System::Object. Toutefois il n’est pas possible d’instancier directement un objet de type System::ValueType.
Les objets de type valeur sont implicitement sealed c’est-à-dire qu’on ne peut pas en dériver et ils possédent un constructeur par défaut. Lorsqu’ils sont passés en paramètre de fonction, ils sont copiés par valeur et une copie par bit est effectuée.

L’intérêt d’utiliser des objets de type valeur est d’allouer ces objets sur la pile plutôt que sur le tas managé, la pile étant plus rapide que le tas managé.

Par exemple, si on déclare la struct:

value struct ManagedPointAsStruct 
{ 
    public: 
      ManagedPointAsStruct(int x, int y) 
      { 
         innerX = x; 
         innerY = y; 
      } 
 
      bool IsEqual(int x, int y) 
      { 
         return innerX == x && innerY == y; 
      } 
 
    private: 
      int innerX, innerY; 
};

On peut l’instancier par valeur:

ManagedPointAsStruct managedPointAsStruct(3, 6); 
managedPointAsStruct.IsEqual(1, 5);

Il est possible de l’instancier par référence avec gcnew:

ManagedPointAsStruct ^managedPointAsStruct = gcnew ManagedPointAsStruct(3, 6); 
managedPointAsStruct->IsEqual(1, 5);

Précisions sur l’instanciation de type valeur par référence

Même s’il est possible d’instancier un objet de type valeur par référence avec gcnew, l’objet est toujours alloué sur la pile et une opération de “boxing” est effectuée pour le convertir en type ManagedPointAsStruct ^. Ce “boxing” implique une opération supplémentaire qui a un coût en performance.

Précisions sur “value class”

La différence entre value struct et value class concerne la différence de portée des membres par défaut:

  • value class: les membres sont privés par défaut.
  • value struct: les membres sont publics par défaut.

On peut éviter d’utiliser value class qui prête beaucoup à confusion.

Caractéristiques des structures en C++/CLI

Les caractéristiques principales des struct en C++/CLI sont:

  • Il n’y a pas d’arguments par défaut dans le constructeur (contrairement au C++), il faut implémenter explicitement un constructeur par défaut.
    Par exemple, si on définit la structure:

    value struct Point 
    { 
        int innerX, innerY; 
        Point(int x, int y) 
        { 
          innerX = x; 
          innerY = y; 
        } 
    };
    
  • On ne peut pas initialiser des membres dans la définition de la structure, il faut le faire dans le constructeur.
  • On ne peut pas utiliser de constructeur sans argument, par défaut, les membres sont initialisés avec une valeur par défaut: 0 pour les entiers, false pour les booléens etc…
  • Les membres sont par défaut publics. Dans l’exemple précédent, il n’est pas nécessaire de préciser public pour accéder aux membres de la structure.
  • Une structure ne peut pas hériter d’un objet et on ne peut pas hériter d’une structure.
  • Une structure peut satisfaire une interface.
  • On ne peut pas avoir d’objets de type référence dans une structure. Les structures étant des objets de type valeur, elles sont initialisées sur la pile. Si le membre d’une structure est un objet de type référence (qui sont alloués dans le tas managé et gérés par le garbage collector), on ne peut plus placer la structure sur la pile. Il existe une exception à cette règle: les chaînes de caractères qui sont des objets de type référence définis en C++/CLI avec un “handle”:
    Si on reprend la classe ManagedPoint définie plus haut:

    value struct Point 
    { 
        String ^pointName; // String est autorisé dans une struct 
        ManagedPoint ^point; // L'objet de type référence ManagedPoint n'est pas autorisé dans une struct 
    };
    

Aggregate initializer

On peut initialiser une structure avec des “aggregate initializer”. Par exemple, si on considère la structure:

value struct Point 
{ 
    int innerX, innerY; 
    String ^pointName; 
};

On peut initialiser directement cette structure en faisant:

Point point = { 3, 8, "pointA" };

Le type n’est pas vérifié par le compilateur lorsqu’on utilise un “aggregate initializer”.

Copie des structures

Les structures sont des objets de type valeur donc quand on effectue une affectation simple d’objets de ce type ou quand on les passe en argument de fonction, il y a une duplication de l’objet.

Par exemple:

Point p1; 
Point p2; 
p2 = p1; // L'objet est dupliqué, p1 et p2 désignent 2 objets différents.

Si on souhaite copier la référence de l’objet, il faut utiliser l’opérateur "%" qui désigne une “tracking reference”.

Opérateur “address-of” et de déférencement

Les opérateurs ne sont pas les mêmes entre le code managé et le code non managé:

  • L’opérateur “address-of” pour des “handles” est "%" (au lieu de "&" en C++ non managé).
  • L’opérateur permettant de déclarer un “handle” est "^" (au lieu de "*" pour un pointeur C++ non managé).

Ainsi en code non managé, on peut définir un pointeur et une réference de cette façon:

NativePoint *pointer = new NativePoint(4, 7); 
NativePoint &reference = *pointer;

En code managé, on peut définir un “handle” et une référence vers le tas de cette façon:

ManagedPoint ^managedPoint = gcnew ManagedPoint(); 
ManagedPoint %heapRef = *managedPoint;

"*" désigne l’objet vers lequel pointe managedPoint. managedPoint et heapRef désignent donc le même objet en mémoire.

Si on écrit:

ManagedPoint managedPoint2 = *managedPoint;

managedPoint2 ne désigne pas le même objet que managedPoint. Une copie est effectuée et l’objet est dupliqué. Durant cette duplication le constructeur de copie est appelé.

Tracking reference

Dans l’exemple précédent:

ManagedPoint %heapRef = *managedPoint;

L’objet heapRef de type ManagedPoint % est appelée “tracking référence”. Comme pour les “handles”, le CLR garantit que si le garbage collector déplace un objet en mémoire, la “tracking reference” désignera toujours le même objet.

Pour un objet de type valeur, on peut le déclarer de cette façon:

int i = 5; 
int %iTrackingRef = i;

iTrackingRef est aussi une “tracking reference”. iTrackingRef désigne le même objet que i.

Pour un objet de type valeur, la notation:

int %iTrackingRef = I; 
Est équivalente à: 
int &iRef = i;

Une “tracking reference” peut aussi être utilisé avec un type non managé class ou struct. Il est équivalent à "&".

Par exemple si on déclare la classe:

class NativePointClass 
{  
    public: 
      NativePointClass(int x) : innerX(x) { }; 
 
      int innerX; 
};

On peut instancier une “tracking reference” vers un objet de ce type alloué sur la pile avec:

NativePointClass pointClass(4); 
NativePointClass %pointClassTrackingRef = pointClass; 
 
De même pour une "struct": 
struct NativePointStruct 
{ 
    NativePointStruct(int x) 
    { 
      innerX = x; 
    }; 
 
    int innerX; 
};

On peut instancier une “tracking reference” de la même façon:

NativePointStruct pointStruct(7); 
NativePointStruct %pointStructTrackingRef = pointStruct;

En résumé
On peut résumer les différents opérateurs dans le tableau suivant:

Opération Code non managé Code managé
Définition de pointeur ou de référence managé * ^
Address-of & %
Accès aux membres d’un objet instancié par référence -> ->
Accès aux membres d’un objet instancié par valeur . .
Instanciation new gcnew
Destruction delete delete
(bien que l’appel à cet opérateur ne soit pas indispensable
puisque la suppression de l’objet sera effectué par le garbage collector)

Constructeur

Les constructeurs des objets en C++/CLI ont la même syntaxe que les constructeurs en C#. Comme en C++, il existe une forme qui est plus spécifique pour initialiser des membres:

ref class ManagedPoint 
{ 
  private: 
    int innerX, innerY; 
    String ^innerName; 
   
  public: 
    ManagedPoint(int x, int y, String ^name) : innerX(x), innerY(y), innerName(name)  
    {} 
};

Constructeur de copie

Les constructeurs de copie (copy constructor) ne sont pas indispensables en C++/CLI. Le compilateur ne rajoute pas implicitement un constructeur de copie comme en C++. Le cas échéant, une implémentation explicite du contructeur de copie est nécessaire en C++/CLI.

Si on déclare la classe:

ref class ManagedPoint 
{ 
    private: 
      int innerX, innerY; 
 
    public: 
      ManagedPoint(int x, int y); 
};

On peut implémenter un constructeur de copie de la façon suivante:

ManagedPoint(const ManagedPoint %other) 
{ 
    innerX = other.innerX; 
    innerY = other.innerY; 
}

Destructeur et “finalizer”

Les destructeurs se définissent de la même façon entre un objet managé et un objet non managé: on utilise la syntaxe ~ClassName().

Par exemple, la déclaration d’un destructeur pour une classe non managée se définit:

class NativePoint 
{ 
    public: 
      NativePoint(int x, int y); 
      ~NativePoint(); 
};

Pour un objet managé:

ref class ManagedPoint 
{ 
    public: 
      ManagedPoint(int x, int y); 
      ~ManagedPoint(); 
};

Dans le cas d’objets managés, l’utilisation des fonctions de destruction répond aux mêmes impératifs qu’en C#. L’utilisation d’un destructeur n’est pas obligatoire, toutefois il est nécessaire lorsqu’on souhaite maitriser la libération d’une ressource:

  • Si la ressource nécessite une opération particulière de fermeture lorsqu’on ne souhaite plus l’utiliser.
  • Si on veut forcer la libération d’une ressource sans attendre que le garbage collector n’effectue cette libération.

Le destructeur est appelé:

  • Pour les objets alloués dans le tas managé (heap): lorsqu’on utilise l’instruction delete pour supprimer un objet de la mémoire.
  • Pour les objets alloués sur la pile (stack): lorsqu’on sort du scope de la fonction qui a alloué l’objet.

Finalizer

En C++/CLI, pour déclarer un “finalizer”, on utilise la syntaxe: !ManagedPoint().
Par exemple, pour la classe précédente, la déclaration sera:

ref class ManagedPoint 
{ 
    public: 
      ManagedPoint(int x, int y); // Constructeur 
      ~ManagedPoint(); // Destructeur 
      !ManagedPoint(); // Finalizer 
};

Il peut être nécessaire de déclarer un “finalizer” si la classe utilise des ressources non managées comme des pointeurs vers des classes non managées, un “handle” d’un fichier, un “handle” d’un objet graphique etc…

Lorsqu’on utilise un “finalizer”, il faut avoir en tête certaines règles:

  • Si plusieurs classes implémentent un “finalizer”, et que des objets du type de ces classes doivent être libérés, il n’y a pas de garantie de l’ordre dans lequel les “finalizers” de ces objets seront exécutés. Il ne faut donc pas appelé un “finalizer” d’une classe à partir du finalizer d’une autre classe.
  • Il n’y a pas de connaissance sur le moment où le “finalizer” sera exécuté.
  • Si un objet implémentant un “finalizer” est toujours utilisé au moment de la fermeture d’une application dans un “background thread”, au moment de la fermeture de l’application, le “finalizer” ne sera pas exécuté.
  • L’implémentation d’un “finalizer” dans une classe implique que la mémoire occupée par un objet du type de cette classe ne sera pas immédiatement libérée à la première phase de “collection” du garbage collector. Si le garbage collector constate qu’un “finalizer” est implémenté, il marquera l’objet pour exécuter son “finalizer” plus tard. C’est lors du second passage du garbage collector, qu’il executera effectivement le “finalizer” de l’objet et que la mémoire sera libérée. L’implémentation d’un “finalizer” a donc une conséquence sur les performances et la libération des objets.
  • Si le destructeur d’une classe est exécuté (après un appel à delete par exemple), le “finalizer” ne sera pas exécuté.

Passage des objets en argument

Par “handle”

On peut passer directement les objets managés alloués dans le tas managé en utilisant le “handle”:

Par exemple, si on déclare la classe:

ref class ManagedPoint 
{  
    public: 
      int innerX; 
};

Si on définit la méthode:

void ChangePointXByReference(ManagedPoint ^point) 
{ 
  point->innerX = 78; 
}

Le passage de l’objet par “handle” se fait directement:

ManagedPoint ^point = gcnew ManagedPoint; 
ChangePointXByReference(point);

Objets managés passés par référence

Les objets managés alloués dans le tas managé en utilisant un “handle” peuvent être passés par référence en utilisant "%".

Pour passer un objet de type ManagedPoint ^ par référence, on déclare la méthode:

void ChangePointXByReference(ManagedPoint ^%point) 
{ 
  point->innerX = 78; 
}

On peut allouer un objet de type ManagedPoint et l’utiliser en faisant:

ManagedPoint ^point = gcnew ManagedPoint; 
ChangePointXByReference(point);

Objets managés passés par tracking reference

On peut aussi utiliser une “tracking reference”.

En définissant la méthode:

void ChangePointXByTrackingRef(ManagedPoint %point) 
{ 
  point.innerX = 78; 
}

L’appel se fait en utilisant l’opérateur de déférencement "*":

ManagedPoint ^point = gcnew ManagedPoint; 
ChangePointXByTrackingRef(*point);

Si on définit la méthode:

void ChangePointX(ManagedPoint copyOfPoint) 
{ 
  copyOfPoint.innerX = 78; 
}

Et qu’on fait l’appel en faisant:

ManagedPoint ^point = gcnew ManagedPoint; 
ChangePointX(*point);

On ne changera la valeur de point mais la valeur d’une copie.

Objets de type valeur

On utilise la “tracking reference” avec "%".

Par exemple si on déclare la struct:

value struct PointStruct 
{ 
    PointStruct(int x) 
    { 
      innerX = x; 
    }; 
 
    int innerX; 
};

Et la méthode:

void ChangePointX(PointStruct %point) 
{ 
  point.innerX = 78; 
}

On peut effectuer l’appel en faisant:

PointStruct point(3); 
ChangePointX(point);

Construction d’objets mixtes

Comme indiqué plus haut, le plus grand intérêt du C++/CLI est de pouvoir utiliser des objets managés et des objets non managés. Il est possible d’utiliser des constructions mixtes dans un même objet suivant certaines règles.
Les limitations dans les constructions mixtes sont dues à 2 raisons:

  • Les objets managés sont alloués dans le tas managé et sont gérés par le CLR, ils ne peuvent donc pas contenir des objets alloués dans le tas non managé. En effet les objets managés sont gérés par le garbage collector qui peut les déplacer en mémoire.
  • Les objets non managés sont alloués dans le tas non managé, ils ne peuvent donc pas contenir des objets alloués dans le tas managé et gérés par le garbage collector.

Ainsi:

  • Une classe managée peut contenir des membres non managés seulement sous forme de pointeur: si l’objet managé est déplacé par le garbage collector en mémoire, l’adresse de l’objet non managé reste inchangé car celui-ci se trouve dans le tas non managé (les objets de cette pile n’étant pas gérés par le garbage collector).
  • Une classe non managée ne peut pas contenir directement des objets managés: les objets managés pouvant être déplacés par le garbage collector dans le tas managé, ils n’ont pas d’adresse fixe. C’est la raison pour laquelle on ne peut pas utiliser directement des pointeurs pour les objets managés. De même, les “handles” ne sont pas utilisables dans des objets natifs.

Par exemple, si on définit les objets:

ref class ManagedPoint 
{ 
}; 
 
class UnmanagedPoint 
{ 
};

Alors:

ref class ManagedObject 
{ 
  ManagedPoint ^managedPoint; // OK: un objet managé dans un objet managé 
  UnmanagedPoint *unManagedPoint; // OK: un pointeur vers un objet non managé 
  UnmanagedPoint unManagedPointValue; // ERROR: on ne peut pas utiliser un objet alloué sur la pile 
}; 
 
class UnmanagedObject 
{ 
  ManagedPoint ^managedPoint; // ERROR: pas d'utilisation de "handles" 
  UnmanagedPoint *unManagedPoint; // OK: un pointeur vers un objet non managé 
};

Utiliser un objet managé dans un objet non managé avec gcroot

La classe gcroot permet d’utiliser un objet managé dans un objet non managé.

Par exemple, si on définit l’objet:

ref class ManagedPoint  
{  
  private: 
    int innerX; 
 
  public: 
    ManagedPoint(int x): innerX(x) 
    {}; 
 
    void SetX(int newX) 
    { 
      innerX = newX; 
    } 
};

On peut l’utiliser dans un objet non managé avec gcroot:

#include <vcclr.h> 
#include <msclr/auto_gcroot.h> 
using msclr::gcroot; 
 
class UnmanagedObject 
{ 
  private: 
    gcroot<ManagedPoint^> managedPoint; 
 
  public: 
    UnmanagedPoint(int x) 
    { 
      managedPoint = gcnew ManagedPoint(x); // L'initialisation se fait directement 
    } 
 
    void ChangedInnerX(int newX) 
    { 
      managedPoint->SetX(newX); // on peut utiliser directement l'opérateur -> 
    } 
};
Remarques pour optimiser l’utilisation de “gcroot”

gcroot effectue des appels non managés vers du code managés qui peuvent être couteux en performance, il faut donc observer quelques précautions quand on utilise cette classe:

  • Réduire le nombre de membres gcroot et auto_gcroot autant que possible: il est préférable d’avoir un seul objet managé qui contient des membres qu’on voudrait accéder à partir d’une classe non managée. On peut ensuite encapsuler cet objet avec “gcroot” dans la classe non managé. Cette construction est préférable par rapport à l’utilisation de plusieurs membres encapsulés avec gcroot.
  • Eviter d’effectuer trop d’appels à l’opérateur "->" de gcroot.

Passage d’objets managés en argument de fonctions non managées

On peut faire passer un objet managé en argument d’une fonction non managée dans certaines conditions. Par exemple, on peut convertir un “handle” d’un tableau managé de “char” en pointeur d’un tableau de char en fixant (pinning) le tableau managé de façon à fixer son adresse en mémoire.

Par exemple:

void FunctionUsingManagedArray(array<unsigned char> ^bytes) 
{ 
  cli::pin_ptr<unsigned char> pinnedPointer = &(bytes[0]); 
  unsigned char *convertedBytes = static_cast<unsigned char*>(pinnedPointer); 
  // ATTENTION: convertedBytes n'est utilisable que dans le scope de la fonction 
  // ... 
}
L’utilisation de “cli::pin_ptr” est limitée à la fonction

L’utilisation de cli::pin_ptr est limitée au scope de la fonction. Si on utilise le pointeur à l’extérieur de la fonction et si le garbage collector déplace l’objet, le pointeur pourrait pointer vers une mauvaise adresse.

Pour aller plus loin…

Partie 4: Syntaxe détaillée

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page