This proposal expands the capabilities of ref
and scoped
in the language. The goal being to leverage the existing types of rules in the model to allow ref struct
usage in more locations and provide more lifetime expressiveness for APIs.
There are still a number of scenarios around ref
which cannot be safely expressed in the language. These are generally when using multiple mutable ref struct
parameters where many are passed by ref
or when trying to use ref struct
in ref
fields.
To fully satisfy all of these scenarios would require us to introduce explicit lifetime parameters and relationships into the language. That is a huge investment that is not yet motivated by need. Instead this proposal takes our existing lifetime annotation, scoped
, and sees how much further ref
safety can be taken without introducing any other annotations or keywords.
This doesn't solve all scenarios but does remove several known friction points in the language. It also serves to show us exactly where the limits are without introducing explicit lifetime parameters.
The rules for ref struct
safety are defined in the following documents:
This proposal will be building on top of those previous ones.
The more detailed rules will rely on the annotation syntax to describe the detailed rules. This is the most direct way to discuss how syntax behaves in the greater model. Readers interested in the very low level details should familiarize themselves with that syntax before digesting this proposal.
The language will allow for parameters to be declared as ref scoped
. This will serve to constrain the safe-to-escape of the value such that it cannot be returned from the current method.
Span<int>M(Span<T>p1,ref scoped Span<int>p2){// Error: cannot return scoped valuereturnp2;// Error: the safe-to-escape of p1 is not convertible to p2.p2=p1;// Okay: heap can always be assignedp2=default;// Okayp2[0]=42;}
This capability will help cases where multiple ref struct
values with different lifetimes are passed by ref
. Having ref scoped
allows developers to note which values do not escape and that allows for more call site flexibility.
refstructData{ ...}voidCopy1(refDatasource,refDatadest){ ...}voidCopy2(refDatasource,ref scoped Datadest){ ...}voidUse(refDatadata){// STE: current methodvarlocal=newData(stackallocint[42]);// Error: compiler has to assume local copied to data Copy1(refdata,reflocal);// Okay: compiler knows lifetime only flows data -> localCopy2(refdata,reflocal);}
This is accomplished by giving every ref scoped
parameter a new escape scope named current parameter N where N is the numeric order of the parameter. For example the first parameter has a safe-to-escape of current parameter 1. An escape scope of current parameter N can be converted to current method but has no other defined relationship. That serves to restrict their usage to the current method.
It's important to note each parameter has a different current parameter N scope. That means they cannot be assigned to each other. This is necessary to prevent ref scoped
parameters from returning each others data.
voidSwap(ref scoped Span<int>p1,ref scoped Span<int>p2){// Error: can't assign current parameter 2 to current parameter 1p2=p1;// Error: can't assign current parameter 1 to current parameter 2p1=p2;// Okay: as current parameter 1 and 2 can be converted to current method scoped Span<int>local1=p1; scoped Span<int>local2=p2;// Okay: however the safe-to-escape here is current parameter N, not // current method so this could cause a bit of confusion later onSpan<int>local3=p1;Span<int>local4=p2;// Okay: the safe-to-escape of the value is inferred in this case as it is // done for ref locals today.refSpan<int>refLocal1=refp1;refSpan<int>refLocal2=refp2;}
A ref scoped
parameter is also implicitly scoped ref
. That means neither the value nor its ref
can be returned from the method. Both ref
and in
parameters can have their values modified with scoped
. An out
parameter cannot have its value modified with scoped
as such a declaration is non-sensical.
voidM(ref scoped Span<int>p1,// Okayin scoped Span<int>p2,// Okayout scoped Span<int>p2,// Error)
The method arguments must match rules will be updated to take ref scoped
into account. Values passed to such parameters do not need to be considered when calculating the return scopes.
Detailed notes:
- A
ref scoped
parameter is implicitlyscoped ref
- An
out scoped
parameter declaration is an error
The language will allow for ref struct
to appear as ref scoped
fields. This scoped
will serve to ensure the values cannot be escaped outside the containing instance but can be read and manipulated within it.
refstructDeserializer{ref scoped Utf8JsonReaderreader;ReadOnlySpan<byte>M1(){// okay: implicitly scoped to current methodvarspan=reader.ValueSpan;// okayreader.Skip();// Error: can't escape the ref data the ref scoped field refers toreturnreader.ValueSpan;}}
This is accomplished by giving every ref scoped
field two new escape scopes named current field N and current ref field N where N is the numeric order of the field. For example, the first field has a safe-to-escape of current field 1 and a ref-safe-to-escape of current ref field N. Both escape scopes can be converted to current method, and current field N can be converted to current ref field N, but no other defined relationships exist. That serves to restrict their usage to the current method where the containing value is used. This escape scope applies to both.
Below are a few examples of these rules in action
refstructNestedRefStruct{}refstructRefStruct{publicNestedRefStructNestedField;}refstructS{ref scoped RefStructfield;RefStructM1(RefStructs){// Okayfield=new();// Error: calling-method is not convertible to current-field-1 as they have // no relationshipfield=s;// Error: safe-to-escape is current-field-1 which isn't returnable returnfield;}NestedRefStructM2(){// Error: safe-to-escape is current-field-1 which isn't returnable returnfield.NestedField;}refRefStructM3(){// Error: safe-to-escape is current-ref-field-1 which isn't returnable returnreffield;}}
The method arguments must match rules do not need to be updated here as they already account for ref
parameters being captured as ref
field. Even though a ref
to ref struct
was not directly returnable before, it could be returned indirectly by a ref
to a struct
field of the value.
The language will also allow for ref
fields to be declared as scoped ref
. There are less use cases for this but ref scoped
implies scoped ref
hence the rules must be adjusted to account for this. As such the syntax will be exposed because while the use cases are small the infrastructure already exists. The ref-safe-to-escape of such fields follows the logic above for ref scoped
fields.
Detailed notes:
- A
ref
field where the type is aref struct
must beref scoped
- A
ref
field may be markedscoped ref
The ability for any type to be a ref
field allows us to fully sunset the notion of restricted types. The compiler has a concept of a set of restricted types which is largely undocumented. These types were given a special status because in C# 1.0 there was no general purpose way to express their behavior. Most notably the fact that the types can contain references to the execution stack. Instead the compiler had special knowledge of them and restricted their use to ways that would always be safe: disallowed returns, cannot use as array elements, cannot use in generics, etc ...
Once ref
fields are available and extended to support ref struct
these types can be fully rationalized within those rules. As such the compiler will no longer have the notion of restricted types when using a language version that supports ref
fields of ref struct
.
To support this our ref
safety rules will be updated as follows:
__makeref(e)
will be logically treated as a method with the signaturestatic TypedReference __makeref(ref T value)
wereT
is the type ofe
.__refvalue(e, T)
- When
T
is aref struct
: will be treated as accessing a field declared asref scoped T
insidee
. - Will be treated as accessing a field declared as
ref T
insidee
- When
__arglist
as a parameter will be implicitlyscoped
__arglist(...)
as an expression will have a ref-safe-to-escape and safe-to-escape of current method.
Conforming runtimes will ensure that TypedReference
, RuntimeArgumentHandle
and ArgIterator
are defined as ref struct
. Further TypedReference
must be viewed as having a ref
field to a ref struct
for any possible type (it can store any value). That combined with the above rules will ensure references to the stack do not escape beyond their lifetime.
Note: strictly speaking this is a compiler implementation detail vs. part of the language. But given the relationship with ref
fields it is being included in the language proposal for simplicity.
At an annotation level every parameter marked ref scoped
will have a new lifetime parameter defined. The name will be $paramN
where N is the numerical order of the parameter. That lifetime will only have the relationship where $paramN : $local
.
refstructS{}voidM(ref scoped Ss)// maps to void M<$param1>(ref<$local> S<$param1> s)where $param1: $local
This definition prevents the value from escaping from the method as the lifetime is not returnable. It also prevents local data from escaping from the current method through the parameter as the lifetime is wider than $local
but not equivalent.
voidM<$param1>(ref<$local>S<$param1>p)where $param1: $local{S<$local>s=newS<$local>(stackallocint[42]);// error: cannot convert S<$local> to S<$param1>p=s;}
At an annotation level every field marked scoped ref
(explicitly or implicitly via ref scoped
) will have a new lifetime parameter defined. The name will be $refFieldN
where N is the numerical order of the field. That lifetime will have the relationship where $refFieldN : $local
in all methods that use the type.
refstructS{ scoped refinti;}SM(Sp){}// maps to refstructS<out $this, $refField1>{ref<$refField1>inti;}S<$cm>M<$cm, $l1>(S<$cm, $l1>p)where $l1: $local{}
Every field marked as ref scoped
will have a new lifetime parameter defined. The name will be $fieldN
where N is the numerical order of the field. That lifetime will have the relationship where $fieldN : $refFieldN
defined on the type. It will also have the relationship where $fieldN : $local
in all method that use the type.
refstructS1{}refstructS2{ref scoped S1field;}S2M(S2p){}// maps to refstructS1<out $this>{}refstructS2<out $this, $refField1, $field1>where $field1: $refField1{ref<$refField1>S1<$field1>field;}S1<$cm, $l1, $l2>M<$cm, $l1>M(S<$cm, $l1, $l2>p)where $l2: $l1where $l1: $local{}
These definitions prevent the values (ref
or value) from escaping as their lifetimes are never returnable. It does allow for them to be manipulated and adjusted though. Non ref
data, or data known to have $heap
lifetime, can be assigned into such fields.
The proposal does not provide any way to mark this
as ref scoped
for a given method. At this time the author can see no significant benefits to this. If such scenarios do come along then an attribute such as [RefScoped]
could be introduced similar to how [UnscopedRef]
works.
Certain readers are likely to be disappointed that ref
field to ref struct
must be ref scoped
. That limits the number of scenarios which can assign ref
data into such fields.
This is unfortunately necessary given the constraints of the design. Having a plain ref
effectively requires that explicit lifetime annotations exist in the language. There is no other way to safely express the relationship between the value and the container.