Static and dynamic polymorphism in C++

Let me make a reservation right away that often polymorphism also includes function overloading, but we will not touch on this topic. Also in C++ there is a rather interesting way of implementing static polymorphism using macros, but this aspect deserves a separate article and, due to the specifics of its use, is of little practical interest.

Polymorphism

Polymorphism provides the ability to combine different types of behavior using a common notation, which typically means using functions to handle different types of data; considered one of the three pillars of object-oriented programming. Based on the implementation features, polymorphism can be divided into limited and unlimited, dynamic and static.

The concept bounded means that interfaces are completely defined in advance, for example, by the construction of the base class. Unrestricted polymorphism does not impose restrictions on the type, but only requires the implementation of a certain syntax.

Static means that binding of interfaces occurs at the compilation stage, dynamic – at runtime.

The C++ programming language provides limited dynamic polymorphism when using inheritance and virtual functions and unlimited static – when using templates. Therefore, within the framework of this article, these concepts will be referred to simply static And dynamic polymorphism. However, in general, different tools in different languages ​​may provide different combinations of types of polymorphism.

Dynamic polymorphism

Dynamic polymorphism – the most common embodiment of polymorphism in general. In C++, this feature is implemented by declaring general capabilities using the functionality of virtual functions. In this case, a pointer to a table of virtual methods (vtable) is stored in the class object, and a method is called by dereferencing the pointer and calling a method corresponding to the type with which the object was created. In this way, you can manipulate these objects using references or pointers to the base class (however, you cannot use copying or moving).

Consider the following simple example: let there be an abstract class Property that describes a taxable property with a single purely virtual method getTaxand field worth, containing the cost; and three classes: CountryHouse, Car, Apartmentwhich implement this method by determining a different tax rate:

Example
class Property
{
protected:
    double worth;
public:
    Property(double worth) : worth(worth) {}
    virtual double getTax() const = 0;
};
class CountryHouse :
    public Property
{
public:
    CountryHouse(double worth) : Property(worth) {}
    double getTax() const override { return this->worth / 500; }
};

class Car :
    public Property
{
public:
    Car(double worth) : Property(worth) {}
    double getTax() const override { return this->worth / 200; }
};

class Apartment :
    public Property
{
public:
    Apartment(double worth) : Property(worth) {}
    double getTax() const override { return this->worth / 1000; }
};


void printTax(Property const& p)
{
    std::cout << p.getTax() << "\n";
}

// Или так

void printTax(Property const* p)
{
    std::cout << p->getTax() << "\n";
}

int main()
{
    Property* properties[3];
    properties[0] = new Apartment(1'000'000);
    properties[1] = new Car(400'000);
    properties[2] = new CountryHouse(750'000);

    for (int i = 0; i < 3; i++)
    {
        printTax(properties[i]);
        delete properties[i];
    }

    return 0;
}

If you look “under the hood,” you can see that the compiler (in my case, gcc) implicitly adds a pointer to a vtable to the beginning of the Property class, and initializes this pointer in accordance with the desired type to the constructor. And this is what a fragment with a call to the getTax() method looks like in disassembled code:

mov     rbp, QWORD PTR [rbx]; В регистр rbp помещаем указатель на объект
mov     rax, QWORD PTR [rbp+0]; В регистр rax помещаем указатель на vtable
call    [QWORD PTR [rax]]; Вызываем функцию, адрес которой лежит по адресу, лежащему в rax (первое разыменование даёт vtable, второе – адрес функции.

Static polymorphism

Let's finally move on to the most interesting part. In C++, templates are a means of static polymorphism. However, this is a very powerful tool that has a huge number of applications, and their detailed consideration and study will require a full-fledged training course, so for the purposes of this article we will limit ourselves to only a superficial consideration.

Let's rewrite the previous example using templates, and at the same time, for demonstration purposes, we'll use the fact that this time we're using unlimited polymorphism.

Example
class CountryHouse
{
private:
    double worth;
public:
    CountryHouse(double worth) : worth(worth) {}
    double getTax() const { return this->worth / 500; }
};

class Car
{
private:
    double worth;
public:
    Car(double worth) : worth(worth) {}
    double getTax() const { return this->worth / 200; }
};

class Apartment
{
private:
    unsigned worth;
public:
    Apartment(unsigned worth) : worth(worth) {}
    unsigned getTax() const { return this->worth / 1000; }
};

template <class T>
void printTax(T const& p)
{
    std::cout << p.getTax() << "\n";
}

int main()
{
    Apartment a(1'000'000);
    Car c(400'000);
    CountryHouse ch(750'000);

    printTax(a);
    printTax(c);
    printTax(ch);
    return 0;
}

Here I have replaced the return type Apartment::GetTax(). Since, thanks to the overloading of the >> operator, the syntax (and, in this case, semantics) remained correct, this code compiles quite successfully, while the virtual function apparatus would not forgive us for such liberty.

In this case, as is expected when using templates, the compiler instantiated (that is, created from a template by substituting parameters) three different functions and substituted the one needed at the compilation stage – therefore template-based polymorphism is static.

As I noted in the introduction, STL is a good example of using static polymorphism. For example, this is what a simple implementation of the function looks like: std::for_each:

template<class InputIt, class UnaryFunc>
constexpr UnaryFunc for_each(InputIt first, InputIt last, UnaryFunc f)
{
    for (; first != last; ++first)
        f(*first);
 
    return f; 
}

When calling a function, we only need to provide objects for which the syntax of the operations present in the body of the function will be correct (plus, since parameters are passed and the result is returned, a copy (move) constructor must be defined for them by value). However, it should be understood that the template only specifies the syntax, so inconsistencies between the accepted syntax and semantics can lead to unexpected results. So, for example, it is natural to assume that *first does not change first, although there are no syntactic restrictions on this.

Concepts

The concept apparatus introduced into the standard relatively recently (starting with C++20) can help to somewhat strengthen the requirements for inline types. In principle, a similar effect could have been achieved before using the SFINAE principle (substitution failure is not an error) and derivative tools such as std::enable_if, but their syntax is quite cumbersome, and the resulting code It's not very pleasant to read. Using concepts, in particular, allows you to get a much more transparent error message when you try to use an inappropriate type.

In our simple example, the concept could look like this:

template <class T>
concept Property = requires (T const& p) { p.getTax(); };

And the printTax declaration:

template <Property T>
void printTax(T const& p);

Now, if we try to pass an int as a parameter, we will get a very accurate error message:

<source>:46:13: error: no matching function for call to 'printTax(int)'
   46 |     printTax(5);
      |     ~~~~~~~~^~~
<source>:34:6: note: candidate: 'template<class T>  requires  Property<T> void printTax(const T&)'
   34 | void printTax(T const& p)
      |      ^~~~~~~~
<source>:34:6: note:   template argument deduction/substitution failed:
<source>:34:6: note: constraints not satisfied

Conclusion

Of course, each method is good in some ways and not so good in others. Thus, the use of templates allows you to save a little time when executing a program, but it also has its own disadvantages inherent to templates. Thus, each instantiation of the same template with different types creates a separate function, which can significantly increase the size of the executable code. In addition, the template mechanism itself, in the form in which it exists in the language, requires that their source code be available at the compilation stage, which also creates its own inconveniences.

That’s all for today, I hope that the reader learned something new from this article or refreshed a well-forgotten old one.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *