J’aime les classes sealed ! [correction]
Par amethyste le Mai 13, 2006 | Dans focus | 4 retours »
Pour moi c’est un réflexe : dès que je construis une classe, je la scelle immédiatement, sauf bien sûr si elle a été spécialement créée pour être héritée.
Oulla ! J’en vois d’ici certains qui bondissent. Une classe sealed, et puis quoi encore ?
Mon point de vue est que sceller une classe à priori, présente plus d’avantages que d’inconvénients. C’est le sujet de ce blog et je tiens le pari qu’ensuite votre opinion va se nuancer sur cette question polémique. Et pas seulement parce que Don Box et Jeffrey Richter soutiennent mon point de vue… Bon OK, c’est plutôt l’inverse ;-)
Evolutivité
Je commence par celui-ci, car en général c’est l’unique argument que les contre abordent. De plus cet argument paraît suffisamment intimidant à bon nombre de développeurs pour ne pas l’analyser vraiment et aller voir au delà.
Les gars soyons honnête avec nous même et disons… un peu plus modestes.
Dans 99.99% des cas les classes que vous développez n’ont aucun intérêt en dehors du projet précis où vous les avez conçues.
Quand bien même on remonte d’un niveau et la classe étend les fonctionnalités de votre portail d’entreprise maison, le score va passer à 99% au mieux.
Le fait est que très peu d’entre nous travaillons chez un fournisseur de composants et donc dans 99% des cas il suffira de supprimer le sealed qui gêne et recompiler.
Ben oui, l’avantage d’un sealed est que je peux le supprimer sans casser aucun code, par contre il est impossible de le rajouter sans gros problèmes.
Performances
Cette fois c’est l’argument le plus fréquemment repris par les « pro-sealed ».
Observez un code comme celui-ci qui redéfinit le membre virtuel ToString().
class Class1
{
public override string ToString()
{
return base.ToString();
}
}
Un code comme new Class1().ToString() va bien entendu réaliser un appel à une méthode virtuelle, ce qui en IL se traduit par un appel callvirt :
callvirt instance string [mscorlib]System.Object::ToString()
Que se passe-t-il si la classe devient sealed ?
Le compilateur va voir que la méthode est virtuelle, mais aussi que la classe est sealed. Dans ces conditions Class1.ToString() ne sera jamais sur classée. Il est par conséquent inutile de mettre en place un appel de méthode virtuelle qui est plus coûteux que l’appel direct. Le compilateur va plutôt générer un code comme :
call instance string [mscorlib]System.Object::ToString()
Qui est beaucoup plus performant.
Mais encore faudrait il que les compilateurs fassent ce genre d’optimisation.
Il est facile de faire un bout de code implémentant les deux cas de figure et le décompiler avec ILDASM.
En C#, que la classe soit sealed ou non, on trouve la même chose:
callvirt instance string [mscorlib]System.Object::ToString()
Ennuyeux pour ma théorie n’est-ce pas ?
La question m’a vraiment troublé longuement jusqu’à ce que je trouve l’explication dans le dernier livre de Jeffrey Richter (p 170).
En fait le compilateur C# ne fait en général pas d’optimisation, ce travail est dévolu au compilateur JIT qui se charge de transformer callvirt en call et donc de faire l’optimisation souhaitée.
Invariants de classe, sécurité
C’est l’argument le moins connu, mais celui qui a ma préférence.
Le fait d’hériter implique diverses responsabilités car je délègue à une surclasse le contrôle du fonctionnement correct de ma classe de base.
Par exemple si je surcharge une méthode il peut être indispensable pour le fonctionnement correct de l’ensemble, voire la sécurité même de l’application, que je n’oublie pas d’appeler la méthode correspondante de la classe de base. L’exemple typique est celui de Dispose().
Or C# ne fournit aucun mécanisme pour forcer cela. Le fait même de pouvoir hériter et surclasser une méthode peut donc être vu comme un trou de sécurité.
Ce n’est pas tout.
La classe dispose également d’états et en particulier d’invariants. Il peut devenir critique de laisser à l’arbitraire d’une sur classe dérivée leur bonne gouvernance.
Le problème n’est pas tant que la chose soit possible, mais le fait qu’elle présuppose que le concepteur de la classe analyse avec soin les conséquences qu’il y a de laisser des utilisateurs dériver la classe.
Vous trouverez dans cet article un exemple très édifiant que je résume ici :
On dispose d’une classe Rectangle.
class Rectangle
{
int Hauteur {get ;set ;} ;
int Largeur {get ;set ;} ;
}
Bien des années plus tard on a besoin également d’une classe Carre. Quoi de plus naturel que de dire, un carré est un rectangle qui a réussi et donc d’écrire un code du genre :
class Carre : Rectangle
{
// ce qu’il faut pour avoir Hauteur = Largeur
}
Quels sont les problèmes posés par cet héritage ?
Tout d’abord Carre n’a besoin que d’une seule mesure. L’autre est redondante, mais consommera malgré tout des ressources. Et cela peut faire vraiment beaucoup de ressources si je développe une application graphique.
La classe Carre ne va pas seulement hériter de propriétés dont elle n’a pas besoin, mais aussi éventuellement de méthodes qui n’ont peut-être aucun sens pour elle.
Ce n’est certes pas un argument forcément rédhibitoire, mais il suggère déjà qu’il existe peut être un problème de conception quelque part : ces membres supplémentaires sont t’ils déclarés au bon endroit ?
D’autres problèmes plus graves peuvent surgir. Par exemple la fonction suivante apparaît dans mon application. Elle est parfaitement légale :
void MaFonction(Rectangle r) {…}
Tout objet implémentant Rectangle peut être reçu en paramètre et donc en particulier un Carre.
Supposons que le code de Mafonction() a besoin de calculer 1/(Hauteur – Largeur) . Cette opération ne pose jamais de problème pour un Rectangle.
Avec Carre, le code va lever DivideByZeroException. Et plus grave, on viole deux principes importants de la conception objet :
Le principe ouvert/fermé
La seule façon de parer au problème est de modifier d’une façon ou une autre le code de MaFonction().
Si MaFonction() est un membre de Rectangle cela signifie que l’on devra modifier une classe de base uniquement pour faire fonctionner un héritage. Et d’ailleurs MaFonction() a t‘elle seulement un sens pour un Carre ?
Le principe de substitution de Liskov
Il s’énonce simplement :
Toute fonction qui reçoit une classe de base doit être en mesure d’utiliser n’importe quel objet dérivé sans rien connaître de la classe dérivée.
A l’évidence ce n’est pas le cas.
Pas mal pour un exemple très simple, tellement simple qu’il fait partie intégrante de beaucoup de livre supposés nous enseigner les principes de la programmation objet?
Et encore on parle dans cet exemple d’un cas simpliste et facile à analyser. Alors que penser de situations plus sophistiquées ?
Que conclure de cela ?
Evidemment, je dresse, volontairement, un tableau caricatural. Il y a, c’est vrai, des tas de situations où la présence d’une classe non sealed ne pose aucun problème. Ne pas sceller une classe ce n’est pas le diable, mais cela peut l’attirer…
Le message est en fait ailleurs.
On est tous éduqué dans l’idée qu’un bon code doit permettre un maximum de souplesse, d’évolutivité…
C’est exact globalement, mais pas nécessairement sur les détails et un code doit d’abord fonctionner correctement.
Toutes les classes n’ont pas vocation à évoluer. De plus, certaines classes n’ont par essence aucune aptitude à évoluer par héritage et cela n’a rien à voir avec un choix d’architecture. Pire, le fait même d’y introduire de la souplesse est une faute de conception.
Ce que je veux dire est que ne pas mettre sealed uniquement par un présupposé sur l’éventuelle évolutivité d’une classe est un mauvais calcul car ce choix n’est pas neutre, il est même impactant et potentiellement dangereux. C’est donc un choix d’architecte et devrai être traité comme tel.
Finalement la caractéristique la plus intéressante de la programmation objet n’est peut être pas l’héritage d’implémentation. D’ailleurs relisez votre bouquin favori sur les GOF. Que sont ces modèles si ce n'est des moyens de se passer de l'héritage?
Bibliographie
CLR via C# :
Jeffrey Richter
Microsoft Press
The Liskov Substitution Principle :
http://www.objectmentor.com/resources/articles/lsp.pdf
Tête la première : design patterns :
O’Reilly Press
Des pensées impérissables sur l’usage (ou non) de sealed :
http://www.sellsbrothers.com/news/showTopic.aspx?ixTopic=411
Proportion de classes sealed dans .NET 2.0 :
http://www.bsdg.org/2005/11/sealed-class-count-in-net-20.shtml
I hate sealed, de Daniel Cazzulino:
http://weblogs.asp.net/cazzu/archive/2005/08/29/IHateSealed.aspx
4 commentaires
Je dois avouer que l'approche faite dans cet article me séduit. En effet, comme pour beaucoup d'entre nous, consultant en SSII, il semble illusoir de croire que toutes nos classes seront réutilisées et notamment par des concurrents sur les projets au forfait.
Attention, je n'entend pas par ce constat qu'il serait préférable d'avoir un code où tout serait écrit en dur sans aucun modèle de séparation des couches ... Mais comme tu le soulignes, il me parrait certainement plus important de privilégier la sécurité et la stabilité d'exécution de notre application avant même de se poser la question de la réutilisabilité de notre code dans un avenir que nous ne maitrisons souvent pas puisque n'intervenant pas pour notre compte mais celui d'un client.
Pour appuyer un peu plus l'approche sur le "sealed", il est aussi fréquent de voir des classes n'implémentant que des méthodes statiques ne pas être scéllées. Le Framework .Net V2 introduit donc la possibilité de marquer la classe elle-même comme static et de ce fait implique qu'aucune classe ne pourra en dérivée.
Bref, sujet intérressant et qui est certainement qu'une ouverture sur le débat mais cela me rassure de voir que je ne suis pas seul à me poser ce genre de questions lors de la modélisation de nos applications.
arno
Si on laissait tomber le fétichisme OOP = héritage et qu'on avait une généralisation des prototypes (JavaScript), des proxy dynamiques à peu près performants (presque Java), des classes dynamiques (Python, Ruby, Smalltalk) et de la délégation déclarative d'implémentation (Delphi) les choses seraient nettement moins fragiles.
Bon, à la place, C# devient peu à peu un langage fonctionnel, c'est pas mal non plus.
Tout à fait d'accord sur le sealed, sinon (mais si l'argument performances permet de convaincre les fanas du C++, il est assez secondaire à mon avis)
Supposons que le code de Mafonction() a besoin de calculer 1/(Hauteur – Largeur) . Cette opération ne pose jamais de problème pour un Rectangle
L'article est intéressant, mais ceci est totalement faux, un Rectangle a tout à fait le droit d'être carré.
Pour le sealed, je pense aussi qu'il est intéressant que tout programmeur se pose des questions sur ce qui sera fait dans sa classe et sache dire aux autres : "Je souhaite que vous utilisiez ma classe en la dérivant ou pas (non sealed)" ou bien "Je souhaite que vous utilisiez ma classe sans la dériver (sealed)".
Laisser un commentaire
| « Sécurité et Internet | Multithreading et Int64 » |