Default Arguments in C++ Virtual Functions
In C++, default arguments in virtual member functions are bound statically at compile time, whereas the virtual function itself is resolved dynamically at runtime via late binding. Because default parameters are determined by the static type of the pointer or reference used in the function call rather than the dynamic type of the underlying object, calling an overridden virtual function through a base class pointer can execute the derived class's method using the base class's default argument.
Static Binding vs. Dynamic Binding
To understand this behavior, it is necessary to distinguish between how C++ handles function calls and parameter passing:
- Dynamic Binding (Late Binding): Virtual function dispatch relies on the virtual table (vtable) and runtime type information to invoke the appropriate override based on the dynamic type of the object.
- Static Binding (Early Binding): Default arguments are substituted directly at the call site during compilation based on the static type declared for the variable, pointer, or reference.
Because default arguments are evaluated at compile time, the compiler has no knowledge of which derived implementation will run at runtime. It simply fills in the missing argument using the default value declared in the static interface visible at that call site.
Code Demonstration
Consider the following class hierarchy:
#include <iostream>
class Base {
public:
virtual void printValue(int x = 10) {
std::cout << "Base implementation: " << x << std::endl;
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void printValue(int x = 20) override {
std::cout << "Derived implementation: " << x << std::endl;
}
};
int main() {
Derived derivedObj;
Base* basePtr = &derivedObj;
// Call through Base pointer
basePtr->printValue();
// Call through Derived pointer/object
derivedObj.printValue();
return 0;
}The output of this program is:
Derived implementation: 10
Derived implementation: 20
When invoking basePtr->printValue(), the compiler
checks the definition of Base::printValue and sees the
default argument 10. It rewrites the call to
basePtr->printValue(10). At runtime, virtual dispatch
correctly executes Derived::printValue(int), but it
receives 10 instead of the derived class's default of
20.
Why C++ Implements Default Arguments Statically
C++ prioritizes execution efficiency and runtime performance. Resolving default parameters dynamically would require storing parameter values inside the vtable or generating runtime lookup mechanisms for missing arguments. This would increase memory overhead and slow down function calls. By inserting default parameters statically, the runtime mechanism remains focused purely on function pointer resolution.
Best Practices and Solutions
Redefining inherited default arguments introduces confusing bugs and violates polymorphic expectations. C++ developers handle this issue through two main design strategies:
- Never Redefine Inherited Default Arguments: If a virtual function defines a default parameter in the base class, derived classes should never redeclare or change that default value.
- The Non-Virtual Interface (NVI) Idiom: Separate the public interface from the polymorphic implementation. Define a non-virtual public function in the base class with the default argument, which internally calls a private or protected pure virtual function without any default arguments.
class Base {
public:
// Non-virtual public interface containing the default argument
void printValue(int x = 10) {
doPrintValue(x);
}
virtual ~Base() = default;
private:
// Virtual implementation with no default argument
virtual void doPrintValue(int x) = 0;
};
class Derived : public Base {
private:
void doPrintValue(int x) override {
std::cout << "Derived implementation: " << x << std::endl;
}
};Using the NVI idiom guarantees that the default parameter is centralized in the base class, eliminating any mismatch between compile-time substitution and runtime dispatch.