Default Arguments
Specify fallback values for trailing function parameters, letting callers omit those arguments when the defaults are appropriate.
Default Argumentssince C++98A default argument is a value specified in a function declaration that the compiler substitutes for a missing trailing argument at the call site.
Overview
Default arguments let a single function signature serve multiple call patterns without requiring separate overloads. Resolution is entirely static: the compiler inserts the default expression into the call as if the caller had written it explicitly. This substitution happens in the translation unit that contains the call, using whichever declaration is visible there β which has significant implications for virtual functions and for where you place your defaults.
The governing constraint is that only trailing parameters may carry defaults. Once a parameter has a default, every parameter to its right must also carry one, supplied either in the same declaration or in a prior declaration in the same scope.
Syntax
// Declaration β defaults live here, typically in a header
void log(const std::string& msg,
int level = 0,
bool flush = false);
// All three call forms are valid
log("startup"); // level=0, flush=false
log("io error", 2); // flush=false
log("fatal", 3, true);Incremental Declaration
A later declaration in the same scope may supply defaults for parameters that still lack them, but it cannot repeat or alter an existing default:
void f(int x, int y = 10);
void f(int x = 5, int y); // OK: adds default for x; y already has one
// void f(int x = 5, int y = 99); // Error: cannot change y's default
// void f(int x = 6, int y); // Error: conflicts with x's defaultA declaration in an inner scope shadows the outer one with its own defaults for that scope's duration:
void g(int x = 1);
void example() {
void g(int x = 42); // local shadow
g(); // calls g(42)
}
// g() here resolves to g(1) againThe inner declaration must still be in the same scope as each other β you cannot reach into an enclosing scope and add a default there.
Expression Defaults
Default arguments are not restricted to literals. Any expression valid at the point of declaration is permitted, including function calls, namespace-scope variables, and sizeof expressions. Crucially, the expression is evaluated each time the default is used, not when the declaration is parsed:
int current_log_level = 0;
void trace(const char* tag, int level = current_log_level);
void demo() {
current_log_level = 3;
trace("init"); // level == 3, not 0 β evaluated at the call site
}This dynamic evaluation is powerful but can be a source of subtle bugs when the referenced state changes unpredictably.
Examples
Reducing Constructor Proliferation
Default arguments are particularly effective on constructors, where providing sensible defaults dramatically narrows the set of constructors needed:
class HttpRequest {
public:
explicit HttpRequest(std::string url,
std::string method = "GET",
int timeout = 30,
bool tls = true);
};
HttpRequest ping("https://example.com/health");
HttpRequest post("https://api.example.com/data", "POST");
HttpRequest slow("https://slow.example.com", "GET", 120);Extending APIs Without Breaking Call Sites
Adding a new trailing parameter with a default is binary-compatible from a source perspective: every existing call site continues to compile unchanged:
// Original
void render(const Scene& s, int width = 1920, int height = 1080);
// Extended β existing calls still compile without modification
void render(const Scene& s,
int width = 1920,
int height = 1080,
int msaa = 4);Preferring Defaults Over Overloads
When two overloads are semantically identical and differ only in whether a trailing argument is present, a default argument is the better tool β it eliminates duplicated intent and makes it explicit that the forms are equivalent (C++ Core Guidelines F.51):
// Avoid: two overloads that obscure their relationship
std::string join(const std::vector<std::string>& parts);
std::string join(const std::vector<std::string>& parts, char sep);
// Prefer: one function, one place to maintain
std::string join(const std::vector<std::string>& parts, char sep = ',');Reserve overloading for cases where the implementations genuinely differ based on argument type or count, not merely on presence.
Best Practices
Declare defaults in the header, not the definition. The compiler substitutes defaults based on the declaration visible at the call site. A default that appears only in the .cpp is invisible to every other translation unit β callers will see a hard error for the omitted argument.
Prefer constant expressions as defaults. A default tied to a mutable global will silently change behaviour at every dependent call site when that global changes. Use constexpr constants, literal values, or deterministic computed expressions.
Avoid defaults on virtual functions. See the pitfall below; the interaction with dynamic dispatch is surprising enough that the safest rule is to never use them on virtual member functions.
Do not use expensive or side-effectful expressions as defaults in hot paths. Because evaluation occurs at each call, a default like getCurrentTimestamp() will show up as call overhead in profiling and may have unintended side effects.
Common Pitfalls
Static Resolution Through Virtual Dispatch
Default arguments are resolved using the static type at the call site, not the runtime type. An overriding function that changes a default introduces a split: callers through a base reference see the base default while the derived implementation runs. The result is a function receiving a value neither party intended:
struct Renderer {
virtual void clear(uint32_t colour = 0xFF0000FF) const; // opaque red
};
struct GlRenderer : Renderer {
void clear(uint32_t colour = 0x000000FF) const override; // opaque black
};
void paint(const Renderer& r) {
r.clear(); // colour == 0xFF0000FF (Renderer's default)
// but GlRenderer::clear executes β receives red, not black
}The standard fix is a non-virtual wrapper that owns the default:
struct Renderer {
void clear() const { clear_impl(0xFF0000FF); }
void clear(uint32_t colour) const { clear_impl(colour); }
protected:
virtual void clear_impl(uint32_t colour) const;
};Defaults Are Prohibited on Most Overloaded Operators
With the sole exception of operator(), overloaded operators may not carry default arguments. Built-in operators have no concept of default operands, and the language preserves that consistency:
struct Vec2 {
Vec2 operator+(const Vec2& rhs) const;
// Vec2 operator+(const Vec2& rhs = Vec2{}) const; // Error
// operator() is exempt
double operator()(double t, double scale = 1.0) const; // OK
};Explicit Template Specializations Cannot Introduce Defaults
Explicit specializations of function templates may not add or alter default arguments. Defaults belong on the primary template:
template <typename T>
void process(T val, int flags = 0); // default here
template <>
void process(int val, int flags); // OK β omit the default
// void process(int val, int flags = 0); // Error: not allowed on specializationInconsistent Defaults Across Translation Units
Providing a default on the declaration in one header and a different default (or none) on the declaration in another header for the same function creates ODR-adjacent chaos: callers in different TUs silently receive different values. A single canonical declaration with all defaults is the only safe pattern.
Redeclaration and Accumulation
Default arguments can be spread across multiple declarations of the same function in the same scope, each adding defaults for previously-undefaulted parameters. This is called accumulation. The rule is simple: a later declaration may add new defaults, but it may never repeat or modify a default that already exists in a prior visible declaration β even if it tries to specify the same value.
// First declaration: only y has a default
void connect(const char* host, int port, int timeout_ms = 5000);
// Later declaration in the same scope: adds default for port
// Now both port and timeout_ms have defaults
void connect(const char* host, int port = 443, int timeout_ms);
// These would both be ill-formed:
// void connect(const char* host, int port = 80, int timeout_ms); // conflicts with port=443
// void connect(const char* host, int port = 443, int timeout_ms); // repeats existing default
// After both declarations any of these are valid calls:
void example() {
connect("example.com"); // port=443, timeout_ms=5000
connect("example.com", 8080); // timeout_ms=5000
connect("example.com", 8080, 10000); // explicit for all
}The incremental accumulation pattern is intentional: it allows a header to expose a minimal interface first and then progressively unlock more convenient calling forms in a later #include-ordered header, without ever duplicating the value in multiple places.
The ODR Accumulation Pitfall
The dangerous corollary is that two translation units can each see a different subset of accumulated defaults for the same function. This does not trigger a linker error β the function has one definition β but callers silently receive different values for the same parameter:
// api.h (included by everyone)
void schedule(Task t, int priority, int delay_ms = 0);
// fast_path.h (only included by fast_path.cpp)
void schedule(Task t, int priority = 5, int delay_ms);
// In fast_path.cpp (sees both headers):
// schedule(task) -> priority=5, delay_ms=0 β intended
// In main.cpp (only sees api.h):
// schedule(task, 5) -> delay_ms=0 β priority must be explicit here
// schedule(task) -> compile error: priority has no default visible hereThis is not technically an ODR violation (the function has one definition), but it creates calling-convention confusion that is nearly impossible to diagnose through link errors alone. The remedy is to keep all defaults in one canonical header declaration, never split across headers that callers may or may not include.
Default Arguments in Virtual Functions
Default arguments interact with virtual dispatch in a way that surprises nearly every C++ programmer at least once. The lookup of a default argument uses the static type at the call site β the type the compiler can see at compile time β not the runtime type of the object. The virtual dispatch mechanism selects which function body runs, but the defaults that fill in the missing arguments are drawn from the declaration that was visible when the call was compiled.
struct Shape {
virtual void draw(int detail_level = 1) const;
};
struct Circle : Shape {
void draw(int detail_level = 4) const override;
};
void render(const Shape& s) {
s.draw(); // detail_level comes from Shape::draw β value is 1
// but Circle::draw executes if s is a Circle
// Circle::draw receives 1, not 4
}
void demo() {
Circle c;
render(c); // Circle::draw(1) β not the 4 you might expect
c.draw(); // Circle::draw(4) β static type is Circle; correct default used
}The mismatch arises because render only knows about Shape. The compiler inserts 1 at the call site before virtual dispatch has any say. Circle::draw runs with an argument it never intended to receive.
Why This Is Structurally Unfixable in Client Code
There is no calling convention or cast that fixes this: the default is resolved before the virtual call mechanism runs. Even static_cast<Circle&>(s).draw() would work, but only because it forces the static type β defeating polymorphism. The problem is architectural.
The Correct Pattern: Non-Virtual Wrapper
The standard solution is to push defaults out of the virtual interface and into a non-virtual public wrapper:
struct Shape {
// Non-virtual: owns the default, always resolved correctly
void draw(int detail_level = 1) const { draw_impl(detail_level); }
protected:
// Virtual: no defaults, just a pure contract
virtual void draw_impl(int detail_level) const = 0;
};
struct Circle : Shape {
protected:
void draw_impl(int detail_level) const override;
// No default here β the wrapper controls that
};This is a variant of the Non-Virtual Interface (NVI) pattern. The wrapper owns policy (defaults, precondition checks, logging); the virtual hook delivers specialization. With this structure, s.draw() always resolves defaults through Shape::draw, regardless of s's runtime type, and the correct body still runs via draw_impl.
The practical guideline: never place default arguments on virtual functions. Clang-Tidy's modernize-use-default-member-init and Google's C++ style guide both flag this pattern.
Default Arguments in Function Templates
Function templates can carry default arguments just like ordinary functions, with two additional capabilities: a default expression may reference earlier template parameters, and the default is instantiated at the call site rather than at the template definition.
template <typename Container>
void print_first(const Container& c, std::size_t n = c.size() / 2) {
// n defaults to half the container size β evaluated each call
for (std::size_t i = 0; i < n && i < c.size(); ++i)
std::cout << c[i] << ' ';
}
// Both calls compile; default is instantiated per call site
std::vector<int> v = {1, 2, 3, 4, 5};
print_first(v); // n = 2 (5/2)
print_first(v, 3); // n = 3Because the default is instantiated at the call site, it can reference the deduced template arguments β something impossible with ordinary functions whose defaults are fixed at parse time.
Default Args Can Reference Template Parameters
template <typename T, typename Alloc = std::allocator<T>>
class Buffer {
public:
// Default uses the template parameter T
explicit Buffer(std::size_t capacity, const T& fill_value = T{});
};Here T{} is not evaluated when the template is defined; it is instantiated when a caller writes Buffer<MyType>(64). If MyType is not default-constructible, the error fires at that call site, not at the template definition β consistent with how template errors generally work.
Explicit Specializations Cannot Add or Change Defaults
A full explicit specialization of a function template is treated as a separate function declaration. It inherits no defaults from the primary template and may not introduce its own:
template <typename T>
void serialize(T val, bool pretty = false);
template <>
void serialize(std::string val, bool pretty); // OK: no default on specialization
// template <>
// void serialize(std::string val, bool pretty = true); // ill-formedCallers invoking serialize("hello") will still apply the primary template's default for bool pretty β the lookup of defaults follows the primary template declaration, not the specialization.
Default Arguments Referencing Class Members
A default argument expression is evaluated in the scope of the function declaration's enclosing namespace, not inside the class. This means non-static data members cannot be named in a default argument β they have no meaning without an object, and no implicit object is in scope at that point.
struct Config {
int retries = 3;
// Ill-formed: non-static data member 'retries' in default arg
// void run(int n = retries) {}
// These are all well-formed alternatives:
static constexpr int kDefaultRetries = 3;
void run_static(int n = kDefaultRetries) {} // static constexpr member
enum Limits { MaxRetries = 10 };
void run_enum(int n = MaxRetries) {} // enumerator
void run_sizeof(std::size_t s = sizeof(retries)) {} // sizeof is an exception
};The sizeof exception exists because sizeof does not evaluate its operand; it only inspects the type, which is available without an object. The same reasoning applies to decltype and alignof.
If you need the default to reflect a per-instance value, the answer is an overload or a sentinel:
struct Config {
int retries = 3;
void run(int n) { /* core implementation */ }
void run() { run(retries); } // wrapper uses member naturally
};Static data members and constexpr members work because they are essentially namespace-scoped objects that happen to live inside the class:
struct Timeouts {
static constexpr int connect_ms = 1000;
static constexpr int read_ms = 5000;
void fetch(int timeout = connect_ms + read_ms) {} // fine: both are static
};Default Arguments and Function Pointers
A function pointer's type carries no information about default arguments. When you call a function through a pointer, you always supply all arguments explicitly β the pointer has no mechanism to record which parameters have defaults, and no declaration is visible at the indirect call site for the compiler to consult.
void log(const char* msg, int level = 0, bool flush = false);
// Pointer type encodes arity and types only β no defaults
void (*fp)(const char*, int, bool) = log;
log("start"); // OK: level=0, flush=false substituted
fp("start"); // Error: too few arguments β fp knows nothing of defaults
fp("start", 0, false); // OK: all args explicitThis applies equally to std::function, member function pointers, and any other indirect call mechanism. If you need a callable object that bakes in default values, a lambda or std::bind is the right tool:
auto log_info = [](const char* msg) { log(msg, 1, false); };
log_info("processing"); // internally calls log with explicit argsA subtler implication: you can take the address of a function that has default arguments, and the resulting pointer is perfectly valid. The defaults simply cease to exist at any call through that pointer. This means APIs that expose both a void(*)(Args...) pointer and a defaulted declaration must document that callers through the pointer are responsible for supplying every argument.
Named Parameter Idiom as an Alternative
When a function accumulates more than two or three default arguments, readability degrades and call sites become fragile β adding a parameter in the middle shifts every positional argument that follows it. The named parameter idiom using aggregate initialization with designated initializers (C++20) offers a structured alternative:
struct FetchOptions {
std::string method = "GET";
int timeout = 30;
bool follow_redirects = true;
int max_redirects = 5;
std::string user_agent = "bigcpp/1.0";
};
std::string fetch(std::string_view url, FetchOptions opts = {});
// Callers name only the fields they care about
fetch("https://api.example.com/data");
fetch("https://api.example.com/upload", {.method = "POST", .timeout = 120});
fetch("https://api.example.com/page", {.follow_redirects = false});The compiler fills in every unspecified field from its in-class default, just as it does with ordinary default arguments. But the benefits over a long default-argument list are significant:
- Order independence: you can set
.timeoutwithout specifying.method. - Self-documenting call sites:
{.timeout = 120}is unambiguous;fetch(url, "GET", 120)is not. - Stable API: adding a new field to
FetchOptionswith a default never breaks existing call sites, even positionally. - No virtual interaction: an options struct passed by value has no polymorphism concerns.
When to Use Default Arguments vs. Named Parameters
| Criterion | Default arguments | Named parameter idiom |
|---|---|---|
| Number of optional params | 1β2 | 3 or more |
| Parameter independence | Any | Best when params are independent |
| C++ version required | C++98 | C++20 (designated initializers) |
| Overload-set interaction | Natural | Requires a separate struct type |
| Template-parameter defaults | Supported | Struct defaults work too |
| Discoverability in IDEs | Excellent (tooltips) | Good (struct field names) |
Neither approach is universally superior. Default arguments remain the right choice for short, stable, ordered parameter lists β especially in function templates and constructors. Named parameters pay off when the option space is large, when options are genuinely independent, or when the function is likely to gain new parameters over time.
See Also
reference/language/abstract-classesβ virtual dispatch and why defaults interact dangerously with overridingreference/language/class-templateβ default type arguments for template parameters follow analogous but distinct rulesreference/language/converting-constructorβ constructors with all-defaulted parameters and the implicit conversions they enable