Langages formels
Analyse lexicale
Expressions régulières
Automates.
Analyse en deux phases
Conceptuellement, deux phases :
-
Analyse lexicale
- transforme une suite de caractères en une suite de
lexèmes (mots).
- Analyse grammaticale
- transforme une suite de lexèmes en une
représentation arborescente (syntaxe abstraite).
Stricto-sensu, une seule passe.
Enjeux
Les analyses lexicales et grammaticales ont un domaine d'application bien
plus large que celui de la compilation.
On les retrouve dans de nombreuses applications
(analyses des commandes, des requêtes,
etc.).
Ces deux analyses utilisent de façon essentielle les automates, on
retrouve aussi les automates dans de nombreux domaines de l'informatique.
Les expressions régulières sont un langage de description d'automates; elles
sont utilisées dans de nombreux outils Unix (emacs,
grep...), et fournies en bibliothèque
dans la plupart des langages de programmation
(cf. Perl).
Note
L'étude détaillée des automates et des grammaires formelles
pourrait constituer un cours à part entière.
Nous nous contentons ici de la présentation formelle minimale, avec comme
but:
- d'expliquer le fonctionnement des analyseurs de façon à pouvoir écrire
soi-même des analyseurs lexicaux ou grammaticaux.
- de se familiariser aussi avec les expressions régulières et les automates.
Le but du cours n'est pas d'écrire le moteur d'un analyseur, ni de
répertorier toutes les techniques d'analyses.
Les langages formels
On se donne un ensemble Σ appelé alphabet, dont les éléments sont
appelés caractères.
Un mot (sur Σ) est une séquence de caractères (de Σ).
-
On note :
-
є le mot vide,
- uv la concaténation des mots
u et v (la concaténation est associative et a є pour
élément neutre).
- Σ ∗ est l'ensemble des mots sur Σ
- Σ + est l'ensemble des mots non vides.
Un langage sur Σ est un sous-ensemble L de Σ ∗.
Exemples
- Σ1 est l'alphabet et L1 l'ensemble des mots du dictionnaire
français avec toutes leurs variations (pluriels, conjugaisons).
- Σ2 est L1 et L2 est l'ensemble des phrases grammaticalement
correctes de la langue française.
Ou bien L2' le sous-ensemble des palindromes de L2.
- Σ3 est l'ensemble des caractères ASCII, et L3 est
composé de tous les mots clés de pseudo pascal, des symboles,
des identificateurs, et de l'ensemble des entiers décimaux.
- Σ4 est L3 et L4 est l'ensemble des programmes pseudo pascal.
- Σ est {a, b} et L est { a n b n ∣ n ∈ IN}
(expressions bien parenthésées).
Expressions régulières
Un formalisme simple permettant de décrire
certains langages simples (les langages réguliers).
On note a, b, etc. des lettres de Σ, M et N
des expressions régulières, [[M]] le langage associé à M.
- Une lettre a désigne le langage {a}.
- Epsilon: є désigne le langage {є}.
- Concaténation: M N désigne le langage [[M]] [[N]].
- Alternative: M ∣ N désigne le langage [[M]] ∪ [[N]].
- Répétition: M∗ désigne le langage [[M]] ∗.
On ajoute du sucre syntaxique (ie. sans changer l'expressivité):
- [abc] pour (a ∣ b ∣ c)
et
[a1-a2] pour {c ∈ Σ, a1 ≤ c ∧ c ≤ a2}
(et [^…], les complémentaires).
- M? pour M ∣ є
et
M+ pour M M∗.
_
(ou parfois .
) pour n'importe quel caractère.
Exemples
-
Entiers décimaux
-
[0-9]+
- Entiers hexadécimaux
-
0x([0-9a-fA-F])+
- Nombres (Pascal)
-
[0-9]+ (. [0-9]*)? ([Ee] [-+]? [0-9]+)?
- Sources Caml
-
«
# ls *.ml{,[ily]}
»
(Avec quelques changements de syntaxe...)
Analyse lexicale
On décrit chaque sorte de lexème par une expression régulière :
- Les mots clés:
"let"
,
"in"
- Les variables:
['a'-'z''A'-'Z']+ ['a'-'z''A'-'Z''0'-'9']*
- Les entiers:
['0'-'9']+
- Les symboles:
'('
,
')'
,
'+'
,
'*'
'='
- Les espaces:
(' ' | '\n' | '\t')
, à oublier.
À reconnaître et transformer en :
type token =
| LET | IN (* mots-clés *)
| VAR of string (* variables *)
| INT of int (* entiers *)
| LPAR | RPAR | ADD | SUB | MUL | DIV | EQUAL (* symboles *) |
|
Procédure
Pour toute expression régulière M,
il existe un automate qui reconnaît [[M]].
-
Les mots clefs :
- Les entiers :
- L'un ou l'autre :
Utilisation de l'automate, on veut un lexème...-
Appeler l'automate,
- Il renvoie le code d'un état final atteint.
Recommencer pour lire tous les lexèmes (flux = entrée impérative).
Ambiguïté
Problème Il y a des ambigüités :
-
"let" pourrait être reconnu comme une variable.
- "lettre" pourrait aussi être reconnu comme la séquence
LET; VAR "tre"
ou encore
VAR "let"; VAR "tre"
Solution Des règles de priorité.
-
Entre deux lexèmes de taille différentes : le plus long.
- Sinon, suivre l'ordre de définition des sortes de lexèmes.
Ainsi la phrase let lettre = 3 in 1 + fin
produit la suite de
lexèmes:
LET; VAR "lettre"; EQUAL; INT 3; IN; INT 1; PLUS; VAR "fin"
Réalisation des règles de priorité
-
Le lexème le plus long À l'exécution de l'automate : se
souvenir du dernier état final rencontré, le rendre en cas de blocage.
- L'ordre de définition Au moment de la construction de
l'automate.
ocamllex, un générateur d'analyseurs syntaxiques
L'analyseur est sous forme de règles
«| motif {
action}
» dans un fichier
lexer.mll.
Compilation en deux temps :
-
ocamllex lexer.mll
ocamlc -c lexer.ml
Le fichier lexer.ml contient l'automate sous forme Caml.
Cet automate déclenche la bonne action lorsque son entrée est filtrée
par un des motifs.
Exemple de source ocamllex
{ (* prélude copié au début de lexer.ml *)
open Token
exception Error
}
(* définition de l'analyseur « token » *)
rule token = parse
(* Les lexèmes stricto-sensu *)
| '(' {LPAR} | ')' {RPAR} …
| "let" {LET} | "in" {IN}
| ['A'-'Z' 'a'-'z'] ['A'-'Z' 'a'-'z' '0'-'9']* as lxm
{VAR lxm}
| ['0'-'9']+ as i
{INT (int_of_string i))} |
|
Exemple (suite)
(* Règles supplémentaires *)
| eof {EOF}
| [' ' '\n' '\t' ] {token lexbuf}
| "" {raise Error} |
|
Type de l'analyseur token
? Lexing.lexbuf -> Token.token
Usage
let entrée = Lexing.from_channel stdin in (* Fabriquer le flux *)
let count = ref 0 in
while Lexer.token entrée <> Token.EOF do
count := !count + 1
done |
|
Première sorte de commentaire
Le commentaires sont enlevés au moment de l'analyse syntaxique.
// Je vais au bout de la ligne |
Solution en une ligne de plus pour token
| "//" [^'\n']* '\n'? {token lexbuf} |
|
Deuxième sorte de commentaire
Une ouverture, une fermeture , imbrication interdite (mais pourquoi
donc ?).
/* Je suis un commentaire
sur deux lignes */ |
Solution Avec un deuxième automate :
rule token = parse
...
| "/*" {incomment lexbuf}
and incomment = parse
| "*/" {token lexbuf}
| _ {incomment lexbuf}
| eof {raise Error} |
|
Troisième sorte de commentaires
Les mêmes imbricables (mais pourquoi faire ?)
(* Je suis
(* un commentaire *)
dans le commentaire *) |
Il y a un vrai problème théorique.
Les automates finis ne savent pas reconnaître le langage des
parenthèses.
Solution, dans le code des actions
{let depth = ref 0}
rule token = parse
| "(*" {depth := 1 ; incomment lexbuf ; token lexbuf}
and incomment = parse
| "*)"
(* fermeture "*)" de l'ouverture "(*" à la profondeur depth *)
{depth := !depth-1 ;
if !depth > 0 then incomment lexbuf}
| "(*" {depth := !depth+1 ; incomment lexbuf}
| _ {incomment lexbuf}
| eof {raise Error} |
|
Ce code est-il pleinement satisfaisant en pratique ?
Pas tout à fait, voir le commentaire dans le code
lui-même !
Une autre solution, sans référence
rule token = parse
…
| "(*" {incomment 0 lexbuf ; token lexbuf}
and incomment depth = parse
| "*)"
{if depth > 0 then incomment (depth-1) lexbuf}
| "(*" {incomment (depth+1) lexbuf}
| _ {incomment depth lexbuf}
| eof {raise Error} |
|
Type de incomment
?
val incomment : int -> Lexing.lexeme -> unit |
Une autre solution, sans compteur du tout
rule token = parse
| "(*" {incomment lexbuf ; token lexbuf}
and incomment = parse
| "*)" {()}
| "(*" {incomment lexbuf ; incomment lexbuf}
| _ {incomment lexbuf}
| eof {raise Error} |
|
Les chaînes (avec citations)
"Voila \" le délimiteur des chaînes et le caractère \\ pour citer\n" |
C'est très semblable au commentaires /*
... */
.
Mais il faut récupérer le contenu de la chaîne.
let sbuff = Buffer.create 16 (* fabriquer le buffer *)
(* Mettre un caractère à la fin de sbuff *)
let put_char c = Buffer.add_char sbuff c
(* Récupérer le contenu de sbuff et le réinitialiser *)
let to_string () =
let r = Buffer.contents sbuff in
Buffer.clear sbuff ; r |
|
Solution, avec un buffer
rule token = parse …
| '"' {STRING (instring lexbuf)}
and instring = parse
| '"' {to_string ()} (* Fin *)
| '\\' ('\\' | '\"' as c) (* Caractères cités *)
{put_char c ; instring lexbuf}
| '\\' 'n' (* Caractères spéciaux *)
{put_char '\n' ; instring lexbuf}
| _ as c
{put_char c ; instring lexbuf}
| eof
{raise Error} |
|
Abondance de mots-clés
Lorsqu'il y a beaucoup de mot-clés.
rule token = parse
…
| "var" { VAR } | "alloc" { ALLOC }
| "false" { BOOL false } | "true" { BOOL true }
…
| "boolean" { BOOLEAN } | "program" { PROGRAM }
| ['A'-'Z''a'-'z'] + as lxm { Var lxm }
… |
|
Quel problème pratique peut-il se poser :
La taille de l'automate, en gros il aura N états, où N est la
somme des longueurs des mot-clés.
Abondance de mot-clés
-
Dans le prélude, on fabrique une association
chaîne → lexème.
- Dans, l'automate, on reconnaît variables et mot-clés ensemble, puis
on discrimine.
(* Table de hachage, taille initiale 17 *)
let keywords = Hashtbl.create 17
(* Vérifier/ajouter un mot-clé *)
let check_keyword s =
try Hashtbl.find keywords s with Not_found -> IDENT s
and add_keyword s k = Hashtbl.add keywords s k
(* Définir les mot-clés *)
let () =
add_keyword "var" VAR ; add_keyword "alloc" ALLOC ;
…
add_keyword "boolean" BOOLEAN ; add_keyword "program" PROGRAM ;
() |
|
rule token = parse
$\ldots$
| ['A'-'Z''a'-'z'] + as lxm { check_keyword lxm }
$\ldots$ |
|
Comparaisons, pour deux analyseurs des lexème de Pseudo-Pascal.
# ocamllex sans_hash.mll
117 states, 8014 transitions, table size 32758 bytes
# ocamllex avec_hash.mll
26 states, 435 transitions, table size 1896 bytes
Diversion : utilisation avancée de ocamllex
On dispose de 1000 fichiers de style agenda, le format est assez
libre :
-
Chaque ligne contient un nom, suivi éventuellement d'un numéro
de téléphone.
- Les noms sont composés de suites de mots, qui peuvent contenir
des tirets à l'intérieur.
- Les numéros de téléphone ont 10 chiffres et peuvent contenir
des espaces à peu-près n'importe où.
On souhaite (pour lire tout ça avec Excel)
transformer les fichiers en un format plus strict : le CSV.
-
Chaque ligne comporte deux items.
- Les items sont séparés par des virgules.
- Les items sont compris entre deux double-quotes « " ».
Diversion: les motifs
{ … }
(* Noms de personnes *)
let blank = [' ''\t']
let lettre = ['A'-'Z' 'a'-'z' 'À'-'Ö' 'Ù'-'ö' 'ù'-'ÿ']
let mot = lettre+
let mot2 = mot ('-' mot)*
let nom = mot2 (blank+ mot2)*
(* ex: Jean-Hugues Leroy de Pressalé *)
(* Nunéro de téléphone à 10 chiffres *)
let digit = ['0'-'9'] (* un chiffre *)
let db = digit blank*
let numero = db db db db db db db db db digit |
|
Diversion: l'automate
let eol = '\n' (* Unix *) | ('\r' '\n') (* Windows *) | '\r' (* Mac *)
(* Saut de ligne universel (?) *)
rule line = parse
| (nom as name) (blank+ (numero as phone))? blank* eol
{Printf.printf "\"%s\",\"%s\"\n"
name (match phone with None -> "" | Some s -> s) ;
line lexbuf }
| eof { () }
{ let buf = from_channel stdin
let _ = line buf ; exit 0 } |
|
Retour à la réalité : les erreurs
En cas d'erreur, il est gentil d'indiquer un minimum d'information :
-
une position dans le fichier d'entrée,
- le genre de l'erreur,
- le texte non compris.
Exemple Caml :
let x = 1
let y = "coucou
let z = 1
File "er.ml", line 2, characters 8-9:
String literal not terminated
Quant à tenter de réparer...
Comment fonctionnne ocamllex?
Le schéma traditionnel :
- Chaque expression régulière est compilée en un automate,
- L'ensemble des automates sont fusionnés en un seul,
- L'automate résultant est determinisé.
- L'automate est minimisé.
En fait, ocamllex produit directement des automates
déterministes et ne les minimise pas.
Automates finis déterministes (DFA)
Un automate fini déterministe M est un quintuple (Σ, Q, δ,
q0, F) où
- Σ est un alphabet;
- Q est un ensemble fini d'états;
- δ : Q × Σ → Q est la fonction (partielle) de
transition;
- q0 est l'état initial;
- F est un ensemble d'états finaux.
On peut étendre δ sur Q × Σ ∗→ Q par
{
δ (q, є) = q |
δ (q, aw) = δ (δ (q, a), w) |
.
Le langage L(M) reconnu par l'automate M est
l'ensemble { w ∣ δ (q0, w) ∈ F} des mots permettant
d'atteindre un état final à partir de l'état initial.
Automates finis non-déterministes (NFA)
Comme DFA, compte tenu des deux détails suivants :
-
Il peut y avoir plusieurs transition homonymes issues d'un sommet.
- Il existe des transitions « spontanées »
(étiquelle є.)
Modèle de δ comme une relation sur
Q × (Σ ∪ {є}) × Q.
⎧
⎪
⎨
⎪
⎩ |
|
δ(q,є, q) |
|
|
δ(q,є, q'') ∧ δ(q'', w, q') |
⇒ |
δ(q, w, q') |
δ(q, a, q'') ∧ δ(q'', w, q') |
⇒ |
δ(q, aw, q') |
|
|
Langage reconnu : {w ∣ ∃ qf ∈ F, δ (q0, w, qf)}
Formalisation de l'exécution d'un NFA
Le plus simple est de considérer des ensembles d'états :
-
la fermeture F, comme le point fixe de
S = S ∪ { q ∣ ∃ q' ∈ S, δ(q', є, q)}
(autrement dit, on suit toutes les transitions spontanées possibles
issues des états de S).
- la consommation d'un caractère a, noté Ca comme
Ca(S) = {q ∣ ∃ q' ∈ S, δ(q', a, q)}.
État initial :
F({q0})
Une étape d'exécution :
Automate d'une expression régulière
L'alphabet Σ est fixé.
On associe à une expression régulière M un automate non déterministe
(Q, δ, s, F) défini récursivement par:
- [[a]] = ({s, f}, {s ↦a f}, s, {f})
- [[є]]= ({s, f}, {s ↦єf}, s, {f})
- [[M ∣ M']] =
(Q ∪ Q' ∪ {s''}, δ ∪ δ' ∪
{ s'' ↦єs, s'' ↦єs' }, s'', F ∪ F')
- [[M M]] =
(Q ∪ Q', δ ∪ δ' ∪ {f ↦єs', f ∈ F}, s, F')
- [[M∗]] =
(Q ∪ {s', f'},
δ ∪ {f ↦єf', f ↦єs, f ∈ F} ∪
{s' ↦єs} ∪ {s' ↦єf'},
s', {f'})
La même chose en dessins
Pour a, є et [ab].
La même chose en dessins
Pour M N, M ∣ N et M*.
Exemple
Expression régulière (a b)∗.
Déterminisation de l'automate (egale à l'є-fermeture, ici):
Déterminisation d'un automate
Pour tout automate non déterministe An = (Q, δ, q0, F), il
existe un automate déterministe Ad = (R, γ , Q0, G) qui
reconnaît le même langage.
On pose R ⊆ 2Q et tout simplement :
Q0 = F({q0}) |
γ(Qi,a) = Qj
⇔
Qj = F(Ca(Qi)) |
Cela revient à parcourir toutes les exécutions de An en s'arrêtant
aux état déjà vus.
Minimisation de l'automate
Elle repose sur l'équivalence de langage de suffixes définis par deux
états.
Un peu plus sur la minimisation
En pratique on utilise une relation d'équivalence ≅ plus facilement
calculable (par point fixe) :
qi ≅ qj
⇔
(qi ∈ F ⇔ qj ∈ F)
∧
(∀ a ∈ Σ, δ(qi,a) ≅ δ(qj,a))
La relation ≅ est facile à calculer par point fixe.
Le faire de façon efficace est une autre affaire
(nombreux algorithmes publiés).
Ce document a été traduit de LATEX par HEVEA