|
Le signe © renvoie à la correction |
Objectif. Notre objectif ici est lécriture dun programme qui trace la représentation graphique dune fonction donnée au moment de lexécution. Lapplication finale ressemblera à ceci :
Tracé dune fonction
Lutilisateur écrit dans un champ en bas du cadre un texte qui est lexpression dune fonction réelle dune variable réelle. Il donne aussi quatre nombres (X min, X max, Y min et Y max) qui définissent les limites du rectangle de tracé, et deux autres nombres (delta X et delta Y) qui définissent les intervalles entre les lignes du quadrillage sur lequel la fonction est tracée. Dans ce quadrillage, deux lignes plus épaisses représentent les axes de coordonnées.
Le traval à faire peut se décomposer en quatre parties :
La fonction à tracer sera représentée dans votre programme par un arbre dont les nœuds correspondent aux divers éléments dune expression arithmétique :
Par exemple, larbre représentatif de lexpression
f(x) = exp(-x * 0.2) * sin(x)
est quelque chose comme ceci :
Les nœuds de larbre représentatif de lexpression à tracer seront des instances des classes :
Expression (interface) ExpressionBinaire (classe abstraite) Addition (classe) Soustraction (classe) Multiplication (classe) Division (classe) ExpressionUnaire (classe abstraite) Sin (classe) Cos (classe) Exp (classe) Log (classe) Constante (classe) Variable (classe)
(ci-dessus, lindentation traduit les relations implements et extends existant entre ces classes).
Lexercice consiste à écrire toutes ces classes. Pour fixer les idées, voici le texte intégral de linterface Expression :
public interface Expression { double valeur(double x); }
La signification de cette déclaration est la suivante : à tout objet qui prétend être une [implémentation de linterface] Expression on pourra demander la valeur prise pour une valeur donnée de la variable x. Ainsi, lélément le plus important de chaque classe concrète implémentant Expression, directement ou indirectement, sera la définition dune méthode valeur adaptée à ce que la classe représente.
Toujours pour fixer les idées, voici le texte intégral de la classe ExpressionBinaire :
abstract class ExpressionBinaire implements Expression { protected Expression argumentGauche, argumentDroite; public ExpressionBinaire(Expression g, Expression d) { argumentGauche = g; argumentDroite = d; } }
Cette classe doit être déclarée abstraite car elle ne définit pas la méthode valeur « promise » par linterface Expression. Du code ci-dessus il découle que les constructeurs des classes Addition, Soustraction, etc., devront avoir pour arguments les deux opérandes de lopération en question.
En revanche, il se révélera commode de pouvoir construire une opération unaire (cest-à-dire lappel dune fonction prédéfinie) alors que largument nest pas encore connu. Voici la classe ExpressionUnaire :
abstract class ExpressionUnaire implements Expression { protected Expression argument = null; void setArgument(Expression a) { argument = a; } }
N.B. Les classes quil faut écrire ici étant très simples et très liées entre elles, vous pouvez pour une fois les mettre toutes ensemble dans un seul fichier (seule une dentre elles pourra alors être publique). Mais ce nest pas une obligation...
Afin de vérifier la correction des classes écrites à lexercice précédent, définissez dans chacune une méthode public String toString(), puis une classe de test avec une méthode main qui
N.B. On vous demande des méthodes toString correctes, mais il est inutile de perdre du temps à chercher comment minimiser le nombre de parenthèses.
Exemple simple (vous devez essayer plusieurs expressions plus complexes !) :
public static void main(String[] args) { Expression f = new Soustraction( new Multiplication(new Variable(), new Variable()), new Constante(1)); System.out.println("f(x) = " + f); double[] v = { -2, -1, 0, 1, 2 }; for (int i = 0; i < v.length; i++) System.out.println("f(" + v[i] + ") = " + f.valeur(v[i])); }
Affichage obtenu :
f(x) = (x)*(x)-(1.0) f(-2.0) = 3.0 f(-1.0) = 0.0 f(0.0) = -1.0 f(1.0) = 0.0 f(2.0) = 3.0
Lanalyseur prend en entrée un texte (c.-à-d. une chaîne de caractères) et produit en sortie larbre représentatif de la fonction dont ce texte est lexpression écrite. Durant ce travail, les erreurs de syntaxe dans le texte son détectées et provoquent laffichage dun message et labandon de lanalyse (une seule erreur est donc détectée chaque fois).
Le travail danalyse se compose de deux couches distinctes :
Le point de départ dun analyseur syntaxique est la grammaire du langage visé. Dans notre cas, elle est définie par trois règles, expression, terme et facteur, quon peut représenter par les diagrammes suivants :
Diagramme 1 - Syntaxe dune expression
Le diagramme 1 indique quune expression est une suite de termes, séparés entre eux par les opérateurs + et -, éventuellement précédée dun signe -.
Diagramme 2 - Syntaxe dun terme
Le diagramme 2 dit quun terme est une suite de facteurs, séparés entre eux par les opérateurs * et /.
Diagramme 3 - Syntaxe dun facteur
Enfin, le diagramme 3 affirme quun facteur est soit un nombre, soit une occurrence de la variable x, soit une expression entre parenthèses, soit enfin le nom dune fonction standard suivi dune expression placée entre parenthèses.
Lanalyse lexicale sera prise en charge par la classe java.io.StreamTokenizer, fournie dans la bibliothèque standard. Un objet StreamTokenizer fait lanalyse lexicale dun flot (stream) de caractères, alors que nous aurons ici une chaîne (string) de caractères : il faudra donc transformer cette dernière en un flot, cela est le rôle dun objet StringReader.
(N.B. La bibliothèque Java offre aussi une classe java.util.StringTokenizer. Mais elle nest pas équivalente à StreamTokenizer, en fait elle ne couvre pas nos besoins.)
Lessentiel du travail à faire ici est donc lécriture dune classe Analyseur. Elle comporte un constructeur public Analyse(String texte), une méthode publique Expression analyser() qui représente lanalyse de la chaîne donnée au constructeur, et trois méthodes privées, appelées par exemple analyserExpression, analyserTerme et analyserFacteur.
Pour fixer les idées, voici un programme de test, une application Java qui analyse le texte donné en premier argument, affiche lexpression obtenue ainsi que les valeurs que cette expression prend pour des valeurs de x données comme autres arguments (vous ajouterez cette méthode à votre classe Analyseur afin de la tester) :
public static void main(String[] args) { try { Analyseur analyseur = new Analyseur(args[0]); Expression expression = analyseur.analyser(); System.out.println("f(x) = " + expression); for (int i = 1; i < args.length; i++) { double x = Double.parseDouble(args[i]); System.out.println("f(" + x + ") = " + expression.valeur(x)); } } catch (Exception exception) { exception.printStackTrace(); } }
Exécution
C:\>java Analyseur "x * x - 1" -2 -1 0 1 2 f(x) = (x)*(x)-(1.0) f(-2.0) = 3.0 f(-1.0) = 0.0 f(0.0) = -1.0 f(1.0) = 0.0 f(2.0) = 3.0
Indications. Voici le constructeur de la classe Analyseur :
private StreamTokenizer lexical; public Analyseur(String texte) throws IOException { lexical = new StreamTokenizer(new StringReader(texte)); lexical.ordinaryChar('/'); lexical.ordinaryChar('-'); }
(Par défaut, un StreamTokenizer considère que le caractère / est le début dun commentaire et que - peut faire partie dun identificateur ; cela explique les deux réglages ci-dessus).
Une fois lobjet StreamTokenizer créé, on le « fait avancer » (c.-à-d. on le positionne sur lunité suivante) en appelant sa méthode nextToken(). Pour connaître lunité courante on consulte la variable dinstance ttype.
Egalement à titre dexemple, voici le texte intégral de la méthode analyser :
public Expression analyser() throws IOException, ErreurDeSyntaxe { lexical.nextToken(); Expression resultat = analyserExpression(); if (lexical.ttype != StreamTokenizer.TT_EOF) throw new ErreurDeSyntaxe("caractère inattendu à la fin du texte"); return resultat; }
Quand on écrit un analyseur syntaxique il est important de se donner une règle précise définissant le positionnement de lanalyseur lexical. Ici, on se donne la règle suivante : juste avant et juste après lappel de chacune des méthodes analyserExpression, analyserTerme et analyserFacteur, lunité lexicale courante est la prochaine unité à examiner.
La méthode analyser illustre cela. Dune part, cest pour respecter cette règle quon fait précéder lappel de analyserExpression par un appel de nextToken. Dautre part, cest parce quon fait confiance à cette règle quau retour de analyserExpression on peut tester ttype sans quil faille le faire avancer.
Encore à titre dexemple, voici la méthode analyserTerme :
private Expression analyserTerme() throws IOException, ErreurDeSyntaxe { Expression resultat = analyserFacteur(); while (lexical.ttype == '*' || lexical.ttype == '/') { boolean estUnProduit = (lexical.ttype == '*'); lexical.nextToken(); Expression facteur = analyserFacteur(); if (estUnProduit) resultat = new Multiplication(resultat, facteur); else resultat = new Division(resultat, facteur); } return resultat; }
Il ne vous reste plus quà écrire analyserExpression et analyserFacteur, et à tester le tout le plus exhaustivement possible.
N.B. A certains endroits de lanalyseur des erreurs de syntaxe sont détectées (parenthèse manquante, identificateur non reconnu, etc.). Il faut alors lancer une exception, qui sera une instance dune classe spécialement définie à cet effet
public class ErreurDeSyntaxe extends Exception { ErreurDeSyntaxe(String message) { super(message); } }
Comme exemple dutilisation, voici un court extrait de analyserFacteur :
... else if (lexical.ttype == () { lexical.nextToken(); resultat = analyserExpression(); if (lexical.ttype != )) throw new ErreurDeSyntaxe(") attendue"); lexical.nextToken(); } ...
Lexercice consiste ici à mettre en place les éléments de linterface graphique sans nous occuper du tracé effectif de la fonction (un simple JPanel vide prendra, pour commencer, la place du panneau de tracé).
Indications. Le cadre principal est défini par une sous-classe de JFrame, avec quelques variables dinstance :
N.B. Dautres éléments sont nécessaires pour fabriquer linterface voulue. Sils ne sont pas déclarés comme variables dinstance cest que leur portée est réduite au seul constructeur de la classe.
Pour obtenir une présentation comme celle montrée ci-dessus, la partie la plus... agaçante du travail est la mise en place dun système de panneaux imbriqués, chacun avec le gestionnaire de disposition adéquat, afin davoir laspect recherché quelle que soit la taille et la forme du cadre principal. Voici une suggestion :
N.B. Le panneau dit de gauche semble inutile, puisquil ne contient quun panneau. Vous constaterez que ce panneau, qui est muni dun FlowLayout, « donne du mou » et permet au panneau de paramètres davoir une hauteur constante. Sans cela, les six champs de texte se laissent étirer et prennent une hauteur anormale (peut-être un bug dans BoxLayout?).
Un seul événement nous intéresse ici, lappui sur le bouton Tracer. Cela devra appeler une méthode nommée, par exemple, preparerLeDessin qui doit
Toutes ces opérations sont des analyses qui peuvent échouer de diverses manières (champ vide, syntaxe incorrecte, etc.). On traitera toutes ces erreurs au moyen dexceptions.
Pour commencer ne vous occupez pas de tracer la grille et les axes, mais uniquement la fonction.
Le principe du tracé dune fonction y = f(x) est simple : on se donne une constante entière nbr (par exemple, nbr = 1000) , on considère la suite des nbr + 1 valeurs xi = Xmin + i * h, avec h = (Xmax - Xmin) / nbr et les valeurs de y correspondantes yi = f(xi). Pour tracer la fonction il suffit de tracer les segments [(xi-1,yi-1), (xi,yi)].
Mais il faut regler le problème du changement de coordonnées : la fonction est exprimée en coordonnées « de lutilisateur » (xu, yu) , il faut passer en coordonnées « écran » (xe, ye) . Pour cela on transforme valeurs de x dun côté et celles de y de lautre par deux transformations affines :
xe = Ax
* xu + Bx
ye = Ay
* yu + By
Pour trouver les coefficients Ax, Bx, Ay et By il suffit de considérer la figure suivante, dans laquelle les coordonnées de lutilisateur sont bleues et les coordonnées écran rouges :
Ci-dessus, d représente la dimension du panneau de dessin (on lobtient par la méthode getSize()).
Il ne reste plus quà ajouter le tracé de la grille et des axes de coordonnées.
Pour expliquer comment tracer la grille, examinons le cas des droites verticales (les droites horizontales se traitent de la même manière, en échangeant x et y). Lécartement entre ces droites est défini par le paramètre deltaX : cela signifie quon doit tracer les droites déquations x = 0, x = deltaX, x = 2 * deltaX, etc., cest-à-dire les droites déquation x = k * deltaX où k est un entier tel que cette droite soit visible à lécran :
k = ... -2, -1, 0, 1, 2, ... et xMin <= k * deltaX <= xMax
ou, de manière équivalente
k = ... -2, -1, 0, 1, 2, ... et xMin / deltaX <= k <= xMax / deltaX
cest-à-dire quon doit rechercher les entiers k = k0, k0 + 1, ... k1 - 1, k1 avec
k0 = le plus petit entier supérieur ou égal à xMin / deltaX
k1 = le plus grand entier inférieur ou égal à xMax / deltaX
N.B. Dans beaucoup de langages, les deux entiers mentionnés ci-dessus
sobtiennent facilement avec des fonctions appelées souvent ceil
et floor.
Pour finir, il ny a pas de difficulté à trouver comment tracer les axes. Pour les dessiner plus épais que les autres droites, la classe Graphics noffre pas de moyen spécifique. On se contentera de tracer deux traits à distance dun pixel.