En ce 10 novembre, jour de .NET Conf, Microsoft lance .NET 11, une nouvelle version STS (Standard Term Support), prise en charge jusqu'en novembre 2028, qui apporte son lot de nouveautés.
L'année dernière, cet événement aurait occupé une petite place dans ma vie de développeur, et j'aurais probablement suivi les conférences en ligne avec intérêt. Mais dans un monde « boosté » à l'IA, la hype est nettement moins présente.
Après tout, ces améliorations, notamment celles qui concernent la syntaxe, ce sera surtout l'IA qui les exploitera. Qu'elle utilise un membre d'extension plutôt qu'une méthode d'extension… Je vais même vous dire : qu'elle utilise Java plutôt que .NET, ça ne changera pas grand-chose au prompt Claude.
C'est donc peut-être l'un des derniers articles du genre. Mais en attendant de s'en cogner complètement le coquillard du code, célébrons cette nouvelle version !
Union Types : l'arlésienne de l'écosystème
Microsoft en parle depuis de nombreuses années, et la communauté s'écharpe depuis tout aussi longtemps sur la meilleure façon d'implémenter les types union. The wait is over ! C# rattrape enfin son retard sur d'autres langages, comme TypeScript, Rust ou F#, qui disposent depuis longtemps de mécanismes comparables.
Pour rappel, un type union (ou discriminated union dans d'autres langages) permet de représenter plusieurs types de valeurs possibles sous un même type. Plus besoin de recourir à une interface, à une hiérarchie de classes ou à un type object. Inutile également de prévoir un cas inconnu. Le compilateur connaît les différentes possibilités, ce qui permet de conserver un typage fort tout en garantissant la cohérence des données.
Prenons l'exemple d'un import de fichier CSV qui peut se trouver dans quatre états : en attente, en cours, terminé ou en échec. Chaque état possède ses propres informations, comme le pourcentage de progression, le nombre de lignes importées ou un message d'erreur.
ImportState import = new Completed(150);
var message = import switch
{
Pending => "Import pending",
InProgress p => $"Import in progress: {p.Progress}%",
Completed c => $"Import completed: {c.ImportedRows} rows imported",
Failed f => $"Import failed: {f.Error}"
};
Console.WriteLine(message);
// En C#15 avec Union Type
public record Pending;
public record InProgress(int Progress);
public record Completed(int ImportedRows);
public record Failed(string Error);
public union ImportState(
Pending,
InProgress,
Completed,
Failed
);
// En C#14, nous aurions pu envisager une classe dotée de propriétés nullables.
public class ImportState
{
public ImportStatus Status { get; set; }
public int? Progress { get; set; }
public int? ImportedRows { get; set; }
public string? Error { get; set; }
}
Contrairement à une classe classique, qui regroupe tous les états et leurs propriétés au risque d'autoriser des combinaisons incohérentes, un type union définit explicitement les différentes possibilités, chacune avec ses propres données.
Cette approche renforce la sécurité du typage, évite de multiplier les propriétés inutiles ou nullables et permet au compilateur de signaler les cas oubliés lors du traitement des différents états.
Hiérarchies fermées
Un nouveau mot-clé, closed, fait son apparition pour mieux encadrer les relations d'héritage. Contrairement à sealed, qui interdit tout héritage, closed autorise celui-ci, mais uniquement au sein du même assembly pour les classes directement dérivées.
Le compilateur connaît ainsi les différentes possibilités et peut vérifier l'exhaustivité des expressions switch, sans nécessiter de cas par défaut (_).
Une classe closed est également implicitement abstract : elle ne peut donc pas être instanciée directement.
ImportState import = new Completed(150);
var message = import switch
{
Pending => "Import pending",
Completed c => $"Import completed: {c.ImportedRows} rows imported"
};
Console.WriteLine(message);
// C# 15 - Closed Hierarchy
public closed class ImportState;
public sealed class Pending : ImportState;
public sealed class Completed(int importedRows) : ImportState
{
public int ImportedRows { get; } = importedRows;
}
Boucles étiquetées : break et continue
Les mots-clés break et continue peuvent désormais cibler une boucle précise grâce à une étiquette (label).
Dans des boucles imbriquées, il devient ainsi possible de quitter directement une boucle externe ou de passer à son itération suivante, sans recourir à des variables intermédiaires ou à un goto.
Une nouveauté qui améliore essentiellement la lisibilité du code (son intérêt reste donc assez limité dans le monde d'aujourd'hui).
// Continue label
ordersLoop: foreach (var order in orders)
{
foreach (var item in order.Items)
{
if (item.Quantity <= 0)
continue ordersLoop;
}
ProcessOrder(order);
}
// Break Label
Product? foundProduct = null;
search: foreach (var category in categories)
{
foreach (var product in category.Products)
{
if (product.Sku == searchedSku)
{
foundProduct = product;
break search;
}
}
}
Arguments dans les expressions de collection
Depuis C# 12, Microsoft propose une syntaxe simplifiée pour initialiser les collections avec []. C'est pratique, mais cette syntaxe présentait jusqu'ici une limitation : impossible de transmettre des arguments au constructeur de la collection.
C'est désormais de l'histoire ancienne grâce à with(...), qui permet de préciser ces arguments directement en tête de l'expression de collection.
// C# 14
HashSet<string> emails = new(StringComparer.OrdinalIgnoreCase)
{
"alice@example.com",
"ALICE@example.com",
"bob@example.com"
};
// C# 15
HashSet<string> emails =
[
with(StringComparer.OrdinalIgnoreCase),
"alice@example.com",
"ALICE@example.com",
"bob@example.com"
];
