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:
- The
ref
,in
, orout
parameter is passed to the constructor ofParamProxy<T>
(aref struct
) - The
ParamProxy<T>
stores theref
-qualified parameter in aref
field - The
ParamProxy<T>
object is passed toInvoke
as ascoped
parameter - The
Invoke
method then "forwards" theref
field's value to an appropriateref
,in
, orout
parameter of adelegate
- The
delegate
is invoked immediately, beforeInvoke
returns
The 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 thatout
parameters are implicitlyscoped
, and does not discussUnscopedRefAttribute
.If I apply the attribute to the
out
parameter (constructor of the original question), then I can assign_ref = ref outRef;
. No tricky usage ofUnsafe.AsRef
required to mask the escape scope error.Additionally, trying to intentionally allow the
out
parameter to escape theParamProxy<T>.OutRef
gives an error. As best as I can tell, the compiler does correctly recognize that the storedref
field is a reference to the originalout
parameter. Even with theUnscopedRefAttribute
applied, the compiler doesn't allow theout
parameter to escape through theref
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 useUnsafe.AsRef
only in theout
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
andin
constructors are valid, specifically because I did not make the constructor parametersscoped
. If the parameters werescoped
, then storing them in aref
field would violate their "escape scope".The
out
constructor should be removed entirely, but the user can achieve the same effect by declaring anddefault
-initializing a local and then passing it byref
. As far as the source generator is concerned, aref
is a valid argument for anout
parameter, so this would still work.I still welcome any other feedback on this, but I'll mark this as the accepted answer.