What is easiest to maintain when implementing different functionality for each subclass? Downcasting, Reflection or Polymorphism?

softwareengineering.stackexchange https://softwareengineering.stackexchange.com/questions/396327

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");
    }
}
Was it helpful?

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

  1. Add a new method TriggerTurn(OutcomeTurn) to OutcomeExecutioner.
  2. Add a new subclass OutcomeTurn that derives from OutcomeBase.

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

  1. Add a new method TriggerTurn(OutcomeTurn) to OutcomeExecutioner.
  2. Add a new subclass OutcomeTurn that derives from OutcomeBase.
  3. Add a new enumerator value to OutcomeType
  4. 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.

Licensed under: CC-BY-SA with attribution
scroll top