Quantcast
Channel: Publicis Sapient Engineering – Engineering Done Right
Viewing all articles
Browse latest Browse all 1865

Découvrir la programmation fonctionnelle #1 | Fonctions

$
0
0

functional-programmingJava 8 vient de souffler sa première bougie. Si la programmation fonctionnelle (PF dans la suite de cet article) ne l’a pas attendue pour exister, on ne peut que constater le grand coup de projecteur donné à ce paradigme tantôt méconnu et parfois méprisé.

Néanmoins, on ne peut réduire la PF aux seuls Streams, Collections & Lambdas. Il ne s’agit pas seulement d’améliorer la lisibilité ou la performance de notre code; c’est surtout une opportunité de découvrir un nouveau paradigme et d’y migrer petit à petit depuis le monde (à priori) bien connu de l’impératif et de l’orienté-objet (POO).

Dans une série d’articles, nous allons donc vous proposer de découvrir les bases de la PF en nous attelant à vous présenter ces nouveaux concepts et patterns au travers d’exemples simples et concrets, les définitions mathématiques sous-jacentes n’étant pas toujours des plus aisées.

Commençons donc cette série avec un premier article dédié au concept central de fonction.

Avertissement au lecteur

Nous tenons à préciser dès maintenant que notre objectif n’est pas de présenter la PF « pure » telle que pratiquée et préconisée par certains. Nous souhaitons donner l’opportunité à ceux qui maîtrisent la POO et le style impératif de découvrir efficacement un nouveau paradigme tout en leur permettant de se raccrocher à ce qui leur est familier. C’est pourquoi les exemples donnés le seront au travers d’un langage au paradigme « impur », Scala, dont la popularité ne cesse de croître, que ce soit au travers de frameworks (Akka, Spark), outils (Gatling) ou de grands du Web (Netflix, LinkedIn, Twitter).

La fonction, un concept central

Une valeur à part entière

En PF, la fonction est un concept central structurant. Mais au fait, qu’est-ce qu’une fonction ?
En POO, nous connaissons bien le concept de méthode :

class Player(var score: Int = 0) {
  def incrementScore() = score += 1
}

Dans l’exemple ci-dessus, il s’agit d’une méthode d’instance. Elle y est donc nécessairement liée et ne peut être utilisée sans car elle agit sur l’un de ses membres (ici la variable score). Pour essayer de nous défaire de ce membre, nous pouvons utiliser une méthode d’une instance singleton :

object Player {
  def incrementScore(initialScore: Int) = initialScore + 1
}

Notre méthode reçoit maintenant un paramètre en entrée, travaille dessus et retourne un résultat en sortie. Elle reste néanmoins liée à une classe, ici Player.

La PF nous offre la possibilité de nous défaire de ce lien. On ne parle alors plus de méthode mais de fonction. Il s’agit alors d’une valeur comme une autre, au même titre qu’un nombre ou qu’une chaîne de caractères. On peut donc la stocker dans une variable :

val incrementMe = (initialScore: Int) => initialScore + 1

Et l’utiliser par la suite comme un appel de méthode classique :

incrementMe(score)

On parle alors d’application de fonction : on applique la fonction incrementMe à l’argument score. La fonction étant un concept à part entière, elle possède donc un type qui est dans notre cas (Int) => Int. Comprendre “une fonction qui prend en entrée une valeur de type entier et retourne en sortie une valeur de type entier”.

Fonctions d’ordre supérieur

Notre fonction stockée, nous pouvons donc l’utiliser. Mais on peut alors se poser la question de son intérêt par rapport à une méthode d’instance ou de classe ? Si nous nous bornons à l’invoquer, effectivement, c’est assez réduit. Mais ne soyons pas si défaitistes et reprenons la fonction que nous venons de voir :

val incrementMe = (initialScore: Int) => initialScore + 1

Nous précisions qu’il s’agissait d’une valeur comme une autre. Concrètement, cela nous autorise à utiliser une fonction là où, par exemple, nous utilisons actuellement un nombre. incrementMe pourrait donc prendre une fonction en paramètre, chargée de lui fournir l’entier à incrémenter :

val incrementMe = (f: () => Int) => f() + 1

f est ici une fonction sans paramètre qui retourne un entier. Elle pourrait être définie de cette manière :

val provideInitialScore = () => 0

incrementMe prend f en paramètre, l’applique et incrémente le résultat de cette application. Son type est donc (() => Int) => Int. Comprendre “une fonction qui retourne un entier à partir d’une fonction sans paramètre fournissant un entier”. L’application de incrementMe à la fonction provideInitialScore vue précédemment a alors cette forme :

incrementMe(provideInitialScore)

D’autre part, puisqu’une fonction est une valeur comme une autre, incrementMe pourrait donc en retourner une.

val incrementMe = (initialScore: Int) => (increment: Int) => initialScore + increment

La fonction retournée incrémente le paramètre fourni en entrée (initialScore) par un incrément qui sera fourni lors de l’utilisation (increment). Le type d’incrementMe est donc (Int) => (Int) => Int. Comprendre “une fonction prenant un entier en paramètre et retournant une nouvelle fonction qui prend un entier en paramètre et en retourne un autre”.

Dans l’usage, cela donne :

val incrementMeBy = incrementMe(2) // Initial score of 2
incrementMeBy(1) // Returns 3

De telles fonctions, qui reçoivent ou produisent d’autres fonctions, sont appelées « fonctions d’ordre supérieur ».

Fonctions anonymes

Les fonctions d’ordre supérieur ont donc la capacité de recevoir en paramètre ou de retourner d’autres fonctions. Les plus attentifs d’entre vous auront remarqué que dans le dernier exemple du précédent paragraphe, nous n’avons pas nommé la fonction produite :

val incrementMe = (initialScore: Int) => (increment: Int) => initialScore + increment

Une telle fonction est appelée « fonction anonyme ». Cela s’avère pratique lorsque l’on produit des fonctions courtes, très peu réutilisées, mais également lorsque l’on souhaite les passer en paramètre. Souvenez-vous de cet exemple :

val incrementMe = (f: () => Int) => f() + 1

Nous avions stocké une première fonction :

val provideInitialScore = () => 0

Puis l’avions utilisé lors de l’appel à incrementMe :

incrementMe(provideInitialScore)

Le concept de fonction anonyme nous permet de créer la fonction « à la volée » comme suit :

incrementMe(() => 0)

De l’usage des fonctions d’ordre supérieur et anonymes

Dans le paradigme fonctionnel, la fonction est un élément central et structurant. Les fonctions d’ordre supérieur reçoivent et produisent d’autres fonctions. Cela confère une réelle puissance au développeur qui peut alors partir à la chasse aux structures algorithmiques similaires afin d’en extraire des abstractions.

Dans ce cadre, une fonction passée en argument représente un comportement qui sera exécuté ultérieurement et une manière de fournir un détail d’algorithme. En conséquence, plusieurs design patterns impératifs deviennent obsolètes ou évoluent fortement comme le souligne cet excellent article de Samir Talwar : « Du nouvel usage des Design Patterns ».

D’autre part, les fonctions d’ordre supérieur sont largement utilisées dans les librairies de n’importe quel langage supportant le paradigme fonctionnel. L’exemple le plus connu étant probablement les collections que ce soit celles de Scala où celles enrichies avec Java 8. Nous aurons l’occasion d’y revenir dans la suite de cette série mais pour le moment, explorons les possibilités offertes par ce concept de fonction.

Une nouvelle étendue de possibilités

Transparence référentielle; au diable les effets de bord!

Effets de bord

On nomme effet de bord l’utilisation (en lecture ou écriture), par une fonction, de toute variable qui est en dehors de son contexte local. Ce contexte se limite aux variables définies à l’intérieur de la fonction elle-même, ainsi qu’à ses paramètres. Toute autre manipulation est interdite. Cela comprend par exemple les IO, les générateurs de nombres aléatoires, etc.

Un exemple très simple :

// Class definition...
var name: String
def setName(name: String) { this.name = name; } 

La méthode ci-dessus modifie une variable d’instance et modifie donc un contexte plus large que le sien.

Le paradigme fonctionnel préconise d’éviter les effets de bords qui peuvent être considérés comme une erreur de programmation. Ces derniers ont en effet des conséquences négatives sur la lisibilité et sur la testabilité d’un programme, particulièrement dans un contexte multi-threadé. Une fonction faisant usage d’une variable globale n’est pas déterministe ce qui la rend difficile à tester, à évaluer, et potentiellement cible de race conditions dans un environnement d’exécution multi-threadé. L’objectif est donc de réduire la portée des variables au strict nécessaire.

Fonctions pures

Il est souvent facile d’éviter les effets de bord. Prenons la méthode suivante qui permet de faire avancer un joueur de n pas :

def advance(steps: Int) = { position.x = position.x + steps; }

Cette méthode présente plusieurs inconvénients. Premièrement elle n’est pas déterministe; elle fait en effet usage d’une variable d’instance position. Deuxièmement, elle crée un effet de bord en modifiant cette même variable d’instance.

Nous pouvons la réécrire de la façon suivante :

def advance(position: Point, steps: Int): Point = { new Point(position.x + steps, position.y); }

Ici, on recréé un nouveau point à partir de la position précédente mais sans la modifier. Ainsi, cette fonction n’a pas d’effet de bord et est déterministe. Elle est dite “pure” ou encore “transparente”. L’usage de fonctions pures est une condition nécessaire et suffisante au principe de transparence référentielle qui permet de remplacer une expression par son résultat.

Ce principe peut-être illustré simplement.

var squared = Math.pow(5,2);

est équivalent à :

var squared = 25 // 25 <=> Math.pow(5,2);

Vous n’êtes certainement pas surpris par cette affirmation. Pourtant, cela implique que la fonction pow est déterministe et sans effet de bord ce qui est toujours vrai pour une fonction pure. Les fonctions pures et la transparence référentielle permettent au développeur d’évaluer et de comprendre plus simplement le code mais ce n’est pas leur unique point fort. En effet, le compilateur sera en mesure d’appliquer diverses optimisations qui ne sont pas possibles dans un contexte impératif.

Composition de fonctions

Nous l’avons vu, la programmation fonctionnelle est un paradigme mettant les fonctions (au sens mathématique) au centre de l’écriture des programmes. Il convient d’écrire des fonctions courtes, qui font une seule chose, mais qui la font bien ! C’est un peu la même chose que pour les programmes Unix (grep, find, etc.). Pour obtenir des comportements plus complexes, il faut chainer ces outils (avec des pipes). Avec les fonctions, on parlera plutôt de composition; c’est une notion mathématique bien connue, qui a d’ailleurs hanté la scolarité de beaucoup d’entre nous !

Prenons une fonction f qui incrémente un nombre donné par 2:

val f = (x: Int) => x + 2

Ainsi qu’une fonction g qui multiplie un nombre donné par 4:

val g = (x: Int) => x * 4

Ces deux fonctions possèdent le même type (Int) => Int. Si je souhaite incrémenter un nombre par 2 puis le multiplier par 4, je pourrais écrire une fonction de ce type:

val anotherFunction = (x: Int) => (x + 2) * 4

Mais nous allons plutôt composer nos deux fonctions simples afin d’en obtenir une nouvelle plus complexe. Nous allons d’abord appliquer f à notre entier x:

val res = f(x)

Puis nous allons appliquer la fonction g à ce résultat:

g(res)

Soit:

g(f(x))

Pour ce faire, nous avons besoin des deux fonctions en entrée:

val h = (f: Int => Int, g: Int => Int) => g(f(x))

Ainsi, nous pouvons en faire usage de la sorte:

h(3) // (3 + 2) * 4 = 20 <=> anotherFunction(3)

Une telle fonction de composition est notée g o f (lire g rond f ou encore g après f). Elle est bien évidemment disponible de base dans tout langage fonctionnel et voici, à titre indicatif, sa signature en Scala.

def compose[A, B, C](f: A => B, g: B => C): (A => C) = x => g(f(x))

f est une fonction qui prend en paramètre une valeur de type A et retourne une valeur de type B. g est une fonction qui prend en paramètre une valeur de type B et retourne une valeur de type C. La fonction résultante prendra donc en paramètre une valeur de type A et retournera une valeur de type C.

Dans un langage purement fonctionnel, la transparence référentielle nous assure qu’une fonction ne fera rien d’autre que d’effectuer un traitement sur les paramètre d’entrée (en d’autres termes, pas d’effets de bords), ce qui nous assure que la fonction composée n’effectuera aucun effet de bord. En Scala, les fonctions ne sont pas pures et les effets de bord permis (mais pas obligatoires!). La composition de fonction peut donc contenir des effets de bords (traitements asynchrones, écriture sur la sortie standard, …).

De l’élégance des fonctions

Nous avons vu précédemment comment déclarer des fonctions qui peuvent posséder des arguments. Ce nombre d’arguments est parfois appelé arité. La fonction incrementMe ci-après a par exemple une arité de 1.

val incrementMe = (initialScore: Int) => initialScore + 1

Plus intéressant encore, nous avons vu qu’une fonction pouvait retourner une autre fonction. Souvenez-vous, nous avions écrit ceci :

val incrementMe = (initialScore: Int) => (increment: Int) => initialScore + increment

incrementMe est une fonction qui prend un entier en argument (initialScore) et retourne une fonction, prenant elle même un entier en paramètre (increment) et sommant le tout.

Nous l’avons ensuite utilisée de la sorte :

val incrementMeBy = incrementMe(2)

incrementMeBy était ainsi une fonction permettant d’incrémenter 2 par un incrément à fournir. Par exemple :

incrementMeBy(1) // → 3

Prenons un instant pour bien observer la fonction contenue dans la variable incrementMeBy. Étions-nous obligés de la stocker ainsi ? Pouvions-nous l’utiliser directement ?

La réponse est bien évidemment positive. Voyons ce que cela donne :

incrementMe(2)(1) // → 3

La forme est originale mais ne devrait pas vous poser de problème de compréhension si vous avez suivi nos explications jusqu’ici. Nous appliquons la fonction incrementMe à l’entier 2. Le résultat est une autre fonction que nous appliquons de suite à l’entier 1.

Les plus perspicaces d’entre-vous se diront (à raison) que l’on pouvait écrire le tout avec une seule et même fonction :

def incrementMeBy(initialScore: Int, increment: Int) = initialScore + increment

Que nous aurions utilisé de la sorte :

incrementMeBy(2, 1) // → 3 encore et toujours...

Effectivement, ces deux fonctions sont équivalentes dans le sens où elles donnent le même résultat. La seule différence est donc le nombre d’arguments (ou arité).

Ce que nous venons de faire consiste à passer d’une fonction d’arité 2 à une fonction d’arité 1 retournant une autre fonction d’arité 1. Si l’on abstrait un peu le tout, on s’aperçoit que les fonctions d’ordre supérieur nous permettent de transformer une fonction d’arité N en N fonctions d’arité 1.

Ce processus s’appelle la Curryfication (Currying en anglais), d’après le célèbre mathématicien américain Haskell Brooks Curry. Il est très apprécié des programmeurs fonctionnels car il offre une certaine élégance au code. Mais il serait injuste de le résumer à cela ! Il s’avère être une aide précieuse pour les compilateurs s’adonnant à l’inférence de types. C’est notamment le cas en Scala où vous trouverez de nombreux exemples dans la documentation de la librairie (jetez donc un coup d’oeil à la méthode correspond du trait Seq).

Conclusion

Ainsi se terminent vos premiers pas dans le monde de la programmation fonctionnelle.

Dans cet article, nous avons pu voir que la fonction est un concept central. C’est un élément à part entière du langage et à ce titre, il peut être stocké, passé en paramètre et même retourné. Nous avons également aperçu que les fonctions anonymes pouvaient révolutionner certains design patterns pourtant bien ancrés dans le monde du développement.

Dans un second temps, nous avons parlé de transparence référentielle et de fonctions pures avant de terminer par les fameuses composition de fonctions et Curryfication.

Dans les prochains articles, nous traiteront de l’immuabilité et de la récursivité. En attendant, n’hésitez pas à poster vos retours afin que nous puissions améliorer la qualité de nos publications.


Viewing all articles
Browse latest Browse all 1865

Trending Articles