Introduction▲
Il y a environ deux ans, Microsoft a rendu le compilateur C# open source et multiplateforme, et tout le processus d'évolution du langage, de la conception à l'implémentation, se fait maintenant publiquement sur Github. Microsoft semble d'ailleurs avoir plongé tête la première dans le monde open source, puisque nombre de ses produits de développement sont maintenant sur GitHub (Roslyn, .NET Core, Core CLR, ASP.NET Core, Entity Framework Core, MSBuild, PowerShell…).
C# 7 est la première version du langage qui intègre des contributions de la communauté. Cette mouture n'est pas encore en version finale, mais on peut déjà tester les nouvelles fonctionnalités dans la Release Candidate de Visual Studio 2017 publiée récemment.
Pour cette version, il ne semble pas y avoir de thème clairement défini. Certaines fonctionnalités apportent des concepts issus de la programmation fonctionnelle (fonctions locales, tuples, pattern matching), d'autres ont pour but d'améliorer les performances dans certains scénarios (variables locales et retour de fonctions par référence, généralisation des types de retour asynchrones), d'autres enfin permettront juste de rendre le code plus clair et plus concis (variables out, littéraux numériques…).
1. Fonctions locales▲
Dans la plupart des langages, il est fréquent de créer des fonctions auxiliaires pour éviter la répétition de code ou pour simplifier une méthode complexe. Bien souvent, ces fonctions auxiliaires n'ont de sens que du point de vue de la méthode d'où elles ont été extraites. C# 7 introduit donc une fonctionnalité qui existe déjà dans la plupart des langages fonctionnels : les fonctions locales. Il s'agit tout simplement de la possibilité de déclarer une méthode à l'intérieur d'une autre méthode. Ces fonctions locales ne sont accessibles que dans le scope de la méthode qui les déclare. Par exemple :
Monster GetClosestTarget
(
Point playerPosition,
Monster[]
monsters)
{
double
GetDistance
(
Point a,
Point b)
{
double
dx =
a.
X -
b.
X;
double
dy =
a.
Y -
b.
Y;
return
Math.
Sqrt
(
dx *
dx +
dy *
dy);
}
return
monsters
.
OrderBy
(
m =>
GetDistance
(
playerPosition,
m.
Position))
.
FirstOrDefault
(
);
}
Le fait de grouper les fonctions auxiliaires avec la méthode qui les utilise n'est pas le seul intérêt de cette approche ; en effet, les fonctions locales peuvent accéder directement aux variables locales et paramètres de la fonction qui les déclare. On pourrait donc par exemple simplifier le code ci-dessus comme suit :
Monster GetClosestTarget
(
Point playerPosition,
Monster[]
monsters)
{
double
GetDistance
(
Point p)
{
double
dx =
playerPosition.
X -
p.
X;
double
dy =
playerPosition.
Y -
p.
Y;
return
Math.
Sqrt
(
dx *
dx +
dy *
dy);
}
return
monsters
.
OrderBy
(
m =>
GetDistance
(
m.
Position))
.
FirstOrDefault
(
);
}
Notez que la fonction GetDistance ne prend plus qu'un seul paramètre, et récupère la position du joueur directement depuis les paramètres de GetClosestTarget.
Il n'y a pas vraiment de limitations particulières à ce qu'on peut faire avec les fonctions locales ; elles peuvent être génériques, asynchrones, ou être des itérateurs.
Il était déjà possible de faire quelque chose de similaire à l'aide d'expressions lambda, mais cela présentait pas mal d'inconvénients :
- performances dégradées (coût d'allocation et d'appel d'un delegate) ;
- syntaxe peu pratique, en particulier pour une fonction récursive ;
- pas de paramètres ref, out, params ou optionnels ;
- pas possible de créer une lambda générique.
2. Tuples▲
Un problème très fréquemment rencontré par les développeurs C# est l'absence de mécanisme pour renvoyer plusieurs valeurs depuis une fonction. Il y a bien sûr des solutions de contournement, mais elles sont généralement peu pratiques :
- utiliser un tableau ou une liste ;
- utiliser des paramètres out ;
- utiliser la classe Tuple<...> ;
- créer une classe spécifique pour représenter les résultats de la fonction.
Aucune de ces solutions n'était vraiment satisfaisante. Heureusement, C# 7 vient régler le problème en introduisant les tuples. Un tuple est un ensemble ordonné fini de valeurs typées (potentiellement de types différents), et éventuellement nommées. Dit comme ça, ce n'est pas très parlant, on va donc voir un exemple. La fonction suivante calcule et renvoie le nombre d'éléments dans une liste de nombres, ainsi que leur somme :
static
(
int
count,
double
sum) Tally
(
IEnumerable<
double
>
values)
{
int
count =
0
;
double
sum =
0
.
0
;
foreach
(
var
value
in
values)
{
count++;
sum +=
value
;
}
return
(
count,
sum);
}
Notez la déclaration du type de retour : (int count, double sum) est un type tuple composé d'un entier nommé count et d'un double nommé sum. Notez que les noms ne sont pas obligatoires, mais ils sont généralement pratiques pour faire référence à un membre du tuple.
Il y a deux façons d'utiliser le résultat de cette fonction :
-
en affectant le tuple à une variable :
Sélectionnezvar
t=
Tally
(
numbers);
Console.
WriteLine
(
$"Il y a
{t.
count}nombres dans la liste, et leur somme est
{t.
sum}."
);
- en décomposant le tuple directement en deux variables :
var
(
count,
sum) =
Tally
(
numbers);
Console.
WriteLine
(
$"Il y a
{count} nombres dans la liste, et leur somme est
{sum}."
);
La possibilité de décomposer les tuples en fait un outil extrêmement pratique. Par exemple, échanger les valeurs de deux variables devient trivial :
(
x,
y) =
(
y,
x)
Plus besoin de variable intermédiaire !
Les tuples reposent sur la structure System.ValueTuple<...>, qui ne fait pas encore partie du .NET Framework (elle devrait être ajoutée en 4.6.3). En attendant, pour utiliser cette fonctionnalité, il faut donc ajouter le package NuGet System.ValueTuple.
3. Déconstructeurs▲
Le mécanisme de décomposition mentionné plus haut pour les tuples n'est en fait pas limité aux tuples : n'importe quel type peut être décomposé de cette manière, s'il a une méthode Deconstruct avec la signature adéquate. Par exemple, pour décomposer un type Point en ses propriétés X et Y, on peut lui ajouter une méthode comme celle-ci :
public
void
Deconstruct
(
out
double
x,
out
double
y)
{
x =
this
.
X;
y =
this
.
Y;
}
Et il devient possible d'écrire :
Point p =
GetPosition
(
);
var
(
x,
y) =
p;
Notez que la méthode Deconstruct peut aussi être une méthode d'extension, ce qui permet de décomposer des types dont vous ne contrôlez pas le code source.
4. Pattern matching▲
Le pattern matching (qu'on peut traduire par « filtrage par motif » selon Wikipedia) est un mécanisme présent dans la plupart des langages fonctionnels, et qui permet de déterminer si une valeur correspond à certains cas prédéfinis. Dit comme ça, ça ressemble à un switch, et effectivement c'est un peu similaire, mais beaucoup plus puissant, puisqu'il permet de faire ce genre de choses (exemple en F#) :
let
describe shape =
match shape with
| Rectangle(width, height) ->
printfn "Rectangle with area %f"
(width *
height)
| Circle(r) when r <
5
->
printfn "Small circle"
| Circle(r) ->
printfn "Large circle"
| _ ->
printfn "Unknown shape"
Sans aller aussi loin pour l'instant, C# 7 introduit certains éléments de pattern matching, qui peuvent se retrouver sous deux formes :
-
l'une avec l'opérateur is :
Sélectionnezif
(
shapeis
Rectangle r) Console.
WriteLine
(
$"Rectangle with area
{r.
Width*
r.
Height}"
);
else
if
(
shapeis
Circle c&&
c.
Radius<
5
) Console.
WriteLine
(
"Small circle"
);
else
if
(
shapeis
Circle c) Console.
WriteLine
(
"Large circle"
);
-
Notez la variable introduite après le type dans la condition : si l'objet est du type spécifié, une nouvelle variable de ce type est introduite et peut être utilisée dans le corps du if. Cela évite d'avoir à faire un cast après avoir vérifié le type.
-
l'autre avec l'instruction switch :
Sélectionnezswitch
(
shape){
case
Rectangle r:
Console.
WriteLine
(
$"Rectangle with area
{r.
Width*
r.
Height}"
);
break
;
case
Circle cwhen
c.
Radius<
5
:
Console.
WriteLine
(
$"Small circle"
);
break
;
case
Circle c:
Console.
WriteLine
(
$"Large circle"
);
break
;
}
- Première chose qu'on remarque : on peut maintenant faire un switch sur le type de l'objet. Sinon, même principe que précédemment : chaque case introduit une variable du type correspondant, qui est utilisable dans le scope de ce case. De plus, notez la possibilité d'introduire une clause when pour indiquer une condition à satisfaire.
Tout ça ne semble pas très impressionnant à première vue, mais il faut bien comprendre que ce n'est qu'un premier pas, et que cette fonctionnalité est destinée à être améliorée et complétée dans les versions suivantes. En fait, une preview plus ancienne de Visual Studio « 15 » intégrait le support de patterns plus avancés, mais le design de cette fonctionnalité n'était pas encore assez mûr, et elle a donc été retirée.
Une conséquence de l'ajout du pattern matching à C# est que l'instruction switch a été améliorée. Jusqu'ici, elle était limitée aux types primitifs et enums, mais on peut maintenant l'utiliser sur des valeurs de n'importe quel type.
5. Variables out▲
L'utilisation de paramètres out a toujours été relativement peu pratique, du fait de devoir déclarer avant l'appel une variable qui va recevoir la valeur :
int
i;
if
(
int
.
TryParse
(
s,
out
i))
{
Console.
WriteLine
(
$"La chaine représente un entier de valeur
{i}"
);
}
C# 7 permettra de déclarer la variable directement dans l'appel, de la façon suivante :
if
(
int
.
TryParse
(
s,
out
int
i))
{
Console.
WriteLine
(
$"La chaine représente un entier de valeur
{i}"
);
}
Cette fonctionnalité avait initialement été prévue pour C# 6, mais avait finalement été repoussée du fait de divers problèmes de conception.
Il est également possible d'ignorer la valeur du paramètre out, si on n'en a pas besoin. Pour cela, il suffit d'utiliser la syntaxe out _ :
if
(
int
.
TryParse
(
s,
out
_))
{
Console.
WriteLine
(
"La chaine représente un entier"
);
}
Pas grand-chose de plus à dire sur cette fonctionnalité, si ce n'est qu'elle va permettre de rendre un peu plus agréable l'utilisation de paramètres out.
6. Amélioration des littéraux numériques▲
C# 7 apporte deux améliorations à l'écriture de littéraux numériques.
-
La notation binaire
Il était jusqu'ici possible d'écrire des nombres entiers en décimal ou hexadécimal, on peut maintenant les écrire aussi en binaire, ce qui est pratique pour certains cas d'usage (manipulation de bits par exemple). Il suffit pour cela de préfixer le nombre par 0b :Sélectionnezbyte
value
=
...;
byte
mask=
0b00000111
;
if
((
value
&
mask)!=
0
)...
- Les séparateurs de chiffres
On peut désormais inclure le caractère _ comme séparateur dans les littéraux numériques, pour améliorer la lisibilité :
byte
binary =
0b0001_1000
;
int
hex =
0xAB_BA
;
int
dec =
1_234_567_890
;
Ces améliorations avaient été envisagées pour C# 6, mais pour une raison que j'ignore, elles avaient été repoussées.
7. Membres dont le corps est une expression▲
C# 6 avait introduit une syntaxe simplifiée pour les méthodes et propriétés constituées d'une seule instruction :
public
double
Length =>
Math.
Sqrt
(
X*
X +
Y*
Y);
public
string
ToString
(
) =>
$"(
{X},
{Y})"
;
Mais cette syntaxe ne pouvait pas être appliquée aux constructeurs, finaliseurs et aux accesseurs de propriétés. C# 7 généralise cette syntaxe, on peut donc maintenant écrire des choses comme ça :
public
Person
(
string
name) =>
_name =
name;
~
Person
(
) =>
Console.
WriteLine
(
"Finalized"
);
public
string
Name
{
get
=>
_name;
set
=>
_name =
value
;
}
C'est sans doute d'une utilité assez limitée, mais ça a le mérite de rendre cette fonctionnalité plus cohérente.
8. Expressions throw▲
Jusqu'à maintenant, l'instruction throw qui permet de lancer une exception ne pouvait pas être utilisée dans un contexte où une expression était attendue. Par exemple, les extraits de code suivants étaient illégaux :
// expression lambda
Func<
int
>
f =
(
) =>
throw
new
Exception
(
);
// opérateur conditionnel
int
x =
a ==
42
?
1
:
throw
new
Exception
(
);
// méthode dont le corps est une expression
private
string
Foo
(
) =>
throw
new
Exception
(
);
Il fallait à la place écrire le code de cette façon :
// utiliser un bloc
Func<
int
>
f =
(
) =>
{
throw
new
Exception
(
);
};
// utiliser un if
int
x;
if
(
a ==
42
)
x =
1
;
else
throw
new
Exception
(
)
// utiliser une méthode classique
private
string
Foo
(
)
{
throw
new
Exception
(
);
}
En C# 7, throw devient une expression convertible en n'importe quel type. Cela permet par exemple de simplifier la validation des arguments dans un constructeur :
public
Person
(
string
firstName,
string
lastName)
{
FirstName =
firstName ??
throw
new
ArgumentNullException
(
nameof
(
firstName));
LastName =
lastName ??
throw
new
ArgumentNullException
(
nameof
(
lastName));
}
Au lieu de la forme habituelle un peu plus lourde :
public
Person
(
string
firstName,
string
lastName)
{
if
(
firstName ==
null
) throw
new
ArgumentNullException
(
nameof
(
firstName));
if
(
lastName ==
null
) throw
new
ArgumentNullException
(
nameof
(
lastName));
FirstName =
firstName;
LastName =
lastName;
}
9. Généralisation du type de retour des méthodes asynchrones▲
C# 5 avait introduit le support du code asynchrone directement dans le langage. Une méthode marquée async ne pouvait avoir comme type de retour que void, Task ou Task<T>. C# 7 permettra de définir des méthodes asynchrones renvoyant d'autres types, pour peu que ces types répondent à certaines caractéristiques.
À quoi ça sert ? Pour la plupart des gens, pas à grand-chose, mais quand on veut faire du code asynchrone très optimisé, le type Task<T> pose un problème. En effet, on est toujours obligé d'allouer une instance de Task<T> pour renvoyer un résultat, même dans les cas où la méthode se termine de façon synchrone. Or, Task<T> étant un type référence, cette allocation sur le tas a un coût non négligeable et donne plus de travail au garbage collector. L'équipe qui travaille sur .NET Core a donc mis au point un type ValueTask<T> qui peut représenter une valeur ou une tâche qui va renvoyer une valeur. ValueTask<T> est un type valeur, son allocation se fait donc sur la pile, évitant le coût d'une allocation sur le tas dans les chemins de code synchrones. Cette modification du langage va donc permettre d'écrire des méthodes asynchrones dont le type de retour est ValueTask<T>.
Voici un exemple de méthode asynchrone avec un chemin de code synchrone, qui peut bénéficier de cette amélioration :
static
async
ValueTask<
decimal
>
GetPriceAsync
(
int
productId)
{
if
(
_cache.
TryGetValue
(
productId,
out
decimal
price))
return
price;
price =
await
FetchPriceFromServerAsync
(
productId);
_cache[
productId]
=
price;
return
price;
}
Cette méthode renvoie une valeur depuis le cache si elle est disponible (chemin synchrone), et va la récupérer dans le cas contraire (chemin asynchrone). Dans le cas synchrone, aucune instance de Task n'est créée.
Implémenter correctement un type utilisable comme retour d'une méthode asynchrone sera relativement complexe (voir l'implémentation de ValueTask<T>, et celle des classes d'infrastructure nécessaires pour que ça fonctionne), il est donc probable que la plupart des utilisateurs ne créeront pas eux-mêmes de tels types. Mais on pourrait envisager que des frameworks en introduisent de nouveaux pour leurs besoins spécifiques, éventuellement sans lien avec l'asynchronisme, pour tirer parti du mécanisme de réécriture offert par async/await.
Note : si vous voulez tester cette fonctionnalité, vous pouvez récupérer le type ValueTask<T> dans le package NuGet System.Threading.Tasks.Extensions.
10. Variables locales et retours de fonctions par référence▲
Cette dernière nouveauté est assez avancée et s'adresse principalement aux développeurs qui cherchent à optimiser au maximum les performances de leur code, par exemple pour les jeux. En effet, les applications à haute performance font souvent un usage important des types valeurs (structures) pour tirer parti de leurs caractéristiques intéressantes en termes de gestion de la mémoire, notamment l'allocation sur la pile qui évite les coûts associés au garbage collector, et une meilleure localité des données (contiguïté des données dans un tableau). Mais ces avantages sont malheureusement contrebalancés par le fait que la manipulation des types valeurs implique de nombreuses copies de données, ce qui peut avoir un coût non négligeable, surtout pour des structures de taille importante. L'utilisation de code unsafe et de pointeurs permet de pallier ce problème, mais cela se fait au détriment de la sécurité du code, puisqu'on abandonne alors les garde-fous de la gestion automatique de la mémoire.
C# permet depuis toujours de passer des paramètres par référence plutôt que par valeur, grâce à l'utilisation du mot clé ref (et out, qui est en fait la même chose avec une sémantique un peu différente). C# 7 étend ce mécanisme aux variables locales et aux valeurs de retour des fonctions, évitant ainsi les copies inutiles de données dans certains scénarios.
Voici un petit exemple de ce que ça peut donner :
struct
MyBigStruct
{
public
int
Id {
get
;
set
;
}
public
string
Name {
get
;
set
;
}
}
static
MyBigStruct[]
_items;
static
ref
MyBigStruct FindItem
(
int
id)
{
for
(
int
i =
0
;
i <
_items.
Length;
i++
)
{
if
(
_items[
i].
Id ==
id)
{
return
ref
_items[
id];
}
}
throw
new
Exception
(
"Item not found"
);
}
static
void
Test
(
)
{
ref
MyBigStruct item =
ref
FindItem
(
42
);
item.
Name =
"test"
;
}
Dans la méthode Test, on récupère non pas une copie, mais une référence vers la structure, là où elle se trouve dans le tableau _items. Ainsi, quand on modifie la valeur de Name, c'est bien l'objet dans le tableau qui est modifié (c'est en fait le même comportement qu'on aurait eu si MyBigStruct avait été un type référence).
Cette nouvelle fonctionnalité peut d'ailleurs donner du code un peu surprenant ; en effet, le code suivant est parfaitement valide :
FindItem
(
42
) =
new
MyBigStruct
(
);
Ce code semble affecter une valeur à la méthode FindItem, ce qui n'aurait pas vraiment de sens ; en réalité, il écrit à l'emplacement mémoire renvoyé par la méthode.
Conclusion▲
C# 7 introduit donc une dizaine de nouveautés, relativement mineures dans l'ensemble, mais qui sont susceptibles de bien nous simplifier la vie. Certaines sont très spécifiques et ne seront pas directement utiles à la plupart des développeurs, mais permettront d'améliorer les performances de façon non négligeable dans certains scénarios.
Il est intéressant de noter que C# 7, en supposant que la version finale soit publiée début 2017, arrive seulement un an et demi après C# 6, ce qui est nettement plus court que les délais entre les précédentes versions. Il semblerait que Microsoft tende désormais vers des mises à jour plus petites et plus fréquentes du langage.
Et pour la suite ? On ne sait pas encore de quoi sera fait C# 8, mais on peut déjà commencer à spéculer d'après les échanges sur Github… Par exemple, le support des séquences asynchrones semble susciter pas mal d'intérêt. La programmation fonctionnelle reste également une source d'inspiration importante, avec par exemple l'extension du pattern matching (dont C# 7 pose les fondations), ou encore les types record.
Remerciements▲
Je tiens à remercier Hinault Romaric pour la relecture technique de cet article, ainsi que f-leb pour les corrections orthographiques et typographiques.