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 :
-
PRINT expression
|
Affiche le résultat de l'évaluation de l'expression. |
- INPUT variable
|
Affiche un prompt à l'écran (?), attend qu'un
entier soit entré au clavier, et affecte la variable avec cette
valeur. |
- LET variable = expression
|
Affecte la variable avec le résultat de l'évaluation de l'expression. |
- GOTO numéro de ligne
|
Reprend l'exécution à la ligne mentionnée. |
- IF condition THEN numéro de ligne
|
Reprend l'exécution à la ligne mentionnée si la condition
est vraie. |
- REM chaîne de caractères quelconque
|
Commentaire sur une ligne. |
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 :
-
une expression suivie d'un opérateur unaire
- une expression suivie d'un opérateur binaire et d'une
expression.
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.
- 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.
- 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.
- 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.
- 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.