Background:
I am building an editor extension for Unity (although this question is not strictly unity related). The user can select a binary operation from a dropdown and the operation is performed on the inputs, as seen in the diagram:
The code is taken from a tutorial, and uses an enum
Problem
Based on my prior experience programming in other languages, and my desire to allow for user-extensible operations that don't require users to edit a switch statement in the core code, I would LIKE the resulting code to look something like this (invalid) C# code:
... snip ...
// OperatorSelection.GetSelections() is automagically populated by inheritors of the GenericOperation class
// So it would represent a collection of types?
// so the confusion is primarily around what type this should be
public GenericOperations /* ?? */ MathOperations = GenericOperation.GetOperations();
// this gets assigned by the editor when the user clicks
// the dropdown, but I'm unclear on what the type should
// be since it can be one of several types
// from the MathOperations collection
public Operation /* ?? */ operation;
public override object GetValue(NodePort port)
{
float a = GetInputValue<float>("a", this.a);
float b = GetInputValue<float>("b", this.b);
result = 0f;
result = operation(a, b);
return result;
}
... snip ...
Reference Behavior To be crystal clear about the kind of behavior I'm hoping to achieve, here is a reference implementation in Python.
class GenericOperation:
@classmethod
def get_operations(cls):
return cls.__subclasses__()
class AddOperation(GenericOperation):
def __call__(self, a, b):
return a b
if __name__ == '__main__':
op = AddOperation()
res = op(1, 2)
print(res) # 3
print(GenericOperation.get_operations()) # {<class '__main__.AddOperation'>}
Specific Questions So ultimately this boils down to three interrelated questions:
What sort of type do I assign to
MathOperations
so that it can hold a collection of the subtypes ofGenericOperation
?How do I get the subtypes of
GenericOperation
?What type do I assign
operation
, which can be one of several types?
Work So Far
I have been looking into generics and reflection from some of the following sources, but so far none seem to provide exactly the information I'm looking for.
- https://docs.microsoft.com/en-us/dotnet/csharp/fundamentals/types/generics
- https://igoro.com/archive/fun-with-c-generics-down-casting-to-a-generic-type/
- Using enum as generic type parameter in C#
- https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/generics-and-reflection
Edit: I edited the comments in the C# psuedocode to reflect that the primary confusion boils down to what the types should be for MathOperations
and operation
, and to note that the editor itself selects the operation
from the MathOperations
when the user clicks on the dropdown. I also changed the question so that they can be answered factually.
CodePudding user response:
Usually I'd say your question is quite broad and the use case very tricky and requires a lot of not so trivial steps to approach. But I see you also have put quite an effort in research and your question so I'll try to do the same (little Christmas Present) ;)
In general I think generics is not what you want to use here. Generics always require compile time constant parameters.
As I am only on the phone and don't know I can't give you a full solution right now but I hope I can bring you into the right track.
1. Common Interface or base class
I think the simplest thing would rather be a common interface such as e.g.
public interface ITwoFloatOperation
{
public float GetResult(float a, float b);
}
A common abstract
base class would of course do as well. (You could even go for a certain attribute on methods)
And then have some implementations such as e.g.
public class Add : ITwoFloatOperation
{
public float GetResult(float a, float b) => a b;
}
public class Multiply : ITwoFloatOperation
{
public float GetResult(float a, float b) => a * b;
}
public class Power : ITwoFloatOperation
{
public float GetResult(float a, float b) Mathf.Pow(a, b);
}
... etc
2. Find all implementations using Reflection
You can then use Reflection (you already were on the right track there) in order to automatically find all available implementations of that interface like e.g. this
using System.Reflection;
using System.Linq;
...
var type = typeof(ITwoFloatOperation);
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p));
3. Store/Serialize a selected type in Unity
Now you have all the types ...
However, in order to really use these within Unity you will need an additional special class that is [Serializable]
and can store a type e.g. like
[Serializable]
// See https://docs.unity3d.com/ScriptReference/ISerializationCallbackReceiver.html
public class SerializableType : ISerializationCallbackReceiver
{
private Type type;
[SerializeField] private string typeName;
public Type Type => type;
public void OnBeforeSerialize()
{
typeName = type != null ? type.AssemblyQualifiedName : "";
}
public void OnAfterDeserialize()
{
if(!string.NullOrWhiteSpace(typeName)) type = Type.GetType(typeName);
}
}
4. Interface type selection and drawing the drop-down
Then since you don't want to type the names manually you would need a special drawer for the drop down menu with the given types that implement your interface (you see we are connecting the dots).
I would probably use an attribute like e.g.
[AttributeUsage(AttributeTarget.Field)]
public ImplementsAttribute : PropertyAttribute
{
public Type baseType;
public ImplementsAttribute (Type type)
{
baseType = type;
}
}
You could then expose the field as e.g.
[Implements(typeof (ITwoFloatOperation))]
public SerializableType operationType;
and then have a custom drawer. This depends of course on your needs. Honestly my editor scripting knowledge is more based on MonoBehaviour etc so I just hope you can somehow translate this into your graph thingy.
Something like e.g.
[CustomPropertyDrawer(typeof(ImplementsAttribute))]
public class ImplementsDrawer : PropertyDrawer
{
// Return the underlying type of s serialized property
private static Type GetType(SerializedProperty property)
{
// A little bit hacky we first get the type of the object that has this field
var parentType = property.serializedObject.targetObject.GetType();
// And then once again we use reflection to get the field via it's name again
var fi = parentType.GetField(property.propertyPath);
return fi.FieldType;
}
private static Type[] FindTypes (Type baseType)
{
var type = typeof(ITwoFloatOperation);
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p));
return types.OrderBy(t => t.AssemblyQualifiedName).ToArray();
}
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
label = EditorGUI.BeginProperty(position, label, property);
var implements = attribute as ImplementsAttribute;
if (GetType(property) != typeof (SerializableType))
{
EditorGUI.HelpBox(position, MessageType.Error, "Implements only works for SerializableType!");
return;
}
var typeNameProperty = property.FindPropertyRelative("typeName");
var options = FindTypes (implements.baseType);
var guiOptions = options.Select(o => o.AssemblyQualifiedName).ToArray();
var currentType = string.IsNullOrWhiteSpace(typeNameProperty.stringValue) ? null : Type.GetType(typeNameProperty.stringValue);
var currentIndex = options.FindIndex(o => o == curtentType);
var newIndex = EditorGUI.Popup(position, label.text, currentIndex, guiOptions);
var newTypeName = newIndex >= 0 ? options[newIndex] : "";
property.stringValue = newTypeName;
EditorGUI.EndProperty();
}
}
5. Using the type to create an instance
Once you somehow can store and get the desired type as a last step we want to use it ^^
Again the solution would be reflection and the Activator
which allows us to create an instance of any given dynamic type using Activator.CreateInstance
so once you have the field you would e.g. do
var instance = (ITwoFloatOperation) Activator.CreateInstance(operationType.Type));
var result = instance.GetResult(floatA, floatB);
Once all this is setup an working correctly ( ^^ ) your "users"/developers can add new operations as simple as implementing your interface.
Alternative Approach - "Scriptable Behaviors"
Thinking about it further I think I have another - maybe a bit more simple approach.
This option is maybe not what you were targeting originally and is not a drop-down but we will rather simply use the already existing object selection popup for assets!
You could use something I like to call "Scriptable Behaviours" and have a base ScriptableObject
like
public abstract class TwoFloatOperation : ScriptableObject
{
public abstract float GetResult(float a, float b);
}
And then multiple implementations (note: all these have to be in different files!)
[CreateAssetMenu (fileName = "Add", menuName = "TwoFloatOperations/Add")]
public class Add : TwoFloatOperation
{
public float GetResult(float a, float b) => a b;
}
[CreateAssetMenu (fileName = "Multiply", menuName = "TwoFloatOperations/Multiply")]
public class Multiply : TwoFloatOperation
{
public float GetResult(float a, float b) => a * b;
}
[CreateAssetMenu (fileName = "Power", menuName = "TwoFloatOperations/Power"]
public class Power : TwoFloatOperation
{
public float GetResult(float a, float b) Mathf.Pow(a, b);
}
Then you create one instance of each vis the ProjectView
-> Right Click -> Create
-> TwoFloatOperations
Once you did this for each type you can simply expose a field of type
public TwoFloatOperation operation;
and let Unity do all the reflection work to find instances which implement this in the assets.
You can simply click on the little dot next to the object field and Unity will list you all available options and you can even use the search bar to find one by name.
Advantage:
- No dirty, expensive and error prone reflection required
- Basically all based on already built-in functionality of the editor -> less worries with serialization etc
Disadvantage:
- This breaks a little with the actual concept behind
ScriptableObject
since usually there would be multiple instances with different settings, not only a single one - As you see your developers have to not only inherit a certain type but additionally add the
CreateAssetMenu
attribute and actually create an instance in order to be able to use it.
As said typing this on the phone but I hope this helps with your use case and gives you an idea of how I would approach this