Master CCI
POO, langage Java
Henri Garreta 

Tracer des fonctions

        1. Structures de données
        2. Analyseur
        3. Interface graphique

Le signe © renvoie à la correction

Objectif. Notre objectif ici est l’écriture d’un programme qui trace la représentation graphique d’une fonction donnée au moment de l’exécution. L’application finale ressemblera à ceci :


Tracé d’une fonction

L’utilisateur écrit dans un champ en bas du cadre un texte qui est l’expression d’une fonction réelle d’une 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 :

1 – Structures de données ©

La fonction à tracer sera représentée dans votre programme par un arbre dont les nœuds correspondent aux divers éléments d’une expression arithmétique :

Par exemple, l’arbre représentatif de l’expression

f(x) = exp(-x * 0.2) * sin(x

est quelque chose comme ceci :

1.1 – Classes pour réaliser les nœuds

Les nœuds de l’arbre représentatif de l’expression à 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, l’indentation traduit les relations implements et extends existant entre ces classes).

L’exercice consiste à écrire toutes ces classes. Pour fixer les idées, voici le texte intégral de l’interface 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 l’interface] 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 d’une 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 l’interface 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 l’opération en question.

En revanche, il se révélera commode de pouvoir construire une opération unaire (c’est-à-dire l’appel d’une fonction prédéfinie) alors que l’argument n’est 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 qu’il 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 d’entre elles pourra alors être publique). Mais ce n’est pas une obligation...

1.2 – Programme d’essai

Afin de vérifier la correction des classes écrites à l’exercice 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

2 – Analyseur ©

L’analyseur prend en entrée un texte (c.-à-d. une chaîne de caractères) et produit en sortie l’arbre représentatif de la fonction dont ce texte est l’expression écrite. Durant ce travail, les erreurs de syntaxe dans le texte son détectées et provoquent l’affichage d’un message et l’abandon de l’analyse (une seule erreur est donc détectée chaque fois).

Le travail d’analyse se compose de deux couches distinctes :

Principe de l’analyse syntaxique

Le point de départ d’un analyseur syntaxique est la grammaire du langage visé. Dans notre cas, elle est définie par trois règles, expression, terme et facteur, qu’on peut représenter par les diagrammes suivants :


Diagramme 1 - Syntaxe d’une expression

Le diagramme 1 indique qu’une expression est une suite de termes, séparés entre eux par les opérateurs + et -, éventuellement précédée d’un signe -.


Diagramme 2 - Syntaxe d’un terme

Le diagramme 2 dit qu’un terme est une suite de facteurs, séparés entre eux par les opérateurs * et /.


Diagramme 3 - Syntaxe d’un facteur

Enfin, le diagramme 3 affirme qu’un facteur est soit un nombre, soit une occurrence de la variable x, soit une expression entre parenthèses, soit enfin le nom d’une fonction standard suivi d’une expression placée entre parenthèses.

2.1 – Ecriture de l’analyseur

L’analyse lexicale sera prise en charge par la classe java.io.StreamTokenizer, fournie dans la bibliothèque standard. Un objet StreamTokenizer fait l’analyse lexicale d’un 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 d’un objet StringReader.

(N.B. La bibliothèque Java offre aussi une classe java.util.StringTokenizer. Mais elle n’est pas équivalente à StreamTokenizer, en fait elle ne couvre pas nos besoins.)

L’essentiel du travail à faire ici est donc l’écriture d’une classe Analyseur. Elle comporte un constructeur public Analyse(String texte), une méthode publique Expression analyser() qui représente l’analyse 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 l’expression 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 d’un commentaire et que ’-’ peut faire partie d’un identificateur ; cela explique les deux réglages ci-dessus).

Une fois l’objet StreamTokenizer créé, on le « fait avancer » (c.-à-d. on le positionne sur l’unité suivante) en appelant sa méthode nextToken(). Pour connaître l’unité courante on consulte la variable d’instance ttype.

Egalement à titre d’exemple, 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 l’analyseur lexical. Ici, on se donne la règle suivante : juste avant et juste après l’appel de chacune des méthodes analyserExpression, analyserTerme et analyserFacteur, l’unité lexicale courante est la prochaine unité à examiner.

La méthode analyser illustre cela. D’une part, c’est pour respecter cette règle qu’on fait précéder l’appel de analyserExpression par un appel de nextToken. D’autre part, c’est parce qu’on fait confiance à cette règle qu’au retour de analyserExpression on peut tester ttype sans qu’il faille le faire avancer.

Encore à titre d’exemple, 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 l’analyseur 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 d’une classe spécialement définie à cet effet

public class ErreurDeSyntaxe extends Exception {
    ErreurDeSyntaxe(String message) {
        super(message);
    }
}

Comme exemple d’utilisation, voici un court extrait de analyserFacteur :

...
else if (lexical.ttype == ’(’) {
    lexical.nextToken();
    resultat = analyserExpression();
    if (lexical.ttype != ’)’)
        throw new ErreurDeSyntaxe(") attendue");
    lexical.nextToken();
} 
...

3 – Interface graphique ©

3.1 – Cadre principal

L’exercice consiste ici à mettre en place les éléments de l’interface 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 d’instance :

N.B. D’autres éléments sont nécessaires pour fabriquer l’interface voulue. S’ils ne sont pas déclarés comme variables d’instance c’est 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 d’un système de panneaux imbriqués, chacun avec le gestionnaire de disposition adéquat, afin d’avoir l’aspect recherché quelle que soit la taille et la forme du cadre principal. Voici une suggestion :

N.B. Le panneau dit de gauche semble inutile, puisqu’il ne contient qu’un panneau. Vous constaterez que ce panneau, qui est muni d’un FlowLayout, « donne du mou » et permet au panneau de paramètres d’avoir une hauteur constante. Sans cela, les six champs de texte se laissent étirer et prennent une hauteur anormale (peut-être un bug dans BoxLayout?).

3.2 – Evénements

Un seul événement nous intéresse ici, l’appui 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 d’exceptions.

3.3 – Panneau de tracé

Pour commencer ne vous occupez pas de tracer la grille et les axes, mais uniquement la fonction.

Le principe du tracé d’une 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 l’utilisateur » (xu, yu) , il faut passer en coordonnées « écran » (xe, ye) . Pour cela on transforme valeurs de x d’un côté et celles de y de l’autre 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 l’utilisateur sont bleues et les coordonnées écran rouges :

Ci-dessus, d représente la dimension du panneau de dessin (on l’obtient par la méthode getSize()).

3.4 – Panneau et axes

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 qu’on doit tracer les droites d’équations x = 0, x = deltaX, x = 2 * deltaX, etc., c’est-à-dire les droites d’équation x = k * deltaXk 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

c’est-à-dire qu’on 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 s’obtiennent facilement avec des fonctions appelées souvent ceil et floor.

Pour finir, il n’y a pas de difficulté à trouver comment tracer les axes. Pour les dessiner plus épais que les autres droites, la classe Graphics n’offre pas de moyen spécifique. On se contentera de tracer deux traits à distance d’un pixel.