-
Notifications
You must be signed in to change notification settings - Fork 4.7k
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]: Add RuntimeHelpers.GetUserDefinedOperatorMethods for trimming-safe access to user-defined operators #97709
Comments
Tagging subscribers to this area: @dotnet/area-system-reflection Issue DetailsBackground and motivationLibraries such as In the context of System.Linq.Expressions, there is a necessity to resolve operators corresponding to specific expressions. Although the trimmer currently includes a hard-coded special case for System.Linq.Expressions to preserve user-defined operators, attempts to implement a similar workaround for AOT weren't successful (see #79016). Introducing a new API could effectively decouple the trimmer from the runtime, explicitly defining this currently hidden contract. In the case of .NET MAUI, invoking implicit operators through reflection is essential for converting values in data bindings and XAML property setters (see dotnet/maui#19922). Unfortunately, a reliable trimming-safe method for implementing this feature is currently lacking, posing challenges to making MAUI trimmable without introducing breaking changes for our customers. API Proposalnamespace System.Runtime.CompilerServices
{
public partial class RuntimeHelpers
{
public static MethodInfo[] GetUserDefinedOperatorMethods(Type type, string name, BindingFlags flags, Type[] types);
}
} The method will behave the same way as The method must throw when the name is not one of the operator method names ( Trimming semantics:
API UsageFor example, the new API could be used in place of public static bool TryCast(ref object value, Type targetType)
{
var cast = GetCastMethod(value.GetType(), from: value.GetType(), to: targetType)
?? GetCastMethod(targetType, from: value.GetType(), to: targetType);
if (cast is not null)
{
value = cast.Invoke(null, [value])!;
return true;
}
return false;
static MethodInfo? GetCastMethod(Type type, Type from, Type to)
{
const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.FlattenHierarchy;
return RuntimeHelpers.GetUserDefinedOperatorMethods(type, "op_Implicit", flags, [from])
.FirstOrDefault(m => to.IsAssignableFrom(m.ReturnType));
}
} Alternative Designs
// Conversion operators
public static MethodInfo[] GetImplicitOperatorMethods(Type type, Type parameter);
public static MethodInfo[] GetImplicitOperatorMethods(Type type, Type parameter, BindingFlags flags);
public static MethodInfo[] GetExplicitOperatorMethods(Type type, Type parameter);
public static MethodInfo[] GetExplicitOperatorMethods(Type type, Type parameter, BindingFlags flags);
// Binary operators
public static MethodInfo? GetAdditionOperatorMethod(Type type, Type left, Type right);
public static MethodInfo? GetAdditionOperatorMethod(Type type, Type left, Type right, BindingFlags flags);
public static MethodInfo? GetSubtractionOperatorMethod(Type type, Type left, Type right);
public static MethodInfo? GetSubtractionOperatorMethod(Type type, Type left, Type right, BindingFlags flags);
// + Multiply, Division, Modulus, LeftShift, RightShift, LessThan, GreaterThan, LessThanOrEqual,
// GreaterThanOrEqual, Equality, Inequality, BitwiseAnd, ExclusiveOr, BitwiseOr, LogicalNot
// Unary operators
public static MethodInfo? GetUnaryNegationOperatorMethod(Type type);
public static MethodInfo? GetUnaryNegationOperatorMethod(Type type, BindingFlags flags);
// + UnaryPlus, OnesComplement, True, False, Increment, Decrement
// Accepted names: "op_Implicit", "op_Explicit"
public static MethodInfo? GetConversionOperator(Type type, string name, Type convertFrom, Type convertTo);
public static MethodInfo? GetConversionOperator(Type type, string name, Type convertFrom, Type convertTo, BindingFlags flags);
// Accepted names: "op_Addition", "op_Subtraction", "op_Multiplication", ...
public static MethodInfo? GetBinaryOperator(Type type, string name, Type left, Type right);
public static MethodInfo? GetBinaryOperator(Type type, string name, Type left, Type right, BindingFlags flags);
// Accepted names: "op_True", "op_False", "op_UnaryNegation", ...
public static UnaryExpression? GetUnaryOperator(Type type, string name, Type parameter);
public static UnaryExpression? GetUnaryOperator(Type type, string name, Type parameter, BindingFlags flags);
RisksLow risk of misuse. The new API doesn't replace
|
Why is this method defined on My understanding is that System.Runtime.CompilerServices is meant for public APIs that we expect Roslyn (and other compilers) to use, but generally not used by developers. |
Its worth noting that It may be worthwhile to see if that can be utilized instead (either via new APIs, extensions, etc) as it is a much stronger and more robust pattern. |
@eerhardt I see. The method is supposed to be a thin wrapper on top of the |
It's not clear to me how this helps with System.Linq.Expressions. I think NativeAot would still need to implement the same heuristic as ILLink does (or a similar heuristic based on existing It seems like the MAUI usage is not trim-safe because the type is not statically known due to |
@sbomer Yes, I wasn't clear enough. ILC would need to recognize the method too and handle it the same way. I'll update the text to make this clear. My understanding is that adding new APIs to Expressions would be difficult so we should instead focus on the existing APIs and make them trimming-safe. Yes, we don't know the types statically and we can't change the APIs to be generic (yet). I'm not sure how we would narrow down the set of types where preserving the operators is required. The problem is that either the parameter type or the return type usually isn't statically known. Maybe ILLink/ILC could narrow down the operators that it preserves when for example the return type or some of the parameters are known statically? |
I don't remember we even attempted this; we track the work in #69745. I don't think it would be particularly hard. We just didn't implement it because #79016 would be preferable and LINQ expressions have other issues.
The proposed API will quite literally hamper trimming of the app :). Conversion operators typically The problem with wanting to access a method with some name and signature but not knowing on what type is more general than just the operators. We could solve this by just allowing |
Yes, it is of course a trade-off between keeping support for the existing feature and increasing app size. At least in the case of MAUI, it works around this issue by disabling trimming completely. If we allow trimming everything except for
I am not sure if this wouldn't be a breaking change for some customers. I can imagine scenarios where the type isn't statically known, but the developer only calls it with type where they make sure that the method is preserved using |
Tagging subscribers to 'linkable-framework': @eerhardt, @vitek-karas, @LakshanF, @sbomer, @joperezr, @marek-safar Issue DetailsBackground and motivationLibraries such as In the context of System.Linq.Expressions, there is a necessity to resolve operators corresponding to specific expressions. Although the trimmer currently includes a hard-coded special case for System.Linq.Expressions to preserve user-defined operators, attempts to implement a similar workaround for AOT weren't successful (see #79016). Introducing a new API could effectively decouple the trimmer from the runtime, explicitly defining this currently hidden contract. In the case of .NET MAUI, invoking implicit operators through reflection is essential for converting values in data bindings and XAML property setters (see dotnet/maui#19922). Unfortunately, a reliable trimming-safe method for implementing this feature is currently lacking, posing challenges to making MAUI trimmable without introducing breaking changes for our customers. API Proposalnamespace System.Runtime.CompilerServices
{
public partial class RuntimeHelpers
{
public static MethodInfo[] GetUserDefinedOperatorMethods(Type type, string name, BindingFlags flags, Type[] types);
}
} The method will behave the same way as The method must throw when the name is not one of the operator method names ( Trimming semantics
API UsageFor example, the new API could be used in place of public static bool TryCast(ref object value, Type targetType)
{
var cast = GetCastMethod(value.GetType(), from: value.GetType(), to: targetType)
?? GetCastMethod(targetType, from: value.GetType(), to: targetType);
if (cast is not null)
{
value = cast.Invoke(null, [value])!;
return true;
}
return false;
static MethodInfo? GetCastMethod(Type type, Type from, Type to)
{
const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.FlattenHierarchy;
return RuntimeHelpers.GetUserDefinedOperatorMethods(type, "op_Implicit", flags, [from])
.FirstOrDefault(m => to.IsAssignableFrom(m.ReturnType));
}
} Alternative Designs
// Conversion operators
public static MethodInfo[] GetImplicitOperatorMethods(Type type, Type parameter);
public static MethodInfo[] GetImplicitOperatorMethods(Type type, Type parameter, BindingFlags flags);
public static MethodInfo[] GetExplicitOperatorMethods(Type type, Type parameter);
public static MethodInfo[] GetExplicitOperatorMethods(Type type, Type parameter, BindingFlags flags);
// Binary operators
public static MethodInfo? GetAdditionOperatorMethod(Type type, Type left, Type right);
public static MethodInfo? GetAdditionOperatorMethod(Type type, Type left, Type right, BindingFlags flags);
public static MethodInfo? GetSubtractionOperatorMethod(Type type, Type left, Type right);
public static MethodInfo? GetSubtractionOperatorMethod(Type type, Type left, Type right, BindingFlags flags);
// + Multiply, Division, Modulus, LeftShift, RightShift, LessThan, GreaterThan, LessThanOrEqual,
// GreaterThanOrEqual, Equality, Inequality, BitwiseAnd, ExclusiveOr, BitwiseOr, LogicalNot
// Unary operators
public static MethodInfo? GetUnaryNegationOperatorMethod(Type type);
public static MethodInfo? GetUnaryNegationOperatorMethod(Type type, BindingFlags flags);
// + UnaryPlus, OnesComplement, True, False, Increment, Decrement
// Accepted names: "op_Implicit", "op_Explicit"
public static MethodInfo? GetConversionOperator(Type type, string name, Type convertFrom, Type convertTo);
public static MethodInfo? GetConversionOperator(Type type, string name, Type convertFrom, Type convertTo, BindingFlags flags);
// Accepted names: "op_Addition", "op_Subtraction", "op_Multiplication", ...
public static MethodInfo? GetBinaryOperator(Type type, string name, Type left, Type right);
public static MethodInfo? GetBinaryOperator(Type type, string name, Type left, Type right, BindingFlags flags);
// Accepted names: "op_True", "op_False", "op_UnaryNegation", ...
public static UnaryExpression? GetUnaryOperator(Type type, string name, Type parameter);
public static UnaryExpression? GetUnaryOperator(Type type, string name, Type parameter, BindingFlags flags);
RisksLow risk of misuse. The new API doesn't replace
|
I'm assuming that means an interface would need to be created for each user-defined operator, and then somehow registered or discoverable by LINQ, for example. That would be a breaking change for LINQ, which is not wanted here. Thoughts @simonrozsival? |
I am not sure how we could apply the interfaces from generic math to LINQ and MAUI. I also don't think we would be able to add similar new APIs to these libraries, especially to LINQ. |
I think this depends on how we set the goals. For example, there was a discussion of a trimming mode where types would always be kept in their entirety. So if one reflection-grabs a type, calling GetMethod/GetField/GetXXX on it will always work. The only trim-unsafe API would be the likes of Type.GetType, Assembly.Load, etc. It would be very compatible with existing .NET code - most people won't have to do anything, tons of unsafe pattern (including reflection serialization) will still work. But it would not be possible to reach sizes competitive with non-.NET languages. If our goal is just to have sizes smaller than existing .NET, this would be a good addition. If our goal is to be competitive with non-.NET languages, additions like this make it harder to reach that goal. The discussed behavior is closer to the "compatible, but we trim a lot less". It's a valid option if we consider it more important to not break existing code than to get the best trimming. It would be good to collect some numbers on how much keeping all operators would cost us in size vs doing a breaking change in MAUI here and let them trim (e.g. we could make this so this code is only active for TrimMode=partial, but disabled in TrimMode=full, including F5 debugging). The comment at dotnet/maui#19922 (comment) seems to indicate this might not be very popular to begin with.
Could you expand on the breaking change aspect? Is that about the warning suppression no longer suppressing the new warning? (It sounds solvable.) |
Yes, this proposal goes definitely in the "compatible, but we trim a lot less" direction. It seems like a reasonable solution to this problem to me, especially for LINQ, which already has this workaround in ILLink. I agree that especially in MAUI, where there's more flexibility in the API, we might want to be more ambitious and try to compete with the native solutions on both Android and iOS. I will measure the size difference of MAUI app with and without trimmed operators and I'll get back to you.
If I understood your previous idea that would increase the scope from operators to all methods correctly, calling class A { public void X() {} }
class B { public void X() {} } // previously B.X could be trimmed
[DynamicDependency("X", typeof(A))]
void Y(object value)
{
var x = value.GetType().GetMethod("X");
// ...
} |
They would not opt out. We always reserve the right to add intrinsic handling for cases that were previously not intrinsically handled; we don't create opt outs for those. The intrinsic handling is always correct with respect to the API and what is observable by trim safe code. It might end up more broad than what was done by a targeted suppression (that is no longer needed after the intrinsic handling). We sometimes keep broader set of things if there's not enough information. |
@MichalStrehovsky Thanks for the clarification with
I collected the numbers for a basic MAUI app. I rooted all the implicit operators that are defined in the MAUI codebase using [DynamicDependency] and that changed the size of the final (compressed) bundle by 9.6 kB (0.17%), sizoscope shows difference in the code size of 19.6 kB. As Jonathan mentions in the issue, it's a niche feature. |
Background and motivation
Libraries such as
System.Linq.Expressions
or MAUI often require the invocation of user-defined operators through reflection. Unfortunately, achieving trimming-safe code by utilizing annotations with Type.GetMethod or Type.GetMethods is not possible in certain scenarios.In the context of System.Linq.Expressions, there is a necessity to resolve operators corresponding to specific expressions. Although the trimmer currently includes a hard-coded special case for System.Linq.Expressions to preserve user-defined operators, attempts to implement a similar workaround for AOT weren't successful (see #79016). Introducing a new API could effectively decouple the trimmer from the runtime, explicitly defining this currently hidden contract.
In the case of .NET MAUI, invoking implicit operators through reflection is essential for converting values in data bindings and XAML property setters (see dotnet/maui#19922). Unfortunately, a reliable trimming-safe method for implementing this feature is currently lacking, posing challenges to making MAUI trimmable without introducing breaking changes for our customers.
API Proposal
The method will behave the same way as
Type.GetMethod(string name, BindingFlags flags, Type[] types)
but instead of throwingAmbiguousMatchException
in case of multiple matches, the method returns all matches in an array.The method must throw when the name is not one of the operator method names (
op_Implicit
,op_Addition
, ...) and filter out any matching methods which don't have a special name. This API is not meant to allow trimming-safe access to arbitrary methods via name. This functionality could be easily misused by developers and unintentionally hamper trimming of their apps.Trimming semantics
type
is annotated with[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)]
the trimmer doesn't need to do any additional work.name
isn't a constant string, the trimmer should produce a trimming warning.name
is a constant string, the trimmer should preserve all operators with the given name on all preserved types and it should not produce any warning.name
isn't a valid operator name, the method should throw an InvalidOperationException and the trimmer should produce a compile-time trimming warning.API Usage
For example, the new API could be used in place of
Type.GetMethods(BindingFlags)
in the MAUI codebase:Alternative Designs
System.Linq.Expressions has a hard-coded special case in the trimmer and .NET MAUI could implement a workaround.
Risks
Low risk of misuse. The new API doesn't replace
Type.GetMethod
andType.GetMethods
, it is deliberately limited to user-defined operators.The text was updated successfully, but these errors were encountered: