Why does a zero-length stackalloc make the C# compiler happy to allow conditional stackallocs?

The following "fix" is very confusing to me; the scenario here is conditionally deciding whether to use the stack vs a leased buffer depending on the size - a pretty niche but sometimes-necessary optimization, however: with the "obvious" implementation (number 3, deferring definite assignment until we actually want to assign it), the compiler complains with CS8353:

A result of a stackalloc expression of type 'Span<int>' cannot be used in this context because it may be exposed outside of the containing method

The short repro (a complete repro follows) is:

// take your pick of:
// Span<int> s = stackalloc[0]; // works
// Span<int> s = default; // fails
// Span<int> s; // fails

if (condition)
{   // CS8353 happens here
    s = stackalloc int[size];
}
else
{
    s = // some other expression
}
// use s here

The only thing I can think here is that the compiler is really flagging that the stackalloc is escaping the context in which the stackalloc happens, and is waving a flag to say "I can't prove whether this is going to be safe later in the method", but by having the stackalloc[0] at the start, we're pushing the "dangerous" context scope higher, and now the compiler is happy that it never escapes the "dangerous" scope (i.e. it never actually leaves the method, since we're declaring at the top scope). Is this understanding correct, and it is just a compiler limitation in terms of what can be proven?

What's really interesting (to me) is that the = stackalloc[0] is fundamentally a no-op anyway, meaning that at least in the compiled form the working number 1 = stackalloc[0] is identical to the failing number 2 = default.

Full repro (also available on SharpLab to look at the IL).

using System;
using System.Buffers;

public static class C
{
    public static void StackAllocFun(int count)
    {
        // #1 this is legal, just initializes s as a default span
        Span<int> s = stackalloc int[0];
        
        // #2 this is illegal: error CS8353: A result of a stackalloc expression
        // of type 'Span<int>' cannot be used in this context because it may
        // be exposed outside of the containing method
        // Span<int> s = default;
        
        // #3 as is this (also illegal, identical error)
        // Span<int> s;
        
        int[] oversized = null;
        try
        {
            if (count < 32)
            {   // CS8353 happens at this stackalloc
                s = stackalloc int[count];
            }
            else
            {
                oversized = ArrayPool<int>.Shared.Rent(count);
                s = new Span<int>(oversized, 0, count);
            }
            Populate(s);
            DoSomethingWith(s);
        }
        finally
        {
            if (oversized is not null)
            {
                ArrayPool<int>.Shared.Return(oversized);
            }
        }
    }

    private static void Populate(Span<int> s)
        => throw new NotImplementedException(); // whatever
    private static void DoSomethingWith(ReadOnlySpan<int> s)
        => throw new NotImplementedException(); // whatever
    
    // note: ShowNoOpX and ShowNoOpY compile identically just:
    // ldloca.s 0, initobj Span<int>, ldloc.0
    static void ShowNoOpX()
    {
        Span<int> s = stackalloc int[0];
        DoSomethingWith(s);
    }
    static void ShowNoOpY()
    {
        Span<int> s = default;
        DoSomethingWith(s);
    }
}

Solution 1:

The Span<T> / ref feature is essentially a series of rules about to which scope a given value can escape by value or by reference. While this is written in terms of method scopes it's helpful to simplify to just one of two statements:

  1. The value cannot be return from the method
  2. The value can be returned from the method

The span safety doc goes into great detail about how the scope is calculated for various statements and expressions. The relevant part here though is for how locals are processed.

The main take away is that whether or not a local can return is calculated at the local declaration time. At the point the local is declared the compiler examines the initializer and makes a decision about whether the local can or cannot be return from the method. In the case there is an initializer then the local will be able to return if the initialization expression is able to be returned.

How do you handle the case where a local is declared but there is no initializer? The compiler has to make a decision: can it or can it not return? When designing the feature we made the decision that the default would be "it can be returned" because it's the decision that caused the least amount of friction for existing patterns.

That did leave us with the problem of how developers could declare a local that wasn't safe to return but also lacked an initializer. Eventually we settled on the pattern of = stackalloc [0]. This is an expression that is safe to optimize away and a strong indicator, basically a requirement, that the local isn't safe to return.

Knowing that this explains the behavior you are seeing:

  • Span<int> s = stackalloc[0]: this is not safe to return hence the later stackalloc succeeds
  • Span<int> s = default: this is safe to return because default is safe to return. This means the later stackalloc fails because you're assigning a value that isn't safe to return to a local that is marked as safe to return
  • Span<int> s;: this is safe to return because that is the default for unininitialized locals. This means the later stackalloc fails because you're assigning a value that isn't safe to return to a local that is marked as safe to return

The real downside to the = stackalloc[0] approach is that it's only applicable to Span<T>. It's not a general solution for ref struct. In practice though it's not as much of a problem for other types. There is some speculation on how we could make it more general but not enough evidence to justify doing it at this point.

Solution 2:

Isn't an answer to "why"; however you could change it to a ternary operator slicing the result of the array assignment to a Span:

public static void StackAllocFun(int count)
{
    int[] oversized = null;
    try
    {
        Span<int> s = ((uint)count < 32) ?
            stackalloc int[count] :
            (oversized = ArrayPool<int>.Shared.Rent(count)).AsSpan(0, count);

        Populate(s);
        DoSomethingWith(s);
    }
    finally
    {
        if (oversized is not null)
        {
            ArrayPool<int>.Shared.Return(oversized);
        }
    }
}