Why C++ Binds Default Arguments Statically
In C++, developers frequently encounter unexpected behavior when combining default arguments with overridden virtual functions. This confusion arises because virtual functions are dispatched dynamically at runtime based on the actual type of the object, whereas default parameter values are determined statically at compile time based on the static type of the pointer or reference used to invoke the function. Consequently, calling an overridden function through a base class pointer can result in the derived class's implementation being executed using the base class's default arguments, leading to subtle logic errors and inconsistencies.
The Mechanics of the Conflict
To understand why this issue occurs, one must distinguish between static typing and dynamic dispatch:
- Virtual Functions Use Dynamic Binding: When a
function is declared
virtual, the compiler uses dynamic dispatch (typically via a virtual method table, or vtable) to determine which function body to execute at runtime. If aDerivedobject is pointed to by aBase*, calling the virtual function executesDerived's version. - Default Arguments Use Static Binding: Default arguments are resolved entirely at compile time. When the compiler encounters a function call missing an argument, it checks the static type of the calling expression and substitutes the default value provided in that specific class declaration directly into the call site.
Consider the following scenario:
class Base {
public:
virtual void print(int value = 10) {
std::cout << "Base: " << value << '\n';
}
};
class Derived : public Base {
public:
void print(int value = 20) override {
std::cout << "Derived: " << value << '\n';
}
};
Base* obj = new Derived();
obj->print(); // Outputs: "Derived: 10"In this code, the developer might expect
Derived::print() to run with its own default value of
20. Instead, the compiler sees that obj is of
static type Base*, notes that Base::print has
a default argument of 10, and rewrites the call to
obj->print(10). At runtime, dynamic dispatch calls
Derived::print(int), but with the argument 10
supplied by the base class.
Why C++ Uses Static Binding for Defaults
The language design favors execution speed and simplicity over dynamic default resolution. Determining default values dynamically would introduce non-trivial runtime overhead. The runtime environment would need a mechanism to look up, evaluate, and push default values onto the call stack dynamically before jumping to the function address. To adhere to the zero-overhead principle—where you do not pay for what you do not use—C++ evaluates default arguments strictly at the call site during compilation.
Best Practices to Avoid Confusion
To prevent mismatches between overridden functions and default parameters, adhere to two main design principles:
- Never Redefine an Inherited Default Argument: As established in standard C++ guidelines, if a virtual function has a default parameter in the base class, derived classes should either omit the default argument entirely or ensure it is never changed.
- Use the Non-Virtual Interface (NVI) Idiom: Make public interface functions non-virtual and let them define the default arguments. These non-virtual wrappers can then forward calls to private or protected virtual methods that accept mandatory parameters:
class Base {
public:
void print(int value = 10) { // Non-virtual with default
doPrint(value);
}
private:
virtual void doPrint(int value) {
std::cout << "Base: " << value << '\n';
}
};
class Derived : public Base {
private:
void doPrint(int value) override {
std::cout << "Derived: " << value << '\n';
}
};By decoupling the default argument from the dynamic dispatch mechanism, the interface remains predictable regardless of how derived types are manipulated.