I. Introduction▲
Après une visite rapide de valeurs de retour de type scalaire avec Count et LongCount, nous sommes de retour avec un opérateur qui retourne une séquence : Concat.
II. Qu'est-ce ?▲
Concat n'a qu'une signature, ce qui rend la vie plus simple :
public
static
IEnumerable<
TSource>
Concat<
TSource>(
this
IEnumerable<
TSource>
first,
IEnumerable<
TSource>
second)
La valeur de retour est simplement une séquence contenant les éléments de la première séquence, suivi des éléments de la seconde séquence - la concaténation des deux séquences.
Il m'arrive parfois de penser que l'absence de méthodes « Prepend » / « Append », qui se comporteraient de la même manière mais avec un seul élément, est pénible - cela serait bien pratique en effet dans une situation telle qu'une liste déroulante de pays avec une option supplémentaire du genre « Aucun ». C'est assez simple d'utiliser Concat pour y arriver, en créant un tableau d'un seul élément, mais il me semble que des méthodes spécifiques seraient plus lisibles. MoreLINQ dispose de méthodes Concat supplémentaires prévues à cet effet, mais Edulinq se concentre uniquement sur les méthodes existantes de LINQ to Objects.
Comme d'habitude, quelques remarques sur le comportement de Concat :
- les arguments sont validés immédiatement : ils doivent tous deux être non-nuls ;
- le résultat utilise l'exécution différée : si ce n'est pour la validation, les arguments ne sont pas utilisés lors de l'appel initial ;
- chaque séquence est évaluée à la demande… si vous arrêtez l'itération de la séquence de sortie avant que la première séquence soit épuisée, la seconde séquence restera inutilisée.
C'est là l'essentiel.
III. Qu'allons-nous tester ?▲
La partie concaténation du comportement est très simple à tester en un seul exemple - nous pourrions également montrer la concaténation en utilisant des séquences vides, mais il n'y a aucune raison de supposer que cela échouerait.
La validation des arguments est testée en procédant de la manière habituelle, en appelant la méthode avec des arguments invalides mais sans essayer d'utiliser la requête résultante.
Pour finir, il y a quelques tests pour indiquer le moment où chaque séquence est utilisée. On y parvient en utilisant le type ThrowingEnumerable que nous avons déjà utilisé pour les tests de l'opérateur Where :
[Test]
public
void
FirstSequenceIsntAccessedBeforeFirstUse
(
)
{
IEnumerable<
int
>
first =
new
ThrowingEnumerable
(
);
IEnumerable<
int
>
second =
new
int
[]
{
5
};
// No exception yet...
var
query =
first.
Concat
(
second);
// Still no exception...
using
(
var
iterator =
query.
GetEnumerator
(
))
{
// Now it will go bang
Assert.
Throws<
InvalidOperationException>((
) =>
iterator.
MoveNext
(
));
}
}
[Test]
public
void
SecondSequenceIsntAccessedBeforeFirstUse
(
)
{
IEnumerable<
int
>
first =
new
int
[]
{
5
};
IEnumerable<
int
>
second =
new
ThrowingEnumerable
(
);
// No exception yet...
var
query =
first.
Concat
(
second);
// Still no exception...
using
(
var
iterator =
query.
GetEnumerator
(
))
{
// First element is fine...
Assert.
IsTrue
(
iterator.
MoveNext
(
));
Assert.
AreEqual
(
5
,
iterator.
Current);
// Now it will go bang, as we move into the second sequence
Assert.
Throws<
InvalidOperationException>((
) =>
iterator.
MoveNext
(
));
}
}
Je n'ai pas écrit de tests pour vérifier que les itérateurs étaient libérés, etc. mais les itérateurs de chacune des séquences devraient l'être normalement. À noter qu'il est même normal que l'itérateur de la première séquence soit libéré avant même que la seconde séquence soit parcourue.
IV. Voyons l'implémentation !▲
L'implémentation est relativement simple, mais cela me fait envier F#… Nous avons la séparation habituelle entre validation des arguments et implémentation du bloc itérateur et chaque partie est vraiment simple :
public
static
IEnumerable<
TSource>
Concat<
TSource>(
this
IEnumerable<
TSource>
first,
IEnumerable<
TSource>
second)
{
if
(
first ==
null
)
{
throw
new
ArgumentNullException
(
"first"
);
}
if
(
second ==
null
)
{
throw
new
ArgumentNullException
(
"second"
);
}
return
ConcatImpl
(
first,
second);
}
private
static
IEnumerable<
TSource>
ConcatImpl<
TSource>(
IEnumerable<
TSource>
first,
IEnumerable<
TSource>
second)
{
foreach
(
TSource item in
first)
{
yield
return
item;
}
foreach
(
TSource item in
second)
{
yield
return
item;
}
}
Cela vaut la peine ici de se rappeler combien cela aurait été ennuyeux à mettre en œuvre sans bloc itérateur. Pas vraiment difficile en tant que tel, mais nous aurions dû retenir quelle séquence nous étions en train de parcourir (s'il y en avait effectivement une) et ainsi de suite.
Cependant, en utilisant F#, nous aurions pu rendre cela encore plus simple avec l'expression yield!, qui produit une séquence entière plutôt qu'un seul élément. Certes, dans ce cas il n'y a pas d'amélioration significative des performances en utilisant yield! (comme il pourrait certainement y en avoir dans des situations récursives) mais cela serait plus élégant si on avait la possibilité de produire une séquence entière en une seule instruction. (Spec# dispose d'une construction similaire appelée itérateurs imbriqués, exprimée en utilisant yield foreach). Je ne prétends pas que j'en connais assez à propos des détails de F# ou Spec# pour établir une comparaison détaillée mais nous reparlerons plusieurs fois du modèle « pour chaque élément de la collection, produire l'élément » avant d'en avoir fini. Souvenons-nous que nous ne pouvons pas extraire cela dans une méthode de librairie car l'expression « yield » nécessite un traitement particulier de la part du compilateur C#.
V. Conclusion▲
Même lorsque c'est présenté avec une implémentation simple, je peux encore trouver le moyen de ronchonner :). Ce serait bien d'avoir les itérateurs imbriqués en C#, mais pour être honnête, le nombre de fois où je suis frustré par leur absence est assez faible.
Concat est un opérateur utile mais, finalement, il s'agit simplement de la spécialisation d'un autre opérateur : SelectMany. Après tout, Concat ne fait qu'aplatir deux séquences en une… alors que SelectMany est capable d'aplatir une séquence entière de séquences, avec même plus de possibilités encore si nécessaire. J'implémenterai SelectMany prochainement et vous montrerai comment d'autres opérateurs peuvent être implémentés simplement en termes de SelectMany. (Nous verrons la même capacité pour des opérateurs retournant des valeurs simples lorsque nous implémenterons l'opérateur Aggregate).
VI. Addendum : Éviter de conserver des références quand ce n'est pas nécessaire.▲
Une suggestion reçue en commentaire stipulait que nous devrions définir « first » à null une fois utilisé. De cette façon, dès que nous avons fini de parcourir la collection, elle peut être éligible pour être collectée par le ramasse-miettes. Cela conduit à une implémentation comme celle-ci :
private
static
IEnumerable<
TSource>
ConcatImpl<
TSource>(
IEnumerable<
TSource>
first,
IEnumerable<
TSource>
second)
{
foreach
(
TSource item in
first)
{
yield
return
item;
}
// Avoid hanging onto a reference we don't really need
first =
null
;
foreach
(
TSource item in
second)
{
yield
return
item;
}
}
Ici, en temps normal, j'aurais dit que ça n'aurait aidé en rien - définir une variable locale à null quand elle n'est pas utilisée dans le reste de la méthode ne change rien lorsque la CLR fonctionne en mode optimisé, sans débogueur attaché : en effet, le ramasse-miettes se préoccupe uniquement des variables qui peuvent toujours être accédées dans le reste de la méthode.
Dans ce cas, cependant, cela fait une différence car ce n'est pas une variable locale normale. Elle termine en variable d'instance dans la classe cachée générée par le compilateur C#… et la CLR n'est pas capable de dire si une variable d'instance sera encore utilisée ou non dans le futur.
Nous pourrions supprimer notre seule référence à « first » au début de GetEnumerator. Nous pourrions écrire une méthode comme celle-ci :
public
static
T ReturnAndSetToNull<
T>(
ref
T value
) where
T :
class
{
T tmp =
value
;
value
=
null
;
return
tmp;
}
et l'appeler comme ceci :
foreach
(
TSource item in
ReturnAndSetToNull
(
ref
first))
Je considérerais ça comme excessif, particulièrement parce qu'il est fort probable que l'itérateur conserve une référence à la collection elle-même, mais simplement définir « first » à null juste après l'avoir parcouru à du sens selon moi.
Je ne crois pas que la « vraie » implémentation de LINQ to Objects le fasse, qu'en pensez-vous ? (À un certain moment je le testerai avec une collection qui dispose d'un destructeur).
VII. Remerciements▲
Merci à Jon Skeet de m'avoir autorisé à traduire son article Reimplementing LINQ to Objects : Part 8 - Concat
Merci à Thomas Levesque (tomlev) pour ses conseils avisés et ses corrections toujours bienvenues.
Merci à Torgar pour ses corrections et sa rigueur orthographique.