스토리텔링 개발자

[Effective Modern C++] 7. 괄호 초기화 vs 중괄호 초기화 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 7. 괄호 초기화 vs 중괄호 초기화

김디트 2025. 2. 13. 11:52
728x90

항목 7. 객체 생성 시 괄호와 중괄호를 구분하라

 

 

C++11에서의 객체 생성 구문
int x(0); // 괄호로 초기화
int y = 0; // 등호로 초기화
int z{ 0 }; // 중괄호로 초기화

int z = { 0 }; // 등호와 중괄호로 초기화
// 대체로 중괄호만 사용한 구문과 동일하게 취급된다.

 

 

 

초기화와 할당
  • 등호를 사용하면 반드시 할당(assignment)이 일어난다는 것은 오해이다.
  • 초기화와 할당의 차이
    • int 같은 내장 타입에서는 차이가 없다.
    • 사용자 정의 타입에서는 각자 다른 함수를 호출한다.(생성자, operator=)
Widget w1; // 기본 생성자를 호출
Widget w2 = w1; // 복사 생성자를 호출
w1 = w2; // 할당; 복사 할당 연산자(operator=)를 호출

 

 

 

유니폼 초기화(uniform initialization)
  • C++98에서는 서로 다른 임의의 값들을 담는 STL 컨테이너를 한 문장으로 생성하는 것이 불가능했다.
  • 하지만 모던 C++에서는 유니폼 초기화를 사용하여 해결이 가능하다.
  • 중괄호를 사용하므로, 중괄호 초기화(braced initialization)이라고도 한다.
std::vector<int> v{ 1, 3, 5 }; // 서로 다른 임의의 값들로 초기화
  • 비정적(non-static) 멤버
    • 중괄호 초기화가 가능하다.
class Widget
{
...
private:
    int x{ 0 }; // 유니폼 초기화 가능.
    int y = 0; // 여전히 가능한 방법.
    int z(0); // 에러!
};
  • 복사할 수 없는 객체(이를테면 std::atomic(항목 40 참조))
    • 중괄호나 괄호로는 초기화가 가능하지만,
    • 등호로는 불가능하다.
std::atomic<int> ai1{ 0 }; // 성공

std::atomic<int> ai2(0); // 성공

std::atomic<int> ai3 = 0; // 에러!
  • 즉, 유니폼(균일) 초기화라고 불리는 이유는 C++이 지원하는 세 가지 초기화 표현식 어디에서든 모두 사용할 수 있기 때문이다.
  • 또 다른 장점으로는, 암묵적 축소 변환(narrowing conversion)을 방지해준다.
    • 중괄호 초기화 값이 대상 타입으로 온전히 표현되지 않는 상황이라면 컴파일러가 반드시 알려준다.
double x, y, z;
...
int sum1{ x + y + z }; // double의 합이 int로 표현하지 못할 수 있으므로 에러!

int sum2(x + y + z); // 암묵적 변환 성공
int sum3 = x + y + z; // 여전히 성공
  • 또 다른 장점, 가장 성가신 파싱(most vexing parse)에서 자유롭다.
    • 선언으로 해석할 수 있는 것은 항상 선언으로 해석해야 한다는 C++ 규칙의 사이드 이펙트이다.
Widget w1(10); // 10으로 Widget의 생성자를 호출

Widget w2(); // 인수 없는 생성자를 호출하고 싶었지만, 함수 선언으로 해석해버린다.(성가신 파싱)

Widget w3{}; // 인수 없는 생성자를 정상적으로 호출한다.

 

 

 

유니폼 초기화의 단점
  • 종종 예상치 못한 행동을 보인다.
    • 중괄호는 auto에서 std::initializer_list로 추론된다.
    • 그와 같은 문제가 중괄호 초기화에서도 발생한다.
class Widget
{
public:
    // 생성자에 std::initializer_list 매개변수는 없다.
    Widget(int i , bool b);
    Widget(int i , double d);
    ...
};

Widget w1(10, true); // 첫 번째 생성자
Widget w2{ 10, true }; // 첫 번째 생성자
Widget w3(10, 5.0); // 두 번째 생성자
Widget w4{ 10, 5.0 }; // 두 번째 생성자


// 근데 만일 std::initializer_list를 받는 생성자가 추가된다면?
class Widget
{
public:
    Widget(int i , bool b);
    Widget(int i , double d);
    Widget(std::initializer_list<long double> il);
    ...
};

Widget w1(10, true); // 첫 번째 생성자
Widget w2{ 10, true }; // 세 번재 생성자 호출(10과 true가 각각 long double로 변환됨)
Widget w3(10, 5.0); // 두 번째 생성자
Widget w4{ 10, 5.0 }; // 세 번재 생성자 호출(10과 5.0가 각각 long double로 변환됨)


// 복사 생성이나 이동 생성에도 문제가 생길 수 있다.
class Widget
{
public:
    Widget(int i , bool b);
    Widget(int i , double d);
    Widget(std::initializer_list<long double> il);
    ...
    
    operator float() const; // float로 변환
};

Widget w5(w4); // 복사 생성자 호출
Widget w6{ w4 }; // std::initializer_list 생성자 호출(Widget->float->long double 변환)

Widget w7(std::move(w4)); // 이동 생성자 호출
Widget w8{ std::move(w4) }; // std::initializer_list 생성자 호출(Widget->float->long double 변환)


// std::initializer_list 생성자의 우선순위가 가장 높아서, 최선의 함수보다 우선된다.
class Widget
{
public:
    Widget(int i , bool b);
    Widget(int i , double d);
    Widget(std::initializer_list<bool> il);
    ...
};

Widget w{ 10, 5.0 }; // 에러! 축소 변환이 필요하다.
  • 그 외의 오버로드 함수를 탐색하는 일은, 중괄호 초기화 인수 타입들을 std::initializer_list 안의 타입으로 변환할 방법이 아예 없을 때 뿐이다.
    • 즉, 축소 변환까지 불가능한 상황이라야 겨우 탐색을 시작한다.
class Widget
{
public:
    Widget(int i , bool b);
    Widget(int i , double d);
    
    Widget(std::initializer_list<std::string> il); // std::string을 받는다.
    ...
};

Widget w1(10, true); // 첫 번째 생성자
Widget w2{ 10, true }; // 첫 번째 생성자
Widget w3(10, 5.0); // 두 번째 생성자
Widget w4{ 10, 5.0 }; // 두 번째 생성자

 

 

 

빈 중괄호의 의미?
  • 기본 생성자를 지원하고, std::initializer_list 생성자도 지원한다면,
  • 이 경우 빈 중괄호를 사용한 초기화에 대해선 어떤 생성자를 선택할까?
  • 이럴 때는 기본 생성자가 호출된다.
    • 즉, 빈 중괄호는 빈 std::initializer_list가 아니라 인수 없음을 뜻한다.
class Widget
{
public:
    Widget();
    Widget(std::initializer_list<int> il);
    ...
};

Widget w1; // 기본 생성자 호출
Widget w2{}; // 여전히 기본 생성자 호출
Widget w3(); // 가장 성가신 파싱! 함수 선언으로 해석된다.
  • 빈 std::initializer_list로 std::initializer_list 생성자를 호출하고 싶다면?
    • 빈 중괄호를 괄호로 감싸거나
    • 빈 중괄호를 또 다른 중괄호로 감싸면 된다.
Widget w4({});

Widget w5{ {} };

 

 

 

템플릿 작성 시의 문제
  • 괄호와 중괄호 중 어느 것을 사용해야 하는지 판단하는 게 불가능하다.
  • 예를 들어 임의의 개수의 인수로 임의의 타입의 객체를 생성하게 되는 가변 인수 템플릿을 설계 중이라면..
template<typename T, typename... Ts>
void doSomeWork(Ts&&... params)
{
    // 버전 1.
    T LocalObject(std::forward<Ts>(params)...); // 괄호를 사용한 버전
    // 버전 2.
    T LocalObject{ std::forward<Ts>(params)... }; // 중괄호를 사용한 버전
    
    ...
}

std::vector<int> v;
...
doSomeWork<std::vector<int>>(10, 20); // 이렇게 호출을 했다면..
  • 괄호 버전이라면, 요소가 10개인 std::vector이다.
    • { 20, 20, 20, 20, 20, 20, 20, 20, 20, 20 }
  • 중괄호 버전이라면, 요소가 2개인 std::vector이다.
    • { 10,  20 }
  • 이 문제는 표준 라이브러리 std::make_unique와 std::make_shared가 해결해야 했던 문제와 완전 동일하다.(항목 21 참조)
    • 내부적으로는 괄호를 사용하고, 문서화하여 해결했다.

 

 

 

정리
  • 클래스를 작성할 때, 오버로드 생성자 중에 std::initializer_list 버전이 하나라도 있다면
    • 중괄호 초기화 구문을 이용하면 std::initializer_list 버전만 적용될 수 있다!
    • 그러니 생성자를 설계할 땐 괄호냐 중괄호냐에 따라 다른 오버로드 버전이 선택되지 않도록 설계하자.
    • 예컨대, std::initializer_list 버전 생성자가 없던 클래스에 이를 추가하면, 기존 중괄호 구문들이 문제를 일으킬 수 있다.
  • 클래스 사용자로서 객체를 생성할 때 괄호와 중괄호를 세심하게 선택해야 한다.
728x90
Comments