Introduction aux concepts de la programmation fonctionnelle en F#

La programmation fonctionnelle est un style de programmation qui met l’accent sur l’utilisation de fonctions et de données immuables. On parle de programmation fonctionnelle typée quand la programmation fonctionnelle est combinée avec des types statiques, par exemple avec F#. En général, les concepts suivants sont mis en avant dans la programmation fonctionnelle :

  • Utilisation de fonctions comme constructions principales
  • Expressions à la place d’instructions
  • Valeurs immuables au lieu de variables
  • Programmation déclarative plutôt que programmation impérative

Tout au long de cette série, vous allez explorer les concepts et les modèles de la programmation fonctionnelle en F#. En cours de route, vous découvrirez également quelques-uns des éléments de F#.

Terminologie

La programmation fonctionnelle, comme d’autres paradigmes de programmation, s’accompagne d’une terminologie que vous devrez éventuellement apprendre. Voici quelques termes courants que vous rencontrerez souvent :

  • Fonction : une fonction est une construction qui produit une sortie quand une entrée lui est transmise. Plus formellement, elle mappe un élément d’un ensemble à un autre. Ce formalisme est élevé au niveau concret de plusieurs façons, en particulier lors de l’utilisation de fonctions qui opèrent sur des collections de données. Il s’agit du concept le plus simple (et le plus important) de la programmation fonctionnelle.
  • Expression : une expression est une construction dans le code qui produit une valeur. En F#, cette valeur doit être liée ou explicitement ignorée. Une expression peut être remplacée de manière triviale par un appel de fonction.
  • Pureté : la pureté est une propriété d’une fonction. Elle garantit que la valeur de retour d’une fonction est toujours la même pour les mêmes arguments et que son évaluation n’a aucun effet secondaire. Une fonction pure dépend entièrement de ses arguments.
  • Transparence référentielle : la transparence référentielle est une propriété d’une expression. Elle garantit que le remplacement d’une expression par sa sortie n’affecte pas le comportement d’un programme.
  • Immuabilité : l’immuabilité signifie qu’une valeur ne peut pas être modifiée sur place. Ceci contraste avec les variables qui peuvent être modifiées sur place.

Exemples

Les exemples suivants illustrent ces concepts de base.

Fonctions

La construction la plus fondamentale et la plus courante en programmation fonctionnelle est la fonction. Voici une fonction simple qui ajoute 1 à un entier :

let addOne x = x + 1

Sa signature de type est la suivante :

val addOne: x:int -> int

La signature peut être lue comme ceci : « addOne accepte un int nommé x et produit un int ». Plus formellement, addOnemappe une valeur de l’ensemble d’entiers à l’ensemble d’entiers. Le jeton -> signifie ce mappage. En F#, vous pouvez généralement examiner la signature de fonction pour avoir une idée de ce qu’elle fait.

Pourquoi la signature est-elle importante ? En programmation fonctionnelle typée, l’implémentation d’une fonction est souvent moins importante que la signature de type proprement dite. Le fait que la fonction addOne ajoute la valeur 1 à un entier est intéressant au moment de l’exécution, mais quand vous construisez un programme, c’est le fait qu’elle accepte et retourne un int qui détermine comment vous allez réellement l’utiliser. Par ailleurs, une fois que vous utilisez correctement cette fonction (eu égard à sa signature de type), le diagnostic des problèmes ne peut être effectué que dans le corps de la fonction addOne. C’est ce concept qui sous-tend la programmation fonctionnelle typée.

Expressions

Les expressions sont des constructions dont l’évaluation donne une valeur. Contrairement aux instructions qui effectuent simplement une action, les expressions effectuent une action qui retourne une valeur. En programmation fonctionnelle, les instructions sont presque toujours délaissées au profit d’expressions.

Prenez la fonction précédente, addOne. Le corps de addOne est une expression :

// 'x + 1' is an expression!
let addOne x = x + 1

C’est le résultat de cette expression qui définit le type de résultat de la fonction addOne. Par exemple, vous pouvez remplacer le type de l’expression qui compose cette fonction par string :

let addOne x = x.ToString() + "1"

La signature de la fonction est maintenant :

val addOne: x:'a -> string

Étant donné que ToString() peut être appelé sur n’importe quel type en F#, le type de x a été rendu générique (c’est ce qu’on appelle la généralisation automatique) et le type résultant est string.

Les expressions ne sont pas seulement les corps de fonctions. Vous pouvez avoir des expressions qui produisent une valeur que vous utilisez ailleurs. Un exemple courant est if :

// Checks if 'x' is odd by using the mod operator
let isOdd x = x % 2 <> 0

let addOneIfOdd input =
    let result =
        if isOdd input then
            input + 1
        else
            input

    result

L’expression if produit une valeur appelée result. Notez que vous pouvez omettre result entièrement, ce qui fait de l’expression if le corps de la fonction addOneIfOdd. L’élément clé à retenir à propos des expressions est qu’elles produisent une valeur.

Il existe un type spécial, unit, qui est utilisé lorsqu’il n’y a rien à retourner. Par exemple, prenez cette fonction simple :

let printString (str: string) =
    printfn $"String is: {str}"

La signature ressemble à ceci :

val printString: str:string -> unit

Le type unit indique qu’aucune valeur réelle n’est retournée. Cela est utile quand vous avez une routine qui doit « faire un travail », mais qu’il n’y a aucune valeur à retourner à la suite de ce travail.

Cela contraste fortement avec la programmation impérative, où la construction équivalente if est une instruction et où la production de valeurs est souvent effectuée avec des variables de mutation. Par exemple, en C#, le code peut être écrit comme ceci :

bool IsOdd(int x) => x % 2 != 0;

int AddOneIfOdd(int input)
{
    var result = input;

    if (IsOdd(input))
    {
        result = input + 1;
    }

    return result;
}

Il convient de noter que C# et d’autres langages de style C prennent en charge l’expression ternaire, celle-ci permettant la programmation conditionnelle basée sur des expressions.

En programmation fonctionnelle, il est rare de muter des valeurs avec des instructions. Bien que certains langages fonctionnels prennent en charge les instructions et les mutations, ces concepts sont rarement utilisés en programmation fonctionnelle.

Fonctions pures

Comme mentionné précédemment, les fonctions pures sont des fonctions :

  • dont l’évaluation donne toujours la même valeur pour la même entrée ;
  • qui n’ont pas d’effets secondaires.

Il est utile de faire le rapprochement avec les fonctions mathématiques dans ce contexte. En mathématiques, les fonctions dépendent uniquement de leurs arguments et n’ont pas d’effets secondaires. Dans la fonction mathématique f(x) = x + 1, la valeur de f(x) dépend uniquement de la valeur de x. Les fonctions pures en programmation fonctionnelle sont identiques.

Quand vous écrivez une fonction pure, la fonction doit dépendre uniquement de ses arguments et ne pas effectuer d’action entraînant un effet secondaire.

Voici un exemple de fonction non pure, car elle dépend d’un état global et mutable :

let mutable value = 1

let addOneToValue x = x + value

La fonction addOneToValue est clairement impure, car value peut être modifié à tout moment et avoir une valeur différente de 1. Ce modèle de dépendance à une valeur globale doit être évité en programmation fonctionnelle.

Voici un autre exemple de fonction non pure, car elle produit un effet secondaire :

let addOneToValue x =
    printfn $"x is %d{x}"
    x + 1

Bien que cette fonction ne dépende pas d’une valeur globale, elle écrit la valeur de x dans la sortie du programme. Bien que cela ne soit pas foncièrement mauvais, il en résulte une fonction qui n’est pas pure. Si une autre partie de votre programme dépend d’un élément externe au programme, comme la mémoire tampon de sortie, l’appel de cette fonction peut affecter cette autre partie de votre programme.

La suppression de l’instruction printfn rend la fonction pure :

let addOneToValue x = x + 1

Bien que cette fonction ne soit pas intrinsèquement meilleure que la version précédente avec l’instruction printfn, vous avez la garantie qu’elle se limite au retour d’une valeur. L’appel de cette fonction n’importe quel nombre de fois produit le même résultat : une valeur. La prévisibilité qui découle de la pureté est quelque chose que visent de nombreux programmeurs fonctionnels.

Immuabilité

Enfin, l’immuabilité est l’un des concepts les plus fondamentaux de la programmation fonctionnelle typée. En F#, toutes les valeurs sont immuables par défaut. Cela signifie qu’elles ne peuvent pas être mutées sur place, sauf si vous les marquez explicitement comme mutables.

Dans la pratique, l’utilisation de valeurs immuables signifie que votre approche en matière de programmation doit passer de « J’ai besoin de changer quelque chose » à « J’ai besoin de produire une nouvelle valeur ».

Par exemple, ajouter 1 à une valeur signifie produire une nouvelle valeur, et non muter la valeur existante :

let value = 1
let secondValue = value + 1

En F#, le code suivant ne mute pas la fonction value ; au lieu de cela, il effectue une vérification d’égalité :

let value = 1
value = value + 1 // Produces a 'bool' value!

Certains langages de programmation fonctionnelle ne prennent pas du tout en charge la mutation. Elle est prise en charge en F#, mais ce n’est pas le comportement par défaut pour les valeurs.

Ce concept s’étend aussi aux structures de données. En programmation fonctionnelle, les structures de données immuables comme les ensembles (et bien d’autres) ont une implémentation différente de celle à laquelle vous pourriez vous attendre initialement. D’un point de vue conceptuel, l’ajout d’un élément à un ensemble ne modifie pas cet ensemble, mais produit un nouvel ensemble avec la valeur ajoutée. Sous le capot, cela est souvent accompli par une structure de données différente qui permet un suivi efficace d’une valeur afin que la représentation appropriée des données puisse être donnée en conséquence.

Ce style de travail avec des valeurs et des structures de données est essentiel, car il vous oblige à traiter toute opération qui modifie un élément comme si elle générait une nouvelle version de cet élément. Cela permet à certaines choses telles que l’égalité et la comparabilité d’être cohérentes dans vos programmes.

Étapes suivantes

La section suivante couvre en détail les fonctions et explore différentes façons de les utiliser en programmation fonctionnelle.

L’utilisation de fonctions en F# explore les fonctions en profondeur et montre comment les utiliser dans différents contextes.

Pour aller plus loin

L’excellente série Thinking Functionally est une autre ressource qui vous permet d’en savoir plus sur la programmation fonctionnelle avec F#. Elle aborde les principes fondamentaux de la programmation fonctionnelle de manière pragmatique et facile à lire, en utilisant les fonctionnalités de F# pour illustrer les concepts.