C++ Default Arguments: Rules, Virtual Function Traps, and API Design
Key takeaways
Default arguments are filled in by the compiler at each call site. That one fact explains the declaration rules, the virtual function surprise, and why changing a default in a header needs a rebuild.
What default arguments are
A default argument lets a caller leave out trailing arguments:
void greet(const std::string& name = "Guest") {
std::cout << "Hello, " << name << "!\n";
}
greet("Alice"); // Hello, Alice!
greet(); // Hello, Guest!
It is easy to think of greet as “a function with an optional parameter”, but that is not what the compiler sees. greet always takes exactly one parameter. When the compiler sees greet(), it rewrites the call to greet("Guest") at the call site. The default value is not stored in the function, is not part of its type, and does not exist at runtime. Almost every rule and surprise below follows from that.
The rules
Defaults must be trailing
void f1(int a, int b = 2, int c = 3); // OK
void f2(int a = 1, int b = 2, int c = 3); // OK
void f3(int a = 1, int b, int c = 3);
// error: default argument missing for parameter 2 of 'void f3(int, int, int)'
Arguments are matched left to right, so there is no way to skip b and give c. C++ has no named arguments. If you need to set the third option without the second, the parameter list is the wrong tool (see the options-struct pattern below).
Specify each default once, on the declaration callers see
// widget.h
void resize(int w, int h = 100);
// widget.cpp
void resize(int w, int h) { /* ... */ } // no default here
Writing = 100 on both is an error when the compiler sees both in the same translation unit, which it does when widget.cpp includes widget.h:
error: default argument given for parameter 2 of 'void resize(int, int)' [-fpermissive]
note: previous specification in 'void resize(int, int)' here
The reverse mistake is worse because it compiles. If the default is only on the definition in the .cpp, callers in other files never see it, and resize(10) fails there with “too few arguments”, while it works inside widget.cpp.
Defaults can be added across redeclarations
void func(int x, int y, int z);
void func(int x, int y, int z = 30); // adds default for z
void func(int x, int y = 20, int z); // adds default for y (z already has one)
// func(1) now calls func(1, 20, 30)
This is legal but rarely a good idea outside of generated code. A reader who finds one declaration does not see the whole picture.
A default cannot use other parameters
void f(int x, int y = x);
// error: local variable 'x' may not appear in this context
Parameters are not in scope as values when the default is evaluated. Overload instead: void f(int x) { f(x, x); }.
The expression is evaluated on every call
int counter = 0;
int next() { return ++counter; }
void h(int id = next());
h(); // id 1
h(); // id 2
h(100); // id 100, next() not called
h(); // id 3
A default can be any expression, including a function call or a global, and it runs fresh at each call that omits the argument. This occasionally bites when someone writes = std::chrono::steady_clock::now() or = getConfig().timeout and assumes it was computed once. The name lookup happens where the function is declared, but the evaluation happens where it is called.
Default arguments and overloading
void g(int x);
void g(int x, int y = 0);
g(1);
// error: call of overloaded 'g(int)' is ambiguous
// note: candidate: 'void g(int)'
// note: candidate: 'void g(int, int)'
Both candidates accept one argument equally well, so overload resolution cannot choose. The declarations themselves are fine. The error only shows up at a call that omits y, which may be in a different file written later. When I add a default to an existing function, I first check whether any overload with fewer parameters already exists.
The trade-off between the two tools:
- Default argument: one function, one body, and the default is visible in the signature. The cost is that the value is baked into every caller at compile time.
- Overload that forwards (
void connect(const std::string& host) { connect(host, 8080); }): the default lives in the library’s compiled code, so changing it does not require recompiling callers. Each form has its own address, and the choice can differ by type as well as count.
For a public library header, the forwarding overload is often the safer choice, because of the recompilation issue. For internal code that is always rebuilt together, default arguments are simpler.
The virtual function trap
struct Base {
virtual void f(int x = 1) { std::cout << "B " << x << '\n'; }
virtual ~Base() = default;
};
struct Derived : Base {
void f(int x = 2) override { std::cout << "D " << x << '\n'; }
};
Derived d;
Base& b = d;
d.f(); // D 2
b.f(); // D 1 <- Derived's body, Base's default
The compiler fills in the default at compile time from the static type of the expression (Base&), then virtual dispatch picks the body at runtime from the dynamic type (Derived). You get a mix of both. The code compiles cleanly, and GCC does not warn by default. clang-tidy has a check for this (google-default-arguments flags defaults on virtual functions).
This kind of bug is hard to track down because the code looks correct wherever you check it. The derived class’s author reads x = 2 in their override and expects 2. Every test that calls through a Derived object confirms it. Production code that holds Base* in a container gets 1. The usual fix is the non-virtual interface pattern: a public non-virtual function with the default that calls a protected virtual without one.
class Base {
public:
void f(int x = 1) { doF(x); } // default lives here only
virtual ~Base() = default;
protected:
virtual void doF(int x) = 0;
};
Constructors: defaults create conversions you did not ask for
Default arguments on a constructor change which special members the class has. A constructor whose parameters all have defaults is the default constructor, and one that can be called with a single argument is a converting constructor:
class Buffer {
public:
Buffer(std::size_t size = 4096, bool zeroed = false);
};
void send(const Buffer& b);
Buffer a; // default constructor: Buffer(4096, false)
send(64); // compiles: 64 is silently converted to Buffer(64, false)
Buffer c = 128; // copy-initialization, also compiles
The author probably wanted two conveniences, a sensible size and an optional zero fill. What they also got is an implicit conversion from any integer to Buffer, so a call like send(bytesWritten) that was meant for another overload quietly builds a temporary 64-byte buffer. Marking the constructor explicit keeps Buffer a; and Buffer b(64); working and turns send(64) into a compile error:
explicit Buffer(std::size_t size = 4096, bool zeroed = false);
My rule is that any constructor callable with exactly one argument gets explicit unless the conversion is genuinely part of the type’s meaning (like std::string from const char*). Adding a default to the second parameter of a two-argument constructor is the easy way to miss this, because the constructor did not look like a single-argument one when it was written.
Templates: defaults are only instantiated when used
In a function template, a default argument expression is not instantiated unless a call actually relies on it. That makes this legal even for types without a default constructor:
template <typename T>
void fill(std::vector<T>& v, const T& value = T{}) {
std::fill(v.begin(), v.end(), value);
}
struct NoDefault { explicit NoDefault(int) {} };
std::vector<NoDefault> v(3, NoDefault{1});
fill(v, NoDefault{2}); // fine: T{} is never instantiated
// fill(v); // error only here: no matching constructor for NoDefault
The flip side is that a broken default in a template does not show up when you write the template, only when someone first calls it without the argument, possibly months later and with an error pointing into your header.
Two other places behave differently enough to trip people up. Lambdas have allowed default arguments since C++14 ([](int x, int y = 0) { ... }), and they follow the same call-site rule. Template parameter defaults (template <typename T = int>) are a separate feature: they supply a type or value when deduction and explicit arguments leave a template parameter unspecified, and repeating one on a redeclaration is an error just like with function defaults.
ODR: the same function with different defaults
Because the default is not part of the function, two translation units can declare the same function with different defaults without any linker error:
// a.cpp
void connect(const std::string& host, int port = 80);
// b.cpp
void connect(const std::string& host, int port = 8080);
Each file compiles and links, and connect("db") passes 80 from one file and 8080 from the other. Neither the compiler nor the linker can see the disagreement, because each translation unit is self-consistent and the symbol connect(const std::string&, int) is the same in both. The usual way this happens is not two hand-written declarations but a stale copy: someone forward-declares a function in a .cpp to avoid an include, and later the header’s default changes. This is the real reason to keep every default in exactly one header that everyone includes: it is not only about the “given twice” error, it is about making sure every caller in the program agrees on the value.
Function pointers lose the default
void k(int a, int b = 5);
void (*fp)(int, int) = k;
fp(1); // error: too few arguments to function
Default arguments are not part of the function type, so pointers, std::function, and templates that take a callable do not carry them. If you pass such a function as a callback, the callback always receives both arguments.
When the parameter list gets long
Defaults work well for one or two trailing options. Beyond that, calls like log("msg", LogLevel::Info, "log.txt", true, false) are unreadable, and callers cannot set the last option alone. An options struct scales better, and C++20 designated initializers make call sites self-documenting:
struct LogOptions {
LogLevel level = LogLevel::Info;
std::string file = "log.txt";
bool flush = false;
};
void log(const std::string& message, const LogOptions& opt = {});
log("Application started");
log("Crash", {.level = LogLevel::Error, .flush = true}); // C++20
Here the defaults live in the struct’s member initializers. The = {} on the parameter is the only default argument left.
For a parameter that may be absent where no sensible default value exists, std::optional<T> expresses “not provided” directly instead of using a magic value like -1.