Notons que Rust est un langage de programmation compilé multiparadigme, conçu par Graydon Hore alors employé chez Mozilla Research. Utilisé par plusieurs grandes entreprises et par de nombreux développeurs dans le monde, Rust est devenu le langage de base pour certaines des fonctionnalités indispensables du navigateur Firefox et de son moteur Gecko, ainsi que pour le moteur Servo de Mozilla.
Avec Rust, il est possible de développer des pilotes de périphériques, des systèmes embarqués, des systèmes d'exploitation, des jeux, des applications web, et bien d'autres choses encore. Des centaines d'entreprises dans le monde entier utilisent Rust en production pour des solutions multiplateformes et économes en ressources. Des logiciels connus et appréciés, comme Firefox, Dropbox, et Cloudflare, utilisent ce langage. De la startup à la multinationale, du système embarqué au service web à haute disponibilité, Rust serait une excellente solution.
Zig est un langage de programmation open source conçu par Andrew Kelley pour garantir les performances telles que la robustesse et la maintenabilité. Andrew a annoncé dans une courte introduction au langage Zig qu’il l’a créé dans le but de concurrencer, voire remplacer à l’avenir, le redoutable langage C dans le cadre de la programmation système. Ainsi, il dit avoir bâti Zig sur quatre principaux aspects afin qu’il soit un langage de programmation pragmatique, optimal, un coffre-fort en matière de sécurité et un langage le plus lisible possible.
« Je ne suis pas si ambitieux et mon objectif est de créer un nouveau langage de programmation qui sera plus pragmatique que le C. C'est comme essayer d'être plus diabolique que le diable lui-même », a écrit Andrew en introduction à la présentation du langage Zig. Lorsqu’il parle de langage plus pragmatique, il fait allusion au fait « que tout ce qui compte à la fin, c’est de savoir si le langage vous a aidé à faire ce que vous tentiez de faire et d’une manière plus simple que les autres langages ».
Zig, une meilleure alternative à Rust ?
Le récupérateur de mémoire est la partie importante, il est difficile de le faire fonctionner et d'être rapide et sûr parce que c'est fondamentalement un problème qui ne joue pas bien avec le vérificateur d'emprunts. Il existe deux façons de le faire de façon sûre en Rust : en utilisant le référence-counting et en utilisant arenas+handles, mais les deux semblent être plus lents que l'approche traditionnelle mark/sweep.
L'implémentation spécifique de l'interpréteur de bytecode est tirée du livre : Crafting Interpreters : Crafting Interpreters. En particulier, il s'agit d'une VM basée sur la pile pour un langage qui supporte les fonctions, les fermetures, les classes/instances, etc.
L'implémentation non sécurisée de Rust
De l'avis de Zack Radisic, l'implémentation non sécurisée de Rust est difficile. Beaucoup plus difficile que le C, il a beaucoup de règles nuancées sur le comportement non défini - grâce au vérificateur d'emprunts - qui rendent facile d'introduire des bogues. En effet, le compilateur effectue des optimisations en supposant que ses règles de propriété soient suivies. « Mais si vous les enfreignez, cela est considéré comme un comportement non défini, et le compilateur continue, appliquant ces mêmes optimisations et transformant potentiellement le code en quelque chose de dangereux. »
Pour rendre les choses encore plus compliquées, « Rust ne sait pas exactement quel comportement est considéré comme non défini », soutient Zack Radisic. Vous pouvez donc écrire du code qui présente un comportement non défini sans même le savoir.
Une façon d'y remédier est d'utiliser Miri, un interprète pour la représentation intermédiaire de Rust qui peut détecter les comportements non définis. Plus précisément, Miri est un interpréteur expérimental pour la représentation intermédiaire de niveau moyen (MIR) de Rust. Il peut exécuter les binaires et les suites de tests des projets cargo et détecter certaines classes de comportements non définis. « La source la plus difficile de comportement non défini que j'ai rencontré était liée aux règles d'aliasing de Rust », déclare Zack Radisic.
Comme mentionné précédemment, Rust utilise ses règles d'emprunt et de propriété pour optimiser le compilateur. Si vous enfreignez ces règles, vous obtenez un comportement non défini. L'astuce consiste à utiliser des pointeurs. Ils n'ont pas les mêmes contraintes d'emprunt que les références Rust classiques, ce qui permet de contourner le vérificateur d'emprunt.
Par exemple, vous pouvez avoir autant de pointeurs bruts mutables (*mut T) ou immuables (*const T) que vous le souhaitez.
Si vous transformez un pointeur brut en référence (μt T ou &T), vous devez vous assurer qu'il respecte les règles de ce type de référence pendant toute sa durée de vie. Par exemple, une référence mutable ne peut pas exister tant que d'autres références mutables/immuables existent également.
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 | fn do_something(value: *mut Foo) { // Turn the raw pointer into a mutable reference let value_mut_ref: &mut Foo = value.as_mut().unwrap(); // If I create another ref (mutable or immutable) while the above ref // is alive, that's undefined behaviour!! // Undefined behaviour! let value_ref: &Foo = value.as_ref().unwrap(); } |
Il serait très facile d'enfreindre cette règle. Vous pouvez faire une référence mutable à certaines données, appeler quelques fonctions, et ensuite, 10 couches plus loin dans la pile d'appels, une fonction peut faire une référence immuable à ces mêmes données, et provoque un comportement indéfini. Le problème est que les pointeurs bruts n'ont pas la même ergonomie que les références. Tout d'abord, vous ne pouvez pas avoir de fonctions associées qui prennent le self comme pointeur brut :
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | struct Class { /* fields... */ } impl Class { // Regular associated function fn clear_methods(&mut self) { /* ... */ } fn clear_methods_raw(class: *mut Class) { /* ... */ } } unsafe fn test(class: *mut Class) { let class_mut_ref: &mut Class = class.as_mut().unwrap(); // This syntax is nice and ergonomic class_mut_ref.clear_methods(); // But with raw pointers you'll have to just call the function like in C Class::clear_methods_raw(class); } |
Ensuite, il n'y a pas de syntaxe de déréférencement de pointeur comme ptr->field en C.
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 | // The way to make these readable is to create a variable for each dereference, // but that's annoying so in some places I got lazy. // ew (*(*closure.as_ptr()).upvalues.as_ptr().offset(i as isize)) = upvalue; // ewwwwww let name = (*(*(*ptr.cast::<ObjBoundMethod>().as_ptr()).method.as_ptr()) .function .as_ptr()).name; |
Travailler avec des tableaux
« Si j'ai un pointeur brut sur un tableau de données (*mut T), je peux le transformer en une tranche &mut [T], et je peux utiliser une boucle for ... in ou n'importe quel itérateur pratique (.for_each(), .map(), etc.) », déclare Zack Radisic. Mais le transformer en &mut [T] revient à faire référence à toutes les données du tableau, ce qui permet de violer encore une fois les règles de Rust.
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 | unsafe fn do_stuff_with_array(values: *mut Value, len: usize) { let values: &mut [Value] = std::slice::from_raw_parts_mut(values, k); // I can use the ergonomics of iterators! for val in values { /* ... */ } // I just have to make sure none of the Values are turned // into references (mutable or immutable) while the above slice is active... } |
Selon Zack Radisic, la solution est d'éviter de faire des références, donc à certains endroits il a fini par écrire des for-loops de style C sur le tableau raw ptr. « Mais les pointeurs bruts sont nuls par rapport aux structures de Rust parce qu'on ne peut pas les indexer et qu'il n'y a pas de vérification hors limites.
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 | pub struct Closure { upvalues: *mut Upvalue } unsafe fn cant_index_raw_pointer(closure: *mut Closure) { // Can't do this: let second_value = (*closure.upvalues)[1]; // Have to do this, and no out-of-bounds checking let value = *(*closure).upvalues.offset(1); } |
Miri utilise le modèle Stacked Borrows et peut donc détecter les UB liés aux règles d'aliasing mentionnées ci-dessus, mais les corriger était un défi. « C'est un peu comme lorsque j'apprenais Rust, il y a ces règles qui existent mais je n'en ai pas un modèle mental solide. Je devais comprendre pourquoi ce que je faisais n'était pas correct, puis expérimenter pour trouver un moyen de le corriger », écrit Zack Radisic.
Notons également que la boucle de rétroaction est beaucoup plus lente parce qu'il n'y a pas de LSP dans son éditeur de code pour le guider, il doit recompiler le programme à chaque fois et voir la sortie de Miri. « Cela a vraiment gâché l'expérience pour moi, précise-t-il. À la fin, je n'écrivais plus vraiment en Rust, mais plutôt dans un langage mi-Rust mi-C qui était beaucoup plus délicat et sujet aux erreurs. »
« Après avoir passé beaucoup de temps à pratiquer les arts sombres en Rust, j'étais excité à l'idée de quitter Rust, d'apprendre Zig et de commencer à réécrire le projet dans ce langage », confie-t-il.
L'implémentation
Selon Zack Radisic, Zig est un langage qui comprend que vous allez faire des choses qui ne sont pas sûres pour la mémoire, il est donc conçu et optimisé pour rendre cette expérience bien meilleure et moins sujette à l'erreur. Voici quelques éléments clés présentés par Zack Radisic :
Stratégies d'allocation explicites
Dans Zig, toute fonction qui alloue de la mémoire doit se voir passer un Allocateur. « C'était génial parce que j'ai fait du garbage collector un Allocator personnalisé », indique Zack Radisic. Chaque allocation/désallocation suivait le nombre d'octets alloués et déclenchait le ramasse-miettes si nécessaire.
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | const GC = struct { // the allocator we wrap over that does // the heavy lifting inner_allocator: Allocator bytes_allocated: usize // and other fields... // `alloc`, `resize`, and `free` are required functions // to implement an Allocator fn alloc(self: *GC, len: usize, ptr_align: u29, len_align: u29, ret_addr: usize) ![]u8 { // let the inner allocator do the work const bytes = try self.inner_allocator.rawAlloc(len, ptr_align, len_align, ret_addr); // keep track of how much we allocated self.bytes_allocated += bytes.len; // collect if we exceed the heap growth factor try self.collect_if_needed(); return bytes; } }; |
« Ce que j'aime dans ce choix de conception de Zig, c'est qu'il rend sans idiomatique l'utilisation de différentes stratégies d'allocation qui sont optimales pour votre cas d'utilisation », écrit Zack Radisic. « Par exemple, si vous savez que certaines allocations ont une durée de vie similaire et finie, vous pouvez utiliser une autre technique d’allocation pour accélérer votre programme. Cette fonctionnalité existe en Rust, mais elle n'est pas aussi intéressante qu'en Zig. », ajoute-t-il.
Un allocateur spécial qui détecte les bugs de mémoire
Lorsqu'il est utilisé, il détecte les use-after-frees et les double-frees. Il affiche une belle trace de pile de quand/où les données ont été allouées, libérées et utilisées.
Code : | Sélectionner tout |
1 2 3 4 | // All you have to do is this const alloc = std.heap.GeneralPurposeAllocator(.{ .retain_metadata = true, }){}; |
Pointeurs non nuls par défaut
La plupart des bugs de sécurité mémoire sont des déréférences de pointeurs nuls et des indexations de tableaux hors limites. Les types de pointeurs bruts de Rust sont nullables par défaut et n'ont pas de vérification de déréférencement de pointeur nul. Il existe un type de pointeur NonNull<T> qui offre plus de sécurité. Les pointeurs Zig, par défaut, sont non nuls et la vérification du déréférencement des pointeurs nuls est également activée par défaut.
Pointeurs et segments
Selon zackoverflow, Zig comprend que vous allez travailler avec des pointeurs, et il rend cette expérience agréable. « Un gros problème avec la version non sécurisée de Rust était que les pointeurs bruts avaient une ergonomie terrible. La syntaxe de déréférencement était horrible, et je ne pouvais pas indexer les tableaux de ptr bruts en utilisant la syntaxe slice[idx]. » Les pointeurs de Zig ont la même ergonomie que les références Rust, à savoir que l'opérateur point double le déréférencement du pointeur :
Code : | Sélectionner tout |
1 2 3 4 5 | fn do_stuff(closure: *Closure) { // This dereferences `closure` to get the // `upvalues field` const upvalues = closure.upvalues; } |
Zig dispose également de quelques types de pointeurs supplémentaires qui permettent de distinguer les « pointeurs sur une valeur unique » des « pointeurs sur un tableau » :
c
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 | onst STACK_TOP = 256; const VM = struct { // pointer to unknown number of items stack_top:[*]Value, // like a rust slice: // contains a[*]Value + length // has bounds checking too stack: []Value, // alternative to slices when // N is a comptime known constant stack_alt: *[STACK_TOP]Value }; |
Ils supportent l'indexation avec la syntaxe array[idx], alors que les pointeurs classiques (*T) ne le supportent pas, ce qui, selon zackoverflow est vraiment génial pour la sécurité. Le plus intéressant est qu'il est très facile de passer d'un type de pointeur à l'autre :
Code : | Sélectionner tout |
1 2 3 4 5 6 7 | fn conversion_example(chars:[*]u8, len: u8) []Value { // Converting to a []T is easy: var slice: []const u8 = chars[0..len]; // And back var ptr:[*]u8 = @ptrCast([*]u8, &slice[0]); } |
Selon Zack Radisic, les pointeurs "traditionnels", comme ceux que l'on trouve en C/C++, sont très sujets aux erreurs. Rust résout ce problème en ajoutant une couche de façade sur les pointeurs : ses types de référence (&T ou &mut T). Mais malheureusement pour Rust, ses pointeurs bruts ont toujours les mêmes problèmes qu'en C/C++.
Zig résout ce problème en supprimant simplement une grande partie des pointeurs et en ajoutant des garde-fous supplémentaires. Pour Zack Radisic, écrire une quantité substantielle de Rust non sécurisé réduit vraiment la beauté du langage. « J'avais l'impression de marcher sur la pointe des pieds dans ce verre brisé de comportement indéfini, ou d'écrire dans cette abomination bizarre d'un langage muté moitié Rust/moitié C. » il ajoute : « tout l'intérêt de Rust est d'utiliser le vérificateur d'emprunts, mais lorsque vous devez fréquemment faire quelque chose que le vérificateur d'emprunts n'aime pas... devriez-vous vraiment utiliser le langage ? »
Rust est un langage entièrement développé de façon ouverte. Il offre la possibilité de construire des logiciels fiables et efficaces. Ses domaines de prédilection étant la programmation système, les applications en ligne de commande, les applications Web via WebAssembly, les services réseaux et les systèmes embarqués. Le langage est également apprécié par sa communauté pour les raisons suivantes :
- performance : Rust est un langage rapide et économique en mémoire, sans environnement d'exécution, ni ramasse-miettes, il peut dynamiser des services à hautes performances, s'exécuter dans des systèmes embarqués, et s'intégrer facilement à d'autres langages ;
- fiabilité : le système de typage et le modèle d’ownership de Rust garantissent la sécurité mémoire ainsi que la sécurité des threads. Il permet d'éliminer de nombreuses variétés de bugs dès la compilation ;
- productivité : Rust dispose d'une excellente documentation, d'un compilateur bienveillant, avec des messages d'erreur utiles. Le langage a également un gestionnaire de paquet et de compilation intégré, divers éditeurs intelligents avec autocomplétions et analyse de type. Il dispose également d’un outil de mise en forme automatique.
Certain aspect de Rust ne semble pas être suffisamment mentionné lorsque les gens comparent Rust et Zig, et devrait certainement être pris en compte si vous allez faire des choses non sûres pour la mémoire pour des raisons de performance. En tant qu’une personne qui aime Rust, je vais certainement explorer l'utilisation de Zig pour des projets.
Source : Zack Radisic's blog post
Que pensez-vous du langage Zig ?
« Zig, le langage de programmation compilé, inspiré de Rust, serait plus sûr et plus rapide que Rust », partagez-vous cet avis ?
Selon vous, quelle plus value ce langage pourra apporter ?
L'analyste de Rust Zack Radisic est-elle pertinente ?
Voir aussi :
Zig est un langage de programmation polyvalent et serait une chaîne d'outils permettant de maintenir des logiciels robustes, optimaux et réutilisables
La version 0.9.0 de ZIG, le langage de programmation compilé, inspiré de Rust et conçu pour concurrencer le C, est disponible, avec une amélioration de l'interface mem.Allocator et bien plus