[CppCon 2015] Retour sur la conférence de B. Stroustrup

Améliorons l'expressivité du C++ sans frais

La conférence annuelle CppCon 2015 a eu lieu la semaine dernière du 21 au 25 Septembre. CppCon est la grande-messe du C++ où se rencontrent de nombreux utilisateurs et concepteurs du langage pour échanger et discuter. La semaine fut rythmée par de nombreuses conférences qui sont enregistrées et mises à disposition sur Youtube.

Les premières vidéos de cette session 2015 sont déjà disponibles : on y trouve la keynote de Bjarne Stroustrup, « Writing Good C++14 » ainsi que la vidéo « Writing Good C++14… By Default » de Herb Sutter. D'autres devraient arriver d'ici peu !

Dans sa keynote d'ouverture, Bjarne Stroustrup (le créateur du langage) a développé de nombreux points (dont le projet CoreGuideLine qui sera développé dans un prochain article), mais on va ici s'attarder sur deux idées : owner et not_null. Mais avant de voir de quoi il en retourne, regardons ce qui compose la grande majorité des bugs en C++ et quels problèmes ces outils vont résoudre.

Les erreurs classiques

Le C++ possède (en raison de son histoire) une forte compatibilité avec le C, qui peut se traduire par un certain nombre de problèmes de design si on cherche à calquer l'utilisation du C++ sur le C. Ce mauvais C++ est souvent qualifié de C++ old school, C with classes par opposition au « C++ moderne » qui vise à promouvoir des outils et patterns efficaces en terme de sécurité, maintenabilité et facilité de lecture du code.

Mauvaise utilisation des pointeurs

Parmi les points les plus critiqués du C++ old school on trouve les pointeurs et les accès aux tableaux. Pour rappel, pour utiliser un pointeur, celui-ci doit être valide, c’est-à-dire qu’il doit pointer sur un objet valide. Les erreurs proviennent du fait qu’un pointeur vaut nullptr (qui correspond à une adresse invalide) ou que le pointeur pointe sur un objet invalide (dangling pointer, soit parce que le pointeur n’est pas correctement initialisé, soit parce que l’objet a été détruit).

1
2
3
4
5
6
7
8
9
Object* p = nullptr;
p->f();  // erreur, appel sur nullptr

Object* pp;
pp->f(); // erreur, pointeur invalide

Object* ppp = new Object;
delete ppp;
ppp->f();  // erreur, pointeur invalide

Bien sûr, sur ces codes aussi simples, l’erreur est évidente (bien que beaucoup de personnes ignorent en fait qu’un pointeur non initialisé ne vaut pas nullptr, mais prend une valeur aléatoire). Mais on rencontre souvent ces erreurs dans de vrais projets, ce ne sont pas que des problèmes théoriques.

Le second type d’erreur sont les accès en dehors des limites d’un tableau.

1
2
int array[5];
array[10] = 0;  // erreur, accès hors limite

Avec les tableaux C++ (std::vector, std::array, etc.), il est possible de tester facilement ce problème en utilisant la fonction membre size et une assertion.

1
2
3
std::vector<int> array(5);
assert(10 < array.size());  // produira un crash ici
array[10] = 0;

Il est possible (et je recommande) de toujours mettre un assert devant un accès à un tableau.

Le problème est plus compliqué avec les tableaux de style C. Ceux-ci ne conservent pas leur taille et il est facile de perdre cette information. Un dernier type d’erreur est la fuite de mémoire (memory leak). Cela arrive lorsqu’un objet créé dynamiquement n’est pas correctement libéré.

1
2
3
4
5
int* p = new int;
p = new int;  // perte du pointeur sur le premier objet créé

int* p = new int[10];
delete p;  // appel de delete au lieu de delete[]

Undefined Behavior

Beaucoup de débutants (ou plus expérimentés) font une erreur classique : les problèmes décrits précédemment ne produisent pas d’erreur de compilation. Et ne produisent pas non plus de crash. Pas toujours en tout cas. Et jamais en indiquant la ligne de code qui pose problème. Ce type d’erreur produit ce que l’on appelle un comportement indéfini (Undefined Behavior ou UB dans le jargon). Cela signifie que le comportement du programme n'est pas garanti par la norme. Il peut continuer à avoir un comportement normal, ne pas crasher, mais donner des résultats faux, ou crasher à n’importe quel moment. Le comportement observé peut changer en fonction du compilateur ou des options de compilation, ce qui rend ce type d’erreur très difficile à diagnostiquer et parfois à corriger.

Accéder à une zone mémoire invalide est un exemple d'undefined behavior et ceci a plus de chances de se produire quand on manipule des pointeurs nus, car on dispose de moins d'informations sur ce dernier (est-ce un tableau ? dois-je libérer la zone pointée ?) et le programmeur est donc plus susceptible de commettre une erreur.

Une solution ?

À cause de ces problèmes et de la difficulté pour les diagnostiquer, le C++ est souvent considéré comme un langage de programmation complexe. La solution proposée par B. Stroustrup est finalement assez simple.

Ne faites pas cela !

Quand on rencontre des problèmes avec une approche, un concept, une syntaxe, le plus simple pour éviter les erreurs est de ne pas faire cela.

Bon, ok, c’est un peu facile. Qu’est-ce que l’on doit faire à la place alors ?

Il existe beaucoup de nouveaux concepts en « C++ moderne », mais ces concepts ne sont pas liés à une norme du langage en particulier (C++03, C++11 ou plus). Un exemple classique sont les pointeurs « intelligents », beaucoup pensent que c’est un des ajouts majeurs du C++11. Mais ce n’est pas le cas ! La nouveauté du C++11 est simplement de proposer une implémentation de ces pointeurs dans la bibliothèque standard. Les pointeurs intelligents sont utilisables en C++03, en utilisant des bibliothèques (Boost, Qt, etc.) ou en les implémentant soi-même.

Pourtant, nombre de ces concepts ne sont pas encore assimilés par les développeurs C++ (débutants ou anciens). Les raisons sont multiples, mais il y a un point cité par B. Stroustup qui m’intéresse particulièrement. Dans de nombreux cas, on a tendance, dans les guidelines ou sur les forums, à donner des règles de façon assez directive (« ne fais pas cela ») sans forcement expliquer le pourquoi et donner l’approche correcte. Alors qu'il est plus logique de montrer le bon comportement dès le début plus que dire ce qu'on ne doit pas faire.

Quelques approches originales

Je ne vais pas entrer dans le détail de toutes les solutions en « C++ moderne » permettant d’éviter ces problèmes (c’est l’objet de mon cours C++), mais détailler deux concepts présentés par B. Stroustrup : owner et not_null. Une proposition d’implémentation par Microsoft est publiée sur GitHub.

Owner

L’implémentation de ce type est tellement simple que je me permets de copier ici le code provenant du GitHub de Microsoft.

1
2
template <class T>
using owner = T;

Là, normalement, vous vous dites.

Ils se moquent de moi ? Ce type ne fait absolument rien !

Et vous auriez parfaitement raison :)

Mais analysons la situation plus en détail. Ce type a pour objectif de résoudre le problème de la libération de la mémoire.

Généralement, l’acquisition d’une ressource n’est pas la source principale de problèmes dans un programme. Le plus simple est que chacun est responsable de ses ressources, on les alloue avant de les utiliser et on les libère lorsque l’on n’en a plus besoin. Plus souvent, on va utiliser une ressource qui a été allouée par quelqu’un d’autre. Dans les cas extrêmes, il y aura un responsable dédié pour un type de ressource, tous ceux qui veulent ce type de ressource doivent passer par lui.

Par contre, la libération est plus problématique : personne ne va prendre la responsabilité de libérer la ressource, tout le monde va considérer qu’un autre le fera. Ce problème survient parce que personne n’est clairement désigné pour être le propriétaire d’une ressource, c’est-à-dire celui qui est responsable de la libérer (ownership ou « propriété » en français).

À mon sens, l’apport principal des pointeurs intelligents du C++11 n’est pas les pointeurs eux-mêmes, mais la réflexion que cela a engendré sur l’importance de définir clairement qui est le propriétaire.

Le problème est simple : on doit toujours libérer les ressources. Mais comment savoir qui doit le faire et quand le faire ? Prenons un code d’exemple.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void f(int* p) {
    // On reçoit une ressource du code appelant, 
    // doit-on la libérer ?

    int* pp = g();
    // Autre ressource reçue, doit-on la libérer ?

    int* ppp = new int;
    h(ppp);
    // On donne une ressource à un autre. Comment
    // dire que l'on souhaite qu'il la libère ?
    // Ou lui dire que l'on va la libérer
    // nous-même ?
}

À chaque fois, il manque un moyen de dire qui est le propriétaire de la ressource, si on devient propriétaire ou pas d’une ressource que l’on reçoit, ou si on donne la propriété ou non d’une ressource à un autre. C’est donc un simple problème d’expressivité, pouvoir dire ce que l’on souhaite faire. Qui n’a pas été confronté un jour à un code écrit par un autre et s’est demandé ce qu’il voulait faire ?

Le rôle de owner est de simplement exprimer cette intention sur la propriété. L’idée est qu’une ressource transmise en utilisant owner transmet la propriété, une ressource transmise par un pointeur nu ne la transmet pas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void f(int* p);
    // f ne prend pas la propriété, si on lui donne
    // une ressource, il ne va pas la libérer.

void g(owner<int*> p);
    // g prend la responsabilité de la ressource. On
    // n'est plus propriétaire de celle-ci et on ne pas la
    // libérer.

int* h();
    // h transmet une ressource, mais pas la
    // propriété, on ne doit pas la libérer.

owner<int*> i();
    // i transmet une ressource et la propriété,
    // il faut libérer la ressource.

Bien sûr, ce type est purement indicatif pour le développeur, une mauvaise utilisation ne va pas provoquer d’erreur de compilation ou d’exécution.

Il est par contre possible d’utiliser des outils d’analyse statique, qui pourraient prendre en compte ce type d’information.

En suivant une règle simple, on peut alors améliorer la qualité du code, et éviter les problèmes de libération.

Quand on est propriétaire d’une ressource (que ce soit une ressource que l’on a allouée soi-même ou que l’on a reçue), lorsqu'on arrive en fin de portée du pointeur, soit on transmet la propriété à un autre (dans une fonction que l’on appelle ou en retour de fonction), soit on libère la ressource.

Il suffit parfois d’améliorer l’expressivité pour améliorer la qualité d’un code.

Certains l’auront compris, owner est une transmission de propriété non partagée. S’il faut avoir plusieurs propriétaires, owner ne pourra pas être utilisé, il faudra utiliser par exemple std::shared_ptr ou trouver un autre moyen d’indiquer la propriété. Je précise quand même que le partage de la propriété devrait être une situation d’exception plus que la règle.

Not_null

La seconde classe n’est pas tellement plus compliquée, elle tient en quelques lignes. Je vous laisse aller voir le code sur le GitHub de Microsoft. L’idée est assez simple : on ne doit pas utiliser un pointeur nullptr ? Il suffit d’interdire à un pointeur de l’être !

Pour cela, la classe not_null, qui possède une sémantique de pointeur, interdit explicitement l’utilisation de nullptr, 0 ou NULL. À la construction.

1
2
not_null(std::nullptr_t) = delete;
not_null(int) = delete;

Et pour l’affectation.

1
2
not_null<T>& operator=(std::nullptr_t) = delete;
not_null<T>& operator=(int) = delete;

Ces opérations supprimées permettent de produire une erreur lorsque l’on essaie de créer un pointeur nul.

1
2
3
int main() {
    not_null<int*> p = 0;
}

Ce code affiche ce qui suit.

1
2
3
4
5
6
7
8
9
main.cpp:59:20: error: conversion function from 'int
' to 'not_null<int *>' invokes a deleted function
  not_null<int*> p = 0;
                 ^    ~
main.cpp:12:5: note: 'not_null' has been explicitly 
marked deleted here
  not_null(int) = delete;
  ^
1 error generated.

Les opérations arithmétiques sont aussi interdites (cette classe ne doit pas servir pour un pointeur sur un tableau).

1
2
3
4
5
6
7
8
not_null<T>& operator++() = delete;
not_null<T>& operator--() = delete;
not_null<T> operator++(int) = delete;
not_null<T> operator--(int) = delete;
not_null<T>& operator+(size_t) = delete;
not_null<T>& operator+=(size_t) = delete;
not_null<T>& operator-(size_t) = delete;
not_null<T>& operator-=(size_t) = delete;

Il est bien sûr assez facile de « tromper » le compilateur, en faisant une conversion ou en passant par une variable intermédiaire.

1
2
3
int main() {
    not_null<int*> p = static_cast<int*>(nullptr);
}

Mais c'est du ressort explicite du programmeur que de faire une telle chose.

La sécurité de not_null n’est pas non plus garantie par le compilateur ou lors de l’exécution. C’est surtout une amélioration de l’expressivité du code. Si on manipule un not_null, on sait que l’on ne doit pas passer un pointeur nul ou qui peut être nul. Basiquement, cela veut dire que l’on ne doit pas affecter un pointeur nu à un not_null.

1
2
3
int* p = f();
not_null<int*> q = p;  // violation du contrat
                       // de not_null

Conclusion

On voit par ces deux exemples simples l’importance de l’expressivité. Une erreur dans un code peut survenir parce que l’on ne sait pas tout de suite ce que l’on doit faire, quand on doit libérer une ressource, quand un pointeur est nul.

L’amélioration du code « C++ moderne » n’est pas simplement une affaire de syntaxe et notions complexes à maîtriser, mais aussi transmettre les bonnes attitudes et questionnements aux développeurs C++, comme se poser la question de la propriété d’une ressource ou de la nullité d’un pointeur.

Ou de ne pas utiliser de pointeurs lorsque cela n’est pas nécessaire (utiliser des références, des passages par valeur ou par déplacement).

Note : cet article est directement inspiré du billet de blog suivant [CppCon 2015] Stroustrup : Don’t do that! et adapté par Davidbrcz pour Zeste de Savoir.

Merci à Dominus Carnufex pour la lecture orthographique et grammaticale.


11 commentaires

Même dans les langages de haut niveau comme Java il faut penser à libérer la mémoire (ex: fermeture de flux, de fichier, de connexion JDBC etc). Je fais en sorte (si possible) de libérer la mémoire dans le même scope où je l'ai alloué, ainsi je n'ai pas à chercher dans une fonction en mode poupée russe où je la libère.

+0 -0

Ce qui est assez marrant, c'est que ces concepts sont aussi présent en Rust et forment même la base du langage. Le compilateur vérifie d'ailleurs à la compilation que les ressources n'ont qu'un seul propriétaire, et que les pointeurs ne sont pas nuls. C'est ce qui permet à Rust d'éviter la plupart des erreurs de mémoire!

Un autre concept plus ou moins empruntée (toujours le problème de la poule et de l'oeuf) à Rust est celui des durées de vie (lifetime). Je crois que quelque chose a été proposé à ce sujet lors de la CppCon15.

Étant donné que Rust est une alternative plausible au C++ dans pas mal de domaines d'applications, c'est une bonne chose que la concurrence et l'émulation permette à tous les langages de s'améliorer !

+0 -0

Lu'!

J'ai regardé le talk de Stroustrup hier et commencé celui de Sutter.

Les idées sont très intéressantes (même si elles ne sont pas nouvelles), plus particulièrement ce qui est intéressant c'est de voir ce qu'on peut faire pour continuer à enrichir, améliorer et donner des possibilités de simplifications à une usine à gaz aussi massive que ce qu'est devenu C++. Tout en se refusant à tuer la compatibilité de l'existant et à détériorer les performances (idéalement on veut même aller plus vite).

Ces guidelines sont vraiment les bienvenues, notamment parce qu'elles vont certainement répertorier un grand nombre de choses qui sont archi-connues par les gens qui développent communément en C++, mais trop peu enseigné (et on peut espérer que ça a plus de chances de percer de ce côté si le défi de les faire simples est relevé).

Je suis particulièrement intéressé par l'outil d'analyse statique qu'ils mentionnent ce côté, plus de feedback sur le code qu'on écrit, c'est la possibilité d'écrire du code plus robuste. Je suis assez pressé que la vidéo de présentation soit disponibles notamment parce que j'aimerai savoir jusqu'où va leur analyse statique, si c'est plus que lexical, ce serait vraiment génial ! (bon j'y crois pas trop quand même).

D'ailleurs le raffinement de la notion de "unsafe" (présente en Rust par exemple), à de simples désactivations de guideline particulières me semble particulièrement saine pour ne pas débrayer trop sèchement les contrôles de sûreté.

Dans les vidéos que j'attends également de la CppCon, il y a également celles sur les Modules et sur la programmation par contrat dont je suis pressé de voir les idées et résultats.

Merci pour cet article, mais je reste quand même moyennement convaincus de ces deux designs. Ça ne règle pas le problème du C++ de toujours vouloir faire confiance au développeur. En effet, ça invite les développeurs à être plus expressifs dans leurs codes, mais ça n'assure que très peu de sécurité à la compilation et à l'exécution. L'auteur parle d'undefined behavior, c'est plutôt la sortie des compilateurs C++ qui est undefefined behavior

1
2
3
4
5
6
7
8
9
Object* p = nullptr;
p->f();  // erreur, appel sur nullptr

Object* pp;
pp->f(); // erreur, pointeur invalide

Object* ppp = new Object;
delete ppp;
ppp->f();  // erreur, pointeur invalide

Concernant ce code, étant donné la présence de paires de parenthèses, f est une fonction membre. Je ne sais plus ce qu'impose la norme à ce sujet mais je sais que sans certains flags ce code peut compiler. Le problème ne se pose plus si on vire les parenthèses, f serait assurément une variable (ou constante) membre inaccessible depuis un pointeur nul ou invalide.

+0 -0

En effet, ça invite les développeurs à être plus expressifs dans leurs codes, mais ça n'assure que très peu de sécurité à la compilation et à l'exécution.

Aloqsin

Non en effet, mais comme dit dans l'article :

Bien sûr, ce type est purement indicatif pour le développeur, une mauvaise utilisation ne va pas provoquer d’erreur de compilation ou d’exécution.

Il est par contre possible d’utiliser des outils d’analyse statique, qui pourraient prendre en compte ce type d’information.

Concernant le code que tu cites, il compile de toute façon, puisqu'il est syntaxiquement correct et typé correctement. Par contre, son comportement c'est "undefined behavior".

Ça ne règle pas le problème du C++ de toujours vouloir faire confiance au développeur.

Aloqsin

C'est pas une question de confiance, c'est une question de laisser le contrôle. Si le développeur ne suit pas les guidelines (et il est libre de le faire) et que son programme fait n'importe quoi, les outils pourront toujours lui dire "on t'avait prévenu".

il faut penser à libérer la mémoire

Juste pour le vocabulaire : pour éviter les confusions, je préfère parler d'acquisition et libération de ressources (dont fait partie la mémoire). Le RAII est un principe plus général que la simple libération de mémoire.

Un autre concept plus ou moins empruntée (toujours le problème de la poule et de l'oeuf) à Rust est celui des durées de vie (lifetime)

C'est un concept plus ancien (idem pour l'ownership). Par contre, très clairement, le C++ n’hésite pas a piquer les bonnes idées des autres langages. Pourquoi se priver ? :)

Mais il est vrai que l'on prend de plus en plus en compte explicitement ces concepts.

commencé celui de Sutter.

J'ai beaucoup plus de mal a rédiger quelque chose dessus (pas sur la trame principale et les 3 parties, mais sur la "simplification à une usine à gaz"). Pour le moment, je suis passé aux autres présentations, je vais laisser mûrir…

Ces guidelines sont vraiment les bienvenues

Apres le premier survol, cela semble être pas mal de guideline pour faire avec le code existant pleins de pointeurs nus (comme c'est un peu le cas avec not_null et owner). Il va falloir probablement faire un peu de trie pour nos chers débutants qui n'ont le droit que d'utiliser des pointeurs intelligent :)

Et voir aussi comment cela s’intègre dans un cours débutant.

Je suis particulièrement intéressé par l'outil d'analyse statique qu'ils mentionnent ce côté,

Si tu as vu la présentation de Sutter, tu as du voir cela en action.

Ça ne règle pas le problème du C++ de toujours vouloir faire confiance au développeur.

C'est pas un problème, c'est un choix :)

"On ne paie que pour ce que l'on utilise". Quand on debug, on veux des vérifications et des garanties. Quand on release, on ne veut pas d'overhead. Cette approche a forcement des contraintes.

+0 -0

Ce qui est assez marrant, c'est que ces concepts sont aussi présent en Rust et forment même la base du langage. Le compilateur vérifie d'ailleurs à la compilation que les ressources n'ont qu'un seul propriétaire, et que les pointeurs ne sont pas nuls. C'est ce qui permet à Rust d'éviter la plupart des erreurs de mémoire!

Un autre concept plus ou moins empruntée (toujours le problème de la poule et de l'oeuf) à Rust est celui des durées de vie (lifetime). Je crois que quelque chose a été proposé à ce sujet lors de la CppCon15.

Étant donné que Rust est une alternative plausible au C++ dans pas mal de domaines d'applications, c'est une bonne chose que la concurrence et l'émulation permette à tous les langages de s'améliorer !

Luthaf

Oh tiens ! Quelqu'un d'autre qui connaît Rust ! :) Le problème actuel avec Rust est qu'il est intéressant mais presque uniquement en nightly car beaucoup de features ne sont pas encore accessibles autrement.

Sinon chouette article, ça fait du bien un petit rappel des actuels du C++. Félicitations à l'auteur !

+0 -0

Le problème actuel avec Rust est qu'il est intéressant mais presque uniquement en nightly car beaucoup de features ne sont pas encore accessibles autrement.

Je ne suis pas trop d'accord. Je tourne avec la version stable depuis 5 mois maintenant, et je n'ai pas besoin de nighlty. Il y a bien des truc funs dedans (dans ce qui m'intéresse: SIMD, les traits AddAssign et compagnie), mais on s'en passe pas mal. Les seules fois où j'ai eu besoin de la version nigthly a été quand j'ai utilisé clippy sur mon code.

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte