Consider the following relation between classes:
int main(int, char**) { | class Window { | class Layout { | class Box {
/* Use argc/argv */ | Layout layout; | Box box; | int height,
Window window; | | | max_width;
} | bool print_fps; | public: |
| | Layout(); | public:
| public: | }; | Box (int,int);
| Window (); | | };
| }; | |
I made up this structure just for simplicity, in reality there are many more classes.
In main()
I fetch some application parameters (via configuration files, database, CLI arguments). Now I want to deliver those values to the desired objects.
My question: Which is the best/most elegant way to "break the wall" between the classes so that I can "throw" the configuration and whoever needs it to "grab" it?
Initialy I "opened some doors" and gave the Window
constructor everything that was needed by Window
, Layout
and Box
. Then, Window
gave to Layout
everything needed by Layout
and Box
. And so on.
I quickly realized this was very similar to what Dependency Injection it about but as it turns out, it does not apply directly to my case.
Here I work with primitives like bool
and int
and in fact if I accept them as constructor parameters, I get the result described just above - a very long chain of similar calls: Window(box_height, box_max_width, window_print_fps)
.
What if I'd like to change the type of Box::height
to long
? I would need to walk through every pair of header/source of every class in the chain to change it.
If I want my classes to be isolated (and I do) then Window shouldn't worry about Box and main shouldn't worry about Layout.
Then, my second idea came up: create some JSON-like structure which acts as a config object. Everybody gets a (shared) pointer to it and whenever they want, they say this->config["box"]["height"]
- everyone's happy.
This would kinda work, but there are two problems here: no type safety
and tight coupling between a class (Config
) and the entire code base.
Basically, I see two ways around the problem:
It's either up or down - I'm trying to think out of the box (In fact, it is a line - just ↑ or ↓) but I ended up only here.
Another issue arising from my two ideas earlier is about the way configuration is parsed:
int height
to Box
, then it needs to know about box in order to parse the value properly, right? (tight-coupling)If, on the other hand, main.cpp doesn't know about Box (ideally), how should it store the value in a friendly for the box way?
Optional parameters shouldn't be needed in constructors => shouldn't break the application. That is, main should accept the absence of some parameter, but it also needs to know that a setter has to be called for the desired object after it's been constructed with the required parameters.
The whole idea is to strive to these three principles:
I implemented a sub-optimal solution I'll post as a self-answer, which works well for now and it's better than nothing but I'm looking forward for something better!
I am very much in favor of the second approach and hacked together a quick outline of an implementation. Please keep in mind that I am not claiming that this is the best solution, however, it is a solid one in my opinion. Interested to hear your comments and critics.
First, have some tiny objects, that represent a configurable value.
class ConfigurationParameterBase
{
public:
ConfigurationParameterBase(ConfigurationService* service,
std::string name)
: service_(service), name_(std::move(name)) {
assert(service_);
service_->registerParameter(this);
}
protected:
~ConfigurationParameterBase() {
service_->unregisterParameter(this);
}
public:
std::string name() const { return name_; }
virtual bool trySet(const std::string& s) = 0;
private:
ConfigurationService* service_;
std::string name_;
};
template<typename T>
class ConfigurationParameter : public ConfigurationParameterBase
{
public:
ConfigurationParameter(ConfigurationService* service,
std::string name, std::function<void(T)> updateCallback = {})
: ConfigurationParameterBase(service, std::move(name))
, value_(boost::none)
, updateCallback_(std::move(updateCallback))
{ }
bool isSet() const { return !!value_; }
T get() const { return *value_; }
T get(const T& _default) const { return isSet() ? get() : _default; }
bool trySet(const std::string& s) override {
if(!fromString<T>(s, value_))
return false;
if(updateCallback_)
updateCallback_(*value_);
return true;
}
private:
boost::optional<T> value_;
std::function<void(T)> updateCallback_;
};
Every object stores a value of any particular type (easily handled using a single class template) that represents a configuration parameter specified by its name. Furthermore, it holds an an optional callback that shall be invoked upon modification of the respective configuration. All instantiatons share a common base class, which registers and unregisters the class with the given name at a central ConfigurationService
object. Beyond that, it forces derived classes to implement trySet
, which reads a configuration value (string) into the type expected by the parameter. If this is successful, the value is stored and the callback invoked (if any).
Next, we have the ConfigurationService
. It is responsible for keeping track of the current configuration and all the observers. Individual options can be set using setConfigurationParameter
. Here we can add functions to read entire configurations from a file, a database, the network or whatever.
class ConfigurationService
{
public:
void registerParameter(ConfigurationParameterBase* param) {
// keep track of this observer
params_.insert(param);
// set current configuration value (if one exists)
auto v = values_.find(param->name());
if(v != values_.end())
param->trySet(v->second);
}
void unregisterParameter(ConfigurationParameterBase* param) {
params_.erase(param);
}
void setConfigurationParameter(const std::string& name,
const std::string& value) {
// store setting
values_[name] = value;
// update all 'observers'
for(auto& p : params_) {
if(p->name() == name) {
if(!p->trySet(value))
reportInvalidParameter(name, value);
}
}
}
void readConfigurationFromFile(const std::string& filename) {
// read your file ...
// and for each entry (n,v) do
// setConfigurationParameter(n, v);
}
protected:
void reportInvalidParameter(const std::string& name,
const std::string& value) {
// report whatever ...
}
private:
std::set<ConfigurationParameterBase*> params_;
std::map<std::string, std::string> values_;
};
We can then finally define the classes of our application. Every class member (of type T
) that shall be configurable, is replaced by a member ConfigurationParameter<T>
and initialized in the constructor with the respective configuration name, and - optionally - an update callback. The class can then make use of these values as if they were normal class members (e.g. fillRect(backgroundColor_.get())
) and callbacks are invoked whenever values change. Notice how these callbacks are directly mapped to standard setter methods of the class.
class Button
{
public:
Button(ConfigurationService* service)
: fontSize_(service, "app.fontSize",
[this](int v) { setFontSize(v); })
, buttonText_(service, "app.button.text") {
// ...
}
void setFontSize(int size) { /* ... */ }
private:
ConfigurationParameter<int> fontSize_;
ConfigurationParameter<std::string> buttonText_;
};
class Window
{
public:
Window(ConfigurationService* service)
: backgroundColor_(service, "app.mainWindow.bgColor",
[this](Color c){ setBackgroundColor(c); })
, fontSize_(service, "app.fontSize") {
// ...
button_ = std::make_unique<Button>(service);
}
void setBackgroundColor(Color color) { /* ... */ }
private:
ConfigurationParameter<Color> backgroundColor_;
ConfigurationParameter<int> fontSize_;
std::unique_ptr<Button> button_;
};
Lastly, we stick everything together (e.g. in main
). Create an instance of ConfigurationService
and then create everything with access to it. With the above implementation it is important, that the service
outlives all observers, this can be changed easily however.
int main()
{
ConfigurationService service;
auto win = std::make_unique<Window>(&service);
service.readConfigurationFromFile("config.ini");
// go into main loop
// change configuration(s) whenever you need
service.setConfigurationParameter("app.fontSize", "12");
}
Due to the observers and update callbacks, entire configuration (or merely single entries) can be changed at any time.
Feel free to play around with the above code here
Let me quickly summarize:
Window
does not need to know anything about the configuration of Button
. ConfigurationService
, but one can easily this by an interface, making the actual implementation interchangeable.fromString
template, but that is needed anyway if you want to parse configurations from text files.loadConfigurationFromDatabase
) to the ConfigurationService
, to do so.ConfigurationService
and the programmatically changed configuration can be written back to file or database at program exit.ConfigurationParameter
members) can be used comfortably. This can be improved further by providing a respective cast operator (operator T() const
).ConfigurationService
instance must be passed to all classes that need to register something. This can be circumvented using a global or static instance (e.g. as a Singleton), although I am not sure whether this is any better.If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With