Modularité : Notes de cours
LU3IN032 : Programmation comparée

Table des matières

De tout temps, l'humanité a cherché à écrire du code modulaire et réutilisable.

1 Structurer le code : modules et interfaces

Séparer le code en module permet de structurer le code. En plus de rendre le code plus facilement compréhensible et manipulable pour le programmeur, cela permet aussi de permettre la définition de bibliothèques logicielles, comme des collections naturelles de modules, et donc permet la réutilisation de sections de code.

On cherche en général à ce qu'un module remplisse un jeu de fonctionnalités

cohérentes et bien définies. Les modules disposent donc en général d'interfaces, qui peuvent être vues comme une liste de fonctionnalités.

On illustrera cette première section en fournissant en C, Java et OCaml l'interface et l'implémentation (incomplète) d'une structure très simple : un ensemble d'entiers (représenté par une liste triée en ordre croissant, sans doublon).

  • L'insertion en place (c'est-à-dire de manière à garder les données triées et à éviter les doublons)
  • La recherche (qui exploite l'ordre dans lequel les entiers sont triés)
  • L'affichage en ordre croissant

1.1 C : code multi-fichier

Le langage C ne possède pas de système de module formellement défini. Cependant, le langage permet la compilation séparée, c'est-à-dire la construction d'un programme à partir de plusieurs fichiers sources et de bibliothèques.

#ifndef _SORTED_LIST_H
#define _SORTED_LIST_H_

typedef struct _list {
  int data;
  struct _list *next;
} List;

/** Create a new empty set */
List *sorted_empty();

/** Insert an element in the set */
List *sorted_insert(List *s, int data);

/** Check if an element is in the set */
int sorted_mem(const List *s, int data);

/** Print the set in sorted order */
void sorted_print(const List *s);

/** Free the set */
void sorted_free(List *s);

#endif
#include "list.h";

List *sorted_empty() {
  return NULL;
}

List *sorted_insert(List *s, int data) { [...] }

int sorted_mem(const List *s, int data) { [...] }

void sorted_print(const List *s) { [...] }

void sorted_free(List *s) { [...] }

Tout fichier ayant besoin d'utiliser notre "module" peut #include ``list.h'' . Par exemple un programme main.c utilisant les listes peut etre compilé avec :

gcc -c -o list.o list.c
gcc -c -o main.o main.c
gcc -o main list.o main.o

1.2 Java : POO à la rescousse

Java est un langage purement orienté-objet Gosling:Java:2020. Ce paradigme offre une approche naturellement modulaire. On peut en effet voir chaque classe comme un petit module, exposant un certain nombre de fonctionnalités via ses attributs publics (qui forment son interface).

public class SortedList {
    private ArrayList<int> data;

    public SortedList() { data = new ArrayList<>(); }

    public void insert(int t) { [...] }

    public boolean mem(int t) { [...] }

    @Override
    public String toString() { [...] }
}

1.3 Le système de modules d'OCaml

Si Java présente une solution typiquement orientée-objet au problème de la modularité, le langage OCaml, en plus de disposer d'objets, propose aussi un système de modules complet LeroyEtAl:OCamlMan:2020, MinskyMadHic:RWOCaml:Modules.

La façon la plus simple de définir un module en OCaml est, comme pour le C, par fichier. On définit un fichier .mli (4) qui contient l'interface du module, ainsi qu'un fichier .mli (5) qui contient son implémentation. Seules les valeurs décrites dans l'interface pourront être vues et utilisées par un utilisateur du module. Le reste est "privé". La vérification de types d'OCaml vérifie que les valeurs données dans l'implémentation correspondent bien à celles déclarées dans l'interface. Si une interface explicite n'est pas donnée, la signature implicite du module est calculée par l'inférence de types d'OCaml.

Contrairement aux objets java, de tels modules n'encapsulent pas de données. On peut voir ces modules comme des classes ne contenant que des champs statiques.

type t

val empty : t
val insert : int -> t -> t
val mem : int -> t -> bool
val to_string : t -> string
type t = int list

let empty = []

let rec insert d l =
  match l with
  | [] -> [d]
  | hd::tl when (d < hd) -> d::hd::tl
  | hd::tl when (d = hd) -> hd::tl
  | hd::tl -> hd::(insert d tl)

let rec mem d l =
  match l with
  | [] -> false
  | hd::_ when (d = hd) -> true
  | hd::_ when (d < hd) -> false
  | _::tl -> mem d tl

let to_string l =
  String.concat " " (List.map string_of_int l)

On peut également définir plusieurs modules par fichiers, avec signatures explicites ou non.

module type Sorted = sig
  type t
  val empty : t
  val insert : int -> t -> t
  val mem : int -> t -> bool
  val to_string : t -> string
end
module SortedList : Sorted = struct
  type t = int list
  let empty = []
  let rec insert d l = [...]
  let rec mem d l = [...]
  let to_string l = [...]
end

1.4 Unités de compilation et unités logiques

Dans les exemples précédants, on a vu que les compilateurs permettaient de décomposer un programme un plusieurs unités de compilations (fichiers). gcc, ocamlc et javac permettent tous la compilation séparée :

  • .c + .h -> .o
  • .java -> .class
  • .ml + .mli -> .cmo + .cmi

C'est un aspect technique des compilateurs, qui ne fait pas partie du "langage" à proprement parler. On a vu qu'en C, cette structuration en unités de compilations est en fait la seule possible. En revanche, en Java comme en OCaml, on ajoute une structuration plus "logique", qui fait partie du langage.

De manière astucieuce, les compilateurs font correspondre unités logiques et unités de compilation. En OCaml, le fichier sortedList.ml définit un module qui peut être ouvert avec open SortedList. De même, en Java, une classe publique SortedList doit être définie dans un fichier SortedList.java (qui sera donc compilé en SortedList.class). Java va même plus loin, puisque le chemin d'un fichier (par exemple pcomp/sorted/SortedList.java) correspond exactement au package (pcomp.sorted.SortedList.java).

2 Une interface pour plusieurs implémentations ?

L'un des intérêts d'une interface est de pouvoir "cacher" certains détails d'implémentation sur lesquels l'utilisateur du module ne devrait pas s'appuyer. Cela signifie que l'utilisation d'une interface doit être totalement indépendante des choix d'implémentation. En particulier, il est tout à fait possible de donner plusieurs implémentations différentes d'une interface, fonctionnellement équivalentes (c'est-à-dire qu'elles produisent les mêmes effets du point de vue de l'utilisateur), mais pas équivalentes en terme de performances par exemple.

On va voir dans cette partie comment C, Java et OCaml nous permette de changer de manière transparente d'implémentation. On illustrera ce procédé par la définition d'une nouvelle implémentation par arbres binaires pour notre ensemble d'entiers. Celle-ci devrait en effet permettre une recherche plus rapide que l'implémentation par liste.

2.1 C : bricolages de compilation

Comme on l'a vu, la modularité en C ne peut être accomplit qu'en assemblant plusieurs fichiers, et n'est pas réellement supportée au niveau du langage. De manière prévisible, cela signifie que pour interchanger les implémentations, il faut fournir différents fichiers objets (.o) donnant les définitions d'une même interface (.h) lors de l'édition des liens.

Par exemple, si on avait deux fichiers list.c et tree.c remplissant une interface commune sorted.h, on pourrait utiliser les commandes gcc -o main list.o main.o ou gcc -o main tree.o main.o pour produire un exécutable.

Comment définir cette interface ? La difficulté est que la structure de données manipulée par list.c n'est pas la mème que celle manipulée par tree.c, ce qui signifie que les signatures fournies dans l'interface ne peuvent pas dépendre de ces structures/types. On est donc obligé de donner aux fonctions de l'interface des types génériques, en utilisant le type void * pour représenter une structure abstraite (voir 7).

/** Create a new empty set */
void *sorted_empty();

/** Insert an element in the set */
void *sorted_insert(void *s, int data);

/** Check if an element is in the set */
int sorted_mem(const void *s, int data);

/** Print the set in sorted order */
void sorted_print(const void *s);

/** Free the set */
void sorted_free(void *s);
#include "sorted.h"

typedef struct _list {
  int data;
  struct _list *next;
} List;

void *sorted_empty() { return NULL; }

void *sorted_insert(void *s, int data) {
  List *l = (List *) s;
  [...]
}

int sorted_mem(const void *s, int data) {
  List *l = (List *) s;
  [...]
}

void sorted_print(const void *s) {
  List *l = (List *) s;
  [...]
}

void sorted_free(void *s) {
  List *l = (List *) s;
  [...]
}

Les fichiers sources list.c et tree.c peuvent alors définir les structures concrètes, et implémenter les fonctions. Le problème, comme on le voit en 8, est qu'étant donnée la signature de ces fonctions, on est obligé d'utiliser des opérations de transtypage explicite au début de chaque fonction pour transformer un void * en List *. Ces opérations peuvent amener a des erreurs de mémoires, en particulier si la donnée passée par l'utilisateur n'est pas réellement une List * (ce qui est possible puisque la fonction accepte un void *). C'est en réalité la grande faiblesse de cette approche, qui est liée au système de types limité de C. On va voir plus loin comment remédier à ce problème dans nos autres langages.

2.2 Java : Interfaces (avec un grand I)

En Java, on dispose d'un mécanisme bien plus naturel pour définir des interfaces : les Interface, qui permettent de lister un ensemble de méthodes qu'une classe doit déclarer. Une classe peut implémenter plusieurs interfaces.

Pour notre exemple, on propose une interface Sorted (9). Celle-ci est implémentée par les classes SortedList (10) et SortedTree.

public interface Sorted {
    public void insert(int t);
    public void mem(int t);
}
public class SortedList implements Sorted {
    private ArrayList<int> data;

    public SortedList() { data = new ArrayList<>(); }

    @Override
    public void insert(int t) { [...] }

    @Override
    public boolean mem(int t) { [...] }

    @Override
    public String toString() { [...] }
}

Les interfaces permettent une forme de "sous-typage" en Java : deux objets issus de classe implémentant Sorted peuvent être manipulés de manière équivalente, pour peu qu'on n'en utilise que les champs apparaissant dans l'interface. Par exemple, le code suivant peut être écrit par l'utilisateur:

Sorted[] arr = new Sorted[2];
arr[0] = new SortedList();
arr[1] = new SortedTree();
arr[0].insert(42); arr[1].insert(42);
[...]

De même, l'utilisation des constructeurs SortedList() et SortedTree() peuvent eux-même être cachés à l'utilisateur (avec le DP "Factory" par exemple). On peut donc totalement abstraire les implémentations de ces modules.

2.3 OCaml

En OCaml, on a bien vu que les signatures de modules étaient séparées déclarées indépendemment de leurs implémentations. Il est donc très simple de déclarer deux modules partageant la même interface. En reprenant l'interface donnée en 6, on peut ajouter l'implémentation par arbres des ensembles comme un nouveau module:

module SortedTree : Sorted = struct
  type t = Leaf | Node of (t * int * t)
  let empty = Leaf
  let rec insert d l = [...]
  let rec mem d l = [...]
  let rec to_string l = [...]
end

Pour l'utilisateur, l'implémentation de ces deux modules est encore une fois totalement cachée par l'interface Sorted.

2.4 Une implémentation pour plusieurs interfaces

Si l'interface permet de cacher les détails d'implémentations à l'utilisateur, elle peut également être utilisées pour cacher certaines fonctionnalités.

Par exemple, considérons un objet PlayerCharacter, encapsulant les données relatives au personnage d'un jeu en ligne. Supposons que cet objet soit partagé sur le réseau, et donc accessible par le joueur le contrôlant (par le biais d'un cliant de jeu), et par le serveur de jeu. Cet objet n'offre alors pas alors la même interface au joueur et au serveur : le joueur peut contrôler les déplacements du personnage, lui faire effectuer un certain nombres d'actions, etc. Le serveur lui, peut repercuter sur le joueur les effets de son environnement : prise de dégats, etc… Les interfaces de l'objet reflète le fait que les deux parties ont des droits différents sur l'objet.

3 Modules paramétrés

Jusqu'ici, on a décrit un ensemble capables de stocker des entiers, et seulement des entiers. On aimerait pouvoir faire de même pour tout type de donnée:

  • Supportant une comparaison formant un ordre total sur les données
  • Affichable

On pourrait bien sur réécrire ce code pour chaque type de donnée qu'on voudrait supporter (entiers, flottants, chaînes de caractères, …) mais on ne pourrait alors pas vraiment dire que ce module est "ré-utilisable". On va montrer ici comment paramétrer notre module pour qu'il supporte n'importe type de données répondant à ces contraintes. On présente les solutions de Java et OCaml, puisque la solution C revient une fois de plus à utiliser des void *, ce qui est peu satisfaisant.

3.1 Génériques Java

Java présente un mécanisme de "génériques" permettant de paramétrer des classes, ou interfaces, par des types. Par exemple, en 12, on paramètre l'interface Sorted par un type T, qui peut ensuite être utilisé dans les signatures des méthodes insert et mem.

public interface Sorted<T> {
    public void insert(T t);
    public boolean mem(T t);
}

Comment alors déclarer les classes implémentant cette interface ? Comme on l'a décrit plus haut, le type T doit répondre à certaines contraintes : il doit être affichable, et comparable. En Java, toutes les classes disposent d'une méthode toString (qui peut être redéfinies), donc il suffit de s'intéresser au problème de comparaison. La bibliothèque standard de Java défini l'interface Comparable, qui expose la méthode int compareTo(T o) permettant de compararer l'objet courant (this) à un autre (o). L'interface Comparable est héritée par la plupart des classes encapsulant des types primitifs de Java (Integer, Double, String, …). Les signatures de nos classes seront donc:

public class SortedList<T extends Comparable> implements Sorted<T>
public class SortedTree<T extends Comparable> implements Sorted<T>

Comme auparavant, elles sont interchangeable du point de vue de l'utilisateur (tant qu'utilisées comme Sorted<T>, et peuvent être instanciées pour n'importe quel type héritant de Comparable.

3.2 Foncteurs OCaml

En OCaml, l'approche est un peu différente. En effet, au lieu de générique, OCaml propose types paramétrés et polymorphisme. Par exemple, le type des arbres binaires contenant des éléments de type 'a peut être défini comme:

type 'a tree = Leaf | Node of (tree * 'a * tree)

A première vue, il semble qu'on peut utiliser cette fonctionnalité pour implémenter nos structures de données génériques. Dans 13, on fourni une signature Sorted : les données stockées sont de type data, et la structure à la type t (dont l'implémentation dépendra du type data).

module type Sorted = sig
  type data
  type t

  val empty : t
  val insert : data -> t -> t
  val mem : data -> t -> bool
  val to_string : t -> string
end

Cependant, comment savoir si ce data est comparable et affichable ? La solution est de fournir de demander à l'utilisateur de fournir des fonctions compare : data -> data -> int permettant de comparer deux données, et to_string : data -> string permettant d'afficher une donnée. Comme ces fonctionnalités sont indépendantes, on va les placer chacune dans un module : voir 14 et 15. Ces deux interfaces peuvent aisément ètre instanciées pour les types de bases, en utilisant des fonctions de la bibliothèque standard OCaml.

module type Comparable = sig
  type data
  val compare : data -> data -> int
end
module type Printable = sig
  type data
  val to_string : data -> string
end

On peut maintenant écrire des foncteurs pour nos deux implémentations : un foncteur prend des modules en paramètre et construit un nouveau module, qui peut utiliser les fonctions définies dans les modules passés.

On présente en 16 le foncteur SortedList, qui prend en paramètre des modules Comparable et Printable, et retourne un Sorted. On contraint les types data des deux modules d'entrées à être égaux au type data du module construit. Par ailleurs, il faut aussi exposer dans l'interface cette égalité. Dans le cas contraire le type data du module Sorted serait abstraite, et l'utilisateur ne pourrait donc pas passer de valeur aux fonctions insert et mem.

module SortedList
    (C : Comparable)
    (P : Printable with type data:=C.data)
  : Sorted with type data = C.data = struct
  type data = C.data
  type t = data list

  let empty = []

  let rec insert d l =
    match l with
    | [] -> [d]
    | hd::tl when (C.compare d hd < 0) -> d::hd::tl
    | hd::tl when (C.compare d hd = 0) -> hd::tl
    | hd::tl -> hd::(insert d tl)

  let rec mem d l =
    match l with
    | [] -> false
    | hd::_ when (C.compare d hd = 0) -> true
    | hd::_ when (C.compare d hd < 0) -> false
    | _::tl -> mem d tl

  let to_string l =
    String.concat " " (List.map P.to_string l)
end

On présente en 17 un exemple d'utilisation de ce foncteur (et SortedTree) avec des modules répondant aux interfaces Printable et Comparable pour le type int.

module IntCompomp = struct
  type data = int
  let compare = Int.compare
end

module IntPrint = struct
  type data = int
  let to_string = string_of_int
end

module SListInt = SortedList(IntComp)(IntPrint)
module STreeInt = SortedTree(IntComp)(IntPrint)

4 Inclusion, extension, redéfinition

Une propriété intéressante d'un système de modules est de permettre à l'utilisateur d'étendre ou de modifier des modules existants. Par exemple, un utilisateur pourrait vouloir définir une nouvelle version de l'arbre binaire de recherche, muni d'une méthode permettant de ré-équilibrer l'arbre. Par ailleurs, il pourrait aussi vouloir redéfinir la méthode d'insertion, de manière à ce que l'arbre soit rééquilibré automatiquement après chaque insertion.

4.1 Java : héritage

Java propose la notion d'héritage de classes : il est possible de définir des classes "héritant" des fonctionnalités d'une classe existante. Ces classes "filles" peuvent accéder à tous les attributs marqués public ou protected de la classe "mère" héritée.

public class BalancingSortedTree<T extends Comparable> extends SortedTree<T> {
    public void rebalance() { [...] }

    @Override
    public void insert(T t) {
        super.insert(t);
        rebalance();
    }
}

En 18, on montre à quoi la classe Java étendant l'arbre binaire de recherche ressemble. On note que la méthode rebalance accède directement aux champs de "données" de l'arbre, qui doivent donc être marquées protected plutôt que private. La nouvelle fonction d'insertion utilise elle directement la fonction d'insertion de la classe mère, à laquelle elle accède via le mot clé super.

4.2 OCaml : inclusion de modules

Comme Java, OCaml permet de définir de nouveaux modules utilisant les "héritant" de modules existants. On appelle ce mécanisme "inclusion" de modules.

module BalancingSortedTree
    (C : Comparable)
    (P : Printable with type data:=C.data)
  : Sorted with type data = C.data = struct
    include SortedTree(C)(P)

    let rebalance t = [...]

    let insert d t =
      rebalance (insert d t)
end

En 19, on voit comment utiliser cette fonctionnalité pour implémenter notre extension de l'arbre binaire de recherche. Le mot clé include permet de spécifier le (ou les modules) étendus. Contrairement à Java, on peut en effet inclure plusieurs modules à la fois.

On note que dans la nouvelle méthode insert, on appelle directement insert : contrairement à Java, il n'y a pas de mot clé signifiant que la fonction doit être récupérée dans un des modules inclus. En fait, ici c'est parce qu'insert n'est pas déclarée récursive que le compilateur déduit qu'elle est définit "plus haut", c'est à dire dans le module SortedTree inclus.

On note également que dans cette implémentation, la fonction rebalance n'est pas visible à l'extérieur du module, puisque le module est contraint par la signature Sorted.

5 Bonus : Fonctionnalités avancées, tout le monde s'influence

5.1 Modules de première classe

En Java, on peut écrire:

Sorted<Integer> s;
if(b) { s = new SortedList<>(); }
else { s = new SortedTree<>(); }

Autrement dit, le résultat d'un calcul permet d'influencer le choix de l'implémentation. C'est utile, par exemple si on veut pouvoir choisir une implémentation en passant un paramètre à l'exécution du programme.

En OCaml, on ne peut pas à priori faire cela, car la déclaration et la construction de modules (par application de foncteurs) ne "vit" pas dans le même langage que les expressions classiques : OCaml est en effet stratifié en langage "noyau" d'expressions, et langage de modules. Le second contient le premier, mais pas le contraire.

Heureusement, il existe une solution en OCaml : les modules de première classe permettent de manipuler des modules comme valeurs de première classe. On peut par exemple, avec les déclarations de SListInt et STreeInt données plus haut, écrire le code suivant:

let (module S : Sorted with type data = int) =
  if b then (module SListInt)
  else (module STreeInt)

La manipulation de modules n'est donc pas plus limitée que l'instantiation de classes, heureusement.

5.2 Packages et Modules Java

Jusqu'ici, on a principalement parlé de classes en Java. Mais il se trouve que depuis sa 9eme version, Java dispose également d'un système de modules (Reinhold:JSR376:2016). Ce système est en fait très différent de la modularité qu'on a vu jusqu'ici, puisqu'il agit a beaucoup plus gros grain.

En Java les classes sont regroupées dans des packages, qui correspondent en fait à la hiérarchie des fichiers sources. Par exemple, pour les classes données plus tôt, on pourrait proposer la hiérarchie suivante:

src/
  pcomp/
    interfaces/
      Sorted.java
    implems/
      SortedTree.java
      SortedList.java
      BalancingSortedTree.java

Dans cette configuration, on ajouterait au début des fichiers Sorted.java la ligne package pcomp.sorted.interfaces; et au début de SortedList.java, SortedTree.java et BalancingSortedTree.java la ligne package pcomp.sorted.implems;.

Ce système de packages permet de qualifier les classes, et donc de gérer l'utilisation d'une classe dans une autre : les classes dans le même package ont accès "par défaut" les unes aux autres. En revanche, il faut manuellement "importer" une classe déclarée dans un autre package : par exemple, dans SortedTree.java, il faut utiliser import pcomp.interfaces.Sorted;.

Le systèmes de modules construit au dessus du système de packages : il permet de définir un module comme un ensemble de packages. Il permet de mieux structurer la distribution de modules. En particulier, il permet de définir des relations de dépendances entre modules, où un module utilise le code d'un autre. Cela permet d'éliminer à la compilation des erreurs (bibliothèque non installée, etc…) qui n'apparaissaient auparavant qu'à l'exécution.

Ci dessous, on montre comment définir un module pour notre petite bibliothèque. Celui ci exporte le contenu des packages interfaces et implems, et indique qu'il utilise le package java.utils (car on utilise la classe ArrayList).

module pcomp.sorted {
    requires java.utils;
    exports pcomp.sorted.interfaces;
    exports pcomp.sorted.implems;
}

On peut ensuite compiler l'ensemble des classes, en ajoutant le fichier module-info.java dans le chemin de compilation:

javac -d classes/sorted \
         src/pcomp.sorted/module-info.java \
         src/pcomp.sorted/pcomp/sorted/interfaces/*.java \
         src/pcomp.sorted/pcomp/sorted/implems/*.java
jar -c -M -f sorted.jar -C classes/sorted .

L'archive sorted.jar fourni doit alors être incluse avec l'option --module-path quand on compile ou exécute un autre module dépendant du module pcomp.sorted.

Références

  • [Gosling:Java:2020] James Gosling, Bill Joy, Guy Steele, Gilad Bracha, Alex Buckley, Daniel Smith & Gavin Bierman, The Java Language Specification, Oracle (2020).
  • [LeroyEtAl:OCamlMan:2020] Xavier Leroy, Damien Doligez, Alain Frisch, , Jacques Garrigue, Didier Rémy, & Jérôme Vouillon, The OCaml system: Documentation and user's manual, Inria (2020).
  • [MinskyMadHic:RWOCaml:Modules] Yaron Minsky, Anil Madhavapeddy & Jason Hickey, Real World OCaml: Functional programming for the masses - Files, Modules, and Programs, , (2020). link.
  • [Reinhold:JSR376:2016] Mark Reinhold, The State of the Module System, , (2020). link.

Auteur: Basile Pesin

Created: 2021-03-12 ven. 18:26

Validate