C# 10 was released in November of 2021, and it came with a host of new features. Some of the features that you may have heard of include file-scoped namespaces, global usings, target-type new expressions, record improvements, and many more. Several new extension methods have been added to LINQ as well including MaxBy
, MinBy
, DistinctBy
, IntersectBy
, ExceptBy
, and UnionBy
.
In this post, we will take a look at the aforementioned new C# 10 features and how they work.
The Data
In order to use LINQ features, you must have some data. The data set that we will use in our demonstration will likely be familiar to you. We will use some of the iconic characters from A New Hope and Empire Strikes Back in the upcoming examples.
public class Character { public int Id {get;set;} public string Name {get;set;} public int Age {get;set;} } List<Character> newHopeCharacters = new() { new() { Id = 1, Name = "Luke Skywalker", Age = 19 }, new() { Id = 2, Name = "Leia Organa", Age = 19 }, new() { Id = 3, Name = "Han Solo", Age = 32 }, new() { Id = 4, Name = "Darth Vader", Age = 45 }, }; List<Character> empireStrikesBackCharacters = new() { new() { Id = 1, Name = "Luke Skywalker", Age = 19 }, new() { Id = 2, Name = "Leia Organa", Age = 19 }, new() { Id = 3, Name = "Han Solo", Age = 32 }, new() { Id = 4, Name = "Darth Vader", Age = 45 }, new() { Id = 5, Name = "Emperor Palpatine", Age = 88 }, new() { Id = 6, Name = "Boba Fett", Age = 35 } };
New LINQ Methods
MaxBy
LINQ has always had a Max
method that returns the Max value specified in the lambda expression.
var maxAge = newHopeCharacters.Max(character => character.Age); Console.WriteLine(maxAge); //45
Let’s try the same thing with MaxBy
and see what the results are.
var maxByAge = newHopeCharacters.MaxBy(character => character.Age); Console.WriteLine(maxAge); //Character
In the example above, maxByAge
is a Character
object. Is that what you expected? Perhaps not. We don’t know which character is being returned yet, so let’s edit the Character
class so that we can see which character is being returned.
We’ll override the ToString()
method on Character
to display the Id, Name, and Age so that we can see who is being returned in the methods above.
public class Character { public int Id {get;set;} public string Name {get;set;} public int Age {get;set;} public override string ToString() { return $"Id: {Id}\r\nName: {Name}\r\nAge: {Age}"; } }
If we try running that code again, we can see who the characters are.
var maxByAge = newHopeCharacters.MaxBy(character => character.Age); Console.WriteLine(maxAge); //Id 4 //Name Darth Vader //Age 45
MinBy
LINQ also has had a Min
function for a long time. It returns the minimum value specified by the lambda function.
var minAge = newHopeCharacters.Min(character => character.Age); Console.WriteLine(minAge); //19
We learned previously that MaxBy
returns an item and not just a value. In the previous examples, the item is a Character
. Now if we try to run this MinBy
on our data set you might realize that there are two characters that share the same age. What do you expect MinBy
to do? Let’s find out.
var minByAge = newHopeCharacters.MinBy(character => character.Age); Console.WriteLine(maxAge); //Id 1 //Name Luke Skywalker //Age 19
After running the queries, you notice that Luke Skywalker is the only character returned. Do you know why? It’s because he was the first item in the list to match the lambda functions MinBy Age
expression. If Leia were first she would have been the character that was returned.
MinBy
and MaxBy
are scalar methods, meaning they return only one result.
DistinctBy
You may know there is a Distinct
function in LINQ already. It finds distinct items in an IEnumerable
of items using IEquatable
or an IEqualityComparer
. We don’t yet have an IEquatable implementation of Character
so let’s add that now so we can get Distinct
to work properly.
public class Character : IEquatable<Character> { public int Id {get;set;} public string Name {get;set;} public int Age {get;set;} public override string ToString() { return $"Id: {Id}\r\nName: {Name}\r\nAge: {Age}"; } public bool Equals(Character? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; return Id == other.Id && Name == other.Name && Age == other.Age; } public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; return Equals((Character) obj); } public override int GetHashCode() { return HashCode.Combine(Id, Name, Age); } }
Now that we have implemented IEquatable
, we can see what the distinct method will do.
var distinctCharacters = newHopeCharacters.Distinct(); distinctCharacters.ToList().ForEach(Console.WriteLine); //print the characters /* Id: 1 Name: Luke Skywalker Age: 19 Id: 2 Name: Leia Organa Age: 19 Id: 3 Name: Han Solo Age: 32 Id: 4 Name: Darth Vader Age: 45 */
As you can see, each of the characters is unique in each group. Also, you may have noticed that you can’t pass a lambda function to Distinct
.
That’s where the DistinctBy
comes in. DistinctBy
allows you to make items distinct according to a lambda expression. Let’s try that again using the character’s age.
var distinctCharacters = newHopeCharacters.DistinctBy(character => character.Age); distinctCharacters.ToList().ForEach(Console.WriteLine); /* Id: 1 Name: Luke Skywalker Age: 19 Id: 3 Name: Han Solo Age: 32 Id: 4 Name: Darth Vader Age: 45 */
Do you see what has changed? One character is missing. Leia Organa and Luke Skywalker are the same age, and we wanted distinct results by age, so only the first character was chosen between the two.
IntersectBy
Intersect
takes two IEnumerables
and finds the common values between them and ignores the rest. Let’s look at an example of intersecting two sets of characters.
var intersection = newHopeCharacters.Intersect(empireStrikesBackCharacters); intersection.ToList().ForEach(Console.WriteLine); //print the charaters /* Id: 1 Name: Luke Skywalker Age: 19 Id: 2 Name: Leia Organa Age: 19 Id: 3 Name: Han Solo Age: 32 Id: 4 Name: Darth Vader Age: 45 */
Just like Distinct
, Intersect
does not take a lambda expression. It uses the IEquatable
interface to determine the equality of the items in each list. IntersectBy
, like DistincyBy
, does allow you to pass a lambda expression to tell Intersect
how to determine how to compare items.
As you can see, its second parameter expects IEnumerable
meaning a set of keys for which to evaluate the IEnumerable
against. The keySelector
is where you would pass the lambda expressing which value you want to match against the IEnumerable
.
That’s a lot to take in! Let’s see an example.
In this example, we want to find all characters in both A New Hope and Empire Strikes back using IntersectBy
.
//IEnumerable<TSource> IntersectBy<TSource,TKey> (this IEnumerable<TSource> first, IEnumerable<TKey> second, // Func<TSource,TKey> keySelector, IEqualityComparer<TKey>? comparer); var intersection = newHopeCharacters.IntersectBy(empireStrikesBackCharacters.Select(character => character.Id), character => character.Id); intersection.ToList().ForEach(Console.WriteLine); //print the charaters /* Id: 1 Name: Luke Skywalker Age: 19 Id: 2 Name: Leia Organa Age: 19 Id: 3 Name: Han Solo Age: 32 Id: 4 Name: Darth Vader Age: 45 */
This happens to be the same result as just using Intersect
on the two movie character lists. The benefit of IntersectBy
is that your IEnumerable
can come from any source instead of being limited to a source of the same type as the IEnumerable
source.
ExceptBy
Except
has the opposite behavior of Intersect
. It finds items that don’t appear in both sources, as opposed to finding items that appear in both sources with Intersect
.
var exceptions = empireStrikesBackCharacters.Except(newHopeCharacters); exceptions.ToList().ForEach(Console.WriteLine); //print the charaters /* Id: 5 Name: Emperor Palpatine Age: 88 Id: 6 Name: Boba Fett Age: 35 */
Emperor Palpatine and Boba Fett were not in A New Hope, but Luke, Han, Leia, and Darth Vader were. Similar to IntersectBy
, there is an ExceptBy
that behaves probably as you would expect. Let’s take a look at how to do this with ExceptBy
.
//IEnumerable<TSource> ExceptBy<TSource,TKey> (this IEnumerable<TSource> first, IEnumerable<TKey> second, Func<TSource,TKey> keySelector, IEqualityComparer<TKey>? comparer); var exceptions = empireStrikesBackCharacters.ExceptBy(newHopeCharacters.Select(character => character.Id), character => character.Id); exceptions.ToList().ForEach(Console.WriteLine); //print the charaters /* Id: 5 Name: Emperor Palpatine Age: 88 Id: 6 Name: Boba Fett Age: 35 */
In this case, we are using the set of Ids from the characters in newHopeCharacters
as the exception, as opposed to the characters themselves. ExceptBy
, like IntersectBy
, gives you the flexibility of using different sources for the key comparison.
UnionBy
Union
is used to create a single group of distinct items from two groups of items of the same type. Like many of the other methods, Union
does not take a lambda expression.
//IEnumerable<TSource> Union<TSource>(IEnumerable<TSource>, IEnumerable<TSource>) var union = empireStrikesBackCharacters.Union(newHopeCharacters); union.ToList().ForEach(Console.WriteLine); //print the charaters /* Id: 1 Name: Luke Skywalker Age: 19 Id: 2 Name: Leia Organa Age: 19 Id: 3 Name: Han Solo Age: 32 Id: 4 Name: Darth Vader Age: 45 Id: 5 Name: Emperor Palpatine Age: 88 Id: 6 Name: Boba Fett Age: 35 */
UnionBy
does allow you to pass a lambda expression to choose what to union by. Let’s try to union the characters from the two movies by their age. What would you expect the output to be?
//IEnumerable<TSource> UnionBy<TSource,TKey>(IEnumerable<TSource>, IEnumerable<TSource>, Func<TSource,TKey>) var union = empireStrikesBackCharacters.UnionBy(newHopeCharacters, character => character.Age); union.ToList().ForEach(Console.WriteLine); //print the charaters /* Id: 1 Name: Luke Skywalker Age: 19 Id: 3 Name: Han Solo Age: 32 Id: 4 Name: Darth Vader Age: 45 Id: 5 Name: Emperor Palpatine Age: 88 Id: 6 Name: Boba Fett Age: 35 */
As you can see once again, Leia is missing. Do you know why? Union
creates a distinct set of items in the union, and the key in the set that we specified was Age
. Since the key was Age
, the Union
assumes any other values with the same Age
are duplicates. If we used Id, which is the real Key
of Character
, we would have seen the same values as the previous Union
example.
Conclusion
C# 10 has added several new features/useful LINQ methods the give us a bit more flexibility when comparing datasets. I hope this post gave you a brief overview of those.
Good luck, and may the force be with you!
Side note: If getting paid to write blogs in your free time is something you’d enjoy, check out our Careers page. It’s an exciting time to join the Keyhole team, and we may just have a spot for you!