Copier un objet en .NET

Florian NOURI

Voici un petit article qui explique comment copier un objet. Nous allons voir dans un premier temps voir ce que l'on entend par « copier un objet », puis nous verrons quelles solutions apporte le Framework .NET pour résoudre ce problème. Avant de poursuivre, nous allons faire un petit rappel sur ce que sont les types références et les types valeurs en .NET.

N'hésitez pas à commenter cet article ! Commentez ♪

- Titre : Copier un objet en .NET

- Auteur : Florian NOURI

- Parution : 15 avril 2009

- Licence : Copyright ® 2009 Nouri Florian. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- Rappel

- I. Copier un objet

- II. Copier un objet « composé »

- II-A. Objet composé de types références

- II-B. La sérialisation

- II-C. Copier un objet composé de types valeurs

- III. Conclusion

Rappel

Dans le Framework .NET il y a deux espaces mémoire distincts pour stocker les objets ; la pile (ou « stack » en anglais) et le tas (ou « heap » en anglais). Pour la suite, il faut retenir ces deux points :

- c'est sur la pile que sont stockés les types valeurs du .NET Framework (c'est-à-dire les structures, les enum et globalement les types qui dérivent de System.Value) ;

- c'est sur le tas que sont stockés les types références du .NET Framework (c'est-à-dire les types qui ne dérivent pas de System.Value). Et sur la pile n'est stocké qu'un pointeur vers l'objet sur le tas.

Exemple : prenons deux types : PersonneRef et PersonneValue. PersonneRef est un type référence et PersonneValue un type valeur. Observons ce qui se passe en mémoire pour expliquer ces différences de comportements entre ces deux types :

PersonneRef personneRef1 = new PersonneRef("Pastorius", "Jaco", new DateTime(1951, 1, 12));
PersonneValue personneVal1 = new PersonneValue("Michael Peter", "Balzary", new DateTime(1952, 10, 16));
PersonneRef personneRef2 = personneRef1;

Peut être résumé comme ceci :

En créant une copie de PersonneRef1, nous ne copions que le pointeur vers l'objet stocké sur le tas et non l'objet lui-même. Ce qui fait qu'en modifiant l'objet via une des deux références (soit personneRef1, soit personneRef2) les deux variables seront affectées :

personneRef2.Prenom = "John Francis Anthony";
Console.WriteLine(personneRef1); // KO Affiche John Francis Anthony Pastorius...
Console.WriteLine(personneRef2); // OK Affiche John Francis Anthony Pastorius...

En revanche avec un type valeur, l'objet est entièrement copié dans la pile :

PersonneRef personneRef1 = new PersonneRef("Pastorius", "Jaco", new DateTime(1951, 1, 12));
PersonneValue personneVal1 = new PersonneValue("Michael Peter", "Balzary", new DateTime(1952, 10, 16));
PersonneRef personneRef2 = personneRef1;
PersonneValue personneVal2 = personneVal1;

Il n'y a aucune « dépendance » entre les deux objets personneVal1 et personneValue2, ce sont deux objets distincts. Ce qui fait que si on modifie soit personneVal1 soit personneVal2, l'autre objet ne sera pas affecté.

Il est à noter toutefois, que certains types références se comportement comme des types valeurs, c'est-à-dire qu'il n'y a plus aucune référence à l'objet copié. System.String ainsi que System.Nullable<T> sont deux exemples de ce type de classe qu'on appelle immuables (ou « immutable » en anglais).

Pour illustrer nos propos :

- nous allons créer une classe Personne ;

- nous allons créer une collection de Personne à l'aide d'une List<Personne>.

I - Copier un objet

Voici la classe Personne, qui nous que nous utiliserons pour illustrer nos exemples :

class Personne
{
private string _nom;
private string _prenom;
private DateTime _dateNaissance;

public DateTime DateNaissance
{
get { return _dateNaissance; }
set { _dateNaissance = value; }
}

public string Prenom
{
get { return _prenom; }
set { _prenom = value; }
}

public string Nom
{
get { return _nom; }
set { _nom = value; }
}

public Personne(string nom, string prenom, DateTime dateNaissance)
{
_nom = nom;
_prenom = prenom;
_dateNaissance = dateNaissance;
}

public override string ToString()
{
string[] s = String.Format("{0} {1} né le {2}",
Prenom,
Nom,
DateNaissance.ToShortDateString()).Split(new char[]{' '},
StringSplitOptions.RemoveEmptyEntries);
return String.Join(" ",s);
}
}

Supposons que nous instancions 1 'Personne' et que nous souhaitions garder une copie de cette instance en mémoire :

// On instancie notre 'Personne'
Personne p = new Personne("Pastorius", "John Francis Anthony", new DateTime(1951, 12, 1));

// On souhaite garder une copie de p
Personne p_bck = p;

// On vérifie si les données sont bien "copiées" dans la variable p_bck
Console.WriteLine(p);
Console.WriteLine(p_bck); // OK, identique à p
Console.WriteLine();

A priori, cela pourrait suffire, mais si on modifie notre personne p :

// On modifie p
p.Prenom = "Jaco";

// On revérifie.
Console.WriteLine(p);
Console.WriteLine(p_bck); // KO, l'objet p_bck a changé de valeur également.

Notre « copie » a également changé de valeur, ce n'est pas du tout ce qu'on souhaitait.
C'est dû au fait que Personne est un type référence. Si Personne avait été un type valeur (une structure), le problème ne se serait pas posé, on aurait eu le résultat escompté :
Pour copier véritablement l'objet (et son état) il faut créer une autre instance :

Personne p = new Personne("Pastorius", "John Francis Anthony", new DateTime(1951, 12, 1));
Personne p_bck = new Personne(p.Nom, p.Prenom, p.DateNaissance);

// On vérifie si les données sont bien "copiées" dans la variable p_bck
Console.WriteLine(p);
Console.WriteLine(p_bck); // OK, identique à p
Console.WriteLine();

//On modifie p
p.Prenom = "Jaco";

// On revérifie.
Console.WriteLine(p);
Console.WriteLine(p_bck); // OK, l'objet p_bck reste une copie

Il est préférable d'implémenter ce code au sein de la classe, en implémentant l'interface ICloneable :

#region ICloneable Members
public object Clone()
{
PersonneCollection personnes_bck = new PersonneCollection();
personnes_bck.AddRange(PersonneClone());
return personnes_bck;
}
#endregion

On peut également utiliser la méthode MemberwiseClone() de la classe Object :

public object Clone()
{
return this.MemberwiseClone();
}

Et de l'utiliser ainsi :

Personne p = new Personne("Pastorius", "John Francis Anthony", new DateTime(1951, 12, 1));
Personne p_bck = (Personne)p.Clone();

// On vérifie si les données sont bien "copiées" dans la variable p_bck
Console.WriteLine(p);
Console.WriteLine(p_bck); // OK, identique à  p
Console.WriteLine();

//On modifie p
p.Prenom = "Jaco";

// On revérifie.
Console.WriteLine(p);
Console.WriteLine(p_bck); // OK, l'objet p_bck reste une copie

MemberwiseClone n'est pas la solution à tous nos problèmes. En effet, il ya deux types de copies :

- la copie dite superficielle (ou Shallow copy en anglais) ;

- la copie dite profonde (ou Deep Copy en anglais).

La copie superficielle « copie » les membres de l'instance en fonction de son type (type valeur ou type référence) c'est-à-dire que si un membre de l'instance est un type valeur, le comportement de la copie superficielle va effectivement copier la valeur dans une nouvelle variable. En revanche, si le membre de l'instance est de type référence, alors il sera de copier uniquement la référence et non la valeur du membre elle-même (voir rappels).

La copie profonde ne va pas se contenter de copier les références, mais va créer des copies des valeurs de ces références.

Nous avons vu comment copier un objet simple de façon permanente. Qu'en est-il des objets composés ? Nous verrons que dans beaucoup de cas, la copie superficielle ne suffira pas.

II - Copier un objet « composé »

Un objet composé est un objet composé d'un ou plusieurs autres objets. Les collections sont un bon exemple d'objet composé.

Nous allons voir le comportement d'objets composés de type référence, puis le comportement d'objets de type valeur.

II-A - Objet composé de types références

On va créer trois instances de la classe Personne, puis les ajouter dans une liste de Personne :

Personne jaco = new Personne("Pastorius", "John Francis Anthony", new DateTime(1951, 12, 1));
Personne marcus = new Personne("Miller", "Marcus", new DateTime(1959, 6, 14));
Personne victor = new Personne("Wooten", "Victor Lemonte", new DateTime(1964, 11, 8));

List<Personne> personnes = new List<Personne>();
personnes.Add(jaco);
personnes.Add(marcus);
personnes.Add(victor);

Console.WriteLine("Liste initiale :");
AfficherPersonnes(personnes);

affiche :

Nous allons maintenant modifier la liste. La modification consistera en l'addition d'une Personne dans la liste (notez que chaque fois que l'on modifie un objet, on change son état) :

//...
personnes.Add(new Personne(string.Empty, "Flea", new DateTime(1962, 10, 16)));
Console.WriteLine("Liste modifiée :");
AfficherPersonnes(personnes);

Que faire si on veut retrouver notre liste initiale ? Si on crée une autre liste à partir de celle-là, nous n'obtiendrons pas le résultat escompté, car nous copierons uniquement la référence de la liste (et non la liste elle-même) :

Personne jaco = new Personne("Pastorius", "John Francis Anthony", new DateTime(1951, 12, 1));
Personne marcus = new Personne("Miller", "Marcus", new DateTime(1959, 6, 14));
Personne victor = new Personne("Wooten", "Victor Lemonte", new DateTime(1964, 11, 8));

List<Personne> personnes = new List<Personne>();
personnes.Add(jaco);
personnes.Add(marcus);
personnes.Add(victor);

// On fait une "copie" de la liste initiale
List<Personne> copie_personnes = personnes;

// On affiche la liste initiale
Console.WriteLine("Liste initiale :");
AfficherPersonnes(personnes);

// On modifie la liste initiale en ajoutant un élément
personnes.Add(new Personne(string.Empty, "Flea", new DateTime(1962, 10, 16)));

// On affiche la liste modifiée
Console.WriteLine("Liste modifiée :");
AfficherPersonnes(personnes); // OK, la liste est bien modifiée

// On affiche la copie de la liste initiale
Console.WriteLine("Copie de la liste initiale :");
AfficherPersonnes(copie_personnes); // KO, la liste affichée n'est pas la copie de la liste initiale.

affiche :

Le moyen de contourner ceci est de copier tous les items de la collection (et non copier la référence à la collection) via la méthode CopyTo. Pour garder la liste initiale, il faut donc remplacer ceci :

List<Personne> copie_personnes = personnes;

par :

Personne[] personnesArray = new Personne[personnes.Count];
personnes.CopyTo(personnesArray);
List<Personne> copie_personnes = new List<Personne>(personnesArray);

Note : le code ci-dessus est écrit de façon à insister sur le comportement de la copie via la méthode CopyTo de la classe Array. Il se trouve qu'il existe une sur surcharge du constructeur List<T> qui fait exactement la même chose. le code suivant fera donc la même chose et est plus lisible :

List<Personne> copie_personnes = new List<Personne>(personnes); // Copie les éléments de la liste 'personnes'

Ainsi on se retrouve bien avec une vraie copie de la liste :

En revanche, si on modifie un ou des items de la collection :

List<Personne> personnes = new List<Personne>();
personnes.Add(jaco);
personnes.Add(marcus);
personnes.Add(victor);

List<Personne> copie_personnes = new List<Personne>(personnes);
jaco.Prenom = "Jaco"; // On modifie un élément de la liste.
//...

Toujours dû aux références, si on modifie un objet stocké dans la collection , il sera modifié partout… Or nous souhaitons avoir une copie exacte et fiable de notre liste initiale. Il existe plusieurs méthodes pour cela, dont celle-ci : la sérialisation.

II-B - La sérialisation

La sérialisation est le processus qui permet de convertir et stocker un objet en une séquence d'octets. La désérialisation est le processus inverse qui convertit une séquence d'octets en un objet. Cette séquence d'octets peut être stockée n'importe où (en mémoire ou dans un fichier).

Le gros inconvénient de cette méthode est qu'il faut que la classe métier soit « sérialisable », c'est-à-dire que si elle ne l'est pas et que vous n'avez pas la main sur cette classe, il faudra passer par une autre méthode.

Si vous avez accès au code de la classe en question (ici la classe Personne), il suffit d'ajouter l'attribut [Serializable] et le tour est joué :

namespace CopyCollectionObjects
{
[Serializable]
class Personne
{
private string _nom;
//...

On va utiliser un MemoryStream dans lequel on va stocker notre collection sérialisée, puis on va stocker notre collection sérialisée dans un tableau de bytes. Quand on aura besoin de retrouver notre collection initiale, il suffira de désérialiser le tableau d'octets dans un MemoryStream et de le caster dans la collection.

On ajoute un tableau d'octets :

static void Main(string[] args)
{
Personne jaco = new Personne("Pastorius", "John Francis Anthony", new DateTime(1951, 12, 1));
Personne marcus = new Personne("Miller", "Marcus", new DateTime(1959, 6, 14));
Personne victor = new Personne("Wooten", "Victor Lemonte", new DateTime(1964, 11, 8));
byte[] personnes_bck = null;
BinaryFormatter formatter = new BinaryFormatter();
//...

À noter que la classe BinaryFormatter se trouve dans l'espace de nom System.Runtime.Serialization.Formatters.Binary.

C'est le BinaryFormatter qui se charge de tout pour la sérialisation et la désérialisation.

Ensuite, on sérialise la List<Personne> en mémoire comme ceci :

using (MemoryStream stream = new MemoryStream())
{
formatter.Serialize(stream, personnes);
personnes_bck = stream.ToArray(); // On stocke la collection sérialisée dans un tableau d'octets
}

Et voilà. Maintenant si on veut récupérer la liste initiale il faut :

List<Personne> liste_personnes_initiale = null;
using (MemoryStream stream = new MemoryStream(personnes_bck))
{
liste_personnes_initiale = (List<Personne>)formatter.Deserialize(stream);
}

Attention à ne pas oublier de renseigner le tableau d'octets à l'instanciation du memorystream !

L'avantage de cette méthode est qu'elle est relativement simple à mettre en œuvre (étant donné que c'est le framework qui se charge de tout au niveau de la sérialisation et de la désérialisation). L'autre avantage énorme c'est que le binaryformatter accepte en paramètre un objet, on peut donc sérialiser n'importe quel type. On peut y stocker un graphe d'objets et non un objet seulement : si un objet est composé d'un autre objet (par exemple une classe société a une propriété List<Employe> Employes). Il faut cependant que les objets du graphe en question aient l'attribut [Serializable].

On peut ajouter ce comportement de sauvegarde d'état en implémentant l'interface ICloneable à notre objet à sérialiser :

[Serializable]
class Personne : ICloneable
{
#region ICloneable Members
public object Clone()
{
BinaryFormatter formatter = new BinaryFormatter();
object personnes_bck = null;
using (MemoryStream stream = new MemoryStream())
{
formatter.Serialize(stream, this);
stream.Position = 0;
personnes_bck = formatter.Deserialize(stream);
}
}
#endregion
}

On peut ainsi l'utiliser plus facilement :

//...
PersonneCollection personnes_bck = (PersonneCollection)personnes.Clone();
//...

II-C - Copier un objet composé de types valeurs

Pour illustrer cette partie, on va simplement modifier la classe Personne en une structure Personne (ce qui revient à remplacer le mot-clé class par struct), on aura donc bien un objet (une liste) composé d'objets de type valeur (Personne). De ce fait, on n'aura plus le même type de comportement. En effet les structures étant par définition des types valeurs, les données sont directement créées dans la pile ainsi :

On remplace la classe par une structure pour la définition de Personne :

namespace CopyCollectionObjects
{
[Serializable]
struct Personne
{
//...

Si on refait les mêmes tests qu'au départ, que se passe-t-il ? Commençons par créer notre liste initiale :

Personne jaco = new Personne("Pastorius", "John Francis Anthony", new DateTime(1951, 12, 1));
Personne marcus = new Personne("Miller", "Marcus", new DateTime(1959, 6, 14));
Personne victor = new Personne("Wooten", "Victor Lemonte", new DateTime(1964, 11, 8));

List<Personne> personnes = new List<Personne>();
personnes.Add(jaco);
personnes.Add(marcus);
personnes.Add(victor);

Console.WriteLine("Liste initiale :");
AfficherPersonnes(personnes);

affiche :

Pour l'instant le code est strictement identique qu'avec une classe un type référence et le résultat aussi. Maintenant, modifions notre liste :

Maintenant si on veut récupérer la liste initiale ? Peut-on écrire ceci :

List<Personne> personnes_bck = personnes;

eh bien non, car si les éléments de la liste sont de type valeur, la liste elle est bien un type référence. Le résultat est donc identique. On ne copie pas la liste, mais uniquement sa référence.
en revanche en faisant :

List<Personne> personnes_bck = new List<Personne>(personnes);

On récupère bien la liste initiale. Essayons maintenant de modifier un élément de la liste :

Personne jaco = new Personne("Pastorius", "John Francis Anthony", new DateTime(1951, 12, 1));
Personne marcus = new Personne("Miller", "Marcus", new DateTime(1959, 6, 14));
Personne victor = new Personne("Wooten", "Victor Lemonte", new DateTime(1964, 11, 8));

List<Personne> personnes = new List<Personne>();
personnes.Add(jaco);
personnes.Add(marcus);
personnes.Add(victor);

List<Personne> personnes_bck = new List<Personne>(personnes);

List<Personne> personnes_bck = new List<Personne>(personnes);

Console.WriteLine("Liste initiale :");
AfficherPersonnes(personnes);

personnes.Add(new Personne(string.Empty, "Flea", new DateTime(1962, 10, 16)));

Console.WriteLine("Liste modifiée :");
AfficherPersonnes(personnes);

Console.WriteLine("Copie de la liste initiale :");
AfficherPersonnes(personnes_bck);

affiche :

Il faut noter deux choses : d'une part la « copie de la liste initiale » est strictement identique à la « liste initiale ». C'est le comportement que nous attendions. En revanche dans la « liste modifiée » le prenom n'a pas été modifié bien que j'ai ajouté cette instruction :

jaco.Prenom = "jaco";

En effet ce n'est pas l'objet jaco qui est dans la liste, mais une copie de l'objet. Donc si on veut modifier l'élément dans la liste, il faudra faire ça :

//...
int index = personnes.IndexOf(jaco); // On récupère l'index de l'objet.
Personne jacoTmp = personnes[index]; // On crée une copie de l'objet que l'on souhaite modifier.
jacoTmp.Prenom = "Jaco";             // On modifie la copie de l'objet.
personnes.RemoveAt(index);           // On supprime l'objet que l'on souhaite modifier de la collection.
personnes.Insert(index, jacoTmp);    // Puis on insère la copie de l'objet dans la collection.
//...

On doit créer une copie de l'objet dans la collection, modifier cette copie, supprimer l'objet que l'on souhaitait dans la collection, puis insérer la copie de l'objet dans la collection.
Affiche

III - Conclusion

Copier un objet est beaucoup moins trivial qu'il n'y parait. Si vous avez accès au code source de la classe, pensez à la sérialisation. En effet cette puissante fonctionnalité est peu coûteuse à implémenter en termes de temps. Elle permettra d'implémenter une méthode capable de copier le graphe de votre objet (Deep copy) et ce, simplement. Si vous n'avez pas accès au code source de votre classe, alors il vous faudra implémenter vous-même la méthode pour copier le graphe de votre objet via une classe helper par exemple.