lucidiot's cybrecluster

Unités de PseudoScience

Lucidiot Informatique 2018-02-20
Un peu de blabla technique autour du nouveau backbone de mes études pseudo-scientifiques.


J'en avais parlé dans une étude pseudoscientifique précédente, Astropolitain, et j'en reparle maintenant ; je travaille depuis quelques mois sur un projet Python nommé PseudoScience et sur lequel j'ai encore beaucoup de choses à améliorer, comme entre autres la documentation. Le projet est destiné à me faciliter des calculs pseudo-scientifiques, comme par exemple le mouvement de véhicules ou encore les conséquences sur la santé humaine de la consommation en trop grande quantité de nourriture. J'espère pouvoir, à l'avenir, faire des calculs plus complexes et plus réalistes pouvant prendre en compte plus de paramètres, et ainsi pouvoir répondre à plus de questions.

Attention, l'article risque d'être un peu technique ; je décris comment fonctionne en interne une partie de ce projet, et une connaissance au moins basique de Python sera probablement nécessaire.

La plus grosse partie du projet, à l'heure actuelle, est représentée par la gestion des unités de mesure. C'est bien triste, mais Python n'étant pas un langage destiné seulement aux calculs mathématiques — loin de là — il n'a pas de gestion interne des unités de mesure. Il existe de nombreuses solutions pour la gestion des unités, et j'ai pendant un moment pensé à utiliser SciPy, AstroPy ou encore Pint pour Astropolitain. Cependant, comme je débutais en Python, je me disais qu'il pouvait être intéressant de gérer moi-même les unités, d'une manière qui serait simple pour moi. J'ai donc commencé à implémenter la Distance.

La librairie Skyfield, qui est en fait la toute première librairie Python que j'ai utilisé dans l'étude, comprend trois unités : Distance, Velocity, et Angle, dont je me suis inspiré. J'ai donc construit mes unités sur cette base, pensant que c'était la bonne manière Pythonique de faire. Python suivant une philosophie qui lui est presque propre et qui peut souvent être éloignée des langages que je connaissais ou des habitudes que j'ai, j'ai pensé que ce code open-source allait convenir. Et j'avais tort.

Pendant l'étude, je me suis mis à implémenter Distance, Time, Velocity, Acceleration, en prenant le temps d'ajouter des moyens de faire des conversions (en jours, en heures, en kilomètres, en années-lumière, en kilomètres-heure, en kilomètres-heure par seconde, etc.) en utilisant le même système que Skyfield, avec des constantes à chaque fois et une longue série de if name == 'unité': pour convertir tout seul. En ce qui concerne les opérations, j'ai surchargé les méthodes magiques de Python __add__, __mul__, etc. et ajoutais une autre série de if/elseif pour gérer la division, par exemple, d'une Distance par un Time pour donner une Velocity. Tout ce bordel impliquait une répétition absolument massive du code, ce que CodeClimate n'a pas hésité à me faire remarquer avec la centaine de problèmes de duplication qu'il a signalé lorsque je l'ai mis en place sur mon projet.

Dans une question que j'avais posé sur StackOverflow, on m'a d'ailleurs fait remarquer dans un commentaire qu'avoir une classe spécifique par unité semblait être une route vers l'enfer. Il est devenu nécessaire de faire quelque chose lorsque j'ai commencé à implémenter plus d'unités, pour faire évoluer le projet au-delà de la première étude pseudoscientifique qui l'utilise.

J'ai donc tenté d'utiliser d'abord un simple héritage ; une classe d'unité n'aurait qu'à initialiser des attributs pour spécifier avec quelle unité elle peut s'additionner, se multiplier, etc. C'était une bonne idée, mais l'héritage a fait que toutes les unités étaient reconnues comme capables de se multiplier entre elles, probablement à cause du duck typing de Python. En fait, c'était la méthode d'addition entre deux classes de type Unit qui était appelée, au lieu d'utiliser les sous-classes.

J'ai donc dû avoir recours à une technique que j'ai survolée sur un tutoriel OpenClassrooms de Python et qui est plutôt rarement utilisée dans des programmes Python normaux : les métaclasses. J'écris cet article plusieurs mois après cette réalisation ; on m'a ensuite appris qu'il existait des formes de métaclasses en Java par exemple, mais qu'elles sont bien plus complexes. Les métaclasses sont des classes dont l'unique but est de construire des classes. En Python, type est une fonction retournant le type d'une classe, mais elle est également une métaclasse.

Une métaclasse peut être apparentée à un décorateur, sauf qu'un décorateur (connu sous le nom d'annotation en Java) s'applique à une fonction, alors qu'une métaclasse s'applique à une classe. Elle hérite de type et contient une méthode de classe __new__ qui prend en paramètre une chaîne de caractères correspondant au nom de la classe, un tuple contenant les classes dont la classe hérite et un dictionnaire décrivant les attributs et méthodes de la classe. On peut jouer comme on en a envie avec ces arguments, et le but est de retourner type.__new__() auquel on passera les mêmes arguments.

Dans mon projet PseudoScience, j'ai donc créé une métaclasse UnitBase qui permet de créer des unités. Cette métaclasse fait un traitement assez lourd, et ajoute en fait à une unité une grande quantité de fonctionnalités. Afin de simplifier l'explication, prenons l'implémentation actuelle de l'unité de distance (la docstring a été réduite) :

class Distance(Unit):
    """Décrit une mesure de distance."""

    fullname = "meter"
    pluralname = "meters"
    convert = {'nm': 1e-9, 'm': 1, 'km': 1e3, 'au': AU_M, 'ly': LY_M}
    multiply = {'Distance': 'Area', 'Area': 'Volume', 'Force': 'Energy'}
    divide = {'Time': 'Velocity', 'Velocity': 'Time'}

Vous remarquerez d'abord que je fais un héritage d'une classe appelée Unit ; c'est une classe dont la métaclasse est UnitBase, ce qui me permet de faire un simple héritage et de faire abstraction de la complexité de mon implémentation. Vous vous demanderez alors probablement quel est l'intérêt d'utiliser une métaclasse à la place d'un héritage si je fais finalement un héritage quand même ; la réponse est que les fonctionnalités ajoutées par UnitBase ne sont pas vraiment prises en compte comme un héritage, et qu'à leur exécution elles font attention au véritable type d'unité.

Ensuite, observons les cinq seuls attributs nécessaires pour l'unité. Sachez que chaque unité prenait une bonne centaine de lignes au moins à chaque fois ; j'en ai désormais au moins une vingtaine, divisée en plusieurs modules, et je n'ai aucun problème. fullname décrit simplement le nom, au singulier, de l'unité par défaut (par convention, c'est celle du système international ou à défaut la plus courante). Ce nom est simplement utilisé à des fins d'affichage. pluralname est la même chose, mais au pluriel.

Ce qui est plus intéressant, ce sont les trois autres. convert est un dictionnaire spécifiant les possibilités de conversion. Ici, il deviendra possible d'instancier une distance en nanomètres par exemple, avec Distance(nm=800), ce qui est utilisé notamment dans le cadre des calculs liés aux ondes électromagnétiques. En clé est spécifié l'attribut utilisé, et en valeur un taux de conversion (1 nanomètre vaut 10?? mètres par exemple). On peut également spécifier à la place du taux de conversion une fonction lambda : c'est notamment utile pour l'unité de température puisque la conversion en Celsius ou Fahrenheit depuis des Kelvin est plus complexe.

multiply et divide sont deux autres dictionnaires spécifiant les capacités de multiplication et de division d'unités et le résultat qu'elles fournissent. Par exemple, multiplier une distance par une autre distance donnera une Area (une surface), et diviser une distance par du temps Time donnera une vitesse Velocity. En clé, on indique l'autre opérande, et en valeur le résultat.

Vous noterez que ces clés et valeurs sont spécifiés à l'aide de chaînes de caractères, alors qu'on aurait pu directement utiliser les classes. L'utilisation des classes aurait impliqué un grand nombre d'importations entre les modules d'unités (et pourquoi pas des imports circulaires d'ailleurs), et l'utilisation de chaînes de caractères va seulement venir compliquer la métaclasse.

L'implémentation de la métaclasse tient en fait un registre d'unités ; un dictionnaire associant chaînes de caractères aux véritables classes. Elle ignore d'ailleurs Unit dans celui-ci, ce qui permet de briser le problème d'héritage rencontré et de s'assurer que Unit est une sorte de classe abstraite que personne n'irait vraiment utiliser. À chaque appel de la méthode __new__, qui correspond à l'initialisation d'une nouvelle classe d'unité, la nouvelle unité est ajoutée au dictionnaire. Lorsqu'il y a des opérations impliquant l'interaction entre des unités, les méthodes ajoutées par la métaclasse vont aller rechercher si l'un des dictionnaires multiply ou divide, selon l'opération contiennent l'autre opérande, et vont aller rechercher l'unité en résultant et faire le calcul demandé.

Cela implique un dernier problème ; l'utilisation d'une telle métaclasse empêche en fait un héritage correct. Si on souhaite surcharger une méthode manuellement parce qu'il y a des fonctionnalités supplémentaires à fournir, la métaclasse va réécrire cette surcharge. Pour palier à ce problème, une compréhension de dictionnaire est utilisée et seules les méthodes qui n'ont pas été ajoutées dans la classe sont ajoutées par la métaclasse. Cela apporte le défaut qu'une surcharge de la méthode ne permet pas d'utiliser les méthodes de base de la classe, et qu'il devient alors nécessaire de tout réimplémenter soi-même.

Il y a donc encore quelques problèmes à cette implémentation, et je suis tout à fait ouvert à des suggestions (vous pouvez même participer directement au projet et proposer vos propres modifications). Ne me suggérez cependant pas d'aller utiliser d'autres systèmes d'unités déjà tout faits ; j'ai réalisé tout ça afin de m'entraîner à la programmation orientée objet en Python, et je veux le garder pour pouvoir l'améliorer au fil de l'acquisition de mes connaissances !


Commentaires

Il n'y a pour l'instant aucun commentaire. Soyez le premier !