📜 ⬆️ ⬇️

Encapsulation of interfaces. We make API in C ++ convenient and understandable.

In due time, I wrote a series of articles for the Hacker magazine for the “Academy C ++” column, in which I described interesting possibilities of using C ++. The cycle has long been completed, but I am still often asked how exactly the emulation of dynamic typing in the first article works. The fact is that when I started the cycle, I did not know exactly what was needed and what was not, and I missed a number of necessary facts in the description. In vain! There is nothing superfluous in the training material. Today I will explain in detail exactly how a beautiful high-level API is obtained in terms of the most common C ++: just classes, methods and data.

What is it for


As a rule, something fast is written in C ++, but not always easy to use. In the process of developing any product, general functionality is provided with a more or less well-designed interface for working with product entities. C ++ language strongly encourages pointers and links to base classes, which multiply and complicate the code, wrapped in all sorts of "smart" pointers and generate kilometer-long lines in any reference to such a design!
Agree, it is hardly convenient to use this:
std::unordered_map<std::string, std::vector< std::shared_ptr<base_class>>> 

Especially if for each element of the vector an operation of a class of successor is needed, that is, the method is not included in the above-mentioned base_class. What, can not find the base_class in the construction a little higher? And I talked about!
For ease of use of working with the base class, the easiest way to select the essence of working with it and encapsulate the interface into it as a simple pointer to the class data.

Base class interface


To simplify the narrative as much as possible, there will be many examples, and we will not go far from the code. The code itself will be attached to the article, and here it will not be lost anywhere else. So, I propose to form the base class as a data object, let's simplify it as much as possible:

 class object { public: object(); //     ,  null virtual ~object(); //    unique_ptr //  object(const object& another); object& operator = (const object& another); //   null bool is_null() const; //      class data; //     const data* get_data() const; //     const char* data_class() const; protected: //    object(data* new_data); void reset(data* new_data); //       void assert_not_null(const char* file, int line) const; private: //    std::unique_ptr<data> m_data; }; 

')
What we previously used as an interface to the base class turns into object :: data - the most important class, which is no longer visible anywhere outside.
In fact, in object, as in object :: data, there should be basic operations for which that base_class was wound up. But we will not need them in the description, and without that there will be many interesting things.
In its minimal form, the data class of an object looks easier than ever:

 class object::data { public: //      virtual data* clone() const = 0; //     virtual const char* class_name() const = 0; }; 


The only method that we really need in the base class is to clone the data of the corresponding heir. And, as you can see, the interface class is fine without the clone () method, the object itself and all of its descendants use the usual copy constructors. This is where we come to the most important thing - inheritance from the encapsulated base class.

Double inheritance


For heirs, we need to select a pair of entities. Let's develop a computer game where we will have spaceships and asteroids. Accordingly, we need two pairs of classes for work: asteroid and spaceship.
Let's add a unique method to the classes of successors: let asteroids be distinguished by an integer identifier, and the spacecraft are identified by a unique name:

 class asteroid : public object { public: //       asteroid(int identifier); //   asteroid(const asteroid& another); asteroid& operator = (const asteroid& another); //     "" asteroid(const object& another); asteroid& operator = (const object& another); //   - int get_identifier() const; //    class data; private: //     (!)   data* m_data; }; class spaceship : public object { public: //      spaceship(const char* name); //    spaceship(const spaceship& another); spaceship& operator = (const spaceship& another); //     "" spaceship(const object& another); spaceship& operator = (const object& another); //    " " const char* get_name() const; //    class data; private: //    (!)    data* m_data; }; 


Please note that although the ancestor of the object performs the role of the container, there is a reference in the successors to the contents of the object, but of the desired type. The inheritance of the main classes should also be duplicated for the data classes (I will show below what this is for):

 class asteroid::data : public object::data { public: //       data(int identifier); //       int get_identifier() const; //     ! virtual object::data* clone() const override; //       virtual const char* class_name() const override; private: //   asteroid     int m_identifier; }; class spaceship::data : public object::data { public: //  ,        data(const char* name); //       spaceship::data const char* get_name() const; //     ! virtual object::data* clone() const override; //      virtual const char* class_name() const override; private: //       #include <string> std::string m_name; }; 


Now we’ll go through the implementation in more detail, and everything will immediately fall into place.

Implementation methods


Creating an instance of type directly by the default constructor will mean creating an object with a null value.

 object::object() { } object::~object() { } object::object(object::data* new_data) : m_data(new_data) { } object::object(const object& another) : m_data(another.is_null() ? nullptr : another.m_data->clone()) { } object& object::operator = (const object& another) { m_data.reset(another.is_null() ? nullptr : another.m_data->clone()); return *this; } bool object::is_null() const { return !m_data; } const object::data* object::get_data() const { return m_data.get(); } const char* object::data_class() const { return is_null() ? "null" : m_data->class_name(); } void object::reset(object::data* new_data) { m_data.reset(new_data); } void object::assert_not_null(const char* file, int line) const { if (is_null()) { std::stringstream output; output << "Assert 'object is not null' failed at file: '" << file << "' line: " << line; throw std::runtime_error(output.str()); } } 


Now the most important thing is how instances of inheritance classes are initialized:

 asteroid::asteroid(int identifier) : object(m_data = new asteroid::data(identifier)) { } spaceship::spaceship(const char* name) : object(m_data = new spaceship::data(name)) { } 


As you can see from these few lines, we immediately kill a flock of hares with one volley of a phase blaster:
  1. we get the creation of the heirs with the preservation of the reference to the data in a special container class by the usual constructor;
  2. the container class is also the base class for all others, all the basic work of storing the interface is done in the base class;
  3. a descendant class has an interface for working with the data class of the corresponding class in m_data;
  4. we work with the most common classes, not by reference, getting all the benefits of C ++ automation working with instances of classes.


Of course, when accessing data, the corresponding class will use its own heir interface, while checking the data for null:

 int asteroid::get_identifier() const { assert_not_null(__FILE__, __LINE__); return m_data->get_identifier(); } const char* spaceship::get_name() const { assert_not_null(__FILE__, __LINE__); return m_data->get_name(); } 


A simple example that will work like a clock:

  asteroid aster(12345); spaceship ship("Alfa-Romeo"); object obj; object obj_aster = asteroid(67890); object obj_ship = spaceship("Omega-Juliette"); 


Checking:
Test for null:
aster.is_null (): false
ship.is_null (): false
obj.is_null (): true
obj_aster.is_null (): false
obj_ship.is_null (): false

Test for data class:
aster.data_class (): asteroid
ship.data_class (): spaceship
obj.data_class (): null
obj_aster.data_class (): asteroid
obj_ship.data_class (): spaceship

Test identification:
aster.get_identifier (): 12345
ship.get_name (): Alfa-Romeo


Doesn’t it resemble high-level languages: C #, Java, Python, etc.? The only difficulty will be getting back the interface of the heirs packed in the object. Now we will learn how to extract into the asteroid and spaceship instances what was previously packed into the object.

Way up


All we need is to overload the constructor of the class of heirs, though the initialization itself will not work very well:

 asteroid::asteroid(const asteroid& another) : object(m_data = another.is_null() ? nullptr : static_cast<asteroid::data*>(another.get_data()->clone())) { } asteroid& asteroid::operator = (const asteroid& another) { reset(m_data = another.is_null() ? nullptr : static_cast<asteroid::data*>(another.get_data()->clone())); return *this; } asteroid::asteroid(const object& another) : object(m_data = (dynamic_cast<const asteroid::data*>(another.get_data()) ? dynamic_cast<asteroid::data*>(another.get_data()->clone()) : nullptr)) { } asteroid& asteroid::operator = (const object& another) { reset(m_data = (dynamic_cast<const asteroid::data*>(another.get_data()) ? dynamic_cast<asteroid::data*>(another.get_data()->clone()) : nullptr)); return *this; } 


 spaceship::spaceship(const spaceship& another) : object(m_data = another.is_null() ? nullptr : static_cast<spaceship::data*>(another.get_data()->clone())) { } spaceship& spaceship::operator = (const spaceship& another) { reset(m_data = another.is_null() ? nullptr : static_cast<spaceship::data*>(another.get_data()->clone())); return *this; } spaceship::spaceship(const object& another) : object(m_data = (dynamic_cast<const spaceship::data*>(another.get_data()) ? dynamic_cast<spaceship::data*>(another.get_data()->clone()) : nullptr)) { } spaceship& spaceship::operator = (const object& another) { reset(m_data = (dynamic_cast<const spaceship::data*>(another.get_data()) ? dynamic_cast<spaceship::data*>(another.get_data()->clone()) : nullptr)); return *this; } 


As you can see, you will have to use dynamic_cast here, simply because you have to go up the hierarchy of data classes. It looks massive, but the result is worth it:

  object obj_aster = asteroid(67890); object obj_ship = spaceship("Omega-Juliette"); asteroid aster_obj = obj_aster; spaceship ship_obj = obj_ship; 


Checking:
Test for null:
aster_obj.is_null (): false
ship_obj.is_null (): false

Test for data class:
aster_obj.data_class (): asteroid
ship_obj.data_class (): spaceship

Test identification:
aster_obj.get_identifier (): 67890
ship_obj.get_name (): Omega-Juliette


Roundtrip. Like Tolkien, only much shorter.
Do not forget to test also assignment statements:

  aster = asteroid(335577); ship = spaceship("Ramambahara"); obj = object(); obj_aster = asteroid(446688); obj_ship = spaceship("Mamburu"); aster_obj = obj_aster; ship_obj = obj_ship; 


And check again:
Test for null:
aster.is_null (): false
ship.is_null (): false
obj.is_null (): true
obj_aster.is_null (): false
obj_ship.is_null (): false
aster_obj.is_null (): false
ship_obj.is_null (): false

Test for data class:
aster.data_class (): asteroid
ship.data_class (): spaceship
obj.data_class (): null
obj_aster.data_class (): asteroid
obj_ship.data_class (): spaceship
aster_obj.data_class (): asteroid
ship_obj.data_class (): spaceship

Test identification:
aster.get_identifier (): 335577
ship.get_name (): Ramambahara
aster_obj.get_identifier (): 446688
ship_obj.get_name (): Mamburu


Everything works as it should! Below is a link to GitHub with sources .

PROFIT!


What we have? This is not Pimpl, for Pimpl there is too much polymorphism, and the name “implementation pointer” is not the most successful. In C ++, the implementation is already separate from the class declaration, in .cpp files, Pimpl allows you to remove the data in the implementation. Here, the data are not just hidden in the implementation, they constitute a hierarchy tree, while mirroring the hierarchy of interface classes. In addition, we get the encapsulation of null values ​​and we can embed the validity logic of null values ​​in the inheritance classes. All classes easily juggle with data - both their own and the whole chain of ancestors and heirs, while the syntax itself will be simple and concise.
Want to make it easy in your library's API? Now nothing bothers you. As for the replicas that C ++ is very complex and you cannot make high-level logic on it - please, you can combine arrays of such objects, not worse than C # or Java, and the transformations will be even simpler. You can make your classes easy to use, without having to store pointers to the base class, messing with factories, in general, you can no longer emulate ordinary constructors and assignment operators in any way.

useful links


With the article are the source code posted on GitHub.
Sources are supplemented with a couple of methods that simplify testing and allow you to more quickly understand how data transfer between objects works.
I will also leave a link to the series of articles "Academy C ++" for the magazine "Hacker".

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


All Articles