What Happens When You Call an Inherited Function?

A derived class starts life with every member function its base class declared. That much you already know. What this lesson is really about is the question the compiler has to answer at each call site: given a hierarchy where several classes might declare a function with the right name, which one runs?

The answer is a search, and the search has a rule that catches almost everybody the first time. Get the rule right and the rest of this lesson is consequences.

The Search Stops at the First Class That Has the Name

When you write object.functionName(arguments), the compiler does two things in order, and it is important that they are separate steps.

  1. Find the name. Starting at the object's own class, it looks for any member declared with that name. If there is none, it moves to the direct base class and looks again, continuing up the chain until it finds a class that declares the name.
  2. Choose among the overloads it found. Ordinary overload resolution then runs, but only over the declarations in that one class. Every other class in the hierarchy has already dropped out of consideration.

Step 1 asks about names. It does not look at argument types at all. Step 2 looks at argument types, but by then the candidate set is fixed. Stated in one sentence: the compiler picks the best match from the most-derived class that declares at least one function with that name.

Key Concept
Name lookup and overload resolution are two separate phases. The search for a name stops as soon as it hits a class that has it, and every base class above that point is invisible for the rest of the call, no matter how much better one of its functions would have matched.

Here is the search walking up two levels:

#include <iostream>

class Ledger
{
public:
    void summarize() const { std::cout << "ledger totals only\n"; }
    void archive() const { std::cout << "archived by Ledger\n"; }
};

class SubscriptionLedger : public Ledger
{
public:
    void summarize() const { std::cout << "renewals only\n"; }
};

class TrialLedger : public SubscriptionLedger
{
};

int main()
{
    TrialLedger trials{};

    trials.summarize(); // not in TrialLedger, found in SubscriptionLedger, search stops
    trials.archive();   // not in TrialLedger or SubscriptionLedger, found in Ledger

    return 0;
}
renewals only
archived by Ledger

Two calls on the same object, resolved in two different classes, and neither one had anything to do with the object's run-time behaviour. The compiler settled both while compiling.

Inheriting the Behaviour Unchanged

The simplest case is a derived class that declares nothing. The search runs off the end of the derived class immediately and lands in the base.

#include <iostream>

class Ledger
{
public:
    void summarize() const { std::cout << "ledger totals only\n"; }
};

class SubscriptionLedger : public Ledger
{
};

int main()
{
    Ledger ledger{};
    ledger.summarize();

    SubscriptionLedger renewals{};
    renewals.summarize();

    return 0;
}
ledger totals only
ledger totals only

renewals.summarize() runs Ledger::summarize() for the plain reason that SubscriptionLedger::summarize() does not exist. If the base class already does what the derived class needs, writing nothing is the whole implementation.

Declaring Your Own Version Stops the Search Early

Add a function with the same name to the derived class and the search now finds it on the first try:

#include <iostream>

class Ledger
{
public:
    void summarize() const { std::cout << "ledger totals only\n"; }
};

class SubscriptionLedger : public Ledger
{
public:
    void summarize() const { std::cout << "renewals only\n"; }
};

int main()
{
    Ledger ledger{};
    ledger.summarize();

    SubscriptionLedger renewals{};
    renewals.summarize();

    return 0;
}
ledger totals only
renewals only

Nothing special was required. No keyword, no annotation, no declaration in the base class granting permission. Redefining an inherited function is just declaring a function whose name the search reaches first.

Important
This is not the same thing as overriding a virtual function, and the override keyword does not belong here. What happens above is decided entirely at compile time from the static type of the expression. Call summarize() through a Ledger& that refers to a SubscriptionLedger and you will get Ledger::summarize(), because the compiler resolved the call from the reference's declared type. Virtual functions, which change that, are the subject of the next chapter.

Access Specifiers Do Not Travel With the Name

The search is looking for a name, and the access specifier is a separate property attached to each declaration. A derived declaration therefore gets whatever access it was written under, regardless of the base declaration it shadows.

#include <iostream>

class Ledger
{
private:
    void audit() const { std::cout << "audit hidden inside Ledger\n"; }
};

class SubscriptionLedger : public Ledger
{
public:
    void audit() const { std::cout << "audit exposed by SubscriptionLedger\n"; }
};

int main()
{
    SubscriptionLedger renewals{};
    renewals.audit();

    return 0;
}
audit exposed by SubscriptionLedger

Ledger::audit() is private and unreachable from outside. SubscriptionLedger::audit() is public and callable. The direction works both ways: a public base function can be shadowed by a private derived one, which is a way of withdrawing part of an interface from a derived class.

Reaching the Version the Search Skipped

Shadowing replaces. Often what you want is to add, keeping the base behaviour and doing something extra around it. To reach a function that name lookup has hidden, name it explicitly with the scope resolution operator, which tells the compiler exactly which class to look in and skips the search entirely.

#include <iostream>

class Ledger
{
public:
    void summarize() const { std::cout << "ledger totals only\n"; }
};

class SubscriptionLedger : public Ledger
{
public:
    void summarize() const
    {
        std::cout << "renewals only\n";
        Ledger::summarize();
    }
};

int main()
{
    Ledger ledger{};
    ledger.summarize();

    SubscriptionLedger renewals{};
    renewals.summarize();

    return 0;
}
ledger totals only
renewals only
ledger totals only

The derived function prints its own line and then delegates the rest to the base version. Put the qualified call first instead and the base output comes first: the ordering is yours to choose, because it is an ordinary function call in an ordinary function body.

Why the Qualifier Is Not Optional

Leaving Ledger:: off does not fall back to the base class. It starts the normal search from inside SubscriptionLedger, which finds SubscriptionLedger::summarize(), which is the function you are already in.

The following program is broken. It recurses until it exhausts the stack:

#include <iostream>

class Ledger
{
public:
    void summarize() const { std::cout << "ledger totals only\n"; }
};

class SubscriptionLedger : public Ledger
{
public:
    void summarize() const
    {
        std::cout << "renewals only\n";
        summarize(); // no qualifier, so this calls SubscriptionLedger::summarize() again
    }
};

int main()
{
    SubscriptionLedger renewals{};
    renewals.summarize();

    return 0;
}

The compiler spots this one before you ever run it:

s.cpp: In member function 'void SubscriptionLedger::summarize() const':
s.cpp:12:10: warning: infinite recursion detected [-Winfinite-recursion]
   12 |     void summarize() const
      |          ^~~~~~~~~
s.cpp:15:18: note: recursive call
   15 |         summarize(); // no qualifier, so this calls SubscriptionLedger::summarize() again
      |         ~~~~~~~~~^~

That warning only appears because the recursion here is unconditional and easy to see. Hide the call behind an if and the compiler will go quiet while the bug stays. Treat the qualifier as mandatory whenever a derived function delegates to the version it shadows.

Friend Functions Need a Cast, Not a Qualifier

Friend functions are declared inside a class but are not members of it, so there is no Ledger:: to qualify. operator<< is the one you will hit most often.

Since the two operator<< overloads differ only in their parameter type, the way to select the base one is to change the type of the argument you hand it. A derived object is a base object, so a static_cast to a base reference is enough:

#include <iostream>

class Ledger
{
public:
    friend std::ostream& operator<<(std::ostream& stream, const Ledger&)
    {
        stream << "[ledger totals]\n";
        return stream;
    }
};

class SubscriptionLedger : public Ledger
{
public:
    friend std::ostream& operator<<(std::ostream& stream, const SubscriptionLedger& entry)
    {
        stream << "[renewals]\n";
        stream << static_cast<const Ledger&>(entry); // pick the Ledger overload on purpose
        return stream;
    }
};

int main()
{
    SubscriptionLedger renewals{};

    std::cout << renewals;

    return 0;
}
[renewals]
[ledger totals]

The cast creates no new object and copies nothing. It just presents the same bytes under a different static type, which is enough to steer overload resolution to the other function.

The Overload Trap

Now for the consequence of step 1 that costs people real debugging time. Suppose the base class charges an account in either whole cents or fractional dollars:

#include <iostream>

class Ledger
{
public:
    void charge(int cents) const { std::cout << "charged " << cents << " cents\n"; }
    void charge(double dollars) const { std::cout << "charged " << dollars << " dollars\n"; }
};

class SubscriptionLedger : public Ledger
{
};

int main()
{
    SubscriptionLedger renewals{};
    renewals.charge(250);

    return 0;
}
charged 250 cents

Exactly as expected. SubscriptionLedger has no charge, the search continues into Ledger, both overloads are candidates, and int is an exact match for 250.

Now the derived class adds one specialised overload, and the call site does not change at all. This program is wrong, and nothing about it looks wrong:

#include <iostream>

class Ledger
{
public:
    void charge(int cents) const { std::cout << "charged " << cents << " cents\n"; }
    void charge(double dollars) const { std::cout << "charged " << dollars << " dollars\n"; }
};

class SubscriptionLedger : public Ledger
{
public:
    void charge(double dollars) const { std::cout << "renewal of " << dollars << " dollars\n"; }
};

int main()
{
    SubscriptionLedger renewals{};
    renewals.charge(250);

    return 0;
}
renewal of 250 dollars

A charge of two dollars fifty has become a charge of two hundred and fifty dollars, and the compiler is content, because it did what the rule says. Step 1 found the name charge in SubscriptionLedger, so Ledger was never consulted. Step 2 then had exactly one candidate, charge(double), and 250 converts to double perfectly well.

Danger
Declaring one overload in a derived class hides every base overload with that name, including ones the derived class never meant to touch. Because the hidden overloads may still be reachable through a conversion, the result is often a silent change in behaviour at unchanged call sites rather than a compile error.

Getting the Hidden Overloads Back

There are three ways out, and they are not equally good.

Approach What you write When it is the right call
Qualify at the call site renewals.Ledger::charge(250); A one-off, where a single call needs the base version
Forward each overload by hand void charge(int cents) const { Ledger::charge(cents); } Almost never, but it is worth seeing why
Bring the name in with a using-declaration using Ledger::charge; The general fix, and the one to reach for

The hand-written forwarder does work:

#include <iostream>

class Ledger
{
public:
    void charge(int cents) const { std::cout << "charged " << cents << " cents\n"; }
    void charge(double dollars) const { std::cout << "charged " << dollars << " dollars\n"; }
};

class SubscriptionLedger : public Ledger
{
public:
    void charge(int cents) const { Ledger::charge(cents); }
    void charge(double dollars) const { std::cout << "renewal of " << dollars << " dollars\n"; }
};

int main()
{
    SubscriptionLedger renewals{};
    renewals.charge(250);

    return 0;
}
charged 250 cents

It works, and it scales badly. Every base overload you want back needs its own forwarding function, each one pure boilerplate, and adding an overload to the base class later silently re-opens the hole in the derived class.

A using-declaration says the same thing in one line. It injects the base class's declarations of that name into the derived class, so they become candidates alongside whatever the derived class declares:

#include <iostream>

class Ledger
{
public:
    void charge(int cents) const { std::cout << "charged " << cents << " cents\n"; }
    void charge(double dollars) const { std::cout << "charged " << dollars << " dollars\n"; }
};

class SubscriptionLedger : public Ledger
{
public:
    using Ledger::charge; // every Ledger::charge overload becomes visible here
    void charge(double dollars) const { std::cout << "renewal of " << dollars << " dollars\n"; }
};

int main()
{
    SubscriptionLedger renewals{};
    renewals.charge(250);
    renewals.charge(19.99);

    return 0;
}
charged 250 cents
renewal of 19.99 dollars

Both calls now land where you would want them. charge(250) reaches Ledger::charge(int), which is the better match, and charge(19.99) still reaches the derived version. The derived charge(double) and the injected Ledger::charge(double) have identical parameter lists, and in that situation the derived declaration wins rather than becoming ambiguous, which is what makes the combination usable.

Best Practice
Whenever a derived class declares a function whose name is already overloaded in a base class, add using Base::functionName; unless you specifically intend to hide the base overloads. It costs one line and removes a whole category of silent misbehaviour.
Important
A using-declaration names a function, not a signature. using Ledger::charge; brings in every Ledger declaration called charge; there is no way to admit some overloads and keep others hidden. If you need finer control than that, you are back to writing forwarders, and it is worth asking whether the base interface is right.

Looking Forward

Everything in this lesson has been about making a hidden base function reachable again. The next lesson turns the idea around and asks how to hide base functionality deliberately, changing the access of an inherited member or removing it from the derived interface altogether.

Further out, note how much of this rests on the compiler resolving calls from the static type of the expression. Redefining a function does not change what happens when the same object is reached through a base reference or pointer. Making the object's own type decide is what virtual is for, and that is the subject of the chapter on runtime polymorphism.

Key Terminology

  • Name lookup: the compile-time search up the inheritance chain for a class that declares a given name
  • Shadowing (name hiding): a derived declaration making a base declaration of the same name unreachable by unqualified lookup
  • Redefining: declaring a non-virtual function in a derived class with the same name as a base function
  • Scope resolution operator (::): names the class to look in directly, bypassing name lookup
  • Friend function: a non-member function granted access to a class's private members, and therefore not qualifiable with ClassName::
  • Using-declaration: using Base::name; inside a derived class, which makes all base declarations of name candidates for overload resolution there

Summary

  • Calling a member function is resolved in two phases: find the name, then run overload resolution over the declarations in the single class where the name was found.
  • The lookup stops at the most-derived class that declares the name. Classes above it are not consulted, even when one of them holds a better match.
  • A derived class that declares nothing inherits the base behaviour as-is.
  • Redefining an inherited function needs no keyword. Declaring the same name in the derived class is the whole mechanism.
  • Redefinition is not virtual overriding. Calls are resolved from the static type of the expression, so reaching the object through a base reference still selects the base function.
  • A redefined function takes the access specifier it is declared under, not the one the base declaration had.
  • Base::functionName() calls the shadowed version directly, which is how a derived function extends base behaviour instead of replacing it.
  • Omitting that qualifier makes the function call itself. GCC catches the simple unconditional case with -Winfinite-recursion, but it will not catch a conditional one.
  • Friend functions such as operator<< are not members and cannot be qualified. Select the base version by casting the argument: static_cast<const Base&>(object).
  • Declaring one overload in a derived class hides all base overloads of that name, which can silently redirect calls that used to resolve to the base.
  • using Base::functionName; restores them, applies to every overload of that name at once, and is the fix to prefer over hand-written forwarders.