C# 10 new features

LINQ Improvements in C# 10

Adam Wright C#, Development Technologies, Programming Leave a Comment

Attention: The following article was published over 2 years ago, and the information provided may be aged or outdated. Please keep that in mind as you read the post.

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!

5 1 vote
Article Rating
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments