What Are Pointers and References to the Base Class of Derived Objects?

A derived object physically contains a base class subobject, so a pointer or reference to the base class can be aimed at a derived object without any conversion, cast, or copy. The address is already the address of something that is a valid base object.

That single fact is what the whole of this chapter is built on, and it comes with a catch that is easy to miss. Aiming a base handle at a derived object changes nothing about the object; it changes what the compiler is willing to say about it. Getting the payoff and the catch straight now is what makes virtual functions look inevitable rather than magical two lessons from here.

The Handle and the Object Are Not the Same Type

Two different types are in play whenever you touch an object through a pointer or a reference, and the rest of this lesson is just the consequences of keeping them apart.

The static type of an expression is the type the compiler wrote down for it. It is fixed by the source text and never changes while the program runs. The dynamic type is the type of the object that is actually sitting at that address at the moment the code executes.

Declaration Static type of the expression Dynamic type of the object Members the compiler will let you name
FragileParcel crate{1200}; FragileParcel FragileParcel everything in FragileParcel and everything it inherits
FragileParcel& crateRef{crate}; FragileParcel FragileParcel the same set
Parcel& parcelRef{crate}; Parcel FragileParcel only what Parcel declares
Parcel* parcelPtr{&crate}; Parcel FragileParcel only what Parcel declares

The last two rows are where the interesting behaviour lives, and they are the only rows where the two type columns disagree. Everything a compiler decides about a member access, including which function of a given name to call, is decided from the third column and never from the fourth, because the fourth is not knowable until the program runs.

Binding a Base Handle to a Derived Object

Here is a small parcel hierarchy. Each class reports a handling code, and the derived classes shadow the base version with one of their own, in the ordinary non-virtual way covered back in the hiding inherited functionality lesson.

#include <iostream>
#include <string_view>

class Parcel
{
protected:
    int m_weightGrams{};

public:
    Parcel(int weightGrams)
        : m_weightGrams{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "GENERAL"; }
    int getWeightGrams() const { return m_weightGrams; }
};

class FragileParcel : public Parcel
{
public:
    FragileParcel(int weightGrams)
        : Parcel{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "FRAGILE"; }
    int getPaddingMillimetres() const { return 15; }
};

int main()
{
    FragileParcel crate{1200};

    FragileParcel& crateRef{crate};
    Parcel& parcelRef{crate};  // legal: every FragileParcel contains a Parcel
    Parcel* parcelPtr{&crate}; // legal for the same reason

    std::cout << "crate:     " << crate.getHandlingCode() << '\n';
    std::cout << "crateRef:  " << crateRef.getHandlingCode() << '\n';
    std::cout << "parcelRef: " << parcelRef.getHandlingCode() << '\n';
    std::cout << "parcelPtr: " << parcelPtr->getHandlingCode() << '\n';
    std::cout << "weight through parcelPtr: " << parcelPtr->getWeightGrams() << " g" << '\n';

    return 0;
}

This prints:

crate:     FRAGILE
crateRef:  FRAGILE
parcelRef: GENERAL
parcelPtr: GENERAL
weight through parcelPtr: 1200 g

Four handles, one object. The first two report FRAGILE and the second two report GENERAL, and no line of the program has done anything to the crate in between.

Notice what the last line proves at the same time: parcelPtr really is pointing at the crate. It reads back 1200 grams, the value the FragileParcel constructor passed up. There is no second object anywhere, nothing was copied, and nothing was sliced. One FragileParcel sits in memory and four expressions of two different static types refer to it.

Key Concept
Binding a base handle to a derived object is not a conversion of the object. The base subobject sits inside the derived object at a known offset, and the handle simply refers to that portion. The object on the other end is unchanged and still complete.

Name Lookup Runs on the Handle

Why do parcelRef and parcelPtr report GENERAL when the object they refer to is unmistakably a FragileParcel?

Because the compiler resolves parcelRef.getHandlingCode() by looking the name up in the static type, which is Parcel. It finds Parcel::getHandlingCode, checks that the call is valid, and emits a call to that function. FragileParcel::getHandlingCode is never a candidate. The compiler is not being lazy here; it genuinely cannot know what will be at that address. The same reference could be bound to a plain Parcel on the next run of the program.

This is worth separating carefully from something you already know. FragileParcel::getHandlingCode does shadow the base version, and it does so for any expression whose static type is FragileParcel. Shadowing is resolved by name lookup, name lookup starts from the static type, and a base handle's static type is the base. The shadowing is still there; the handle just is not standing anywhere that can see it.

What the Handle Cannot Reach

The same rule applies to members that only the derived class declares, and there the consequence is not a surprising value but a refusal to compile. This program is broken on purpose, to show what the compiler does with it:

#include <iostream>
#include <string_view>

class Parcel
{
protected:
    int m_weightGrams{};

public:
    Parcel(int weightGrams)
        : m_weightGrams{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "GENERAL"; }
    int getWeightGrams() const { return m_weightGrams; }
};

class FragileParcel : public Parcel
{
public:
    FragileParcel(int weightGrams)
        : Parcel{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "FRAGILE"; }
    int getPaddingMillimetres() const { return 15; }
};

int main()
{
    FragileParcel crate{1200};
    Parcel* parcelPtr{&crate};

    std::cout << parcelPtr->getPaddingMillimetres() << '\n';

    return 0;
}

GCC rejects it:

s.cpp: In function 'int main()':
s.cpp:36:29: error: 'class Parcel' has no member named 'getPaddingMillimetres'
   36 |     std::cout << parcelPtr->getPaddingMillimetres() << '\n';
      |                             ^~~~~~~~~~~~~~~~~~~~~

Read the message literally and it is exactly right. class Parcel has no such member. The padding function exists, the object has it, and the pointer is aimed straight at that object, but none of that is available to a compiler working from a Parcel*.

So a base handle narrows a derived object down to its base interface. That sounds like pure loss, and taken on its own it is. The next two sections are about why you would ever want it.

One Function for a Whole Family

Give the hierarchy a second category, ColdChainParcel, and suppose you need a routine that prints a shipping label for any parcel at all. Without base handles, the parameter type forces you into one overload per derived class:

void printLabel(const FragileParcel& parcel)
{
    std::cout << parcel.getWeightGrams() << " g -> " << parcel.getHandlingCode() << '\n';
}

void printLabel(const ColdChainParcel& parcel)
{
    std::cout << parcel.getWeightGrams() << " g -> " << parcel.getHandlingCode() << '\n';
}

Two classes make that look like a minor annoyance. A real logistics system with twenty parcel categories makes it twenty copies of one function body, and every category added later drags another copy in behind it. The bodies are identical; only the parameter type differs, which is the clearest possible sign that the parameter type is the wrong thing to be varying.

A single parameter of base reference type collapses all of them:

#include <iostream>
#include <string_view>

class Parcel
{
protected:
    int m_weightGrams{};

public:
    Parcel(int weightGrams)
        : m_weightGrams{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "GENERAL"; }
    int getWeightGrams() const { return m_weightGrams; }
};

class FragileParcel : public Parcel
{
public:
    FragileParcel(int weightGrams)
        : Parcel{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "FRAGILE"; }
    int getPaddingMillimetres() const { return 15; }
};

class ColdChainParcel : public Parcel
{
public:
    ColdChainParcel(int weightGrams)
        : Parcel{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "COLD"; }
    int getMaxCelsius() const { return 4; }
};

void printLabel(const Parcel& parcel) // one function, any parcel in the family
{
    std::cout << parcel.getWeightGrams() << " g -> " << parcel.getHandlingCode() << '\n';
}

int main()
{
    const FragileParcel glassware{1200};
    const ColdChainParcel vaccines{860};
    const Parcel textbooks{2400};

    printLabel(glassware);
    printLabel(vaccines);
    printLabel(textbooks);

    return 0;
}

One function accepts all three, including types written years after printLabel was compiled, since anything deriving from Parcel fits the parameter. The weights come through correctly. The handling codes do not:

1200 g -> GENERAL
860 g -> GENERAL
2400 g -> GENERAL

Inside printLabel, the static type of parcel is const Parcel&, so the call resolves to Parcel::getHandlingCode for all three arguments. The generalisation worked; the part that was supposed to vary stopped varying.

One Container for a Whole Family

The second motivation is storage. A container holds elements of exactly one type, so a warehouse manifest built out of concrete parcel types needs one container per category:

std::vector<FragileParcel> fragileItems;
std::vector<ColdChainParcel> coldItems;

Twenty categories, twenty containers, and any code that walks "everything in the manifest" has to walk all twenty of them in some fixed order. Adding a category means touching that code again.

Pointers give you the single container instead. const FragileParcel* and const ColdChainParcel* both convert to const Parcel*, so const Parcel* is one element type that can hold any of them:

#include <array>
#include <iostream>
#include <string_view>

class Parcel
{
protected:
    int m_weightGrams{};

public:
    Parcel(int weightGrams)
        : m_weightGrams{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "GENERAL"; }
    int getWeightGrams() const { return m_weightGrams; }
};

class FragileParcel : public Parcel
{
public:
    FragileParcel(int weightGrams)
        : Parcel{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "FRAGILE"; }
};

class ColdChainParcel : public Parcel
{
public:
    ColdChainParcel(int weightGrams)
        : Parcel{weightGrams}
    {
    }

    std::string_view getHandlingCode() const { return "COLD"; }
};

int main()
{
    const FragileParcel glassware{1200};
    const FragileParcel mirrors{3100};
    const ColdChainParcel vaccines{860};
    const Parcel textbooks{2400};

    const std::array<const Parcel*, 4> manifest{&glassware, &mirrors, &vaccines, &textbooks};

    for (const Parcel* parcel : manifest)
    {
        std::cout << parcel->getWeightGrams() << " g -> " << parcel->getHandlingCode() << '\n';
    }

    return 0;
}

The container is homogeneous and the objects in it are not, which is exactly what was wanted. The loop body is written once. And the output has the same hole in it as before:

1200 g -> GENERAL
3100 g -> GENERAL
860 g -> GENERAL
2400 g -> GENERAL
Warning
A container of raw pointers does not own anything. Every object in the manifest above has to outlive the array that points at it, which is why they are all locals of main declared before it. Storing pointers to objects that go out of scope first leaves the container full of dangling pointers.

Both techniques deliver the structural win and then stall on the same wall: the element or parameter type is the base, so every call resolves to the base version of the function. The uniform handling is real and the uniform behaviour is the problem.

Why a Template Is a Different Trade

A function template also collapses the overload set, and it is worth knowing why it is not a replacement for a base reference:

template <typename T>
void printLabel(const T& parcel)
{
    std::cout << parcel.getWeightGrams() << " g -> " << parcel.getHandlingCode() << '\n';
}

This does call the derived function, because each instantiation has the concrete derived type as its static type. But it buys that at a real cost. The signature no longer documents that T is meant to be a parcel, and nothing enforces it either, so any unrelated class that happens to have getWeightGrams() and getHandlingCode() is accepted. More decisively, a template resolves types at compile time, one instantiation per type used. It cannot help with the manifest at all: there is still no single element type that a mixed container could use, because printLabel<FragileParcel> and printLabel<ColdChainParcel> are two separate functions and the container would need to know which one to call before it could store anything.

Pushing the Difference Down Into the Base

Before reaching for a language feature, it is worth trying the obvious workaround, because seeing where it breaks is what makes the language feature make sense.

The handling code differs per category and a base handle can only see base members, so put the handling code in the base as data. The derived class no longer shadows a function; it supplies a value at construction, either by passing it to the base constructor or by assigning to the inherited member in its own constructor body:

#include <array>
#include <iostream>
#include <string>
#include <string_view>

class Parcel
{
protected:
    int m_weightGrams{};
    std::string m_handlingCode;

public:
    Parcel(int weightGrams, std::string_view handlingCode)
        : m_weightGrams{weightGrams}
        , m_handlingCode{handlingCode}
    {
    }

    std::string_view getHandlingCode() const { return m_handlingCode; }
    int getWeightGrams() const { return m_weightGrams; }
};

class FragileParcel : public Parcel
{
public:
    FragileParcel(int weightGrams)
        : Parcel{weightGrams, "FRAGILE"}
    {
    }
};

class ColdChainParcel : public Parcel
{
public:
    ColdChainParcel(int weightGrams)
        : Parcel{weightGrams, "COLD"}
    {
    }
};

int main()
{
    const FragileParcel glassware{1200};
    const FragileParcel mirrors{3100};
    const ColdChainParcel vaccines{860};

    const std::array<const Parcel*, 3> manifest{&glassware, &mirrors, &vaccines};

    for (const Parcel* parcel : manifest)
    {
        std::cout << parcel->getWeightGrams() << " g -> " << parcel->getHandlingCode() << '\n';
    }

    return 0;
}

This prints what the earlier version could not:

1200 g -> FRAGILE
3100 g -> FRAGILE
860 g -> COLD

And it prints it without breaking any rule from earlier in the lesson. getHandlingCode is a base member, the static type is Parcel, name lookup finds exactly one candidate, and the value it returns happens to differ per object because the constructor put a different string there. Nothing is being dispatched; a base function is reading a base member.

Why That Workaround Runs Out

The trouble is that the trick only stretches so far, in three directions at once.

It costs a member per difference. Handling code, padding depth, maximum temperature, insurance band: each way the categories differ becomes another string or integer in Parcel, carried by every parcel in the program including the ones that have no use for it. The base class swells to the union of everything any derived class might want to say about itself.

It only handles differences fixed at construction. A member is a stored value, so it can only answer questions whose answer is known when the object is built. The moment a handling code needs to depend on the current temperature reading, or on how many times the parcel has been rerouted, there is no value to store and the pattern has nothing to offer.

It cannot vary the behaviour, only the data. getHandlingCode is one function with one body for every category, and that body returns a member. Give one category a different rule for computing its code and the rule has to be written inside Parcel, which now has to know about its own derived classes. That inverts the direction inheritance is supposed to run in, and it grows a branch for every category added.

What Is Actually Missing
Every dead end in this lesson has the same shape: the function to call is chosen from the static type of the handle, and the static type is the base. What is needed is a way to have that choice made from the dynamic type instead, at the moment of the call. That is precisely the job of a virtual function, and it is the subject of the next lesson.

Looking Forward

The next lesson adds one keyword to getHandlingCode and every output in this lesson changes, without a single call site being touched. From there the chapter covers override and final for catching mistakes in a hierarchy, virtual destructors for cleaning up through a base pointer, and the virtual table that makes the mechanism work at runtime. Object slicing, later in the chapter, explains what goes wrong when a derived object is copied into a base value rather than referred to through a base handle, which is the one case this lesson deliberately avoided.

Key Terminology

  • Static type: the type an expression has according to the compiler, fixed by the source text and unchanged at runtime
  • Dynamic type: the type of the object actually present at a given address while the program is running
  • Base subobject: the base class portion embedded inside every derived object, which is what a base handle refers to
  • Base handle: informal shorthand for a pointer or reference whose static type is a base class
  • Shadowing: a derived class declaring a member with the same name as a base member, hiding the base one from lookups that start in the derived class

Summary

  • A derived object contains a base subobject, so a base class pointer or reference can be bound to a derived object with no conversion and no copy
  • Two types are always in play: the static type of the handle, known at compile time, and the dynamic type of the object, known only at runtime
  • The compiler decides which members may be named, and which function of a given name to call, entirely from the static type
  • Through a base handle, only base members are reachable; naming a derived-only member is a compile error, not a runtime failure
  • A shadowing derived function is not called through a base handle, because lookup starts in the base and never sees it
  • Base handles let one function parameter accept every type in a hierarchy, including types written after that function
  • Base pointers let one container hold objects of several derived types, since every derived pointer converts to the base pointer type
  • Both techniques stall at the same point: calls resolve to the base version, so behaviour stops varying just as storage and interfaces become uniform
  • Storing the differing data as a base member restores the right values, but costs a member per difference, works only for values fixed at construction, and cannot vary behaviour at all
  • Virtual functions, covered next, move the choice of function from the static type to the dynamic type and remove the limitation