Optimizing C# Value Types

Posted on June 10, 2020 in dotnet

In a Twitter exchange with my friend James, we recently wondered how to properly document methods that return named value tuples. Why would we have to deal with methods returning value tuples? Well, consider this classic method:

public bool TryGetValue(TKey key, out TValue value) { ... }

Enters async. Ideally, we want:

public async ValueTask<bool> TryGetValue(TKey key, out TValue value) { ... }

But... async does support out parameters. And thus, the method logically becomes:

public async ValueTask<(bool, TValue)> TryGetValue(TKey key) { ... }

Which leads us to the original question: how would you document such a method?

/// <summary>Tries to get a value.</summary>
/// <param name="key">A key.</param>
/// <returns> ???? </returns>
public async ValueTask<(bool, TValue)> TryGetValue(TKey key) { ... }

The subject is not exactly new as Roslyn issue #13216 or CSharp issue #145 already explored more or less convoluted solutions in 2016. And somewhere in these issues, one can read:

"tuples really aren't well suited for public APIs or scenarios where you'd need or want to document them"

With the recommendation to use a record of some sort to return results. Which quite probably means returning a struct or even a class... and this is obviously going to cause some allocations, overhead etc.

Let us say, for the sake of the argument, that we refactor the method as:

/// <summary>Tries to get a value.</summary>
/// <param name="key">A key.</param>
/// <returns>A attempt at getting the value for the specified key.</returns>
public async ValueTask<Attempt<TValue>> TryGetValue(TKey key) { ... }

With Attempt<T> defined as:

public struct Attempt<T>
{
    public bool Success { get; set; }
    public T Value { get; set; }
}

So now, we are going to allocate an instance of Attempt<T> each time we return, and then use the property getters (i.e. invoke a method) to figure out whether the attempt was successful, and retrieve the value it carries.

How bad is it? Just to be sure, I have used SharpLab to show me the generated IL and assembly code for the following methods:

public void InvokeWithTuple()
{
    var (success, value) = TryGetValueWithTuple("foo");
    if (success) Console.WriteLine(value);
}

public void InvokeWithAttempt()
{
    var attempt = TryGetValueWithAttempt("foo");
    if (attempt.Success) Console.WriteLine(attempt.Value);
}

This is the assembly code generated for the tuple version (in Release mode):

L0000: push ebp
L0001: mov ebp, esp
L0003: sub esp, 8
L0006: xor eax, eax
L0008: mov [ebp-8], eax
L000b: mov [ebp-4], eax
L000e: push 1
L0010: lea edx, [ebp-8]
L0013: call C.TryGetValueWithTuple(Boolean)
L0018: mov ecx, [ebp-4]
L001b: movzx ecx, cl
L001e: mov eax, [ebp-8]
L0021: test ecx, ecx
L0023: je short L002c
L0025: mov ecx, eax
L0027: call System.Console.WriteLine(System.String)
L002c: mov esp, ebp
L002e: pop ebp
L002f: ret

And now, the assembly code generated for the Attempt version:

L0000: push ebp
L0001: mov ebp, esp
L0003: sub esp, 8
L0006: xor eax, eax
L0008: mov [ebp-8], eax
L000b: mov [ebp-4], eax
L000e: push 1
L0010: lea edx, [ebp-8]
L0013: call C.TryGetValueWithAttempt(Boolean)
L0018: cmp byte ptr [ebp-4], 0
L001c: je short L0026
L001e: mov ecx, [ebp-8]
L0021: call System.Console.WriteLine(System.String)
L0026: mov esp, ebp
L0028: pop ebp
L0029: ret

Can you spot the difference? Me neither. But... let us say you are quite fond of the var (success, value) = syntax. Can we still have it with attempts? Yes, with deconstruction:

public struct Attempt<T>
{
    public bool Success { get; set; }
    public T Value { get; set; }

    public void Deconstruct(out bool success, out T value)
    {
        success = Success;
        value = Value;
    }
}

And then, this is valid code:

var (success, value) = TryGetValueWithAttempt("foo");

But this involves deconstruction... it has to be more expensive, right? I will spare you the assembly code, it is exactly the same. The C# compiler and CLR jitter figure the deconstruction out for you.

There is one lesson here: the C# compiler and the CLR jitter are quite clever, and you probably want to understand how they work before implementing pointless optimizations. When in doubt, just write clean code.

There used to be Disqus-powered comments here. They got very little engagement, and I am not a big fan of Disqus. So, comments are gone. If you want to discuss this article, your best bet is to ping me on Mastodon.