Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make objects in the deep easily configurable "from the top"?

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:

  1. "Downwards": Out -> In
    Objects on the top (outer) care about object in the deep (inner). They push down explicitly what inners want.
  2. "Upwards": In <- Out (the "diagram" is the same, but please wait)
    Objects on the bottom (inner) care about themselves on their own. They satisfy their needs by reaching out to some container on the top(outer) and pull what they want.

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:

  1. If main.cpp (or the config parser) needs to give int height to Box, then it needs to know about box in order to parse the value properly, right? (tight-coupling)
  2. 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?

  3. 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:

  1. Type safety. Provided by solution 1. but not 2.
  2. Loose coupling. Provided neither by 1. (main cares about Box) nor by 2. (everybody needs Config)
  3. Avoid duplication. Provided by 2 but not 1. (many identical parameters forwarded until they reach their target)

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!

like image 460
Al.G. Avatar asked Nov 07 '22 17:11

Al.G.


1 Answers

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:

  • There is no intertwining of classes. Window does not need to know anything about the configuration of Button.
  • It is true that all classes need access to the ConfigurationService, but one can easily this by an interface, making the actual implementation interchangeable.
  • Implementation effort is mediocre. The only thing you need to extend to support more configuration types is the fromString template, but that is needed anyway if you want to parse configurations from text files.
  • Configurations can be per class (as in the example) or per object (just pass the configuration key or just a prefix to the class constructor).
  • Different classes/objects can hook onto the same configuration entry.
  • Configurations can be provided from arbitrary sources. Merely add another function (e.g. loadConfigurationFromDatabase) to the ConfigurationService, to do so.
  • Unknown configuration entries and/or unexpected types can be detected and reported to the user, a log file or somewhere else.
  • Configuration can be changed programmatically, if necessary. Add a corresponding method to ConfigurationService and the programmatically changed configuration can be written back to file or database at program exit.
  • Configurations can be changed during execution (not only at startup).
  • Configurable values (ConfigurationParameter members) can be used comfortably. This can be improved further by providing a respective cast operator (operator T() const).
  • The 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.
  • A lot of improvements are possible regarding performance and memory consumption (e.g. converting every parameter only once). See the above as a mere sketch.
like image 130
nh_ Avatar answered Nov 14 '22 21:11

nh_