This is exactly one of the problems that The "Tell, Don't Ask" principle was created for.
You're describing an object that holds onto references to other objects, and wants to ask them what type of object they are before telling them what they need to do. From the article linked above:
The problem is that, as the caller, you should not be making decisions based on the state of the called object that result in you then changing the state of the object. The logic you are implementing is probably the called object’s responsibility, not yours. For you to make decisions outside the object violates its encapsulation.
If you break the rules of encapsulation, you not only introduce the runtime risks incurred by rampant downcasts, but also make your system significantly less maintainable by making it easier for components to become tightly coupled.
Now that that's out there, let's look at how the "Tell, Don't Ask" could be applied to your design problem.
Let's go through your stated constraints (in no particular order):
GameSchedule
needs to iterate over all games, performing general operationsGameSchedule
needs to iterate over a subset of all games (e.g.,Basketball
), to perform type-specific operations- No downcasts
- Must easily accommodate new
Game
subclasses
The first step to following the "Tell, Don't Ask" principle is identifying the actions that will take place in the system. This lets us take a step back and evaluate what the system should be doing, without getting bogged down into the details of how it should be doing it.
You made the following comment in @MarkB's answer:
If there's a
TestCricket
class inheriting fromCricket
, and it has many specific attributes concerning the timings of the various innings of the match, and we would like to initialize the values of allTestCricket
objects' timing attributes to some preset value, I need a loop that picks allTestCricket
objects and calls some function likesetInningTimings(int inning_index, Time_Object t)
In this case, the action is: "Initialize the inning timings of all TestCricket
games to a preset value."
This is problematic, because the code that wants to perform this initialization is unable to differentiate between TestCricket
games, and other games (e.g., Basketball
). But maybe it doesn't need to...
Most games have some element of time: Basketball games have time-limited periods, while Baseball games have (basically) innings with basically unlimited time. Each type of game could have its own completely unique configuration. This is not something we want to offload onto a single class.
Instead of asking each game what type of Game
it is, and then telling it how to initialize, consider how things would work if the GameSchedule
simply told each Game
object to initialize. This delegates the responsibility of the initialization to the subclass of Game
- the class with literally the most knowledge of what type of game it is.
This can feel really weird at first, because the GameSchedule
object is relinquishing control to another object. This is an example of the Hollywood Principle. It's a completely different way of solving problems than the approach most developers initially learn.
This approach deals with the constraints in the following ways:
GameSchedule
can iterate over a list ofGame
s without any problemGameSchedule
no longer needs to know the subtypes of itsGame
s- No downcasting is necessary, because the subclasses themselves are handling the subclass-specific logic
- When a new subclass is added, no logic needs to be changed anywhere - the subclass itself implements the necessary details (e.g., an
InitializeTiming()
method).
Edit: Here's an example, as a proof-of-concept.
struct Game
{
std::string m_name;
Game(std::string name)
: m_name(name)
{
}
virtual void Start() = 0;
virtual void InitializeTiming() = 0;
};
// A class to demonstrate a collaborating object
struct PeriodLengthProvider
{
int GetPeriodLength();
}
struct Basketball : Game
{
int m_period_length;
PeriodLengthProvider* m_period_length_provider;
Basketball(PeriodLengthProvider* period_length_provider)
: Game("Basketball")
, m_period_length_provider(period_length_provider)
{
}
void Start() override;
void InitializeTiming() override
{
m_period_length = m_time_provider->GetPeriodLength();
}
};
struct Baseball : Game
{
int m_number_of_innings;
Baseball() : Game("Baseball") { }
void Start() override;
void InitializeTiming() override
{
m_number_of_innings = 9;
}
}
struct GameSchedule
{
std::vector<Game*> m_games;
GameSchedule(std::vector<Game*> games)
: m_games(games)
{
}
void StartGames()
{
for(auto& game : m_games)
{
game->InitializeTiming();
game->Start();
}
}
};