What is easiest to maintain when implementing different functionality for each subclass? Downcasting, Reflection or Polymorphism?
https://softwareengineering.stackexchange.com/questions/396327
-
01-03-2021 - |
Question
I'm interested in knowing what is a good way to architect a system that can act differently based on the subclass being passed in. I assume polymorphism is the least computationally intensive.
I'm going to paste some short, functional code. The Program
class is the same for both snippets. I deliberately left out Interfaces and dumb-ified the code to so as to be as short as possible.
The code is of a game where the player has to make Choices. Each Choice
can have different Outcomes
, which are chosen based on some logic, which I randomized in the Program, as to illustrate that this is outside the system.
Also, the Choice
does not have the right to implement its Outcome
, only the OutcomeExecutioner
has, which means that the Choice
is very much a data container for an Outcome
, which is itself a data container for what has to happen.
My top-pick for now is using the template method
pattern because the OutcomeExecutioner doesn't have to do any casting/reflecting. It just calls a function. Then again, it does use memory space for an extra member, which is just a reference so probably not a big deal? It's also cleaner and allows for creating an interface
so that in the future the OutcomeExecutioner
could be replaced with a different Executioner.
class Choice
{
public OutcomeBase Outcome;
public Choice (OutcomeBase outcome)
{
this.Outcome = outcome;
}
}
abstract class OutcomeBase
{
public OutcomeExecutioner Executioner;
abstract public void TriggerOutcome();
}
class OutcomeMove : OutcomeBase
{
override public void TriggerOutcome()
{
Executioner.TriggerMove(this);
}
}
class OutcomeTalk : OutcomeBase
{
override public void TriggerOutcome()
{
Executioner.TriggerTalk(this);
}
}
class OutcomeExecutioner
{
public void OnChoice(Choice choice)
{
choice.Outcome.Executioner = this;
choice.Outcome.TriggerOutcome();
}
public void TriggerMove(OutcomeMove move)
{
Console.WriteLine("Move");
}
public void TriggerTalk(OutcomeTalk talk)
{
Console.WriteLine("Talk");
}
}
And this is using downcasting. The same goes for reflection, except that instead of using a cast based on OutcomeType
, I can simply use reflection and remove that OutcomeType
enum alltogether (although Reflection might be more computationally intensive, and I do work with a huge data set, so probably I'd leave the enum in).
enum OutcomeType
{
Move,
Talk
}
class Choice
{
public OutcomeBase outcome;
public Choice(OutcomeBase outcome)
{
this.outcome = outcome;
}
}
abstract class OutcomeBase
{
public OutcomeType OType;
}
class OutcomeMove : OutcomeBase
{
public OutcomeMove()
{
OType = OutcomeType.Move;
}
}
class OutcomeTalk : OutcomeBase
{
public OutcomeTalk()
{
OType = OutcomeType.Talk;
}
}
class OutcomeExecutioner
{
public void OnChoice(Choice choice)
{
switch (choice.outcome.OType)
{
case OutcomeType.Move:
TriggerMove((OutcomeMove)choice.outcome);
break;
case OutcomeType.Talk:
TriggerTalk((OutcomeTalk)choice.outcome);
break;
}
}
public void TriggerMove(OutcomeMove move)
{
Console.WriteLine("Move");
}
public void TriggerTalk(OutcomeTalk talk)
{
Console.WriteLine("Talk");
}
}
Solution
You seem to be focusing too much on premature micro-optimizations. Unless you are on a really tight memory or computing time budget (less than 1MB total memory, or you need the frame-rate of a video game) an extra virtual member or a virtual function call are not going to make a significant impact.
What is making an impact is how easy your code will be to understand for new maintainers (including yourself in 6 months) and how easy it is to add new Choices/Outcomes.
As an example, lets try to add an OutcomeTurn
in both solutions:
In the inheritance case, there are two changes (additions) to make
- Add a new method
TriggerTurn(OutcomeTurn)
toOutcomeExecutioner
. - Add a new subclass
OutcomeTurn
that derives fromOutcomeBase
.
Most importantly, the code for invoking an outcome is not affected at all and if you forget one of the changes, code trying to use the new outcome will fail to build.
In the casting/reflection case, there are 4 (or 3) changes to make
- Add a new method
TriggerTurn(OutcomeTurn)
toOutcomeExecutioner
. - Add a new subclass
OutcomeTurn
that derives fromOutcomeBase
. - Add a new enumerator value to
OutcomeType
- Extend the switch in
OutcomeExecutioner::OnChoice
to handle calling the new outcome.
If you miss that last change, at best you might get a warning that not all enumerator values are covered by the switch statement. At worst, you will notice at run-time that the selected choice has no noticeable effect. Those dispatch functions are also usually not covered that well by unittests, as it is easy to forget you might need to add one and the logic appears simple enough that it can be verified by reviews only.