Certaines classes sont destinées à être intégrées dans une structure hiérarchique, d'autres ne le sont pas. Ce texte présente un résumé des grandes lignes qui devraient vous guider lorsque vous choisissez l'une ou l'autre de ces avenues (permettre à une classe d'avoir des enfants ou ne pas le lui permettre).
Si l'écriture dans cette courte section vous rebute, portez votre regard sur les exemples qui suivent juste après.
C'est à Barbara Liskov, informaticienne très réputée, que nous devons le principe principal guidant nos pas lorsque nous devons faire un choix entre permettre l'héritage ou ne pas le permettre. Le principe de substitution qu'elle nous a énoncé nous dit, informellement, qu'il est raisonnable de dériver une classe D d'une autre classe B si les opérations de B ont le même sens dans B et dans D. Exprimé autrement, il est raisonnable de dériver D de B si les invariants de B s'appliquent aussi à D.
Pourquoi donc approchons-nous le problème ainsi? Simple : l'héritage (public, seul que permette C#) décrit une relation au sens du verbe « être ». Si D dérive de B, alors un D est un B. On peut donc traiter tout D comme un B, et passer un D en paramètre à une fonction qui prend un B. Cela signifie que les opérations sur le D en tant que B doivent avoir le même sens que les opérations sur le D en tant que D, sinon le traiter comme son parent pourra le placer dans un état incohérent.
Pour cette raison, en programmation orientée objet, on ne dérivera habituellement pas Carré de Rectangle même si ce serait raisonnable de voir sous cet angle la relation géométrique entre ces classes. Voyons pourquoi avec un exemple simpliste. Imaginons les classes ci-dessous :
Classe Rectangle simple | Classe Carré simple (très discutable) |
---|---|
|
|
Programme de test | |
|
À l'exécution, ce programme affichera :
Rectangle 3 x 5
Rectangle 4 x 4
Rectangle 12 x 8
Appuyez sur une touche pour continuer...
La ligne Afficher(c) affiche le Carré nommé c... qui n'est plus un Carré, malgré les apparences, puisque sa largeur et sa hauteur diffèrent l'une de l'autre. Traiter c comme un Rectangle dans Modifier en a brisé les invariants! Notez qu'Afficher dit de chaque objet décrit à la console qu'il s'agit d'un Rectangle, ce qui est vrai dans la structure en exemple, même pour un Carré.
Dans l'exemple précédent, l'erreur est de dériver Carré d'une classe, Rectangle, qui n'offre aucun service polymorphique. L'héritage public a pour principal rôle de permettre le polymorphisme; y avoir recours pour d'autres raisons est probablement une faute de design.
Il est possible de faire fonctionner ces classes dans une hiérarchie (mais notez que « design qui fonctionne » ne signifie pas nécessairement design recommandable). Pour y arriver, il suffit d'ajouter du polymorphisme dans Rectangle, comme dans le code suivant :
Classe Rectangle simple | Classe Carré simple (mieux, mais...) |
---|---|
|
|
Programme de test | |
|
À l'exécution, ce programme affichera :
Rectangle 3 x 5
Rectangle 4 x 4
Rectangle 24 x 24
Appuyez sur une touche pour continuer...
L'introduction de méthodes (ici, de propriétés) virtuelles dans le parent Rectangle rend le programme fonctionnel et respectueux, en surface, des invariants de chacune des classes impliquées. J'écris respectueux en surface du fait que la méthode Modifier(Rectangle) agit sur le Rectangle en modifiant de manière indépendante sa largeur et sa hauteur. Du point de vue du code client, ces opérations devraient modifier indépendamment les deux caractéristiques du Rectangle reçu en paramètre, or l'implémentation polymorphique protège l'invariant du Carré dans le cas où un Carré (qui est aussi un Rectangle) est passé en paramètre à Modifier et fait en sorte que les attentes du code client ne soient pas rencontrées.
La clé, ici encore, est de reconnaître que l'héritage public se prête à décrire une relation entre deux classes dont les invariants sont compatibles et cohérents. Ce n'est pas le cas des classes Rectangle et Carré.
Conceptuellement, on pourrait débattre que Rectangle devrait être une classe terminale, donc une classe qui n'est pas destinée à avoir d'enfants, et qu'il devrait en être de même pour Carré. Sur la base de ce point de vue, on aurait ce qui suit :
Classe Rectangle simple | Classe Carré simple |
---|---|
|
|
Programme de test (https://dotnetfiddle.net/SKLUkk) | |
|
Notez l'introduction du mot clé sealed, faisant des classes ainsi qualifiées des classes « scellées ». Une classe qualifiée sealed ne pourra pas avoir d'enfants, simplement, donc qu'elle sera terminale. Sans que ce ne soit nécessaire (cet exemple fonctionnerai de la même manière sans que l'on ait recours à ce mot clé), le concept de classe terminale permet aux programmeuses et aux programmeurs d'affirmer dans le code un point de design, soit « je n'ai pas conçu cette classe pour qu'on puisse en dériver sans problème; si vous souhaitrez établir une relation entre elle et vos propres classes, l'héritage n'est pas une bonne option ».
Puisqu'un Carré n'est pas un Rectangle dans ce design, il n'est plus possible de définir une seule méthode Afficher et une seule méthode Modifier, toutes deux applicables à un Rectangle, puisque ce cas ne couvrirait plus la classe Carré. Ceci nous force à écrire un peu plus de code, ce qui est une forme d'irritant, mais nous verrons dans l'exemple suivant une manière de retrouver la sérénité.
à l'exécution, ce programme affichera :
Rectangle 3 x 5
Carré 4 x 4
Carré 24 x 24
Appuyez sur une touche pour continuer...
Enfin, si nous souhaitons décrire que nos classes terminales Carré et Rectangle ont en commun de respecter un (ou plusieurs) contrat(s), par exemple celui d'être nommé et celui d'être une entité 2D, il nous serait possible de leur faire implémenter une ou plusieurs interfaces, puis d'exprimer certaines de nos opérations de manière générique comme suit :
Interfaces | |
---|---|
|
|
Classe Rectangle simple | Classe Carré simple |
|
|
Programme de test (https://dotnetfiddle.net/ZzUubx) | |
|
Nous avons retrouvé la capacité d'exprimer une seule méthode Afficher, générique sur la base d'un type T quelconque. Pour les besoins de l'exemple, nous avons découpé l'affichage en deux temps :
C'est la clause where T : Entité2D, Nommé qui permet à Afficher<T> de décrire les règles applicables à un T. Concrètement, Afficher<T> s'applique à tout type T qui respecte les contrats décrits par les interfaces Nommé et Entité2D. La manière par laquelle une classe indique qu'elle s'engage à respecter un tel contrat est d'implémenter l'interface indiquée.
Par la suite, Nommer prend un Nommé en paramètre et appelle sa propriété Nom. De son côté, Décrire prend une Entité2D et en décrit la Largeur et la Hauteur. Ces deux méthodes ne sont pas génériques, puisque leur seule exigence est que l'objet reçu en paramètre implémente une interface précise.
Dans cette implémentation, j'ai gardé deux méthodes Modifier distinctes car l'interface Entité2D utilisée se limite à exiger des propriétés get. Si vous souhaitez modifier le design pour qu'il n'y ait qu'une seule méthode Modifier(Entité2D), il vous faudra ajouter une propriété set (ou un mécanisme semblable) à Entité2D. Je ne suis pas convaincu que ce soit sage, par contre; pour des raisons mentionnées plus haut, il me semble préférable de faire en sorte que le code qui souhaite modifier un Carré ou un Rectangle sache de quel type il s'agit, pour que les invariants associés à chacun lui soient connus. Cela rejoint à mon avis le principe de moindre surprise.
À l'exécution, ce programme affichera :
Rectangle 3 x 5
Carré 4 x 4
Carré 24 x 24
Appuyez sur une touche pour continuer...
Voilà!