C++ Copy vs Direct Initialization: Why T x = value Fails Where T x(value) Compiles
Key takeaways
C++ copy initialization uses the = syntax and follows different overload-resolution rules than direct initialization with () or {}. This guide covers when explicit constructors block copy initialization, how RVO changes the performance picture, and common gotchas with auto and initializer lists.
What is Copy Initialization?
Copy initialization is a way to initialize variables using the = expr syntax. It is distinct from uniform initialization {} and is often optimized away through move semantics or RVO/NRVO. Understanding it alongside copy and move constructors helps clarify its behavior.
int x = 10; // Copy initialization
std::string s = "Hi"; // Copy initialization
int y(10); // Direct initialization
std::string s2("Hi"); // Direct initialization
The name “copy initialization” is a historical artifact and, honestly, a bit misleading — under the standard’s rules it doesn’t necessarily copy anything. What it actually specifies is a two-step conceptual process: convert the right-hand expression to the target type (possibly via an implicit conversion or a converting constructor), then initialize the variable from that converted value. Whether an actual copy happens, gets elided, or turns into a move is a separate question the compiler answers based on RVO and move semantics. The one rule that does consistently apply, and the one that actually matters day to day, is the overload-resolution restriction below: copy initialization refuses to consider explicit constructors, while direct initialization will use them.
Direct Initialization vs Copy Initialization
class Widget {
public:
explicit Widget(int x) {}
};
// ❌ Copy initialization: explicit constructor not allowed
// Widget w1 = 10;
// ✅ Direct initialization: explicit constructor allowed
Widget w2(10);
Widget w3{10};
explicit exists to stop exactly this kind of implicit conversion from happening by accident — imagine a Widget(int) constructor that isn’t marked explicit, and a function void process(Widget w) somewhere. Every call site that passes a plain int to process() would silently construct a Widget, which is rarely what anyone intended and is a classic source of confusing overload-resolution surprises. Marking the constructor explicit forces every conversion to be spelled out — process(Widget(10)) or process(Widget{10}) — so the construction is visible in the calling code, not hidden behind an innocuous-looking assignment.
Practical Examples
Example 1: Primitive Types
// Copy initialization
int x = 10;
double d = 3.14;
char c = 'A';
// Direct initialization
int x(10);
double d(3.14);
char c('A');
// Uniform initialization
int x{10};
double d{3.14};
char c{'A'};
For built-in types like int and double, all three forms end up identical in the generated code — there’s no constructor to call, no conversion to resolve, just a value placed into storage. The differences only start to matter for class types, which is where explicit and overload resolution actually come into play. One real difference even for primitives: int x{3.14}; is a compile error (narrowing conversion caught by brace-init), while int x = 3.14; and int x(3.14); both silently truncate to 3. That narrowing check is one of the better reasons to reach for brace initialization by default.
Example 2: Classes
class String {
std::string data;
public:
String(const char* str) : data(str) {}
String(const String& other) : data(other.data) {
std::cout << "Copy constructor" << std::endl;
}
};
// Copy initialization
String s1 = "Hello"; // const char* -> String
// Direct initialization
String s2("Hello");
String s1 = "Hello"; looks like it should invoke a copy constructor — hence the name — but what actually happens is: "Hello" (a const char*) gets implicitly converted to a temporary String via the single-argument constructor, and then that temporary either gets copied into s1 or, in every compiler built after C++17, is elided entirely and constructed directly in s1’s storage (mandatory copy elision). The “Copy constructor” message some tutorials tell you to expect from this exact line simply won’t print on a modern compiler — worth knowing so you don’t spend time debugging a “missing” copy that was never supposed to happen.
Example 3: Implicit Conversions
class Number {
int value;
public:
Number(int v) : value(v) {} // Allows implicit conversion
};
void func(Number n) {}
int main() {
func(42); // int -> Number (copy initialization)
Number n = 42; // Copy initialization
}
This is the same mechanism as the Widget/explicit example above, just without the guard rail — because Number’s constructor isn’t explicit, both func(42) and Number n = 42; happily construct a temporary Number behind the scenes. That’s occasionally exactly what you want (numeric wrapper types that should feel like plain numbers), but it’s worth a deliberate choice rather than an accident: if you didn’t intend int to silently become Number at every call site, mark the constructor explicit and let the compiler catch the unintended conversions.
Example 4: Return Values
std::string getName() {
return "Alice"; // Copy initialization
}
int getValue() {
int x = 10;
return x; // Copy initialization
}
Returning a local by value is copy initialization of the (invisible) return-value slot, and it’s the textbook case for NRVO (named return value optimization) — the compiler is permitted, and in practice reliably does, construct x directly in the caller’s storage rather than constructing it locally and then copying. That’s one of the reasons “return by value” stopped being considered a performance anti-pattern once compilers matured; for getName(), the "Alice" string literal is converted to a std::string once, directly into the caller’s destination, with no intermediate copy in the common case.
explicit and Initialization
class Widget {
public:
explicit Widget(int x) {}
};
// ❌ Copy initialization
// Widget w1 = 10;
// void func(Widget w) {}
// func(10);
// ✅ Direct initialization
Widget w2(10);
Widget w3{10};
The func(10) case is worth calling out separately from the variable-declaration case above, because it’s the one that surprises people most in code review: passing an argument to a function that takes the class by value is also copy initialization of the parameter, subject to the exact same explicit restriction as Widget w1 = 10;. If you’ve ever seen a compile error on a function call that mentions “no matching function” when the types looked compatible, an explicit constructor blocking an implicit argument conversion is one of the most common causes.
Common Issues
Issue 1: Explicit Constructor
class Array {
public:
explicit Array(size_t size) {}
};
// ❌ Copy initialization
// Array arr = 10;
// ✅ Direct initialization
Array arr(10);
Array arr2{10};
explicit on a single-argument constructor is close to a default best practice in modern C++ — leave it off only when you specifically want the type to behave like an implicit wrapper around its argument (rare wrapper/strong-typedef cases). For anything resembling a container or resource handle, like Array here, an implicit int -> Array conversion is almost never desired and just invites the kind of accidental construction described above.
Issue 2: Conversion Operators
class Fraction {
public:
Fraction(int n, int d) {}
operator double() const {
return /* ... */;
}
};
Fraction f(1, 2);
double d = f; // Copy initialization (conversion)
Conversion operators are the mirror image of converting constructors — instead of “how does this type get built from other types,” it’s “how does this type turn into other types,” and the same implicit-conversion concerns apply in reverse. operator double() without explicit means Fraction will silently convert to double in far more contexts than just this assignment — inside arithmetic expressions, comparisons, even if (f)-style conditions if you’re not careful — which is a common source of surprising overload resolution. Marking conversion operators explicit (allowed since C++11) is the equivalent guard rail for this direction of conversion.
Issue 3: Initialization Lists
// Copy initialization
std::vector<int> v1 = {1, 2, 3};
// Direct initialization
std::vector<int> v2{1, 2, 3};
// Both produce the same result
For std::vector specifically, both forms end up calling the same initializer_list constructor, so there’s genuinely no behavioral difference here — this is one of the few cases where copy-init and direct-init converge completely for a class type, because {1, 2, 3} is unambiguously an initializer_list<int> either way. The distinction re-emerges the moment a type has an overload that could interpret the braces differently, which is exactly the trap the next example walks into.
Issue 4: auto Type Deduction
auto x = 10; // int
auto y = {10}; // initializer_list<int>
auto z{10}; // C++17: int, C++11/14: initializer_list<int>
This is the trap: auto y = {10}; deducing std::initializer_list<int> instead of int catches almost everyone the first time, because it looks like it should behave exactly like int y = 10;. The rule that saves you is the special-case fix in C++17 — auto z{10}; (direct-list-initialization with auto) was changed to deduce a single scalar type instead of an initializer_list, precisely because the old behavior was considered a language wart. The practical takeaway: with auto, prefer = value for a single value and reach for {} only when you actually mean “a list of these,” to avoid depending on which form triggers initializer_list deduction.
Performance Considerations
// Copy initialization
std::string s1 = "Hello"; // Temporary object may be created
// Direct initialization
std::string s2("Hello"); // Direct construction
// Optimized with RVO
In practice, don’t choose between = and () for performance reasons on modern compilers — mandatory copy elision (C++17) means s1 and s2 produce identical machine code here; there is no temporary std::string constructed and then copied for s1. The historical advice to prefer direct initialization “to avoid a temporary” predates that guarantee and is no longer a reason to pick one syntax over the other. Choose based on readability and the explicit-constructor rule instead — that’s where the two forms still genuinely diverge.
FAQ
Q1: What is copy initialization?
A: Uses =. Allows implicit conversions.
Q2: What is direct initialization?
A: Uses () or {}. Allows explicit constructors.
Q3: What’s the difference?
A: Whether explicit constructors can be invoked.
Q4: What about performance?
A: Optimized with RVO. No significant difference.
Q5: When should I use each?
A:
- Copy: For simple initializations.
- Direct: When explicit constructors are needed.
Q6: Where can I learn more about copy initialization?
A:
- “Effective C++”
- “C++ Primer”
- cppreference.com
Related Articles
- C++ Brace Initialization
- C++ Move Semantics | Copy vs. Move
- C++ Copy and Move Constructors | Rule of Five
- C++ RVO/NRVO | Return Value Optimization
- C++ explicit Keyword