3

I've got a DataSource base class and a few different sub-classes. I want to be able to switch which sub-class i'm using at runtime so I'm doing the following :

// pointer for our datasource object
DataSource *data;
if (ds.equals("mqtt")) {
  data = new Data_mqtt(config,display);  // Data_mqtt is a sub-class of DataSource

} else if (ds.equals("wf")) {
  data = new Data_wf(config,display);  // sub-class of DataSource

Is this the correct use case ? Is there an implicit object cast going on here ? Everything is working fine but I've got a weird feeling about it (code smell?).

Doing it this way, the only methods and members that are available via the data pointer are the ones declared in the DataSource.h with all the DataSource specific stuff hidden from the user so it's kinda of like an interface in that respect.

2
  • Just for info: a function that does that is called a factory. (And a dependency injection container mentioned by amon is essentially an object (usually provided by a library) that's in part a factory, and in part a life-cycle manager for dependencies). Commented Sep 10, 2020 at 16:52
  • en.wikipedia.org/wiki/State_pattern
    – Basilevs
    Commented Sep 11, 2020 at 1:04

2 Answers 2

5

This kind of code is fairly normal. You have different classes, and at some point it is necessary to decide which class shall be used.

However, the limitation is that you have hard-coded support for a fixed number of classes. Depending on the context of your application, it could make more sense to create a data structure where classes can be dynamically registered, e.g. via plugins. That would allow new classes to be added without recompiling your entire application. Something like this:

using Constructor = std::function<std::unique_ptr<DataSource>(Config, Display)>;
std::unordered_map<std::string, Constructor> registry& registry() {
  // keep the static variable inside a function to control initialization order
  static std::unordered_map<std::string, Constructor> r;
  return r;
}


// selecting the appropriate implementation
std::unique_ptr<DataSource> create_data(std:string_view ds, Config c, Display d) {
  return registry().at(ds)(c, d);
}

int main() {
  // initialization: register available implementations
  registry().emplace("mqtt", [](Config config, Display display) {
    return std::make_unique<Data_mqtt>(config, display);
  });
  registry().emplace("wf", [](Config config, Display display) {
    return std::make_unique<Data_wf>(config, display);
  });

  // ... some application code

  return 0;
}

This is the manually implemented version of what's performed in other languages by a dependency injection container. In some languages, reflection capabilities could also be used. Additionally, more complex DI containers can also track dependencies between the objects they construct, e.g. they might be able to automatically resolve the Config or Display objects. In C++, that would only be possible with lots of casting.

Whether such a registry makes sense for your software depends on whether you want to be able to potentially keep the software extensible without recompilation. This is extremely useful for libraries or for plugin architectures, but is mostly useless for more monolithic applications.

Your point that the use of a DataSource interface limits the available functions is very good. This is an example of the interface segregation principle where you try to avoid offering a larger interface than what's actually needed, in order to prevent unexpected dependencies.

4
  • Thank you for the in depth response. Recompilation isn't an issue so I probably won't go that far. But thanks again for responding with such thorough example code.
    – tavis
    Commented Sep 10, 2020 at 10:31
  • 4
    Note: You do not need this registry, unless you need it. You will know if you need it. If you don't need it, then please stick with the hardcoded if chain or switch for the sake of future maintainers who are trying to figure out your code. Commented Sep 10, 2020 at 11:26
  • @user253751 You're completely right for applications, but not for libraries. If you have an API that has to maintain binary compatibility, things have to be right the first time – even if you don't yet need that flexibility. For an application that can be modified at any time, ease of change is more important. E.g. I find GeePawHill's Change Harvesting vs Rework Avoidance interesting in this context.
    – amon
    Commented Sep 10, 2020 at 12:11
  • 5
    @amon If you want to write a library which lets the application register more data source types, then this falls into the "you know you need it" category. Because how else will you let the application register data source types? If you want to write a library and don't want to let the application register data source types, then write the if or switch and worry about the registration API later, when you actually need one. Commented Sep 10, 2020 at 12:26
1

The original code is easy to read, and the use of the subclass is correct. However, if this is the complete code, if neither of the "if - else if" cases are true, the data pointer remains uninitialized. Either provide a default initialization, or a default else case.

1
  • Good eye. I did indeed run into that situation with the null pointer and have since provided a default case. Thanks
    – tavis
    Commented Sep 11, 2020 at 21:27

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.