C++ / CLASSES AND OBJECT LIFECYCLE
Explicit constructors and unwanted conversions
Decide when a constructor should double as an implicit conversion, and use explicit to stop ints, braces, and stray values from silently becoming your type.
What you will learn
- Spot constructors that double as conversions: any callable with one argument
- Use explicit to block copy-init and argument passing while keeping Strict s(7) legal
- Write explicit operator bool so if (h) compiles but int n = h; does not
- Mark two-argument constructors explicit to reject show({4, 5}) and return {2, 3};
Understanding Explicit constructors and unwanted conversions
A constructor that can be called with exactly one argument is not only a way to build an object; it is a rule telling the compiler how to turn that argument type into your type. Wherever a conversion is needed, such as initialising a function parameter, initialising a return value, or writing `T x = value;`, the compiler may reach for that constructor without asking you. For `std::string` built from a `const char*` that is exactly right, because both spellings denote the same text. For a type whose constructor argument is merely a construction detail, such as a buffer built from a byte count, it means any stray integer in the program is a valid argument.
`explicit` does not disable a constructor; it removes it from the set the compiler is allowed to use silently. The dividing line is direct-initialisation versus copy-initialisation: `Strict s(7);`, `Strict s{7};` and `static_cast<Strict>(7)` all consider explicit constructors, while `Strict s = 7;`, passing `7` to a `Strict` parameter, and `return 7;` from a function returning `Strict` consider only the non-explicit ones. The mental model is that explicit means usable, but only where the type is named at the point of use.
Choosing comes down to one question: is the argument the same value in a different spelling, or a different thing altogether? A string built from a literal is the same text, so the implicit conversion reads naturally, whereas a buffer built from a byte count is not a byte count and should not quietly accept one. Two further pressures push toward explicit as the default: on a multi-parameter constructor it also decides whether `show({4, 5})` and `return {4, 5};` compile, and adding explicit to a widely used class later breaks every caller, while removing it breaks nobody.
<iostream>
struct Loose {
int n;
Loose(int v) : n(v) { std::cout << "Loose(" << v << ")\n"; }
};
struct Strict {
int n;
explicit Strict(int v) : n(v) { std::cout << "Strict(" << v << ")\n"; }
};
void useLoose(Loose l) { std::cout << " useLoose got " << l.n << "\n"; }
void useStrict(Strict s) { std::cout << " useStrict got " << s.n << "\n"; }
int main() {
Loose a = 7; // copy-initialisation: conversion inserted for you
useLoose(3); // an int silently becomes a Loose
// Strict bad = 7; // error: no implicit conversion from int to Strict
// useStrict(3); // error: the parameter is copy-initialised too
Strict b(7); // direct-initialisation still sees the constructor
useStrict(Strict{3}); // conversion written down, therefore allowed
std::cout << a.n + b.n << "\n";
}
A constructor callable with one argument is a conversion rule the compiler applies on its own, and explicit keeps the constructor while withdrawing that permission.
Worked examples
explicit operator bool and contextual conversion
Shows that an explicit conversion operator still works in conditions, which is why validity checks do not need to leak a conversion to int.
<iostream>
struct Handle {
int fd;
explicit Handle(int f) : fd(f) {}
explicit operator bool() const { return fd >= 0; }
};
int main() {
Handle good(3), bad(-1);
if (good) std::cout << "good is open\n";
if (!bad) std::cout << "bad is closed\n";
std::cout << std::boolalpha
<< static_cast<bool>(good) << ' '
<< static_cast<bool>(bad) << '\n';
// int n = good; // error: no implicit conversion to int
// if (good == bad) {} // error: no comparison exists
}
Example explained
Line 1`Handle good(3)` is direct-initialisation, so the explicit constructor is a candidate and the line compiles.
Line 2`if (good)` works because an if-condition performs a contextual conversion to bool, which is one of the few places that does consider explicit conversion operators.
Line 3`!bad` is contextual too, so negating the handle needs no cast.
Line 4Drop the explicit on `operator bool` and `good == bad` starts compiling: both sides convert to bool, promote to int, and compare truthiness instead of identity.
explicit on a two-argument constructor
Demonstrates that explicit on a multi-parameter constructor is what forbids braced-list arguments and braced returns.
<iostream>
struct Size {
int w, h;
Size(int a, int b) : w(a), h(b) {}
};
struct Grid {
int w, h;
explicit Grid(int a, int b) : w(a), h(b) {}
};
Size makeSize() { return {2, 3}; }
Grid makeGrid() { return Grid{2, 3}; }
// Grid makeBad() { return {2, 3}; } // error: constructor is explicit
void show(Size s) { std::cout << s.w << 'x' << s.h << '\n'; }
int main() {
show({4, 5});
show(makeSize());
Grid g = makeGrid();
std::cout << g.w << 'x' << g.h << '\n';
}
Example explained
Line 1`show({4, 5})` copy-list-initialises the parameter, so it may only pick a non-explicit constructor; Size qualifies.
Line 2`return {2, 3};` in makeSize is the same mechanism applied to the return value, which is why explicit would reject it.
Line 3makeGrid has to write `Grid{2, 3}` because naming the type turns the initialisation into direct-list-initialisation.
Line 4`Grid g = makeGrid();` still compiles even though the constructor is explicit: both sides are already Grid, so no conversion is involved.
Important notes
An implicit conversion sequence allows standard conversions around the single user-defined one, so a non-explicit Loose(int) makes useLoose(3.9) compile and truncate to 3; only one user-defined conversion is permitted, so longer chains never form on their own.
explicit changes how library code sees your type: with explicit Strict(int), v.push_back(7) is rejected because the element is copy-initialised, while v.emplace_back(7) direct-initialises and works.
Common mistakes
Overlooking default arguments: Buffer(int size, int growth = 2) is callable with one argument, so sink(10) still compiles and quietly builds a Buffer from a loop counter.
Leaving operator bool implicit so that h1 == h2 and std::cout << h compile; they look like they compare and print handles, but they compare and print truthiness as integers.
Concluding that explicit broke the class when Strict s = 7; stops compiling; copy-initialisation is exactly what explicit switches off, so write Strict s(7); or Strict s{7};.
Try it yourself
Change, predict, then run
Define struct Celsius { double v; Celsius(double d) : v(d) {} }; and void report(Celsius c) that prints c.v, then call report(98.6) with what is really a Fahrenheit reading and watch it print as 98.6 Celsius. Add explicit, fix the call so the conversion is written down, and confirm that Celsius c(98.6); still compiles.
Open the C++ workspaceCheck your understanding
A class declares explicit Buffer(int); and there is a function void sink(Buffer);. Why does Buffer b(10); compile while sink(10); does not?
- Passing an argument copies the object, and explicit constructors cannot be copied.
- Direct-initialisation may use explicit constructors, but initialising a parameter is copy-initialisation, which may not.
- The call would need two user-defined conversions, and only one is permitted in a conversion sequence.
- explicit applies only to constructors invoked with braces, and sink(10) uses parentheses.
Show answer
explicit partitions initialisation contexts rather than affecting access or copying: Buffer b(10); is direct-initialisation and considers all constructors, whereas a by-value parameter is copy-initialised from the argument and considers only non-explicit ones. The two-conversions option is tempting because that rule genuinely exists, but sink(10) needs just one user-defined conversion (int to Buffer), so the limit is not what rejects it.