So I am designing a framework that has multiple classes, which derive from one another in a cascading fashion in order to perform more and more specific tasks. Each class also has its own settings, which are their own classes. However, the settings should have a parallel inheritance relationship to that of the classes themselves. It is important to the nature of this question that the settings MUST be separate from the class itself as they have constraints specific to my framework. (Edit: I also want to avoid the Composition pattern with settings as it greatly complicates the workflow in the framework I am using. So the settings must be single objects, not compositions of multiple objects.) That is I think of this as sort of like two parallel class hierarchies. One relevant example would perhaps be this:
Suppose you have a Vehicle
class, and an accompanying class, VehicleSettings
, that stores the relevant settings every vehicle should have, say topSpeed
and acceleration
.
Then suppose you have now a Car
and an Airplane
class, both of which inherit from the Vehicle
class. But now you also create CarSettings
, which inherits from the VehicleSettings
class but adds the member gear
. Then also have AirplaneSettings
, inheriting from VehicleSettings
, but adding the member drag
.
To show how I might continue this pattern, suppose now I have a new class SportsCar
, which inherits from Car
. I create corresponding settings SportsCarSettings
, which inherits from CarSettings
and I add a member sportMode
.
What is the best way to set up such a class hierarchy such that I can also account for the derived classes for the settings?
Ideally, I would like such an example to work like this:
public class VehicleSettings
{
public float topSpeed;
public float acceleration;
// I am explicitly not adding a constructor as these settings get initialized and
// modified by another object
}
public class Vehicle
{
public float speed = 0;
protected VehicleSettings settings;
// Similarly here, there is no constructor since it is necessary for my purposes
// to have another object assign and change the settings
protected virtual float SpeedEquation(float dt)
{
return settings.acceleration * dt;
}
public virtual void UpdateSpeed(float dt)
{
speed += SpeedEquation(dt)
if(speed > settings.topSpeed)
{
speed = settings.topSpeed;
}
}
}
public class CarSettings : VehicleSettings
{
public int gear;
}
public class Car : Vehicle
{
// Won't compile
public CarSettings settings;
// Example of the derived class needing to use
// its own settings in an override of a parent
// method
protected override float SpeedEquation(float dt)
{
return base.SpeedEquation(dt) * settings.gear;
}
}
public class AirplaneSettings : VehicleSettings
{
public float drag;
}
public class Airplane : Vehicle
{
// Won't compile
public Airplane settings;
// Another example of the derived class needing to use
// its own settings in an override of a parent
// method
public override void UpdateSpeed(float dt)
{
base.UpdateSpeed(dt)
speed -= settings.drag * dt;
}
}
public class SportsCarSettings : CarSettings
{
public bool sportMode;
}
public class SportsCar : Car
{
// Won't compile
public SportsCarSettings settings;
// Here is again an example of a further derived class needing
// to use its own settings to override a parent method
// This example is here to negate the notion of using generic
// types only once and not in the more complicated way mentioned
// below
public override float SpeedEquation(float dt)
{
return (settings.acceleration + (settings.sportMode ? 2 : 1)) * dt;
}
}
Looking at a few possible solutions
Use generic types. For example, I could have it so that it is
public class Vehicle<T> : where T : VehicleSettings
and I could have it have the lineT settings
so that whenCar
andAirplane
inherit fromVehicle
they can do that like this:public Car<T> : Vehicle<T> where T : CarSettings
andpublic Airplane<T> : Vehicle<T> where T : AirplaneSettings
, and so on. This does complicate things somewhat if I want to instantiate, for example, aCar
without plugging in a generic type. Because then I would have to create a child classCar
ofCar<T>
as follows:public class Car : Car<CarSettings> { }
. And I would have to do similarly for every derived type.Use type casting in the necessary methods. For example, I could modify the
Car
class as follows:public class Car : Vehicle { // Don't reassign settings and instead leave them // as VehicleSettings // Cast settings to CarSettings and store that copy // locally for use in the method protected override float SpeedEquation(float dt) { CarSettings settings = (CarSettings)this.settings; return base.SpeedEquation(dt) * settings.gear; } }
I also saw one recommendation to use properties as in this example, but this seems very clunky mainly since it doesn't seem to actually solve the problem. You would still have to cast the returned value to your desired type even if the dynamic type is returned from the property. If that is the proper way to go about it, I would appreciate an explanation as to how to properly implement that.
It may be possible to use the
new
keyword to hide the parent class's version ofsettings
and replace it with a new variable calledsettings
of the correspond child class's settings type, but I believe this is generally advised against, mainly for reasons of complicating the relationship to the original parent class's 'settings', which affects the scope of that member in inherited methods.
So my question is which is the best solution to this problem? Is it one of these approaches, or am I missing something pretty significant in the C# syntax?
Edit:
Since there has been some mention of the Curiously Recurring Template Pattern or CRTP, I would like to mention how I think this is different.
In the CRTP, you have something like Class<T> : where T : Class<T>
. Or similarly you might confront something like, Derived<T> : T where T : Base<Derived>
.
However, that is more about a class which needs to interact with an objects that are of the same type as itself or a derived class that needs to interact with base class objects that need to interact with derived class objects. Either way, the relationship there is circular.
Here, the relationship is parallel. It's not that a Car will ever interact with Car, or that a SportsCar will interact with a Vehicle. Its that a Car needs to have Car settings. A SportsCar needs to have SportsCar settings, but those settings only change slightly as you move up the inheritance tree. So I think it seems kind of nonsensical that if such a deeply OO language like C# requires jumping through so many hoops to support "parallel inheritance", or in other words that it isn't just the object itself which "evolve" in their relationship from the parent to the child, but also that the members themselves do so.
When you don't have a strongly typed language, say Python for example, you get this concept for free since for our example, so long as the settings that I assigned to any particular instance of an object had the relevant properties, its type would be irrelevant. So I suppose it's more that sometimes the strongly typed paradigm can be a hindrance in that I want an object to be defined by its accessible properties rather than its type in this case with settings. And the strongly typed system in C# forces me to make templates of templates or some other strange construct to get around that.
Edit 2:
I have found a substantial issue with option 1. Suppose I wanted to make a list of vehicles and a list of settings. Suppose I have a Vehicle
, a Car
, an Airplane
and a SportsCar
, and I want to put them into a list and then iterate over the list of vehicles and settings and assign each setting to its respective vehicle. The settings can be put in a list of type VehicleSettings
, however there is no type (other than Object
) that the vehicles can be put in since the parent class of Vehicle
is Vehicle<VehicleSettings>
, the parent class of Car is Car<CarSettings>
, etc. Therefore what you lose with generics is the clean parent child hierarchy that makes grouping similar objects into lists, dictionaries, and the like so comfortable.
However, with option 2, this is possible. If there is no way to avoid the aforementioned problem, option 2, despite being uncomfortable in some respects, seems the most manageable way to do it.