Précédent Index Suivant

Interprète BASIC

L'application présentée dans cette section est un interprète de programmes Basic. C'est donc un programme sachant exécuter d'autres programmes écrits en Basic. Bien entendu, nous ne décrirons qu'un langage restreint, il ne contient que les instructions suivantes :

Chaque ligne d'un programme Basic est étiquetée par son numéro de ligne et ne contient qu'une seule instruction. Par exemple, un programme calculant puis affichant la factorielle d'un entier entré au clavier s'écrit :
 
 5  REM  entree de l'argument 
10  PRINT " factorielle de :"
20  INPUT A
30  LET B = 1 
35  REM debut de la boucle 
40  IF A <= 1 THEN 80 
50  LET B = B * A
60  LET A = A - 1
70  GOTO 40 
75  REM le resultat est affiche
80  PRINT B
Nous souhaitons de surcroît réaliser un mini-éditeur fonctionnant sur le principe d'une boucle d'interaction. Il devra rendre possible l'ajout de nouvelles lignes, l'affichage d'un programme et l'évaluation. L'exécution du programme précédent est lancée par la commande RUN. Voici un exemple d'évaluation de ce programme :
> RUN
 factorielle de : ? 5
120
Nous décomposons la réalisation de cet évaluateur en plusieurs parties relativement distinctes :
Description de la syntaxe abstraite
: il faut définir des types de données permettant de représenter les programmes Basic ainsi que leurs constituants (lignes, instructions, expressions, etc.).
Affichage du programme
: cette partie consiste à transformer la représentation interne des programmes Basic en chaînes de caractères afin de les visualiser.

Analyse lexicale et analyse syntaxique
: ces deux parties réalisent l'opération inverse, à savoir transformer une chaîne de caractères en la représentation interne d'un programme Basic (sa syntaxe abstraite).

Évaluation
: le coeur de l'évaluateur gouvernant l'exécution du programme. Nous verrons qu'un langage fonctionnel comme Objective CAML est particulièrement bien adapté à ce genre de problème.

Boucle d'interaction
: elle met en oeuvre ce qui précède.

Syntaxe abstraite

La figure 6.2 présente la syntaxe concrète du Basic que nous allons implanter par une grammaire sous la forme BNF. Cette façon de présenter la syntaxe d'un langage est décrite au chapitre 11, page ??.

Op_Unaire ::= -    |    !
Op_Binaire ::= +    |    -    |    *    |    /    |    %
  | =    |    <    |    >    |    <=    |    >=    |    <>
  | &    |    ' | '
Expression ::= entier
  | variable
  | "chaîne de caractères"
  | Op_Unaire   Expression
  | Expression   Op_Binaire   Expression
  | ( Expression )
Instruction ::= REM chaîne de caractères
  | GOTO entier
  | LET variable = Expression
  | PRINT Expression
  | INPUT variable
  | IF Expression THEN entier
 
Ligne ::= entier Instruction
 
Programme ::= Ligne
  | Ligne Programme
 
Commande ::= Ligne | RUN | LIST | END

Figure 6.2 : Grammaire BASIC


Remarquons que la définition des expressions ne garantit pas qu'une expression bien formée soit évaluable. Par exemple, 1+"hello" est une expression et pourtant il n'est pas possible de l'évaluer. Ce choix délibéré permet de simplifier la syntaxe abstraite du langage Basic et de raccourcir l'analyse syntaxique. Le prix à payer est la possibilité que des programmes Basic syntaxiquement corrects puissent produire une erreur d'exécution due à une incohérence de type.

La définition des types de données Objective CAML pour cette syntaxe abstraite découle immédiatement de la définition de la syntaxe concrète en utilisant un type somme :

# type op_unr = OPPOSE | NON ;;
# type op_bin = PLUS | MOINS | MULT | DIV | MOD
| EGAL | INF | INFEQ | SUP | SUPEQ | DIFF
| ET | OU ;;
# type expression =
ExpInt of int
| ExpVar of string
| ExpStr of string
| ExpUnr of op_unr * expression
| ExpBin of expression * op_bin * expression ;;
# type instruction =
Rem of string
| Goto of int
| Print of expression
| Input of string
| If of expression * int
| Let of string * expression ;;
# type ligne = { num : int ; inst : instruction } ;;
# type program = ligne list ;;


On définit les éléments de syntaxe abstraite pour les commandes du mini-éditeur de programme :

# type phrase = Ligne of ligne | List | Run | End ;;


Il est d'usage d'alléger l'écriture des expressions arithmétiques en autorisant le programmeur à ne pas mentionner toutes les parenthèses. Par exemple, l'expression 1+3*4 est habituellement interprétée comme 1+(3*4). On attribue, à cette fin, un entier à chacun des opérateurs du langage :

# let priority_ou = function NON -> 1 | OPPOSE -> 7
let priority_ob = function
MULT | DIV -> 6
| PLUS | MOINS -> 5
| MOD -> 4
| EGAL | INF | INFEQ | SUP | SUPEQ | DIFF -> 3
| ET | OU -> 2 ;;
val priority_ou : op_unr -> int = <fun>
val priority_ob : op_bin -> int = <fun>
Ces entiers indiquent ce que l'on appelle la priorité des opérateurs. Nous verrons comment elle est utilisée pour l'affichage des programmes et l'analyse syntaxique.

Affichage des programmes

Pour afficher un programme contenu en mémoire, il faut savoir transformer une ligne de programme, sous forme de syntaxe abstraite, en une chaîne de caractères.

La conversion des opérateurs est immédiate :

# let pp_opbin = function
PLUS -> "+" | MULT -> "*" | MOD -> "%" | MOINS -> "-"
| DIV -> "/" | EGAL -> " = " | INF -> " < "
| INFEQ -> " <= " | SUP -> " > "
| SUPEQ -> " >= " | DIFF -> " <> " | ET -> " & " | OU -> " | "
let pp_opunr = function OPPOSE -> "-" | NON -> "!" ;;
val pp_opbin : op_bin -> string = <fun>
val pp_opunr : op_unr -> string = <fun>
L'affichage des expressions tiendra compte des règles de priorité pour engendrer un parenthésage allégé. Par exemple, on ne parenthèse une sous-expression à droite d'un opérateur que lorsque son opérateur principal a une priorité inférieure à l'opérateur principal de l'expression entière. De plus, les opérateurs arithmétiques sont associatifs à gauche, c'est-à-dire que l'expression 1-2-3 est implicitement parenthésée comme (1-2)-3.

Pour ce faire, nous avons recours à deux fonctions auxiliaires ppg et ppd pour traiter respectivement les sous-arbres gauches et les sous-arbres droits. Ces fonctions prennent deux paramètres, d'une part l'arbre d'expression à afficher mais aussi la priorité de l'opérateur qui est en amont de cet arbre afin de décider s'il y a lieu de parenthéser l'expression. Nous distinguons les sous-arbres gauches des sous-arbres droits pour traiter l'associativité des opérateurs. En cas d'égalité de priorité des opérateurs on ne mettra pas de parenthèse pour l'opérande gauche alors qu'elles sont indispensables dans le cas de l'opérande droit comme dans les expressions 1-(2-3) et 1 + ( 2 + 3).

On considère l'arbre initial comme un sous-arbre gauche sous un opérateur de priorité minimale (0). Voici la fonction pp_expresssion d'affichage des expressions :

# let parenthese x = "(" ^ x ^ ")";;
val parenthese : string -> string = <fun>
# let pp_expression =
let rec ppg pr = function
ExpInt n -> (string_of_int n)
| ExpVar v -> v
| ExpStr s -> "\"" ^ s ^ "\""
| ExpUnr (op,e) ->
let res = (pp_opunr op)^(ppg (priority_ou op) e)
in if pr=0 then res else parenthese res
| ExpBin (e1,op,e2) ->
let pr2 = priority_ob op
in let res = (ppg pr2 e1)^(pp_opbin op)^(ppd pr2 e2)
(* parenthèse si la priorité n'est pas supérieure *)
in if pr2 >= pr then res else parenthese res
and ppd pr exp = match exp with
(* les sous-arbres droits ne diffèrent *)
(* que pour les opérateurs binaires *)
ExpBin (e1,op,e2) ->
let pr2 = priority_ob op
in let res = (ppg pr2 e1)^(pp_opbin op)^(ppd pr2 e2)
in if pr2 > pr then res else parenthese res
| _ -> ppg pr exp
in ppg 0 ;;
val pp_expression : expression -> string = <fun>


L'affichage des instructions utilise la fonction précédente sur les expressions. L'affichage d'une ligne ajoutera un numéro de ligne devant l'instruction.

# let pp_instruction = function
Rem s -> "REM " ^ s
| Goto n -> "GOTO " ^ (string_of_int n)
| Print e -> "PRINT " ^ (pp_expression e)
| Input v -> "INPUT " ^ v
| If (e,n) -> "IF "^(pp_expression e)^" THEN "^(string_of_int n)
| Let (v,e) -> "LET " ^ v ^ " = " ^ (pp_expression e) ;;
val pp_instruction : instruction -> string = <fun>
# let pp_ligne l = (string_of_int l.num) ^ " " ^ (pp_instruction l.inst) ;;
val pp_ligne : ligne -> string = <fun>


Analyse lexicale

Les analyses lexicale et syntaxique effectuent la transformation inverse de l'affichage. Elles reçoivent une chaîne de caractères et construisent un arbre de syntaxe. L'analyse lexicale découpe le texte d'une ligne d'instruction en unités lexicales indépendantes appelées lexèmes, dont le type Objective CAML suit :

# type lexeme = Lint of int
| Lident of string
| Lsymbol of string
| Lstring of string
| Lfin ;;
Nous avons rajouté un lexème particulier pour marquer la fin d'une expression : Lfin. Il ne fait pas partie de la chaîne analysée, mais sera engendré par la fonction d'analyse lexicale (voir fonction lexer, page ??).

La chaîne que l'on souhaite analyser est conservée dans un enregistrement contenant un champ modifiable indiquant l'indice de la partie de la chaîne restant à parcourir. La taille de la chaîne étant une information qui sera nécessaire en de multiples occasions et qui ne varie pas, nous la stockons dans l'enregistrement :

# type chaine_lexer = {chaine:string; mutable courant:int; taille:int } ;;
Cette représentation permet de définir l'analyse lexicale d'une chaîne comme l'application d'une fonction à une valeur de type chaine_lexer et rendant une valeur de type lexeme. La modification de l'indice de la chaîne restant à parcourir se fait par effet de bord.

# let init_lex s = { chaine=s; courant=0 ; taille=String.length s } ;;
val init_lex : string -> chaine_lexer = <fun>
# let avance cl = cl.courant <- cl.courant+1 ;;
val avance : chaine_lexer -> unit = <fun>
# let avance_n cl n = cl.courant <- cl.courant+n ;;
val avance_n : chaine_lexer -> int -> unit = <fun>
# let extrait pred cl =
let st = cl.chaine and ct = cl.courant in
let rec ext n = if n<cl.taille && (pred st.[n]) then ext (n+1) else n in
let res = ext ct
in cl.courant <- res ; String.sub cl.chaine ct (res-ct) ;;
val extrait : (char -> bool) -> chaine_lexer -> string = <fun>


Les fonctions suivantes extraient un lexème de la chaîne et positionnent le caractère courant sur le prochain caractère à traiter. Les deux fonctions auxiliaires :
extrait_int et extrait_ident extraient respectivement un entier et un identificateur.

# let extrait_int =
let est_entier = function '0'..'9' -> true | _ -> false
in function cl -> int_of_string (extrait est_entier cl)
let extrait_ident =
let est_alpha_num = function
'a'..'z' | 'A'..'Z' | '0' .. '9' | '_' -> true
| _ -> false
in extrait est_alpha_num ;;
val extrait_int : chaine_lexer -> int = <fun>
val extrait_ident : chaine_lexer -> string = <fun>


La fonction lexer utilise les deux fonctions précédentes pour extraire un lexème.

# exception LexerErreur ;;
exception LexerErreur
# let rec lexer cl =
let lexer_char c = match c with
' '
| '\t' -> avance cl ; lexer cl
| 'a'..'z'
| 'A'..'Z' -> Lident (extrait_ident cl)
| '0'..'9' -> Lint (extrait_int cl)
| '"' -> avance cl ;
let res = Lstring (extrait ((<>) '"') cl)
in avance cl ; res
| '+' | '-' | '*' | '/' | '%' | '&' | '|' | '!' | '=' | '(' | ')' ->
avance cl; Lsymbol (String.make 1 c)
| '<'
| '>' -> avance cl;
if cl.courant >= cl.taille then Lsymbol (String.make 1 c)
else let cs = cl.chaine.[cl.courant]
in ( match (c,cs) with
('<','=') -> avance cl; Lsymbol "<="
| ('>','=') -> avance cl; Lsymbol ">="
| ('<','>') -> avance cl; Lsymbol "<>"
| _ -> Lsymbol (String.make 1 c) )
| _ -> raise LexerErreur
in
if cl.courant >= cl.taille then Lfin
else lexer_char cl.chaine.[cl.courant] ;;
val lexer : chaine_lexer -> lexeme = <fun>


Le principe de la fonction lexer est des plus simples, elle filtre le caractère courant d'une chaîne et, suivant sa valeur, rend le lexème correspondant en ayant avancé le caractère courant de la chaîne jusqu'au caractère suivant. Cette approche est bien adaptée car mis à part pour deux caractères, des lexèmes différents peuvent se distinguer dès leur premier caractère. Dans le cas de '<' nous sommes par contre obligés d'examiner le caractère suivant pour savoir s'il s'agit de = ou de > ce qui produira un lexème différent. De même pour le caractère '>'.

Analyse syntaxique

L'unique difficulté de l'analyse syntaxique de notre langage provient des expressions. En effet, la connaissance du début de l'expression ne suffit pas pour déterminer sa structure. Supposons que nous ayons analysé la portion de chaîne 1+2+3. Suivant ce qui suit, par exemple : +4 ou *4, nous obtiendrons des arbres d'expression dont la structure concernant 1+2+3 n'est pas la même (voir figure 6.3).


Figure 6.3 : Basic : exemple d'arbres de syntaxe abstraite


Cependant, la structure de l'arbre correspondant à 1+2 est la même dans les deux cas. Nous pouvons donc la construire. comme le sort réservé à +3 est encore indéterminé, nous stockerons temporairement cette information jusqu'à ce que nous possédions suffisamment d'information pour pouvoir la traiter.

Nous allons procéder à la construction de l'arbre de syntaxe abstraite en ayant recours à un automate à pile proche de celui que construit l'outil yacc (voir page ??). Les lexèmes sont lus l'un après l'autre et sont empilés tant que l'information n'est pas suffisante pour construire l'expression. Lorsque nous savons décider de la structure des éléments d'expressions empilés, nous les retirons de la pile et les remplaçons par l'expression construite. Cette opération s'appelle réduction.

Le type des éléments empilés est le suivant :

# type exp_elem =
Texp of expression (* expression *)
| Tbin of op_bin (* opérateur binaire *)
| Tunr of op_unr (* opérateur unaire *)
| Tpg (* parenthèse gauche *) ;;
Remarquons que les parenthèses droites ne sont pas stockées car seules les parenthèses gauche sont significatives pour l'opération de réduction.

La figure 6.4 illustre le principe de l'utilisation de la pile pour l'analyse de l'expression (1+2*3)+4. Le caractère surmontant la flèche est le caractère courant de la chaîne.


Figure 6.4 : Basic : exemple de construction d'un arbre de syntaxe abstraite


On définit l'exception suivante pour les erreurs de syntaxe.

# exception ParseErreur ;;
La première étape à effectuer est de retrouver les opérateurs à partir des symboles :

# let symb_unr = function
"!" -> NON | "-" -> OPPOSE | _ -> raise ParseErreur
let symb_bin = function
"+" -> PLUS | "-" -> MOINS | "*" -> MULT | "/" -> DIV | "%" -> MOD
| "=" -> EGAL | "<" -> INF | "<=" -> INFEQ | ">" -> SUP
| ">=" -> SUPEQ | "<>" -> DIFF | "&" -> ET | "|" -> OU
| _ -> raise ParseErreur
let tsymb s = try Tbin (symb_bin s) with ParseErreur -> Tunr (symb_unr s) ;;
val symb_unr : string -> op_unr = <fun>
val symb_bin : string -> op_bin = <fun>
val tsymb : string -> exp_elem = <fun>


La fonction suivante (reduit) implante l'opération de réduction de la pile. Il y a deux cas à considérer, la pile commence par : De plus, reduit prend en argument la priorité minimale qu'un opérateur doit avoir pour que la réduction de la pile ait lieu. Pour réduire sans condition, il suffit de donner une profondeur minimale nulle.

# let reduit pr = function
(Texp e)::(Tunr op)::st when (priority_ou op) >= pr
-> (Texp (ExpUnr (op,e)))::st
| (Texp e1)::(Tbin op)::(Texp e2)::st when (priority_ob op) >= pr
-> (Texp (ExpBin (e2,op,e1)))::st
| _ -> raise ParseErreur ;;
val reduit : int -> exp_elem list -> exp_elem list = <fun>


Notez que les éléments d'expressions sont empilés suivant l'ordre de lecture, d'où la nécessité d'inverser les deux opérandes d'une opération binaire.

La fonction principale de l'analyse syntaxique est la fonction empile_ou_reduit qui, suivant le lexème donné en argument, ajoute un nouvel élément dans la pile ou procède à une réduction.

# let rec empile_ou_reduit lex stack = match lex , stack with
Lint n , _ -> (Texp (ExpInt n))::stack
| Lident v , _ -> (Texp (ExpVar v))::stack
| Lstring s , _ -> (Texp (ExpStr s))::stack
| Lsymbol "(" , _ -> Tpg::stack
| Lsymbol ")" , (Texp e)::Tpg::st -> (Texp e)::st
| Lsymbol ")" , _ -> empile_ou_reduit lex (reduit 0 stack)
| Lsymbol s , _
-> let symbole =
if s<>"-" then tsymb s
(* lever l'ambiguïté du symbole ``-'' *)
(* suivant la pile (i.e dernier exp_elem empilé) *)
else match stack
with (Texp _)::_ -> Tbin MOINS
| _ -> Tunr OPPOSE
in ( match symbole with
Tunr op -> (Tunr op)::stack
| Tbin op ->
( try empile_ou_reduit lex (reduit (priority_ob op)
stack )
with ParseErreur -> (Tbin op)::stack )
| _ -> raise ParseErreur )
| _ , _ -> raise ParseErreur ;;
val empile_ou_reduit : lexeme -> exp_elem list -> exp_elem list = <fun>


Une fois tous les lexèmes isolés et empilés, l'arbre de syntaxe abstraite doit être finalement construit avec les éléments restant dans la pile. C'est le but de la fonction reduit_tout. Si l'expression à analyser est bien formée, il ne doit plus rester dans la pile qu'un seul élément contenant l'arbre de cette expression.

# let rec reduit_tout = function
| [] -> raise ParseErreur
| [Texp x] -> x
| st -> reduit_tout (reduit 0 st) ;;
val reduit_tout : exp_elem list -> expression = <fun>


La fonction parse_exp est la fonction principale de l'analyse des expressions. Elle parcourt une chaîne de caractères, en extrait les différents lexèmes et les passe à la fonction empile_ou_reduit. L'analyse s'arrête lorsque le lexème courant satisfait un prédicat passé en argument.

# let parse_exp fin cl =
let p = ref 0 in
let rec parse_un stack =
let l = ( p:=cl.courant ; lexer cl)
in if not (fin l) then parse_un (empile_ou_reduit l stack)
else ( cl.courant <- !p ; reduit_tout stack )
in parse_un [] ;;
val parse_exp : (lexeme -> bool) -> chaine_lexer -> expression = <fun>
Notons que le lexème qui a déterminé l'arrêt du parcours n'a pas servi à construire l'expression. Il faut donc restaurer son indice de début (variable p) pour qu'il puisse être traité par la suite.

Passons maintenant à l'analyse d'une ligne d'instruction :

# let parse_inst cl = match lexer cl with
Lident s -> ( match s with
"REM" -> Rem (extrait (fun _ -> true) cl)
| "GOTO" -> Goto (match lexer cl with
Lint p -> p
| _ -> raise ParseErreur)
| "INPUT" -> Input (match lexer cl with
Lident v -> v
| _ -> raise ParseErreur)
| "PRINT" -> Print (parse_exp ((=) Lfin) cl)
| "LET" ->
let l2 = lexer cl and l3 = lexer cl
in ( match l2 ,l3 with
(Lident v,Lsymbol "=") -> Let (v,parse_exp ((=) Lfin) cl)
| _ -> raise ParseErreur )
| "IF" ->
let test = parse_exp ((=) (Lident "THEN")) cl
in ( match ignore (lexer cl) ; lexer cl with
Lint n -> If (test,n)
| _ -> raise ParseErreur )
| _ -> raise ParseErreur )
| _ -> raise ParseErreur ;;
val parse_inst : chaine_lexer -> instruction = <fun>


Enfin, voici la fonction principale d'analyse syntaxique des commandes entrées par l'utilisateur depuis la boucle d'interaction :

# let parse str =
let cl = init_lex str
in match lexer cl with
Lint n -> Ligne { num=n ; inst=parse_inst cl }
| Lident "LIST" -> List
| Lident "RUN" -> Run
| Lident "END" -> End
| _ -> raise ParseErreur ;;
val parse : string -> phrase = <fun>


Évaluation

Un programme Basic est constitué d'une suite de lignes d'instruction. Son exécution démarre à la première ligne. L'interprétation d'une ligne de programme consiste donc à effectuer le travail demandé par l'instruction qui s'y trouve. Il y a trois familles d'instruction : les entrées-sorties (PRINT et INPUT), les déclarations ou modifications de variables (LET) et les branchements (GOTO et THEN). Les instructions d'entrées-sorties gèrent l'interaction avec l'utilisateur et exécuteront les fonctions équivalentes en Objective CAML.

Les déclarations de variables et leurs modifications ont besoin de savoir calculer la valeur d'une expression arithmétique et de connaître la localisation mémoire de la variable. L'évaluation d'une expression retourne une valeur entière ou booléenne ou des chaînes de caractères. Nous les regroupons sous le type valeur.

# type valeur = Vint of int | Vstr of string | Vbool of bool ;;


La déclaration d'une variable doit pouvoir réserver une place mémoire pour le rangement de la valeur qui lui est attribuée. De même la modification d'une variable nécessite la modification de la valeur associée à son nom. Pour cela l'évaluation d'un programme Basic utilise un environnement qui contient la liaison entre un nom de variable et sa valeur. On le représente par une liste d'associations, liste de couples (nom,valeur) :
 
# type environnement = (string * valeur) list ;;
On accède au contenu d'une variable par son nom. La modification de la valeur d'une variable change l'association.

Les instructions de branchements, inconditionnels ou conditionnels, précisent à quel numéro de ligne l'exécution du programme doit se poursuivre. Par défaut, la nouvelle instruction à exécuter est la suivante. Pour cela il est nécessaire de conserver le numéro de la prochaine ligne à exécuter.

La structure de liste d'instructions qui représente le programme édité sous la boucle d'interaction ne convient pas à une exécution raisonnablement efficace du programme. En effet, pour réaliser les instructions de saut (If et Goto) nous devrions reparcourir la totalité de la liste d'instructions pour trouver la ligne indiquée par le saut. Il est possible d'accéder directement à une ligne indiquée par une instruction de saut en substituant à la structure de liste, une structure de tableau et en utilisant les indices de localisation des instructions à la place des numéros de ligne. Pour cela, nous soumettrons l'ensemble des instructions du programme à un prétraitement, dit d'assemblage avant chaque exécution réclamée par une commande RUN. Pour des raisons de commodité que nous expliquons au paragraphe suivant, nous représentons un programme assemblé pour l'exécution non pas comme un simple tableau d'instructions, mais comme un tableau de lignes :

# type code = ligne array ;;


Comme pour la calculatrice des chapitres précédents, l'évaluateur manipule un état que l'évaluation de chaque instruction modifie. Les informations à connaître à chaque instant sont le programme en entier, la prochaine ligne à exécuter et les valeurs des variables. Le programme exécuté n'est pas exactement celui construit sous la boucle d'interaction : plutôt que d'exécuter une liste d'instructions, nous manipulerons un tableau d'instructions. L'état d'exécution d'un programme est donc :

# type etat_exec = { ligne:int ; xprog:code ; xenv:environnement } ;;


Il y a deux sources d'erreur à l'évaluation d'une ligne de programme : le calcul d'une expression et un branchement sur une ligne qui n'existe pas. Il nous faut donc les gérer pour que l'interprète s'arrête correctement en affichant un message d'erreur. On définit une exception et une fonction la déclenchant tout en indiquant à quel numéro de ligne elle s'est produite.

# exception RunErreur of int
let runerr n = raise (RunErreur n) ;;
exception RunErreur of int
val runerr : int -> 'a = <fun>


Assemblage
Le processus d'assemblage d'un programme donné sous forme de liste de lignes numérotées (type program) consiste à transformer cette liste en tableau puis à ajuster les instructions de saut. Pour réaliser cet ajustement, il suffit de conserver l'association entre les numéros de lignes et l'indice correspondant du tableau. C'est pour obtenir facilement cette association que nous construisons un tableau de lignes numérotées. Pour retrouver l'indice auquel est associé une ligne, on parcourt ce tableau. Si le numéro de ligne recherché n'est pas trouvé, on lui assigne, par convention, l'indice erroné -1.

# exception Resultat_cherche_indice of int ;;
exception Resultat_cherche_indice of int
# let cherche_indice tprog num_ligne =
try
for i=0 to (Array.length tprog)-1 do
let mun_i = tprog.(i).num
in if mun_i=num_ligne then raise (Resultat_cherche_indice i)
else if mun_i>num_ligne then raise (Resultat_cherche_indice (-1))
done ;
(-1 )
with Resultat_cherche_indice i -> i ;;
val cherche_indice : ligne array -> int -> int = <fun>

# let assemble prog =
let tprog = Array.of_list prog in
for i=0 to (Array.length tprog)-1 do
match tprog.(i).inst with
Goto n -> let indice = cherche_indice tprog n
in tprog.(i) <- { tprog.(i) with inst = Goto indice }
| If(c,n) -> let indice = cherche_indice tprog n
in tprog.(i) <- { tprog.(i) with inst = If (c,indice) }
| _ -> ()
done ;
tprog ;;
val assemble : ligne list -> ligne array = <fun>


Évaluation des expressions
La fonction d'évaluation des expressions parcourt en profondeur l'arbre de syntaxe abstraite et effectue les opérations indiquées à chaque noeud de l'arbre.

L'exception RunErreur est déclenchée dans les cas suivants : incohérence de type, division par zéro, variable non déclarée.

# let rec eval_exp n envt expr = match expr with
ExpInt p -> Vint p
| ExpVar v -> ( try List.assoc v envt with Not_found -> runerr n )
| ExpUnr (OPPOSE,e) ->
( match eval_exp n envt e with
Vint p -> Vint (-p)
| _ -> runerr n )
| ExpUnr (NON,e) ->
( match eval_exp n envt e with
Vbool p -> Vbool (not p)
| _ -> runerr n )
| ExpStr s -> Vstr s
| ExpBin (e1,op,e2)
-> match eval_exp n envt e1 , op , eval_exp n envt e2 with
Vint v1 , PLUS , Vint v2 -> Vint (v1 + v2)
| Vint v1 , MOINS , Vint v2 -> Vint (v1 - v2)
| Vint v1 , MULT , Vint v2 -> Vint (v1 * v2)
| Vint v1 , DIV , Vint v2 when v2<>0 -> Vint (v1 / v2)
| Vint v1 , MOD , Vint v2 when v2<>0 -> Vint (v1 mod v2)

| Vint v1 , EGAL , Vint v2 -> Vbool (v1 = v2)
| Vint v1 , DIFF , Vint v2 -> Vbool (v1 <> v2)
| Vint v1 , INF , Vint v2 -> Vbool (v1 < v2)
| Vint v1 , SUP , Vint v2 -> Vbool (v1 > v2)
| Vint v1 , INFEQ , Vint v2 -> Vbool (v1 <= v2)
| Vint v1 , SUPEQ , Vint v2 -> Vbool (v1 >= v2)

| Vbool v1 , ET , Vbool v2 -> Vbool (v1 && v2)
| Vbool v1 , OU , Vbool v2 -> Vbool (v1 || v2)

| Vstr v1 , PLUS , Vstr v2 -> Vstr (v1 ^ v2)
| _ , _ , _ -> runerr n ;;
val eval_exp : int -> (string * valeur) list -> expression -> valeur = <fun>


Évaluation des instructions
Pour réaliser la fonction d'évaluation d'une ligne d'instruction, nous avons besoin de quelques fonctions auxiliaires.

On ajoute une nouvelle association dans un environnement en supprimant éventuellement la liaison d'une variable de même nom :

# let rec ajoute v e env = match env with
[] -> [v,e]
| (w,f)::l -> if w=v then (v,e)::l else (w,f)::(ajoute v e l) ;;
val ajoute : 'a -> 'b -> ('a * 'b) list -> ('a * 'b) list = <fun>


Enfin l'affichage d'une valeur d'un entier ou d'une chaîne de caractères sera utile pour l'instruction PRINT.

# let print_valeur v = match v with
Vint n -> print_int n
| Vbool true -> print_string "true"
| Vbool false -> print_string "false"
| Vstr s -> print_string s ;;
val print_valeur : valeur -> unit = <fun>


L'évaluation d'une instruction est une transition menant d'un état à un autre. En particulier sont modifiés d'une part l'environnement si l'instruction est une affectation et d'autre part la prochaine ligne à exécuter. Par convention, si la prochaine ligne à exécuter sort du tableau contenant le code du programme, sa valeur est -1.

# let ligne_suivante etat =
let n = etat.ligne+1 in
if n < Array.length etat.xprog then n else -1 ;;
val ligne_suivante : etat_exec -> int = <fun>
# let eval_inst etat =
match etat.xprog.(etat.ligne).inst with
Rem _ -> { etat with ligne = ligne_suivante etat }
| Print e -> print_valeur (eval_exp etat.ligne etat.xenv e) ;
print_newline () ;
{ etat with ligne = ligne_suivante etat }
| Let(v,e) -> let ev = eval_exp etat.ligne etat.xenv e
in { etat with ligne = ligne_suivante etat ;
xenv = ajoute v ev etat.xenv }
| Goto n -> { etat with ligne = n }
| Input v -> let x = try read_int ()
with Failure "int_of_string" -> 0
in { etat with ligne = ligne_suivante etat;
xenv = ajoute v (Vint x) etat.xenv }
| If (t,n) -> match eval_exp etat.ligne etat.xenv t with
Vbool true -> { etat with ligne = n }
| Vbool false -> { etat with ligne = ligne_suivante etat }
| _ -> runerr etat.ligne ;;
val eval_inst : etat_exec -> etat_exec = <fun>


À chaque appel, la fonction de transition eval_inst cherche la ligne à exécuter (variable lc) et le numéro de la ligne suivante (variable ns). Si la dernière ligne du programme est atteinte, on attribue à ns la valeur -1. Cela nous permettra d'arrêter l'exécution.

Évaluation d'un programme
On applique récursivement les transitions jusqu'à obtenir un état où le numéro de la ligne courante est -1.

# let rec run etat =
if etat.ligne = -1 then etat else run (eval_inst etat) ;;
val run : etat_exec -> etat_exec = <fun>


Mise en oeuvre

Il ne nous reste plus qu'à réaliser un mini-éditeur et d'assembler toutes les briques réalisées dans les sections précédentes.

La fonction inserer ajoute, à la bonne place, une nouvelle ligne dans un programme.

# let rec inserer ligne p = match p with
[] -> [ligne]
| l::prog ->
if l.num < ligne.num then l::(inserer ligne prog)
else if l.num=ligne.num then ligne::prog
else ligne::l::prog ;;
val inserer : ligne -> ligne list -> ligne list = <fun>


La fonction print_prog affiche le source d'un programme.

# let print_prog prog =
let print_ligne x = print_string (pp_ligne x) ; print_newline () in
print_newline () ;
List.iter print_ligne prog ;
print_newline () ;;
val print_prog : ligne list -> unit = <fun>


La fonction une_commande traite l'ajout d'une ligne ou le lancement d'une commande. Elle agit sur l'état de la boucle d'interaction constitué d'un programme et d'un environnement. Cet état, représenté par le type etat_boucle, n'est pas celui de l'évaluation d'un programme.

# type etat_boucle = { prog:program; env:environnement } ;;
# exception Fin ;;

# let une_commande etat =
print_string "> " ; flush stdout ;
try
match parse (input_line stdin) with
Ligne l -> { etat with prog = inserer l etat.prog }
| List -> (print_prog etat.prog ; etat )
| Run
-> let tprog = assemble etat.prog in
let xetat = run { ligne = 0; xprog = tprog; xenv = etat.env } in
{etat with env = xetat.xenv }
| End -> raise Fin
with
LexerErreur -> print_string "Illegal character\n"; etat
| ParseErreur -> print_string "syntax error\n"; etat
| RunErreur n ->
print_string "runtime error at line ";
print_int n ;
print_string "\n";
etat ;;
val une_commande : etat_boucle -> etat_boucle = <fun>


La fonction principale est la fonction go qui lance la boucle d'interaction de notre Basic.

# let go () =
try
print_string "Mini-BASIC version 0.1\n\n";
let rec loop etat = loop (une_commande etat) in
loop { prog = []; env = [] }
with Fin -> print_string "A bientôt...\n";;
val go : unit -> unit = <fun>
La boucle est réalisée par la fonction locale loop. On en sort lorsque l'exception Fin a été déclenchée par la fonction une_commande.

Exemple : C+/C-

On reprend l'exemple du jeu C+/C- décrit au chapitre 3, page ??. Voici le programme Basic équivalent au programme Objective CAML donné :

10 PRINT "Donner le nombre cache : "
20 INPUT N
30 PRINT "Donner un nombre : "
40 INPUT R
50 IF R = N THEN 110
60 IF R < N THEN 90
70 PRINT "C-"
80 GOTO 30
90 PRINT "C+"
100 GOTO 30
110 PRINT "BRAVO"
Voici, pour finir, la trace de l'exécution de ce programme.

> RUN
Donner le nombre caché : 
64
Donner un nombre : 
88
C-
Donner un nombre : 
44
C+
Donner un nombre : 
64
BRAVO

Pour en faire plus

Le Basic que nous venons de programmer reste minimal. Pour ceux qui veulent aller plus loin, nous proposons en exercice quelques pistes pour enrichir l'évaluateur.
  1. Les flottants : tel quel, notre langage ne connaît que les entiers, les chaînes et les booléens. Ajouter les flottants ainsi que leurs principales opérations dans la grammaire du langage. Outre l'analyse lexicale, il faut modifier l'évaluation en tenant compte des conversions implicites entre entiers et flottants.

  2. Les tableaux : il s'agit ici d'ajouter à la syntaxe l'instruction DIM var[x] qui permet de déclarer un tableau var de taille x et l'expression var[i] qui référence le ième élément du tableau var.

  3. Directives : nous devrions aussi ajouter les directives SAVE "nom_fichier" et LOAD "nom_fichier" qui respectivement enregistre ou charge un programme Basic sur ou depuis le disque dur.

  4. Sous-programmes : l'appel à un sous-programme s'effectue en utilisant l'instruction GOSUB numéro de ligne qui effectue un branchement à ce numéro de ligne tout en conservant le numéro de la ligne d'appel. L'instruction RETURN reprend l'exécution à la ligne suivant la dernière instruction GOSUB exécutée si elle existe et sort du programme sinon. Pour ajouter cette possibilité, il faudra gérer dans l'évaluation, en plus de l'environnement, une pile contenant les adresses de retour des différents appels à GOSUB. L'instruction GOSUB permet de définir des sous-programmes récursifs.

Précédent Index Suivant