A nice improvement in .NET is the introduction of LINQ, in .NET 4.5. With that, working with data was simplified a lot and, when I go to a language that doesn’t have something like it, I feel lost (having to deal with for and foreach became painful for me 😃.
The features available in LINQ made my code more synthetic and readable, but sometimes, there was something that wasn’t easily attained with the default features. Microsoft heard that and introduced new features, many of them I was expecting since a long time. These new features came with no huge announcements, but, nevertheless, they are very nice improvements.
Index and Range parameters
Index and Ranges were introduced in C#8, they can ease a lot when you must get a subrange of an array or list:
var arr = Enumerable.Range(1,100).ToArray();
Console.WriteLine($"[{string.Join(",",arr[10..15])}]");
Console.WriteLine(arr[^1]);
var list = new List<int>(arr);
Console.WriteLine(list[^1]);
Console.WriteLine(list[^5]);
When you run this code, you will get something like this:
[11,12,13,14,15]
100
100
96
This is nice, but when you wanted to work with Linq, you were not able to use indexes and ranges. Until now. .NET 6 allows using Ranges and Indices in Linq queries. This is very nice, because when you wanted a subrange of an IEnumerable, you should do something like:
list.Skip(10).Take(5)
Now, you can do the same with:
list.Take(10..15)
Or take the last 10 elements with
list.Take(^10..)
You can also take a single element with ElementAt using Indices. To get the last element in the list, you can use:
list.ElementAt(^1)
Chunking
One thing that is very common is to divide our data in chunks, in order to present it in pieces, so the user does not have to scroll long lists of information. Until now, you had to program that by hand, which could lead to errors. With the new Chunk method, you can split your data in chunks. For example, if you want to split the data in blocks of seven elements, you can do:
var chunked = list.Chunk(7);
With this code, you will obtain something like
[1,2,3,4,5,6,7]
[8,9,10,11,12,13,14]
[15,16,17,18,19,20,21]
[22,23,24,25,26,27,28]
[29,30,31,32,33,34,35]
[36,37,38,39,40,41,42]
[43,44,45,46,47,48,49]
[50,51,52,53,54,55,56]
[57,58,59,60,61,62,63]
[64,65,66,67,68,69,70]
[71,72,73,74,75,76,77]
[78,79,80,81,82,83,84]
[85,86,87,88,89,90,91]
[92,93,94,95,96,97,98]
[99,100]
And you can get one chunk with
chunked.ElementAt(3)
chunked.ElementAt(^1)
Zipping
Sometimes you want to combine three Enumerables into one. Combining Enumerables is done using the Zip method, which allowed to combine only two items at once. .NET 6 introduced the possibility of zipping three sequences at once (if you want more sequences, you must chain Zip functions). For example, if you have these three enumerables:
var list1 = Enumerable.Range(1,100).Select(i => $"ID {i}").ToList();
var list2 = Enumerable.Range(1,100).Select(i => $"Name {i}").ToList();
var list3 = Enumerable.Range(1,100).Select(i => $"Address {i}").ToList();
You can combine it into an IEnumerable of tuples with three elements each with:
var zipped = list1.Zip(list2,list3);
One note, here: in .NET 5 you could use a function to zip two sequences int another one and generate anything else than a tuple. .NET 6 didn’t change that and, if you want to zip the three IEnumerables into an IEnumerable of a class, for example, you must still do something like this:
var zipped1 = list1.Zip(list2, (l1, l2) => new { ID = l1, Name = l2 })
.Zip(list3, (l1, l2) => new { ID = l1.ID, Name = l1.Name, Address = l2 });
DistinctBy, ExceptBy, UnionBy, InterceptBy
One thing that I use a lot is the distinct operator, to get unique values in a sequence. Until now, when I had a class and wanted to get distinct values in a class by some field, and I’m not interested in the other fields, I had to do something like:
public record Person(string Name, int Age);
var people = new List
{
new Person("John", 30 ),
new Person("Peter", 40),
new Person("Mary", 20 ),
new Person("Jane", 30 ),
new Person("Larry", 50),
new Person("Anne", 50 ),
new Person("Paul", 20),
};
var distinctByAge = people.GroupBy(p => p.Age).Select(g => g.Key);
That worked fine, but lacked clarity – the intent was not explicit and it was hard to understand – Why this GroupBy is there?
In .NET 6, the DistinctBy comes to solve that. Now, you can use something like this to get the distinct values :
var distinctAges = people.DistinctBy(p => p.Age).Select(p => p.Age);
Now the intent is clear and the code is easier to follow.
You can also use ExceptBy, to filter a sequence depending on another, like in
var excludedAges = new List<int> {30,40};
var people1 = people.ExceptBy(excludedAges, p => p.Age);
One note, here. Due to the way ExceptBy is coded (it uses a HashSet), it will only add the first duplicate element in the result. In our code, it should show:
Person { Name = Mary, Age = 20 }
Person { Name = Larry, Age = 50 }
Person { Name = Anne, Age = 50 }
Person { Name = Paul, Age = 20 }
But it only shows:
Person { Name = Mary, Age = 20 }
Person { Name = Larry, Age = 50 }
If you want all items that don’t match the excluded ages, you should still go with:
var people2 = people.Where(p => !excludedAges.Contains(p.Age));
If you want to join two sequences, removing duplicates between them, you can use the UnionBy method. This code joins the two lists into another, removing the duplicates:
var people3 = new List<Person>
{
new Person("John", 20 ),
new Person("Peter", 25),
new Person("Paul", 20 ),
new Person("Ringo", 22 ),
new Person("George", 23),
new Person("Anne", 50 ),
new Person("Mark", 20),
};
var people4 = people.UnionBy(people3, p => p.Name);
If you want the have the names present in both lists, you can use the IntersectBy method:
var includedAges = new List<int> {30,40};
var people5 = people.IntersectBy(includedAges, p => p.Age);
In the same way of the ExceptBy, the duplicates are not included. If you want to include them, you should use:
var people6 = people.Where(p => includedAges.Contains(p.Age));
MaxBy and MinBy
When using the methods Max and Min, the sequences should implement the IComparable interface, so they could be compared and the maximum and minimum evaluated. That posed a problem, especially if the class you wanted to compare didn’t implement the IComparable interface. Now, with MinBy and MaxBy you don’t have to use the IComparable and can use something like:
var minByAge = people.MinBy(p => p.Age);
var maxByAge = people.MaxBy(p => p.Age);
This code won’t show all the elements with minimum age. To get that, you should use something like
var minAge = people.Select(p => p.Age).Min();
var allMinByAge = people.Where(p => p.Age == minAge);
var maxAge = people.Select(p => p.Age).Max();
var allMaxByAge = people.Where(p => p.Age == maxAge);
FirstOrDefault, LastOrDefault, SingleOrDefault with a default parameter
These three functions returned Default(T) if the element was not found or the list was empty. This could pose a problem or extra checks if the element was not found. Now, we can set a default value when the element is not found and, in this case, we don’t have to deal with null checks:
var firstOrDefault = people.FirstOrDefault(p => p.Age == 25,new Person("Unknown",25));
var lastOrDefault = people.LastOrDefault(p => p.Age == 25,new Person("Unknown",25));
var singleOrDefault = people.SingleOrDefault(p => p.Age == 25,new Person("Unknown",25));
In all the three cases, the code will return a Person with name Unknown and Age = 25
TryGetNonEnumeratedCount
When you have an IEnumerable and you use the Count() method, it will enumerate the collection, even if it has another method to get the count in another way, thus penalizing the performance. For that, .NET 6 implemented the TryGetNonEnumeratedCount method to try to use another method to get the count, if available. This function will return true if a faster method was available, or false, if not. That way, you can take an action to use something more performant and avoid multiple enumerations. For example:
IEnumerable<Person> people7 = people;
Console.WriteLine(people7.TryGetNonEnumeratedCount(out int count));
Will return true, because The List implements the Count property to get the count. When you have an IEnumerable, result of a Linq operation, like in
var people6 = people.Where(p => includedAges.Contains(p.Age));
Console.WriteLine(people6.TryGetNonEnumeratedCount(out int count1));
It will return false and the count1 variable will have the actual count of the sequence.
Conclusion
As you can see, there are several improvements to Linq in .NET 6. They were not huge improvements, but brought some ease to the development. I’m sure that I will use them a lot.
The sample code for this article is at https://github.com/bsonnino/LinqImprovements