Précédent Index Suivant

Quel style choisir ?

Il n'est pas ici question de religion ni d'esthétique, il n'y a pas a priori un style plus joli ou meilleur que l'autre. En revanche, selon le problème à résoudre un style peut être plus adéquat que l'autre.

La première règle à appliquer est celle de la simplicité. L'algorithme à réaliser (qu'il soit écrit dans un livre ou qu'il soit à l'état de germe dans l'esprit du programmeur) est lui-même décrit dans un certain style; il est naturel d'utiliser le même style pour en faire l'implantation.

Le second critère de choix est l'efficacité du programme. On peut dire qu'un programme impératif (bien écrit) est plus efficace que son homologue fonctionnel, mais dans de très nombreux cas la différence n'est pas suffisamment notable pour justifier de compliquer le code en adoptant un style impératif là où le fonctionnel serait naturel. La fonction map de la section précédente fournit un bel exemple de problème naturellement exprimé dans le style fonctionnel et qui ne gagne rien à être écrit en impératif.

Séquence ou composition de fonctions

Nous avons vu que dès qu'un programme effectue des effets de bord, il est nécessaire d'expliciter précisément l'ordre d'évaluation des éléments de ce programme. Ceci peut se faire dans les deux styles :
fonctionnel :
en utilisant le fait que Objective CAML est un langage strict, c'est-à-dire que l'argument est calculé avant l'application de la fonction. L'expression (f (g x)) est calculée en évaluant d'abord (g x), puis en passant le résultat comme argument de f. Avec des expressions plus complexes, on peut nommer le résultat intermédiaire avec la construction let in mais le principe reste le même : let aux=(g x) in (f aux).
impératif :
en utilisant la séquence ou les autres structures de contrôle (boucles). Dans ce cas, le résultat n'est pas une valeur rendue par une fonction, mais ses effets de bord sur la mémoire : aux:=(g x) ; (f !aux).
Examinons ce problème de choix de style sur un exemple. L'algorithme de tri rapide sur un vecteur se décrit récursivement comme ceci :
  1. choisir un pivot : choisir l'indice d'un élément du tableau;
  2. permuter autour du pivot : permuter les éléments du tableau de sorte que les éléments inférieurs au pivot soient situés avant le pivot et vice et versa;
  3. trier (suivant le même algorithme) les deux tableaux obtenus : les éléments précédant le pivot et ceux le suivant.
Le choix de l'algorithme, à savoir de modifier un tableau pour que ses éléments soient triés, nous incite à utiliser un style impératif au moins pour les manipulations des données.

On commence par définir une fonction permutant deux éléments d'un tableau.

# let permute_element tab n p =
let aux = tab.(n) in tab.(n) <- tab.(p) ; tab.(p) <- aux ;;
val permute_element : 'a array -> int -> int -> unit = <fun>


Le choix d'un bon pivot est déterminant pour l'efficacité de l'algorithme, mais nous retenons ici le choix le plus simple : rendre l'indice du premier élément du (sous) tableau.

# let choisir_pivot tab debut fin = debut ;;
val choisir_pivot : 'a -> 'b -> 'c -> 'b = <fun>


Écrivons l'algorithme que nous souhaitons utiliser pour permuter les éléments du tableau, autour du pivot.
  1. on positionne le pivot au début du tableau en le permutant;
  2. i est l'indice de second élément du tableau;
  3. j est l'indice du dernier élément du tableau;
  4. si l'élément d'indice j est plus grand que le pivot on le permute avec l'élément d'indice i et on incrémente i; sinon on décrémente j;
  5. tant que i est inférieur à j on recommence l'opération précédente;
  6. rendu à ce point chaque élément d'indice inférieur à i (ou j) est inférieur au pivot, les autres lui sont supérieurs; si l'élément d'indice i est inférieur au pivot on le permute avec le pivot, sinon on permute son prédécesseur.
L'implantation de cet algorithme va naturellement adopter des structures de contrôle impératives.

# let permute_pivot tab debut fin ind_pivot =
permute_element tab debut ind_pivot ;
let i = ref (debut+1) and j = ref fin and pivot = tab.(debut) in
while !i < !j do
if tab.(!j) >= pivot then decr j
else
begin
permute_element tab !i !j ;
incr i
end
done ;
if tab.(!i) > pivot then decr i ;
permute_element tab debut !i ;
!i
;;
val permute_pivot : 'a array -> int -> int -> int -> int = <fun>
Outre son effet sur le tableau, cette fonction retourne en résultat l'indice du pivot.

Il ne nous reste plus qu'à composer les différentes étapes et mettre en place la récursion sur les sous-tableaux.

# let rec quick tab debut fin =
if debut < fin
then
let pivot = choisir_pivot tab debut fin in
let place_pivot = permute_pivot tab debut fin pivot in
quick (quick tab debut (place_pivot-1)) (place_pivot+1) fin
else tab ;;
val quick : 'a array -> int -> int -> 'a array = <fun>


Nous avons ici utilisé les deux styles. Le pivot choisi sert d'argument à la permutation autour de ce pivot, et le rang du pivot obtenu après permutation est un argument de l'appel récursif. Par contre, le tableau obtenu après permutation n'est pas retourné par la fonction permute_pivot, ce résultat est rendu par effet de bord. En revanche, la fonction quick retourne un tableau et le tri des sous-tableaux est obtenu par composition des appels récursifs.

La fonction principale est :

# let quicksort tab = quick tab 0 ((Array.length tab)-1) ;;
val quicksort : 'a array -> 'a array = <fun>
C'est une fonction polymorphe car la relation d'ordre < sur les éléments du tableau est elle même polymorphe.

# let t1 = [|4;8;1;12;7;3;1;9|] ;;
val t1 : int array = [|4; 8; 1; 12; 7; 3; 1; 9|]
# quicksort t1 ;;
- : int array = [|1; 1; 3; 4; 7; 8; 9; 12|]
# t1 ;;
- : int array = [|1; 1; 3; 4; 7; 8; 9; 12|]
# let t2 = [|"le"; "petit"; "chat"; "est"; "mort"|] ;;
val t2 : string array = [|"le"; "petit"; "chat"; "est"; "mort"|]
# quicksort t2 ;;
- : string array = [|"chat"; "est"; "le"; "mort"; "petit"|]
# t2 ;;
- : string array = [|"chat"; "est"; "le"; "mort"; "petit"|]


Partage ou copie de valeurs

Tant que les valeurs que nous manipulons ne sont pas modifiables, il n'est pas utile de savoir si elles sont partagées ou non.

# let id x = x ;;
val id : 'a -> 'a = <fun>
# let a = [ 1; 2; 3 ] ;;
val a : int list = [1; 2; 3]
# let b = id a ;;
val b : int list = [1; 2; 3]
Que b soit une copie de la liste a ou que se soit la même liste ne change rien puisque ce sont de toutes façons des valeurs intangibles. Mais si à la place d'entiers nous mettons des valeurs modifiables, nous avons besoin de savoir si la modification d'une valeur entraîne la modification de l'autre.

L'implantation du polymorphisme d'Objective CAML effectue une copie des valeurs immédiates et le partage des valeurs structurées. Bien que le passage des arguments soit toujours par valeur, seul le pointeur d'une valeur structurée est copié. C'est bien le cas de la fonction id :

# let a = [| 1 ; 2 ; 3 |] ;;
val a : int array = [|1; 2; 3|]
# let b = id a ;;
val b : int array = [|1; 2; 3|]
# a.(1) <- 4 ;;
- : unit = ()
# a ;;
- : int array = [|1; 4; 3|]
# b ;;
- : int array = [|1; 4; 3|]


Nous avons ici un vrai choix de programmation pour décider quelle est la manière la plus efficace de représenter une donnée. D'un côté, l'utilisation de valeurs modifiables permet de faire des manipulations en place (c'est-à-dire sans faire d'allocation) mais oblige, dans certains cas, à faire des copies là où l'utilisation d'une valeur non modifiable aurait permis de faire un partage. Nous illustrons ceci avec deux façons d'implanter les listes.

# type 'a liste_fixe = LFnil | LFcons of 'a * 'a liste_fixe ;;
# type 'a liste_modifiable = LMnil | LMcons of 'a * 'a liste_modifiable ref ;;


Les listes fixes sont strictement équivalentes aux listes d'Objective CAML alors que les listes modifiables sont plus dans un style à la C où un maillon est une valeur plus une référence sur le maillon suivant.

Avec les listes fixes, il n'y a qu'une seule façon d'écrire la concaténation et elle impose de dupliquer la structure de la première liste; en revanche, la seconde liste peut être partagée avec le résultat.

# let rec concat l1 l2 = match l1 with
LFnil -> l2
| LFcons (a,l11) -> LFcons(a, (concat l11 l2)) ;;
val concat : 'a liste_fixe -> 'a liste_fixe -> 'a liste_fixe = <fun>

# let lf1 = LFcons(1, LFcons(2, LFnil))
and lf2 = LFcons(3, LFcons(4, LFnil)) ;;
val lf1 : int liste_fixe = LFcons (1, LFcons (2, LFnil))
val lf2 : int liste_fixe = LFcons (3, LFcons (4, LFnil))
# let lf3 = concat lf1 lf2 ;;
val lf3 : int liste_fixe =
LFcons (1, LFcons (2, LFcons (3, LFcons (4, LFnil))))
# lf1==lf3 ;;
- : bool = false
# let tlLF l = match l with
LFnil -> failwith "Liste vide"
| LFcons(_,x) -> x ;;
val tlLF : 'a liste_fixe -> 'a liste_fixe = <fun>
# tlLF(tlLF(lf3)) == lf2 ;;
- : bool = true
Grâce à ces exemples, nous voyons que les premiers maillons de lf1 et de lf3 sont distincts alors que la seconde partie de lf3 est exactement lf2.

Avec les listes modifiables, nous avons le choix entre modifier les arguments (fonction concat_share) ou créer une nouvelle valeur (fonction concat_copy)

# let rec concat_copy l1 l2 = match l1 with
LMnil -> l2
| LMcons (x,l11) -> LMcons(x, ref (concat_copy !l11 l2)) ;;
val concat_copy :
'a liste_modifiable -> 'a liste_modifiable -> 'a liste_modifiable = <fun>
Cette première solution, concat_copy, donne un résultat similaire à la fonction précédente concat. Voici une seconde solution qui partage entièrement arguments et résultat.

# let concat_share l1 l2 =
match l1 with
LMnil -> l2
| _ -> let rec set_last = function
LMnil -> failwith "concat_share : cas impossible !!"
| LMcons(_,l) -> if !l=LMnil then l:=l2 else set_last !l
in
set_last l1 ;
l1 ;;
val concat_share :
'a liste_modifiable -> 'a liste_modifiable -> 'a liste_modifiable = <fun>
La concaténation avec partage ne demande aucune allocation (on n'utilise pas le constructeur LMcons), on se contente de faire pointer le dernier maillon de la première liste vers la seconde. Mais alors, la concaténation est susceptible de modifier les arguments qui sont passés à la fonction.

# let lm1 = LMcons(1, ref (LMcons(2, ref LMnil)))
and lm2 = LMcons(3, ref (LMcons(4, ref LMnil))) ;;
val lm1 : int liste_modifiable =
LMcons (1, {contents=LMcons (2, {contents=LMnil})})
val lm2 : int liste_modifiable =
LMcons (3, {contents=LMcons (4, {contents=LMnil})})
# let lm3 = concat_share lm1 lm2 ;;
val lm3 : int liste_modifiable =
LMcons (1, {contents=LMcons (2, {contents=LMcons (...)})})
On obtient bien le résultat escompté pour lm3. Par contre la valeur liée à lm1 été modifiée.

# lm1 ;;
- : int liste_modifiable =
LMcons (1, {contents=LMcons (2, {contents=LMcons (...)})})
Cela peut donc avoir des conséquences dans le reste du programme.

Éléments de choix

Dans un programme purement fonctionnel, il est interdit d'effectuer des effets de bord, cela prohibe la manipulation de structures de données mutables, les exceptions et les entrées-sorties. Nous donnons une définition moins restrictive du style fonctionnel en disant qu'une fonction ne modifiant pas son environnement global peut être utilisée dans un style fonctionnel. Une telle fonction peut manipuler localement des valeurs mutables (et donc être écrite dans un style impératif) mais ne doit pas modifier de variables globales ni ses arguments. Nous les autorisons de surcroît à lever des exceptions. Vu de l'extérieur, ces fonctions peuvent être considérées comme des << boites noires >> dont le comportement est assimilable à celui d'une fonction purement fonctionnelle si ce n'est qu'elles sont susceptibles de rompre le contrôle par le déclenchement d'une exception. Dans le même esprit, une valeur mutable qui n'est plus modifiée après son initialisation peut être utilisée dans un style fonctionnel.

D'un autre coté, un programme écrit dans un style impératif bénéficie tout de même des avantages apportés par Objective CAML : la sûreté du typage statique, la gestion automatique de la mémoire, le mécanisme d'exception, le polymorphisme paramétrique et l'inférence de types.

Le choix entre style impératif et style fonctionnel dépend de l'application à développer. On peut néanmoins indiquer certaines orientations selon les caractéristiques de celle-ci et les critères jugés important dans son développement. Ces quelques remarques montrent qu'il est souvent judicieux de mélanger les deux styles de programmation dans une application. Le style fonctionnel est plus rapide à développer et confère une organisation plus simple de l'application; mais les sections critiques en temps d'exécution auront intérêt à être développé dans un style impératif plus efficace.


Précédent Index Suivant