Search code examples
c#parametersfieldref-struct

Forwarding ref parameters through a ref struct with "unsafe" ref punning - is this safe?


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, or out parameter is passed to the constructor of ParamProxy<T> (a ref struct)
  • The ParamProxy<T> stores the ref-qualified parameter in a ref field
  • The ParamProxy<T> object is passed to Invoke as a scoped parameter
  • The Invoke method then "forwards" the ref field's value to an appropriate ref, in, or out parameter of a delegate
  • The delegate is invoked immediately, before Invoke 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!


Solution

  • 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.