TP - Évaluateur d'expressions
Introduction
Le but de ce TP sera de programmer un outil permettant d'évaluer une expression fournie comme une chaîne de caractères.
Par exemple 3 + 5 - 10 sera évaluée comme -2ou encore, si on sait que la variable avaut 3, alors l'expression 3*avaudra 9.
Dans un premier temps, les expressions n'admettront que des valeurs entières et pourront reconnaître
- des constantes numériques
- les opérateurs
+,-,*et/ - des variables
- les parenthèses
Les variables suivront les conventions habituelles de Python, à savoir qu'elles ne peuvent commencer par un chiffre
et sont uniquement composées de caractères alphanumériques et de _.
Première partie : la classe Parser
La classe Parser aura comme but de transformer une chaîne de caractères en ensemble de tokens utilisable par la suite.
Un token est un élément constitutif d'une expression ; par exemple un opérateur, le nom d'une variable ou encore une valeur numérique.
Vous trouverez les tests unitaires propres à la classe Parser ici
1. Créer la classe Parser
Le constructeur de la classe prend en argument la liste des caractères spéciaux
(hors caractères alphanumériques) admissibles pour former des tokens. Si aucune liste n'est fournie la valeur
par défaut sera [ '+', '-', '*', '/', '_', '(', ')', ' ']. Cette liste est appelée alphabet et sera stockée dans un attribut d'instance.
2. Créer la méthode is_in_alphabet
Dans la classe Parser créer la méthode suivante
is_in_alphabet(self, c: str) -> bool
qui renvoie True si le caractère c fait partie de l'alphabet ou s'il est de type alphanumérique et renvoie False sinon.
3. Créer la méthode get_token
Dans la classe Parser créer la méthode suivante
get_token(self, flux: str) -> (str, str)
Cette méthode prend en entrée une chaîne de caractères appelée flux et renvoie comme résultat le premier token
conforme à l'alphabet ainsi que le reste de flux, diminué du token lu.
Un token valide est :
- un opérateur isolé parmi
+,-,*,/,(,) -
un nom de variable. Un nom de variable peut être d'une longueur quelconque, doit obligatoirement commencer par une lettre ou
_et est composée d'une combinaison de lettres, de chiffres et de_. Par exemple les tokens suivants sont des noms de variables valides :v__av12_vvvv1v2v3.En revanche
6ane l'est pas, puisqu'il commence par un chiffre. -
un nombre. Un nombre est une suite de longueur quelconque et ininterrompue de chiffres.
La méthode get_token doit renvoyer les résultats suivants :
p = Parser()
flux1 = 'a + b'
flux2 = ' a + b'
flux3 = 'a+b'
flux4 = 'abcd'
flux5 = '12ab'
flux6 = 'ab12'
flux7 = '@ab'
print(p.get_token(flux1)) # doit afficher ('a', ' + b')
print(p.get_token(flux2)) # doit afficher ('a', ' + b')
print(p.get_token(flux3)) # doit afficher ('a', '+b')
print(p.get_token(flux4)) # doit afficher ('abcd', '')
print(p.get_token(flux5)) # doit afficher ('12', 'ab')
print(p.get_token(flux6)) # doit afficher ('ab12', '')
print(p.get_token(flux7)) # doit afficher ('', '@ab')
4. Traiter une chaîne complète
Écrivez une méthode permettant de successivement extraire tous les tokens d'une chaîne de caractères en validant bien les conditions d'arrêt et le comportement en cas de caractères hors alphabet.
Seconde partie : la classe Expression
1. La classe Expression
On définit la classe Expression comme suit :
class Expression:
def eval(self) -> int:
'''
Evalue l'expression et calcule sa valeur
:return: la valeur de l'expression
'''
raise NotImplementedError
Cette définition correspond à une façon simpliste de déclarer une méthode abstraite.
2. Les classes dérivées
On dérivera ensuite la classe Expression en plusieurs sous-classes : Value, Variable, PrefixExpression, InfixExpression.
Pour chacune d'entre elles il sera nécessaire de définir a minima les méthodes suivantes :
- un constructeur
- une implémentation de la méthode
eval() - une surcharge de la méthode
__str__()
La méthode __str__() est une méthode prédéfinie dans Python indiquant comment un objet doit se comporter lorsqu'on
souhaite le transformer en chaîne de caractères (notamment pour l'affichage). La signature de la fonction est
__str__(self) -> str
Vous pouvez vous référer à la documentation de Pyton pour plus de détails.
L'utilisation de cette méthode est implicite dans la plupart des cas et on l'invoque rarement. Par exemple, dans le code suivant, print va automatiquement faire appel à __str__ pour l'affichage.
class MaClasse:
def __str__(self) -> str:
return "MaClasse"
if __name__ == "__main__":
c = MaClasse()
print(c)
Vous trouverez les tests unitaires propres aux classes Expression, Value et Variable ici
- La classe Value
La classe Value permettra de représenter les valeurs numériques.
- son constructeur prend en argument un token ;
- elle a un attribut d'instance nommé
valuede typeint; - le constructeur initialise l'attribut
valueavec celle représentée par le token fourni en entrée ; - la méthode
__str()__renvoie la chaîne de caractères représentant la valeur de l'attributvalue. - la méthode
eval()renvoie la valeur de l'attributvalue.
- La classe Variable
La classe Value permettra de représenter des variables.
- son constructeur prend en argument le nom de la variable
- elle a un attribut
namede typestret un attributvaluede typeìnt. - le constructeur initialise l'attribut
valueàǸone(signifiant que la variable n'est pas initialisée) et initialise l'attributnameavec le token passé en fourni en entrée; - la méthode
__str()__renvoie la chaîne de caractères représentant la valeur de l'attributname. - la méthode
eval()renvoie la valeur de l'attributvalue.
En plus de ces méthodes, la classe Variable possède la méthode set_value(int) qui permet d'attribuer
une valeur à l'attribut value et qui ne renvoie pas de valeur.
- La classe PrefixExpression
La classe PrefixExpression permettra de représenter des expressions précédées
d'un opérateur unaire, comme -5 ou -(5*x).
- son constructeur prend en argument un token opérateur et une
Expressionqu'il stocke dans les attributsopde typestretexprde typeExpression. - la méthode
__str()__renvoie la chaîne de caractères concaténantopet le résultat deexpr.__str__(). - la méthode
eval()renvoieexpr.eval()(polymorphisme) corrigé parop.
Vous trouverez les tests unitaires propres à la classe PrefixExpression ici
- La classe InfixExpression
De façon similaire la classe InfixExpression représente les Expression de type exp1 op exp2
comme 5 * 3 ou -6 + a.
- son constructeur prend en argument un token opérateur et deux
Expressionqu'il stocke dans les attributsopde typestretexpr1etexpr2de typeExpression. - la méthode
__str()__renvoie la chaîne de caractères concaténantopentre le résultat deexpr1.__str__()et deexpr2.__str__(). - la méthode
eval()renvoie le résultat de l'opérateuropaux opérandes gaucheexpr1.eval()et droiteexpr2.eval()(polymorphisme) corrigé parop.
Vous trouverez les tests unitaires propres à la classe InfixExpression ici
Troisième partie : combiner le tout
1. Créer la méthode Parser.is_operator()
Ajouter la méthode is_operator(op: str) -> bool dans la classe Parser. Cette méthode prend comme argument un
token et retourne True s'il s'agit d'un opérateur, et False sinon.
2. Une solution imparfaite
Copiez et analysez le code ci-dessous. Complétez avec des commentaires et expliquez ce qu'il fait.
def read_expression(flot: str) -> (Expression, str):
"""
:param variables:
:param flot:
:return:
"""
p = Parser()
token, flot = p.get_token(flot)
previousExpression = None
currentExpression = None
while token:
currentExpression = None
if token.isnumeric():
# Une expression non aboutie (= previousExpression is not None) ne peut pas être suivie d'un nombre sans
# que les deux ne soient connectés par un opérateur.
if previousExpression is not None:
raise SyntaxError(token)
currentExpression = Value(token)
elif p.is_operator(token[0]):
# Un opérateur est forément suivi d'une expression donc on va lire l'expression qui suit et
# la stocker dans nextExpression.
operator = token
nextExpression, flot = read_expression(flot)
# Selon l'existence ou on d'une expression précédent l'opérateur, on sera soit dans cas d'une
# PrefixExpression ou d'une InfixExpression
if not previousExpression:
currentExpression = PrefixExpression(operator, nextExpression)
else:
currentExpression = InfixExpression(operator, previousExpression, nextExpression)
elif token[0].isalpha() or token[0] == '_':
# Une expression non aboutie (= previousExpression is not None) ne peut pas être suivie d'une variable sans
# que les deux ne soient connectés par un opérateur.
if previousExpression is not None:
raise SyntaxError(token)
currentExpression = Variable(token)
else:
raise NotImplementedError(token)
previousExpression = currentExpression
token, flot = p.get_token(flot)
return currentExpression, flot
Ce code est une première approche d'un évaluateur d'expressions. Testez-le sur les exemples suivants et expliquez les résultats. Vous trouverez le test unitaire ici.
flow1 = "2 + 3"
flow2 = "2 + 3 - 5"
flow3 = "2 + 3 * 4"
flow4 = "3 * 4 + 2"
flow5 = "(3 * 4) + 2"
flow6 = "3 + a"
Quatrième partie : aller jusqu'au bout
1. Gérer les variables
a. Modifiez la fonction read_expression() (une première fois)
Modifiez la fonction read_expression(str) de sorte qu'elle renvoie un tuple comportant les mêmes Expression et
str que précédemment ainsi qu'un dictionnaire qui associe à tous les token de type "variable" l'objet de type Variable associé.
La fonction ainsi créée aura donc comme signature
def read_expression(flot: str) -> (Expression, dict[str, Variable], str):
pass
b. Affectez une valeur aux variables
Testez votre code en prenant le canevas ci-dessous :
flow = "3 + a"
(e, d, _) = read_expression(flow)
##
# Code à implémenter
##
print(f'{e} = {e.eval()}')
Et en implémentant une boucle qui va parcourir tous objets de type Variable dans d et leur affecte une valeur.
Évaluez votre code sur l'expression "a + a". Qu'observez-vous ?
c. Gérez les occurrences multiples d'une même variable
Modifiez la fonction read_expression() (une seconde fois) afin qu'elle prenne la signature suivante :
def read_expression(flot: str, dict[str, Variable] = None) -> (Expression, dict[str, Variable], str):
pass
Modifiez ensuite la partie
...
elif token[0].isalpha():
currentExpression = Variable(token)
...
de telle sorte que, si le token est déjà présent dans le dictionnaire fourni en argument, on ne crée pas de nouvel
objet de type Variable mais qu'on affecte à currentexpression la variable déjà présente dans le dictionnaire.
d. Validation
Validez votre code sur les exemples suivants :
a + 3
3 + a
a + b
a - a
a + 3 + a
Partie optionnelle : Gérer les parenthèses
Cette partie nécessite de maîtriser la notion de récursivité. Il est inutile d'aborder cette partie si le concept ne vous est pas familier.
Pour gérer les parenthèses correctement, il faut implémenter les étapes suivantes :
- déclencher une nouvelle action lors de la lecture du token
(. Cette action consiste en un appel de lecture d'expression (appel récursif). Au retour de cet appel récursif, le prochain token doit forcément être)sinon on provoque une exception de typeSyntaxError. - la subtilité réside dans la gestion des token
). Si on rencontre un)en dehors du cas précédent -- lecture de(suivi d'uneExpressionpuis à nouveau un)-- il faut s'assurer que l'expression courante est bien formée :previousExpressiondoit être différent deNonenotamment. Ensuite, il est important de remettre le token)au début du flot de lecture, de sorte qu'il puisse être lu à la fin de l'appel récursif du point précédent.
Vous trouverez des tests unitaires pour la gestion des parenthèses ici.
Partie optionnelle : Gérer la priorité des opérateurs (difficile)
Faire en sorte que
flow1 = "2 + 3 * 4"
flow2 = "3 * 4 + 2"
s'évaluent en la même valeur 14.