Mini-JS-machine : codes expliqués
Fragment 1
Exemple
Code à compiler :
Code compilé :
Explication 1.1 : Que font SetArg et GetArg ?
La machine dispose d'un environnement/contexte, appelé cc (for "curent context"). Celui-ci associe à chaque variable (ici foo et i) une valeur. La structure des contextes est assez complexe, mais pour l'instant, vous pouvez considérer qu'il s'agit d'une table de hashage qui à chaque nom de variable (String) associe une valeur (flottant ou booléen).
Explication 1.2 : Pourquoi fait-on la multiplication à la fin
Les opérations sur des expressions sont toutes en forme postfix : on calcule les arguments puis on fait l'opération. Cela vaut aussi pour le Set, par exemple. La seule opération qui ne sera pas de cette forme est l'appel de fonction car le nombre d'argument n'est pas fixé. Attention tout de même, l'opération elle même (qui est à la fin) peut prendre plusieurs lignes.
Explication 1.3 : c'est quoi ces nombres à côté du ConJmp et Jump ?
Lorsqu'il est positif, il s'agit du nombre d'instructions à sauter (toujours pour le cas du Jump, et lorsque le booléen récupéré sur la pile est faux pour le ConJmp). Lorsqu'il est négatif, cela veut dire que l'on revient en arrière. Attention,dans ce cas, l'instruction précédente est à -2 pour des raisons d'efficacité.
Fragment 2
Exemple de fonction
Code à compiler :
Code compilé :
Explication 2.1 : Variables déclarées
Les variables déclarés avec "Var" sont remontées affin de couvrir tout leur scope : Si elles sont déclarées dans le programme principal, alors elles sont remontées au début, si elles sont déclarées dans une fonction elles sont remontées au début de la fonction.
Attention, seules les déclarations sont remontées, pas les instanciations, ainsi on a toujours le "foo = 2" dans le programme.
Pourquoi fait-on ça ? Affin que les variables soient déclarées correctement. Dans le programme principal ce n'est pas obligatoire car une variable non déclarée est implicitement déclarée dans les variables globales. Dans les fonctions, par contre, c'est essentiel, car la variable x, ici, est déclarée localement dans la fonction, elle n'est pas accessible depuis le programme principal, ni même depuis une autre instanciation de la fonction (on utilise un contexte diffèrent chaque fois).
Explication 2.2 : Déclaration et instanciation de fonction
Au début du programme, nous définissons aussi (et surtout) les fonctions globales. En C, on doit écrire la signature des fonctions avant leur utilisation, mais en JC pas besoin. Pourquoi ? parce qu'une passe de l'interpréteur le fait à notre place. Il en est de même pour notre compilateur, il doit faire quelques étapes pour définir la fonction dans le contexte.
Explication 2.2.1 : Création d'une clôture
Afin de pouvoir renvoyer à la fonction, on fait une clôture : une valeur qui contient un pointeur sur le début du code de la fonction (ici 25 pour dire que c'est 25 lignes après), et un contexte qui est l'environnement dans lequel est définit la fonction (pas important pour le fragment 2).
Explication 2.2.2: déclaration des arguments d'une fonction
On déclare aussi, dès le départ, les noms des arguments de la fonction. Ceci ajoute, dans la clôture, une liste des arguments que demande la fonction. Avant d'appeler la fonction il faudra que l'on donne des valeurs à ces arguments.
Explication 2.2.3: instanciation du nom de la fonction
On fait un dernier SetVar pour associé au nom bar, la clôture que l'on vient de créer. Remarquez que la fonction est rangée de la même manière que les variables, après tout, JS est un langage fonctionnel où les fonctions sont des valeurs comme les autres !
Explication 2.3 : Appel d'une fonction
Pour appeler une fonction, ce n'est pas une étapes, mais plusieurs qui s'articulant autour du calcul des arguments.
Explication 2.3.1: Récupérer la clôture
La fonction est présentée comme une variable, il suffit d'aller dans le contexte chercher la clôture qui lui est associée.
Explication 2.3.2: Instancier les argument
Une fois une fonction sur la pile, nous allons simplement, pour chaque argument, évaluer son résultat sur la pile et l'associé comme argument à la clôture à l'aide de SetArg. Cette instruction associe la valeur de sommet de pile au nom du premier argument de la fonction dans le contexte de la clôture.
Explication 2.3.3: Appel de la fonction
À la fin, nous appelons la fonction qui est au sommet de la pile. Cela sauvegarde le contexte courant et l'endroit où on en est dans le code pour pouvoir y revenir, c'est ce que l'on appel une continuation. Puis, on utilise la clôture pour sauter au code de la fonction et se placer dans son contexte (avec, en particulier, les arguments de la fonctions qui sont maintenant instanciés !)
Explication 2.3.4: Nouveau contexte
Lorsque l'on utilise une fonction récursive, par exemple
Cela signifie que les environnement d'exécutions sont différents pour chaque appel de la fonction ! Affin de formaliser cette différence, on fait appel à StCall, ou StartCall, qui fournie une version fraîche de la clôture,dans laquelle on va pouvoir instancier les arguments, et les variables locales de la fonction sans rien changer aux autres instances.
Remarque : Dans la première version de la correction du TP2, j'avais oublié cette commande. En fait j'avais oublié de la mettre partout (dans la description de la machine et dans son code aussi), j'ai corrigé cela, je m'excuse pour ce bug...
Explication 2.3.5: Variables partagées
Si on crée un nouvel environnement à chaque fois, comment se fait-il que les variables globales (ici foo) ne soient pas modifiées ? C'est parce que l'on crée un environnement partiellement nouveaux : seuls les arguments (et les variables déclarées avec Var) sont dans le nouveau contexte, les autres sont partagé avec le contexte de création de la fonction (et pas celui de l'appel de la fonction !)
Concrètement, un contexte est une liste chaînée de tables de hashage, StCall ne fait que rajouter une table vide en tête de liste, ainsi tout ce qui était disponible avant l'est toujours, mais tout ce que l'on va rajouter en tête ne sera pas visible pour le contexte précédent.
Explication 2.4 : Le code de la fonction
Lorsque l'on accède au code de la fonction, les arguments sont déjà instanciés dans le contexte, on n'a donc qu'à dérouler la compilation du code.
Explication 2.4.1: Le return
Le return est une instruction qui prends le sommet de pile et la première continuation (il jette tout ce qui est entre les deux car c'est probablement de la pollution de pile), puis il restaure le contexte et la ligne de la continuation (on revient donc dans le contexte et le code où on était avant appel de la fonction), tout en gardant le sommet de pile qui est le résultat de la fonction.
Explication 2.4.1: Le jump avant la fonction
En javascript, on peut déclarer une fonction n'importe où, même au milieu de votre code. Il faut donc qu'il y ai un jump avant la fonction qui saute sa définition. Une autre possibilité est de mettre tous les codes de fonctions à la fin.
Fragment 2bis
Exemple d'exception
Code à compiler :
Code compilé :
Explication 3.1: Le Throw
Le Throw renvoi une erreur, pour ça, il va explorer la pile. S'il trouve une continuation d'erreur, il va l'utiliser en laissant le sommet de pile intacte (et en jetant le reste. S'il ne trouve pas de continuation d'erreur, on obtient l'erreur à top-level.
Explication 3.2: Le Try
Le Try, (très mal) appelé "Continue n False" va mettre sur la pile une continuation d'erreur (d'où le Faux...) pointant sur la position fixe n. Si un Throw est lancé c'est cette continuation d'erreur qui sera exécuté, ce qui correspond au Catch.
Contrairement à toutes les autres instruction, celle-ci utilise une position et non un offset...c'est une erreur de ma part qui sera corrigée en introduisant une autre instruction plus pratique (mais en laissant celle-ci).
Explication 3.3: Sauter le Catch
Il y a trois moyens de sortir du Try : ou bien avec un Throw, ou bien avec un Return, ou bien en arrivant à la fin du code. Les deux premiers cas sont automatiquement traités (un peu subtile pour le return, mais ça marche). Pour le dernier cas, il faut ressortir sans faire le Catch, d'où le jump. Il faut aussi enlever la continuation d'erreur de la pile, d'où le Drop.
Explication 3.4: argument du throw
Il Faut, au début de Catch, récupérer l'argument du Throw, qui est en sommet de pile.
Exemple d'exception avec finally (optionel)
Code à compiler :
Code compilé :
Explication 4.1: Les Finally
Le Finally est la seule construction qui duplique le code. Le soucis est que si on accède à un Finally via un Return, il faut relancer un Return après. Le plus simple est de dupliquer le code, c'est ce qui est fait dans la pratique (Java va même dupliquer le code après chaque Return...)
Explication 4.1: Le Finally supplémentaire
En fait, il y a un autre cas de finally : le cas où une exception arrive au milieux de Catch (par exemple quand le Catch ne fait qu'ajouter un log). Dans ce cas, il faut quand même exécuter le finally et relancer l'exception après... d'où le troisième finally.
Explication 4.1: Les Drop
Comme tout à l'heure, lorsque je sort du Try sans exception, il me faut supprimer ma continuation d'erreur, mais il me faut aussi supprimer ma continuation de finally. Attention aussi à faire ça avant le finally, car une erreur ou un return dans le finally ne le réexécute pas.