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