Virtual functions

C++ virtual functions select an override from the dynamic type; learn pure virtual interfaces, safe destruction, and lifetime dispatch rules.

level intermediate time 9 min at Standard depth
version C++23
what

A virtual function lets a base reference or pointer call the final overrider selected from an object’s dynamic type, enabling subtype polymorphism.

trap

One missing const can prevent an override; a non-virtual destructor, object slicing, or a virtual call during construction can also defeat the expected polymorphic behavior.

fix

Mark derived implementations override, use a public virtual destructor for polymorphic ownership, and preserve dynamic types with references or smart pointers.

What it is and why it exists

A virtual function is a non-static member function declared virtual in a base class. A derived class can provide a matching override. When code calls the function through a base reference or pointer, the runtime selects a function body from the complete object’s dynamic type.

This solves the problem where a caller knows only a common interface but behavior depends on the actual object. An alert system, for example, can store different AlertSink objects and call one send() interface without a central branch that lists every console, file, and network implementation.

This capability is subtype polymorphism . The static type controls which members are visible and which overloads participate in resolution; the dynamic type selects an implementation only after a virtual function has been chosen. Many override bugs come from treating these two stages as one.

Introduce virtual functions only when behavior really must vary at runtime. They fit heterogeneous object collections and library extension points whose implementations aren’t all known to the caller. When the set of types is closed and known at compile time, std::variant, templates, or ordinary composition may state the constraints more clearly.

Virtual functions don’t give an interface correct ownership, lifetime, or substitution semantics automatically. The base must still define inputs, results, errors, and destruction, and derived classes must preserve that contract. Successful dispatch means the language found a function body; it doesn’t mean the hierarchy is well designed.

How it works

An expression’s static type comes from declarations and type deduction. A complete object’s dynamic type is the most-derived type that was actually constructed. If a JsonFormatter object binds to const Formatter&, the reference’s static type is Formatter, while the object’s dynamic type remains JsonFormatter.

The compiler first performs name lookup and overload resolution. If the selected member isn’t virtual, that stage fixes the call. If it is virtual and the call isn’t explicitly qualified, the runtime uses the dynamic type to find the final overrider .

The base writes virtual only on the first declaration; virtuality continues down the derived chain. A derived class should write override so the compiler checks the parameters, const, reference qualifiers, return type, and related rules. final prevents a deeper class from overriding that function, or prevents further derivation when it appears on a class declaration.

A pure virtual function ends its declaration with = 0. A class with a pure virtual function that has no concrete final overrider is an abstract class and can’t be instantiated directly. A derived class becomes concrete only after it supplies every required operation.

Virtual dispatch preserves a dynamic type; it doesn’t preserve data that has already been discarded. Copying a derived object into a base value causes object slicing : the new object contains only the base subobject, and its dynamic type is the base. A later virtual call can’t recover the lost derived portion.

If an interface permits owning and deleting a derived object through a base pointer, the base needs a public virtual destructor . Destruction then enters the most-derived destructor before destroying base parts in order. Deleting through a public non-virtual base destructor has undefined behavior .

Examples

These three programs show virtual versus non-virtual calls, polymorphic ownership through an abstract interface, and restricted dispatch during construction and destruction. Each was compiled and run locally with GCC 13.3 using -std=c++23 -Wall -Wextra -Wpedantic -Werror; the transcripts are the actual output.

Dynamic and static members

render() is virtual; category() isn’t. print_order() receives only a Formatter reference, so the two members behave differently at the same call site.

dispatch.cpp
#include <iostream>
#include <string>

class Formatter {
public:
    virtual std::string render(int order_id) const {
        return "text:" + std::to_string(order_id);
    }

    std::string category() const { return "formatter"; }
    virtual ~Formatter() = default;
};

class JsonFormatter final : public Formatter {
public:
    std::string render(int order_id) const override {
        return "{\"order\":" + std::to_string(order_id) + "}";
    }

    std::string category() const { return "json"; }
};

void print_order(const Formatter& formatter) {
    std::cout << formatter.category() << '\n';
    std::cout << formatter.render(7) << '\n';
}

int main() {
    JsonFormatter formatter;
    print_order(formatter);
    std::cout << formatter.category() << '\n';
}
formatter
{"order":7}
json

formatter.category() is selected from the expression’s static type, JsonFormatter, in main(), so it prints json. Inside print_order(), the non-virtual category() is selected from the static type Formatter and prints formatter.

Overload resolution for render() also starts from the Formatter interface, but this member is virtual. The object’s dynamic type is JsonFormatter, so execution reaches the implementation marked override and prints the JSON text.

Pure virtual interface and ownership

AlertSink specifies behavior without providing a default send(). The container owns different concrete objects through std::unique_ptr<AlertSink>, so the base destructor must be public and virtual.

abstract_sinks.cpp
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

class AlertSink {
public:
    virtual void send(const std::string& message) = 0;
    virtual ~AlertSink() = default;
};

class ConsoleSink final : public AlertSink {
public:
    explicit ConsoleSink(std::string prefix) : prefix_(std::move(prefix)) {}

    void send(const std::string& message) override {
        std::cout << prefix_ << message << '\n';
    }

private:
    std::string prefix_;
};

class CountingSink final : public AlertSink {
public:
    void send(const std::string& message) override {
        std::cout << "count=" << ++count_ << " message=" << message << '\n';
    }

private:
    int count_ = 0;
};

int main() {
    std::vector<std::unique_ptr<AlertSink>> sinks;
    sinks.push_back(std::make_unique<ConsoleSink>("[ops] "));
    sinks.push_back(std::make_unique<CountingSink>());

    for (const auto& sink : sinks) {
        sink->send("disk 95%");
    }
}
[ops] disk 95%
count=1 message=disk 95%

Every container element has the same static interface, AlertSink, but each pointed-to object has a different dynamic type. The loop needs no if or cast: each send() call reaches the right final overrider.

When main() exits, each unique_ptr<AlertSink> deletes through a base pointer. Virtual destruction ensures the ConsoleSink or CountingSink destruction runs before the AlertSink part is destroyed. That ownership contract belongs in the interface even though these derived classes don’t currently declare custom destructors.

Dispatch during construction and destruction

While a layer is being constructed or destroyed, a virtual call doesn’t enter a more-derived part whose lifetime hasn’t begun or has already ended. Both the Stage constructor and destructor below call report(), but both calls stay in Stage::report().

construction_dispatch.cpp
#include <iostream>

class Stage {
public:
    Stage() {
        std::cout << "Stage ctor: ";
        report();
    }

    virtual void report() const {
        std::cout << "Stage::report\n";
    }

    virtual ~Stage() {
        std::cout << "Stage dtor: ";
        report();
    }
};

class Pipeline final : public Stage {
public:
    Pipeline() {
        std::cout << "Pipeline ctor: ";
        report();
    }

    void report() const override {
        std::cout << "Pipeline::report ready=" << std::boolalpha << ready_ << '\n';
    }

    ~Pipeline() override {
        std::cout << "Pipeline dtor: ";
        report();
    }

private:
    bool ready_ = true;
};

int main() {
    Pipeline pipeline;
    std::cout << "main: ";
    pipeline.report();
}
Stage ctor: Stage::report
Pipeline ctor: Pipeline::report ready=true
main: Pipeline::report ready=true
Pipeline dtor: Pipeline::report ready=true
Stage dtor: Stage::report

While the Stage constructor runs, the Pipeline part hasn’t begun its lifetime, so the first line can’t read ready_. Once execution enters the Pipeline constructor body, the member has been initialized and the call can reach Pipeline::report().

Destruction reverses the sequence. The derived part still exists while the Pipeline destructor body runs. By the time control enters the Stage destructor, the derived part’s lifetime has ended and dispatch stops at Stage.

Pitfalls

An apparent override that is a new overload

Fix: mark every derived implementation override and treat a compilation failure as evidence of an interface mismatch. Compare parameter types, const, reference qualifiers, and covariant returns; don’t repeat virtual as a substitute for checking intent.

Base ownership with a non-virtual destructor

Fix: use a public virtual destructor when polymorphic deletion is allowed. If deletion through the base is deliberately forbidden, use a protected non-virtual destructor and make ownership APIs return a type that expresses the real deletion strategy.

Slicing at a value boundary

Fix: use Base& or const Base& for non-owning calls and usually std::unique_ptr<Base> for heterogeneous ownership. When polymorphic value semantics are required, define a deliberate clone() contract or use a closed std::variant representation.

Depending on a constructor to call a derived override

Fix: let each constructor establish invariants that its own class can guarantee. If an overridable step needs a complete object, use a factory that calls it after construction, or pass the needed data to the base as ordinary constructor arguments.

Mixing virtual functions with different defaults

Fix: don’t change default arguments in derived virtual functions. Put the default in a non-virtual base entry point that calls a virtual core without defaults, or require callers to pass the value explicitly.

Deep Final overriders and override rules

Final overriders and override rules

At each virtual call site, name lookup and overload resolution first choose a member declaration from the static type. The dynamic type participates only when that declaration is virtual. The final overrider is the override left along the relevant inheritance path in the most-derived class; if a class has no unique final overrider for a virtual function, the class definition is ill-formed.

An override needs a matching parameter list and matching const, volatile, and reference qualifiers. The return type is usually the same, though a pointer or reference to a class can use a permitted covariant return. override doesn’t create the relationship; it requires the compiler to prove that the relationship already exists.

Access control doesn’t decide whether a function overrides. A derived class can override a private virtual function in its base even though it can’t name that private base member directly. This rule makes the non-virtual interface pattern possible: a public non-virtual entry point controls the flow while a private virtual function supplies the extension point.

Name hiding isn’t overriding either. Declaring a same-named function in a derived class can hide other base overloads from ordinary lookup. using Base::operation can reintroduce the overload set, but the complete function declaration still determines whether an override exists. Enable compiler warnings as well as using override; they catch different classes of mistakes.

Default arguments and qualified calls

Default arguments aren’t part of virtual dispatch. The compiler fills a missing argument from the static declaration visible at the call site, and the runtime then chooses the body. Different defaults in a base and derived class therefore create subtle mixed behavior.

Explicitly qualifying a member name suppresses virtual dispatch. Even when object has a derived dynamic type, object.Base::render() calls Base::render() directly. A derived implementation can use this form to reuse a base implementation, but reviewers must recognize it as a deliberate bypass of dispatch.

A pure virtual function may still have an out-of-class definition, but its class remains abstract and a derived class still needs an override to become concrete. A derived function can enter that definition with a qualified call. A pure virtual destructor must have a definition because the base destructor phase still runs when a concrete derived object is destroyed.

Construction, destruction, and lifetime

Constructing a derived object starts with its base parts, followed by members and the derived part. While a given constructor runs, virtual calls on that object use final overriders in the current construction layer or its bases. They don’t enter a more-derived part whose lifetime hasn’t started.

The rule is symmetric during destruction: the most-derived destructor body runs first, followed by members and bases layer by layer. Once control enters a base destructor, more-derived parts have ended their lifetimes, so virtual calls don’t return to them. This restriction prevents access to unconstructed or destroyed state, but it also makes virtual initialization and cleanup hooks easy to misunderstand.

During construction or destruction, a virtual call on the current object that reaches a pure virtual function has undefined behavior. A diagnostic isn’t guaranteed along every indirect call path, so “the linker will catch it” isn’t a safety mechanism. Factories, explicit post-construction steps, and RAII owned by members express cross-layer lifetime work more reliably.

Language guarantees and common implementations

C++ specifies the observable result of virtual dispatch, but it doesn’t prescribe a virtual function table, the location of a virtual-table pointer, or a table layout. Mainstream ABIs commonly use a table per class and a hidden pointer in each object. That is an implementation strategy, not a portable object-representation contract.

Don’t convert objects to void*** to read table slots, and don’t present sizeof differences as cross-platform constants. Such probes can violate the object model or depend on the compiler, target architecture, and ABI. Debugger views, compiler layout reports, and disassembly are better tools for implementation-level diagnosis.

A virtual call is commonly implemented as an indirect call, but a compiler can devirtualize and inline when it proves the dynamic type. final can make that proof easier; it doesn’t guarantee an optimization and can’t replace a benchmark. Without measurements matched to the compiler, optimization settings, hardware, and real workload, don’t attach a fixed cost to virtual dispatch.

Publishing a C++ class hierarchy across a shared-library or plugin boundary makes ABI policy part of the interface. Adding, removing, or reordering virtual functions can change a particular ABI’s table layout. Source compatibility isn’t binary compatibility; providers must define their compiler, standard library, build settings, version policy, and recompilation boundary, or use a stable C interface with opaque handles.

Testing the dispatch contract

Tests should exercise the interface in the same form production code uses it. Calling Derived::operation() directly proves the derived function works, but it doesn’t prove that the base signature matches, that dynamic dispatch reaches it, or that ownership destroys the complete object.

Use a small call matrix to expose those boundaries:

Test expressionWhat it verifies
derived.operation()Direct derived behavior
base_ref.operation()Override matching and virtual dispatch
base_ptr->operation()Pointer dispatch and null preconditions
Base value = derivedThe deliberate or accidental slicing result
destruction through unique_ptr<Base>The polymorphic destruction contract

Contract tests should run against every concrete implementation through Base& or const Base&. Give the implementations different observable results so a fallback to the base can’t hide. Also test arguments at the base contract’s boundaries; an override that narrows accepted input violates substitutability even when dispatch itself is correct.

Compile-time checks carry part of the load. Write override on every intended override, enable warnings for hidden virtual functions, and use static_assert(std::has_virtual_destructor_v<Base>) when a generic owner requires polymorphic deletion. Such checks document assumptions close to the interface and fail before a missed dispatch reaches runtime.

Destruction tests shouldn’t depend only on a printed destructor name. Give a derived test object an RAII member whose observable cleanup can be checked, destroy it through the real owning base type, and run AddressSanitizer or UndefinedBehaviorSanitizer on exercised paths. Sanitizers don’t prove the design sound, but they can reveal lifetime failures that ordinary output misses.

For construction and destruction hooks, record the order explicitly. A useful regression test distinguishes calls made in the base constructor, derived constructor body, normal lifetime, derived destructor body, and base destructor. The expected sequence should name the selected implementation at each stage.

Review a new derived class in this order:

  1. Compare each intended override with the exact base declaration.
  2. Run common contract cases through a base reference.
  3. Exercise the ownership and destruction route used by callers.
  4. Check construction, destruction, default arguments, and qualified calls for static-type surprises.

This sequence separates language-mechanism failures from behavioral contract failures. A function can dispatch to the intended override and still weaken a postcondition, retain an object too long, or return a reference whose lifetime is too short.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?