C# 11 escape rules for ref parameters: ref int vs Span<int>
Asked Answered
P

2

6

Why does the following code not compile in C# 11?

// Example 1 - fails
class C {
    public Span<int> M(ref int arg) {
        Span<int> span;
        span = new Span<int>(ref arg);
        return span;
    }
}

It produces two compile errors:

error CS9077: Cannot return a parameter by reference 'arg' through a ref parameter; it can only be returned in a return statement.

error CS8347: Cannot use a result of 'Span.Span(ref int)' in this context because it may expose variables referenced by parameter 'reference' outside of their declaration scope.

Neither of them makes sense to me: my code doesn't try to return arg by a ref parameter, and it can't expose variables referenced by arg outside of their declaration scope.

By comparison, the following two pieces of code compile successfully:

// Example 2 - succeeds
class C {
    public Span<int> M(ref int arg) {
        Span<int> span = new Span<int>(ref arg);
        return span;
    }
}
// Example 3 - succeeds
class C {
    public Span<int> M(Span<int> arg) {
        Span<int> span;
        span = new Span<int>(ref arg[0]);
        return span;
    }
}

My intuition is that Span<int> internally holds a ref field of type int, so the escape rules should work the same for Examples 1 and 3 above (which, apparently, they do not).

I made an analogous experiment with a ref struct explicitly holding a ref field:

ref struct S {
    public ref int X;
}

Now, the following code fails to compile:

// Example 4 - fails
class C {
    public S M(ref int arg) {
        S instance;
        instance.X = ref arg;
        return instance;
    }
}

It produces the following error, which at least makes slightly more sense to me:

error CS9079: Cannot ref-assign 'arg' to 'X' because 'arg' can only escape the current method through a return statement.

By comparison, the following two pieces of code compile successfully (with the definition of S above):

// Example 5 - succeeds
class C {
    public S M(ref int arg) {
        S instance = new S() { X = ref arg };
        return instance;
    }
}
// Example 6 - succeeds
class C {
    public S M(S arg) {
        S instance;
        instance.X = ref arg.X;
        return instance;
    }
}

In particular, if arg can only escape the current method through a return statement, as in the error message for Example 4 above, while doesn't the same hold for arg.X in Example 6?

I tried to find the answer in the documentation for low level struct improvements, but I failed. Moreover, that documentation page seems to contradict itself in several places.

Pathogenic answered 25/12, 2022 at 15:11 Comment(3)
This issue has been reported to the Roslyn Github and closed as "By Design" github.com/dotnet/roslyn/issues/53014Roca
OK, this explains why Examples 1 vs 2 (or 4 vs 5) may behave differently. But how about Examples 1 vs 3 (or 4 vs 6)? For instance, it seems that the "lifetime" of arg in Example 4 is tighter than the "lifetime" of arg.X in Example 6, which I don't understand.Pathogenic
I think there is probably a bug in one specific place in the Roslyn compiler where one specific decision is made that misses the ref modifier on the parameter and therefore treats the parameter like a local variable instead of a returnable reference.Roca
S
0

are you sure you are using C# 11? Using linqpad with .Net 7 your "fails to compile" example worked fine for me:

Compiles Fine

Update: doesn't compile if using the daily build of the Rosyln compiler

My new hypothesis is that the spec actually got tighter... and ex1 and ex2 should both fail, but they have not accounted for the ex2 syntax where its not triggering when it should (for the reason Marc G pointed out) so might be worth filing a bug report on this :-)

Salify answered 25/12, 2022 at 15:22 Comment(10)
Yes, I am using C# 11 with .NET 7.0.100. Also see SharplabPathogenic
OK. looks like linkpad uses <LangVersion>preview</LangVersion> in visual studio, it compiles if you use that setting, but not <LangVersion>latest</LangVersion> that leads me to believe this is a edge case in the current spec that they are planning to change / relax constraints for the case you pointed outSalify
I added '<LangVersion>preview</LangVersion>' to my project settings, but this doesn't influence the result. By the way, my VS Code (with the C# extension) doesn't recognize this error either. It occurs only after compiling with 'dotnet build'.Pathogenic
linqpad is able to run the code, so it seems like a compiler setting to enable the relaxed version of the lanugage spec needs to be turned on in the csproj file. that being said - it does seem slightly unusual to do what you are doing for just an int (I assume 'int' is a substitute for a struct to make the example repro simpler) ?Salify
I was experimenting with ref structs and ref fields in C# 11, and this is a minimal example of a behavior that I don't understand. I expected that ref parameters/locals/returns/fields of non-ref struct type behave analogously to non-ref parameters/locals/returns/fields of ref struct type. This example shows that they behave differently. But you may be right that this is some edge case for which the rules are still being changed.Pathogenic
does this work for you? amazing that this produces no error, and yet your version does! working syntax def feels like the spec is not handling all edge casesSalify
Yes, this works. This is Example 2 from my question. I understand why Examples 1 and 2 may behave differently—this is because the compiler needs to determine the "escape scope" of the local variable 'span' at the point of declaration. In Example 2, it can make it match the "escape scope" of 'arg', while in Example 1, it needs to fix it upfront. However, I don't understand why there is a difference between Example 1 and Example 3.Pathogenic
OK - i got linqpad to throw the error only if i use the latest daily roslyn compiler. My new hypothesis is that the spec actually got tighter... and ex1 and ex2 should both fail, but they have not accounted for the ex2 syntax where its not triggering when it should (for the reason Marc G pointed out) might be worth filing a bug report on this :-)Salify
Marcs Code returns a compiler error inside GetSpan at the return clause unless the parameter in M is marked with scoped. In this case GetSpan has no error, but M cannot return its argument.Roca
Unfortunately, Marc removed his code. It was something like: Span<int> GetSpan() { int x = 1; return M(ref x); }. It seems to me that this method is an illustration of this breaking change in C# 11. It compiled successfully in C# 10, because it was impossible to write a method M that would put the reference to x into the span. But C# 11 made it possible, so GetSpan does not compile.Pathogenic
R
0

This very closely related issue had been reported to the Roslyn team before and closed as "by design".

The issue is that the compiler associates an internal scope with a variable of a ref struct type such as a span<>. This scope is decided at the moment the variable is declared.

Later on, when assignments happen, the internal scopes are compared.

Although both the uninitialized local span<> variable as well as the span<> variable wrapped around the ref argument should be returnable from the method, the compiler seems to think otherwise.

I would report this concrete example to the Roslyn team and see what they say about it.

Previous musings:


It is an issue of scope. Look at this more explicit example:

public void M()
{
    Span<int> spanOuter;
    {
        int answer = 42;
        spanOuter = new Span<int>(ref answer); // Compiler error
    }

    Console.WriteLine(spanOuter[0]); // Would access answer 42 which
                                     // is already out of scope
}

The new Span<int>() created has a narrower scope than the variable spanOuter. You cannot assign spans to another span with a broader scope because that could mean that the referenced data they hold is accessed after they don't exist any more. In this example, the answer variable goes out of scope before spanOuter[0] is accessed.

Let's remove the curly braces:

public void M()
{
    Span<int> spanOuter;
    int answer = 42;
    spanOuter = new Span<int>(ref answer); // Compiler error
    Console.WriteLine(spanOuter[0]); 
}

Now this should in theory work because the answer variable is still in scope at the Conole.WriteLine. The compiler still doesn't like it. Although there are no curly braces, the spanOuter variable still has a broader scope than at the new Span<int>() expression because its declaration happens on its own on a previous line.

When checking for breadth of scope, the compiler seems to be very strict and difference in scope just because of the separate variable declaration seems to be enough to not allow the assignment.


Even when we move the answer variable at the very beginning so that it basically has the same scope as an argument has, it is still not allowed.

public void M()
{
    int answer = 42;
    Span<int> spanOuter;
    spanOuter = new Span<int>(ref answer); // Compiler error
    Console.WriteLine(spanOuter[0]); 
}

The compiler seems to treat arguments just like local variables for this check. I agree that the compiler could be a bit more clever, look at the precise scope of the referenced data and allow some more cases, but it just doesn't do that.


Specifically, the compiler seems to have a special treatment when the target span variable is uninitialized as seen by the compiler.

public void M(ref int a)
{
    int answer = 42;

    Span<int> spanNull = null;
    Span<int> spanImplicitEmpty;
    Span<int> spanExplicitEmpty = Span<int>.Empty;
    Span<int> spanInitialized = new Span<int>(ref answer);

    Span<int> spanArgument = new Span<int>(ref a);

    spanNull            = spanArgument; // Compiler Error
    spanExplicitEmpty   = spanArgument; // Compiler Error
    spanImplicitEmpty   = spanArgument; // Compiler Error
    spanInitialized     = spanArgument; // Works
}

The same applies when using a return value:

public Span<int> M(ref int a)
{
    int answer = 42;

    Span<int> spanNull = null;
    Span<int> spanImplicitEmpty;
    Span<int> spanExplicitEmpty = Span<int>.Empty;
    Span<int> spanInitialized = new Span<int>(ref answer);
    
    Span<int> spanInitializedAndThenNull = new Span<int>(ref answer);
    spanInitializedAndThenNull = null;

    Span<int> spanArgument = new Span<int>(ref a);

    spanNull                    = spanArgument; // Compiler Error
    spanExplicitEmpty           = spanArgument; // Compiler Error
    spanImplicitEmpty           = spanArgument; // Compiler Error
    spanInitialized             = spanArgument; // Works
    spanInitializedAndThenNull  = spanArgument; // Works

    return spanArgument;
}
Roca answered 25/12, 2022 at 16:46 Comment(5)
Your first example is clear, but is different from mine, because it tries to return a reference to a local variable. Your second example is similar to my Example 1, but is more understandable to me. The scope of 'spanOuter' is actually even broader—it is allowed to be returned. For example, change the return type from 'void' to 'Span<int>', remove the last to lines, and add the following instead: 'spanOuter = default;' and 'return spanOuter'. It compiles successfully.Pathogenic
So your second example fails because it tries to assign a ref to a local variable to a span that can be returned. However, in my Example 1, a ref parameter is assigned to the span that we want to return. So I still don't understand why that doesn't work.Pathogenic
I've extended my answer a bit. I think the compiler makes a mistake. It specifically chokes only when the target span variable has never been assigned with a real span.Roca
Your last example is very interesting. I understand that spanInitialized = spanArgument; works because the "escape scope" of spanInitialized is local (it captures a reference to a local variable). So the assignment tightens the "escape scope" of spanArgument, which is allowed. Notice that spanInitialized cannot be returned (assuming Span<int> was the return type rather than void) even after the assignment. But I don't understand why the other three assignments produce a compile error.Pathogenic
OK, I will report it later today. Thanks!Pathogenic

© 2022 - 2024 — McMap. All rights reserved.