Précédent Remonter Suivant

Chapitre 3  Fonctions : théorie et pratique

Nous donnons dans ce chapitre un aperçu général sur l'utilisation des fonctions dans un langage de programmation classique, sans nous occuper de la problématique objet, sur laquelle nous reviendrons un peu dans le chapitre consacré aux classes.

3.1  Pourquoi écrire des fonctions

Reprenons l'exemple du chapitre précédent :
class Newton{
    public static void main(String[] args){
        double a = 2.0, x, xold, eps;

        x = a;
        eps = 1e-10;
        do{
            // recopie de la valeur ancienne
            xold = x;
            // calcul de la nouvelle valeur
            x = (xold+a/xold)/2;
            System.out.println(x);
        } while(Math.abs(x-xold) > eps);
        System.out.print("Sqrt(a)=");
        System.out.println(x);
        return;
    }
}
Nous avons écrit le programme implantant l'algorithme de Newton dans la fonction d'appel (la méthode main). Si nous avons besoin de faire tourner l'algorithme pour plusieurs valeurs de a dans le même temps, nous allons devoir recopier le programme à chaque fois. Le plus simple est donc d'écrire une fonction à part, qui ne fait que les calculs liés à Newton :
class Newton2{

    static double sqrtNewton(double a, double eps){
        double xold, x = a;

        do{
            // recopie de la valeur ancienne
            xold = x;
            // calcul de la nouvelle valeur
            x = (xold+a/xold)/2;
            System.out.println(x);
        } while(Math.abs(x-xold) > eps);
        return x;
    }

    public static void main(String[] args){
        double r;

        r = sqrtNewton(2, 1e-10);
        System.out.print("Sqrt(2)=");
        System.out.println(r);
        r = sqrtNewton(3, 1e-10);
        System.out.print("Sqrt(3)=" + r);
        System.out.println(r);
    }
}

Remarquons également que nous avons séparé le calcul proprement dit de l'affichage du résultat.

Écrire des fonctions remplit plusieurs rôles : au-delà de la possibilité de réutilisation des fonctions à différents endroits du programme, le plus important est de clarifier la structure du programme, pour le rendre lisible et compréhensible par d'autres personnes que le programmeur original.

3.2  Comment écrire des fonctions

3.2.1  Syntaxe

Une fonction prend des arguments en paramètres et donne en général un résultat. Elle se déclare par :
  public static typeRes nomFonction(type1 nom1, ..., typek nomk)
Dans cette écriture typeRes est le type du résultat.

La signature d'une fonction est constituée de la suite ordonnée des types des paramètres.

Le résultat du calcul de la fonction doit être indiqué après un return. Il est obligatoire de prévoir une telle instruction dans toutes les branches d'une fonction. L'exécution d'un return a pour effet d'interrompre le calcul de la fonction en rendant le résultat à l'appelant.

On fait appel à une fonction par
  nomFonction(var1, var2, ... , vark)
En général cet appel se situe dans une affectation.

En résumé, une syntaxe très courante est la suivante :
  public static typeRes nomFonction(type1 nom1, ..., typek nomk){
      typeRes r;

      r = ...;
      return r;
  }
...
  public static void main(String[] args){
      type1 n1;
      type2 n2;
      ...
      typek nk;
      typeRes s;

      ...
      s = nomFonction(n1, n2, ..., nk);
      ...
      return;
  }

3.2.2  Le type spécial void

Le type du résultat peut être void, dans ce cas la fonction ne rend pas de résultat. Elle opère par effet de bord, par exemple en affichant des valeurs à l'écran ou en modifiant des variables globales. Il est déconseillé d'écrire des fonctions qui procèdent par effet de bord, sauf bien entendu pour les affichages.

Un exemple typique est celui de la procédure principale :
// Voici mon premier programme
class Premier{
    public static void main(String[] args){
        System.out.println("Bonjour !");
        return;
    }
}
Notons que le return n'est pas obligatoire dans une fonction de type void, à moins qu'elle ne permette de sortir de la fonction dans un branchement. Nous la mettrons souvent pour marquer l'endroit où on sort de la fonction, et par souci d'homogénéité de l'écriture.

3.2.3  La surcharge

En Java on peut définir plusieurs fonctions qui ont le même nom à condition que leurs signatures soient différentes. On appelle surcharge cette possibilité. Le compilateur doit être à même de déterminer la fonction dont il est question à partir du type des paramètres d'appel. En Java, l'opérateur + est surchargé : non seulement il permet de faire des additions, mais il permet de concaténer des chaînes de caractères (voir la section 5.7 pour plus d'information). Par exemple, reprenant le programme de calcul de racine carrée, on aurait pu écrire:
    public static void main(String[] args){
        double r;

        r = sqrtNewton(2, 1e-10);
        System.out.println("Sqrt(2)=" + r);
    }

Nous n'encourageons pas l'utilisation de la surcharge dans ce cours, car elle peut être source d'erreurs de programmation. Le programmeur a souvent plus de mal à s'y retrouver que le compilateur...!

3.3  Visibilité des variables

Les arguments d'une fonction sont passés par valeurs, c'est à dire que leur valeurs sont recopiées lors de l'appel. Après la fin du travail de la fonction les nouvelles valeurs, qui peuvent avoir été attribuées à ces variables, ne sont plus accessibles.

Ainsi il n'est pas possible d'écrire une fonction qui échange les valeurs de deux variables passées en paramètre, sauf à procéder par des moyens détournés peu recommandés.

Reprenons l'exemple donné au premier chapitre :
// Calcul de circonférence
public class Cercle{
    static float pi = (float) Math.PI;

    public static float circonference (float r) {
        return 2. * pi * r;
    }

    public static void main (String[] args){
        float c = circonference (1.5);

        System.out.print("Circonférence:  ");
        System.out.println(c);
        return;
    }
}

La variable r présente dans la définition de circonference est instanciée au moment de l'appel de la fonction par la fonction main. Tout se passe comme si le programme réalisait l'affectation r = 1.5 au moment d'entrer dans f.

Dans l'exemple précédent, la variable pi est une variable de classe, ce qui veut dire qu'elle est connue par toutes les fonctions présentes dans la classe, ce qui explique qu'on peut l'utiliser dans la fonction circonference.

Pour des raisons de propreté des programmes, on ne souhaite pas qu'il existe beaucoup de ces variables de classe. L'idéal est que chaque fonction travaille sur ses propres variables, indépendamment des autres fonctions de la classe, autant que cela soit possible. Regardons ce qui se passe quand on écrit :
class Essai{
    static int f(int n){
        int m = n+1;

        return 2*m;
    }

    public static void main(String[] args){
        System.out.print("résultat=");
        System.out.println(f(4));
        return;
    }
}

La variable m n'est connue (on dit vue) que par la fonction f. En particulier, on ne peut l'utiliser dans la fonction main ou toute autre fonction qui serait dans la classe.

Compliquons encore :
class Essai{
    static int f(int n){
        int m = n+1;

        return 2*m;
    }

    public static void main(String[] args){
        int m = 3;

        System.out.print("résultat=");
        System.out.print(f(4));
        System.out.print(" m=");
        System.out.println(m);
        return;
    }
}
Qu'est-ce qui s'affiche à l'écran ? On a le choix entre :
résultat=10 m=5
ou
résultat=10 m=3
D'après ce qu'on vient de dire, la variable m de la fonction f n'est connue que de f, donc pas de main et c'est la seconde réponse qui est correcte. On peut imaginer que la variable m de f a comme nom réel m-de-la-fonction-f, alors que l'autre a pour nom m-de-la-fonction-main. Le compilateur et le programme ne peuvent donc pas faire de confusion.

3.4  Quelques conseils pour écrire un programme

Un beau programme est difficile à décrire, à peu près aussi difficile à caractériser qu'un beau tableau. Il existe quand même quelques règles simples. Le premier lecteur d'un programme est soi-même. Si je n'arrive pas à me relire, il est difficile de croire que quelqu'un d'autre le pourra. On peut être amené à écrire un programme, le laisser dormir pendant quelques mois, puis avoir à le réutiliser. Si le programme est bien écrit, il sera facile à relire.

Grosso modo, la démarche d'écriture de petits ou gros programmes est à peu près la même, à un facteur d'échelle près. On découpe en tranches indépendantes le problème à résoudre, ce qui conduit à isoler des fonctions à écrire. Une fois cette architecture mise en place, il n'y a plus qu'à programmer chacune de celle-ci. Même après un découpage a priori du programme en fonctions, il arrive qu'on soit amenés à écrire d'autres fonctions. Quand le décide-t-on ? Une règle simple est qu'un morceau de code ne doit jamais dépasser une page d'écran. Si cela arrive, on doit couper en deux ou plus. La clarté y gagnera.

La fonction main d'un programme Java doit ressembler à une sorte de table des matières de ce qui va suivre. Elle doit se contenter d'appeler les principales fonctions du programme. A priori, elle ne doit pas faire de calculs elle-même.

Les noms de fonction ne doivent pas se résumer à une lettre. Il est tentant pour un programmeur de succomber à la facilité et d'imaginer pouvoir programmer toutes les fonctions du monde en réutilisant sans cesse les mêmes noms de variables, de préférence avec un seul caractère par variable. Faire cela conduit rapidement à écrire du code non lisible, à commencer par soi. Ce style de programmation est donc proscrit. Les noms doivent être pertinents. Nous aurions pu écrire le programme concernant les cercles de la façon suivante :
public class D{
    static float z = (float)Math.PI;

    public static float e(float s){
        return 2. * z * s;
    }

    public static void main(String[] args){
        float y = e(1.5);

        System.out.println(y);
        return;
    }
}

ce qui aurait rendu la chose un peu plus difficile à lire.

Un programme doit être aéré : on écrit une instruction par ligne, on ne mégotte pas sur les lignes blanches. De la même façon, on doit commenter ses programmes. Il ne sert à rien de mettre des commentaires triviaux à toutes les lignes, mais tous les points difficiles du programme doivent avoir en regard quelques commentaires. Un bon début consiste à placer au-dessus de chaque fonction que l'on écrit quelques lignes décrivant le travail de la fonction, les paramètres d'appel, etc. Que dire de plus sur le sujet? Le plus important pour un programmeur est d'adopter rapidement un style de programmation (nombre d'espaces, placement des accolades, etc.) et de s'y tenir.

Finissons avec un programme horrible, qui est le contre-exemple typique à ce qui précède :
class mysterepublic static void main(String[] args)
int
z=
Integer.parseInt(args[0]);doif((z%2)==0)
z
/=2;
else z=3*z+1;while(z>1);

3.5  Quelques exemples de programmes complets

3.5.1  Écriture binaire d'un entier

Tout entier n > 0 peut s'écrire en base 2 sous la forme :
n = nt 2t + nt-1 2t-1 + ⋯ + n0 =
t
i=0
ni 2i
avec ni valant 0 ou 1, et par convention nt = 1. Le nombre de bits pour écrire n est t+1.

À partir de n, on peut retrouver ses chiffres en base 2 par division successive par 2 : n0 = n mod2, n1 = (n ÷ 2) mod2 (÷ désigne le quotient de n par 2) et ainsi de suite. En Java, le quotient se calcule à l'aide de / et le reste avec %. Une fonction affichant à l'écran les chiffres n0, n1, etc. est :
    // ENTRÉE: un entier strictement positif n
    // SORTIE: aucune
    // ACTION: affichage des chiffres binaires de n
    static void binaire(int n){
        while(n > 0){
            System.out.print(n%2);
            n = n/2;
        }
        return;
    }

Nous avons profité de cet exemple simple pour montrer jusqu'à quel point les commentaires peuvent être utilisés. Le rêve est que les indications suffisent à comprendre ce que fait la fonction, sans avoir besoin de lire le corps de la fonction. En procédant ainsi pour toute fonction d'un gros programme, on dispose gratuitement d'un embryon de la documentation qui doit l'accompagner. Notons qu'en Java, il existe un outil javadoc qui permet de faire encore mieux, en fabriquant une page web de documentation pour un programme, en allant chercher des commentaires spéciaux dans le code.

3.5.2  Calcul du jour correspondant à une date

Nous terminons ce chapitre par un exemple plus ambitieux. On se donne une date sous forme jour mois année et on souhaite déterminer quel jour de la semaine correspondait à cette date.

Face à n'importe quel problème, il faut établir une sorte de cahier des charges, qu'on appelle spécification du programme. Ici, on rentre la date en chiffres sous la forme agréable jj mm aaaa et on veut en réponse le nom du jour écrit en toutes lettres.

Nous allons d'abord donner la preuve de la formule due au Révérend Zeller et qui résout notre problème.
Théorème 1   Le jour J (un entier entre 0 et 6 avec dimanche codé par 0, etc.) correspondant à la date j/m/a est donné par :
J = (j+⌊ 2.6 m' - 0.2⌋+e+⌊ e/4⌋ + ⌊ s/4⌋ - 2 s) mod7




et a' = 100 s + e, 0≤ e < 100.

Commençons d'abord par rappeler les propriétés du calendrier grégorien, qui a été mis en place en 1582 par le pape Grégoire XIII : l'année est de 365 jours, sauf quand elle est bissextile, i.e., divisible par 4, sauf les années séculaires (divisibles par 100), qui ne sont bissextiles que si divisibles par 400.

Si j et m sont fixés, et comme 365 = 7× 52+1, la quantité J avance d'1 d'année en année, sauf quand la nouvelle année est bissextile, auquel cas, J progresse de 2. Il faut donc déterminer le nombre d'années bissextiles inférieures à a.

Détermination du nombre d'années bissextiles


Lemme 1   Le nombre d'entiers de [1, N] qui sont divisibles par k est ρ(N, k) = ⌊ N/k⌋.

Démonstration : les entiers m de l'intervalle [1, N] divisibles par k sont de la forme m = k r avec 1 ≤ k rN et donc 1/krN/k. Comme r doit être entier, on a en fait 1 ≤ r ≤ ⌊ N/k⌋.
Proposition 1   Le nombre d'années bissextiles dans ]1600, A] est
T(A) = ρ(A-1600, 4)-ρ(A-1600, 100)+ρ(A-1600, 400)
  = A/4⌋-⌊ A/100⌋ + ⌊ A/400⌋ - 388.

Démonstration : on applique la définition des années bissextiles : toutes les années bissextiles sont divisibles par 4, sauf celles divisibles par 100 à moins qu'elles ne soient multiples de 400.

Pour simplifier, on écrit A = 100 s + e avec 0≤ e<100, ce qui donne :
T(A) = ⌊ e/4⌋-s + ⌊ s/4⌋ + 25 s - 388.

Comme le mois de février a un nombre de jours variable, on décale l'année : on suppose qu'elle va de mars à février. On passe de l'année (m, a) à l'année-Zeller (m', a') comme indiqué ci-dessus.

Détermination du jour du 1er mars

Ce jour est le premier jour de l'année Zeller. Posons µ(x) = x mod7. Supposons que le 1er mars 1600 soit n, alors il est µ(n+1) en 1601, µ(n+2) en 1602, µ(n+3) en 1603 et µ(n+5) en 1604. De proche en proche, le 1er mars de l'année a' est donc :
M(a') = µ(n+(a'-1600)+T(a')).

Maintenant, on détermine n à rebours en utilisant le fait que le 1er mars 2002 était un vendredi. On trouve n = 3.

Le premier jour des autres mois

On peut précalculer le décalage entre le jour du mois de mars et ses suivants :
1er avril 1er mars+3
1er mai 1er avril+2
1er juin 1er mai+3
1er juillet 1er juin+2
1er août 1er juillet+3
1er septembre 1er août+3
1er octobre 1er septembre+2
1er novembre 1er octobre+3
1er décembre 1er novembre+2
1er janvier 1er décembre+3
1er février 1er janvier+3

Ainsi, si le 1er mars d'une année est un vendredi, alors le 1er avril est un lundi, et ainsi de suite.

On peut résumer ce tableau par la formule ⌊ 2.6 m' - 0.2⌋-2, d'où :
Proposition 2   Le 1er du mois m' est :
µ(1+⌊ 2.6 m' - 0.2⌋+e+⌊ e/4⌋ + ⌊ s/4⌋ - 2 s)
et le résultat final en découle.

Le programme

Le programme va implanter la formule de Zeller. Il prend en entrée les trois entiers j, m, a séparés par des espaces, va calculer J et afficher le résultat sous une forme agréable compréhensible par l'humain qui regarde, quand bien même les calculs réalisés en interne sont plus difficiles à suivre. Le programme principal est simplement :
  public static void main(String[] args){
      int j, m, a, J;

      j = Integer.parseInt(args[0]);
      m = Integer.parseInt(args[1]);
      a = Integer.parseInt(args[2]);

      J = Zeller(j, m, a);
      // affichage de la réponse
      System.out.print("Le "+j+"/"+m+"/"+a);
      System.out.println(" est un " + chaineDeJ(J));
      return;
  }
Noter l'emploi de + pour la concaténation.

Remarquons que nous n'avons pas mélangé le calcul lui-même de l'affichage de la réponse. Très généralement, les entrées-sorties d'un programme doivent rester isolées du corps du calcul lui-même.

La fonction chaineDeJ a pour seule ambition de traduire un chiffre qui est le résultat d'un calcul interne en chaîne compréhensible par l'opérateur humain :
  // ENTRÉE: J est un entier entre 0 et 6
  // SORTIE: chaîne de caractères correspondant à J
  static String chaineDeJ(int J){
      switch(J){
      case 0:
          return "dimanche";
      case 1:
          return "lundi";
      case 2:
          return "mardi";
      case 3:
          return "mercredi";
      case 4:
          return "jeudi";
      case 5:
          return "vendredi";
      case 6:
          return "samedi";
      default:
          return "?? " + J;
      }
  }

Reste le coeur du calcul :
  // ENTRÉE: 1 <= j <= 31, 1 <= m <= 12, 1584 < a
  // SORTIE: entier J tel que 0 <= J <= 6, avec 0 pour
  //         dimanche, 1 pour lundi, etc.
  // ACTION: J est le jour de la semaine correspondant à
  //         la date donnée sous la forme j/m/a
  static int Zeller(int j, int m, int a){
      int mz, az, e, s, J;

      // calcul des mois/années Zeller
      mz = m-2;
      az = a;
      if(mz <= 0){
          mz += 12;
          az--;
      }
      // az = 100*s+e, 0 <= e < 100
      s = az / 100;
      e = az % 100;
      // la formule du révérend Zeller
      J = j + (int)Math.floor(2.6*mz-0.2);
      J += e + (e/4) + (s/4) - 2*s;
      // attention aux nombres négatifs
      if(J >= 0)
          J %= 7;
      else{
          J = (-J) % 7;
          if(J > 0)
              J = 7-J;
      }
      return J;
  }

Précédent Remonter Suivant