-
Notifications
You must be signed in to change notification settings - Fork 4.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[API Proposal]: UnsafeAccessorTypeAttribute
for static or private type access
#90081
Comments
Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices Issue DetailsBackground and motivationThe Consider the following two scenarios involving methods. Note that fields suffer from the same issue. Scenario 1 - Private type// Assembly A
private class C
{
private static int Method(int a) { ... }
} // Assembly B
[UnsafeAccessor("Method")]
static extern int CallMethod(??? c, int a); // One cannot write type C due to visibility. Scenario 2 - Static type// Assembly A
public static class C
{
private static int Method(int a) { ... }
} // Assembly B
[UnsafeAccessor("Method")]
static extern int CallMethod(??? c, int a); // A static class cannot be used as a parameter in a signature. API ProposalThe following attribute would accept a fully qualified or partially qualified type name to use for member look-up. This attribute would only be referenced if the namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class UnsafeAccessorTypeAttribute : Attribute
{
/// <summary>
/// Instantiates an <see cref="UnsafeAccessorTypeAttribute"/> providing access to a type supplied by <paramref name="typeName"/>.
/// </summary>
/// <param name="typeName">A fully qualified or partially qualified type name.</param>
public UnsafeAccessorTypeAttribute(string typeName)
{
TypeName = typeName;
}
/// <summary>
/// Fully qualified or partially qualified type name to target.
/// </summary>
public string TypeName { get; init; }
} API UsageScenario 1 - Private type// Assembly A
private class C
{
private static int Method(int a) { ... }
} // Assembly B
[UnsafeAccessor("Method")]
[UnsafeAccessorType("C, A, Version=1.0.0.0, Culture=neutral")] // Look up type here as opposed to signature
static extern int CallMethod(int a); Scenario 2 - Static type// Assembly A
public static class C
{
private static int Method(int a) { ... }
} // Assembly B
[UnsafeAccessor("Method")]
[UnsafeAccessorType("C, A")] // Look up type here as opposed to signature
static extern int CallMethod(int a); Alternative DesignsExpand the It could be possible to permit RisksNo response
|
UnsafeAccessorTypeAttribute
for static and private types accessUnsafeAccessorTypeAttribute
for static or private type access
What's the downside to:
? That seems cleaner and easier to reason about than needing a second attribute. |
The idea in #81741 (comment) was that this attribute is applied to parameter and return types. We need to be able to assign the private type for each parameter and return type to be able to call arbitrary methods with non-visible types in the signature. internal class A
{
private class B
{
}
private class C
{
}
// How can one call this method using `UnsafeAccessor`?
private void M(B a, C b)
{
}
} |
Ah. I was focusing too much on the |
What would the full signature of the unsafe accessor method for C.M look like in that case? |
Something like the following. /// Assembly NonVisibleTypes.dll
internal class A
{
private class B
{
}
private class C
{
}
// How can one call this method using `UnsafeAccessor`?
private static void M(B a, C b)
{
}
} // Consuming assembly
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "M")]
[UnsafeAccessorType("A, NonVisibleTypes")]
static extern void CallMethod(
[UnsafeAccessorType("A+B, NonVisibleTypes")] object a,
[UnsafeAccessorType("A+C, NonVisibleTypes")] object b); |
Nit: This is instance method so you need to have the
(This is for my example that has instance method.) |
Yep. Let me update the examples. I've been a bit loose here. |
We will need to decide whether the implementation should do the casts from object to the actual type as regular throwing casts or as unsafe casts. There is a good argument that can be made for either option. |
If this is for highest performance scenarios, I think the "unsafe casts" is what we want. The obvious downside here is destabilizing the runtime and creating painful bugs to hunt down. I think the question is do we expect users of the |
Will you be able to call methods which take private structs / use the types in generics? If so what type would you write? // Assembly A
public class C
{
private struct D { }
private static int Method1(D d) { ... }
private static int Method2(ref D d) { ... }
private static int Method3(delegate* managed<List<D>, D*, TypedReference*, void> d) { ... }
private static int Method4(delegate* managed<in D, void> d) { ... }
private static int Method4(delegate* managed<ref D, void> d) { ... } //to throw off resolution
}
// Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method1")]
static extern int CallMethod1(??? c, int a);
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method2")]
static extern int CallMethod2(??? c, int a);
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method3")]
static extern int CallMethod3(??? c, int a);
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method4")]
static extern int CallMethod4(??? c, int a); My guess would be that all of the following would be allowed: [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method1")]
static extern int CallMethod1([UnsafeAccessorType("C+D, A")] ref byte c, int a); //allowed since D is a struct
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method2")]
static extern int CallMethod2([UnsafeAccessorType("C+D&, A")] ref byte c, int a); //allowed since parameter is a ref
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method1")]
static extern int CallMethod1_Alt([UnsafeAccessorType("C+D, A")] TypedReference c, int a); //allowed for any type of parameter which you can take a TypedReference to - unchecked type?
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method2")]
static extern int CallMethod2_Alt([UnsafeAccessorType("C+D&, A")] TypedReference c, int a); //allowed for any type of parameter which you can take a TypedReference to - unchecked type since it's a ref
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method3")]
static extern int CallMethod3([UnsafeAccessorType("What atrocity would go here?")] void* c, int a); //allowed since it takes a pointer
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method4")]
static extern int CallMethod4([UnsafeAccessorType("Whatever goes here for delegate* managed<in D, void>)")] void* d, int a); //could we allow specifying modifiers with this? this way it could still be used in any cases of ambiguity, e.g. between fn pointer overloads but when the type has some type parameter of a private type. |
If no validation is being performed, what benefit is the attribute providing? Presumably we're not attempting to provide any kind of overload resolution; without that or validation, what purpose does providing a type name serve? |
Member resolution pertaining to overloads would be one. |
We do perform a signature look up on the matching member. For example, in the following scenario we would select the proper public class A
{
private class B() {}
private class C() {}
private void M(B b) { }
private void M(C b) { }
} |
Overload resolution is insanely complicated. What rules are we using? Are we matching C# overload resolution rules? Do we layer on top of that rules pertaining to things that don't exist in C#, like overloading on return type? Or is this not really overload resolution and we're just requiring every type to match 100% with its counterpart, i.e. no base types, no derived types, etc.? If so, I understand now. |
The latter. We aren't performing the complete .NET overload resolution logic at all. We focus on the specific type, don't walk the type hierarchy, and match on full .NET signature (minus custom modifiers). If we detect ambiguity we perform the look-up again but include custom modifiers. If we still detect an ambiguity, then we throw an Ambiguous test: runtime/src/tests/baseservices/compilerservices/UnsafeAccessors/UnsafeAccessorsTests.cs Lines 510 to 523 in c0967dc
|
I'm not seeing a way to manage fields of internal types, would it be possible to also support: Referenced assemblypublic struct A
{
private B _b;
}
internal struct B
{
private C _c;
}
internal struct C; Consuming assembly// Get a field of a private type, from a public type instance
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_b")]
[return: UnsafeAccessorType("B, AssemblyName")]
public static extern ref byte GetB(ref A a);
// Get a field of a private type, from a private type instance
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_c")]
[return: UnsafeAccessorType("C, AssemblyName")]
public static extern ref byte GetC([UnsafeAccessorType("B, AssemblyName")] ref byte b); Return type can always be |
This is already called out a bit in #90081 (comment), but I think it's worth asking explicitly. How will this attribute work with generics (once we support // Module1
internal class G<K> {
internal List<K> GetElements() {... };
};
// Module2
[UnsafeAccessor(UnsafeAccessorKind.Field, Name="GetElements")]
[UnsafeAccessorType("G`1, Module2")] // is this ok?
//[UnsafeAccessorType("G`1<!!0>, Module2")] // or do we need to spell out the instantiation with the !!0 ("T") mvar below?
[return: UnsafeAccessorType("System.Collections.Generic.List<!!0>, System.Runtime")] // how to specify the method return type?
object GetElementsAccessor<T>();
What will be the scope for type parameters? Or do we use the (I think methods are simpler but for fields we might also need to be explicit about return types and the type of self (for generic structs) being byref?) |
How about using compiler preprocessing directives instead? Some might argue that this is dangerous, but so is Reflection, The biggest problem I can see is how to "expose" structs from other assemblies. Even wrapping them would be a problem for ref structs, I think. I called the directive Of course, if this is implemented, it would make Edit: // Module1
namespace Module1;
struct Matrix3x3
{
public Vector3 R1, R2, R3;
...
}
internal ref struct A
{
public ref int SomeValue;
public readonly Span<Matrix3x3> Matrices;
}
internal class B
{
private int _something;
internal string Text { get; private set; }
}
//----
// Module2
#pragma internals enable
namespace Module2;
var a = new Module1.A();
var keyLength = System.IO.Path.KeyLength; // private const int KeyLength = 8;
string path = @"C:\Users\MyName\Documents";
var ix = System.IO.Path.GetDirectoryNameOffset( path ); // internal static int GetDirectoryNameOffset(ReadOnlySpan<char> path)
// keep the memory layout from Module1.Matrix3x3
struct Matrix3x3
{
public Vector3 R1, R2, R3;
...
}
// this way of wrapping won't work.
ref struct ImpossibleWrappedA
{
ref Module1.A _wrappedA; // Error CS9050: A ref field cannot refer to a ref struct.
}
// no idea how to do this properly. Or in this case why.. Silly example.
ref struct WrappedA
{
public ref int SomeValue;
public Span<Matrix3x3> Matrices
internal WrappedA( Module1.A toWrap )
{
SomeValue = ref toWrap.SomeValue;
Matrices = MemoryMarshal.Cast<Module1.Matrix3x3, Matrix3x3>( toWrap.Matrices );
}
}
struct WrappedB
{
Module1.B _value;
// expose private field of wrapped object.
public ref int Something => ref _value._something;
public string Text
{
get => _value.Text;
set => _value.Text = value; // set even though it's private in Module1.B
}
}
// expose interal methods from System.IO.Path.
class PathEx
{
public static int GetDirectoryNameOffset(ReadOnlySpan<char> path)
=> System.IO.Path.GetDirectoryNameOffset( path );
}
#pragma internals disable |
Right. To avoid having to reference those names we would need to create an interface for every one of these types. |
Safe cast in debug builds, unsafe in release builds :) |
@AndriySvyryd Is this needed for .NET 9? This isn't high on my priority list and I was going to start this in .NET 10. |
@AaronRobinsonMSFT It's not high priority, we can use reflection as a workaround for now |
@AndriySvyryd Great. This will likely be done in vNext, unless we hear of a blocking issue. |
This looks a bit limited to me, as you cannot passing a Private type// Assembly A
private class C
{
private static int Method(int a) { ... }
} Usage: // Assembly B
[UnsafeAccessorType("C, A")]
class CProxy { }
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "Method")]
extern static int M(CProxy target, int a);
var proxy = new CProxy();
M(proxy, 42); Static type// Assembly A
public static class C
{
private static int Method(int a) { ... }
} Usage: // Assembly B
[UnsafeAccessorType("C, A")]
class CProxy { }
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "Method")]
extern static int M(CProxy target, int a);
var proxy = new CProxy();
M(proxy, 42); Private class parameters// Assembly A
public class C
{
private class D { }
private static int Method(D d) { ... }
} Usage: // Assembly B
[UnsafeAccessorType("C+D, A")]
class DProxy { }
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "Method")]
extern static int M(C target, DProxy d);
M(new C(), new DProxy()); This will allow us to define a struct or ref struct as "proxy" type to be used in the unsafe accessor. |
The proxy approach is interesting and something we can consider, but I'm inclined to not try to fix this in a suboptimal manner. Our goal here would be to eventually use |
I quite like the proxy approach personally; I think it's the best idea I've seen so far, and solves the problem in full generality without unnecessary boxing or indirection. If it was implemented so that
This (using TypedReferences) feels more useful for general reflection (i.e., dynamically selecting the type & member, etc.) to me, which isn't what we're trying to solve here. Forcing TypedReference doesn't seem necessary or beneficial to solve the limitations of UnsafeAccessor to me. |
This isn't true. It wouldn't work for return types that were For value types and ByRefLike types, the situation would be even more suspect if they contained state, as the definition would be a lie and the local allocation would create issues. The The proxy approach would really only work for "static" scenarios and only when it is the target of a dispatch call. However, this still creates issues with creating an unsafe definition of a type that isn't that type. |
The idea is that the runtime treats it like it actually is the type mentioned in the attribute - similar to a type forward, so declaring it as a local would be fine if allowed.
Not sure what you mean by this. It should work much at least as generally & more easily than specifying the type in a string.
It doesn't seem like more of a lie than something like InlineArray or TypeForwardedTo to me. Or even ref assemblies, since they often lie about types of fields in structs. |
So for example, This would be a new fundamental type system feature, orthogonal to the existing |
Hmm ok, makes sense. Perhaps we could do it like this then: make This should mostly side-step the issues with complicating the type system by having more mechanisms of type forwarding I'd think, since it just guarantees specific things about layout & guarantees about compatibility of some types e.g., in function pointer signatures (which would be unsafe to write/consume anyway). A lot of this work would be done if we did transparent type layout feature anyway. Maybe this has some issues too, but I thought it was at least worth mentioning as an option :) Edit: I suppose this would have some issues with generics actually. |
I was clarifying that to be safe, it would only be possible to call a static method or dispatch from this proxy type. It wouldn't be safe to return anything because of the type issue that mentioned #90081 (comment) and the general safety issues it is violating for what can be done with the type.
This is a false equivalent comparison. The InlineArray is a well-defined semantic that is handled by the Type Loader and all the type information is available in metadata and is simply expanded. TypeForwardedTo is simply pointing were to look, not defining a new name for a type.
This would make the feature require additional language enforcement, which the current proposal has no such requirement or need to be used "safely". It would make using the feature in other .NET languages more unsafe than in C#. Can you elaborate on the issue being observed here? The |
@AaronRobinsonMSFT I've changed my mind after @jkotas 's comments & thinking about it further. I am now not convinced the proxy type approach will be any better overall (and probably is much more complex to implement), assuming we can encode any type into the string format somehow. |
I've been thinking about this lately and came up with a design similar to @hez2010's proxy types. To address some of the feedback from that proposal, we don't have to add support for full type equivalence in the runtime and can keep the type proxying scoped to signature matching for namespace System.Runtime.CompilerServices;
public ref struct UnsafeAccessorValue<T>
{
public static implicit operator UnsafeAccessorValue(object? obj);
public static implicit operator object? (UnsafeAccessorValue<T> value);
// After we add language support for TypedReference:
// public static implicit operator UnsafeAccessorValue(TypedReference typedref);
// public static implicit operator TypedReference (UnsafeAccessorValue<T> value);
}
// For passing by-ref parameters we might need an UnsafeAccessorReference<T> type.
// We can also name them UnsafeAccessorBy(Value|Reference) On signature matching, if ExamplesInstance method on internal type// Assembly A
internal class C
{
private int Method(int a) { ... }
} Usage// Assembly B
[UnsafeAccessorType("C, A")]
class CProxy;
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Method")]
extern static int M(UnsafeAccessorValue<CProxy> target, int a);
object obj = /* Obtain an object of type C somehow. */;
M(obj, 42); Static type// Assembly A
public static class C
{
private int Method(int a) { ... }
} Usage// Assembly B
[UnsafeAccessorType("C, A")]
class CProxy;
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "Method")]
// It's a static class, we won't instantiate it and don't have to wrap it in an UnsafeAccessorValue.
extern static int M(CProxy target, int a);
M(null, 42); Private class parameters// Assembly A
public class C
{
private class D;
private static int Method(D d) { ... }
} Usage:// Assembly B
[UnsafeAccessorType("C+D, A")]
class DProxy;
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "Method")]
extern static int M(C target, UnsafeAccessorValue<DProxy> d);
object obj = /* Obtain an object of type D somehow. */;
M(null, obj); Creating internal class// Assembly A
internal class C
{
public C(int x) { ... }
} Usage:// Assembly B
[UnsafeAccessorType("C, A")]
class CProxy;
[UnsafeAccessor(UnsafeAccessorType.Constructor)]
extern static UnsafeAccessorValue<C> M(int x);
object obj = M(42); This example can naturally extend to picking an overload by return type, which is not currently possible in C#. I think returning an unspecified ref struct is impossible so the runtime should throw |
This would make for an increase of complexity actually and I'm not seeing the gain. Without the Consider the case where the private type(s) are an argument to the member in the middle. This would mean the signature on // Assembly A
internal class A;
internal class B;
internal struct C;
internal class D
{
public D(A a, B b, C c) { ... }
} |
Actually, we're going to need to rewrite the signature anyways. Boo. What is the actual gain/clarity provided by using Reference types: Note this is considered an advanced scenario and will generally be used as an internal implementation detail for source generated code. It is not designed/intended to be used in general scenarios. |
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue, AllowMultiple = false, Inherited = false)]
public sealed class UnsafeAccessorTypeAttribute : Attribute
{
/// <summary>
/// Instantiates an <see cref="UnsafeAccessorTypeAttribute"/> providing access to a type supplied by <paramref name="typeName"/>.
/// </summary>
/// <param name="typeName">A fully qualified or partially qualified type name.</param>
/// <remarks>
/// <paramref name="typeName"> is expected to follow the same rules as if it were being
/// passed to <see name="Type.GetType(String)"/>.
/// </remarks>
public UnsafeAccessorTypeAttribute(string typeName)
{
TypeName = typeName;
}
/// <summary>
/// Fully qualified or partially qualified type name to target.
/// </summary>
public string TypeName { get; }
} |
For the use case of public static types, it would be convenient and more safe to have a constructor overload that takes a public UnsafeAccessorTypeAttribute(Type type)
{
TypeName = type.AssemblyQualifiedName;
} // Assembly B
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name="Method")]
static extern int CallMethod([UnsafeAccessorType(typeof(C))] object? c, int a); |
Background and motivation
The
UnsafeAccessorAttribute
mechanism was designed to provide access to a non-visible static or instance member (that is, method or field). There are however limitations with this design when involvingstatic
types or accessing members on non-visible types. This attribute helps bridge that gap.Consider the following two scenarios involving methods. Note that fields suffer from the same issue.
Scenario 1 - Private type
Scenario 2 - Static type
Scenario 3 - Private class parameters
API Proposal
The following attribute would accept a fully qualified or partially qualified type name to use for member look-up. The string supplied via the
typeName
would be in a format that is accepted byType.GetType(string typeName)
. This API will be specifcally limited by what that API is capable of. There may be additional restrictions for the initial implementation of this. For example, it only works on reference types for now.API Usage
Scenario 1 - Private type
Scenario 2 - Static type
Scenario 3 - Private class parameters
Alternative Designs
Expand the
UnsafeAccessorAttribute
attribute to have an optional type target field/property. Note This API option wouldn't address scenario (3).Risks
No response
The text was updated successfully, but these errors were encountered: