I am developing a C# incremental generator to act as a wrapper between managed and unmanaged callbacks in a generic context. That wrapper generates interface
s that functionally work the same way as a delegate
, with an Invoke
method that supports up to 16 generic type parameters with or without a return type (named similarly to System.Action
and System.Func
).
I wanted to be able to add ref
-qualified parameters to the Invoke
method, but for the same reasons as Action
and Func
, there's simply no way to produce every permutation of by-value
, ref
, in
, and out
for 16 different parameters, even with a source generator. (I'm elaborating on the end-goal to avoid an XY problem.)
Considering alternative approaches, I arrived at the idea of using a ref struct
with a ref
field to represent any one of the possible ref
"categories". I could use a normal field to store a "by-value
" parameter (meaning not ref
-qualified; not necessarily a ValueType
), and a readonly ref readonly
field to store a ref
, in
, or out
parameter:
public enum RefCategory
{
None = 0,
Ref,
InRef,
OutRef
}
public readonly ref struct ParamProxy<T>
{
[MaybeNull]
private readonly T obj;
private readonly ref readonly T _ref;
public readonly RefCategory RefCategory;
public static implicit operator ParamProxy<T>(T obj) => new(obj);
[return: MaybeNull]
public static implicit operator T(ParamProxy<T> proxy) => proxy.Value;
public ParamProxy() : this(default!) { }
public ParamProxy(T obj)
{
this.obj = obj;
_ref = ref Unsafe.NullRef<T>();
RefCategory = RefCategory.None;
}
public ParamProxy(ref T @ref)
{
Unsafe.SkipInit(out obj);
_ref = ref @ref;
RefCategory = RefCategory.Ref;
}
public ParamProxy(in T inRef, object? _ = null)
{
Unsafe.SkipInit(out obj);
_ref = ref inRef;
RefCategory = RefCategory.InRef;
}
public ParamProxy(out T outRef, int _ = 0)
{
Unsafe.SkipInit(out obj);
Unsafe.SkipInit(out outRef);
_ref = ref Unsafe.AsRef(in outRef);
RefCategory = RefCategory.OutRef;
}
private readonly ref T GetRef(RefCategory category)
{
switch (category)
{
case RefCategory.None:
throw new InvalidOperationException("Parameter is not a by-ref parameter");
case RefCategory.Ref:
if (RefCategory != RefCategory.Ref)
{
throw new InvalidOperationException("Parameter is not a `ref` parameter");
}
break;
case RefCategory.InRef:
if ((RefCategory != RefCategory.InRef) && (RefCategory != RefCategory.Ref))
{
throw new InvalidOperationException("Parameter is not an `in` or `ref` parameter");
}
break;
case RefCategory.OutRef:
if ((RefCategory != RefCategory.OutRef) && (RefCategory != RefCategory.Ref))
{
throw new InvalidOperationException("Parameter is not an `out` or `ref` parameter");
}
break;
default:
throw new UnreachableException();
}
return ref Unsafe.AsRef(in _ref);
}
public readonly ref readonly T InRef
{
get => ref GetRef(RefCategory.InRef);
}
public readonly ref T OutRef
{
get => ref GetRef(RefCategory.OutRef);
}
public readonly ref T Ref
{
get => ref GetRef(RefCategory.Ref);
}
[MaybeNull]
public readonly T Value
{
get => RefCategory switch
{
RefCategory.None => obj,
_ => Unsafe.IsNullRef(in _ref) ? default : _ref
};
}
}
This utilizes System.Runtime.CompilerServices.Unsafe to avoid initializing the obj
and/or _ref
fields, based on the constructor used.
The in
and out
constructors have dummy parameters, because C# doesn't allow you to overload methods/constructors only by the ref
category. However, with defaulted dummy parameters, the compiler is able to unambiguously resolve new(ref x)
, new(in x)
, and new(out x)
from each other. (Edit: corrected constructor details)
This proxy type would allow my interface
s to define Invoke
like this:
public ParamProxy<TResult> Invoke(scoped ParamProxy<T1> t1, scoped ParamProxy<T2> t2, scoped ParamProxy<T3> t3);
My source generator is already analyzing type information (T1
, T2
, T3
, TResult
...), and I am able to reason about the types. I would similarly be able to inspect the invocations of Invoke
and issue diagnostics at compile-time if the wrong ref
category is used. Usability is not the concern in question.
My question here is whether I have done something dangerous. In particular, the out
parameter requires using Unsafe.AsRef
to avoid a "narrower escape scope" error.
The specific context of my use case leads me to believing that this is still a reliable and safe scenario:
ref
, in
, or out
parameter is passed to the constructor of ParamProxy<T>
(a ref struct
)ParamProxy<T>
stores the ref
-qualified parameter in a ref
fieldParamProxy<T>
object is passed to Invoke
as a scoped
parameterInvoke
method then "forwards" the ref
field's value to an appropriate ref
, in
, or out
parameter of a delegate
delegate
is invoked immediately, before Invoke
returnsThe user code might then call Invoke
like this:
var getIntValueFromNative = /* ...get interface instance... */;
getIntValueFromNative.Invoke(new(out int value));
Which would generate (via the source generator) an Invoke
implementation that would do:
public void Invoke(scoped ParamProxy<int> param)
{
handler(out param.OutRef); // `handler` is a `delegate`
}
Sorry for the lengthy post. I've tried to be thorough in describing the scenario. My early tests show the expected results. I'm leery of inadvertently leaking memory or corrupting the stack. Thank you in advance for any feedback!
Edit: I discovered UnscopedRefAttribute, which lists out
parameters as a use-case for the attribute. The article I cite below states that out
parameters are implicitly scoped
, and does not discuss UnscopedRefAttribute
.
If I apply the attribute to the out
parameter (constructor of the original question), then I can assign _ref = ref outRef;
. No tricky usage of Unsafe.AsRef
required to mask the escape scope error.
Additionally, trying to intentionally allow the out
parameter to escape the ParamProxy<T>.OutRef
gives an error. As best as I can tell, the compiler does correctly recognize that the stored ref
field is a reference to the original out
parameter. Even with the UnscopedRefAttribute
applied, the compiler doesn't allow the out
parameter to escape through the ref
field in a way that the parameter itself couldn't be used.
I believe I have found my answer while reading more about the scoped
keyword: Low Level Struct Improvements - Change the behavior of out parameters.
While I'm still reasonably certain that in the expected use-case, the out
parameter object wouldn't go out-of-scope, it's conceivable that someone could abuse this, which is why I had to use Unsafe.AsRef
only in the out
constructor.
What's more, is (per the link above), this usage is explicitly disallowed in the language (for out
parameters). I may be getting the correct results now, but there's nothing to guarantee that future implementation changes in the runtime or language wouldn't cause this to break.
AFAICT, the ref
and in
constructors are valid, specifically because I did not make the constructor parameters scoped
. If the parameters were scoped
, then storing them in a ref
field would violate their "escape scope".
The out
constructor should be removed entirely, but the user can achieve the same effect by declaring and default
-initializing a local and then passing it by ref
. As far as the source generator is concerned, a ref
is a valid argument for an out
parameter, so this would still work.
var getIntValueFromNative = /* ...get interface instance... */;
int value = default;
getIntValueFromNative.Invoke(new(ref value)); // `value` is `out` parameter in `handler`
I still welcome any other feedback on this, but I'll mark this as the accepted answer.