11 août 2010

Proposition de mise à jour du standard UML

Bien évidemment, une telle proposition est principalement destinée à mes lecteurs, car il et peu probable qu'un membre du comité de normalisation passe par là et se l'approprie (sait-on jamais).

UML est un bon langage de modélisation. Il a toutefois un défaut : l'approche purement objet qu'il utilise rends complexe la modélisation de code dans des langages autres que Java ou C#. On peut modéliser une partie d'un projet écrit en C++, mais il est impossible d'en modéliser la totalité. Cette limitation vient du fait que UML ne permet pas de modéliser une fonction qui n'est pas attachée à une classe. Hors une telle fonction peut avoir une importance capitale dans la conception - la librairie <algorithm> du C++ ne serait que modérément utile si elle n'était pas composée que de fonctions libres ; en fait on peut argumenter que toute autre approche aurait été une erreur de conception.

Cette proposition élimine cette limitation.

Le problème

Imaginons le problème suivant : supposons que vous ayez à travailler sur une base de code assez importante et entièrement écrite en C. Malgré le langage, une grosse partie de ce code est fortement orienté objet. Le même problème se pose dès que vous utiliser un langage mixant les paradigmes objet et procédural (tel que le C++ ou même python). Votre mission est de découvrir les problèmes d'architecture potentiels présents dans cette base de code. Au vu des centaines de milliers de lignes, vous allez vite vous rendre compte que les erreurs de conception ne peuvent être détectée rien qu'en lisant le code. Votre seul recours est de pouvoir générer des diagrammes vous donnant une idée claire de la structure du code.

Quel diagramme peut vous être utile ? Votre première étape sera probablement de générer des graphes grâce à Doxygen. Hélas, ces graphes n'offrent qu'une vision locale de l'architecture du système - il ne sont donc par conséquent que d'un intérêt limité. Il vous faut des diagrammes vous donnant rapidement une vue d'ensemble du système.

Le premier diagramme que vous allez réaliser est très certainement un diagramme de classes UML donnant une vue des packages du système. Vous pourrez ainsi déterminer aisément quelques une des caractéristiques du système : quels sont les packages stables[1], quel est le degré de couplage entre les différents modules, les principes de conception des modules sont-ils respectés[2]. Grâce à ce schéma, vous allez pouvoir déterminer quels sont les packages qui vont recevoir la plus grande partie de votre attention.

Si vous étiez dans un monde objet pur, la seconde étape naturelle serait bien évidemment de réaliser les diagrammes de classe de chacun des packages pouvant poser un problème. Mais hélas, vous n'êtes pas dans un mode objet pure ; vous êtes dans un monde qui est en partie procédural, et UML n'aime pas le monde procédural. Vous pouvez chercher d'autres langages de modélisation, mais vous allez vite vous rendre compte qu'ils ne sont pas nécessairement adaptés : les rares langages pour lesquels des outils de modélisation graphique existent tendent à établir une vue fonctionnelle de votre modèle, et non pas une vue structurelle. Hors, la conception, c'est d'abord une approche structurelle.

Vous êtes dans une impasse : soit vous utiliser UML - mais ce langage de modélisation ne permet pas la représentation d'une architecture en grande partie procédurale ; soit vous n'utiliser pas UML et vous n'avez pas d'outils graphique à disposition ou ceux que vous avez ne sont pas adapté à une approche structurelle.

Retour aux source: qu'est-ce qu'une fonction ?

D'un point de vue procédural, une fonction (qu'elle soit libre ou membre d'une classe) est une opération qui est appliquée à un ou plusieurs paramètres pour produire un ou plusieurs résultats. Les paramètres sont explicites (ils participent à la définition du prototype de la fonction) ou implicites (variable globale ou statique, pseudo-variable créée par le compilateur...). De même, les résultats sont explicites (valeurs de retour de la fonction) ou implicites (stockage dans une variable globale, statique, ou dans un membre d'une classe). Voilà pour la vision procédurale.

Est-ce la seule vision qu'on peut avoir d'une fonction ? Il est évident que non - ce qui nous est prouvé par les langages implémentant le paradigme fonctionnel. Ainsi, en plus d'être une opération, une fonction a des propriétés (nombre d'arguments, type des arguments...) et des méthode (notamment une méthode d'évaluation qui permet d'exécuter la fonction). Les langages objet pur implémentent les fonctions comme des objets, ce qui leur permet de les traiter comme des entités de première classe. Les langages orientés objet (notamment C# ou C++) mettent en place certaines fonctionnalités pour contourner leurs limitations et offrir un comportement similaire dans certains cas précis (en C#, les delegates ; en C++, les function objects du standard actuel ; depuis la version 3, C# supporte les fonctions anonymes, qui seront à leur tour supportées en C++ dans le standard à venir).

On peut établir les relations suivantes :

  • une fonction-objet[3] possède un nom.
  • une fonction-objet possède des paramètres ; il y a là une relation de composition.
  • une fonction-objet est exécutée grâce à sa pseudo-méthode d'évaluation.
  • une fonction-objet appelle (donc utilise) d'autres fonctions.
  • une fonction-objet peut être possédée par une classe - il s'agit alors d'une fonction membre.

La proposition

Ces quelques relations sont intéressantes, parce qu'elles forment la base de notre proposition. Elles définissent une fonction comme étant une instance de la méta-classe "fonction". Il apparait dès lors logique de représenter une fonction libre sous la forme d'une classe dans un diagramme statique UML. Afin de ne pas confondre cette classe avec les autres classes du modèle, on lui affecte juste un stéréotype (j'ai choisi le stéréotype <<operation>>, mais d'autres sont possibles). Histoire d'illustrer mon propos, voici une représentation de la fonction F, prenant 2 paramètre (p1 de type entier, p2 de type C, C étant une classe).

Opération F() avec deux arguments

Dans l'idéal, une telle représentation est insuffisante - il serait plus judicieux d'avoir un symbole particulier associé à une telle entité UML. Dans les faits, les logiciels de modélisation UML ne permettent pas tout d'étendre aisément la banque de symboles disponibles, donc c'est une solution intéressante mais qui n'est pas viable (à moins, bien sûr, qu'un membre de l'OMG passe par ici et s'intéresse enfin à ce problème). En pratique, la solution proposée est aisément utilisable sur tous les logiciels de modélisation UML du marché à partir du moment où ceux-ci supportent l'affichage des stéréotypes (c'est à dire la quasi-totalité ; le logiciel ouvert StarUML les supporte - à noter que StarUML peut être étendu avec des plugins et que la solution idéale peut être implémentées aux prix d'efforts importants).

Cette notation peut aussi être utilisée pour représenter des fonctions membre d'une classe - grâce à un lien de composition : la classe est cliente, et la fonction est fournisseur. Cette possibilité est intéressante parce que dans le cadre d'une conception procédurale et orientée objet, il peut être difficile de retrouver la structure objet prévue. Si elle est explicite (les champs d'une structure sont des pointeurs sur fonction), le problème ne se pose pas vraiment. Mais une telle structure objet peut être implicite (le premier paramètre des fonctions et l'objet considéré ; par exemple, un module effectuant une gestion de machine d'état dont chaque fonction prend un pointeur sur une structure state_machine en tant que premier paramètre) et dans ce cas, il peut être agréable de pouvoir lier à postériori les <<operation>> à la pseudo-classe à laquelle elles appartiennent. L'exemple suivant présente une classe a_class composée d'un membre du type other_class et d'une fonction membre F.

Opération membre d'une classe

A noter qu'une classe avec le stéréotype <<operation>> ne peut pas être membre de deux classes (ce qui n'a pas de sens).

Pointeurs sur fonction

Un pointeur sur fonction est une notion importante au niveau de la conception. Un langage procédural comme le C ne permet pas la création de "fonctions abstraites" - les pointeurs sur fonctions sont donc la seule solution offerte au programmeur pour définir des interfaces abstraites.

Si l'on souhaite intégrer cette notion à la proposition, deux solutions différentes s'offrent à nous :

  1. Un pointeur sur fonction est une <<operation>> abstraite : puisqu'une opération est une classe, UML nous permet de la définir comme étant abstraite (le nom apparaît en italique). C'est la solution la plus simple, mais c'est aussi la moins lisible dès que le modèle devient important.
  2. Un pointeur sur fonction est une interface associée au stéréotype <<operation>> : cette solution est plus lisible, mais comporte ses propres problèmes. En effet, une interface UML est une classe dont le stéréotype est <<interface>>. Bien qu'une interface puisse tout à fait avoir un stéréotype propre (ce qui ajoute en fait un stéréotype à la classe), la plupart des logiciels du marché n'implémentent pas le support de plusieurs stéréotypes pour une même entité. Le second stéréotype n'est pas affiché - même s'il est présent dans le modèle. C'est tout de même la solution que j'ai adopté dans mes diagrammes. Si besoin, je rajoute une étiquette texte <<operation>> au dessus du symbole de l'interface afin de rajouter un peu de clarté au schéma.

Qu'on choisisse la première ou la seconde solution, on représente le lien entre le pointeur sur fonction et la fonction qui implémente la fonctionnalité souhaitée grâce à un lien de généralisation. L'exemple ci-dessus montre un pointeur sur fonction du type ptr_F et une fonction F qui a le même prototype et qui "implémente" ce pointeur sur fonction.

Une opération implémente un pointeur sur fonction



Conclusion

J'aimerais bien sûr que le standard UML se décide enfin à supporter les fonctions libres. Puisque ça n'est pas le cas à l'heure actuelle, je me suis vu dans l'obligation de proposer ma propre évolution à ce standard - une extension qui est supportée dans les grandes lignes par la plupart des logiciels de modélisation du marché. Bien évidemment, il se pose encore des problèmes : les fonctionnalités de génération de code ou de reverse engineering de ces produits ne supporte toujours pas les fonctions libres, ce qui reste rédhibitoire dans le cadre de l'étude d'une architecture système complète (car il faut dans ce cas créer l'ensemble des diagrammes à la main). Mais dès lors qu'on étudie un nombre réduit de modules, une telle approche permet non seulement de mettre en évidence certaines des caractéristiques de la conception mais aussi de prévoir les changements à effectuer et de vérifier leurs impacts.

Ne me reste plus qu'à attendre vos commentaires - utile, pas utile, déficient, pas déficient, etc.

Notes

[1] la notion de stabilité a été introduite dans un billet précédent, et j'y reviendrais certainement pour en faire un tour d'horizon complet

[2] j'y reviendrais de même dans un billet ultérieur.

[3] pas un fonction object du C++, mais une fonction vue en tant que objet de première classe

Commentaires

1. Le mercredi, août 11 2010, 19:53 par Romain Verdier

Intéressant !

Une petite note à ce sujet, et qui concerne plus particulièrement le monde .NET où les delegates sont effectivement des first-class citizens:

Un delegate est un type du système, tout comme les structs ou les class. Techniquement, tout type de delegate dérive de la classe un peu particulière Delegate, et possède son état (Target, Method, etc.) et ses méthodes (Combine, DynamicInvoke, etc.). Certains outils permettent d'ailleurs de les faire figurer dans des diagrammes de classes pseudo-UML (cf. les class diagrams de Visual Studio par exemple)

Mais à mon avis le réel problème réside dans le fait que l'on puisse vouloir les types de fonction nominatifs ou structurels. En .NET par exemple, les delegates sont des types nominatifs, ce qui permettrait à la rigueur de les assimiler dans une certaine mesure à des classes et de les considérer dans les diagrammes UML. Mais en .NET toujours, avec l'arrivée des Func & Action on comprend bien que la tendance est de simuler (par convention) un système de type structurel pour les "fonctions-objet".

Au cas où le langage considère que les types de fonctions sont des types structurels, alors le fait de les inclure dans un diagramme de classes perd un peu de son sens. Autant les considérer comme des types intrinsèques dans la notation.

2. Le mercredi, août 11 2010, 22:35 par Ekinox

D'abord, les fautes de français, juste pour t'embêter :p :
"une opération qui est appliquée à un ou plusieurs paramètres pour produire u ou plusieurs de résultats."
"Elle défini une fonction comme étant une instance de la méta-classe "fonction"."
"3 par un fonction object du C++, mais une fonction vue en tant que objet de première classe"

Sinon, ça m'a l'air d'une très bonne idée, à l'exception peut-être des méthodes qui seraient représentées par des entités, si j'ai bien compris. Ça nuirait à la lisibilité du diagramme, selon moi.

Enfin, quelques questions sur l'UML : J'arrive à peu près à le comprendre, mais est-ce que quelqu'un pourrait envoyer par exemple en réponse à ce commentaire un programme (compatible GNU/Linux et gratuit de préférence) qui sache générer le diagramme à partir du code C++ (la gestion du PHP serait aussi un plus, mais non nécessaire) ? Merci d'avance :)

Pour terminer (pour de bon), j'incite tous ceux qui ont des blogs à copier cette proposition sur leur blog, ou de mettre un lien vers ici, avec un peu de chance ça aboutira à la normalisation pour de bon :) (enfin, si Emmanuel est d'accord, bien sûr.).

3. Le jeudi, août 12 2010, 10:01 par Emmanuel Deloget

StarUML est capable de faire du reverse engineering à partir du C++ (open source & gratuit, sous Windows). BoUML (open source aussi, gratuit aussi, sous Windows & Linux ; plus compliqué au niveau de l'approche que StarUML selon moi) est aussi capable de parser du C++. Il offre en outre la possibilité de parser du PHP si je me rappelle bien.

Le problème de la plupart de ces produits n'est pas le reverse engineering, mais l'organisation des diagrammes une fois celui-ci effectué. Les entités doivent être repositionnées à la main, ce qui n'est pas toujours pratique (StarUML a bien une fonction "layout diagram" mais je n'ai jamais réussi à la faire fonctionner ; de toute façon, je ne suis pas sûr que le problème soit décidable - c'est un problème classique de routage, conceptuellement proche du problème du sac à dos qui est NP-complet).

Sinon, je n'ai rien contre l'idée que cette proposition soit affinée par l'OMG, donc je n'ai rien contre l'initiative que tu proposes :)

(Merci pour les fautes ; je corrige ça immédiatement)

4. Le samedi, août 21 2010, 01:10 par Ekinox

Pour le problème de layout, dans ce cas, comment se fait-il que dot (série d'outils graphviz, je crois), soit capable d'ordonner ses sommets pour que les arcs ne se croisent pas ? (en même temps, je n'ai encore testé qu'avec 5 sommets, donc ce n'était pas si compliqué ...)
Et, quand bien même le problème serait NP-complet, je pense qu'il serait peut-être possible que dot (par exemple) utilise la solution de recherche lente jusqu'à un certain nombre de sommets/arcs, puis se résolve à utiliser un algorithme d'approximation (glouton ? génétique ? Je n'ai encore jamais tenté de résoudre un problème NP-complet).
Enfin, de toute façon, merci de la réponse :)

5. Le samedi, août 21 2010, 11:58 par Emmanuel Deloget

Tout problème NP-complet a une solution naive en O(k^n) ou en O(n!) (me rappelle pus, trop loin). Pour un petit nombre d'éléments, une solution peut être décidée dans un temps acceptable. De plus, de nombreux problèmes acceptent une heuristique donnant des résultats corrects (mais pas exacts) dans un temps polynomial.

Le problème du layout n'est pas d'empêcher tout croisement, mais de trouver la position des entités UML de manière à ce que le nombre de croisements soit le plus bas possible (si 3 entités dépendent chacune de 3 autres entités, il y a nécessairement au moins un croisement). Certaines heuristiques existent (cf cette page) mais je ne connais pas, à l'heure actuelle, d'algorithme sortant un réponse parfaite dans un temps polynomial. Et je dis ça parce que j'ai à faire un diagramme UML comportant un bon millier d'entités, chacune d'entre elle pouvant être reliées à un grand nombre d'entités.

Un tel algorithme me plairait beaucoup :) En attendant, je fais tout à la main...

6. Le samedi, août 21 2010, 14:36 par Ekinox

Si le problème est NP-complet, alors la solution parfaite en un temps polynomial risque d'être compliquée ...
Par contre, je pense qu'une bonne heuristique peut suffire à minimiser déjà pas mal le nombre de croisements, quitte à finir l'amélioration à la main. Par exemple, un algorithme génétique avec pour chromosome la série des coordonnées des points (X1Y1X2Y2...) sous forme binaire (peut-être que numérique irait aussi, mais il risquerait de nécessiter des 0 de remplissages et d'être plus lent ... A tester), et avec une fonction d'adaptation comme (nb_arcs * (nb_arcs - 1) / 2) - nb_croisements (pour que le nombre de croisements diminue l'adaptation en augmentant), un bon résultat devrait être possible à obtenir plus vite qu'à la main (à moins que tu ne sois rapide du poignet :D )
Par contre, comme rien que compter les croisements est une opération en O(A²) avec A le nombre d'arcs, donc ce ne sera pas fini en deux secondes (tout dépend du nombre d'itérations, mais bon ...). Bref, je pense qu'un bon algorithme génétique devrait suffire à résoudre un problème comme le tien en pas trop de temps, tout en te laissant finir de perfectionner à la main.

7. Le lundi, août 23 2010, 10:06 par Emmanuel Deloget

Un tel algorithme peut être intéressant. Il est censé aboutir à une réponse parfaite si on lui laisse une infinité de temps pour s'exécuter, mais devrait effectivement obtenir de bons résultats en un temps relativement court.

Le fait d'arranger les quelques problèmes restants à la main n'est pas complètement rébarbatif. C'est déjà mieux que tout faire à la main, comme je fais en ce moment.

Par contre, je ne vais pas m'en aller le coder. Trop de fainéantistravail... :)

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet