How to make objects in the deep easily customizable "top"?

Consider the following relationship 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 compiled this structure just for simplicity, in fact there are many more classes.
In main() I retrieve some application parameters (via configuration files, database, CLI arguments). Now I want to deliver these values ​​to the desired objects.

My question is: What is the best / most elegant way to “break a wall” between classes so that I can “drop” a configuration and whoever needs it to “grab” it?


Initialy I “opened some doors” and gave the Window designer everything that was needed for Window , Layout and Box . Window then gave Layout everything needed for Layout and Box . And so on.

I quickly realized that this is very similar to Injection Dependency , but as it turned out, it does not apply directly to my case.
Here I work with primitives such as bool and int , and in fact, if I accept them as constructor parameters, I get the result described above - a very long chain of similar calls: Window(box_height, box_max_width, window_print_fps) .

What if I would like to change the type of Box::height to long ? I will need to go through each pair of headers / sources of each class in the chain to change it.

If I want my classes to be isolated (and I do), then Window should not worry about Box and main should not worry about layout.


Then came my second idea : create some JSON-like structure that acts as a configuration object. Everyone gets a (generic) pointer to it, and whenever they want, they say this->config["box"]["height"] - everyone is happy.

This will work, but there are two problems: there is no type security and there is a tight connection between the class ( Config ) and the entire code base.


Basically, I see two ways of the problem :

  • Down: Exit → To
    Objects on top ( external ) take care of the object in depth ( inside ). They are clearly abandoning what they want inside.
  • "Up": In <- Out ("diagram" is the same, but please wait)
    The objects below ( internal ) take care of themselves. They satisfy their needs by accessing the container from above ( external ) and pulling what they want.

This is either up or down - I'm trying to think out of the box (Actually this is a string - just & uarr; or & darr;) , but I ended up here only.


Another issue related to my previous ideas is how the configuration is parsed:

  • If main.cpp (or the syntax parser) should give int height to Box , then it should know about the field to parse the value correctly, right? (Tight coupling)
  • If, on the other hand, main.cpp does not know about Box (ideally), how should it store the value in a box-friendly way?

  • Optional parameters are not needed in the constructors => should not interrupt the application. That is, the main one must accept the absence of any parameter, but he must also know that the setter must be called for the desired object after its creation with the required parameters.


The whole idea is to strive for these three principles:

  • Enter security. Provided by solution 1. but not 2.
  • Free communication. Provided neither by 1. (the main thing is taking care of Box), nor by 2. (everyone needs Config)
  • Avoid duplication. It is provided 2, but not 1. (many identical parameters are redirected until they reach their goal).

I have implemented a suboptimal solution that I will post as a standalone answer that works well now, and it's better than nothing, but I'm looking forward to something better!

+5
source share
2 answers

I decided that I prefer the first approach more, because of type safety, also I am not very similar to the idea of ​​every object containing a pointer to some Config.

I also realized that my perception of DI was not entirely correct, or at least that there was a better way to implement it in my case.
Thus, instead of taking all the parameters of all objects down the tree, the constructor receives only the direct and required dependencies . This completely solves the problem of duplication 1.

This leaves us only with a dense communication problem - the main thing is to know almost everyone. To learn how to create a window, he needs to create a layout, a field and all the rest.

Firstly, I wanted to solve this problem elsewhere, so I created a class to create objects for me. Like a factory, but only used to hide dirty things from main.
In ConfigFactory now I save all the parameters in some way and pass them when I need to build the object. It doesn't matter how the configuration is configured, I decided to have a simple struct Data and use PIMPL so that no recompilation of any ConfigFactory.h -dependent file is required when changing the logic of the parameters (which should happen quite often)

Then I saw how this guy used templates to make Factory more general: fooobar.com/questions/28479 / ... but instead of working with the base and derived classes, pointers and dynamic allocation , I defined a member function that returns stack allocated object (ideally):

 template <typename T> T produce() const { return {}; } 

If there is no produce specialization for the desired type, T is built by default - thus, it is more flexible when adding / removing specializations.

I save ConfigFactory.h only this one small and free of any #include s, so I don't take any unnecessary dependencies to those who include it.

Now, if I need something produced() , I turn on ConfigFactory.h and declare a specialization for it. I put its definition in some source file ( ConfigFactory.cpp ) and the produce() object using the parameters in this->data :

main.cpp:

 #include "ConfigFactory.h" #include "Window.h" template <> Window ConfigFactory::process() const; int main (int argc, const char** argv) { ConfigFactory factory{argc, argv}; Window window = factory.produce<Window>(); } 

ConfigFactory.cpp, Window.cpp or who knows how to make a window:

 template <> Layout ConfigFactory::produce() const; template <> Window ConfigFactory::produce() const { Window window{produce<Layout>()}; // required dependencies window.setPrintFps(data->print_fps); // optional ones return window; } 
  • If I put the definitions of produce() in the appropriate source files, I have some bad feelings about it - because I define things that are not declared in the header.
  • But if I use ConfigFactory.h for definitions, it becomes quite long and heavy, since it needs to know about each produce() class.

I am currently using both and generally works, but this is not ideal.

0
source

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"); // go into main loop // change configuration(s) whenever you need service.setConfigurationParameter("app.fontSize", "12"); } 

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.
0
source

Source: https://habr.com/ru/post/1271425/


All Articles