castleproject / Core

Castle Core, including Castle DynamicProxy, Logging Services and DictionaryAdapter
http://www.castleproject.org/
Other
2.2k stars 467 forks source link

Support by-ref-like (`ref struct`) parameter types such as `Span<T>` and `ReadOnlySpan<T>` #663

Open stakx opened 1 year ago

stakx commented 1 year ago

DynamicProxy does not currently support those because by-ref-like values cannot be boxed to be put in the object[] IInvocation.Arguments array.

I haven't yet been able to think of a general way how all by-ref-like types (including user-defined ones) could be supported, ecause of their various limitations. If anyone has ideas, I'd be interested in hearing them!

However, it might be fairly easy to at least add support for Span<T> and ReadOnlySpan<T> specifically. Those types are becoming more and more common in the .NET FCL, so while not an ideal solution, it might still be sufficient for most use cases:

We could introduce a new type in DynamicProxy's namespace:

public interface ISpanMarshaller  // NOTE: outdated API proposal!
{
    object BoxSpan<T>(Span<T> span);
    object BoxReadOnlySpan<T>(ReadOnlySpan<T> readOnlySpan);
    ReadOnlySpan<T> UnboxReadOnlySpan<T>(object boxedReadOnlySpan);
    Span<T> UnboxSpan<T>(object boxedSpan);
}

Then we could introduce a new property to ProxyGenerationOptions, ISpanMarshaller SpanMarshaller { get; set; }, which would get injected into generated proxies so they could use it to transfer spans into and out of the object[] IInvocation.Arguments array.

I don't really like an addition that deals with two very specific types, but they are becoming more and more common, and I haven't yet been able to come up with a more general mechanism.

Update

See the PR linked further down for an updated proposal that supports arbitrary by-ref-like types, including user-defined ones.

Opinions, or alternate ideas, anyone?

stakx commented 1 year ago

I've done some quick prototyping. The extension proposed above is feasible and works.

(I'd however perhaps make one change to the proposal: instead of adding ISpanMarshaller SpanMarshaller { get; set; } to ProxyGenerationOptions, it might be better to add a Type SpanMarshallerType { get; set; } instead. Proxies would then instantiate a marshaller instance themselves, instead of getting one injected. This has a few benefits:

stakx commented 1 year ago

Scratch the above proposal, I think I can actually come up with one that works for arbitrary by-ref-like types. I'll post an updated feature proposal shortly.

stakx commented 1 year ago

I've put something together that appears to work... see the draft PR linked above (#664) for two short code examples how this would be used in practice. I'd be glad for some feedback (and whether the suggested API would actually be usable e. g. in downstream testing libraries).

/cc @thomaslevesque & @blairconrad (FakeItEasy), @dtchepak & @zvirja (NSubstitute), @jonorossi

thomaslevesque commented 1 year ago

Hey @stakx, nice job! Using converters is an interesting approach. I think it would work just fine for mocking scenarios, where performance isn't typically a critical concern. In other scenarios, the performance impact of converting spans to arrays is something to keep in mind. I don't see a better way, though... Something else to consider: there might not always be a sensible way of converting a ref struct to an object and still be able to convert back (I'm thinking of Utf8JsonReader, for instance). From FakeItEasy's perspective, I think the converters for Span<T>/ReadOnlySpan<T> would be provided directly by the library, and we could expose extension points to provide additional converters. BTW, is there a particular reason why converters don't implement an interface in your proposal?

stakx commented 1 year ago

Thanks for the feedback @thomaslevesque.

is there a particular reason why converters don't implement an interface in your proposal?

Yes, there is. ref struct types cannot be used as generic type arguments. So a proper interface type such as IByRefLikeConverter<TByRefLike> could never be instantiated.

stakx commented 1 year ago

there might not always be a sensible way of converting a ref struct to an object and still be able to convert back

True. I don't see what we could do about that, though... do you? If I understand your concern correctly, this would make it only impossible to do round-tripping of ref struct values during interception. "Half-trips" should still work since the Box / Unbox methods are only looked up & called when needed; in theory, if you don't need the conversion back from object (i.e. your ref struct type does not figure as a method return type or in a ref/out parameter) you wouldn't even need to provide the Unbox conversion method.

stakx commented 1 year ago

the performance impact of converting spans to arrays is something to keep in mind.

Indeed. Which is why the copying behavior shouldn't be the default behavior (for spans), and why I thought it would be a good idea for the converter selectors to pinpoint single method parameters, so you're free to choose to nullify most spans except where retaining the value somehow – e.g. as an array copy – is actually important. Being only able to set up a single converter globally that gets used for all by-ref-like parameters equally might be too coarse-grained.

I've also briefly considered doing weird unsafe pointer-based stuff (similar to what System.Runtime.CompilerServices.Unsafe does) instead of converters that simply convert to/from object, but apart from being unsure if it would've even worked, I don't think it would've resulted in a very nice, easy to understand API).

blairconrad commented 1 year ago

Thanks, @stakx. Slightly over my head, but in general, I think I understand the approach and the tradeoffs. Appreciate your work. Thanks for thinking of us to invite for comments.

The possible performance optimization by specifying the position of the parameter is interesting, and not something I would've thought of.

Just to make sure I understand everything, if I combine that power with a comment @thomaslevesque made earlier ("From FakeItEasy's perspective, I think the converters for Span<T>/ReadOnlySpan<T> would be provided directly by the library"), then I expect FakeItEasy would end up providing a selector not unlike the sample one you had:

// this will get invoked during proxy type generation:
class ConverterSelector : IByRefLikeConverterSelector
{
    public Type SelectConverterType(MethodInfo method, int parameterPosition, Type parameterType)
    {
        return parameterType == typeof(Span<byte>) ? typeof(CopyByteSpanConverter) : null;
    }
}

where we'd end up ignoring the parameterPosition.

stakx commented 1 year ago

@blairconrad, I suppose that you'd typically configure a converter out-of-the-box that checks for both Span<*> and ReadOnlySpan<*>, and optionally lets user code opt into the conversion process for all other types. And yes, it should typically suffice to only check the type and ignore the MethodInfo and the parameter position.

For mocking libraries, the selector's method body could look like this:

if (parameterType.IsGenericType)
{
    var def = parameterType.GetGenericTypeDefinition();
    var args = paramrterType.GetGenericArguments();
    if (def == typeof(Span<>))
    {
        return typeof(CopySpanConverter<>).MakeGenericType(args);
    }
    else if (def == typeof(ReadOnlySpan<>)
    {
        // as above but with a CopyReadOnlySpanConverter<> instead
    }
}

// for all other by-ref-like types, either
// * delegate to user-provided selector function; or
// * return null to let DynamicProxy nullify everything.

(Btw., I'm not 100% sure yet whether the Box method's parameter list is going to stay that way; it might also be possible to pass in a ParameterInfo which would tie those three parameters nicely together.)

thomaslevesque commented 1 year ago

Yes, there is. ref struct types cannot be used as generic type arguments. So a proper interface type such as IByRefLikeConverter<TByRefLike> could never be instantiated.

Ah, yes, of course. Thanks for the reply!

True. I don't see what we could do about that, though... do you?

No... I'm not even sure there is a solution. I'm just pointing out a limitation.

If I understand your concern correctly, this would make it only impossible to do round-tripping of ref struct values during interception. "Half-trips" should still work since the Box / Unbox methods are only looked up & called when needed; in theory, if you don't need the conversion back from object (i.e. your ref struct type does not figure as a method return type or in a ref/out parameter) you wouldn't even need to provide the Unbox conversion method.

Yeah, half-trips would probably work in most cases.