C++ 복사 초기화 vs 직접 초기화: explicit 생성자와 암시적 변환 문제

이 글의 핵심

이름에 복사가 들어가 있지만 복사 초기화가 실제로 복사 생성자를 호출하는 것은 아니며, 대부분 이동이나 복사 생략으로 처리됩니다. 암시적 변환이 편리한 대신 의도치 않은 생성자 호출을 만드는 경우를 짚고, 팩토리 함수와 컨테이너 요소 초기화에서 어떤 형태를 쓰는 편이 안전한지 판단할 수 있게 합니다.

복사 초기화란?

복사 초기화(copy initialization) 는 = expr 형태로 변수를 초기화하는 방식입니다. 리스트 초기화 {}와 구분되며, 이동 의미론·RVO/NRVO로 복사가 생략되는 경우가 많습니다. 복사·이동 생성자와 함께 보면 동작을 정리하기 좋습니다.

int x = 10;           // 복사 초기화
std::string s = "Hi"; // 복사 초기화

int y(10);            // 직접 초기화
std::string s2("Hi"); // 직접 초기화

왜 필요한가?:

  • 가독성: 대입 연산자와 유사한 문법으로 직관적
  • 호환성: C 스타일 초기화와 호환
  • 암시적 변환: 타입 변환을 자동으로 수행
// 복사 초기화: 암시적 변환 허용
std::string s = "Hello";  // const char* → std::string

// 직접 초기화: 명시적
std::string s2("Hello");

동작 원리:

C++14까지의 규칙으로 설명하면 복사 초기화는 다음 단계로 수행됩니다:

  1. 임시 객체 생성: 우변을 T 타입 임시 객체로 변환
  2. 복사/이동 생성자 호출: 임시 객체를 변수로 복사/이동
  3. 복사 생략(Copy Elision): 컴파일러가 2단계를 최적화로 제거 가능 (단, 복사/이동 생성자는 접근 가능해야 함)

C++17부터는 이 설명이 바뀌었습니다. 우변이 같은 타입의 prvalue(Widget(10) 같은 임시 표현식)이거나 변환 생성자로 만들어지는 prvalue라면, 임시 객체가 처음부터 존재하지 않고 대상 변수 자리에 바로 생성됩니다(보장된 복사 생략). 그래서 C++17에서는 2단계가 “생략되는 최적화”가 아니라 애초에 없는 단계이고, 복사·이동 생성자가 삭제된 타입도 T x = T(10);으로 초기화할 수 있습니다. 오래된 자료에서 “복사 초기화는 복사 생성자가 필요하다”는 설명을 보면 C++14 기준이라고 이해하면 됩니다.

class Widget {
public:
    Widget(int x) { std::cout << "생성자\n"; }
    Widget(const Widget&) { std::cout << "복사 생성자\n"; }
};

Widget w = 10;  // "생성자" (복사 생략됨)
// 개념적으로는: Widget temp(10); Widget w(temp);
// 실제로는: Widget w(10); (최적화)

직접 초기화 vs 복사 초기화

class Widget {
public:
    explicit Widget(int x) {}
};

// ❌ 복사 초기화: explicit 생성자 불가
// Widget w1 = 10;

// ✅ 직접 초기화: explicit 생성자 가능
Widget w2(10);
Widget w3{10};

차이점 상세:

특징복사 초기화직접 초기화
문법T obj = expr;T obj(expr); 또는 T obj{expr};
explicit 생성자❌ 불가✅ 가능
암시적 변환✅ 허용✅ 허용
복사 생성자 필요C++14까지 필요 (생략돼도), C++17부터 prvalue면 불필요❌ 불필요
사용 위치변수, 반환값, 인자변수

실무 예시:

class FileHandle {
public:
    explicit FileHandle(const std::string& path) {
        // 파일 열기
    }
};

// ❌ 복사 초기화: 실수로 암시적 변환 방지
// FileHandle f = "data.txt";

// ✅ 직접 초기화: 명시적
FileHandle f("data.txt");
FileHandle f2{"data.txt"};

// ✅ explicit의 이점: 의도하지 않은 변환 방지
void processFile(FileHandle handle) {}

// processFile("data.txt");  // 에러: explicit 생성자
processFile(FileHandle{"data.txt"});  // OK: 명시적

표에서 실무적으로 가장 중요한 줄은 explicit 생성자입니다. 복사 초기화는 “우변을 T로 암시적으로 변환할 수 있는가”를 묻는 초기화라서, 암시적 변환에 쓰일 수 없도록 표시한 explicit 생성자는 후보에서 빠집니다. 그래서 Widget w = 10;은 explicit이 붙는 순간 컴파일 에러가 나고, GCC는 conversion from 'int' to non-scalar type 'Widget' requested라고 알려 줍니다. 메시지에 explicit이라는 단어가 없어서 처음 보면 원인을 찾기 어렵습니다. 같은 규칙이 함수 인자 전달(f(10))과 return 10;에도 적용됩니다. 이 두 곳도 복사 초기화이기 때문입니다.

복사 생성자 필요성:

class NonCopyable {
public:
    NonCopyable(int x) {}
    NonCopyable(const NonCopyable&) = delete;  // 복사 금지
};

// C++14까지: 복사 생성자가 필요해서 에러 (생략되더라도 접근 가능해야 함)
// C++17부터: 보장된 복사 생략으로 OK
NonCopyable obj1 = NonCopyable(10);

// ✅ 직접 초기화: 복사 생성자 불필요
NonCopyable obj2(10);  // OK
NonCopyable obj3{10};  // OK

실전 예시

이 차이는 std::mutex나 std::atomic처럼 복사도 이동도 안 되는 타입을 팩토리 함수로 만들 때 체감됩니다. C++14에서는 std::atomic<int> a = std::atomic<int>(0);도, 그런 타입을 값으로 반환하는 함수도 쓸 수 없었지만, C++17에서는 prvalue를 반환하는 한 모두 가능해졌습니다. 반면 이름 있는 지역 변수를 반환하는 NRVO는 여전히 선택적 최적화라서, 삭제된 이동 생성자를 가진 타입의 지역 변수를 return local;로 돌려주면 여전히 에러입니다.

예시 1: 기본 타입

// 복사 초기화
int x = 10;
double d = 3.14;
char c = 'A';

// 직접 초기화
int x(10);
double d(3.14);
char c('A');

// 균일 초기화
int x{10};
double d{3.14};
char c{'A'};

예시 2: 클래스

class String {
    std::string data;
    
public:
    String(const char* str) : data(str) {}
    String(const String& other) : data(other.data) {
        std::cout << "복사 생성자" << std::endl;
    }
};

// 복사 초기화
String s1 = "Hello";  // const char* -> String

// 직접 초기화
String s2("Hello");

예시 3: 암시적 변환

class Number {
    int value;
    
public:
    Number(int v) : value(v) {}  // 암시적 변환 허용
};

void func(Number n) {}

int main() {
    func(42);  // int -> Number (복사 초기화)
    
    Number n = 42;  // 복사 초기화
}

예시 4: 반환값

std::string getName() {
    return "Alice";  // 복사 초기화
}

int getValue() {
    int x = 10;
    return x;  // 복사 초기화
}

explicit과 초기화

class Widget {
public:
    explicit Widget(int x) {}
};

// ❌ 복사 초기화
// Widget w1 = 10;
// void func(Widget w) {}
// func(10);

// ✅ 직접 초기화
Widget w2(10);
Widget w3{10};

자주 발생하는 문제

문제 1: explicit 생성자

class Array {
public:
    explicit Array(size_t size) {}
};

// ❌ 복사 초기화
// Array arr = 10;

// ✅ 직접 초기화
Array arr(10);
Array arr2{10};

문제 2: 변환 연산자

class Fraction {
public:
    Fraction(int n, int d) {}
    
    operator double() const {
        return /* ... */;
    }
};

Fraction f(1, 2);
double d = f;  // 복사 초기화 (변환)

변환 연산자는 생성자의 반대 방향 변환이라 문제도 반대 방향에서 생깁니다. operator double()이 암시적이면 double d = f;뿐 아니라 f + 1, f == 0.5, std::sqrt(f)까지 모두 조용히 double로 바뀌어 계산됩니다. 분수를 정확히 다루려고 만든 타입이 어느새 부동소수점 연산으로 새어 나가는 셈입니다. C++11부터는 변환 연산자에도 explicit을 붙일 수 있고, 그러면 double d = f;는 에러가 되고 static_cast<double>(f)나 double d(f);만 허용됩니다.

대표적인 예외가 explicit operator bool()입니다. if (ptr), while (stream), !x, &&처럼 조건으로 쓰이는 문맥에서는 explicit이어도 변환이 허용되지만(문맥상 변환), int n = ptr;이나 ptr + 1처럼 엉뚱한 연산은 막힙니다. 스마트 포인터와 스트림이 모두 이 방식을 씁니다.

문제 3: 초기화 리스트

// 복사 초기화
std::vector<int> v1 = {1, 2, 3};

// 직접 초기화
std::vector<int> v2{1, 2, 3};

// 둘 다 동일한 결과

vector에서는 결과가 같지만, = {...} 형태(복사 리스트 초기화)는 explicit 생성자가 선택되면 에러가 난다는 점이 {...}(직접 리스트 초기화)와 다릅니다. 다른 복사 초기화와 달리 explicit 생성자가 후보에서 빠지는 것이 아니라, 후보로 남아서 선택된 뒤 에러가 됩니다. 예를 들어 explicit Array(size_t)가 있는 타입에 Array a = {10};을 쓰면 GCC는 converting to 'Array' from initializer list would use explicit constructor라는 에러를 냅니다. 함수에서 return {10};으로 반환할 때도 같은 규칙이 적용되어, 생성자에 explicit을 붙인 뒤 이런 return {...} 코드가 한꺼번에 깨지는 일이 생깁니다.

문제 4: auto 추론

auto x = 10;      // int
auto y = {10};    // initializer_list<int>
auto z{10};       // C++17: int, C++11/14: initializer_list<int>

성능 고려사항

// 복사 초기화
std::string s1 = "Hello";  // 임시 객체 생성 가능

// 직접 초기화
std::string s2("Hello");   // 직접 생성

// RVO로 최적화됨

복사 생략(Copy Elision) 최적화:

현대 C++ 컴파일러는 복사 초기화 시 복사를 생략하여 직접 초기화와 동일한 성능을 냅니다.

class Widget {
public:
    Widget(int x) { std::cout << "생성자\n"; }
    Widget(const Widget&) { std::cout << "복사 생성자\n"; }
};

Widget createWidget() {
    return Widget(10);  // 복사 초기화
}

int main() {
    Widget w = createWidget();
    // 출력: "생성자" (복사 생략됨)
    // 복사 생성자 호출 없음!
}

RVO (Return Value Optimization):

std::string getName() {
    std::string name = "Alice";
    return name;  // 복사 초기화
}

int main() {
    std::string s = getName();
    // RVO로 복사 없이 직접 생성됨
}

성능 비교:

초기화 방법복사 생성자 호출최적화 후
Widget w = Widget(10);개념적으로 필요생략됨
Widget w(10);불필요불필요
Widget w{10};불필요불필요

실무 권장:

  • 일반적인 경우: 복사 초기화 사용 (가독성)
  • explicit 생성자: 직접 초기화 사용
  • 성능: 같은 타입의 prvalue라면 차이가 없음. 다른 타입에서 변환되는 경우는 변환 비용이 실제로 발생할 수 있음

성능 차이가 없다는 말은 “우변이 이미 T 타입 prvalue일 때” 정확합니다. std::string s = "Hello";처럼 다른 타입에서 변환되는 경우에는 변환 생성자가 실제로 실행되므로, 비용은 직접 초기화 std::string s("Hello");와 같을 뿐 공짜는 아닙니다. 복사 초기화와 직접 초기화를 고를 때 실제 기준은 성능이 아니라 암시적 변환을 허용할 것인가입니다.

다른 초기화 방식과의 관계

복사 초기화는 리스트 초기화 T x = {a};, 직접 초기화 T x(a);, 균일 초기화 T x{a};와 겹치는 영역이 있습니다.

문법분류explicit 생성자좁은 의미의 “복사 초기화”
T x = v;복사 초기화사용 불가예
T x(v);, T x{v}직접 초기화사용 가능아님
T x = {a,b}문맥에 따라 다름컨테이너·클래스마다 규칙 상이종종 = 와 리스트로 읽힘

핵심은 = 가 있으면 우선 복사 초기화 규칙을 타고, 그다음 변환·생성자 해석이 이뤄진다는 점입니다. 팀 가이드에서 “스칼라·문자열은 = , 리소스 핸들은 {} 또는 직접”처럼 나누면 리뷰가 수월합니다.

실전 활용 사례 (보강)

  • 가독성 우선 지역 변수: auto s = std::string("x"); 처럼 의도가 “값 하나로 초기화”일 때 = 가 자연스럽습니다.
  • 템플릿·자동 추론과 함께: auto x = f(); 는 복사 초기화 형태입니다. 반환형이 값이면 이동/RVO와 잘 맞습니다.
  • 예외 안전: T x = factory(); 에서 RVO가 되면 예외 전에 불필요한 중간 객체가 줄어듭니다(C++17 mandatory elision 케이스).

성능 영향 (세부)

  • C++17 이후 prvalue에서의 복사 생략이 의무인 경우가 있어, T x = T(args); 형태도 실제 복사·이동이 없이 대상에 직접 생성될 수 있습니다.
  • 여전히 암시적 변환이 끼면 임시 객체·변환 연산이 생길 수 있습니다. 예: std::string s = "hi"; 는 개념적으로 변환을 거치지만 최적화로 얇아질 수 있습니다.
  • 거대한 객체를 복사 초기화로 받을 때만 문제가 될 경우는 드뭅니다. 이동 가능 타입이면 이동, RVO면 생성 한 번입니다.

컴파일러 최적화

  • Mandatory copy elision (C++17): T t = T(...); 같은 특정 prvalue 패턴은 복사/이동을 의무적으로 생략합니다.
  • NRVO: 이름 붙은 지역 변수를 반환할 때 컴파일러가 객체를 호출자 공간에 직접 구축할 수 있습니다(조건은 구현·상황에 따름).
  • std::move 남용 금지: 반환값에 return std::move(local); 는 종종 NRVO를 막습니다. return local; 이 기본입니다.

흔한 실수 (보강)

  1. explicit 생성자 + =: 의도는 직접 초기화인데 = 를 쓴 경우. 컴파일 에러로 잘 걸립니다.
  2. auto y = {1};: initializer_list 추론 이슈(C++17에서 auto x{1}; 규칙도 정리). 복사 초기화와 auto 조합은 cppreference auto 기준을 팀에서 고정하는 것이 좋습니다.
  3. NonCopyable + T x = f(): C++17에서는 f()가 prvalue를 반환하면 문제없습니다. 막히는 것은 f() 내부에서 이름 있는 지역 변수를 반환하는 경우(NRVO는 선택적)와 C++14 이하로 빌드하는 경우입니다.
  4. push_back vs emplace_back: v.push_back(10)은 인자를 복사 초기화하므로 explicit 생성자를 가진 원소 타입에서는 에러지만, v.emplace_back(10)은 내부에서 직접 초기화를 하므로 통과합니다. 이 차이 때문에 emplace_back이 explicit으로 막아 둔 변환을 우회해 버리는 경우가 있으니, “항상 emplace_back이 낫다”는 규칙은 조심해서 적용해야 합니다.

제가 코드 리뷰에서 가장 자주 지적하는 것은 1번의 반대 경우입니다. 단일 인자 생성자에 explicit을 빼먹어서 void send(const Buffer&) 같은 함수가 정수나 문자열 리터럴을 조용히 받아들이는 코드입니다. 컴파일도 테스트도 통과하기 때문에 잘못된 호출이 그대로 운영에 나가고, 나중에 오버로드가 추가되면서 호출 대상이 바뀌는 식으로 드러납니다. 인자 하나로 호출 가능한 생성자는 일단 explicit으로 만들고, 변환이 정말 타입의 의미에 맞을 때만 떼는 편이 안전합니다.

실무 패턴

패턴 1: 팩토리 함수

class Connection {
public:
    explicit Connection(const std::string& host) {
        // 연결 설정
    }
};

Connection createConnection() {
    return Connection("localhost");  // 복사 초기화 (RVO)
}

int main() {
    Connection conn = createConnection();  // 복사 초기화
}

패턴 2: 컨테이너 요소

class Widget {
public:
    Widget(int x) { std::cout << "생성자\n"; }
    Widget(const Widget&) { std::cout << "복사\n"; }
};

std::vector<Widget> widgets;
widgets.push_back(Widget(10));  // 복사 초기화
// 출력: "생성자" (복사 생략됨)

패턴 3: 암시적 변환 활용

class Duration {
    int seconds_;
public:
    Duration(int s) : seconds_(s) {}  // 암시적 변환 허용
    int seconds() const { return seconds_; }
};

void wait(Duration d) {
    std::cout << "대기: " << d.seconds() << "초\n";
}

int main() {
    wait(5);  // int → Duration (복사 초기화)
    Duration d = 10;  // 복사 초기화
}

FAQ

Q1: =가 없는데도 복사 초기화인 경우가 있나요?

A: 있습니다. 값으로 받는 함수 인자에 값을 넘길 때, return expr;로 값을 반환할 때, 예외를 throw하고 값으로 catch할 때, 그리고 = {...} 형태의 리스트 초기화가 모두 복사 초기화 규칙을 따릅니다. 그래서 explicit 생성자를 가진 타입은 f(10)이나 return 10;으로도 만들 수 없습니다.

Q2: 성능 차이는?

A: 우변이 같은 타입의 prvalue라면 C++17부터 복사·이동이 아예 일어나지 않아 차이가 없습니다. 우변이 다른 타입이면 변환 생성자가 실행되며, 이 비용은 직접 초기화로 써도 같습니다. 이름 있는 객체에서 복사 초기화하면(T b = a;) 당연히 복사 생성자가 실행됩니다.

Q3: 언제 사용해야 하나요?

A:

  • 복사 초기화: 간단한 초기화, 가독성 우선
  • 직접 초기화: explicit 생성자 필요, 명시적 의도

Q4: 복사 생성자가 없으면 어떻게 되나요?

A: C++14까지는 복사 초기화에 복사(또는 이동) 생성자가 접근 가능해야 했습니다(생략되더라도). C++17부터는 우변이 prvalue인 경우 보장된 복사 생략이 적용되어 삭제된 복사·이동 생성자로도 컴파일됩니다. 아래 코드의 주석 처리된 줄은 C++14에서만 에러입니다.

class NonCopyable {
public:
    NonCopyable(int x) {}
    NonCopyable(const NonCopyable&) = delete;
};

// NonCopyable obj = NonCopyable(10);  // C++14까지 에러, C++17부터 OK
NonCopyable obj(10);  // OK

Q5: 반환값 최적화(RVO)는 항상 적용되나요?

A: C++17부터 특정 경우(prvalue)에는 필수입니다. 그 외에는 컴파일러 재량입니다.

Q6: 규칙을 정확히 확인하려면 어디를 보나요?

A: cppreference.com - Copy initialization 페이지가 언어 버전별 차이(C++17 전후)를 가장 잘 정리해 두었습니다.

복사 초기화는 =로 변수를 초기화하며, 암시적 변환을 허용하지만 explicit 생성자는 사용할 수 없습니다.

관련 글: 리스트 초기화, 이동 의미론, 복사·이동 생성자, RVO·NRVO.


같이 보면 좋은 글