Why does the C# 7 discard identifier _ still work in a using block?
Asked Answered
B

2

8

So, a pattern I use very often while working on my UWP app is to use a SemaphoreSlim instance to avoid race conditions (I prefer not to use lock as it needs an additional target object, and it doesn't lock asynchronously).

A typical snippet would look like this:

private readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1);

public async Task FooAsync()
{
    await Semaphore.WaitAsync();
    // Do stuff here
    Semaphore.Release();
}

With the additional try/finally block around the whole thing, if the code in between could crash but I want to keep the semaphore working properly.

To reduce the boilerplate, I tried to write a wrapper class that would have the same behavior (including the try/finally bit) with less code needed. I also didn't want to use a delegate, as that'd create an object every time, and I just wanted to reduce my code without changing the way it worked.

I came up with this class (comments removed for brevity):

public sealed class AsyncMutex
{
    private readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1);

    public async Task<IDisposable> Lock()
    {
        await Semaphore.WaitAsync().ConfigureAwait(false);
        return new _Lock(Semaphore);
    }

    private sealed class _Lock : IDisposable
    {
        private readonly SemaphoreSlim Semaphore;

        public _Lock(SemaphoreSlim semaphore) => Semaphore = semaphore;

        void IDisposable.Dispose() => Semaphore.Release();
    }
}

And the way it works is that by using it you only need the following:

private readonly AsyncMutex Mutex = new AsyncMutex();

public async Task FooAsync()
{
    using (_ = await Mutex.Lock())
    {
        // Do stuff here
    }
}

One line shorter, and with try/finally built in (using block), awesome.

Now, I have no idea why this works, despite the discard operator being used.

That discard _ was actually just out of curiosity, as I knew I should have just written var _, since I needed that IDisposable object to be used at the end of the using block, and not discarder.

But, to my surprise, the same IL is generated for both methods:

.method public hidebysig instance void T1() cil managed 
{
    .maxstack 1
    .locals init (
        [0] class System.Threading.Tasks.AsyncMutex mutex,
        [1] class System.IDisposable V_1
    )
    IL_0001: newobj       instance void System.Threading.Tasks.AsyncMutex::.ctor()
    IL_0006: stloc.0      // mutex

    IL_0007: ldloc.0      // mutex
    IL_0008: callvirt     instance class System.Threading.Tasks.Task`1<class System.IDisposable> System.Threading.Tasks.AsyncMutex::Lock()
    IL_000d: callvirt     instance !0/*class System.IDisposable*/ class System.Threading.Tasks.Task`1<class System.IDisposable>::get_Result()
    IL_0012: stloc.1      // V_1
    .try
    {
        // Do stuff here..
        IL_0025: leave.s      IL_0032
    }
    finally
    {
        IL_0027: ldloc.1      // V_1
        IL_0028: brfalse.s    IL_0031
        IL_002a: ldloc.1      // V_1
        IL_002b: callvirt     instance void System.IDisposable::Dispose()
        IL_0031: endfinally   
    }
    IL_0032: ret    
}

The "discarder" IDisposable is stored in the field V_1 and correctly disposed.

So, why does this happen? The docs don't say anything about the discard operator being used with the using block, and they just say the discard assignment is ignored completely.

Thanks!

Bland answered 2/3, 2018 at 19:33 Comment(8)
The IDisposable object does not have to be stored in a local explicitly. You can also just do using(await Mutex.Lock()). – Torino
@Torino Wow, I had absolutely no idea about that, thanks (and it's shorter too!) ahahah πŸ˜„ The using block docs page doesn't say anything about it not needing an explicit variable, and I've never seen it used that was, so I had no clue. If you post this as answer I'll be happy to mark it as valid! – Bland
FWIW, _ is not an operator, it's an identifier. – Catachresis
@BenVoigt Right, thanks for pointing that out, fixed πŸ‘ – Bland
Bit off topic, but I strongly suggest you to add .ConfigureAwait(false) after the await, and remove the finalizer inside the Lock class. – Burchell
@Burchell I often use this thing to synchronize animations in the UI, wouldn't ConfigureAway(false) cause crashes there, as it'd throw away the original context? Also, what's wrong with the finalizer, isn't that part of the suggested IDisposable implementation? Thanks! – Bland
The original context would be captured by the caller of the Lock() method (if needed), and will return to it when Lock() completes. There is no need to capture the context inside Lock(), since it doesn't use any UI objects. Therefore, your current implementation would cause a deadlock as soon as someone calls Lock().Result on the UI thread. Also, you should never try to clean up any managed objects inside a finalizer, because the referred object might have already been finalized. I suggest to read about the recommended IDisposable implementation more carefully. Good luck! – Burchell
@Burchell Great, I wasn't aware of that, thanks for the heads up! 😊 I've fixed the class in the original question too, so future readers will see the corrected code. – Bland
T
7

The using statement does not require an explicit declaration of a local variable. An expression is also allowed.

The language specification specifies the following syntax.

using_statement
    : 'using' '(' resource_acquisition ')' embedded_statement
    ;

resource_acquisition
    : local_variable_declaration
    | expression
    ;

If the form of resource_acquisition is local_variable_declaration then the type of the local_variable_declaration must be either dynamic or a type that can be implicitly converted to System.IDisposable. If the form of resource_acquisition is expression then this expression must be implicitly convertible to System.IDisposable.

https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/statements#the-using-statement

An assignment of an existing variable (or discarding the result) is also an expression. For example the following code compiles:

var a = (_ = 10);
Torino answered 2/3, 2018 at 21:13 Comment(0)
I
5

The use of the discard feature is really a red herring here. The reason that this works is because the using statement can accept an expression that resolves to a value to be disposed (in addition to an alternate syntax that declares a variable). Additionally, the assignment operator resolves to the value that is assigned.

The value that you're providing on the right hand side of the assignment operator is your Lock object, so that's what the expression _ = await Mutex.Lock() resolves to. Since that value (not as a variable declaration, but as a stand alone value) is disposable, it is the thing that will be cleaned up at the end of the using.

Ineffaceable answered 2/3, 2018 at 21:7 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.