Ce projet m’a permis d’apprendre de nombreuses notions sur la compilation ainsi que sur le système de types de Rust, qui m’a particulièrement fasciné. Dans ce document, je présente :
- Une vue d’ensemble du projet
- La partie la plus compliquée : génération des contraintes des durées de vie (Partie 3)
- L’extension envisagée : génération de code assembleur
- Conclusion
-
Objectif général :
Implémenter un mini‐compilateur pour un sous‐ensemble de Rust (MiniRust), incluant la vérification des durées de vie (lifetimes) et la détection des emprunts invalides. -
Principales étapes :
- Analyse syntaxique et construction du MIR (Mid‐level Intermediate Representation).
- Transformation de la syntaxe Rust simplifiée en une représentation intermédiaire (MIR).
- Construction d’un graphe de contrôle et d’un graphe de données très rudimentaires.
- Vérification des accès mémoire et des emprunts :
- Parcours du MIR pour vérifier que chaque porteur d’emprunt mutable/partagé respecte bien les règles de Rust.
- Calcul des variables initialisées / non initialisées à chaque point.
- Génération des contraintes de durées de vie (Partie 3) :
- Construction du graphe d’outlives, qui force la relation « ’l1 outlive ’l2 » quand un emprunt doit survivre plus longtemps qu’un autre.
- Calcul des ensembles de durées de vie vivantes (
lft_sets) en chaque point de programme via un fixpoint.
- Détection des conflits d’emprunts à l’aide d’“Active_borrows”.
- À chaque instruction, vérifier si un emprunt actif s’oppose à l’écriture/lecture demandée.
- (Optionnel) Génération de code assembleur :
- Proposition d’une extension pour transformer le MIR en un graphe de blocs puis en code machine à l’aide de “goto” et d’allocations optimisées.
- Analyse syntaxique et construction du MIR (Mid‐level Intermediate Representation).
La partie 3 est, de loin, la plus complexe : il s’agit de déterminer, pour chaque variable de durée de vie (lifetime) introduite dans la fonction, à quels points du programme elle doit rester « vivante », et quelles autres lifetimes elle doit « outliver ».
Rust impose que, si on a un emprunt &'a T puis un emprunt &'b T utilisé à un même endroit, il faut que 'a = 'b ou bien 'a :> 'b (selon le contexte). MiniRust, n’ayant pas de sous‐typage, simplifie cela en demandant toujours l’unification des lifetimes lorsqu’on passe &'a T à un paramètre de type &'b T.
-
On maintient un référentiel
outlives : LMap.t(map de lifetimes vers ensembles de lifetimes) :add_outlives (l1, l2)ajoute l’arcl1 → l2signifiant «l1outlivel2».unify_lft l1 l2crée deux arcsl1 → l2etl2 → l1, forçant l’unification ('a = 'b).
-
Étapes clés :
- Contraintes implicites liées aux types des variables locales
Pour chaque variable localelvde typeτ, on calculeimplied_outlives prog τ: siτcontient un emprunt&'x …, on ajoute tous les arcs nécessaires pour mettre à jouroutlives. - Pour chaque instruction
instrdu MIR :- Si on a
Iassign (pl, RVplace pl'):- On récupère
tho1 = typ_of_place prog mir plettho2 = typ_of_place prog mir pl'. - On fait
pour remonter tous les lifetimes libres.
outlives := outlives ∪ implied_outlives(tho1) ∪ implied_outlives(tho2) - Puis on appelle la fonction récursive
unify tho1 tho2qui, lorsquetho1 = Tborrow(l1, …, inner1)ettho2 = Tborrow(l2, …, inner2), faitunify_lft l1 l2et redescend (check inner1 inner2).
- On récupère
- Si on a
Iassign (pl, RVborrow (_, pl2)):- On récupère
Tborrow (lft1, …)pour la place ciblepl. - On calcule d’abord
implied_outlivespour le type deplet pour le type depl2(le “source” de l’emprunt). - On parcourt ensuite récursivement les chaînes
PlDeref …danspl2pour chaque niveau d’emprunt imbriqué : sipl2 = *p_innera pour typeTborrow(lf, …), on ajouteadd_outlives (lf, lft1)et on redescend.
- On récupère
- Si on a une construction
RVmake (nom_struct, champs):- On appelle
fields_types_fresh prog nom_structpour obtenir(ltho, ctor_typ), c’est‐à‐dire la liste des types de champs « génériques » et le type du constructeur (Tstruct (nom_struct, fresh_lfts)). - Pour chaque champ, on fait remonter
implied_outlivesdes deux côtés (generic_field_typettyp_of_place prog mir place) puis on appelleunify generic_field_typ actual_field_typ. - Enfin, si
ctor_typ = Tstruct(nom, fresh_lfts)et que la place assignéeplestTstruct(nom, lfts), on faitList.iter2 unify_lft fresh_lfts lfts.
- On appelle
- Si on a un appel
Icall (f, args, ret_place, _):fn_prototype_fresh prog frenvoie(ltho, ret_typ, outlives_constraints)oùlthosont les lifetimes génériques du prototype,ret_typson type de retour (ex :Tborrow('o,_,Tborrow('a,…))), etoutlives_constraintsune liste d’arcs explicites(l1, l2)déjà déclarés dans la signature ('a :> 'opar exemple).- Pour chaque paramètre
typdansret_typ :: lthoet chaque argumentarg, on fait :afin de remonter les lifetimes et d’unifier borrows identiques.outlives := outlives ∪ implied_outlives(prog, typ) ∪ implied_outlives(prog, typ_of_place prog mir arg); unify typ (typ_of_place prog mir arg) - Enfin, on ajoute explicitement chaque
(l1, l2)issu deoutlives_constraintsen faisantadd_outlives (l1, l2).
- Si on a
- Contraintes implicites liées aux types des variables locales
- On initialise
living : LMap.t refà vide, puis on parcourt chaque étiquette de programme:- On obtient la liste des locaux vivants en ce point (
live_locals lbl) à partir deLive_locals.go mir. - Pour chaque local
loc ∈ live_locals(lbl), on récupèrety = Hashtbl.find mir.mlocals locpuis on collectefree_lfts ty(ensemble des lifetimes libres dansty). Pour chacunldans cet ensemble, on faitadd_living (PpLocal lbl) l. - On ajoute aussi, pour chaque lifetime générique
lft ∈ mir.mgeneric_lfts, unadd_living (PpInCaller lft) lft, car toute lifetime générique est considérée « vivante » au point d’appel du caller.
- On obtient la liste des locaux vivants en ce point (
- On utilise le module
Fix.Fix.ForTypepour résoudre le plus petit ensemble satisfaisant la règle :