1. Introduction
En passant de la théorie à la pratique, il est logique de se demander : «Et pourquoi moi, dev .NET/C#, aurais-je besoin de toutes ces techniques de programmation fonctionnelle ?»
En effet, C# n'est pas un langage purement fonctionnel comme F# ou Haskell. Mais depuis la version 3.0 et jusqu'à C# 14, il s'est doté de nombreux outils FP qui peuvent sérieusement améliorer la qualité et l'expressivité du code.
Voici où ils fonctionnent particulièrement bien :
- Travail avec les collections — LINQ, Map/Reduce, filtrage, agrégation, tri et toute la «magie» sur les données.
- Fonctions pures — moins de bugs dus à l'état et aux effets de bord, plus simple à déboguer.
- Fonctions d'ordre supérieur — composants génériques et réutilisables, agréables à manipuler.
- Immuabilité en multithreading — un des principaux gages de sécurité du code dans les scénarios parallèles et asynchrones.
- Composition de fonctions — rend la logique métier complexe concise, lisible et facile à tester.
Tableau : Comparaison approche OOP et approche FP en C#
| Tâche | Impératif (OOP/ancienne école) | Fonctionnel (FP) |
|---|---|---|
| Filtrage d'une liste | |
|
| Transformation d'une liste | |
|
| Recherche par critère | |
|
| Agrégation | |
|
| Mise en cache | |
|
2. LINQ : le plus fonctionnel dans C#
Si vous pensiez que “programmation fonctionnelle” c'est des listes, des filtres et des .Where, .Select, .Aggregate, — félicitations : c'est effectivement ça ! LINQ est la quintessence du FP en C#.
Rappel : comment LINQ fonctionne
LINQ opère sur des collections via des chaînes de méthodes qui prennent des fonctions en paramètres (par ex. des lambdas). Par exemple :
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// On prend seulement les nombres pairs et on les double
var result = numbers
.Where(x => x % 2 == 0)
.Select(x => x * 2);
foreach (var number in result)
Console.WriteLine(number);
Que se passe-t-il ici ?
- .Where — fonction d'ordre supérieur : elle prend une fonction (x => x % 2 == 0) et retourne une autre collection.
- .Select — prend aussi une fonction (x => x * 2).
- On ne modifie pas la collection d'origine, on obtient un nouveau résultat.
Ce style est facile à lire et à étendre (on peut enchaîner .OrderBy, .Take, .Distinct, etc.).
Attention ! Les expressions lambda sont un moyen pratique de créer un “delegate à la volée”. LINQ n'aurait pas été possible sans le support FP en C#.
Schéma : Traitement fonctionnel d'une collection
Collection --> Where(x => bool) --> Select(x => y) --> Nouveau résultat
3. Composition de fonctions et pipeline de traitement des données
En FP on utilise souvent la composition : une opération complexe se construit comme une chaîne de petites fonctions, chacune faisant son boulot.
Exemple : chaîne de traitement d'une chaîne
Style mutatif (OOP) :
string s = " hello world ";
s = s.Trim();
s = s.ToUpper();
s = s + "!";
Console.WriteLine(s); // HELLO WORLD!
Plus «fonctionnel» — comme un pipeline de fonctions :
Func<string, string> trim = x => x.Trim();
Func<string, string> upper = x => x.ToUpper();
Func<string, string> addBang = x => x + "!";
// Composition de fonctions — on les applique successivement
Func<string, string> pipeline = x => addBang(upper(trim(x)));
Console.WriteLine(pipeline(" hello world ")); // HELLO WORLD!
Un combinator de composition simple :
Func<T, R> Compose<T, U, R>(Func<T, U> f, Func<U, R> g) =>
x => g(f(x));
// Et maintenant le pipeline via Compose :
var pipeline2 = Compose(trim, upper);
pipeline2 = Compose(pipeline2, addBang);
Console.WriteLine(pipeline2(" hello again ")); // HELLO AGAIN!
4. Travailler avec l'immuabilité : protection contre les bugs
L'immuabilité est une brique fondamentale du FP. On ne modifie pas les structures de données, on retourne de nouvelles versions. C'est particulièrement important en applications multithread.
Exemple : «mal» (mutable)
List<int> numbers = new List<int> { 1, 2, 3 };
numbers[0] = 42;
Exemple : «bien» (fonctionnel)
var numbers = new List<int> { 1, 2, 3 };
var newNumbers = numbers.Select((x, i) => i == 0 ? 42 : x).ToList();
Dans le C# moderne il y a des collections ImmutableList<T> et d'autres types dans l'espace de noms System.Collections.Immutable :
using System.Collections.Immutable;
var immutableNumbers = ImmutableList.Create(1, 2, 3);
var changed = immutableNumbers.SetItem(0, 42); // Retourne une nouvelle liste !
5. Fonctions d'ordre supérieur dans la vraie vie
Les fonctions d'ordre supérieur permettent d'écrire des composants génériques sans une tonne de conditions.
Exemple : filtre universel pour utilisateurs
class User
{
public string Name { get; set; }
public int Age { get; set; }
}
var users = new List<User>
{
new User { Name = "Vasya", Age = 26 },
new User { Name = "Kate", Age = 17 },
new User { Name = "Alex", Age = 35 }
};
List<User> FilterUsers(List<User> source, Predicate<User> predicate)
{
return source.Where(u => predicate(u)).ToList();
}
// Utilisation :
var adults = FilterUsers(users, u => u.Age >= 18);
var longNames = FilterUsers(users, u => u.Name.Length > 3);
6. Pattern matching et switch-expressions
Le C# moderne utilise beaucoup le pattern matching : les switch-expressions remplacent souvent les longues chaînes de if.
object value = 123;
string description = value switch
{
int i when i > 100 => "Grand nombre",
string s when s.Length > 3 => "Chaîne longue",
null => "Valeur vide",
_ => "Inconnu"
};
Console.WriteLine(description); // Grand nombre
7. Memoization : mise en cache des résultats de fonctions
La memoization est la mise en cache du résultat d'une fonction pour des arguments identiques. En C# on peut l'implémenter facilement soi-même.
Func<int, int> SlowFib = null; // Fonction récursive de Fibonacci
var cache = new Dictionary<int, int>();
SlowFib = n =>
{
if (cache.ContainsKey(n))
return cache[n];
if (n <= 1)
cache[n] = n;
else
cache[n] = SlowFib(n - 1) + SlowFib(n - 2);
return cache[n];
};
Console.WriteLine(SlowFib(40)); // Ultra-rapide !
8. Currying et application partielle
L'application partielle consiste à fixer une partie des arguments d'une fonction. En C# on le fait facilement avec des lambdas.
Func<int, int, int> add = (a, b) => a + b;
// On fixe le premier argument
Func<int, int> add10 = b => add(10, b);
Console.WriteLine(add10(5)); // 15
Console.WriteLine(add10(100)); // 110
9. Style déclaratif avec des fonctions
Impératif :
var result = new List<int>();
foreach (var n in numbers)
{
if (n > 0)
result.Add(n * n);
}
Déclaratif :
var result = numbers
.Where(n => n > 0)
.Select(n => n * n)
.ToList();
10. Exercice pratique
Implémentons un module de filtrage des tâches dans un «task manager pour étudiants».
Modèle :
class StudentTask
{
public string Title { get; set; }
public bool IsCompleted { get; set; }
public int Priority { get; set; }
}
Données initiales :
var tasks = new List<StudentTask>
{
new StudentTask { Title = "Do homework", IsCompleted = false, Priority = 2 },
new StudentTask { Title = "Drink coffee", IsCompleted = true, Priority = 3 },
new StudentTask { Title = "Watch lecture", IsCompleted = false, Priority = 1 }
};
Filtre universel :
List<StudentTask> FilterTasks(
List<StudentTask> all,
Predicate<StudentTask> predicate)
{
return all.Where(t => predicate(t)).ToList();
}
// Recherche des tâches non terminées avec priorité > 1
var importantTasks = FilterTasks(tasks, t => !t.IsCompleted && t.Priority > 1);
// Affichage du résultat
foreach (var task in importantTasks)
Console.WriteLine(task.Title);
Combinators de prédicats :
Predicate<StudentTask> IsActive = t => !t.IsCompleted;
Predicate<StudentTask> IsHighPriority = t => t.Priority > 1;
// On combine plusieurs critères, variante 1
var specialTasks = FilterTasks(tasks, t => IsActive(t) && IsHighPriority(t));
// Variante 2 : fonction-combinator de deux prédicats
Predicate<StudentTask> And(Predicate<StudentTask> a, Predicate<StudentTask> b) => t => a(t) && b(t);
var specialTasks2 = FilterTasks(tasks, And(IsActive, IsHighPriority));
11. Particularités et erreurs typiques en appliquant l'approche fonctionnelle en C#
D'abord, souvenez-vous que C# est fortement typé. Parfois il faut préciser explicitement les types, surtout quand les fonctions retournent des delegates ou des lambdas complexes. Sinon vous pouvez avoir une erreur de compilation à cause d'un type non inféré.
Ensuite, ne passez pas des fonctions avec effets de bord là où on attend des fonctions pures. Modifier des variables externes casse la prédictibilité. Essayez que vos fonctions n'«mutent» pas l'état en dehors de leur scope.
Tiers point : faites attention à la capture de variables externes (closure capture), surtout en code async et multithread. Une variable dont la valeur change après l'avoir passée à une lambda (par ex. dans LINQ) peut provoquer des bugs non évidents.
Enfin, un style FP excessif peut compliquer la maintenance pour une équipe non habituée. Utilisez le FP là où il simplifie vraiment la solution, pas seulement pour la «jolie» façon d'écrire.
GO TO FULL VERSION