I take the second approach very strongly and hacked into a brief implementation diagram. Please keep in mind that I am not saying that this is the best solution, but in my opinion it is solid. Interested in hearing your comments and critiques.
First you have tiny objects that represent a custom 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_; };
Each object stores a value of any particular type (easily processed using a template of one class), which is a configuration parameter specified by its name. In addition, it contains an optional callback that should be called when the corresponding configuration is changed. All instances share a common base class that registers and releases the class with the specified name in the central ConfigurationService object. In addition, it forces derived classes to inject a trySet that reads the configuration value (string) into the type expected by the parameter. If successful, the value is saved and the callback is called (if any).
Next we have a ConfigurationService . He is responsible for tracking the current configuration and all observers. Individual parameters can be set using setConfigurationParameter . Here we can add functions to read entire configurations from a file, database, network, or any other.
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_; };
Then we can finally define the classes of our application. Each class member (type T ) that must be configurable is replaced with a ConfigurationParameter<T> member and initialized in the constructor with the corresponding configuration name and - optionally - with an update callback. The class can then use these values ​​as if they were regular members of the class (for example, fillRect(backgroundColor_.get()) ), and callbacks are called when the values ​​change. Notice how these callbacks map directly to standard class customization methods.
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_; };
Finally, we do everything together (for example, 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 easily changed.
int main() { ConfigurationService service; auto win = std::make_unique<Window>(&service); service.readConfigurationFromFile("config.ini");
Thanks to the observers and update callbacks, the entire configuration (or simply individual records) can be changed at any time.
Feel free to play with the code above here
Let me briefly summarize:
- There are no interlacing classes.
Window does not need to know anything about Button configuration. - It is true that all classes need access to the
ConfigurationService , but this can easily be done using the interface, which makes the actual implementation interchangeable. - Implementation efforts are mediocre. The only thing you need to expand to support more configuration types is the
fromString pattern, but this is necessary anyway if you want to parse configurations from text files. - Configurations can be for each class (as in the example) or for each object (just pass the configuration key or just a prefix to the class constructor).
- Different classes / objects can connect to the same configuration entry.
- Configurations may be provided from arbitrary sources. To do this, add another function (e.g.
loadConfigurationFromDatabase ) to the ConfigurationService . - Unknown configuration entries and / or unexpected types may be detected and reported to the user, the log file, or elsewhere.
- The configuration can be changed programmatically, if necessary. Add the appropriate method to the
ConfigurationService , and the programmatically modified configuration can be written back to a file or database when you exit the program. - Configurations can be changed at runtime (not only at startup).
- Configurable values ​​(
ConfigurationParameter members) can be used conveniently. This can be improved by providing the appropriate casting operator T() const ( operator T() const ). - A
ConfigurationService instance must be passed to all classes that must register something. This can be circumvented using a global or static instance (like Singleton for example), although I'm not sure if this is better. - Great improvements are possible in terms of performance and memory consumption (for example, to convert each parameter only once). See above for a simple sketch.