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:

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:

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++:

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ées 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 managée vers une chaine non managée
En utilisant la méthode:

static void ClrStringToStdString(String ^str, std::string &outStr) 
{ 
    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

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

C++/CLI en 10 min, partie 2: Caractéristiques générales

Le langage C++/CLI est un langage qui permet de manipuler à la fois des objets managés et non managés. Cette caractéristique rend ce langage plus complexe à implémenter car il rend la plupart des mécanismes d’interoperabilité invisibles entre le code managé et le code non managé. Le fait d’ignorer ces mécanismes peut mener à des exécutions moins performantes. Cette partie liste la plupart de ces mécanismes.

Mise en oeuvre pour coder en C++/CLI

Outils nécessaires pour coder en C++/CLI

Pour implémenter en C++/CLI, il faut une version de Visual Studio avec les options d’installation du C++, par exemple Visual Studio Community 2015: https://www.visualstudio.com/.

Chaque version de Visual Studio fournit une version du compilateur C++ et du runtime C/C++. Les versions du runtime et du compilateur sont les mêmes que celles de Visual Studio. Par exemple, Visual Studio 2015 permet de produire des assemblies qui seront compatibles avec le runtime C/C++ VS140. Ce runtime est fournit avec Microsoft Visual C++ 2015 Redistributable.

Comment exécuter une assembly C++/CLI ?

Toutes les assemblies codées en C++/CLI ne nécessitent pas forcément un runtime C/C++ (voir détails des options de compilation). Toutefois, quand on souhaite déployer une assembly codée en C++/CLI qui nécessite un runtime C/C++, il faut au moins que la version du “redistributable C++” correspondant à la version du runtime soit installée sur le poste client.

Dans le cas où le “redistributable C++” n’est pas installé ou si la bonne version n’est pas installée, à l’exécution on aura une erreur du type:

"The program can't start because MSVCRXXX.dll is missing from your computer. 
Try reinstalling the program to fix the problem"

Dans le nom de la DLL MSVCRXXX.dll, XXX étant la version du runtime:

  • 100 pour Visual C++ 2010
  • 110 pour Visual C++ 2012
  • 120 pour Visual C++ 2013
  • 140 pour Visual C++ 2015 etc…

Comment vérifier si le redistributable C++ est installé ?

Pour vérifier que le “redistributable C++” est installée, il faut vérifier la présence des clés de registre suivante:

Runtime Version Architecture Clé de registre
Visual C++ 2010 Redistributable 10.0.40219.325 x86 HKLM\SOFTWARE\Classes\Installer\Products\1D5E3C0FEDA1E123187686FED06E995
x64 HKLM\SOFTWARE\Classes\Installer\Products\1926E8D15D0BCE53481466615F760A7F
Visual C++ 2012 Redistributable 11.0.61030.0 x86 HKLM\SOFTWARE\Classes\Installer\Dependencies\{33d1fd90-4274-48a1-9bc1-97e33d9c2d6f}
x64 HKLM\SOFTWARE\Classes\Installer\Dependencies\{ca67548a-5ebe-413a-b50c-4b9ceb6d66c6}
Visual C++ 2013 Redistributable 12.0.30501.0 x86 HKLM\SOFTWARE\Classes\Installer\Dependencies\{f65db027-aff3-4070-886a-0d87064aabb1}
x64 HKLM\SOFTWARE\Classes\Installer\Dependencies\{050d4fc8-5d48-4b8f-8972-47c82c46020f}
Visual C++ 2015 Redistributable 14.0.24212.0 x86 HKLM\SOFTWARE\Classes\Installer\Dependencies\{462f63a8-6347-4894-a1b3-dbfe3a4c981d}
x64 HKLM\SOFTWARE\Classes\Installer\Dependencies\{323dad84-0974-4d90-a1c1-e006c7fdbb7d}

Options de compilation /clr

Dans un projet C++, pour implémenter en C++/CLI, il faut ajouter une option de compilation /clr. Cette option posséde quelques variantes:

  • /clr: cette option permet de produire une assembly mixte contenant du code C++ natif et du code managé. L’assembly produite contient des metadatas qui lui permettent d’être consommée par une autre assembly managée.
  • /clr:pure: l’assembly produite est très proche d’une assembly C# car elle ne contient que du code managé sous forme de code MSIL. Une assembly de ce type ne contient pas de code natif.
  • /clr:safe: de même que /clr:pure, l’assembly produite ne contient pas de code natif mais seulement du code MSIL. L’assembly produite est vérifiable c’est-à-dire que le CLR peut vérifier à l’exécution qu’elle ne viole aucun critères de sécurité comme pour une assembly du CLR. A la différence des assemblies /clr:pure, une assembly de ce type est compatible avec tes les autres types d’assembly C++/CLI. Le gros intérêt des assemblies de ce type est qu’elle ne dépendent pas du tout du CRT (C/C++ Runtime).
  • Sans option /clr: pas d’utilisation du CLR, la DLL produite ne contient que du code natif et est exécutée par le CRT (C/C++ Runtime).

Détails des options de compilation

Toutes ces options ne sont pas forcément compatibles entre elles.

/clr:safe:

  • Les assemblies de ce type sont compatibles avec toutes les autres, c’est-à-dire qu’elles peuvent consommer tous les autres types d’assembly ou de DLL.
  • Les assemblies /clr:safe ne dépendent pas du runtime C/C++ mais seulement du CLR.
  • Du code C++ natif avec cette option de compilation ne peut être compilé dans une assembly managée et inversement le code source ne peut utiliser du code natif.
  • Cette option permet une compilation statique (option /MT ou /MTd).

/clr:

  • Les assemblies de ce type sont compatibles avec tous les autres types d’assembly à l’exception des assemblies /clr:pure.
  • C’est l’option la plus intéressante pour contenir du code mixte (code managé et code non managé) car le code C++ peut être compilé dans une assembly managée et du code managé peut utiliser des types natifs.
  • Certains types exposés dans une assembly de ce type peuvent être consommés par du code natif.
  • Cette option ne permet pas le chargement d’assembly dynamique avec System::Reflection::Assembly::Load() ou System::Reflection::Assembly::LoadFrom().
  • Cette option est incompatible avec la compilation statique (option /MT ou /MTd), les assemblies de ce type ne peuvent consommer que des assemblies managées ou des DLL dépendant seulement du CRT.
  • Les assemblies de ce type dépendent du CLR et des DLL du runtime C/C++.

/clr:pure:

  • Les assemblies de ce type sont compatibles seulement avec d’autres assembles /clr:pure et /clr:safe.
  • Les types sont chargés à la demande comme du code managé .NET classique.
  • Du code C++ natif peut être compilé dans une assembly managée et le code source peut utiliser des types natifs.
  • Les assemblies de ce type dépendent du CLR et des DLL du runtime C/C++.
  • Cette option est incompatible avec la compilation statique (option /MT ou /MTd).

Détails des options d’un projet C++/CLI

Pour créer un projet C++/CLI, il faut préciser quelques paramètres dans les propriétés du projet.

Runtime Library

Dans “Configuration Properties” -> “C/C++”: il faut choisir une option compatible avec l’option /clr choisie. La compilation statique (c’est-à-dire les options /MT et /MTd) sont compatibles seulement avec /clr:safe. Avec les options /clr et /clr:pure, on ne peut utiliser que les options /MD et /MDd pour produire une DLL dépendante du CRT.

Common Language Runtime Support

C’est l’option la plus importante dans “Configuration Properties” -> “General”:

  • “No Common Language Runtime Support”: cette option correspond à l’absence d’option /clr, elle permet de produire une DLL C++ native.
  • “Common Language Runtime Support (/clr)”: permet de produire une assembly /clr.
  • “Pure MSIL Common Language Runtime Support (/clr:pure)”: permet de produire une assembly /clr:pure.
  • “Safe MSIL Common Language Runtime Support (/clr:safe)”: permet de produire une assembly /clr:safe.

2e fichier d’en-têtes précompilés

Pour compiler un fichier d’en-tête précompilé avec du code managé, il faut utiliser une 2e fichier en plus du fichier “stdafx.pch” compilé sans options /clr. Il faut appeler ce fichier “stdafx_clr.cpp” et ajouter dans ce fichier:
#include “stdafx.h”

D’autres options dans les propriétés du projet sont nécessaires:
Dans C/C++ -> Precompiled Headers:

  • “Create/Use precompiled headers”: affecter la valeur “Create precompiled header /Yc”
  • “Precompiled header file”: affecter “$(IntDir)\$(TargetName)_clr.pch”
  • “Create/Use PCH Through file”: affecter “stdafx.h”

Dans C/C++ -> General:
“Compile with CLR Support”: affecter “Common Language Runtime Support /clr”.

Dans C/C++ -> Code Generation:

  • “Basic Runtime Checks”: affecter “Default”
  • “Enable Minimal Rebuild”: affecter “No”.
  • “Enable C++ Exceptions”: affecter “Yes with SHE exceptions /EHa”.

Transitions entre des objets managés et des objets non managés

On pourrait penser que les appels managés-non managés sont anodins car ils sont faciles à effectuer en C++/CLI. Pourtant même si la syntaxe est parfois simple, le code généré effectue des appels managés-non managés qui peuvent être couteux en performance.

Thunks

Ainsi, en fonction de ce qui est implémenté le compilateur et le linker génère des metadatas qui contiennent des informations d’interopérabilité dans l’assembly générée. A l’exécution, le CLR lira ses informations et les utilisera pour générer des “thunks” quand c’est nécessaire. Dans un cadre différent, le compilateur peut produire aussi directement des “thunks” qui seront chargés directement au démarrage.

Ainsi:

  • Pour les appels de code managé vers du code non managé: les “thunks” sont générés le plus souvent par le compilateur JIT à la demande.
  • Pour les appels de code non managé vers du code managé: les “thunks” sont générés et chargés au démarrage.

Les “thunks” sont routines générés automatiquement dans du code natif ou dans du code MSIL suivant les besoins et le type d’appels. Ils font souvent appels à plusieurs autres instructions et peuvent être la source de pertes en performances s’ils sont utilisés trop souvent durant l’exécution. D’une façon générale, il faut éviter au maximum les transitions entre objets managés et objets non managés de façon à réduire l’utilisation de ces “thunks”.

Certaines transitions managées-non managées ne sont pas évidentes, par exemple quand du code C++ natif est compilé dans une assembly managée, le code produit n’est pas managé mais il reste natif. Ainsi les appels qui sont effectués par ce code natif vers d’autres objets managés entraîneront des transitions managées-non managées.

Convention d’appels

L’utilisation de “thunk” est très dépendante des conventions d’appels utilisées:

  • __cdecl: convention d’appels généralement utilisé en C/C++ et qui peut être utilisé pour des fonctions membres d’une classe managée lorsqu’elle comporte des arguments non managés.
  • __stdcall: convention d’appels utilisée en C/C++ pouvant aussi être utilisée pour des fonctions membres d’une classe managée lorsqu’elle comporte des arguments non managés.
  • __thiscall: convention d’appels utilisée en C/C++ pour les appels de membres d’une classe. Elle peut aussi être utilisé pour des fonctions membres d’une classe managée et pour des fonctions membres non-statiques avec des arguments non managés.
  • __clrcall: convention d’appels utilisée pour des fonctions membres de classe managée en particulier lorsque les arguments des fonctions sont purement managés.

Ainsi si une fonction managée utilise une convention d’appels natives, elle peut éventuellement être appelée par des fonctions natives. Si c’est le cas, un “thunk” sera généré et utilisé dans le cas d’un appel par une fonction native. Si le compilateur ne constate pas d’appels par des fonctions natives, le “thunk” ne sera pas généré.

De même, si une classe native compilée en code managé et qu’elle utilise des conventions d’appels C/C++ comme __thiscall” ou __stdcall, même des appels provenant des codes natifs se produiront en utilisant des “thunks”.

Platform Invoke

Hormis le cas d’appels avec un pointeur de fonction, les appels de code managés vers du code non managés sont effectués en utilisant le mécanisme de Platform Invoke. Ce mécanisme est très semblable à celui qui existe pour des appels de code C# vers des fonctions dans des DLL natives. Le compilateur génére lui-même les metadatas pour effectuer les appels P/Invoke. Ce type d’appel est couteux en performance même si elles sont généralement meilleures que lorsqu’elles sont générés à la main par le dévelopeur.

Pointeur de fonction et fonction virtuelle

Lorsque du code managé appelle une fonction compilée dans du code natif en utilisant un pointeur de fonction, le CLR passe par l’intermédiaire d’un “thunk”. Ce “thunk” contient les arguments de la fonction et la convention d’appel et est généré directement par le compilateur sans faire appel à des instructions P/Invoke.
Le même procédé est utilisé dans le cas d’appels d’une fonction virtuelle codée dans une classe native par du code managé.

Enfin le cas le plus défavorable est le cas où des fonctions virtuelles d’une classe managée utilisent des conventions d’appels natives (par exemple __thiscall ou __stdcall), même si l’appel provient d’une fonction managée, il passera par l’intermédiaire de plusieurs “thunks” pour obtenir l’adresse de la fonction à exécuter.

C++/CLI en 10 min

Le C++/CLI (CLI pour Common Language Infrastructure) est une technologie Microsoft qui permet de manipuler et d’appeler du code natif en C ou en C++ à partir de code managé .NET.
Il s’agit d’un langage qui mélange C++ et technologie .NET.

Il peut arriver dans un projet, qu’on doivent faire cohabiter du code non managé et du code managé car:
Il peut être nécessaire d’effectuer des appels à une API native pour laquelle il n’existe pas d’implémentation en .NET.
On peut aussi avoir une grande quantité de code natif historique et qu’il est trop couteux de le migrer entièrement en .NET.
On a effectué le choix d’implémenter certains traitements en code natif pour des besoins en performance. Le reste du code, étant moins soumis à une contrainte de performance peut être implémenté en C#.

Le choix d’utiliser le C++/CLI pour appeler du code non managé à partir de code managé peut être judicieux car on profite du confort d’un langage managé .NET associée à la flexibilité, parfois recherchée, du langage C++. D’autre part, il est souvent la solution la plus efficace si on recherche de bonnes performances d’exécution.

Il existe d’autres méthodes pour appeler du code non managé à partir de code managé:

  • COM interop: cette technique convient si on doit faire appel à des composants COM.
  • Platform invoke: cette méthode est la plus adaptée lorsque le code managé ne change pas beaucoup ou que l’interface entre le code managé et le code non managé est assez figé. Elle peut facilement provoquer des erreurs toutefois, elle est généralement rapide à mettre en place. Plus de détails sur ce type d’appel dans Platform invoke en 5 min.

Les inconvénients d’utiliser le C++/CLI sont:

  • Le langage ne met pas l’abri d’erreurs de programmation liée à la gestion de la mémoire lorsqu’on écrit du code C++ natif. On peut toutefois réduire au maximum la manipulation d’objets C++ au profit d’objets managés.
  • Le C++/CLI nécessite l’apprentissage d’un langage supplémentaire car il est différent du C++ pur.
  • Le langage ne permet pas d’utiliser toutes les fonctionnalités .NET comme en C#. Par exemple, les expressions lambdas ne sont pas implémentables en C++/CLI ce qui peut rendre plus compliqué l’utilisation de Task Parallel Library (TPL) ou Moq (pour implémenter des tests unitaires).
Pour commencer

Partie 1: Rappels C++

C++/CLI en 10 min, partie 1: Rappels C++

Avant de rentrer dans les détails du C++/CLI, quelques rappels sont effectués sur la manipulation des objets en .NET et en C++. Si vous êtes à l’aise avec le C++, allez directement à la partie suivante.

Généralités sur les pointeurs et les références

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

Un pointeur est une variable contenant une adresse mémoire. Cette adresse peut pointer vers n’importe quel objet en mémoire, y compris vers des objets dont le type est différent. Le pointeur n’a pas connaissance du type de l’objet vers lequel il pointe.

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

En C#, en dehors des objets de type valeur, on manipule des références. Dans un contexte purement managé, on ne manipule jamais directement des pointeurs.
En C++, on peut manipuler les pointeurs définis avec "*" et les références définies avec "&".

Par exemple, on déclare un pointeur:

int *customPointer; 
Point *pointPointer; 

Une référence se déclare avec "&":

int &customPointer = ... ; 
Point &pointReference = ... ;

On peut instancier ces objets:

int value = 5; 
int &valueRef = value; 

Point *pointPointer = new Point(2, 7); 

L’utilisation du mot clé new nécessite d’utiliser delete pour supprimer l’objet:

delete pointPointer; 

Plus directement:

CustomObject &pointReference = Point(7, 3); 

Dans le cas de constructeur par défaut:

Point point; // le constructeur par défaut est appelé 
Point point(); // le constructeur par défaut n'est pas appelé. 

Opérateur “address-of” &

En C++, l’opérateur “&” permet d’obtenir l’adresse d’un objet:


int value1 = 5; 
int *valuePointer = &value1;

Opérateur de déférence “*”

L’opérateur de déférence (deference operator) permet d’obtenir l’objet pointé par un pointeur. Si on écrit *point, on souhaite accéder à l’objet pointé par la variable point.

Par exemple:

int value = 65; 
int *valuePointer; // déclaration 
valuePointer = &value; // initialisation du pointeur 
*valuePointer = 54; // on affecte 54 à l'objet pointé par le pointeur donc on affecte 54 à value 

Objets de type valeur

En C#, les objets de type valeur correspondent au struct et au enum. La plupart des types primitifs sont des struct et donc sont des objets de type valeur:

  • Les types intégraux: sbyte (signed byte), byte, char, short, ushort (unsigned short), int, uint (unsigned int), long et ulong (unsigned long).
  • Les types à virgule flottante: decimal, float et double (voir nombres à virgule flottante en C# pour plus de détails)
  • Le booléen bool.

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

Par défaut, en C# les objets de type valeur sont passés en argument de fonction par valeur. La valeur de l’objet est copiée et l’objet est dupliqué.

On peut passer des objets de type valeur par référence en utilisant les mots clé ref et out:

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

Objets de type référence

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

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

On peut passer des objets de type référence par référence en utilisant les mots clé ref et out avec les mêmes conditions que pour les objets de type valeur.

Objets en C++

En C++, il n’y a pas de distinctions entre objets de type valeur et d’objets de type référence. Par défaut tous les objets sont passés par valeur en paramètre de fonction.

En C++, les struct sont comme les “classes”, la différence concerne la portée par défaut des membres (c’est-à-dire s’il n’y a pas d’opérateur de portée devant le membre):

  • Les membres des struct sont par défaut publiques
  • Les membres des class sont par défaut privés.

Passage d’arguments par valeur

Sans indications, les objets sont passés en argument de fonction ou sont instanciés sur la pile. Par exemple, si on définit une classe:

class Point 
{ 
    private: 
        int x, y; 
    public: 
        Point(int x, int y) 
        { 
            this->x = x; 
            this->y = y; 
        } 
};

Et si on instancie la classe de cette façon:

Point point1(9, 3);

Par défaut, Point est un objet de type valeur. Elle est allouée sur la pile (stack) et sa durée de vie se limite à la fonction dans laquelle elle est définie.

Dans un passage d’objets en argument de fonctions par valeur, la valeur de l’objet est copiée et l’objet est dupliqué. On limite le passage par valeur au type simple comme int, float, bool, char, double, wchar_t, etc…

Par exemple, si on exécute:

#include <iostream> 
using namespace std; 
  
// function declaration 
void Swap(int a, int b) 
{ 
    int c = a; 
    a = b; 
    b = c; 
} 
 
int main () 
{ 
   int a = 100; 
   int b = 200; 
  
   cout << "Avant a = " << a << endl; 
   cout << "Avant b = " << b << endl; 
  
   Swap(a, b); 
  
   cout << "Apres a = " << a << endl; 
   cout << "Apres b = " << b << endl; 
  
   return 0; 
}

L’exécution donnera:

Avant a = 1 
Avant b = 2 
Apres a = 1 
Apres b = 2

Pour des classes, on peut passer des objets en argument de fonction par valeur mais il faut qu’un constructeur de copie (copy constructor) soit implémenté:

class Point 
{ 
    private: 
        int *x_value, *y_value; 
    public: 
        Point(int x, int y) 
        { 
            x_value = new int; 
            y_value = new int; 
            *x_value = x; 
            *y_value = y; 
        } 
 
 
        Point(const Point &obj) 
        { 
            x_value = new int; 
            y_value = new int; 
            *x_value = obj->x_value; 
            *y_value = obj->y_value; 
        } 
};

Passage d’arguments par référence

Pour passer des objets en argument de fonction par référence, on utilise l’opérateur "&". Par exemple, si on définit la méthode:

void Swap(int &a, int &b) 
{ 
    int c = a; 
    a = b; 
    b = c; 
}

En exécutant la méthode précédente, on aura bien une inversion des valeurs:

Avant a = 1 
Avant b = 2 
Apres a = 2 
Apres b = 1

Allocation des objets en mémoire

En C#, c’est le type des objets qui indique où ils seront alloués:
Les objets de type valeur sont alloués dans la pile (stack). Leur durée de vie se limite aux fonctions dans lesquelles ils sont définis.
Les objets de type référence sont alloués dans le tas managé (managed heap). C’est le garbage collector qui gère la destruction de ces objets.

En C++, on a plus de flexibilité pour choisir où un objet sera alloué suivant la façon dont on le déclare.

Si on déclare un objet sur la pile avec une déclaration du type:

Point point(5, 8); 
int intValue = 32;

Comme en C#, leur durée de vie se limite aux fonctions dans lesquelles ils sont définis.

Par exemple, si on définit la classe:

class PointConsumer 
{ 
    private: 
        Point *pointAsMember; 
    public: 
        void DefinePoint() 
        { 
            Point point(9, 23); 
            this->pointAsMember = &point; 
        } 
 
        void UsePointAsMember() 
        { 
            cout << "pointAsMember->x = " << this->pointAsMember->x << endl; 
            cout << "pointAsMember->y = " << this->pointAsMember->y << endl; 
        } 
};

Et si on exécute les méthodes:

PointConsumer pointConsumer; 
pointConsumer.DefinePoint(); 
pointConsumer.UsePointAsMember();

On aura une erreur de type:

An unhandled exception of type "System.AccessViolationException" occurred in ....dll 
Additional information: Attempted to read or write protected memory. 
This is often an indication that other memory is corrupt.

Cette erreur est due au fait que la variable point est alloué sur la pile dans la méthode DefinePoint(). Même si on garde un pointeur sur cet objet, lorsqu’on quitte DefinePoint(), point est supprimée de la pile à la sortie de la fonction. Quand on essaie d’utiliser le pointeur pointAsMember dans UsePointAsMember(), il ne pointe plus vers une instance de l’objet en mémoire puisqu’il en a été supprimé.

En C++, pour déclarer un objet sur le tas (heap), on utilise le mot clé new. L’objet restera dans le tas tant qu’on ne le supprime pas en utilisant le mot clé delete.

Par exemple:

Point *pointInstance = new Point(5, 32);