Blog Acensi
Picto potion et C#

La covariance et la contravariance en C#

Cet article a pour objectif de présenter les différentes fonctionnalités offertes par le langage C# ayant rapport avec la notion de covariance et celle de contravariance. Nous ne chercherons pas à définir formellement ces notions mais à comprendre à l’aide d’exemples concrets ce en quoi elles consistent. Voici ce dont il sera question dans cet article :

1. Les conversions implicites de références en C#

Une conversion implicite de référence est une conversion d’un type référence vers un autre type référence qui ne peut pas échouer. Comme ces conversions ne peuvent pas échouer, elles ont la particularité de ne nécessiter aucune vérification à l’exécution. Sans forcément le réaliser, il s’agit d’un type d’opération que nous effecuons très souvent. Prenons un exemple pour nous en rendre compte :

public class B
{
    public virtual void Operation() { }
}
 
public class C : B
{
    public override void Operation() { }
}
 
C c = new C();
B b = c;
b.Operation();

Lors de l’exécution de la ligne 12, une conversion implicite de référence a lieu. En effet, comme la variable c est de type C, il va falloir la convertir en B avant de pouvoir l’affecter à la variable b. La classe C héritant de la classe B, cette conversion ne peut pas échouer. Comme C et B sont des types références, il s’agit de ce que l’on appelle une conversion implicite de référence. Dans la suite de cet article, nous allons étudier différentes situations mettant en œuvre des conversions implicites de références un peu particulières. Certaines seront quelque peu contre-intuitives, mais néanmoins logiques.

2. Le cas des types génériques

Le langage C# nous propose ce que l’on appelle la covariance et la contravariance des interfaces génériques et des delegates génériques. Voyons de quoi il s’agit.

2.1. La covariance

Commençons avec une conversion de référence qui semble en général évidente à comprendre et qui repose sur la covariance des interfaces génériques et des delegates génériques. Nous disposons d’une interface générique et de son implémentation (l’implémentation n’a pas réellement d’importance dans cet article) :

public interface IPopable<out T> // nous reviendrons sur
                                 // l'utilisation du mot clé out
{
    T Pop();
}
 
public class Stack<T> : IPopable<T>
{
    readonly System.Collections.Generic.Stack<T> _stack =
        new System.Collections.Generic.Stack<T>();
 
    public T Pop()
    {
        return _stack.Pop();
    }
 
    public void Push(T toPush)
    {
        _stack.Push(toPush);
    }
}

Nous pouvons maintenant instancier et peupler une Stack<Button> :

Stack<Button> stack = new Stack<Button>();
stack.Push(new Button { Name = "Button 1" });
stack.Push(new Button { Name = "Button 2" });
stack.Push(new Button { Name = "Button 3" });

Nous pouvons également convertir la référence contenue dans la variable stack en une référence vers un IPopable<Button> puisque Stack<Button> implémente l’interface IPopable<Button>.

IPopable<Button> buttonPopable = stack;

Jusqu’ici nous n’avons pas encore touché à la notion de covariance. Les choses deviennent plus intéressantes ensuite. Nous rappelons que la classe Button dérive de la classe Control.

IPopable<Control> controlPopable = buttonPopable;
Control c1 = controlPopable.Pop();
Control c2 = controlPopable.Pop();
Control c3 = controlPopable.Pop();

L’instruction de la première ligne est-elle légale ? Peut-on convertir une référence vers un IPopable<Button> en une référence vers un IPopable<Control> en toute sécurité ? La réponse est oui. Pour le comprendre analysons ce qui se passe ligne par ligne et voyons si un problème dû à la conversion est susceptible de se produire.
Tout d’abord la référence contenue dans la variable buttonPopable est affectée à la variable controlPopable. La variable controlPopable étant de type IPopable<Control>, l’exécution de la méthode Pop sur cette variable est supposée nous retourner une instance de la classe Control. La méthode Pop réellement exécutée est cependant celle de notre instance de Stack<Button> initiale. Cette méthode nous retourne une instance de la classe Button et non une instance de la classe Control. Néanmoins, la classe Button dérivant de la classe Control, l’instance de Button retournée par la méthode Pop peut sans problème être affectée à la variable c1 qui est de type Control. (Même remarque pour les deux instructions suivantes.)
On voit donc que le code est type-safe, ce qui signifie qu’aucun échec de conversion ne peut se produire au runtime. Le code étant type safe, le compilateur C# nous autorise à l’écrire.
Dans notre exemple, le paramètre générique de l’interface IPopable<T> n’est utilisé qu’en valeur de retour des méthodes de l’interface. Il n’est jamais utilisé en paramètre d’une méthode. Nous allons voir que c’est précisément pour cette raison que la conversion d’un IPopable<Button> en IPopable<Control> est sûre. Pour cela, ajoutons une méthode Push à notre interface, que l’on renomme alors en IStack<T> :

public interface IStack<T>
{
    T Pop();
 
    void Push(T toPush);
}
 
public class Stack<T> : IStack<T>
{
    readonly System.Collections.Generic.Stack<T> _stack =
        new System.Collections.Generic.Stack<T>();
 
    public T Pop()
    {
        return _stack.Pop();
    }
 
    public void Push(T toPush)
    {
        _stack.Push(toPush);
    }
}

Instancions et peuplons une instance de Stack<Button> :

Stack<Button> stack = new Stack<Button>();
stack.Push(new Button { Name = "Button 1" });
stack.Push(new Button { Name = "Button 2" });
stack.Push(new Button { Name = "Button 3" });
IStack<Button> buttonStack = stack;

La seule conversion rencontrée jusqu’ici se produit lorsque le contenu de la variable stack est affecté à la variable buttonStack. Mais comme dans l’exemple précédent, cette conversion ne pose aucun problème. Les ennuis commencent maintenant :

IStack<Control> controlStack = buttonStack;
Control c4 = new Control { Name = "Control" };
controlStack.Push(c4);

Si la conversion de la première ligne était légale, voici ce qui pourrait se passer. La méthode Push sur la variable controlStack peut recevoir une instance de Control puisque cette variable est de type IStack<Control>. Cependant, la variable controlStack référence en réalité une instance de Stack<Button>. La méthode Push du type Stack<Button> attend elle une instance de Button et non une instance de Control et il n’existe pas de conversion sure de Control en Button (il existe des instances de Control qui ne sont pas des instances de Button). Si l’instruction de la première ligne était autorisée par le compilateur, il se produirait des erreurs de conversion à l’exécution. Le code ne serait pas type-safe. Pour éviter ce problème, le compilateur refuse cette conversion puisque le paramètre générique est utilisé en paramètre d’une méthode.
Revenons à la situation précédente dans laquelle le paramètre générique n’était utilisé qu’en type de retour dans l’interface IPopable<T>. Pour signaler au compilateur que le paramètre générique n’est utilisé qu’en type de retour des méthodes de notre interface, nous utilisons le mot clé out sur ce paramètre :

public interface IPopable<out T>
{
    T Pop();
}

Bien entendu, l’utilisation du mot clé out n’est autorisée que si le paramètre annoté n’est utilisé qu’en valeur de retour des méthodes de l’interface.
De manière générale, si nous disposons d’une interface générique I<out T> (le paramètre T étant alors exclusivement utilisé en type de retour des méthodes) la conversion suivante est légale si la classe C hérite de la classe B (ou si la classe C implémente l’interface B si B est une interface) :

I<C> c = // whatever...
I<B> b = c;

Cette fonctionnalité s’appelle la covariance des interfaces génériques et des delegates génériques. Nous ne nous sommes pas attardés sur le cas des delegates génériques, mais la situation est exactement la même. Si un paramètre générique est utilisé uniquement en type de retour du delegate, il peut être annoté du mot clé out et il existe alors le même genre de conversion implicite (4ème ligne de l’exemple suivant) qu’avec les interfaces génériques. En voici un exemple mettant en œuvre le delegate générique Func<TResult> faisant partie du framework .NET :

public delegate TResult Func<out TResult>();
 
Func<Button> createButton = () => new Button { Name = "button" };
Func<Control> createControl = createButton;
Control c = createControl();

La covariance est assez intuitive. Il existe une autre fonctionnalité, la contravariance, qui elle l’est beaucoup moins. Voyons ce en quoi elle consiste.

2.2. La contravariance

Nous l’avons vu, la covariance peut se résumer à ceci :

I<C> c = // whatever...
I<B> b = c;

Si C dérive de B (ou implémente B si B est une interface) et si la conversion de I<C> en I<B> est permise, alors I<T> est covariante. Dans le cas de la contravariance, c’est la conversion inverse qui est permise (dans cet exemple comme dans le précédent, c’est bien la classe C qui hérite de la classe B) :

I<B> b = // whatever...
I<C> c = b;

Prenons un exemple concret pour illustrer ce cas de figure. Nous implémentons cette fois une interface générique contravariante qui permet de « pousser » des éléments :

public interface IPushable<in T>
{
    void Push(T toPush);
}
 
public class Stack<T> : IPushable<T>
{
    readonly System.Collections.Generic.Stack<T> _stack =
        new System.Collections.Generic.Stack<T>();
 
    public T Pop()
    {
        return _stack.Pop();
    }
 
    public void Push(T toPush)
    {
        _stack.Push(toPush);
    }
}

Une fois encore nous fournissons une implémentation sans grande importance.
Nous pouvons utiliser cette interface et son implémentation de la façon suivante :

Stack<Control> stack = new Stack<Control>();
IPushable<Control> controlPushable = stack;
 
IPushable<Button> buttonPushable = controlPushable;
 
buttonPushable.Push(new Button { Name = "Button 1" });
buttonPushable.Push(new Button { Name = "Button 2" });
buttonPushable.Push(new Button { Name = "Button 3" });

Cet extrait est type-safe, et donc compile et s’exécute correctement. Pourquoi ? Une fois encore, voyons de plus près ce qui se passe à l’exécution. Nous commençons par instancier une Stack<Control>. Cette instance est alors affectée à la variable stack. Jusqu’ici aucune conversion n’a lieu. Nous affectons ensuite le contenu de la variable stack à la variable controlPushable. Comme Stack<Control> implémente IPushable<Control> la conversion qui se produit ne pose aucun problème particulier. Au cours de l’instruction suivante, une nouvelle conversion de référence implicite se produit. Lorsque la méthode Push est ensuite appelée sur la variable buttonPushable la méthode réellement exécutée est la méthode Push de notre instance initiale de Stack<Control>. Cette dernière méthode attend en paramètre une instance de Control mais on lui fournit une instance de Button. Comme il existe une conversion implicite de référence d’un Button vers un Control aucun problème de conversion ne se produit au runtime.
Cette fois, c’est parce que le paramètre générique de l’interface IPushable<T> n’est utilisé qu’en paramètre des méthodes de cette interface que la conversion d’un IPushable<Control> vers un IPushable<Button> est possible. En effet, si l’on se met à utiliser ce paramètre en type de retour d’une méthode, la conversion n’est plus sûre :

public interface IStack<T>
{
    T Pop();
 
    void Push(T toPush);
}
 
public class Stack<T> : IStack<T>
{
    readonly System.Collections.Generic.Stack<T> _stack =
        new System.Collections.Generic.Stack<T>();
 
    public T Pop()
    {
        return _stack.Pop();
    }
 
    public void Push(T toPush)
    {
        _stack.Push(toPush);
    }
}
 
Stack<Control> stack = new Stack<Control>();
IStack<Control> controlStack = stack;
controlStack.Push(new Control { Name = "Control" });
 
IStack<Button> buttonStack = controlStack;
 
Button b = buttonStack.Pop();

Si la conversion de la ligne 28 était légale, au cours de l’exécution de la ligne suivante, on tenterait d’affecter l’instance de Control retournée par la méthode Pop à la variable b qui est, elle, de type Button. Ceci impliquerait une un échec de conversion. Pour éviter cette situation, la conversion de la ligne 28 est refusée par le compilateur.
Pour indiquer au compilateur qu’un paramètre générique d’une interface n’est utilisé qu’en type de paramètre des méthodes de cette interface, il faut annoter ce paramètre générique avec le mot clé in. Ainsi l’interface générique sera contravariante :

public interface IPushable<in T>
{
    void Push(T toPush);
}

L’utilisation du mot clé in est évidemment illégale si le paramètre générique est aussi utilisé en type de retour d’une méthode de l’interface.
Les delegates génériques peuvent aussi faire usage du mot clé in sur les paramètres génériques utilisés en paramètre du delegate et sont alors contravariants. Voici un exemple mettant en œuvre le delegate générique Action<T> issu du framework .NET :

public delegate void Action<in T>(T obj);
 
Action<Control> consumeControl = c => c.Invalidate();
Action<Button> consumeButton = consumeControl;
consumeButton(new Button { Name = "Button" });

2.3. Limitation avec les paramètres de sortie out

Attention, les paramètres des méthodes annotés du mot out clé ne peuvent pas être considérés comme des valeurs de retour ! L’exemple suivant ne compile pas :

public interface IDoesntCompile<out T>
{
    void Method(out T outArg);
}

2.4. Limitation avec les types valeurs

Dans nos exemples, nous avons systématiquement paramétré nos types génériques avec des types références (les classes Control, Button, B et C). La covariance et la contravariance des interfaces génériques et des delegates génériques ne sont disponibles que si les paramètres génériques sont de type référence. L’exemple suivant ne compile pas (on rappelle que int est un type valeur et qu’il implémente l’interface IComparable<int>) :

Func<int> f1 = () => 0;
Func<IComparable<int>> f2 = f1;
IComparable<int> c = f2();

La deuxième ligne est rejetée par le compilateur. Si elle ne l’était pas, un problème se produirait lors de l’exécution de la ligne suivante : en effet, l’invocation de f2 retournerait un type valeur (un int) qui ne pourrait pas être affecté à la variable c qui elle est d’un type référence.
Passons maintenant à l’étude de la covariance et de la contravariance à travers l’utilisation de delegates (non génériques). Les explications seront semblables à celles rencontrées jusqu’ici. Nous nous permettrons donc de fournir moins de détails.

3. Le cas des delegates

Commençons par discuter de la covariance du type de retour des delegates.

3.1. Covariance du type de retour

Dans l’exemple suivant nous déclarons un delegate sans paramètre retournant un Control et une méthode dont la signature correspond à ce delegate :

public delegate Control ProduceControl();
 
public Control CreateControl()
{
    return new Control { Name = "Control" };
}

Nous pouvons donc sans surprise instancier un delegate de type ProduceControl référençant la méthode CreateControl :

ProduceControl pc = CreateControl;
Control c = pc();

Supposons maintenant que nous disposons d’une méthode sans paramètre retournant une instance de Button :

public Button CreateButton()
{
    return new Button { Name = "Button" };
}

Pouvons nous instancier un delegate ProduceControl référençant cette méthode ?

ProduceControl pc = CreateButton;
Control c = pc();

Oui cet extrait compile et s’exécute correctement. En effet l’invocation du delegate pc retourne concrètement une instance de Button qui peut donc sans problème être affecté à la variable c de type Control.
En généralisant le problème : si l’on dispose d’un type C dérivant d’un type B (ou implémentant B si B est une interface) ainsi que d’un delegate D retournant un B et d’une méthode M retournant un C, alors on pourra créer une instance de D référençant la méthode M :

public class B { }
 
public class C : B { }
 
public delegate B D();
 
public C M()
{
    return new C();
}
 
D d = M;
B b = d();

Cette fonctionnalité se nomme la covariance des types de retour des delegates.

3.2. Contravariance des paramètres

Pour comprendre la contravariance des paramètres des delegates, utilisons l’exemple suivant :

public delegate void ConsumeButton(Button b);
 
public void TakesAControl(Control c)
{
    Size s = c.Size;
}
 
ConsumeButton cb = TakesAControl;
cb(new Button { Name = "Button" });

Ici nous instancions un delegate de type ConsumeButton référençant la méthode TakesAControl dont la signature ne respecte pas celle imposée par ConsumeButton. Cependant, comme la méthode TakesAControl attend une instance de Control en paramètre, elle sera également capable de traiter une instance de Button comme l’exige le delegate ConsumeButton. Le code compile et s’exécute donc sans problème.
Plus généralement : si l’on dispose d’un type C dérivant d’un type B (ou implémentant B si B est une interface) ainsi que d’un delegate D prenant en paramètre un C et d’une méthode M prenant en paramètre un B, alors on pourra créer une instance de D référençant la méthode M :

public class B { }
 
public class C : B { }
 
public delegate void D(C c);
 
public void M(B b) { }
 
D d = M;
d(new C());

Cette fonctionnalité se nomme la contravariance des paramètres des delegates.

3.3. Limitation avec les types valeurs

Comme pour la covariance et la contravariance des types génériques, la covariance des types de retour des delegates et la contravariance des paramètres des delegates n’est disponible que si les types de retour et les types des paramètres sont des types références.
Jusqu’ici, les cas que nous avons étudiés étaient rigoureusement thread-safe. Nous allons maintenant passer à une situation plus délicate : la covariance des tableaux.

4. Le cas des tableaux

4.1. La covariance

Les tableaux sont covariants en C#. Cela signifie que l’extrait suivant est accepté par le compilateur :

Button[] buttons = new Button[]
{
    new Button { Name = "Button 1" },
    new Button { Name = "Button 2" },
    new Button { Name = "Button 3" }
};
 
Control[] controls = buttons;
 
Control c0 = controls[0];
Control c1 = controls[1];
Control c2 = controls[2];

La conversion présente à la ligne 8 met en œuvre la covariance des tableaux. Pour des raisons déjà détaillées de nombreuses fois dans cet article, on comprend pourquoi le code compile et s’exécute sans erreur.
En généralisant : si l’on dispose d’un type C dérivant d’un type B (ou implémentant B si B est une interface) alors il existe une conversion implicite de C[] vers B[]. On dit que les tableaux sont covariants :

C[] a1 = new C[] { new C(), new C() };
B[] a2 = a1;
B b = a2[0];

4.2. Limitation avec les types valeurs

La covariance des tableaux est elle aussi limitée aux types références. L’exemple suivant ne compile pas :

int[] integers = { 1, 2, 3, 4 };
object[] objects = integers;

4.3. Le problème de la covariance

Il faut cependant utiliser cette fonctionnalité avec modération. En effet, cette conversion ouvre la voie à des problèmes de conversions au runtime :

Button[] buttons = new Button[3];
Control[] controls = buttons;
controls[0] = new Control { Name = "Control" };

Ici le code compile. Nous l’avons vu, la conversion d’un tableau de Button en un tableau de Control est légale. La dernière ligne compile également, en effet nous affectons une instance de Control à la première position d’un tableau de Control. En revanche au runtime, cette dernière ligne lève une exception de type ArrayTypeMismatchException car en réalité, le tableau dans lequel on tente de placer une instance de Control est un tableau de Button or une instance de Control ne peut pas être convertie en Button.
La conversion n’est pas type safe, il faut faire particulièrement attention lorsqu’on la met en œuvre.
Nous avons terminé d’explorer ce que le langage C# a à nous offrir en terme de covariance et de contravariance. Nous allons maintenant finir en découvrant une dernière forme de covariance et de contravariance que le langage C# ne nous propose pas mais qui aurait pu être utile, puis en regardant précisément quelles sont les versions du langage C# qui nous offrent les fonctionnalités étudiées dans cet article.

5. Quelques remarques

5.1. Redéfinition de méthode

Lorsque l’on redéfinit à l’aide du mot clé override une méthode dans une classe dérivée, les signatures des deux méthodes doivent parfaitement correspondre. Pourtant on aurait pu vouloir spécialiser le type de la valeur de retour et généraliser les types des paramètres :

public class B
{
    public virtual Control CreateControl()
    {
        return new Control { Name = "Control" };
    }
 
    public virtual void ConsumeButton(Button b)
    {
    }
}
 
public class C : B
{
    public override Button CreateControl()
    {
        return new Button { Name = "Button" };
    }
 
    public override void ConsumeButton(Control c)
    {
    }
}

Cette forme de covariance et de contravariance n’est cependant pas disponible en C#.

5.2. Versions de C# offrant la covariance et la contravariance

Voici un tableau récapitulatif des différentes formes de covariance et contravariance dont nous avons parlées dans cet article indiquant pour chacune d’elles les versions du langage C# les supportant :

Version du langageInterfaces / delegates génériquesDelegatesTableaux
C# 1NonNonOui
C# 2NonOuiOui
C# 3NonOuiOui
C# 4OuiOuiOui
C# 5OuiOuiOui

6. Conclusion

Dans cet article nous avons vu en quoi consistaient la covariance et la contravariance des interfaces et des delegates génériques, la covariance des valeurs de retour des delegates, la contravariance des paramètres des delegates et la covariance des tableaux. Nous avons aussi pointé du doigt certaines limites de ces mécanismes.
Nous avons vu que ces mécanismes se résumaient à des conversions autorisées par le compilateur, mais nous n’avons que très peu montré à quoi pouvaient bien servir ces fonctionnalités au quotidien. Ce dernier point pourra faire l’objet d’un futur article.

7. Sources

C# 5 in a nutshell ; Joseph & Ben Albahari ; O’REILLY
C# in depth third Edition ; Jon Skeet ; Manning
Design patterns : elements of reusable object-oriented software ; Gamma, Johnson, Helm, Vlissides ; Addison-Wesley

Ajouter un commentaire

Pourquoi ce blog ?

Pour permettre à nos consultants et experts techniques de partager leurs connaissances et retours d’expérience autour des sujets qui les passionnent. Ce blog, intégralement écrit par eux, a pour vocation d’être un véritable lieu d’échanges et d’apprentissage.

Alors n’hésitez pas à commenter nos articles pour rejoindre la conversation !

Une suggestion ?

Si vous avez des idées pour améliorer ce blog, nous sommes à l’écoute de vos remarques. Vous pouvez nous écrire via le formulaire de contact qui se trouve en bas de page.

Bonne visite !