What is a Virtual Function – And How Can C++ Devs Make the Most of Them?

Amir Kirsh
Amir Kirsh reading time: 10 minutes
December 21, 2022

Table of contents

What is a virtual function?

A virtual function is a member function which is declared within a base class, and that you expect to be redefined in derived classes.

So what is a virtual function in C++?

In C++, a virtual function is usually used to achieve runtime polymorphism, one of the features of object-oriented programming offered by C++. We’ll go into runtime polymorphism in a little more detail below.

A virtual function’s most important job is to ensure that the correct function is called regardless of the type of pointer or reference used for the function call.

Rules of virtual functions

C++ virtual functions must follow a few key rules:

  • Functions are declared using the ‘virtual’ keyword in the base class
  • They cannot be static
  • To achieve runtime polymorphism, virtual functions should be accessed using a pointer or reference
  • The prototype of these functions should be the same for both the base and the derived class (Covariant return type is allowed, we will discuss this below)
  • In case you have virtual functions in your base class you should also have a virtual destructor to prevent calls to the wrong destructor

An example of a virtual function with C++

Let’s see what a virtual function in C++ looks like in action.

class Pet {
public:
    virtual ~Pet() {}
    virtual void make_sound() const = 0;
};

class Dog: public Pet {
public:
    virtual void make_sound() const override {
        std::cout << "raf raf\n";
    }
};

class Cat: public Pet {
public:
    virtual void make_sound() const override {
        std::cout << "mewo\n";
    }
};

int main() {
    Cat mitzi;
    Dog roxy;
    Pet *pets[] = {&mitzi, &roxy};
    for(auto pPet: pets) {
        pPet->make_sound();
    }
}

Let’s explain the example above.

Pet doesn’t know how to make any sound, it’s just a generic base class. But we still want it to have a `make_sound` function, so we can call `make_sound` on a pet without knowing its type. The actual pet type would be known only at compile time. This is why we declare the virtual function `make_sound` in the base with =0 saying that this is a pure virtual function to be implemented by the derived classes.

Then Dog and Cat actually implement the function. We add the keyword `override` to their implementations so the compiler would make sure that the signatures of the functions matches the one in the base class.

In the main, we can call `make_sound` on a Pet pointer without knowing at compile time which kind of pet this pointer points at, and at runtime we reach the desired function based on the actual object that is there. 

We must emphasize that this is a very simple example. For this simple example we could have other solutions (e.g. to hold a data member for the pet’s sound and avoid the need for virtual functions), but the idea was to show how virtual functions are implemented. Usually virtual functions would be used to model a different behavior in derived classes that cannot be modeled with a simple data member.

Covariant Return Type

We mentioned that when implementing a virtual function, the signature of the derived class function must match the one in the base. The only difference that is allowed is in the return type and as long as the return type of the derived is a derived type of the one returned by the base. Let’s see an example:

class PetFactory {
public:
    virtual ~PetFactory() {}
    virtual Pet* create() const = 0;
} 

class DogFactory: public PetFactory {
public:
    virtual Dog* create() const override {
        return new Dog();
    }
}; 

class CatFactory: public PetFactory {
public:
    virtual Cat* create() const override {
        return new Cat();
    }
};

int main() {
    std::vector<Pet*> pets;
    DogFactory df;
    CatFactory cf;
    PetFactory* petFactory[] = {&df, &cf};
    for(auto factory: petFactory) {
        pets.push_back(factory->create());
    }
    for(auto pPet: pets) {
        pPet->make_sound();
    }
    for(auto pPet: pets) {
        delete pPet;
    }
}

In the example above, the PetFactory create function can only know that it may return a Pet* but the DogFactory and CatFactory can be more specific, using covariant return type, and that is still a valid virtual function implementation.

Benefits of using a virtual function in C++

Now, if you’ve spent any time playing around with C++, you’ve probably noticed that you don’t need a virtual function to redefine base functions in derived classes.

But there’s one major difference that makes virtual functions indispensable: virtual functions override base class function, making runtime polymorphism possible.

Polymorphism is, essentially, the ability of a function or object to perform in different ways, depending on how it is used. It’s one of the key features of object-oriented programming – which, together with many other features, is what sets C++ apart as a programming language from C.

More flexible, more generic code

This is the major benefit that underlies all polymorphic programs: by allowing a call to a function to perform in different ways depending on the calling object as known at runtime, you can make your program much more flexible and generic. In this way, runtime polymorphism really allows your code to reflect reality – specifically the way that objects (or people, or animals, or shapes) don’t always perform the same way in every scenario.

Reusable code

By using virtual functions, we can separate between a generic operation that should be implemented only once, and the specific details that may differ in different subclasses. Consider the following example: if we wish to implement a hierarchy of prism classes, the base area needs to be calculated in each derived class separately, but the volume function can be implemented in the base class, using the derived classes implementation for base area. It would look like this:

class Prism {
    double height;
public:
    virtual ~Prism() {}
    virtual double baseArea() const = 0;
    double volume() const {
        return height * baseArea();
    }
    // ...
};

class Cylinder: public Prism {
    double radius;
public:
    double baseArea() const override {
         return radius * radius * std::numbers::pi
    }
    // ...
};

Design by Contract

The term design-by-contract refers to the idea that if your code sets a contract that enforces your design, it is much better than enforcing your design only by documentation. Virtual functions, and especially pure virtual functions, are considered a design-by-contract tool as they dictate the design decision of reimplementing a specific operation differently in the derived classes.

Limitations of virtual functions

Virtual functions can be very powerful, but they’re not without downsides. There are a few things you should be aware of before you start using them.

Performance

Virtual functions cost more than a regular function, both in runtime performance and in memory.

The memory part is usually redundant, it is implementation dependent, but most usually an additional internal pointer per each object. Which is not a big deal unless you have millions of small objects for which the additional pointer may become a memory issue.

The runtime performance cost is with two hops to the function instead of one, or two hops instead of zero if the function could be inlined. Virtual function requires one jump to the virtual table and another one to the function itself. The two jumps are not the only costs, this additional jump increases the probability of not having the instructions ready in the CPU’s instructions cache.

At the end, if you need polymorphism the extra cost in performance is usually reasonable compared to other alternatives. And yet, it is usually one of the considerations when adding the first virtual function to your class.

Design Issues

Inheritance and specifically virtual functions, raise design issues. Bad design of your inheritance hierarchy may lead to inflation of classes and odd relations between classes.

The rules of calling a virtual function from the constructor and destructor also affect your design. Any call to a virtual function from the constructor and destructor is not polymorphic, leading sometimes to a need of moving operations from the constructor to an `init` virtual function.

To avoid bad design, one should remember that inheritance and polymorphism is not necessarily the best solution for any problem. Watch Sean Parent’s talk “Inheritance is the Base Class of Evil” to get deeper into that.

Difficult to debug, easy to make mistakes

One of the challenges with virtual functions is, ironically, their resilience.

Debugging virtual function calls may become a bit messy, trying to follow the call flow. It is not much more difficult that following function calls in general, but you still need some additional effort in following hidden dispatching based on the type of the object. The debugger of course will take you in the right path, but it may become a bit more difficult to decide where to put your breakpoints.

As for easier to make mistakes. In some cases, a virtual function shall not call its base implementation, in some it shall call it at the beginning and sometimes it shall call it at the end. It’s very easy to get this wrong and forget calling the base implementation or calling it in the wrong place or when not needed.

Alternatives for virtual functions

Just Data Members

The first alternative would be to try and model the different behavior based on simple data members. If the only difference between types is their sound, get the sound into a data member, initialize it upon construction and you are done. But in many cases the behavior is more complicated and requires different implementation.

Variant

Another option is to use std::variant and std::visit, which may be relevant especially when the different type to support are known and the list is not too long. You can read more about this option here and here.

Functional Programming

Instead of modeling the different operations in a class hierarchy you can model it by passing the operation to be performed, as a lambda a function object or even as an old C-style pointer to function. This approach keeps separation between your data model and the operations that you may want to perform, allowing great flexibility.

Static Polymorphism

Static polymorphism is a template based approach for getting dynamic of polymorphism but at compile time, based on the types that you actually want to use as known at compile time. For example you may want your code to support both UDPConnection and TCPConnection, but you probably know at compile time which flow is using which. Relying on templates static polymorphism would achieve better performance.

Some of the alternative techniques may result with getting longer compilation times for your project. We would argue that this shall not affect your design decisions, especially if you are using Incredibuild for accelerating your builds. Pick first the proper design choices, then use the right tool to shorten your compilations.

Frequently asked questions

1.     What is a virtual function with C++?

A virtual function is a member function which is declared within a base class, which will be redefined in derived classes. In C++, a virtual function is used to achieve runtime polymorphism.

2.    What are some issues with virtual functions

 Virtual functions can cause more than regular functions in terms of runtime performance and memory usage. Additionally, it can create design issues based on inheritance hierarchy, leading to class inflation and odd relations. Finally, virtual functions are often difficult to debug due to their function calls. They also make it easier for mistakes to happen due to unpredictability in call order.

3.    Are there any alternatives for virtual functions in C++?

Yes, there are alternatives that you may want to consider for better design or to gain better performance. And still, virtual functions are commonly used by C++ programmers and should be considered as one of the tools in your toolkit.

In case you go with an alternative, like static polymorphism based on templates, don’t let the longer compilation times divert you from your design choices. Make sure to have the proper tools for accelerating your builds and if you are not using Incredibuild for that check out our solutions and see how it can do miracles in terms of reducing compile time.

 

Amir Kirsh
Amir Kirsh reading time: 10 minutes minutes December 21, 2022
December 21, 2022

Table of Contents

Related Posts

10 minutes 8 Reasons Why You Need Build Observability

Read More  

10 minutes These 4 advantages of caching are a game-changer for development projects

Read More  

10 minutes What Level of Build Observability Is Right for You?

Read More