Modèle objet de PHP

Licence Informatique 3ème année

Alexandre Niveau — Jean-Marc Lecarpentier

Enseignement des technologies du Web

 

Modèle objet de PHP

Notes de cours

Travail personnel

Objectifs

On continue à manipuler PHP et on aborde les aspects objets.

Exercice 1 — Analyse d’un fichier CSV #

Attention, ne perdez pas trop de temps en TP sur ce premier exercice. Si vous y avez passé plus de 45 minutes, passez au suivant (rien ne vous empêche de revenir sur le premier en-dehors du TP).

Dans cet exercice on va manipuler un fichier CSV avec des données sur les prénoms donnés en France. Ce fichier est mis à disposition sur le portail des données ouvertes du gouvernement.

  1. Télécharger le fichier des données par département de naissance (au cas où, je mets le lien du fichier sur le portail). Décompressez l'archive et placez le fichier CSV quelque part.
  2. Regarder un peu le contenu du fichier (attention, il fait 75 Mo : vous pouvez utiliser par exemple less en ligne de commande pour que ça reste fluide), et comprendre le contenu. Par exemple, la ligne 1;AARON;2005;14;4 signifie qu'en 2005, dans le Calvados, sont nés quatre bébés assignés garçons qui ont été prénommés Aaron. Voir aussi la doc sur le site.
  3. On va manipuler ces données depuis un script PHP, en convertissant le fichier CSV en un tableau de tableaux. Chaque élément du tableau correspondra à une ligne du fichier, et sera un tableau associatif avec comme clefs sexe, prenom, annee, departement, nombre.

    Créer un script prenoms.php ; si vous êtes vraiment à l'aise, vous pouvez essayer d'écrire le code pour lire le fichier et construire le tableau de données décrit au paragraphe précédent, mais ce n'est pas la priorité du TP. Pour gagner du temps vous pouvez simplement copier le code suivant :

    // ouvrir le fichier en lecture
    $fp = fopen('dpt2018.csv', 'r');
    
    // initialiser un tableau pour y mettre les données extraites du fichier
    $data = array();
    
    // ATTENTION la 1ère ligne du fichier contient les entêtes:
    // sexe;preusuel;annais;dpt;nombre
    // Il ne faut pas l'ajouter au tableau de données.
    // Pour cela, on lit 1 ligne avant de commencer la boucle.
    $line = fgets($fp);
    
    // lire ligne à ligne
    while (($line = fgets($fp)) !== false) {
        // enlever le caractère de fin de ligne
        $line = trim($line);
    
        // extraire les infos (séparateur «;»)
        // $infos est donc un tableau avec sexe, prénom, année, département, nombre
        $infos = explode(";", $line);
        // ajouter un élément au tableau $data
        // les champs sexe, annee et nombre sont convertis en int,
        // mais pas departement (par ex Corse a 2 départements 2A et 2B...)
        $data[] = [
            "sexe" => (int) $infos[0],
            "prenom" => $infos[1],
            "annee" => (int) $infos[2],
            "departement" => $infos[3],
            "nombre" => (int) $infos[4],
        ];
    }
    // fermer le fichier
    fclose($fp);
    
  4. Regarder le contenu du tableau résultant $data (avec var_export ou une autre fonction d'affichage debug) et s'assurer de bien en comprendre la structure.
  5. Utiliser ce tableau pour répondre aux questions suivantes.
    1. Combien d'enfants sont nés (en France) sur la période ?
    2. Combien d'enfants sont nés dans le Calvados (14) sur la période ?
    3. Combien d'enfants ont été appelés David sur la période ?
    4. Quel était le prénom féminin le plus fréquent dans la Manche (50) en 1978 ?
    5. Combien de prénoms différents le fichier contient-il ?
    6. Quel prénom a été le plus donné sur la période ?
    7. Optionnel: Combien d'enfants ont eu un nom commençant par un ou plusieurs « A » et se terminant par un ou plusieurs « Z » ?
    8. Optionnel: Combien y a-t-il eu de naissances à la Guadeloupe (971) sur la période ? NB: les données ne permettent de répondre qu'approximativement à cette question, car les prénoms donnés moins de 3 fois dans une année sont rassemblés dans l'année spéciale XXXX
    9. Optionnel: Quel département a la plus grande diversité de prénoms (ratio du nombre de prénoms distincts donnés sur la période et du nombre de naissances) ?

Exercice 2 — Rule 110, un automate cellulaire élémentaire #

L’énoncé de cet exercice est long, mais c’est parce qu’il est très guidé !

Dans cet exercice on va programmer l'automate cellulaire élémentaire appelé « rule 110 » (un des systèmes les plus simples qui soit Turing-universel, c'est-à-dire capable de simuler n'importe quel calcul).

Contexte

Automate cellulaire élémentaire

On peut voir un automate cellulaire élémentaire comme la simulation d'un monde très simple. Ce monde est constitué de cellules, qui n'ont que deux états possibles, blanc ou noir (ou morte/vivante, ou vide/pleine, ou fausse/vraie… comme vous voulez). Elles sont organisées sur une ligne (le monde n'a donc qu'une seule dimension). En général on considère que les deux extrémités de la ligne se rejoignent — il s'agit donc plutôt d'un cercle, mais ce n'est pas très important ici.

À chaque pas de temps (ou à chaque « génération »), les cellules évoluent : une cellule blanche peut devenir noire et inversement, et ce, en fonction de son état et de l'état de ses deux cellules voisines, suivant une règle établie au départ. Un exemple de règle : une cellule se retrouve noire si, au pas de temps précédent, parmi elle et ses deux voisines il y avait un nombre impair de cellules noires (cette règle est appelée « rule 150 »). En fonction de la règle d'évolution, l'automate se comporte de manière drastiquement différente ; certaines règles donnent des résultats très répétitifs, d'autres complètement chaotiques.

Rule 110
illustration animée du fonctionnement de la règle 110
Illustration animée du fonctionnement de la règle 110. En haut, l'état du monde ; au milieu, la règle d'évolution ; en bas, la génération suivante. Source : Wikimedia Commons, CC-BY-SA

La règle 110 n'est pas très compliquée : une cellule ne peut se retrouver blanche que dans trois cas

  • si elle et ses voisines étaient toutes trois blanches
  • si elle et ses voisines étaient toutes trois noires
  • si sa voisine de gauche était noire, mais elle-même et sa voisine de droite étaient blanches

Cette règle donne des résultats très intéressants, car à la fois réguliers et chaotiques : de fait, l'automate correspondant est capable de simuler l'exécution de n'importe quel programme (en encodant le programme comme un état initial du monde, et en lisant le résultat dans un état considéré comme final).

Implémentation

Après ces éléments de contexte, passons à l'implémentation. On va écrire un script rule-110.php.

Remarque : il est conseillé de mettre des types au paramètres et valeurs de retour des fonctions et méthodes, dans la mesure du possible. Il est également conseillé d'utiliser la vérification stricte des types. Voir cours.

Représentation du monde

On va créer une classe WorldState, qui représentera un état courant du monde. En interne, le monde sera représenté par un tableau de booléens, true représentant une cellule noire (vivante, présente) et false une cellule blanche (morte, absente). Depuis l'extérieur de la classe, on ne pourra pas accéder au tableau lui-même : on pourra seulement vérifier si une cellule à une position donnée est vivante ou non. En particulier, on ne pourra pas modifier l'état des cellules — les instances de WorldState sont dites immutables.

  1. Créer une classe WorldState dans le script.
  2. Lui déclarer une propriété $cells, de visibilité privée.
  3. Le constructeur de la classe doit prendre un paramètre $nbCells, le nombre de cellules du monde (par défaut 100), et créer dans sa propriété $cells un tableau de booléens représentant les cellules, qui doivent toutes être mortes. Remarque : vous pouvez stocker $nbCells dans une propriété de la classe si vous voulez, mais ce n'est pas obligatoire, puisque l'on a accès à la longueur du tableau.
  4. Tester : créer une petite instance de WorldState et l'afficher avec var_export ou var_dump. A-t-elle bien les propriétés attendues ?
  5. Ajouter une méthode statique buildFixedWorld($nbCells), qui va construire une instance de WorldState, rendre vivantes uniquement la deuxième et l'avant-dernière des cellules du tableau, et renvoyer cette instance. (Remarque : les méthodes de ce genre s'appellent des factory methods. On en ajoutera une autre plus tard.) Tester cette méthode de construction (de la même façon que précédemment).
  6. Ajouter une méthode isCellAliveAtPosition. Elle doit attendre un entier $position en paramètre, et renvoyer true si et seulement si la cellule à la position indiquée est vivante (c'est-à-dire noire). NB: la position correspond à l'indice dans le tableau. Si la position demandée n'existe pas, lever une exception. Tester sur des cellules censées être mortes, sur des cellules censées être vivantes, et pour des positions trop grandes ou trop petites : assurez-vous dans tous ces cas que le comportement obtenu est le bon.
  7. On veut pouvoir afficher le monde comme une chaîne de caractères, en utilisant des espaces pour les cellules blanches et un autre caractère pour les cellules noires (on pourra utiliser un #, ou le caractère unicode « FULL BLOCK, █ »). Implémenter la méthode __toString afin de remplir cet objectif, et vérifier que l'affichage d'une instance construite par buildFixedWorld est bien cohérent.
Évolution
Pour implémenter l'évolution, on commence par une version simple mais peu flexible, qu'on rendra plus générale dans la suite.
  1. Écrire une fonction compute_next_state_rule110($leftAlive, $selfAlive, $rightAlive), qui renvoie l'état d'une cellule à la prochaine génération, en fonction de son propre état (paramètre $selfAlive) et de celui de ses deux voisines ($leftAlive et $rightAlive).
  2. Ajouter une méthode computeNextGeneration() à la classe WorldState, qui renvoie une nouvelle instance de WorldState représentant le prochain état du monde, après application de la règle d'évolution (grâce à compute_next_state_rule110). Pour simplifier, on ignorera les cellules aux extrémités du tableau (on considère que le monde est une ligne, et pas un cercle).
  3. Écrire une classe Simulator, qui a comme propriété une instance de WorldState (passée à son constructeur), et comme méthode displayEvolution($nbGenerations), qui affiche $nbGenerations successives du monde.
Pour tester le programme, construire un monde et un simulateur, et lui faire afficher 50 générations. Vérifiez que ça marche ! (La règle est-elle bien respectée à vue de nez ? Est-ce que ça ressemble aux dessins sur Wikipédia ?)
Le TP étant très long, nous ne nous attendons pas à ce que cet exercice soit traité en intégralité par tou·tes les étudiant·es. Il est néanmoins vivement conseillé, dans la mesure du possible, de s'attaquer à la suite de l'exercice, qui aborde divers aspects de conception objet qui sont notamment pertinents dans le contexte du web (mais pas que). Si vous êtes assez à l'aise, vous devriez aller vite ; si vous n'êtes pas à l'aise, il est d'autant plus intéressant d'aller aussi loin que possible !

Architecture pour les règles d'évolution

La façon dont la règle 110 a été implémentée dans le programme n'est pas très satisfaisante : il faut modifier le code de WorldState si on veut utiliser une autre règle. Pour éviter ça, une possibilité pourrait être de rendre WorldState abstraite et de lui ajouter une méthode abstraite computeNextStateCell, qui serait définie dans des sous-classes WorldStateRule110, WorldStateRule150, etc.

Cette solution est cependant un peu lourde, et limite la flexibilité du code. Une meilleure idée est d'utiliser la composition : WorldState va faire appel à un objet tiers pour le calcul de l'état suivant, et cet objet tiers pourra avoir diverses implémentations (un par règle). Ainsi la classe WorldState reste indépendante du changement de règle, et elle peut être elle-même modifiée sans risque d'impacter des sous-classes.

  1. Créer une interface EvolutionRule, qui doit définir une seule méthode computeNextStateCell($leftAlive, $selfAlive, $rightAlive).
  2. Créer une classe Rule110 qui implémente l'interface, et supprimer la fonction compute_next_state_rule110.
  3. Modifier la méthode computeNextGeneration de WorldState : elle doit prendre en paramètre une instance de EvolutionRule, et utiliser sa méthode pour le calcul de la génération suivante. NB: vous devez forcer le paramètre à avoir le type EvolutionRule.
  4. L'instance de EvolutionRule va donc devoir être donnée par notre Simulator. A priori, c'est à la construction du Simulator que la règle va être choisie. Par conséquent, ajouter une propriété $evolutionRule à la classe Simulator, propriété qui doit être initialisée via un paramètre du constructeur. Modifier ensuite l'appel à computeNextState.
  5. Tester, en donnant au simulateur une instance de Rule110 : le programme doit fonctionner comme avant.

À présent, pour utiliser une autre règle, il suffit de créer une implémentation différente de EvolutionRule.

  1. Optionnel : Implémenter une autre règle, par exemple la règle 184, et tester. Voir ici des dessins d'un bon nombre d'autres règles.

Architecture pour l'affichage

L'utilisation d'une méthode __toString pour la partie « affichage » du programme est pratique, mais pas très robuste : on est dépendant de l'implémentation de __toString (on ne peut pas décider de changer les caractères, ou la taille des cellules, depuis l'extérieur de la classe), et réciproquement, on ne peut pas faire évoluer __toString n'importe comment. Cette partie vise à vous montrer ça.

Modification du __toString, et impact sur le programme
  1. On va ajouter une propriété age à WorldState, qui sera initialisée à 0 dans le constructeur, et incrémentée dans computeNextGeneration comme il se doit. Elle aura bien sûr la visibilité privée, et on lui ajoutera un accesseur (mais pas de mutateur — on veut toujours que WorldState soit immutable).
  2. Modifier le __toString pour que l'âge du monde apparaisse entre parenthèses au début de la chaîne. Relancer le programme pour voir le résultat.

Notre nouvelle méthode __toString est pratique pour faire des tests sur le programme, mais elle impacte l'affichage des simulations : ce n'est pas (forcément) ce qu'on veut.

De manière générale il est une bonne idée de séparer le modèle, ou logique métier, du programme, et l'affichage, qui peut avoir besoin de varier en fonction des contextes. Nous allons nous y employer.

Séparation modèle et vue
  1. Créer une interface Displayer avec une seule méthode, displayWorld, qui doit prendre en paramètre une instance de WorldState.
  2. Modifier Simulator pour qu'il utilise une instance de Displayer pour afficher le monde, plutôt que de faire un echo.
  3. Créer TerminalDisplayer, l'implémentation de Displayer qui correspond à l'affichage qu'on avait auparavant (sans l'âge). NB: on peut passer en paramètre du constructeur de TerminalDisplayer les caractères à utiliser pour les cellules blanches et noires ! Ce n'était pas vraiment possible de le faire quand on utilisait le __toString de WorldState sans alourdir futilement l'API de la classe.
  4. Passer une instance de TerminalDisplayer au simulateur, et tester que le programme fonctionne bien comme avant.
Contrôle plus fin de la vitesse des itérations

Notre programme affiche l'évolution du monde, mais si on veut afficher un grand nombre d'itérations, le défilement peut être très rapide. On voudrait pouvoir le gérer plus finement : soit en faisant une petite pause après chaque affichage, soit en attendant que l'utilisateurtrice appuie sur Entrée avant de passer à la génération suivante.

  1. Ajouter une méthode abstraite iterationControl() à TerminalDisplayer (qui va donc devoir être une classe abstraite). Cette méthode doit être appelée à la fin de displayWorld.
  2. Créer une sous-classe InteractiveTerminalDisplayer qui implémente iterationControl en attendant l'appui sur Entrée (rappelez-vous de la fonction readline vue au premier TP), et tester le résultat.
  3. Créer une autre sous-classe, PausingTerminalDisplayer, qui implémente iterationControl en attendant un certain nombre de microsecondes (éventuellement paramétrable) (voir la fonction usleep — qui ne marche pas forcément sous windows). Tester le résultat.
  4. Optionnel : créer une classe AnimatedTerminalDisplayer qui étend PausingTerminalDisplayer. Elle doit appeler le iterationControl de sa parente, puis afficher la chaîne suivante : "\e[2J\e[1;1H". Il s'agit de séquences d'échappement qui effacent le contenu du terminal (pour les terminaux compatibles — ça devrait toujours marcher sous Linux, sans doute aussi sous OSX et BSD, et pour Windows ça dépend des versions). Ce displayer permet donc de voir l'évolution du monde de façon animée (ça marche mieux si les itérations ne sont pas trop rapides, typiquement 3-5 FPS). Tester le résultat.
Un affichage différent
  1. Créer une classe StatsDisplayer, qui implémente Displayer en montrant non pas l'état du monde, mais des statistiques dessus : âge et nombre de cellules noires/blanches. Tester.
  2. Comment faire pour avoir l'affichage des statistiques avec un défilement lent / interactif, comme on l'a fait dans la section précédente ? Les choix d'architecture pour le contrôle de la vitesse des itérations étaient-ils bons ? Comment aurait-on pu faire ?