Instantiating Abstract Class Subclasses via Reflection in C#
This post demonstrates a way of instantiating abstract class subclasses via reflection in C#. As an example, it uses the Calculator project, which implements arithmetic expressions, including their construction, evaluation, and conversion to and from JSON and XML.
Here are a few related posts that cover various other aspects of the calculator project:
- Making a Calculator Engine in C# - Revisiting Software Design Principles
- From C# Arithmetic Expressions to JSON and Back
- Exploring XML Serialisation for Arithmetic Expressions in C#
The starting point is the following method:
public static Operation FromName(string operationName)
{
return operationName switch
{
Addition.OperationName => new Addition(),
Division.OperationName => new Division(),
Multiplication.OperationName => new Multiplication(),
Subtraction.OperationName => new Subtraction(),
_ => throw new Exception("Unsupported operation name")
};
}
This post reworks the method to eliminate the need for manual updates to it when adding new operations and discusses other optimisations to simplify the process of adding new operations.
All changes described in this post are part of this pull request.
Step 1: Operations Instantiater
The first commit introduces the OperationsInstantiater
class.
Previously, the logic to instantiate operations by name was in the Operation
class. This update is a good opportunity to isolate that logic into its own class.
Step 2: Tests Standardisation
At this stage, the operations instantiation logic is untested, so it cannot yet be modified. This section addresses that oversight.
The second commit organises all operations into the Operations
folder/namespace. This change intends to make the distinction between operation and non-operation classes more explicit.
The third commit introduces tests to ensure that each operation can be instantiated. Since the focus is on operations rather than OperationsInstantiater
, each operation gets its own unit tests class.
The fourth commit adds tests to ensure that each operation can be evaluated. This checks that multi-valued expressions for each operation can be correctly evaluated.
As a result, all operations are implemented in the same, standardised way: located in the Operations folder and tested for instantiation and evaluation.
Step 3: Reflection
The fifth commit rewrites the OperationsInstantiater
class to dynamically collect all operation classes without the need for explicit updates. It filters for classes that inherit from the Operation
class, are not abstract, and have a default constructor. In particular, this excludes the Constant
class and the Operation
class itself.
This technique of creating instances of types at runtime is known as reflection.
The class uses a static constructor, which, according to the documentation, is a special method executed the first time the class is used. It is called at most once.
public sealed class OperationsInstantiater
{
private static readonly Dictionary<string, Type> OperationTypes;
static OperationsInstantiater()
{
OperationTypes = Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => typeof(Operation).IsAssignableFrom(t) && !t.IsAbstract)
.Where(t => t.GetConstructor(Type.EmptyTypes) != null)
.ToDictionary(t => ((Operation)Activator.CreateInstance(t)!).Name, t => t);
}
public Operation Create(string operationName)
{
if (OperationTypes.TryGetValue(operationName, out Type? operationType))
{
return (Operation)Activator.CreateInstance(operationType)!;
}
throw new Exception($"Unsupported operation name: {operationName}");
}
}
Step 4: More Operations
At this stage, everything is prepared to make implementation of new operations as simple as possible. The sixth commit demonstrates this by adding direction, exponentiation, logarithm, and standard deviation operations.
Conclusion
This post showcased how reflection and standardised tests can streamline the process of adding new operations to the calculator project.