C++ / FUNCTIONS
Default arguments and their binding quirks
Place default arguments correctly, predict when their expressions run, and avoid the static-binding trap in virtual overrides.
What you will learn
- Give a parameter its default in exactly one declaration, not in the definition too.
- Predict that a default expression re-runs on every call that omits the argument.
- Read a virtual call's default from the static type, not the override that actually runs.
- Supply every argument when calling through a function pointer or std::function.
Understanding Default arguments and their binding quirks
A default argument is not stored inside the function; it is a rewrite performed at the call site. When the compiler sees report("hi"), it looks at the declaration of report that is visible at that point, finds the missing trailing argument, and pastes the default expression into the argument list. The called function therefore has no way to know whether an argument was defaulted, and the expression is evaluated fresh at every call that omits it, in the caller's context, alongside the other arguments.
Because the substitution uses whichever declaration the call site can see, a default belongs to a declaration in a scope rather than to the function itself. Defaults accumulate: a later declaration in the same scope may add a default to an earlier parameter, which is why int area(int w, int h = 1); followed by int area(int w = 4, int h); is legal. The flip side is that a file which only sees a declaration without the default cannot omit that argument, and repeating a default in the same scope is an error even when the value is identical.
Since the filling-in happens while compiling the call, the choice of default is always static. A virtual call therefore splits in two: the vtable picks the body at run time, but the argument value was already chosen from the static type of the object expression, so a base reference to a derived object runs the derived body with the base's default. The same staticness explains function pointers, whose type int(*)(int, int) carries no defaults at all, and it explains why defaults do not create overloads: with f(int) and f(int, int = 0) both declared, f(1) is ambiguous.
<iostream>
<string>
int calls = 0;
int next_id() { return ++calls; }
void report(const std::string& msg, int id = next_id()) {
std::cout << "[" << id << "] " << msg << "\n";
}
int main() {
report("started");
report("loading");
report("explicit id", 99);
report("finished");
std::cout << "next_id ran " << calls << " times\n";
}
A default argument belongs to a declaration rather than to the function, so the compiler substitutes it at the call site and evaluates it anew on every call that omits the argument.
Worked examples
Virtual dispatch picks the body, the static type picks the default
An override that declares its own default value produces a call that mixes the derived body with the base's argument.
<iostream>
struct Base {
virtual void draw(int width = 10) const {
std::cout << "Base::draw width=" << width << "\n";
}
virtual ~Base() = default;
};
struct Derived : Base {
void draw(int width = 50) const override {
std::cout << "Derived::draw width=" << width << "\n";
}
};
int main() {
Derived d;
Base& b = d;
Base plain;
b.draw();
d.draw();
plain.draw();
}
Example explained
Line 1b.draw(): the vtable selects Derived::draw, but the compiler already inserted 10 because b's static type is Base.
Line 2d.draw(): the call is written on a Derived object, so the visible declaration is Derived::draw and its own default 50 applies.
Line 3plain.draw(): no dispatch is involved, and here body and default come from the same declaration, which is why the mismatch only appears through a base reference.
Line 4An override does not inherit the base's default either: had Derived::draw been declared as draw(int width), the call d.draw() would fail to compile.
Defaults accumulate across declarations and vanish in the type
A second declaration adds a default to an earlier parameter, while a pointer to the same function requires every argument.
<iostream>
int area(int w, int h = 1);
int area(int w = 4, int h);
int area(int w, int h) { return w * h; }
int main() {
std::cout << area() << " " << area(3) << " " << area(3, 5) << "\n";
int (*fp)(int, int) = area;
std::cout << fp(2, 6) << "\n";
}
Example explained
Line 1int area(int w = 4, int h); is accepted because h already received a default in an earlier declaration in the same scope, so area() means area(4, 1).
Line 2area(3) binds 3 to w by position and falls back to the default for h; there is no way to pass h alone.
Line 3The definition repeats no defaults on purpose: writing = 1 a second time in this translation unit is a redefinition error even with the same value.
Line 4fp holds only the type int(*)(int, int), and defaults live in declarations rather than in types, so fp(2) would be rejected as too few arguments.
Names bound where written, values read at the call
Two textually identical calls produce different results because the default expression reads a variable each time it is used.
<iostream>
int limit = 2;
int clamp_to_limit(int value, int cap = limit) {
return value < cap ? value : cap;
}
int main() {
std::cout << clamp_to_limit(7) << "\n";
limit = 5;
std::cout << clamp_to_limit(7) << "\n";
std::cout << clamp_to_limit(7, 1) << "\n";
}
Example explained
Line 1The name limit is looked up where the default is written, so it must already be declared above the function.
Line 2The read of limit happens at each call, so the first call caps at 2 and the second, with identical source text, caps at 5.
Line 3int cap = value would not compile: parameters may not appear in default arguments, even parameters of the same list.
Line 4The third call supplies cap explicitly, so the default expression is never evaluated at all.
Important notes
A default such as const std::string& name = "anonymous" is safe because the temporary lives until the end of the full expression containing the call, but the callee must not store that reference for later.
Default arguments do not create overloads: with both f(int) and f(int, int = 0) declared, the call f(1) is ambiguous instead of preferring one of them.
Common mistakes
Repeating the default in the .cpp definition when the header already has it: the compiler rejects it as a redefinition of a default argument, and 'fixing' that by moving the default into the .cpp only makes the shorter call impossible in every other file.
Giving an override a different default from the base: a call through a base reference or pointer runs the derived body with the base's value, so behaviour silently differs depending on which type the caller holds, with no warning by default.
Expecting to skip an argument, as in resize(, 5), or defaulting only a leading parameter: arguments bind left to right by position, so such code does not compile, since C++ has no named or skipped arguments.
Try it yourself
Change, predict, then run
Build a two-class hierarchy where the base declares virtual void print(int indent = 2) and the override declares print(int indent = 8), then call print() through a Base& bound to a Derived object and directly on the Derived object and explain the two different indents. Then delete the default from the override and note which of the two calls stops compiling.
Open the C++ workspaceCheck your understanding
A base class declares virtual void draw(int w = 10) and a derived class overrides it as void draw(int w = 50). What happens when draw() is called with no arguments through a Base& that refers to a Derived object?
- Derived::draw runs with w = 10, because the body is chosen at run time but the default was substituted at compile time from the static type Base
- Derived::draw runs with w = 50, because virtual dispatch carries the override's default along with its body
- Base::draw runs with w = 10, because supplying a default argument suppresses virtual dispatch for that call
- The call is ill-formed, because the two conflicting defaults for the same virtual function are a compile error
Show answer
The compiler completes the argument list while compiling the call, using the declaration reachable through the expression's static type, which is Base's, so 10 is passed; only the choice of body is deferred to the vtable, giving Derived::draw width=10. Option two is tempting because virtual dispatch really does select the override, but defaults are not part of the virtual mechanism and are never looked up at run time; option four is wrong because redeclaring a default in a derived class is legal, which is exactly why the mismatch slips through.