Conception et implémentation de logiciels (avec des exemples de B Conception et implémentation de logiciels (avec des exemples de B. Kernighan et R. Pike « La programmation en pratique ») Vladimir Makarenkov (Université du Québec à Montréal)
Introduction La conception est l’activité qui permet de rattacher les spécifications au codage et au débogage. La conception est un problème vicieux. La conception de structures de données est le point crucial dans le processus de création d’un programme. La conception sous entend l’élaboration d’une solution conceptuelle satisfaisant aux besoins plutôt que la mise en œuvre de la solution.
La conception Est un processus sans rigueur. Est une affaire de négociation et de priorité. Suppose des restrictions. N’est pas déterministe. Est un processus heuristique. Est une science nouvelle.
Caractéristiques d’une bonne conception Complexité minimale Facilité de maintenance Couplage modéré Extensibilité Réutilisabilité Haut niveau de fan-in Niveau de fan-out faible ou moyen Portabilité Dépouillement Stratification Technique standard
Les techniques de conception L’itération Diviser pour conquérir Les approches : ascendante : partir du simple vers le complexe; descendante : partir du complexe vers le simple. Le prototypage expérimental La conception collaborative
Problème Générer du texte aléatoire se lisant bien. En émettant des lettres ou des mots aléatoires, le résultat n’aura aucun sens. Pour obtenir de meilleurs résultats, il faut utiliser un modèle statistique mieux structuré : fréquence d’apparition de phrases entières; où trouver ce genre de statistiques?
Problème Prendre un gros texte et l’étudier en détail. On peut utiliser tout texte et générer à partir de ce modèle un texte aléatoire possédant des statistiques similaires à l’original. Entrée/Sortie. Algorithme de la chaîne de Markov.
Algorithme de la chaîne de Markov Entrée: une séquence de phrases qui se chevauche. L’algorithme divise chaque phrase en deux parties : Un préfixe composé de plusieurs mots. Un suffixe d’un seul mot qui suit le préfixe. Il émet des phrases en sortie en sélectionnant aléatoirement le suffixe qui suit le préfixe conformément aux statistiques.
Algorithme de la chaîne de Markov (2) Des phrases de trois mots fonctionnent bien. Un préfixe de deux mots est employé pour sélectionner le suffixe : Initialiser les variables w1 et w2 avec les 2 premiers mots. Imprimer w1 et w2 Boucle: Sélectionner aléatoirement w3, l’un des successeurs du préfixe w1w2 dans le texte. Imprimer w3. Remplacer w1 et w2 par w2 et w3. Répéter la boucle.
Algorithme de la chaîne de Markov (3) Exemple: Show your flowcharts and conceal your tables and I will be mystified. Show your tables and your flowcharts will be obvious. (end). Préfixes Suffixes qui suivent Show your your flowcharts flowcharts and flowcharts will your tables will be be mystified. be obvious. flowcharts tables and will conceal be and and mystified. obvious. Show (end)
Algorithme de la chaîne de Markov (4) L’algorithme commencera par imprimer Show your pour choisir aléatoirement soit flowcharts soit tables. S’il sélectionne tables, le mot suivant sera and. Se poursuit jusqu’à ce que suffisamment de sorties aient été générées ou que la marque (end) devienne suffixe.
Choix d’une structure de données Combien d’entrées voulons nous traiter ? A quelle vitesse le programme devrait s’exécuter ? L’algorithme doit voir toutes les entrées avant de commencer à générer la sortie. Il faut stocker la totalité des entrées sous une forme quelconque.
Choix de structure de données Première possibilité. Lire les entrées et les stocker sous forme d’une longue chaîne de caractères. Pour faciliter la décomposition des entrées en mot, nous pouvons stocker les mots sous forme de tableaux de pointeurs de mots. Problème: cela signifie qu’il faut balayer 100 000 mots en entrée pour générer chaque mot.
Choix de structure de données (2) Seconde possibilité. Stocker les mots uniques en entrée. Stocker en même temps les endroits où ces mots apparaissent dans le texte. Permet de faciliter la localisation des mots qui suivent. Possibilité d’employer une table de hachage. Il faut pouvoir rapidement localiser tous les suffixes.
Choix de structure de données (3) Besoin d’une structure de données qui représente mieux un préfixe et les suffixes associés. Le programme comportera deux passages : un passage en entrée pour construire la structure de données représentant les locutions; un passage en sortie qui se sert de la structure de données pour générer la sortie aléatoire. Lors des deux passages il faudra rechercher un préfixe rapidement : dans celui d’entrée pour mettre à jour ses suffixes; dans celui de sortie pour sélectionner aléatoirement un des suffixes possibles. Cela suggère une table de hachage dont les clés sont des préfixes et les valeurs sont les suffixes correspondants.
Choix de structure de données (4) Nous prendrons un préfixe de deux mots. Le nombre de mots du préfixe ne doit pas affecter la construction. Le programme doit pouvoir manipuler toutes les longueurs de préfixe. État : Le préfixe et l’ensemble de tous ses suffixes possibles. Que se passe-t-il si une locution apparaît deux fois « je veux lire »? Représenter cela en plaçant 2 fois « lire » dans la liste des suffixes correspondant; ou une seule fois avec un compteur associé défini à 2. La première représentation est plus facile car ajouter un suffixe ne nécessite pas de vérifier d’abord s’il y est.
Choix de structure de données (5) Il faut choisir comment représenter les mots. Stocker sous forme de chaîne de caractères individuelles. Mais cela exige de nombreuses répétitions car certains mots apparaissent plusieurs fois dans un texte. Utiliser une seconde table de hachage de mots uniques. Cela accélérerait également le hachage des préfixes. Possibilité de comparer des pointeurs au lieu de caractères individuels. Les chaînes de caractères uniques possèdent des adresses uniques.
Construction de la structure de données en C Définir des constantes enum { NPREF = 2, /* nombre de mots pour le préfixe */ NHASH = 4093, /* taille de la table de hachage état */ MAXGEN = 10000 /* maximum de mot générés */ }; Le préfixe peut être stocké sous forme d’un tableau de mots. Les éléments de la table de hachage seront représentés sous forme d’un type de données State, en associant la liste de suffixes au préfixe.
Structure de données en C typedef struct State State; typedef struct Suffix Suffix; struct State { /* préfixe + liste de suffixes */ char *pref[NPREF]; /* mots préfixes */ Suffix *suf; /* liste de suffixes */ State *next; /* le suivant dans la table de hachage */ }; struct Suffix { /* liste des suffixes */ char *word; /* suffixe */ Suffix *next; /* le suivant dans la liste des suffixes */ State *statetab[NHASH]; /* table de hachage d’états */
La structure de données
Nécessité d’une fonction de hachage /* hash: calcule la valeur de hachage pour le tableau de chaînes de caractères NPREF */ enum {MULTIPLIER = 31}; unsigned int hash (char *s[NPREF]) { unsigned int h; unsigned char *p; int i; h = 0; for (i = 0; i < NPREF; i++) for (p = (unsigned char *) s[i]; *p != ‘\0’; p++) h = MULIPLIER * h + *p; return h % NHASH; }
Nécessité d’une fonction de recherche /* lookup: recherche le préfixe; le crée si nécessaire */ /* retourne le pointeur s’il est présent ou créé; NULL sinon */ /* la création ne fait pas de strdup ainsi les chaînes ne changeront pas par la suite */ State * lookup (char *prefix[NPREF], int create) { int i, h; State *sp; h = hash(prefix); for (sp = statetab[h]; sp != NULL; sp = sp->next) { for (i = 0; i < NPREF; i++) if (strcmp(prefix[i], sp->prefix[i]) != 0) break; if (i == NPREF) /* il est trouvé */ return sp; } if ( create) { sp = (State *) malloc(sizeof(State)); sp->pref[i] = prefix[i]; sp->suf = NULL; sp->next = statetab[h]; statetab[h] = sp;
La fonction de recherche Elle n’effectue aucune copie des chaînes entrantes lorsqu’elle crée un nouvel état. Stocke des pointeurs dans sp->pref[]. Les fonctions utilisant lookup doivent garantir que les données ne seront pas écrasées.
Construction de la table de hachage /* build: lit l’entrée, construit la table des préfixes */ void build (char *prefix[NPREF], FILE *f) { char buf[100], fmt[10]; /*crée une chaîne de caractères formatée; les %s pourraient dépasser la capacité de buf */ sprintf(fmt,”%%%ds”,sizeof(buf) - 1); while (fscanf(f,fmt, buf) != EOF) add(prefix, estrdup(buf)); }
La fonction de construction L’appel particulier à sprintf contourne un problème irritant se posant avec fscanf: fscanf avec le format % lira le mot suivant délimité par une espace; donc le mot peut être très long (inhabituel). Solution: créer dynamiquement la chaîne de caractères nécessaires à sprintf. La fonction passe prefix et une copie du mot en entrée à add qui ajoute une nouvelle entrée à la table de hachage.
La fonction add /* add: ajoute un mot à la liste suffix, met à jour prefix */ void add (char *prefix[NPREF], char *suffix) { State *sp; sp = lookup(prefix,1); /* créer si pas trouvé */ addsuffix(sp, suffix); /* mettre à jour prefix */ memmove(prefix, prefix + 1, (NPREF - 1) * sizeof(prefix[0])); prefix[NPREF-1] = suffix; }
La fonction add (2) L’appel à memmove est une expression idiomatique pour effectuer des suppressions dans un tableau. Déplace la mémoire pour laisser la place pour un nouveau mot. Utilise la méthode addsuffix. /* addsuffix: ajoute un nouveau suffix à l’état courant. suffix ne doit pas changer. */ void addsuffix (State *sp, char *suffix) { Suffix *suf; suf = (Suffix *) emalloc(sizeof(Suffix)); suf->word = suffix; suf->next = sp->suf; sp->suf = suf; }
Note add: exécute la tâche générale consistant à ajouter un suffixe à un préfixe. addsuffix: traite l’action spécifique de l’implémentation consistant à ajouter un mot à une liste de suffixes. Le détail d’implémentation d’addsuffix peut changer. Donc c’est nécessaire d’en faire une fonction à part, bien qu’elle ne soit appelée qu’une seule fois.
Générer des données en sortie Avec un préfixe donné, on sélectionne l’un de ses suffixes au hasard, on l’imprime, puis on avance le préfixe. Il faut connaître la manière de démarrer et arrêter l’algorithme : démarrer avec les mots du premier préfixe; utiliser un marqueur de fin. On peut rajouter un mot dont on est sûr qu’il n’apparaîtra dans aucune entrée (NONWORD). char NONWORD[] = “\n”; /* ne peut apparaître comme un mot réel */ build(prefix,stdin); add(prefix,NONWORD);
La fonction generate /* generate: produit la sortie, un mot par ligne */ void generate(int nwords) { State *sp; Suffix *suf; char *prefix[NPREF], *w; int i, nmatch; for (i = 0; i < NPREF; i++) /* réinitialise le préfixe initial */ prefix[i] = NONWORD; for (i = 0; i < nwords; i++) { sp = lookup(prefix, 0); nmatch = 0; for (suf = sp->suf; suf != NULL; suf = suf->next) if (rand() % ++nmatch == 0) /* prob = 1/nmatch */ w = suf->word; if (strcmp(w, NONWORD) == 0) break; printf(“%s\n”,w); memmove(prefix, prefix+1, (NPREF-1) * sizeof(prefix[0])); prefix[NPREF-1] = w; }
La fonction generate (2) Produit un mot par ligne de sortie. Avec l’emploi des chaînes de caractères NONWORD initiale et finale, generate commence et se termine correctement. Permet de sélectionner un élément au hasard lorsque nous ne connaissons pas le nombre total d’éléments. La variable nmatch fait le décompte du nombre de correspondance au fur et à mesure de la lecture de la liste. Les premières valeurs de Suffix seront les premiers mots du document.
Programme principal /* markov main: génération de texte aléatoire avec une chaîne de Markov */ int main (void) { int i, nwords = MAXGEN; char *prefix[NPREF]; /* préfixe d’entrée courant */ for (i=0; i < NPREF; i++) /* définition du préfixe initial */ prefix[i] = NONWORD; build(prefix, stdin); add(prefix, NONWORD); generate(nwords); return 0; }
Implémentation en C Donne au programmeur un contrôle complet sur l’implémentation. Les programmes tendent à être rapides. Le programmeur C doit faire le gros du travail : allocation et libération de la mémoire; création des tables de hachage et des listes chaînées…
Implémentation en JAVA Java est un langage orienté objet. Il incite à faire particulièrement attention aux interfaces et composantes du programme. Il possède une bibliothèque plus riche que celle de C : un objet Vector qui fournit un tableau dynamique pour tout type d’objets; la classe Hashtable qui permet de stocker et d’extraire des valeurs d’un type particulier à l’aide d’une clé; etc. Dans notre exemple, les vecteurs de chaînes de caractères constituent le choix idéal pour gérer les préfixes et les suffixes.
Implémentation en JAVA (2) Il n’est pas nécessaire d’expliciter le type State car Hashtable relie implicitement les préfixes au suffixes. La classe Hashtable fournit : une méthode de mise en place pour stocker une paire de valeur de clé; une méthode de récupération pour extraire la valeur d’une clé. Hashtable h = new Hashtable(); h.put(key, value); Sometype v = (Sometype) h.get(key);
Implémentation en JAVA (3) Notre implémentation contient 3 classes : Prefix: pour gérer les mots du préfixe; Chain: qui lit les données en entrée, construit la table de hachage et génère la sortie; Markov: qui représente l’interface publique contenant le main.
Classe Markov (en Java) class Markov { static final int MAXGEN = 10000; //maximum de mots générés public static void main(String [] args) throws IOException { Chain chain = new Chain(); int nwords = MAXGEN; chain.build(System.in); chain.generate(nwords); } …
Classe Chain - variables class Chain { static final int NPREF = 2; // taille de préfixe static final String NONWORD = "\n"; // mot qui ne peut apparaître Hashtable statetab = new Hashtable(); // clé = Prefix, valeur = Vector suffixe Prefix prefix = new Prefix(NPREF,NONWORD); // préfixe initial Random rand = new Random(); …
Classe Chain – build (suite) // build: construit la table des State à partir de l’entrée void build(InputStream in) throws IOException { StreamTokenizer st = new StreamTokenizer(in); st.resetSyntax(); // annule les règles par défaut st.wordChars(0, Character.MAX_VALUE); // parcourir tous les caractères st.whitespace(0, ‘ ’); // exclure les caractères jusqu’à l’espace while (st.nextToken() != st.TT_EOF) add(st.sval); add(NONWORD); } …
Classe Chain – add (suite 2) // add: ajoute le mot à la liste des suffixes // Met à jour le préfixe void add(String word) { Vector suf = (Vector) statetab.get(prefix); if (suf == null) { suf = new Vector(); statetab.put(new Prefix(prefix),suf); } suf.addElement(word); prefix.pref.removeElementAt(0); prefix.pref.addElement(word);
Classe Chain – generate (fin) // generate : génère les mots en sortie void generate(int nwords) { prefix = new Prefix(NPREF, NONWORD); for (int i = 0; i < nwords; i++) { Vector v = (Vector) statetab.get(prefix); int r = Math.abs(rand.nextInt()) % v.size(); String suf = (String) v.elementAt(r); if (suf.equals(NONWORD)) break; System.out.println(suf); prefix.pref.removeElementAt(0); prefix.pref.addElement(suf); } } // fin de la classe Chain
Classe Prefix – variables et constructeurs class Prefix { public static final int MULTIPLIER = 31; // pour hashCode public Vector pref; // mots adjacents de NPREFS à partir de l’entrée // Constructeur de Prefix: duplique les préfixes existants Prefix(Prefix p) { pref = (Vector) p.pref.clone(); } // Constructeur de prefix: n copies de str Prefix(int n, String str) pref = new Vector(); for (int i = 0; i < n; i++) pref.addElement(str); …
Classe Prefix – hashCode (suite) // hashCode: génère le hachage à partir de tous les mots préfixes public int hashCode() { int h = 0; for (int i = 0; i < pref.size(); i++) h = MULTIPLIER * h + pref.elementAt(i).hashCode(); return h; }
Classe Prefix – equals (fin) // equals: compare deux préfixes pour les mots identiques. public boolean equals(Object o) { Prefix p = (Prefix) o; for (int i = 0; i < pref.size(); i++) if (!pref.elementAt(i).equals(p.pref.elementAt(i))) return false; return true; } } // fin de la classe Prefix
Implémentation Java Programme plus concis que en C. Java propose une meilleure séparation des fonctionnalités. Java fournit des outils qui facilitent l’implémentation de certaines fonctions (ex : Vector). Pour utiliser Hashtable, nous avons toujours besoin d’écrire les méthodes hashCode et equals.
Implémentation C++ Le programme écrit en C est également un programme C++. Un usage approprié de C++ consiste à définir des classes pour les objets du programme. Il est possible d’utiliser la bibliothèque standard STL (Standard Template Library). Le standard ISO du C++ inclut la STL.
Implémentation C++ - STL La STL fournit: des conteneurs tels que: des vecteurs, des listes, des ensembles; une famille d’algorithmes fondamentaux de recherche, de tri, d’insertion et de suppression. Elle fonctionne en employant les patrons (template). Les conteneurs sont exprimés sous forme de patrons C++ qui sont instanciés pour des types de données spécifiques : vector <int>, vector <string> deque <int>, deque <string> map <int, vector <string> >
Implémentation C++ - Main Les déclarations: typedef deque<string> Prefix; map<Prefix, vector<string> > statetab; // prefixe ->suffixes L’utilisation de cette structure s’avère plus pratique que celle du C ou même du Java, parce qu’il n’est pas nécessaire de fournir une fonction hash ou equals. // main : génération de texte aléatoire avec une chaîne de Markov int main(void) { int nwords = MAXGEN; Prefix prefix; // préfixe d’entrée courant for (int i = 0; i < NPREF; i++) // initialisation du préfixe add(prefix,NONWORD); build(prefix,cin); generate(nwords); return 0; }
Implémentation C++ - build et add // build: lit les mots en entrée, construit la table des états void build(Prefix& prefix,istream& in) { string buf; while (in >> buf) add(prefix, buf); } // add: ajoute un mot à la liste des suffixes, met à jour le préfixe void add(Prefix& prefix, const string& s) if (prefix.size() ==NPREF) { statetab[prefix].push_back(s); prefix.pop_front(); prefic.push_back(s); // statetab[prefix]: effectue une opération de recherche
Implémentation C++ - generate // generate: produit une sortie, un mot par ligne void generate(int nwords) { Prefix prefix; int i; for (i = 0; i < NPREF; i++) // réinitialise le préfixe initial add(prefix, NONWORD); for (i = 0; i < nwords; i++) { vector<string>& suf = statetab[prefix]; const string& w = suf[rand() % suf.size()]; if (w == NONWORD) break; cout << w << "\n"; prefix.pop_front(); // avance prefix.push_back(w); } // Cette version est plus compact que le code C mais il y a un prix à payer // au niveau de la vitesse d’exécution.
Performances Comparaison des différentes implémentations pour un texte comprenant : 42685 mots; 5238 mots distincts; 22482 préfixes; suffisamment de répétitions de phrases pour qu’une liste de suffixes possède plus de 400 mots. Le livre des psaumes de la King James Bible. Les versions C et C++ ont été compilées avec des compilateurs optimisés, tandis que l’exécution Java dispose de compilateurs de type just-in-time.
Performances (suite) Problème avec deque sous Windows. C 0,36 0,30 150 250 Mhz R100000 Mips (sec) 400 Mhz Pentium II (sec) Ligne de code source C 0,36 0,30 150 Java 4,9 9,2 105 C++/STL/deque 2,6 11,2 70 C++/STL/List 1,7 1,5 Awk 2,2 2,1 20 Perl 1,8 1,0 18 Problème avec deque sous Windows.
Performance Les langages de haut niveau donnent des programmes plus lents que ceux de bas niveau. Des bibliothèques standards comme la STL C++ ou les tableaux associatifs peuvent conduire à du code plus compact et du temps de développement plus court. Comment estimer la perte de contrôle et le volume du programme lorsque le code fourni par le système devient important ? Au fur et à mesure que les outils deviennent compliqués, ils deviennent moins compréhensibles et plus difficiles à maîtriser.
Leçons Lorsque tout fonctionne, les environnements de programmation riches en ressources (Java, C++ -STL) peuvent être productifs, mais lorsqu’ils échouent, les possibilités de recours sont faibles. Il est important de choisir des algorithmes et des structures de données simples. Il ne faut pas reconstruire la roue. Il est difficile de concevoir complètement un programme et de l’implémenter par la suite : Utiliser des processus itératifs.