Du bon usage des types

Même pas une semaine depuis que j’ai publié Zinc et déjà un bug ! Une sombre histoire de fonction qui reçoit un chemin relatif alors qu’elle attend un chemin absolu. La correction ne m’a pris que quelques minutes, mais ça ne serait jamais arrivé au départ si j’avais utilisé des types différents et incompatibles pour les chemins absolus et les chemins relatifs : le compilateur aurait détecté l’erreur dès la compilation. (Le pire est que j’y avais pensé, mais par flemme, je ne l’avais pas fait.)

Ce bug est l’occasion parfaite d’évoquer l’équivalence de Curry-Howard. En gros, que dit-elle ? Qu’il n’y a pas de différence entre établir une démonstration mathématique et écrire un programme fonctionnel correctement typé. C’est la même chose. Autrement dit : si vous écrivez un code typé et qu’il compile sans erreur, c’est littéralement une démonstration mathématique qu’il est correct et qu’il fonctionne. Votre compilateur est un démonstrateur de preuve.

Sous certaines conditions, bien sûr !

La première, c’est évidemment d’utiliser un langage fortement et statiquement typé. Exit le C, JavaScript, PHP, ou Python. C’est jouable avec C++ mais à condition de l’utiliser correctement et non comme un C amélioré… (Globalement : en déclarant les constructeurs explicit et en ne surchargeant pas les opérateurs de coercition de type pour éviter les conversions silencieuses.)

Ensuite, une telle preuve ne garantit pas que votre programme implémente correctement la spécification demandée. Si vous avez une boucle infinie, ou un signe plus à la place d’un signe moins dans un calcul, ou un signe supérieur à la place d’un signe supérieur ou égal dans une comparaison, le programme est logiquement correct, il compile et il tourne, mais il produit un résultat faux vis-à-vis de ce qui est attendu.

Une autre condition est qu’il n’y ait pas d’effet de bord. (D’où l’importance de fonctionnel dans l’expression programme fonctionnel correctement typé ci-dessus.) Un effet de bord typique, c’est une erreur ou une exception. Considérez la déclaration suivante :

int foo(string);

Vous pensez être en face d’une fonction foo qui prend une chaîne de caractères et qui retourne un entier ? Si cette fonction peut aussi envoyer une exception, ou bien retourner une valeur spéciale qui représente une erreur, vous avez en réalité une fonction qui prend une chaîne de caractères et qui retourne un entier ou une erreur. D’un point de vue logique formelle, c’est totalement différent : string → int n’est pas la même chose que string → int ∨ error et du coup, une démonstration logique impliquant cette fonction ne sera plus équivalente à la simple vérification de son typage. Pourtant, dans la plupart des langages, la nuance ne saute pas aux yeux à la lecture du code parce que l'envoi d'exception ou d'erreur ne transparait pas dans la syntaxe de la déclaration.

Enfin, la dernière condition, c’est que vous utilisiez le concept de type correctement, c’est-à-dire non pas pour indiquer au compilateur comment stocker, représenter et manipuler vos données, mais pour lui indiquer ce qu’elles sont. Autrement dit, utiliser le typage du langage non pour indiquer au compilateur que telle donnée est une chaîne de caractères, mais pour lui indiquer qu’il s’agit d’un chemin relatif, ou un chemin absolu, ou une URL, ou un nom d’utilisateur, etc. Ainsi, si vous affectez par mégarde une URL à une variable qui attend un nom de fichier, vous le saurez dès la compilation : il y aura une erreur type mismatch, qui voudra dire en réalité que le compilateur n’aura pas pu établir une preuve que votre code est correct.

Un autre exemple peut-être plus parlant : les fonctions de manipulation de dates. Souvent, elle prennent une flopée d’arguments, une année, un mois, un jour, des heures, des minutes, des secondes, etc., et on ne se rappelle jamais ni lesquels ni dans quel ordre. Pourquoi ? Parce que ces fonctions sont déclarées pour accepter une liste d’entiers.

long makedate(int, int, int, int, int, int);

On est bien avancé ! Une telle déclaration indique certes que cette fonction manipule des nombres, mais ça ne dit rien de ce que représentent ces nombres, donc ni le compilateur ni le développeur ne peuvent raisonner valablement sur la façon de l’utiliser. En revanche, si le langage propose de vrais types de données appropriés, et qu’il n’existe pas de conversion implicite possible entre ces types, cela change tout :

timestamp makedate(year, month, day, hour, minute, second);

Non seulement la signature de la fonction indique sans ambiguïté ce que représentent les paramètres attendus et la valeur de retour, mais de plus, le compilateur peut vérifier dès la compilation que le développeur utilise la fonction correctement.

En pratique, comment faire ? En créant autant de types que nécessaires. C’est assez trivial en Haskell parce que le langage est prévu pour, c’est un peu plus verbeux en C++ mais le jeu en vaut la chandelle. Voici par exemple une classe pour représenter une année :

class year {
public:
    year() : year_(0)                                               {                                     }
    explicit year(int y) : year_(y)                                 {                                     }
    year(year const & other) : year_(other.year_)                   {                                     }
    year & operator = (year const & other)                          { year_ = other.year_; return *this;  }

    friend bool operator == (year const & lhs, year const & rhs)    { return lhs.year_ == rhs.year_;      }
    friend bool operator != (year const & lhs, year const & rhs)    { return lhs.year_ != rhs.year_;      }
    friend bool operator <  (year const & lhs, year const & rhs)    { return lhs.year_ < rhs.year_;       }
    friend bool operator >  (year const & lhs, year const & rhs)    { return lhs.year_ > rhs.year_;       }
    friend bool operator <= (year const & lhs, year const & rhs)    { return lhs.year_ <= rhs.year_;      }
    friend bool operator >= (year const & lhs, year const & rhs)    { return lhs.year_ >= rhs.year_;      }

    year & operator += (int interval)                               { year_ += interval; return *this;    }
    friend year operator + (year const & lhs, int rhs)              { year tmp(lhs); return tmp += rhs;   }

    int get() const                                                 { return year_;                       }

private:
    int year_;
};

En gros, on encapsule juste un entier dans une classe avec un nom significatif, on désactive les conversions implicites pour que le contrôleur de type de C++ fasse son boulot, et on surcharge les opérateurs qui ont du sens, et uniquement ceux-là. Ici, par exemple, ça a du sens de comparer deux années, ou d’ajouter un nombre d’années à une année, mais pas d’additionner deux années ou de les multiplier. On prévoit aussi un accesseur get() pour les rares cas où l’on a réellement besoin d’accéder à la valeur encapsulée (par exemple pour la passer à printf, quoique dans ce cas il vaille sans doute mieux surcharger l’opérateur << d’injection dans un flux). L’impact de tout ceci en terme de performance est nul : comme il n’y a ni virtuelles ni héritage, une telle classe n’occupe que la taille de la donnée encapsulée (en l’occurrence un int) et elle peut donc être passée et retournée par valeur sans surcoût ; et comme toutes les fonctions et opérateurs sont triviaux et définis inline, ils sont optimisés et éliminés à la compilation. Il n'y a que des avantages et notamment le plus important : le compilateur peut désormais valider la sémantique de votre code, et plus uniquement sa syntaxe.

Comme une telle pratique tant à multiplier les déclarations de ce genre, on tirera avantage des namespace de C++ pour les regrouper logiquement. Par exemple, les classes year, month, day, hour, minute et second trouveront leur place dans un namespace date.

Bon typage ! N’oubliez pas : votre compilateur est votre meilleur ami, il est un peu agaçant parfois mais il est là pour vous aider...