C++ Type Conversions: Implicit Rules, static_cast, explicit, and Conversion Operators
Key takeaways
C++ converts values silently far more often than most code suggests. This guide covers standard conversions and where they lose data, signed/unsigned comparison bugs, brace-initialization narrowing checks, converting constructors and conversion operators, why only one user-defined conversion is allowed, explicit operator bool, and C++20 explicit(bool).
Why conversions deserve attention
C++ inherited C’s willingness to convert between arithmetic types without being asked, and then added user-defined conversions on top: any single-argument constructor and any operator T() can be applied by the compiler on its own. Most of the time this is convenient. When it goes wrong, it goes wrong silently: a value is truncated, a negative number becomes a huge positive one, or overload resolution picks a function you did not expect. None of those produce an error unless you ask for warnings.
This article walks through the built-in conversions first, because they cause the most bugs, then the user-defined ones and the tools (explicit, casts, brace initialization) for keeping them under control.
Standard conversions and where they lose data
int x = 10;
double y = x; // int -> double: exact for every int value
float f = 3.9f;
int z = f; // float -> int: truncates toward zero, z == 3
bool b = 42; // nonzero -> true
char c = 200; // out of range for signed char
The conversions fall into a few groups:
- Promotions (
char,short,booltoint;floattodouble) preserve the value and are preferred by overload resolution. - Integral and floating conversions (
inttoshort,doubletofloat,doubletoint) may lose information. - Boolean conversion: any arithmetic value or pointer can become
bool. - Pointer conversions:
Derived*toBase*, any object pointer tovoid*,nullptrto any pointer.
The data-losing cases do not all behave the same. Converting an out-of-range value to an unsigned type is well defined: it wraps modulo 2^N. Converting an out-of-range integer to a signed type was implementation-defined before C++20 and is defined as modular since C++20; on common platforms char c = 200; yields -56 either way. But converting a floating-point value that does not fit to an integer is undefined behavior: int big = 3000000000.0; is UB, and GCC merely warns with -Woverflow when it can see the constant. Hiding that behind a static_cast does not make it defined.
The usual arithmetic conversions
Before a binary operator such as +, < or == runs, both operands are converted to a common type. Small types are promoted to int first, and then, if one operand is unsigned and has at least the rank of the other, the signed operand becomes unsigned:
int i = -1;
unsigned u = 1;
std::cout << (i < u) << '\n'; // prints 0: -1 converts to 4294967295
The version of this bug I have seen most often is the loop over a container that looks harmless:
std::vector<int> v{1, 2, 3};
for (int k = 0; k < v.size() - 4; ++k) { /* runs, and keeps running */ }
v.size() is unsigned, so v.size() - 4 does not become -1; it wraps to a huge value, and k is converted to unsigned for the comparison. The loop runs until something crashes. It compiles without a word unless -Wall (which enables -Wsign-compare for C++) is on. Since C++20, std::ssize(v) returns a signed size and std::cmp_less(k, v.size()) compares mixed-sign integers by value, and I reach for both whenever an index can be combined with subtraction.
A related surprise: unsigned char a = 200, b = 100; auto s = a + b; gives s the type int with value 300, because both operands are promoted before the addition. Arithmetic on small types never happens in the small type.
Brace initialization rejects narrowing
List-initialization with braces refuses conversions that can lose information when the value is not a constant known to fit:
int a = 3.5; // compiles, a == 3 (a warning at best)
int b{3.5}; // error: narrowing conversion of '3.5e+0' from 'double' to 'int'
short s{x}; // ill-formed when x is a non-constant int (Clang: error, GCC: -Wnarrowing warning)
This is one of the more practical reasons to prefer braces for local variables and member initializers: the compiler catches the truncation for you. The rules are covered in detail in uniform initialization.
Explicit casts
double d = 3.14;
int x = (int)d; // C-style cast
int y = static_cast<int>(d); // C++ cast, same result here
For arithmetic types the two produce the same value. The difference is what else a C-style cast is willing to do: it tries const_cast, static_cast, and reinterpret_cast in sequence, so (Derived*)ptr can silently become a reinterpret cast between unrelated types, or cast away const, without any hint in the source. static_cast refuses those, and each of the four named casts is searchable. The casting guide covers when each is appropriate.
What static_cast does not do is check anything at runtime. It turns an implicit or allowed conversion into a visible one, which is valuable in code review, but it is not a safety net.
Converting constructors
A constructor that can be called with one argument doubles as an implicit conversion from that argument’s type:
#include <iostream>
class Distance {
double meters;
public:
Distance(double m) : meters(m) {}
double getMeters() const { return meters; }
};
void printDistance(Distance d) {
std::cout << d.getMeters() << "m\n";
}
int main() {
printDistance(100.5); // implicit double -> Distance
printDistance(Distance(50.0)); // explicit construction
}
For a unit type this is exactly the problem. printDistance(100.5) compiles whether the caller meant meters, feet or a timeout in seconds. Marking the constructor explicit forces callers to name the type:
class Distance {
double meters;
public:
explicit Distance(double m) : meters(m) {}
double getMeters() const { return meters; }
};
void printDistance(Distance d);
// printDistance(100.5); // error: could not convert '100.5' from 'double' to 'Distance'
printDistance(Distance(100.5)); // OK
Distance d = 100.5; // also an error: copy-initialization needs an implicit conversion
Distance e{100.5}; // OK: direct-initialization
The same applies to the classic container case: Array(std::size_t n) without explicit lets process(10) build a ten-element array when someone meant to pass an index. The standard library makes std::vector’s size constructor explicit for this reason. The rules and exceptions (copy constructors, constructors that should stay implicit like std::string(const char*)) are in the explicit keyword.
Conversion operators
A conversion operator goes the other direction, from your type to another:
class Distance {
double meters;
public:
Distance(double m) : meters(m) {}
operator double() const { return meters; }
};
Distance d(100.5);
double x = d; // implicit Distance -> double
Having conversions in both directions is where things get tangled. Add an operator+(Distance, Distance) to this class and d + 1.0 stops compiling:
error: ambiguous overload for 'operator+' (operand types are 'Distance' and 'double')
note: candidate: 'operator+(double, double)' (built-in)
note: candidate: 'Distance operator+(Distance, Distance)'
The compiler can convert d to double and use built-in addition, or convert 1.0 to Distance and use yours; both need one user-defined conversion, so neither wins. Implicit conversions in both directions are almost never worth it. Keep at most one of them implicit, and usually neither.
Conversion to pointer types is especially risky
A string wrapper with an implicit operator const char*() looks convenient for passing to C APIs:
class String {
std::string data;
public:
String(const char* str) : data(str) {}
operator const char*() const { return data.c_str(); }
};
String s("hi");
bool same = (s == "hi"); // compiles, and compares pointers, not text
std::string’s operator== is a template and cannot deduce through a user-defined conversion, so the only viable candidate is the built-in pointer comparison. The result is false, and GCC only notices because of -Waddress. The pointer is also only valid while s is alive, so const char* p = makeString(); dangles immediately. The standard library chose a named c_str() over a conversion operator for exactly these reasons, and I would copy that choice.
explicit operator bool
Since C++11, conversion operators can be explicit as well. For bool this is the standard idiom:
template <typename T>
class SmartPtr {
T* ptr;
public:
explicit SmartPtr(T* p = nullptr) : ptr(p) {}
~SmartPtr() { delete ptr; }
SmartPtr(const SmartPtr&) = delete; // owning pointer: no copies
SmartPtr& operator=(const SmartPtr&) = delete;
explicit operator bool() const { return ptr != nullptr; }
T& operator*() const { return *ptr; }
T* operator->() const { return ptr; }
};
SmartPtr<int> p(new int(5));
if (p) { /* OK: contextual conversion */ }
// bool b = p; // error: cannot convert 'SmartPtr<int>' to 'bool' in initialization
bool b2 = static_cast<bool>(p); // OK
An explicit operator bool is still applied in contextual conversions: the condition of if, while and for, the operands of !, && and ||, and the first operand of ?:. It is not applied in bool b = p;, in arithmetic such as p + 1, or when comparing two unrelated objects that both convert to bool. Before C++11, library authors used the elaborate “safe bool idiom” to get the same effect. Note that the deleted copy operations are not optional here: the original sketch without them would double-delete as soon as the pointer was copied.
The same technique works for any lossy conversion. A Fraction with explicit operator double() can still be converted with static_cast<double>(f), but will not quietly turn into a double in the middle of an expression.
The one user-defined conversion rule
An implicit conversion sequence may contain at most one user-defined conversion (a converting constructor or conversion operator), optionally surrounded by standard conversions:
class A { public: A(int) {} };
class B { public: B(const A&) {} };
// B b1 = 42; // error: conversion from 'int' to non-scalar type 'B' requested
B b2(42); // OK
A a = 42;
B b3 = a; // OK
B b1 = 42 asks for one implicit conversion from int to B, which would need int to A and then A to B. B b2(42) is different: direct-initialization calls a constructor of B, and only the argument of B(const A&) has to be converted, which takes a single user-defined step. That asymmetry between = and parentheses surprises people, but it follows directly from the rule.
The rule exists to keep overload resolution tractable and predictable. Without it the compiler would have to search chains of arbitrary length through every type in scope, and adding an unrelated class could make existing calls ambiguous. When you do need two steps, write one of them yourself.
Conditional explicit in C++20
Generic wrappers have a problem: Wrapper<std::string> should be implicitly constructible from const char* (because std::string is), but not from std::string_view (because that conversion is explicit for std::string). explicit(bool) lets the constructor mirror the wrapped type:
#include <string>
#include <string_view>
#include <type_traits>
#include <utility>
template <typename T>
class Wrapper {
T value;
public:
template <typename U>
explicit(!std::is_convertible_v<U, T>)
Wrapper(U&& u) : value(std::forward<U>(u)) {}
};
void take(Wrapper<std::string>) {}
int main() {
take("hello"); // implicit, like std::string
// Wrapper<std::string> w = std::string_view("x"); // error: explicit, like std::string
Wrapper<std::string> w2{std::string_view("x")}; // OK: direct-initialization
}
Before C++20, std::pair, std::tuple and std::optional achieved this with two constructor overloads each, one explicit and one not, constrained with SFINAE. explicit(bool) collapses them into one.
Keeping conversions under control
Across a codebase, the defaults I would argue for are: make single-argument constructors explicit unless the conversion is lossless and obviously what the caller means; avoid implicit conversion operators entirely except explicit operator bool; prefer brace initialization so narrowing is an error; build with -Wall -Wextra and consider -Wconversion for numeric code, which flags implicit conversions that may change a value (noisy on existing code, but valuable for new modules); and use distinct types rather than bare int/double for quantities that must not mix, such as IDs, units and indices. Overload sets interact with all of this, and function overloading explains how the compiler ranks the candidates once conversions are involved.