Les unités de mesure

Les unités de mesure permettent d'annoter les nombres, améliorant ainsi la lisibilité et la sûreté du code F#.

Le système de typage permet d'assurer que le code est cohérent, que les fonctions appelées existent bien et que leurs arguments ont le bon type. On ne compare pas des pommes et des oranges, on n'additionne pas des mètres et des litres. Pourtant, dans un programme, les litres et les mètres sont souvent tous deux stockés sous forme de nombres, généralement des flottants. Partant de là, comment le système de typage pourrait-il se rendre compte si l'on fait une erreur, si l'on oublie une conversion ? Il est bien sûr possible d'encapsuler chacun dans une nouvelle classe, de surcharger les opérateurs usuels. Mais c'est lourd et il faudrait alors définir une nouvelle classe si l'on souhaite multiplier ou diviser deux unités (pour obtenir, par exemple, des mètres par seconde). C'est lourd, peu pratique et l'on préfère habituellement s'en passer.

F# permet d'annoter les nombres avec des unités. Contrairement à la solution évoquée précédemment, cela vient sans le moindre coût à l'exécution : cela se passe entièrement au moment de la compilation, le code généré ne diffère pas d'un octet.

Principe de base

Commençons par définir quelques unités du système international :

[fsharp]
[<Measure>] type s
[<Measure>] type m
[<Measure>] type kg

L'attribut Measure indique que ce ne sont pas des types ordinaires, car ils serviront à paramétrer les types int, float et les autres. Déclarons deux distances :

[fsharp]
let height = 4<m>
let width = 2<m>

L'aire d'un rectangle est le produit des longueurs de ses côtés :

[fsharp]
> let area = height * width;;
val area : int<m ^ 2> = 8

L'unité a été inférée : on obtient effectivement des mètres carrés. Si l'on multiplie le résultat par un entier, on aura toujours des mètres carrés, de même si l'on additionne deux superficies. En le divisant par des mètres, on récupère des mètres ; s'il est multiplié par des mètres, on se retrouve avec des mètres cubes. Tout calcul incohérent aboutit à une erreur de compilation :

[fsharp]
> area + 4<m>;;
error FS0001: The unit of measure 'm' does not match the unit of measure 'm ^ 2'

F# est aussi capable de manipuler des alias, ce qui est appréciable lorsque les unités deviennent complexes :

[fsharp]
[<Measure>] type Hz = s^-1
[<Measure>] type N  = kg m s^-2
[<Measure>] type N  = kg m / s^2   // notation équivalente
[<Measure>] type N  = kg m / s / s // encore une autre

Conversions

Si l'on manipule dans un programme plusieurs unités comparables (utiliser des grammes, des kilogrammes et des livres, ou avoir des euros et des dollars), on peut déclarer des constantes faisant la conversion. Par exemple :

[fsharp]
let dollar_per_euros = 1,3783<usd/eur>

On peut trouver plus propre de ranger cette valeur dans une méthode statique d'une unité (il n'est pas autorisé d'utiliser une méthode d'instance puisque, il a été dit, les unités n'ont pas d'existence à l'exécution) :

[fsharp]
[<Measure>] type lb // livre

type lb with
  static member to_kg(x) = x * 0.45359237<kg/lb>

> lb.to_kg(42.<lb>);;
val it : float<kg> = 19.05087954

Lors des entrées-sorties (on lit un nombre dans un fichier ou dans une interface graphique), les valeurs n'ont pas d'unité. Pour en ajouter une, il suffit de multiplier par une seconde, un mètre, etc. Pour la supprimer, il faut simplement diviser par cette unité. Il est également possible d'appeler directement les fonctions de conversion comme int et float.

[fsharp]
> area / 1<m^2>;;
val it : int = 8

> float area;;
val it : float = 8.0

Fonctions génériques

Rapidement, on désire pouvoir faire des fonctions génériques, pouvant manipuler n'importe quelle unité. Pour cela, on utilise le caractère _ (de la même façon que pour les autres types paramétrés) :

[fsharp]
> let sqr (x: float<_>) = x * x;;
val sqr : float<'u> -> float<'u ^ 2>

Quelle que soit l'unité u en entrée, le résultat aura pour unité u². Peu importe que le type en entrée soit simple ou non :

[fsharp]
> let speed = 20.<m/s> in sqr speed;;
val it : float<m ^ 2/s ^ 2> = 400.0

L'inférence s'en sort souvent très bien :

[fsharp]
> let foo (x: float<_>) (y: float<_>) = x * x + y * 1.<m>;;
val foo : float<'u> -> float<'u ^ 2/m> -> float<'u ^ 2>

Types génériques

Dans certaines applications, comme les jeux vidéo, on a à manipuler des vecteurs vitesse. Là encore, les unités de mesure s'intègrent facilement :

[fsharp]
> type Vector<[<Measure>] 'u> = {
   x: float<'u>
   y: float<'u>
}
with
  static member (+)(a, b) = {x = a.x + b.x; y = a.y + b.y}
  member v.Length = sqrt(v.x * v.x + v.y * v.y)
  static member ( *)(a, b) = {x = a.x * b; y = a.y * b}
;;

type Vector<[<Measure>] 'u> =
  {x: float<'u>;
   y: float<'u>;}
  with
    member Length : float<'u>
    static member ( + ) : a:Vector<'u0> * b:Vector<'u0> -> Vector<'u0>
    static member ( * ) : a:Vector<'u0> * b:float<'v> -> Vector<'u0 'v>
  end

En une seconde, un individu qui court se déplace de 4 mètres selon l'axe des x et de 5 mètres sur l'axe des y :

[fsharp]
let vect = {x = 4.<m/s>; y = 5.<m/s>}

Cela représente une vitesse de 6,4 mètres par seconde.

[fsharp]
> vect.Length;;
val it : float<m/s> = 6.403124237

En 3 secondes, il s'est déplacé de :

[fsharp]
> vect * 3.<s>;;
val it : Vector<m> = {x = 12.0;
                      y = 15.0;}

C'est-à-dire, près de 20 mètres :

[fsharp]
> (vect * 3.<s>).Length;;
val it : float<m> = 19.20937271

Conclusion

Les unités de mesure apportent énormément de sûreté dans les programmes manipulant beaucoup de nombres. Bien que les applications dans le domaine de la physique soient les plus évidentes, il faut voir plus loin et trouver des usages à d'autres occasions. Dans le domaine de la finance (monnaies), des mathématiques, des statistiques, des jeux vidéo (temps, distances, points de vie, argent), les utilisations sont multiples. En plus d'apporter de la sûreté, on gagne aussi en lisibilité. Si une créature d'un jeu a un champ « régénération », on comprendra mieux si la valeur est 10 points de vie par seconde, que si l'on a juste un nombre. Du fait de l'inférence de types, les annotations restent rares, à part pour les constantes : le code F# reste alors presque aussi concis.

Comments

1. On Saturday, June 20 2009, 16:58 by Rubix

Est-ce qu'il faut définir deux fonctions sqr pour pouvoir calculer le carré de 7 et celui de 7<s> (sans clutter chaque appel par du "* 1<s>") ?
Si oui, quelle est l'approche de la lib standard ?

2. On Sunday, June 21 2009, 23:12 by Laurent

Non : le type float est équivalent au type float<1>, qui est compatible avec le code qui possède des unités. La bibliothèque standard fournit la fonction sqrt qui fonctionne aussi bien sur du code classique que sur du code avec des unités. La fonction sqrt a pour type : float<'u^2> -> float<'u> (en vrai, le type est surchargé, mais c'est pour l'exemple). Un nombre sans unité est accepté, puisque 1² = 1.