Le problème dit du diamant concerne principalement C++ et aussi Java mais pas C# qui résout le problème de façon intelligente mais qui peut surprendre… Mais quel est ce problème ?
Le problème du diamant
Je ne dirais pas que dans une interview d’embauche la question vaudrait de l’or… mais elle vaudrait peut-être du diamant !
Qu’est-ce que cette histoire de brillants et que vient faire la gemmologie dans C# ?
L'affaire est simple, c’est une banale histoire de famille comme il y en a hélas partout. Une histoire d’argent, enfin de diamant, tout cela pour un héritage.
Le problème du diamant concerne vous l’avez compris l’héritage des classes ou des interfaces, deux entités supportant cette notion de filiation.
Le plus simplement du monde, imaginez une classe appelée Base, puis deux classes filles ClassA et ClassB, chacune proposant une méthode Foo(). Supposons maintenant une nouvelle classe ClassC qui hérite de ClassA et ClassB. L’implémentation de Foo() dans ClassC va poser un petit problème, de quel “Foo()” parlera-t-on ? Celui hérité de ClassA ou de celui provenant de ClassB. Si je créée une instance x de ClassC, écrire “x.Foo();” appellera-t-il la méthode définie dans ClassA ou dans ClassB ?
Ce problème se résume au schéma suivant qui figure… une espèce de diamant :
Multi-héritage ? Mais c'est du C++ pas du C# !
Bien entendu ce problème du diamant est lié à l’héritage multiple typique de C++. Bienheureusement et avec sagesse C#, tout comme Java, n’acceptent tout simplement pas le multi-héritage la question semble donc sans fondement vous dîtes-vous …
C’est vrai, mais pas si vite petit bolide !
Les interfaces supportent le multi-héritage sous C#
Si la question n’a pas de sens sous C# pour les classes, en revanche dans le petit schéma ci-dessus, si nous ne parlons plus de classes mais d’interfaces, tout cela fait sens… car l’héritage multiple existe bien en C# pour les interfaces.
Le problème du diamant existe-t-il alors en C# ?
Oui et non.
Oui car bien entendu le multi-héritage, au moins des interfaces, étant autorisé le problème va se poser. Et Non car en réalité C# gère les choses avec une grande logique comme nous allons le voir.
Sachant que Java est de ce point de vue dans la même situation que C# pourquoi ai-je dit en introduction que le problème du diamant ne concernait que C++ et Java ? C’est bête mais dans le cas que nous allons étudier en C# Java donne une erreur de compilation. Ecrivez en Java le code suivant :
//Diamond.java
interface Interface1 {
default public void foo() { System.out.println("Interface1's foo"); }
}
interface Interface2 {
default public void foo() { System.out.println("Interface2's foo"); }
}
public class Diamond implements Interface1, Interface2 {
public static void main(String []args) {
new Diamond().foo();
}
}
… Vous obtiendrez une erreur de compilation “Error:(9, 8) java: class Diamond inherits unrelated defaults for foo() from types Interface1 and Interface2”.
Quelle horreur du Java sur Dot.Blog ! Oui oublions vite cette erreur car d’erreur il n’y a pas en C# dans un tel cas !
Java n’a été que le brouillon de C# et cela se voit à ce genre de choses. Mais point de querelle de langage, c’est une simple et évidente constatation, preuve en est avec le problème du diamant.
En C# le problème ne se pose pas, tout simplement.
C# et le diamant
C# interdit l’héritage multiple des classes, ce qui est une bonne chose donc. Il l’autorise en revanche pour les interfaces. Pourquoi donc l’un et pas l’autre de ces deux cas ?
Je ne sais pas pourquoi Java se sent obligé de lever une erreur de compilation dans le cas présenté car la logique permet d’éviter de se poser des questions. En effet, lorsqu’on parle d’héritage de classe cela implique l’héritage du code de la classe. Donc dans le cas du diamant (revenir au petit schéma plus haut) si les classes B et C implémentent une méthode publique Foo() tout le problème est de savoir comment la VMT de la classe D va résoudre le conflit (si la méthode est virtuelle, si elle ne l'est pas le conflit se fera au niveau de l'early binding, à la compilation)… La VMT (Virtual Method Table, table des méthodes virtuelles) est le mécanisme par lequel la POO gère l’héritage et les méthodes virtuelles. Cette table pointe les méthodes qui doivent être appelées. Pour créer la VMT de la classe D héritant de B et C le compilateur ne saura s’il doit pointer pour Foo() l’implémentation de B ou de C, conflit qui donnera lieu à un arbitrage laissé au développeur en C++.
Mais pour les interfaces le problème n’est pas de même nature. Une interface est une promesse, un contrat. Elle ne contient aucun code. Si dans le schéma plus haut on considère que A, B, C et D sont des interfaces et non des classes, la seule chose que “promet” l’interface D c’est de proposer la liste des opérations (et propriétés) des interfaces B et C. Rien de plus. Donc si j’implémente une classe Z qui supporte l’interface D, et en reprenant le conflit sur Foo() évoqué en Java, Puisque B et C “promettent” d’implémenter Foo(), D aussi, ma classe Z se devra juste d’offrir une méthode Foo(). Aucun conflit ne se pose pour des interfaces. Raison pour laquelle C# fait l’économie d’une erreur. Le contrat est rempli, il n’y a pas de duplication de code ou d’ambigüité particulière.
Pourquoi Java produit-l une erreur de compilation dans le même cas ? Connaissant l’esprit de Java je suppose que cette erreur “inutile” de prime abord est là pour souligner une situation malgré tout embarrassante. Il y a une erreur de conception dans le cas du diamant, c’est une évidence. Java préfère avertir le développeur. Mais un warning aurait été suffisant. C# considère que puisqu’il n’y a aucun problème rien ne sert de bloquer la compilation. C’est une autre vision des choses.
Régler les conflits
On vient de le voir avec les interfaces il n’y a en réalité par d’ambigüité dans le cas du diamant. Mais cela ne veut pas dire qu’il n’y a pas de conflit au moins conceptuel. Comment C# le gère-t-il ?
Comme d’habitude (et lorsque cela est possible) mes exemples de code fonctionnent sous LinqPad qui est bien plus léger pour de tels exercices que VS. Vous pouvez donc copier/coller le programme ci-dessous directement dans cet utilitaire précieux (surtout si vous vous acquittez de la petite licence qui débloque entre autres l’Intellisence ce qui est vraiment bien pratique, ainsi que l'assistance par IA dans la version 8).
void Main()
{
var c = new RealStringChannel();
var b = c as ReadChannel;
var d = c as WriteChannel;
b.Open();
d.Open();
c.Open();
var s = new StrangeChannel();
var sr = s as ReadChannel;
var sw = s as WriteChannel;
s.Open();
sr.Open();
sw.Open();
}
public interface Channel
{ int Number { get; set; } }
public interface ReadChannel : Channel
{ string ReadString(); void Open(); }
public interface WriteChannel : Channel
{ void WriteString(); void Open(); }
public interface StringChannel : ReadChannel, WriteChannel
{ Encoder StringEncoder { get; set;} }
public class RealStringChannel : StringChannel
{
public int Number { get; set;}
public string ReadString() { return ""; }
public void WriteString() { }
void ReadChannel.Open() { Console.WriteLine("Opened for Read."); }
void WriteChannel.Open() { Console.WriteLine("Opened for Write."); }
public void Open()
{
Console.WriteLine("---- RW Channel");
(this as ReadChannel).Open();
(this as WriteChannel).Open();
Console.WriteLine("----");
}
public Encoder StringEncoder { get; set; }
}
public class StrangeChannel : ReadChannel, WriteChannel
{
public int Number { get; set; }
public string ReadString() { return ""; }
public void WriteString() { }
public void Open()
{
Console.WriteLine("*Open Strange Channel");
}
}
Ce qui produira la sortie suivante :
Opened for Read.
Opened for Write.
---- RW Channel
Opened for Read.
Opened for Write.
----
*Open Strange Channel
*Open Strange Channel
*Open Strange Channel
Je vais décomposer un peu car le code exemple fait plusieurs choses et montrent surtout différents aspects du problème du diamant appliqué aux interfaces sous C#.
Où se trouve le diamant ?
Cherche et tu trouveras disent les Ecritures… Enfin c’est mieux si on vous montre au lieu de jouer bêtement au Sphinx ! Et comme je sais où se cache le diamant, voici où le trouver : C’est le groupe de quatre interfaces Channel, ReadChannel, WriteChannel et StringChannel.
J’ai un peu joué au Sphinx quand même car je n’ai pas mis les “I” devant les noms des interfaces laissant croire qu’il peut s’agir de classes… Je suis resté joueur … .
Donc Channel
est une interface qui imaginons permet de décrire un canal de communication numéroté. Cette interface n’oblige qu’à une seule chose : offrir une valeur entière Number
qui permettra de fixer le numéro du canal utilisé.
ReadChannel
hérite de Channel
et propose une méthode ReadString()
ainsi qu’une méthode Open()
. Cette dernière sert à ouvrir le canal de communication pour y lire des strings avec la première (tout cela est fictif).
WriteChannel
hérite aussi de Channel
et propose aussi une méthode Open()
ainsi qu’une méthode WriteString()
permettant d’écrire une string dans le canal.
Le diamant se forme avec StringChannel
qui hérite à la fois de ReadChannel
et WriteChannel
pour créer un canal “complet” pouvant lire et écrire des strings avec un numéro de canal (hérité de Channel
).
Là où ça pourrait coincer (mais pas en C#) c’est que ReadChannel
autant de WriteChannel
proposent une méthode Open()
!
D’un point de vue conceptuel on pourrait régler la chose en déplaçant Open()
dans Channel
(et ajouter un Close()
d’ailleurs) ce qui semblerait un peu plus “sain”. Mais parfois en développement on ne choisit pas les interfaces ni le code dont on .. hérite !
Supposons ainsi que Open()
ne peut pas être déplacé. Que va-t-il se passer ?
Le cas simple
Dans le code exemple regardez d’abord la classe StrangeChannel
. Elle supporte directement ReadChannel
et WriteChannel
. L’effet aurait le même si elle avait supporté directement StringChannel
(sauf que cette dernière ajoute la propriété Encoder
que StrangeChannel
ne reprend pas donc).
Cette classe, StrangeChannel
, puisqu’elle supporte les deux interfaces Read/WriteChannel supporte aussi Channel
, elle offre donc une propriété Number
. Puis les méthodes spécifiques ReadString()
et WriteString()
.
Pour Open() elle n’offre bien entendu qu’une seule implémentation.
Channel
est respecté, Read
et WriteChannel
aussi.
Pour preuve le code de Main
que je redonne ici pour clarifier :
var s = new StrangeChannel();
var sr = s as ReadChannel;
var sw = s as WriteChannel;
s.Open();
sr.Open();
sw.Open();
Qui donne la sortie suivante :
*Open Strange Channel
*Open Strange Channel
*Open Strange Channel
Et oui, il n’existe bien qu’une seule implémentation de Open()
, que l’on regarde l’instance “s
” de StrangeChannel
sous sa forme directe (s.Open()
) ou bien sous l’aspect de l’une ou l’autre des interfaces qu’elle supporte, c’est toujours le code unique de la méthode publique Open() qui est appelé.
Pas de conflit, pas de problème, pas d’erreur de compilation. Et c’est logique puisqu’avec les interfaces l’héritage de code ne se pose pas. On hérite juste de la “promesse” d’offrir certaines méthodes et propriétés. StrangeChannel
répond bien sans tromperie ni ruse aux promesses que cette classe a faites lorsqu’elle a prêté serment de supporter les contrats de ReadChannel
et WriteChannel
!
Un cas plus tordu
Regardons maintenant le code de la classe RealStringChannel
.
Cette classe supporte l’interface StringChannel
, le bas du diamant. Elle promet ainsi de supporter à la fois Channel
, ReadChannel
et WriteChannel
.
Pour l’essentiel nous sommes dans le même cas que précédemment. Nous devons juste ajouter la propriété StringEncoder
que StringChannel
ajoute. Rien de gênant ici.
Mais imaginons que nous souhaitions proposer un code différent pour Open()
selon la forme sous laquelle se présentera l’instance …
C’est ce que montre le code de Main
:
var c = new RealStringChannel();
var b = c as ReadChannel;
var d = c as WriteChannel;
b.Open();
d.Open();
c.Open();
Code qui donne la sortie :
Opened for Read.
Opened for Write.
---- RW Channel
Opened for Read.
Opened for Write.
----
Le code de Main
créée une instance “c
” de RealStringChannel
. Puis comme pour l’exemple précédent sont ajoutées des variables qui capturent cette instance sous la forme plus restreinte de l’une ou l’autre des interfaces supportées (Read et WirteChannel).
L’appel via ReadChannel
nous indique “Opened for Read”.
L’appel via WriteChannel
nous indique “Opened for Write”
Et l’appel via l’instance “complète” de la classe RealStringChannel
nous indique “RW Channel” avec une ouverture pour la lecture et autre pour l’écriture.
Diable !
Trois sorties différentes pour l’appel à la même méthode de la même instance !
Par quelle diablerie cela arrive-t-il ?
C’est dans le code de la classe RealStringChannel
qu’il faut aller regarder, allez, je vous le redonne pour éviter d’avoir à scroller :
public class RealStringChannel : StringChannel
{
public int Number { get; set;}
public string ReadString() { return ""; }
public void WriteString() { }
void ReadChannel.Open() { Console.WriteLine("Opened for Read."); }
void WriteChannel.Open() { Console.WriteLine("Opened for Write."); }
public void Open()
{
Console.WriteLine("---- RW Channel");
(this as ReadChannel).Open();
(this as WriteChannel).Open();
Console.WriteLine("----");
}
public Encoder StringEncoder { get; set; }
}
Regardez comment Open()
est définie trois fois… Une fois sans “public
” et préfixée de ReadChannel
, une autre fois similaire mais préfixée de WriteChannel
et une troisième fois “normalement” si je peux dire.
Selon comment l’instance est vue, soit directement comme une instance de RealStringChannel ou comme une implémentation de Read ou WriteChannel, la méthode Open() qui sera fournie à l’appelant sera différente !
Et oui cela est possible en C#.
Conclusion
Possible veut-il dire “souhaitable” ? Certes non.
Je ne vous conseille vraiment pas de créer du code qui fonctionne de cette façon.
Mais comme toute possibilité il est probable qu’il existe des cas où cela peut soit être très utile, soit aider à se sortir d’une mauvaise passe en réutilisant du code qu’on n’a pas écrit à la base.
Le problème du diamant n’existe pas techniquement sous C# mais il existe tout de même au niveau conceptuel avec les interfaces. Il n’y a pas d’incohérences, pas d’erreur de compilation et cela est normal, mais il est possible de créer des situations alambiquées qui peuvent dérouter.
En tout cas si je vous avais demandé au départ de prédire les sorties du code, ou comment avoir trois sorties différentes pour Open()
avec la même classe, je ne suis pas certain que tout le monde aurait su quoi répondre.
Ouf! vous avez lu Dot.Blog.
Stay Tuned !
Le Guide Complet de.NET MAUI ! Lien direct Amazon : https://amzn.eu/d/95wBULD
Près de 500 pages dédiées à l'univers .NET MAUI !
Existe aussi en version Kindle à prix réduit !