Cours 1

Htmligure
Version & licenses
Creative Commons License

Abstractions

Guyslain
Thursday, 21 July 2016

Retour au sommaire.

Il est temps de s'émanciper de la calculette, et d'introduire les constructions nécessaires pour écrire des programmes sophistiqués. Étonnament, il suffit de seulement deux nouvelles constructions : la création et l'application de fonctions.

Les abstractions

Le principe même de la programmation est de décrire des processus complexes, en les décomposant en processus simples. On construit donc à partir de briques élémentaires, les littéraux et les opérateurs que nous avons vu à la section précédente. Ces briques sont combinées pour former des composantes, les composantes sont elle-mêmes combinées pour former des composantes plus complexes, jusqu'à obtenir le programme désiré.

Le principal travail du programmeur consiste à identifier les composantes qu'il va devoir fournir aux étapes intermédiaires. Il lui faut déterminer leur rôle, leur responsabililité dans le programme. Cette identification nécessite bien sûr un identifiant, c'est-à-dire un nom, qui permette de se référer au composant, de l'utiliser dans d'autres composants. Le nommage d'un composant est extrêmement important :

  • il permet de rappeler le rôle du composant, il doit donc décrire succintement, mais aussi précisement, ce que fait ce composant,
  • il permet d'abstraire le fonctionnement du composant, la façon dont le composant remplit sa fonction. Une fois le composant définit, on peut oublier comment il a été construit, avec quels composants plus simples et selon quelles interactions. Ces détails sont oubliés, abstraits, ce qui permet d'éviter l'explosion de complexité dans les grands programmes.

Ce deuxième point est exactement le rôle des variables en mathématiques. Si je veux calculer la quantité de patates produites par un champ rectangulaire de largeur 20 m et de longueur 30m, et que le rendement est de 4kg.m2, peu importe la valeur précise de ces nombres, je sais que la quantité produite est décrite par les formules :

$$\textrm{aire du champ} = \textrm{largeur} \times \textrm{longueur} $$

$$\textrm{quantité de patates} = \textrm{aire du champ} \times \textrm{rendement par m$^2$}$$

Nous pouvons donc abstraire les données du problème par des variables, au sens mathématiques, c'est à dire des noms permettant de masquer et manipuler des valeurs. Listons quelques avantages de procéder ainsi :

  • si j'ai plusieurs champs de patates, je n'ai plus qu'à appliquer la formule pour chaque champ, sans avoir à reconstruire tout le raisonnement qui m'a permis d'obtenir cette formule.
  • j'ai fait apparaitre de manière lisible mon calcul. Il est ainsi facile de comprendre pourquoi ce calcul est correct.
  • si je veux calculer combien d'argent je vais toucher en vendant les patates, je peux maintenant utiliser directement l'abstraction quantité de patates avec une autre formule simple :

    $$\textrm{recette} = \textrm{quantité de patates} \times \textrm{prix au kg}$$

    ce qui m'évite de devoir écrire une formule complexe faisant intervenir la largeur de mon champ et le prix des patates dans la même expression.

  • si j'obtiens des patates par une autre méthode, je pourrais aussi utiliser ma formule pour connaître combien d'argent je vais gagner. Si mon champ n'est pas rectangulaire, il suffit que je trouve une autre formule pour décrire l'aire. J'ai donc bien isolé les diverses composantes du calcul.

Voilà pourquoi la tâche principale du programmeur est de nommer : nommer les quantités, nommer les calculs. On peut créer un nom de deux façons : soit le nouveau nom dépend d'autres expressions nommés, soit le nouveau nom ne dépend pas d'autres noms. Dans le premier cas, cela s'appelle une fonction : une fonction décrit la dépendance d'une valeur avec une ou plusieurs autres valeurs. Le deuxième cas peut être considérer comme une variante du premier cas, avec une valeur qui dépend de 0 autre valeur. Donc ce que nous avons besoin, c'est simplement de pouvoir construire des fonctions, et de leur donner un nom.

Définir une abstraction.

Étant maintenant compris qu'une variable et une abstraction sont la même chose, c'est à dire une valeur (qui peut être connue ou indeterminée) abstraite sous la forme d'un identifiant, nous employerons les deux termes indistinctement. Remarquons aussi que notre notion de variable n'est pas la même que dans les programmes impératifs : on appellera assignable les soi-disant variables des programmes impératifs. Un assignable est un conteneur d'une valeur, la valeur contenue pouvant être remplacée par une autre. Ce n'est donc pas une abstraction, puisqu'une abstraction est directement une valeur. Cela n'a pas de sens de dire qu'une valeur abstraite est modifiée; une valeur n'est pas modifiable, elle existe et c'est tout. 42 ne peut pas changer de valeur, ça ne veut rien dire.

Syntaxe

La syntaxe pour définir une abstraction est :

  1. let <identifier> = <expression>

L'identifiant est le nom (informellement) donné à l'abstraction. L'expression définit la valeur ainsi abstraite. let se lit soit en français, comme lors de l'introduction d'une variable dans une preuve en mathématiques. La phrase complète se lit "Soit tel identifiant défini comme la valeur de telle expression" .

  1. let field_width = 20. (* largeur, en m *)
  2. let field_length = 30. (* longueur, en m *)
  3. let mean_yield = 4. (* rendement, en kg/m^2 *)

  4. let field_area = field_width *. field_length

  5. let harvested_quantity = field_area *. mean_yield

  6. let price_per_kg = 1.0 (* prix, en euro/kg *)

  7. let income = harvested_quantity *. price_per_kg

L'identifiant se construit par une séquence de lettres, minuscules ou majuscules, de chiffres, et les caractères _ et ' (apostrophe). Tous les autres caractères sont interdits. La longueur de la séquence est arbitraire. La première lettre doit impérativement être une minuscule. Il est d'usage en OCaml de séparer les mots d'un identifiant par _, plutôt qu'utiliser comme en Java des majuscules pour marquer les premières lettres de chaque mot.

Bien choisir l'identifiant

Nous avons discuté de l'importance de nommer en programmation. Cela implique que les noms doivent être bien choisis : chaque identifiant doit décrire le rôle de l'expression abstraite. Il faut bien sûr établir un compromis entre la longueur de l'identifiant (que nous souhaitons court) et la qualité de sa description (que nous voulons précise). C'est souvent assez difficile de trouver des bons noms, et il est normal de passer beaucoup de temps juste pour cela lorsqu'on programme.

Lorsqu'on échoue à trouver un nom approprié, c'est généralement le symptôme d'un programme mal conçu. L'abstraction peut ne pas être la plus adaptée pour le problème. Elle peut ne pas être assez précise, ou au contraire l'être trop. Le programmeur peut ne pas être convaincu du rôle de la variable, ce qui veut dire qu'il devrait faire une pause, prendre du recul sur son travail et re-situer le contexte de ce qu'il essaie de programmer. Tant qu'il n'y a pas adéquation entre la valeur abstraite et l'identifiant donné à l'abstraction, la création de l'abstraction ne peut être considérée comme aboutie.

Une façon de bien cerner quel identifiant utiliser est d'imaginer des petites modifications de l'abstraction : si je change légèrement le rôle et la valeur de la variable, comment pourrais-je refléter ce changement dans le nom ? En quoi le nom que j'ai choisi me permet de distinguer ma variable de sa variante légèrement modifiée ? Si je ne peux pas faire cette distinction, comment le lecteur de mon programme pourra-t-il la faire ?

Le choix du nom doit tenir compte du contexte dans lequel l'abstraction va être employée. Les programmes que nous écrivons doivent être faciles à lire, ils doivent donc avoir une syntaxe et une grammaire aussi proche de l'anglais que possible (puisque le langage est en anglais, on utilisera intégralement cette langue, y compris pour les noms de variables). Ainsi :

  • les identifiants de variables (non-fonctionnelles) seront des groupes nominaux,
  • les fonctions d'actions sont des groupes verbaux commençant par un verbe conjugué (get, find, start,etc.),
  • les fonctions dont le rôle est de définir de nouvelles valeurs pourront être des groupes nominaux. En programmation fonctionnelle, il n'y a pas d'actions à proprement parler, mais on peut néanmoins raisonner pour certaines fonctions en terme d'actions effectuées (par exemple, chercher une clé dans un dictionnaire). Toutes les fonctions définissent des nouvelles valeurs, mais certaines sont moralement des actions et d'autres sont moralement des définitions. Il n'est pas évident a priori de distinguer entre les deux cas, le choix entre groupe nominal et verbal est donc plus subjectif qu'en programmation impérative (groupe verbal systématique pour les fonctions produisant un effet, un changement de l'environnement). Une règle générale est de considérer les fonctions retournant une version modifiée de leur argument comme des actions, donc d'utiliser un groupe verbal dans ce cas, alors que les fonctions retournant une valeur d'un type différent de leur argument sont plutôt des constructeurs, donc on emploie plutôt un groupe nominal dans ce cas.
  • les fonctions retournant un booléen, qu'on appelle prédicats, utiliseront un groupe verbal faisant apparaître clairement leur nature, en particulier, un if then else utilisant un prédicat en condition booléenne doit avoir une grammaire correcte (si ce n'est l'inversion sujet-groupe verbal). Par exemple, l'identifiant du prédicat peut commencer par is, has, can, etc.

En cas de doute, on se place toujours dans le contexte d'utilisation de l'abstraction, et on essaie de rendre cette utilisation la plus lisible et évidente possible. Le critère ultime est que le programme doit être facile à comprendre.

Variables locales

La création de variables tel que nous venons de la voir permet de définir des abstractions qui sont ensuite utilisables dans toute la suite du module dans lequel cette création apparait. On parlera alors de variables module-locales.

Il est possible de définir des variables localement à une expression, que nous appelerons variables expression-locales (ou simplement locale). Par exemple, lors de la définition de la recette obtenue par la vente des patates, on peut vouloir définir le prix par kilogramme uniquement pour l'expression de la recette :

  1. let income =
  2. let price_per_kg = 1.0 in
  3. harvested_quantity *. price_per_kg

Ici, l'abstraction price_per_kg est définie localement pour l'expression qui suit in, c'est-à-dire harvested_quantity * price_per_kg, comme une abstraction de la valeur $1.0$. L'abstraction rice_per_kg ne sera donc pas définie dans la suite du programme.

La syntaxe générale pour définir une variable expression-locale est

  1. let <identifier> = <abstracted value> in
  2. <expression using the abstraction>

Ceci représente une expression. On obtient sa valeur, en remplaçant dans l'expression suivant le in, tous les identifiants correspondant à cette abstraction par la valeur de l'abstraction. Dans l'exemple, income a donc la même valeur que harvested_quantity *. 1.0.

Quelle est la règle de typage associée ?

  1. Si la valeur abstraite a pour type t_abstr,
  2. et que l'expression utilisant l'abstraction a pour type t_expr lorsque l'identifiant a le type t_abstr,
  3. alors l'expression complète a le type t_expr.

Par exemple,

  1. l'expression 1.0 a le type float,
  2. si price_per_kg a le type float, l'expression harvested_quantity * price_per_kg est bien typée de type float,
  3. donc l'expression complète (incluant le let) est de type float.

Si harvested_quantity n'était pas de type float, l'expression complète ne serait pas bien typé.

Variables non-liées, masquage.

Lorsqu'on essaie d'utiliser un identifiant qui n'a pas été défini, le compilateur produit un message d'erreur et interrompt la compilation. Le message d'erreur est unbound variable ce qui veut dire variable non-liée. En effet, la création d'une variable lie un identifiant avec une valeur. Si l'identifiant n'est pas créé, il n'est pas lié à une valeur, c'est ce que rappelle le message d'erreur. On parle donc de liaison pour la création d'une paire identifiant-valeur, c'est-à-dire une abstraction.

À l'inverse, un identifiant peut avoir été défini plusieurs fois, et liés à plusieurs valeurs (qui ne sont même pas nécessairement du même type). Il est tout à fait autorisé d'utiliser plusieurs fois le même identifiant. Dans ce cas, toutes les liaisons continuent d'exister, autrement dit, différentes variables ayant le même identifiant coexistent.

Se pose alors la question : comment le compilateur décide quelle est la bonne liaison, pour chaque occurence de l'identifiant ? Certains langages permettent la surcharge : les compilateurs utilisent le type pour savoir de quelle liaison il est question. Cependant, OCaml infère les types, il ne les connait pas à l'avance et ne peut donc pas deviner grâce au type quelle est la liaison adéquate. De plus la surcharge tend à rendre les programmes plus complexes à lire, puisque trouver la définition d'un identifiant devient non-trivial. OCaml utilise donc une règle simple : définir un identifiant déjà existant masque l'ancienne liaison, tant que la nouvelle liaison existe. Une liaison existe seulement dans l'expression où elle est explicitement définie dans le cas d'une variable expression-locale, et jusqu'à la fin du module dans le cas d'une variable module-locale. C'est donc la dernière liaison valide qui compte.

Un simple exemple suffit à comprendre sans rentrer dans le formalisme nécessaire.

  1. let x (* a *) = 1

  2. let y =
  3. let x (* b *) = 2 in
  4. let x (* c *) = x (* b *) + 1 in
  5. x (* c *)

  6. let z = x (* a, because b and c were expression-local in the definition of y *)

Bien sûr, il faut éviter de donner le même nom à plusieurs variables si cela peut prêter à confusion. On peut utiliser le même nom par exemple pour les arguments de différentes fonctions, si ces arguments ont toujours le même rôle, car cela rend le programme plus cohérent, et que les arguments sont clairement propres à chaque fonction. Par contre un manque d'imagination n'est pas une excuse valide pour réutiliser le même nom plusieurs fois, rappelons une fois encore que le nommage des variables est critique et constitue une partie essentielle du travail de programmation.

Récapitulatif

  • La syntaxe pour définir une variable module-locale est :
  1. let variable_name = expression
  • à l'intérieur de la définition d'une variable module-locale, on peut définir une variable expression-locale :
  1. let variable_name = expression in
  2. expression
  • Il n'y a pas de surcharge en OCaml, si plusieurs variables de même nom sont définies, la dernière définition l'emporte.
  • les noms de variables doivent expliciter leurs rôles, choisissez-les avec attention.

Retour au sommaire.