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

One response... add one

Leave a Reply