LINQ permet d'effectuer simplement, de façon compacte et intuitive des opérations sur des séquences. Certaines opérations peuvent cependant être déroutantes.
LINQ (Language-Integrated Query) est le nom d’un ensemble de technologies basé sur l’intégration de fonctions de requête directement dans le langage C#.
Il permet notamment d'effectuer simplement, de façon compacte et intuitive des opérations sur des séquences comme des filtres, des projections, des partitionnements, des agrégations.
Il est souvent perçu comme un outil sûr, élégant et lisible. On enchaîne des opérations, on exprime une intention, et le résultat suit.
Mais certaines méthodes cachent des contrats implicites qui, si on ne les connaît pas, peuvent poser des soucis et introduire des bugs dans vos applications.
Un cas métier très classique
Prenons un exemple simple : une commande e-commerce.
Chaque commande contient des lignes, et chaque ligne a un prix unitaire et une quantité.
public class OrderLine {
public decimal UnitPrice { get; }
public int Quantity { get; }
public OrderLine(decimal unitPrice, int quantity) {
UnitPrice = unitPrice;
Quantity = quantity;
}
public decimal GetTotal() => UnitPrice * Quantity;
}
On veut calculer le montant total de la commande.
Cas normal :
var lines = new[] {
new OrderLine(10m, 2),
new OrderLine(5m, 1),
new OrderLine(3m, 4)
};
decimal total = lines
.Select(l => l.GetTotal())
.Aggregate((prev, next) => prev + next);
Console.WriteLine(total); // 37
Tout fonctionne.
Le cas réel : commande vide
Maintenant, cas parfaitement réaliste : une commande créée mais encore vide.
var lines = Array.Empty<OrderLine>();
decimal total = lines
.Select(l => l.GetTotal())
.Aggregate((prev, next) => prev + next);
Et là :
InvalidOperationException: Sequence contains no elements
Surprenant, non ? On pourrait s’attendre à un total de 0.
Le vrai problème
Le problème ne vient ni de Select, ni de LINQ en général. Il vient de la surcharge de Aggregate utilisée :
Aggregate((prev, next) => ...)
Cette version n’a pas de valeur initiale (seed). LINQ prend donc le premier élément de la séquence comme point de départ.
Donc :
- s’il y a au moins un élément → OK
- s’il n’y en a aucun → impossible de démarrer → exception
Autrement dit, cette version de Aggregate suppose implicitement :
“Je te garantis qu’il y a au moins un élément.”
Et dans un cas métier réel… ce n’est pas toujours vrai.
Les deux fausses bonnes idées
Quand on découvre le problème, deux solutions peuvent venir à l’esprit.
❌ 1. Tester avec Any()
decimal total = lines.Any()
? lines.Select(l => l.GetTotal()).Aggregate((p, n) => p + n)
: 0m;
Ça fonctionne. Mais ce n’est pas une bonne solution.
Pourquoi ?
- Double énumération :
Any()parcourt la séquence, puisAggregate()la parcourt à nouveau. Sur certaines sources (requêtes, flux…), ça peut être coûteux ou problématique. - Rupture du pipeline LINQ : on sort du flux fonctionnel pour injecter une logique conditionnelle externe.
- Intention métier dispersée : la règle “une commande vide = total 0” n’est pas portée par l’agrégation elle-même.
❌ 2. Utiliser DefaultIfEmpty
decimal total = lines
.Select(l => l.GetTotal())
.DefaultIfEmpty()
.Aggregate((p, n) => p + n);
Cette version fonctionne. Pas d’exception. Mais elle est conceptuellement trompeuse. Pourquoi ?
Elle modifie la nature des données
DefaultIfEmpty() transforme une séquence vide en séquence contenant un élément par défaut.
[] -> [0]
On ne calcule plus :
la somme de 0 éléments
mais :
la somme d’un élément artificiel égal à 0
Ce n’est pas la même sémantique.
Elle introduit une dépendance implicite au type
Ici ça marche parce que default(decimal) = 0. Mais pour d’autres types, la valeur par défaut n’a aucun sens métier.
Elle crée des effets de bord si le calcul évolue
Imagine que demain on calcule :
- une moyenne
- un nombre d’éléments
- une statistique
Avec DefaultIfEmpty, on introduit un élément fictif qui fausse les résultats.
La bonne utilisation d’Aggregate
La bonne solution consiste à utiliser la surcharge avec valeur initiale :
decimal total = lines
.Select(l => l.GetTotal())
.Aggregate(0m, (acc, next) => acc + next);
Ici :
- pas d’exception
- une seule énumération
- la règle métier est explicite
- aucune donnée artificielle n’est introduite
Et en pratique… ce n’est même pas le bon outil
Pour ce cas précis, LINQ fournit déjà une méthode adaptée :
decimal total = lines.Sum(l => l.GetTotal());
C’est plus lisible, plus direct, et parfaitement aligné avec l’intention métier.
À retenir
Quand vous utilisez Aggregate, posez-vous toujours ces questions :
- La séquence peut-elle être vide ?
- Si oui, ai-je fourni une valeur initiale ?
- Existe-t-il une méthode LINQ plus adaptée (
Sum,Join,Count, etc.) ?
Parce que sinon, le jour où la séquence est vide… ce n’est pas un 0 que vous obtiendrez. C’est une exception. 💥
Références
- LINQ (Language-Integrated Query)
- Opérations sur des séquences en C#
- Agrégation sur les énumérables
- Documentation de la méthode
DefaultIfEmpty - Types références nullable