Inheritance/Polymorphism  «Prev  Next»
Lesson 10 C++ Virtual Functions
Objective Discuss guidelines for virtual functions in C++23 along with several rules.

Guidelines for Virtual Functions in C++23

Virtual functions are the core mechanism behind runtime polymorphism in C++. They let you call behavior through a base-class pointer or reference while still executing the derived type’s override. Used well, virtuals enable extension, plugin-style designs, and reusable abstractions. Used poorly, they create brittle hierarchies, unclear ownership, and surprising construction/destruction bugs.

This lesson focuses on practical C++23 guidance: when virtual functions are the right tool, how to write them so overrides are safe and obvious, and which rules keep polymorphic code maintainable.

When to use virtual functions

Core rules and guidelines

  1. Only non-static member functions can be virtual.

    Static functions have no dynamic object, so there is nothing to dispatch on.

  2. Declare virtual in the base, use override in derived classes.

    In C++23, override is your safety net: it makes the compiler reject “almost overrides” caused by signature mismatches (a common source of subtle bugs).

    
    struct Shape {
        virtual ~Shape() = default;
        virtual void draw() const = 0;
    };
    
    struct Circle final : Shape {
        void draw() const override; // checked override
    };
          

  3. Make base destructors virtual when deleting through base pointers.

    Rule of thumb: if a class is intended to be used polymorphically (i.e., has virtual functions), its destructor should almost always be virtual.

    
    struct Base {
        virtual ~Base() = default;
    };
          
  4. Constructors cannot be virtual.

    During base-class construction, the object’s dynamic type is the base. The derived subobject doesn’t exist yet, so dispatching to derived overrides would be unsafe.

  5. Avoid calling virtual functions from constructors and destructors.

    During construction and destruction, virtual dispatch “collapses” toward the currently-constructed (or currently-destroyed) type. This can surprise readers and break invariants.

    If you need “two-phase initialization,” prefer a named factory or builder that constructs the object and then calls a non-virtual initialization step on the fully-formed object, or invoke a virtual hook after construction via an external function.

  6. Use pure virtual functions to define abstract interfaces, but keep them minimal.

    A pure virtual (= 0) function expresses “every derived type must provide this behavior.” Keep the interface small and cohesive; large “fat base classes” tend to become dumping grounds.

    
    struct Reader {
        virtual ~Reader() = default;
        virtual std::string readLine() = 0;
    };
          
  7. Prefer non-virtual interfaces when you want invariants enforced centrally (NVI idiom).

    A common C++ design is to expose a public non-virtual function that enforces pre/post conditions and calls a protected virtual implementation hook. This keeps invariants in one place while allowing customization.

    
    class Document {
    public:
        void save() {          // non-virtual public API
            // validate invariants, logging, timing, etc.
            saveImpl();        // customization point
        }
        virtual ~Document() = default;
    
    protected:
        virtual void saveImpl() = 0;
    };
          
  8. Mark types or functions final when extension is not intended.

    final documents intent and can enable devirtualization optimizations.

  9. Keep exception behavior consistent across overrides.

    Callers typically reason about exceptions at the base interface level. If one override throws where others do not, document it clearly and consider whether the base interface should express that behavior.

  10. Be explicit about ownership and lifetime.

    Polymorphic objects are often created dynamically. In C++23, prefer RAII: store owning pointers in std::unique_ptr<Base> by default, and use std::shared_ptr only when shared ownership is truly required.

How virtual dispatch works

Virtual calls become interesting when you access a derived object through a base pointer or reference. The runtime selects the correct override based on the object’s dynamic type.


#include <iostream>

struct Base {
    virtual ~Base() = default;
    virtual void vfunc() const {
        std::cout << "Base::vfunc()\n";
    }
};

struct Derived1 : Base {
    void vfunc() const override {
        std::cout << "Derived1::vfunc()\n";
    }
};

struct Derived2 : Base {
    void vfunc() const override {
        std::cout << "Derived2::vfunc()\n";
    }
};

int main() {
    Base b;
    Derived1 d1;
    Derived2 d2;

    Base* p = &b;
    p->vfunc();

    p = &d1;
    p->vfunc();

    p = &d2;
    p->vfunc();
}
  

This program displays the following:
Base::vfunc()
Derived1::vfunc()
Derived2::vfunc()
  

Summary


SEMrush Software