Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I replace a switch statement over an enum with runtime-dynamic type-based generic dispatch in C#?

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:

User selected binary operation

The code is taken from a tutorial, and uses an enum here in combination with a switch statement here to achieve the desired behavior.

This next image demonstrates the relationship between the code and the behavior in the graph UI:

enter image description here

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:

  1. What sort of type do I assign to MathOperations so that it can hold a collection of the subtypes of GenericOperation?

  2. How do I get the subtypes of GenericOperation?

  3. 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.

  1. https://docs.microsoft.com/en-us/dotnet/csharp/fundamentals/types/generics
  2. https://igoro.com/archive/fun-with-c-generics-down-casting-to-a-generic-type/
  3. Using enum as generic type parameter in C#
  4. 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.

like image 294
snakes_on_a_keyboard Avatar asked Dec 29 '21 18:12

snakes_on_a_keyboard


1 Answers

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

like image 142
derHugo Avatar answered Nov 02 '22 11:11

derHugo