Provide reflection helper for mods
Pathoschild opened this issue · 7 comments
Mods often need to access private fields or methods, typically using fragile or unvalidated code. Provide a reflection helper that mods can optionally use instead.
The goal is to have a few simple utility methods relevant to most Stardew Valley mods, not to address every use case. (Mods that need more can use the .NET reflection API directly or use one of the existing reflection libraries.) Here are two possible approaches with that in mind.
Proposal A: simple utility methods
Interface
One option is to create a few simple utility methods which let mods get a private value or method directly:
public interface IReflectionHelper
{
/// <summary>Get a private field value.</summary>
/// <typeparam name="TValue">The field type.</typeparam>
/// <param name="parent">The parent object.</param>
/// <param name="name">The field name.</param>
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
TValue GetPrivateField<TValue>(object obj, string name, bool required = true);
/// <summary>Set a private field value.</summary>
/// <typeparam name="TValue">The field type.</typeparam>
/// <param name="parent">The parent object.</param>
/// <param name="name">The field name.</param>
/// <param name="value">The value to set.</param>
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
void SetPrivateField<TValue>(object obj, string name, TValue value, bool required = true);
/// <summary>Get a private method.</summary>
/// <param name="parent">The parent object.</param>
/// <param name="name">The field name.</param>
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
MethodInfo GetPrivateMethod(object parent, string name, bool required = true);
}
Usage
This would be exposed as a mod property (e.g. this.ReflectionHelper
) or helper property (e.g. helper.Reflection
). Here's how you would...
- Get a private field value:
string value = helper.Reflection.GetPrivateField<string>(obj, "fieldName");
- Set a private field value:
helper.Reflection.SetPrivateField<string>(obj, "fieldName", value);
- Cache a private field for later use: not possible.
- Call a private method and capture its return value:
string value = (string)helper.Reflection.GetPrivateMethod(obj, "methodName").Invoke(arguments);
Pros & cons
- Pros:
- ✓ Most common cases are very simple.
- ✓ Validated (e.g. a meaningful error will be thrown if the field or method doesn't exist).
- ✓ Somewhat flexible (e.g. you can fallback by setting
required: false
). - ✓ Self-contained interface, easy to change the implementation.
- Cons:
- ✘ Less common cases aren't supported.
- ✘ Mods can't cache the reflected fields.
- ✘ Handling edge cases like static fields would significantly increase the number of methods.
- ✘ Inconsistent (e.g. returns the value for fields, but a
MethodInfo
for methods).
Proposal B: wrapped reflection API
Interface
Another option is to provide a thin wrapper around .NET's build-in reflection API for validation and strong typing:
public interface IReflectionHelper
{
/// <summary>Get a private field value.</summary>
/// <typeparam name="TValue">The field type.</typeparam>
/// <param name="parent">The parent object which has the field.</param>
/// <param name="name">The field name.</param>
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
IFieldInfo<TValue> GetPrivateField<T>(object obj, string name, bool required = true);
/// <summary>Get a private method.</summary>
/// <param name="parent">The parent object which has the method.</param>
/// <param name="name">The field name.</param>
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
IMethodInfo GetPrivateMethod(object parent, string name, bool required = true);
}
The IFieldInfo<TValue>
would look something like this:
public interface IFieldInfo<TValue>
{
/// <summary>The reflection metadata.</summary>
FieldInfo FieldInfo { get; }
/// <summary>Get the field value.</summary>
TValue GetValue();
/// <summary>Set the field value.</summary>
//// <param name="value">The value to set.</param>
void SetValue(TValue value);
}
And the IMethodInfo
would look something like this:
public interface IMethodInfo
{
/// <summary>The reflection metadata.</summary>
MethodInfo MethodInfo { get; }
/// <summary>Invoke the method.</summary>
/// <typeparam name="TValue">The return type.</typeparam>
/// <param name="args">The method arguments.</param>
TValue Invoke<TValue>(params object[] arguments);
/// <summary>Invoke the method.</summary>
/// <param name="args">The method arguments.</param>
void Invoke(params object[] arguments);
}
Usage
This would be exposed as a mod property (e.g. this.ReflectionHelper
) or helper property (e.g. helper.Reflection
). Here's how you would...
- Get a private field value:
string value = helper.Reflection.GetPrivateField<string>(obj, "fieldName").GetValue();
- Set a private field value:
helper.Reflection.GetPrivateField<string>(obj, "fieldName").SetValue(value);
- Cache a private field for later use:
IFieldInfo<string> field = helper.Reflection.GetPrivateField<string>(obj, "fieldName");
- Call a private method and capture its return value:
string value = helper.Reflection.GetPrivateMethod(obj, "methodName").Invoke<string>(arguments);
Pros & cons
- Pros:
- ✓ Simple to use.
- ✓ Validated (e.g. a meaningful error will be thrown if the field or method doesn't exist).
- ✓ Flexible (you can get the underlying reflection API to do more).
- ✓ Self-contained interface, easy to change the implementation.
- ✓ Mods can cache the
IFieldInfo<TValue>
or underlyingFieldValue
. - ✓ Handling edge cases (like static fields) doesn't significantly increase complexity, since most of the methods are chained.
- Cons:
- ✘ The most common cases are slightly more complicated than proposal A.
Proposal B seems like a good approach simply due to the pro/con ratio largely out-weighing A. Having a standard utility for this will definitely be useful as I often have to drag the same reflection utilities between my own mods which can be a pain to maintain. 👍
I hereby suggest proposal C
where internally, it works like proposal B, but we wrap the mechanics so that users can use them as in proposal A, I have already setup a similar system myself, that also includes the technical side of making the reflection in both case A and B as efficient as doable: https://github.com/Entoarox/StardewMods/tree/master/Framework/Reflection
@Entoarox we can combine your approach nicely with the above proposals, so mod authors have cached access to both the full reflection API and simplified wrappers:
// use reflection API
FieldInfo field = GetPrivateField<string>(obj, "fieldName").FieldInfo;
// use wrapper
string value = GetPrivateField<string>(obj, "fieldName").GetValue();
// use it later
this.Field = GetPrivateField<string>(obj, "fieldName");
...
this.Field.GetValue();
This has a few advantages over any of the proposals individually:
- The reflection can be cleanly cached and optimised internally.
- If you store the field or method, you can use the same APIs on it (so there aren't multiple ways of doing something from the same API).
- The fluent interface is discoverable; for example, autocompletion after
GetPrivateField<T>(…)
will only show you field methods, so it's easy to write code quickly without scrolling through methods. - The fluent interface is easy to extend. For example, you could add a
field.HasValue()
by extendingIFieldInfo<T>
. It would be much messier to add that to proposal A (e.g. you'd haveFieldHasValue<T>(obj, "fieldName")
mixed in with the other methods, not even counting the static/non-static versions or flag). - It's easy to add your own top-level methods the same way.
Done in the develop
branch for the upcoming 1.4 release.
Implementation details:
- The helper supports static and instance reflection with appropriate overloads.
- The helper searches up the type hierarchy for a match (e.g. to get a private base field without casting to the base class).
- The helper throws meaningful exceptions if reflection fails. For example:
The {typeName} object doesn't have a private '{fieldName}' instance field.
Can't convert the private {typeName}.{fieldName} field from {fieldType} to {valueType}. - The reflected fields & methods are cached with a sliding expiry of 5 minutes (to optimise performance without unnecessary memory usage).
Here's what the reflection API looks like in practice:
See:
- reflection API's main code
- SMAPI-1.4-alpha-201612091456.zip (includes the new reflection API)