I am developing a C# incremental generator to act as a wrapper between managed and unmanaged callbacks in a generic context. That wrapper generates interfaces 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 interfaces 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, oroutparameter is passed to the constructor ofParamProxy<T>(aref struct) - The
ParamProxy<T>stores theref-qualified parameter in areffield - The
ParamProxy<T>object is passed toInvokeas ascopedparameter - The
Invokemethod then "forwards" thereffield's value to an appropriateref,in, oroutparameter of adelegate - The
delegateis invoked immediately, beforeInvokereturns
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
outparameters as a use-case for the attribute. The article I cite below states thatoutparameters are implicitlyscoped, and does not discussUnscopedRefAttribute.If I apply the attribute to the
outparameter (constructor of the original question), then I can assign_ref = ref outRef;. No tricky usage ofUnsafe.AsRefrequired to mask the escape scope error.Additionally, trying to intentionally allow the
outparameter to escape theParamProxy<T>.OutRefgives an error. As best as I can tell, the compiler does correctly recognize that the storedreffield is a reference to the originaloutparameter. Even with theUnscopedRefAttributeapplied, the compiler doesn't allow theoutparameter to escape through thereffield in a way that the parameter itself couldn't be used.I believe I have found my answer while reading more about the
scopedkeyword: Low Level Struct Improvements - Change the behavior of out parameters.While I'm still reasonably certain that in the expected use-case, the
outparameter object wouldn't go out-of-scope, it's conceivable that someone could abuse this, which is why I had to useUnsafe.AsRefonly in theoutconstructor.What's more, is (per the link above), this usage is explicitly disallowed in the language (for
outparameters). 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
refandinconstructors are valid, specifically because I did not make the constructor parametersscoped. If the parameters werescoped, then storing them in areffield would violate their "escape scope".The
outconstructor 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, arefis a valid argument for anoutparameter, so this would still work.I still welcome any other feedback on this, but I'll mark this as the accepted answer.