Rust, mes premières impressions
Qui suis-je pour juger ?
À l’heure ou j’écris cet article, j’ai un peu plus d’une vingtaine d’années d’expérience dans l’IT et le développement.
Inévitablement sur une telle période un ingénieur / développeur est amené à utiliser différents langages sur différents environnements au sein de différents contextes métier.
Dernièrement j’ai pratiqué de façon plus ou moins régulière les langages de développement suivants : C, C++, Haskell, Elixir, Dart, Scala, Go, Kotlin et bien sûr Rust.
J’exclus mes expériences avec les langages interprétés tels que le PHP, HTML / CSS, le scripting shell, Wolfram Language ou encore Java Script qui sont pour moi plutôt dans la catégorie des outils de scripting.
Ces 5 dernières années, j’ai développé une petite centaine de projets (du petit outil CLI au système de suivi de production d’échelle gouvernementale en passant par le plugin Kubernetes ou la plate-forme BtoC), essentiellement en Go, langage pour lequel j’ai déjà exprimé un avis ici.
Il y a environ 6 mois, j’ai commencé à m’intéresser à Rust essentiellement pour trois raisons :
- Je n’ai pas trouvé de librairie appropriée en Go pour l’un de mes projets récents (traitement d’images médicales)
- J’ai été frustré sur l’un de mes développements plutôt “memory intensive” (une base de données in-memory) par l’inévitable impact du garbage collector sur les performances
- L’envie permanente de découvrir et d’apprendre qui nous guide tous au quotidien dans ce métier
Le processus de découverte
Rust à la réputation d’un langage difficile à la courbe d’apprentissage plutôt longue, au risque d’écourter le suspense : c’est vrai.
Non pas que sa syntaxe soit si exotique (les habitués de Python ou Ocalm ne seront pas trop dépaysés), non pas que son expressivité le rende illisible (du moins quand on a fait du Scala…), mais plutôt à cause de ce qui le rend si puissant : son absence totale de runtime embarqué et par conséquent de garbage collector.
Rust est en effet un peu un extra-terrestre dans le monde du développement, il s’agit d’un langage hyperexpressif moderne, mais de bas niveaux, deux propriétés habituellement antinomiques.
Nous reviendrons sur les conséquences techniques de ce positionnement inhabituel, mais s’agissant du processus de découverte il implique une chose : ne vous lancez pas tête baissée dans votre premier programme sans avoir étudié les aspects théoriques du langage avant, c’est voué à l’échec, voir au dégoût.
Au contraire de Go par exemple, pour lequel on peut maitriser le language en l’utilisant en quelque jours, la lecture préalable d’au moins un livre sur Rust est fortement recommandée.
La référence étant le “Rust Book” officiel ou sa version Française.
Alternativement, ou en complément, il existe des tutoriels video plus ou moins complet, je conseillerais l’excellente chaine de Ryan Levick.
Une fois les concepts de base propres à Rust assimilés (ownership, borrowing, lifetimes, macros, …) vous pourrez vous lancer sans maudire le compilateur jusqu’à l’écœurement (il deviendra, au contraire, rapidement votre meilleur amis).
Qu’est-ce qui le rend si particulier ?
Comme indiqué précédemment, Rust est une anomalie dans le paysage de l’ingénierie logicielle : c’est un langage évolué et hyperexpressif, mais de bas niveau.
Explications
Traditionnellement on classe les langages de développement dans trois catégories :
- Les langages dits “systèmes” ou de bas niveaux : C en est le roi indétrôné depuis prêt de 50 ans. Ils sont peut expressif (I.E. il faut souvent réinventer la roue ou utiliser les bibliothèques développées par d’autres l’ayant fait à votre place). Peu sécurisés (le bon fonctionnement général repose entièrement sur le développeur auquel on donne toute liberté), mais permettent d’obtenir un code très proche du “métal” (I.E. du CPU) autorisant ainsi son exploitation optimum (si le développeur est bon).
- Les langages dits “évolués” ou “expressifs” : ils ont été conçus essentiellement pour augmenter la productivité des développeurs via différents paradigmes : objet, fonctionnel… . Ils permettent de limiter les risques inhérents à la première catégorie (gestion automatisé de la mémoire évitant ainsi des dépassements de pile et autres “dangling pointer”). On peut y placer Java, Scala, Kotlin, Go, C#, Swift, …. Leurs performances et leurs efficacités (ratio entre la performance et la consommation des ressources) sont clairement moins bonnes, mais dans un monde où la puissance des machines doublait à prix constant tous les 18 mois, personne n’a jamais considéré que c’était un problème.
- Les langages interprétés ou dits “de scripting” : ils ne produisent aucun code compilé, mais sont au contraire interprétés à la volée au moment de leurs exécutions. Ils poussent encore plus loin l’axe de la productivité du développeur, mais au prix de performances et d’un niveau d’efficacité souvent catastrophique. Nous pouvons citer par exemple : Python, Ruby, PHP, JavaScript, R, Perl.
Rust ambitionne de fusionner la première et la deuxième catégorie, et par certains aspects, il y arrive.
Rust est clairement un langage évolué, il n’embrasse pas totalement les concepts de programmation-objet (ça tombe bien plus personne n’en veut) mais à l’instar de Go permet de définir des structures de données et des méthodes applicables à ces dernières, des interfaces et de la composition.
Ce n’est pas non plus un langage fonctionnel pur, mais il s’en approprie la plupart des propriétés (immutabilité par défaut, monades, fonctions d’ordre supérieur, closures, …).
Il embarque une gestion de la généricité très développée, utilise massivement l’inférence de types ou encore le filtrage par motif (“pattern matching”).
Toutes ces capacités le positionnent assez haut dans l’échelle de l’évolution et en font clairement un langage moderne… et pourtant…
Pourtant, son compilateur produit du code machine au moins aussi proche du matériel et efficace que pourrait le faire un compilateur C (voir plus efficace dans certains cas), sans garbage collector et le tout avec un niveau de sécurité rarement atteint.
Rust est nativement “memory safe” (une propriété que l’on trouve habituellement que dans des langages “garbage collectés”) sans pour autant que le développeur n’ait à gérer les allocations / dé-allocations manuellement. Il apporte des garanties empêchant les “race conditions” ou encore les “dead lock” en programmation concurrente le tout avec un niveau d’abstraction très élevé et ce, sans aucun impact sur les performances (on parle de “zero cost abstractions”).
Le compilateur est suffisamment puissant pour produire du code totalement déterministe (y compris sur la gestion de la mémoire) supprimant ainsi le recourt à un runtime et un garbage collector au moment de l’exécution.
Comment réussit-il cela ?
L’explication pourrait remplir des livres entiers (et c’est d’ailleurs le cas), je vais donc simplifier et synthétiser au maximum.
Gestion de la mémoire
Comment peut-il être “memory safe” sans “garbage collector” ?
Il s’appuie sur un concept innovant : la gestion de la propriété.
Plusieurs règles en découlent :
- Une valeur (I.E. une zone mémoire, qu’elle contienne un simple entier ou une structure plus complexe) dispose d’un propriétaire (I.E. une fonction)
- Elle ne peut avoir qu’un propriétaire à la fois
- Lorsque l’exécution sort du scope du propriétaire (I.E. sort de la fonction), la valeur est détruite (dé-allocation)
- La propriété peut être transférée d’une fonction à une autre (la fonction d’origine perd alors l’accès à la valeur)
- Une fonction peut “prêter” l’accès à une valeur à une autre fonction tout en restant le propriétaire (comme on prête un livre)
- Il peut y avoir autant d’emprunts en lecture seule qu’on le souhaite
- Il ne peut y avoir qu’un seul emprunt en lecture / écriture à la fois
- Un emprunt en lecture / écriture interdit tout emprunt en lecture seule
- Un emprunt fait obligatoirement référence à une valeur active (pas encore détruite)
Cet ensemble de règles permet au compilateur de façon statique :
- De déterminer quand allouer et quand libérer la mémoire, et donc de le coder directement dans le binaire produit
- De garantir l’absence de pointeur pendouillant dans le vide (“dangling pointer”)
- De garantir l’absence de “race condition” (écritures simultanées dans un ordre indéterminé à une même zone mémoire)
- De garantir l’absence de dépassement mémoire (“stack overflow”), les références ou emprunts étant des pointeurs “intelligents” soumis notamment à la dernière règle
On estime que ce dernier point est a lui seul à l’origine de 70 à 80% des bugs et incidents de sécurité (CVE) recensés dans le monde.
Ce mécanisme à part est clairement le plus déroutant pour les nouveaux entrants (je fais l’impasse sur le concept de “lifetimes” qui en découle).
Il est à l’origine de batailles tout aussi épiques que fréquentes entre le développeur et le compilateur qui refusera obstinément le non-respect de ces règles.
Pourquoi est-ce aussi déroutant alors qu’elles semblent empreintes de bon sens ? Parce qu’aucun autre langage ne vous oblige à les suivre.
- Les langages système traditionnels comme le C vous laissent les violer en toute impunité (avec les conséquences funestes que cela peut avoir lors de l’exécution)
- Les langages évolués traditionnels comme le Java ou Go gèrent les allocations / dé-allocations à votre place, et vous n’êtes donc jamais soumis à leurs contraintes.
Les macros
Cet impératif de déterminisme total au moment de la compilation pose un problème, il interdit (entre autres) les fonctions variadiques (I.E. les fonctions prenant en paramètre un nombre indéterminé de paramètres).
Or c’est un type de fonctions très très utiles et tout autant utilisées, ne serait-ce que pour le formatage d’une chaîne de caractères.
Par exemple l’équivalent Rust du code Go suivant n’est pas possible :
fmt.Printf("Hello world, my name is %s I'm %d old", "bastien", 42)
La fonction fmt.Printf
prend en paramètre un nombre variable de paramètres (la chaîne de caractère initiale avec ses "placeholders" et les valeurs à utiliser).
Le runtime Go va en réalité générer le code final au moment de l’exécution en fonction du nombre de paramètres effectivement présents. Mais Rust n’a pas de runtime.
Compiler de façon déterministe l’équivalent code de fmt.Printf
impliquerait produire un nombre infini de versions avec de 1 à une infinité de paramètres, c'est évidement impossible.
La solution passe par le concept “macro”.
Elles sont identifiées par un point d’exclamation à la fin de leur nom :
println!("Hello world, my name is {}, I'm {} old", "bastien", 42);
Une macro est “compilée” deux fois.
- La première étape va générer un code Rust (on parle d’expansion de Macro) en fonction du nombre de paramètres effectivement utilisé (ici 3 en analysant au passage le type de valeur utilisé dans les paramètres)
- La seconde étape compilera ce code Rust en code machine.
Simple et élégant.
Pour quel résultat au quotidien ?
L’absence de runtime / garbage collector ouvre des perspectives intéressantes.
Il permet l’utilisation de Rust pour le développement de systèmes d’exploitation et/ou de systèmes embarqués.
- Microsoft à annoncé l’inclure progressivement en remplacement du C et C++ au coeur de ses OS
- Linus Torvald à accepté l’idée d’en expérimenter l’utilisation au sein du noyau Linux (ce qui est un sacré signe quand on connais les positions arrêtés de Linus sur les langages)
- Amazon l’a utilisé avec succès pour le développement de son système de micro-virtualisation caché derrière AWS Lambda
- Google l’utilise pour le développement de l’éventuel remplaçant d’Android: Fucshia
- A l’autre extrême, Il en autorise aussi l’utilisation coté “front” (un endroit ou on rencontre habituellement JavaScript et consort) au travers de WebAssembly.
On peut donc raisonnablement penser qu’il est finalement utilisable d’un bout à l’autre du spectre applicatif.
Cela en ferait-il le graal ?, LE langage universel auquel tout développeur aspire ?
Je me garderais bien d’une telle conclusion.
D’une part parce que seul le temps nous le dira, et d’autre part parce que tout n’est pas si rose.
Les faiblesses
Le coût d’entré
En premier lieu on trouve, comme indiqué, cette lonnnngue courbe d’apprentissage.
Elle peut en démotiver plus d’un, surtout les moins expérimentés ou les fraîchement “reconvertis”.
On voit, en France et dans d’autres pays, fleurir depuis quelques années des écoles (ex : 42) et autres centre de formations rapides (ex : Le Wagon) permettant à qui le souhaite de se former au métier de développeur.
Ils visent à éponger le déficit de profils disponibles sur le marché en complétant la cohorte de diplômés sortant des écoles plus classiques (l’IT est un domaine ou la demande dépasse l’offre) .
Les étudiants fraîchement diplômés (quelque soit leur âge) aurons tendance à s’orienter sur ce pour quoi ils ont été formés en majorité : le développement Web, mobile, l’IA, le Big Data (non ça c’est passé de mode).
Être opérationnel (du point de vue de l’entreprise) en sortie de formation implique que cette dernière se concentre sur les technologies et méthodologies produisant rapidement un résultat (langages interprétés, méthodologie agile …), quitte à faire l’impasse sur tout un pan de la théorie de l’information et de la science informatique.
Or, on ne comprend et on n’apprécie pas Rust sans avoir été initié à l’architecture de Von Neumann, sans saisir la différence entre la Stack et la Heap, sans connaître des ordres de grandeur de coût des différentes méthodes d’allocation.
Rust s’adresse donc, selon moi, a des profils plutôt expérimentés ayant déjà une solide expérience avec d’autres langages et ayant déjà touché les limites de ces derniers.
L’investissement cognitif nécéssaire a sa maîtrise ne lui permettra pas, je pense, de remplacer tous les autres.
La difficulté à la lecture
Je classe Rust dans les langages hyperexpressifs.
À ce titre, il permet d’implémenter de façon concise et élégante des problèmes pourtant compliqués voir complexes.
Mais cela n’en fait pas un langage facile à lire pour autant.
Le formalisme mathématique est concis et élégant, mais cela n’a pourtant pas permis de faire des mathématiques de haut niveau un sport de masse.
Un développeur passe en moyenne 90% du temps actif de sa vie professionnelle à lire du code (le sien ou celui des autres) et seulement 10% à en écrire.
La rapidité de lecture et de compréhension est donc déterminante pour la productivité globale, plus le projet est important (nombre de ligne de codes et nombre de participants) et plus elles sont déterminantes.
Go excelle dans ce domaine, il est tellement simple (au risque de manquer parfois de concision ou d’élégance) qu’un nouvel entrant sur un projet le comprend très rapidement, et, est tout aussi rapidement opérationnel.
La mécanique intellectuelle permettant de comprendre les imbrications multiples de fonctions Rust qui peuvent s’enchaîner sur une seule ligne de code est différente, elle tire d’ailleurs sa philosophie (programmation fonctionnelle) du formalisme mathématique.
Lire du code Rust nécessitera toujours plus d’efforts que de lire du code Go, la question est donc de savoir si cela en vaut la peine.
La puissance du sytème de packaging
Le système de packaging et de gestion de dépendance de Rust (Cargo + Crate) est extrêmement puissant.
Il peut sembler bizarre de le classer alors dans les faiblesses, mais là encore cette puissance se paye.
Elle nécessite pour l’entreprise qui souhaite disposer d’un Registry (entrepôt de Crates) privé une infrastructure plus lourde que Go par exemple (qui se contente d’un simple repository de source (ex: Git), souvent déjà déployé dans l’entreprise).
Enfin, et comme toujours avec les systèmes puissants, sa maitrise nécessite là encore un investissement intellectuel non négligeable.
Le manque de maturité de la programmation concurrente
C’est un aspect souvent cité comme référence lorsqu’on évoque le langage Go, car il a été conçu dès le départ avec l’idée qu’il serait exécuté sur des machines multi-cores connectées en réseau.
Rust est né à peu près la même année que Go, mais la gestion de la programmation concurrente et l’asynchronisme ont curieusement souffert de nombreux hésitations et abandons durant sa courte histoire.
Si les choses semblent se stabiliser aujourd’hui, on est loin de la simplicité d’emplois des Go-routines par exemple.
Encore une fois, l’approche sélectionnée repose sur la puissance et la liberté de choix : il est par exemple possible de choisir son moteur de gestion des thread, qu’il s’agisse d’”OS thread” ou de “green thread”.
La lenteur de la compilation
Vous l’avez compris, le compilateur Rust à beaucoup de travail à faire, entre les vérifications du respect des règles de propriété et d’emprunt, l’expansion des macros, la gestion des types génériques ou la conversion d’abstractions de haut niveau en code machine ultra optimisée, il ne chôme pas.
Cela se paye en temps de traitement, Rust est rapide à l’exécution, mais certainement pas à la compilation.
Conséquence : la productivité du développeur en prend un coup.
Bien que les outils de vérification temps réel permettent de limiter l’appel au compilateur, la complexité du langage rend ce dernier indispensable durant le cycle de développement.
Il fournit en effet de très précieuses et détaillées explications sur les erreurs rencontrées dans le code, et donne même parfois, carrément la solution.
Mieux vaut avoir une machine rapide pour faire du Rust.
Les atouts
La performance
Lorsqu’elle compte par-dessus tout (Systèmes d’exploitation, Jeux Video, moteurs d’IA, Blockchain, …), le prix de l’investissement cognitif évoqué précédemment passe clairement au second plan.
La performance (qui va normalement de pair avec l’efficacité) se paye de toute façon chèrement, et soyons clair, à ce jour, rien ne permet d’aller plus vite que le Rust (sauf peut être à coder en assembleur, forme de torture abolie il y a déjà plusieurs années).
La seule alternative sur les niveaux de performances atteint par Rust est le C, qui ne présente ni son niveau de sécurité ni son niveau d’expressivité.
Il n’a que deux choses pour lui : son historique (nb de bibliothèques disponibles) et le nombre de profils disponibles sur le marché.
Sur ces types d’applications, l’avenir de Rust semble donc tout tracé.
La sécurité
Les mécanismes de contrôles intégrés au compilateur le rendent particulièrement séduisant pour les systèmes critiques ou particulièrement exposés aux risques sécuritaires (bibliothèques cryptographiques, systèmes de gestion d’identité, firewall / proxy réseau …).
Lorsque votre code compile (enfin) vous avez la garantie qu’il s’exécutera correctement et qu’il fera ce que vous lui avez demandé (mais pas forcement ce que vous pensez lui avoir demandé).
Pas de surprise à l’exécution, pas de crash “aléatoire” et une surface d’attaque qui se limite à la logique de votre programme plutôt qu’aux vecteurs traditionnels (manipulation de la mémoire).
La portabilité
Rust peut être compilé, à l’heure ou j’écris ces lignes, pour 84 cibles différentes (couple d’architecture CPU et de système d’exploitation).
Il se paye donc le luxe d’être plus portable que Go est ses 37 cibles supportées, même si en Rust, toutes les cibles n’ont pas le même niveau de support.
La puissance du système de packaging
Comme indiqué, c’est une faiblesse par certains aspects, mais aussi une force.
Sa modularité permet une gestion d’une incroyable finesse : des dépenses, du versioning et même des fonctionnalités que l’on décide d’activer, ou pas, lors de l’import d’une bibliothèque.
Je n’ai personnellement rien expérimenté de tel dans les autres langages que j’ai pratiqués.
La documentation
Rust est très très bien documenté, que ce soit le langage lui-même (c’était nécessaire), sa bibliothèque standard (découpée en plusieurs modules) ou d’une façon générale les bibliothèques disponibles publiquement.
Cela est essentiellement dû à la puissance de son système de génération automatique de la documentation (via cargo).
En commentant correctement votre code (le format Markdown est nativement supporté), un véritable site web va être généré automatiquement non seulement sur la documentation de votre code, mais aussi de toutes ses dépendances.
En conclusion
Rust est devenu un langage important dans le paysage du développement logiciel, c’est indiscutable, malgré son jeune âge (une dizaine d’années).
Après ces six premiers mois d’utilisation et mes premiers résultats concluants, je vous livre ma modeste conclusion.
- Vais-je continuer à l’utiliser ? Certainement.
- Vais-je remplacer Go par Rust ? Certainement pas.
Les deux technologies sont tout à fait complémentaires.
Même un excellent développeur Rust n’atteindra jamais le niveau de productivité d’un bon développeur Go.
Pour des projets à forte vélocité, ou les spécifications évoluent plusieurs fois par jour, ou les équipes sont nombreuses et mouvantes, la collaboration importante, Go reste pour moi imbattable.
Pour les projets critiques, fonctionnellement complexes, où la performance se mesure à la nanoseconde (voir à la picoseconde), Rust est la bonne solution, à condition d’en accepter les coûts.
Malgré son incroyable polyvalence et ses authentiques qualités, je ne pense pas qu’il représente le graal, le langage universel qui remplacera tous les autres.
Mais entre nous, qui y croit encore ?
Ressources pour aller plus loin
Il y a beaucoup de ressources disponibles pour s’entraîner et se perfectionner en Rust, vous trouverez ci-dessous ma sélection des plus intéressantes (pour moi, et jusqu’à présent).
Vidéo :
- La chaîne youtube de Ryan Levick (tutoriel de codage en direct) : https://www.youtube.com/c/RyanLevicksVideos
- Chaîne youtube “Let’s get Rusty” (tutos rapides et efficaces) : https://www.youtube.com/c/LetsGetRusty
- La chaîne youtube de Doug Milford (suggéré par un lecteur) : https://www.youtube.com/channel/UCmBgC0JN41HjyjAXfkdkp-Q
Livres :
- “The Rust Programming Language” : livre officiel et gratuit (https://doc.rust-lang.org/book/)
- “Programming Rust, 2nd Edition” : probablement le meilleur livre sur Rust que j’ai lu, bons exemples, explications simples, descriptions détaillées (https://www.oreilly.com/library/view/programming-rust-2nd/9781492052586/)