Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

List<T>.ForEach does not invoke the action HashCode.Add<T>

In C#, using the structure HashCode and the List<T>.ForEach method shows a behaviour that I cannot explain:

The two snippets below do not return the same result:

  • Working as expected:
var hashCodeLambda = new HashCode();
strings.ForEach(str => hashCodeLambda.Add(str));
  • Not working:
var hashCodeDelegate = new HashCode();
strings.ForEach(hashCodeDelegate.Add);

Does anyone knows why?

From the IDE's perspective, the two invocations seems to be invoking the generic HashCode.Add<T> method.

Here is a piece of code that highlights the issue:

List<string> strings = ["test1", "test2"];

var hashCodeLambda = new HashCode();
var hashCodeLoop = new HashCode();

strings.ForEach(str => hashCodeLambda.Add(str));
foreach (string str in strings)
{
    hashCodeLoop.Add(str);
}
Console.WriteLine($"Expected: {hashCodeLambda.ToHashCode()} / {hashCodeLoop.ToHashCode()}");

var hashCodeDelegate = new HashCode();
strings.ForEach(hashCodeDelegate.Add);
Console.WriteLine($"Unexpected: {hashCodeDelegate.ToHashCode()}");
Console.WriteLine($"Empty hashcode: {new HashCode().ToHashCode()}");

Example output:

Expected: -952326020 / -952326020
Unexpected: 2034223959
Empty hashcode: 2034223959
like image 270
Julian Avatar asked Dec 07 '25 04:12

Julian


1 Answers

The problem is that HashCode is a mutable struct - and that means when you perform a method group conversion with that as the target (i.e. the hashCodeDelegate.Add code), a boxing conversion occurs.

Before further explanation, let's get generics and LINQ out of the way, and reproduce it with a simple struct of our own creation:

class Test
{
    static void Main()
    {
        Console.WriteLine("s1");
        var s1 = new TestStruct();
        s1.Increment();
        s1.Increment();
        s1.Increment();
        Console.WriteLine($"Final value: {s1.Value}");
        Console.WriteLine();

        Console.WriteLine("s2");
        var s2 = new TestStruct();
        Action action = s2.Increment;
        action();
        action();
        action();
        Console.WriteLine($"Final value: {s2.Value}");
    }
}

struct TestStruct
{
    public int Value;

    public void Increment()
    {
        int oldValue = Value;
        Value++;
        Console.WriteLine($"Was {oldValue}; now {Value}");
    }
}

The output of this is:

s1
Was 0; now 1
Was 1; now 2
Was 2; now 3
Final value: 3

s2
Was 0; now 1
Was 1; now 2
Was 2; now 3
Final value: 0

Note how the code using s2 is incrementing something... but s2.Value is still 0 at the end.

The draft C# 8 spec section on method conversions states:

If the method selected at compile-time is an instance method, or it is an extension method which is accessed as an instance method, the target object of the delegate is determined from the instance expression associated with E:

  • The instance expression is evaluated. If this evaluation causes an exception, no further steps are executed.
  • If the instance expression is of a reference_type, the value computed by the instance expression becomes the target object. If the selected method is an instance method and the target object is null, a System.NullReferenceException is thrown and no further steps are executed.
  • If the instance expression is of a value_type, a boxing operation (§10.2.9) is performed to convert the value to an object, and this object becomes the target object.

In this case, the instance expression is of value type, so the initial value of s2 is copied via boxing into an object, then the delegate operates on the boxed value... which is entirely separate from the original variable s2.

To put it another way, your code of this:

var hashCodeDelegate = new HashCode();
strings.ForEach(hashCodeDelegate.Add);

... is roughly equivalent to:

var hashCodeDelegate = new HashCode();
object boxedHashCodeDelegate = hashCodeDelegate;
// Obvious object doesn't have an Add method, but you see what I mean...
strings.ForEach(boxedHashCodeDelegate.Add);

At that point it should be reasonably clear why hashCodeDelegate has the initial value - you haven't operated on that variable at all after boxing its value.

When you use a lambda expression, the lambda captures the variable - so when you call a method which mutates that variable, the change is still visible after the lambda expression has completed (and therefore after the ForEach loop is completed as well).

like image 99
Jon Skeet Avatar answered Dec 08 '25 18:12

Jon Skeet