ICloneable, le faux-ami

Lorsqu'on commence à s'intéresser à la manière dont on doit copier des objets en .Net, on tombe rapidement sur des conseils vous expliquant bien gentiment que pour cela, il faut que les classes que vous voulez copier implémentent l'interface ICloneable. Le programmeur consciencieux commencera donc par se diriger sur la page MSDN de ladite interface, et y verra cette description:

Supports cloning, which creates a new instance of a class with the same value as an existing instance.

Cette interface n'a qu'un seul membre, qui est une fonction appelée Clone, dont voici la description:

Creates a new object that is a copy of the current instance.

Assez clair comme description me direz-vous. Où est donc le faux-ami ici? Il suffit de regarder la rubrique des remarques pour voir que quelque chose cloche:

Clone can be implemented either as a deep copy or a shallow copy. In a deep copy, all objects are duplicated; whereas, in a shallow copy, only the top-level objects are duplicated and the lower levels contain references.

On nous parle ici de deep copy et de shallow copy. Sans même savoir ce que sont ces deux types de copie, on est déjà interpellé. On aurait une seule fonction qui pourrait avoir 2 comportements différents? Voilà qui n'est pas très explicite. Et c'est bien là le souci avec cette interface. Car on ne spécifie pas de manière claire lorsqu'on utilise cette interface de quelle manière seront copiés les objets.

Que faire donc? La tendance au sein des programmeurs .net est de définir 2 interfaces qui lèvent cette ambiguïté: par exemple une interface DeepCopy et une interface ShallowCopy avec chacune une fonction DeepCopy et ShallowCopy, par exemple. Et on pourra également en faire des interfaces génériques pour éviter de retourner Object, et ainsi s'affranchir de conversions de types aussi disgracieuses que dangereuses.

Puisque nous venons de parler de Deep Copy et de Shallow Copy, prenons quelques instants pour expliquer la différence entre les 2.

Shallow Copy et Deep Copy

Une shallow copy est une copie qui va copier tous les membres non-statiques d'une instance vers sa copie. Tous les membres qui sont de type valeur vont être copiés bit à bit, alors que les membres qui sont de type référence vont avoir uniquement leur référence copiée: l'objet source et l'objet copié pointent donc vers le même objet.

Comme vous pouvez le voir sur le petit schéma d'exemple, l'objet Copy a ses propres valeurs i' et s', mais pointe vers la même référence object.

ShallowCopy.png

A contrario, une deep copy va copier bit à bit tous les membres de type valeur, et va créer une copie des membres de type référence (donc une nouvelle instance) que la copie de l'objet va référencer: l'objet source et l'objet copié vont donc avoir chacun une instance différente de l'objet.

Sur ce schéma, le principe reste le même pour les types valeur, alors que la référence object est elle aussi copiée pour donner une nouvelle instance object' vers laquelle pointe l'objet Copy.

DeepCopy.png

Maintenant que les 2 types de copie possibles ont été expliqué, voyons comment les réaliser.

Copie superficielle

Pour implémenter une shallow copy, c'est très simple, le framework .net venant déjà avec l'outillage adéquat, résumé en une seule fonction: MemberWiseClone, qui est fonction protégée de la classe Objet.

Pour réaliser une copie superficielle, il suffit juste de créer l'interface IShallowCopy dont on a parlé plus haut, et de l'implémenter dans la classe à copier, en utilisant donc MemberWiseClone dans la fonction ShallowCopy.

using System;
using System.Globalization;

namespace TutoCopy
{
	public interface IShallowCopy<T>
	{
		T ShallowCopy();
	}

	public class Toto
	{
		private Guid id = Guid.NewGuid();

		public Toto()
		{
		}

		public string ToString()
		{
			return id.ToString();
		}
	}

	public class Foo : IShallowCopy<Foo>
	{
		private Toto toto = new Toto();
		private string s = "azerty";
		private int i = 1234;

		public Foo ShallowCopy()
		{
			return MemberwiseClone() as Foo;
		}

		public string ToString()
		{
			return string.Format(CultureInfo.InvariantCulture, "S : {0} - I : {1} - T : {2}", s, i, toto.ToString());
		}

		class Program
		{
			static void Main(string[] args)
			{
				Foo f = new Foo();
				Foo f2 = f.ShallowCopy();

				Console.WriteLine(f.ToString());
				Console.WriteLine(f2.ToString());
				Console.ReadKey();
			}
		}
	}
}

La sortie console de ce petit programme vous donnera:

 
S : azerty - I : 1234 - T : 4ccb3eaa-c5a7-4862-a6f7-062320f8251c
S : azerty - I : 1234 - T : 4ccb3eaa-c5a7-4862-a6f7-062320f8251c

Comme vous le voyez, les ids des membres Toto sont les même.

Copie "profonde"

Comme nous allons le voir, il existe plusieurs méthodes pour réaliser une copie profonde d'un objet.

Sérialisation

La première d'entre elles est très générique, et s'appuie sur la sérialisation de l'objet à copier, suivi de sa désérialisation immédiate en tant que nouvelle instance:

public static T DeepCopy<T>(T obj)
{
    object result = null;

    using (MemoryStream ms = new MemoryStream())
    {
        BinaryFormatter formatter = new BinaryFormatter();
        formatter.Serialize(ms, obj);
        ms.Position = 0;

        result = (T)formatter.Deserialize(ms);
    }

    return (T)result;
}

Cette méthode est très rapide à implémenter et à utiliser. Mais elle a pour inconvénients que tous les types de votre hiérarchie d'objets soient marqués comme sérialisables (en leur ajoutant l'attribut Serializable), et qu'elle va construire la copie de l'objet en passant par un constructeur par défaut puis de la reflection, pour copier tous les membres de l'objet, publics comme privés, ce qui pose 2 soucis:

  • Le souci avec l'attribut Serializable est que ce n'est peut-être pas possible de l'ajouter à des classes que vous utilisez et qui se trouvent dans une assembly tierce.
  • Le souci avec la création de l'objet copié est que si vous avez de la logique dans le constructeur, qui donne une valeur par défaut à certains champs, cette valeur sera écrasée par la suite pour prendre celle de l'objet source.

Par exemple:

using System;
using System.Globalization;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

namespace TutoCopy
{
    [Serializable]
    public class Toto
    {
        private static readonly Random rnd = new Random();
        private readonly int id = rnd.Next();

        public string ToString()
        {
            return id.ToString();
        }
    }

    [Serializable]
    public class Foo
    {
        private readonly Toto toto = new Toto();

        public string ToString()
        {
            return string.Format(CultureInfo.InvariantCulture, "T : {0}", toto.ToString());
        }

        public Toto T
        {
            get { return toto; }
        }
    }

    internal class Program
    {
        public static T DeepCopy<T>(T obj)
        {
            object result = null;

            using (var ms = new MemoryStream())
            {
                var formatter = new BinaryFormatter();
                formatter.Serialize(ms, obj);
                ms.Position = 0;

                result = (T) formatter.Deserialize(ms);
            }

            return (T) result;
        }

        private static void Main(string[] args)
        {
            var f = new Foo();
            Foo f2 = DeepCopy(f);

            Console.WriteLine(f.ToString());
            Console.WriteLine(f2.ToString());

            Console.ReadKey();
        }
    }
}

Si vous voulez qu'il n'existe pas 2 instances de la classe Toto ayant le même Id, vous l'avez dans l'os avec la sérialisation, ce que la sortie de la console vous confirmera en affichant deux fois le même id.

Voyons donc comment nous pouvons effectuer une copie profonde d'une classe dans laquelle se trouvent des membres de type référence (sinon une copie superficielle suffit), et pour laquelle la sérialisation ne convient pas.

Copie profonde d'un objet non sérialisable ayant des membre de type référence

Prenons l'exemple d'une voiture, qui a comme membres sa marque, et un moteur, qui possède un numéro de série unique ainsi qu'une puissance. On veut pouvoir copier une instance de la voiture. Ce qu'on attend de cette copie, c'est que la marque de la voiture et la puissance du moteur soient identiques, mais qu'un nouveau numéro de série soit créé. Et il est impossible de créer une nouvelle voiture sans lui spécifier dès la création sa marque et les caractéristiques du moteur.

using System;
using System.Globalization;

namespace TutoCopy
{
    public interface IDeepCopy<T>
    {
        T DeepCopy();
    }

    public class Engine
    {
        private static Random rnd = new Random();
        private int horsePower;
        private long serialNumber = rnd.Next();

        public Engine(int horsePower)
        {
            this.horsePower = horsePower;
        }

        public long SerialNumber
        {
            get { return serialNumber; }
        }

        public int HorsePower
        {
            get { return horsePower; }
        }

        public override string ToString()
        {
            return string.Format(CultureInfo.InvariantCulture, "Engine : {0} - {1}HP", SerialNumber, HorsePower);
        }
    }

    public class Car : IDeepCopy<Car>
    {
        private string brand;
        private Engine engine;

        public Car(string brand, Engine engine)
        {
            this.engine = engine;
            this.brand = brand;
        }

        public Engine Engine
        {
            get { return engine; }
        }

        public string Brand
        {
            get { return brand; }
        }

        #region IDeepCopy<Car> Members

        public Car DeepCopy()
        {
            return new Car(Brand, new Engine(Engine.HorsePower));
        }

        #endregion

        public override string ToString()
        {
            return string.Format(CultureInfo.InvariantCulture, "Brand : {0} - {1}", brand, Engine);
        }
    }

    internal class Program
    {
        private static void Main(string[] args)
        {
            var car = new Car("Audi", new Engine(100));
            Car car2 = car.DeepCopy();

            Console.WriteLine(car.ToString());
            Console.WriteLine(car2.ToString());

            Console.ReadKey();
        }
    }
}

La solution est triviale ici. On instancie dans la fonction DeepCopy() une nouvelle voiture avec un nouveau moteur, en leur passant en paramètre les caractéristiques de la voiture à copier.

Le résultat de la console:

Brand : Audi - Engine : 1609828538 - 100HP
Brand : Audi - Engine : 2092943076 - 100HP

Copie profonde d'un objet non sérialisable ayant des membre de type référence vers une hiérarchie de classe

Ajoutons une classe TurboEngine qui dérive de Engine:

public class TurboEngine : Engine
{
	private int turboPower;

	public TurboEngine(int horsePower, int turboPower) : 
		base(horsePower)
	{
		this.turboPower = turboPower;
	}

	public int TurboPower
	{
		get { return turboPower; }
	}

	public override string ToString()
	{
		return string.Format(CultureInfo.InvariantCulture, "{0} - {1}TP", base.ToString(), TurboPower);
	}
}

On a maintenant le choix pour chaque voiture que l'on créée entre un moteur normal et un moteur turbo. Si on ne change pas notre code de copie, voici ce que le programme suivant nous donne:

class Program
{
    static void Main(string[] args)
    {
        Car car = new Car("Audi", new TurboEngine(100, 50));
        Car car2 = car.DeepCopy();

        Console.WriteLine(car.ToString());
        Console.WriteLine(car2.ToString());

        Console.ReadKey();
    }
}
Brand : Audi - Engine : 1752772204 - 100HP - 50TP
Brand : Audi - Engine : 1587482326 - 100HP

Notre voiture originale a bien un turbo, alors que son clone non. Ce qui est évident, puisque dans la fonction DeepCopy de Car, nous créons explicitement une instance de Engine. Comment résoudre ce souci?

Une première solution serait de faire comme ceci:

public Car DeepCopy()
{
    Engine newEngine = null;

    if (Engine is TurboEngine)
        newEngine = new TurboEngine(engine.HorsePower, ((TurboEngine)engine).TurboPower);
    else
        newEngine = new Engine(Engine.HorsePower);
    
    return new Car(this.Brand, newEngine);
}

Problème: si on a une hiérarchie très volumineuse à copier, on doit à chaque fois ajouter une vérification dans la fonction. Avouez que ce n'est ni très élégant, ni très orienté objet, car ça casse un principe fondamental de la programmation OO : l'OCP.

Que peut-on faire? La solution consiste tout simplement à passer en paramètre à la nouvelle instance de la voiture une copie du moteur. C'est à dire laisser la charge à chaque classe ou hiérarchie de classe de se copier elle-même:

public class Engine : IDeepCopy<Engine>
{
	//...
	
	public virtual Engine DeepCopy()
	{
		return new Engine(HorsePower);
	}
}

public class TurboEngine : Engine
{
	//...

	public override Engine DeepCopy()
	{
		return new TurboEngine(HorsePower, TurboPower);
	}
}

public class Car : IDeepCopy<Car>
{
	//...
	
	public Car DeepCopy()
	{
		return new Car(this.Brand, this.Engine.DeepCopy());
	}
}

On pense bien à déclarer DeepCopy virtuelle dans Engine, on l'override dans TurboEngine, et dans la copie de la voiture, on passe en paramètre à la nouvelle voiture une copie du moteur.

Et à la console de notre petit programme de test:

Brand : Audi - Engine : 599116206 - 100HP - 50TP
Brand : Audi - Engine : 539440617 - 100HP - 50TP

Pour information, c'est ce qu'on appelle le design pattern Prototype.

Copie profonde d'un objet non sérialisable ayant des membre de type référence readonly

Pour cet exemple, nous allons simplifier le code de Engine et Car, et basculer l'application en mode WinForms. Nous partirons du principe que lorsque l'on créé une nouvelle voiture, celle-ci aura une puissance de 100CV, et cette puissance sera modifiable via un NumericUpDown créé par Engine, et qui sera situé dans la fenêtre de l'application. Cet exemple peut paraître un peu tiré par les cheveux, mais c'est assez courant lorsqu'on a une hiérarchie d'objets, et que l'on veut que l'utilisateur puisse changer grâce à des contrôles graphiques certaines propriétés de ces objets. Ainsi chaque classe de la hiérarchie crée et affiche les contrôles graphiques dont elle aura besoin, et la mise à jour de ses propriétés se fait en interne dans la classe.

Voici donc tout d'abord nos classes modifiées:

using System.Globalization;
using System.Windows.Forms;

namespace TutoCopy
{
    public interface IDeepCopy<T>
    {
        T DeepCopy();
    }

    public class Engine : IDeepCopy<Engine>
    {
        private int horsePower;
        private NumericUpDown upDown;

        public Engine(int horsePower)
        {
            this.horsePower = horsePower;
        }

        public int HorsePower
        {
            get { return horsePower; }
        }

        public override string ToString()
        {
            return string.Format(CultureInfo.InvariantCulture, "Engine : {0}HP", HorsePower);
        }

        public virtual Engine DeepCopy()
        {
            return new Engine(HorsePower);
        }

        public void BuildControl(Control parentControl)
        {
            upDown = new NumericUpDown
                         {
                             Parent = parentControl,
                             Minimum =  0,
                             Maximum = 600,
                             Value = horsePower
                         };

            upDown.ValueChanged += (s, args) => horsePower = (int) upDown.Value;
        }
    }

    public class Car : IDeepCopy<Car>
    {
        private readonly Engine engine;
        
        public Car()
        {
            engine = new Engine(100);
        }

        public Engine Engine
        {
            get { return engine; }
        }

        public virtual Car DeepCopy()
        {
            return new car();
        }

        public override string ToString()
        {
            return string.Format(CultureInfo.InvariantCulture, "{0}", Engine);
        }

        public void BuildControl(Control parentControl)
        {
            engine.BuildControl(parentControl);
        }
    }
}

Comme vous le voyez, la classe Engine va créer un NumericUpDown qui changera la valeur de la puissance du moteur lorsque sa valeur changera.

Voici maintenant le code de la fenêtre d'affichage:

using System;
using System.Text;
using System.Windows.Forms;

namespace TutoCopy
{
    public partial class Form1 : Form
    {
        private Car car = new Car();

        public Form1()
        {
            InitializeComponent();

            car.BuildControl(this);
        }

        private void button1_Click(object sender, EventArgs e)
        {
            Car newCar = car.DeepCopy();

            StringBuilder sb = new StringBuilder();
            sb.AppendLine(car.ToString());
            sb.AppendLine(newCar.ToString());

            MessageBox.Show(sb.ToString());
        }
    }
}

On crée une voiture par défaut, et on affiche le NumericUpDown via l'appel à BuildControl. On a auparavant placé un bouton sur la fiche, qui va créer une copie profonde de la voiture, et afficher la puissance de chacune des voitures.

On lance l'application, on change la valeur de la puissance à 250CV, et on appuie sur le bouton. Voici l'affichage:

Engine : 250HP
Engine : 100HP

Comme vous le constatez, notre voiture copiée n'a pas récupéré la puissance de l'originale. Ce qui est normal, puisque la fonction DeepCopy() créé une nouvelle instance par défaut de Car, qui a donc par défaut 100CV.

Pour corriger le tir, on peut vouloir modifier la fonction comme suit:

public virtual Car DeepCopy()
{
    Car newCar = new Car();
    newCar.engine = new Engine(engine.HorsePower);

    return newCar;
}

Mais c'est bien sûr impossible puisque le membre engine est readonly. On ne peut donc l'instancier que dans le constructeur ou la déclaration de Car.

Comment faire? En utilisant un constructeur par copie, où l'on va créer une instance de Engine en passant en paramètre la valeur de la puissance du moteur de la voiture de départ:

public class Car : IDeepCopy<Car>
{
    private readonly Engine engine;
    
    public Car()
    {
        engine = new Engine(100);
    }

    protected Car(Car oldCar)
    {
        engine = new Engine(oldCar.engine.HorsePower);
    }

    public virtual Car DeepCopy()
    {
        Car newCar = new Car(this);

        return newCar;
    }
}

Et donc lors du clic sur le bouton, on a bien 2 instances différentes ayant toutes les 2 la même puissance de moteur.

Maintenant imaginons qu'il n'y ait pas ce getter de HorsePower dans Engine. Comment ferions nous pour créer l'instance du moteur dans le constructeur de copie de Car?

Tout simplement en utilisant dans le constructeur de copie le pattern de copie qu'on utilise pour le moment dans Car:

protected Car(Car oldCar)
{
    engine = oldCar.Engine.DeepCopy();
}

Pourquoi DeepCopy et pas uniquement un constructeur par copie public?

C'est vrai ça. Pourquoi passer par une fonction de copie alors que visiblement le constructeur par copie résout nos soucis? Tout simplement une nouvelle fois à cause de l'OCP. En effet, dans notre exemple précédent, si une voiture peut avoir un Engine ou un TurboEngine, comment pourrions nous le copier? Nous ne saurons pas dans le constructeur de quel type est l'instance du moteur, et devrons donc nous appuyer sur du code comme:

public Car(Car oldCar)
{
    if (oldCar.engine is TurboEngine)
        engine = new TurboEngine();
    else
        engine = new Engine();
}

Le genre d'horreur dont nous dispensait l'utilisation de la fonction DeepCopy().

Autres méthodes

L'avantage des méthodes que je viens de vous expliquer et que vous avez la main sur la manière dont sont copiés vos objets; vous ne risquez donc pas de mauvaises surprises. L'inconvénient majeur est que lorsque vous développez vos hiérarchies de classes, vous devez vous assurer d'ajouter à chacune de ces nouvelles classes une fonction DeepCopy et un constructeur par copie protégé.

Il existe d'autres méthodes pour la copie d'objets, génériques, et qui ont donc les inconvénients de leurs avantages: souvent plus lentes que celles exposées ici, et avec des difficultés à personnaliser la copie, ou à apporter de la logique dans le code (lorsque par exemple il faut gérer des ressources non managées).

Mais pour faire complet, je vais quand même en parler rapidement (basé sur cette source):

  • Reflection: il suffit de créer une nouvelle instance de la classe d'origine via Activator.CreateInstance, puis de parcourir tous les champs de l'objet de base pour les réaffecter dans l'objet copié. L'avantage est que vous pouvez changer les définitions de vos classes sans vous en soucier lors de la copie. Les inconvénients sont que c'est lent, et que vous pourrez avoir des problèmes de sécurité dans certains environnements de travail.
  • Intermediate Language: Le principe ici est de générer à la volée le code IL dans un délégué, que vous compilez et exécutez lors de la copie. L'avantage est le même que en se basant sur la reflection. Les inconvénients sont là aussi la lenteur (même si c'est plus rapide que via la reflection), et la difficulté à produire ce genre de code, certainement pas à la portée du premier venu.
  • Extension Methods: Le framework de copie donné en lien se base sur les extensions methods. Il mérite définitivement un coup d'oeil.

Conclusion

Comme vous le voyez, la copie d'objets est un vaste débat. J'espère que cet article aura su vous démontrer les pièges de l'utilisation de l'interface ICloneable, les différences entre une copie profonde et une copie superficielle, et les différentes manières de réaliser une copie profonde d'une classe (ou de toute une hiérarchie), quelques soient les contraintes qu'elles vous imposent.

N'hésitez pas à me donner votre avis sur cet article!