Pipe et composition
Le scénario
Vous avez une liste de nombres et vous voulez : garder les impairs, les élever au carré, puis sommer le résultat. En C#, sans LINQ fluide, cela s'écrit facilement en appels imbriqués les uns dans les autres :
var result = Enumerable.Sum(Enumerable.Select(Enumerable.Where(numbers, x => x % 2 != 0), x => x * x));
Pour lire ce code, il faut le déchiffrer de l'intérieur vers l'extérieur : d'abord Where, puis Select, puis Sum — alors que l'ordre logique des opérations, lui, va de gauche à droite. La syntaxe fluide de LINQ (numbers.Where(...).Select(...).Sum()) corrige déjà ce problème côté C#. F# propose un mécanisme équivalent, mais qui ne se limite pas aux méthodes d'un objet : l'opérateur pipe, |>.
L'opérateur pipe : |>
|> prend une valeur à sa gauche et une fonction à sa droite, et applique la fonction à la valeur. Sa définition tient en une ligne dans la bibliothèque standard de F# :
let (|>) x f = f x
Cela permet d'enchaîner des transformations dans l'ordre où elles se produisent réellement, de gauche à droite (ou de haut en bas) :
let numbers = [ 1; 2; 3; 4; 5 ]
let result =
numbers
|> List.filter (fun x -> x % 2 <> 0)
|> List.map (fun x -> x * x)
|> List.sum
Notez au passage la syntaxe de la liste : des éléments séparés par des points-virgules entre crochets, [ 1; 2; 3; 4; 5 ], et non par des virgules comme dans un new List<int> { 1, 2, 3, 4, 5 } C# — F# réserve la virgule à un autre usage (le regroupement de plusieurs valeurs en une seule, que vous croiserez plus loin dans ce parcours).
Notez aussi fun x -> x % 2 <> 0 : c'est une fonction anonyme F#, l'équivalent direct de la lambda C# x => x % 2 != 0. Le mot-clé fun introduit les paramètres (ici, un seul, x), et -> remplace le => du C# pour séparer les paramètres du corps de la fonction. Vous en écrirez une à chaque fois que vous passerez une petite fonction directement à List.filter ou List.map, sans lui donner de nom avec let au préalable. Autre différence de syntaxe au passage : F# écrit « différent de » <>, là où C# écrit !=.
Chaque ligne se lit comme une étape : « partir de numbers, garder les impairs, les mettre au carré, sommer ». C'est exactement le calcul de l'exemple C# imbriqué du haut, mais écrit dans l'ordre où on le pense.
List.filter et List.map fonctionnent comme leurs équivalents LINQ Where et Select ; List.sum calcule la somme d'une liste de nombres, à la condition que le type des éléments supporte l'opérateur + — ce qui est le cas de int ici.
L'opérateur de composition : >>
Le pipe enchaîne des appels — il a besoin d'une valeur de départ pour fonctionner. La composition, elle, construit une nouvelle fonction à partir de deux fonctions existantes, sans jamais leur fournir de valeur : c'est une opération sur des fonctions, pas sur des données.
let addOne x = x + 1
let timesTwo x = 2 * x
let addOneThenDouble = addOne >> timesTwo
addOneThenDouble est une fonction à part entière, qui n'a encore rien calculé — elle n'est appliquée à une valeur que lorsque vous l'appelez :
addOneThenDouble 3
Ici, 3 passe d'abord par addOne (résultat 4), puis ce résultat passe par timesTwo (résultat 8). >> lit donc, comme |>, de gauche à droite : la fonction à gauche s'exécute en premier.
La différence en une phrase
|> prend une valeur et une fonction, et renvoie une valeur. >> prend deux fonctions, et renvoie une fonction. Rien d'autre à retenir pour l'instant — la confusion la plus fréquente est d'essayer d'utiliser >> avec une valeur au lieu d'une fonction à gauche, ou l'inverse avec |> : le compilateur refusera dans les deux cas, car les types ne correspondent pas.
Il n'existe pas d'équivalent direct à >> en C# — combiner deux Func<T,T> en une seule fonction demande d'écrire soi-même une fonction englobante ou de passer par Func.Compose-like helpers absents du langage lui-même ; >> fait cela nativement.
Exercice 1 — réécrire un appel imbriqué en pipeline
Voici un second exemple imbriqué, cette fois pour transformer une liste de chaînes de caractères : garder celles qui ont plus de 3 caractères, les mettre en majuscules.
var result = Enumerable.Select(Enumerable.Where(words, w => w.Length > 3), w => w.ToUpper());
Ouvrez dotnet fsi et écrivez la version F# en pipeline, à partir de cette liste :
let words = [ "un"; "deux"; "trois"; "quatre"; "dix" ]
Écrivez votre pipeline en utilisant List.filter et List.map, puis exécutez-la avec printfn "%A" result;; pour afficher le résultat. printfn est l'équivalent F# de Console.WriteLine, avec une syntaxe de formatage héritée du printf du C ; %A est un spécificateur propre à F# qui affiche n'importe quelle valeur — liste, tuple, record — de façon lisible, sans code de conversion à écrire vous-même.
Sortie attendue :
["DEUX"; "TROIS"; "QUATRE"]
Si vous obtenez ["UN"; "DEUX"; "TROIS"; "QUATRE"; "DIX"], la condition du filtre n'a pas été appliquée avant la mise en majuscule — vérifiez l'ordre de vos deux étapes dans le pipeline.
Exercice 2 — composer deux fonctions courtes
Écrivez deux fonctions minuscules :
let square x = x * x
let addTen x = x + 10
Puis, sans utiliser |>, composez-les avec >> en une seule fonction nommée squareThenAddTen, et appliquez-la à 4.
Sortie attendue :
val squareThenAddTen: (int -> int)
val it: int = 26
(4 au carré donne 16, plus 10 donne 26.) Si vous obtenez 196 à la place (addTen 4 donne 14, puis square 14 donne 196), c'est que l'ordre des deux fonctions dans la composition est inversé — rappelez-vous que >> exécute d'abord la fonction de gauche.
Ce que vous venez de faire
Vous avez remplacé un appel de fonctions imbriquées, difficile à lire dans l'ordre où le calcul se produit réellement, par un pipeline |> qui suit cet ordre littéralement. Vous avez aussi construit une nouvelle fonction à partir de deux fonctions existantes avec >>, sans jamais leur fournir de valeur avant de les combiner — une opération qui n'a pas d'équivalent direct et natif en C#.
Au module suivant, vous quittez la transformation de données pour aborder la prise de décision : comment match remplace if/else et switch pour brancher sur la forme d'une valeur, en particulier sur un tuple.
Vérifiez votre compréhension
Sur numbers |> List.filter (fun x -> x % 2 <> 0) |> List.map (fun x -> x * x) |> List.sum, un collègue propose d'intervertir les deux dernières étapes : numbers |> List.filter (fun x -> x % 2 <> 0) |> List.sum |> List.map (fun x -> x * x). Que se passe-t-il ?
Avec let addOne x = x + 1 et let timesTwo x = 2 * x, quelle est la différence entre addOne >> timesTwo et addOne |> timesTwo ?
Vous définissez let square x = x * x et let addTen x = x + 10, puis let addTenThenSquare = addTen >> square. Quel est le résultat de addTenThenSquare 4 ?
Envie d'être prévenu des prochains modules ?
L'Académie reste gratuite et en accès libre, sans inscription. Si vous voulez juste être averti par email à la sortie d'un nouveau module, c'est ici — aucune obligation, désinscription en un clic.