Item 7. 객체 생성 시 ()(괄호)와 {}(중괄호)를 구분해라


C++에서 사용하는 객체 초기화 방법

C++에서 객체를 초기화하는 4가지 방법이 있다.

int x1(3);    // 1. 초기값을 ()로 감싸는 방법
int x2 = 3;   // 2. 초기값을 "=" 다음으로 지정하는 방법 
int x3 = {3}; // 3. 초기값을 {}로 감싸는 방법
int x4{3};    // 4. "="와 {}로 초기값을 지정하는 방법

C++에서는 3, 4번 방법을 동일하게 취급한다.


초기화(Initialization)와 할당(Assignment)의 차이

객체 초기화 과정에서 사용하는 =는 객체 할당에 사용하는 =와 다르다는 것을 알고 있어야한다.

int와 같은 기본 자료형(primitive type)에서는 큰 상관이 없지만 사용자 정의 객체에서는 서로 다른 함수가 호출된다.

Widget w1;      // 객체 초기화, 기본 생성자 호출
Widget w2 = w1; // 객체 초기화, 복사 생성자 호출
w1 = w2;        // 객체 할당, 복사 할당 연산자(operator=) 호출


균일한 초기화(Uniform Initialization)

{}(중괄호)를 사용해서 객체를 초기화하는 방식을 균일한 초기화(uniform initialization)라 부른다.

uniform initialization은 초기화 구문이 주는 혼동을 피하기 위해 C++11부터 도입되었다.

uniform initialization은 {}를 사용하니 중괄호 초기화(braced initialization)라고 많이 불린다.


uniform initialization의 장점

중괄호 초기화는 다음과 같은 장점을 가지고 있다.

새로운 객체 생성 표현 가능

중괄호 초기화는 이전에는 표현할 수 없던 방식의 객체 생성을 표현할 수 있다.

vector<int> v{1, 3, 5}; // v의 초기 원소는 1, 3, 5

이처럼 중괄호 초기화를 통해 vector 컨테이너의 초기 원소들의 값을 각각 지정할 수 있다.

비정적 멤버 변수 초기화

중괄호 초기화는 비정적(non-static) 멤버 변수의 초기화 값을 지정할 때 사용할 수 있다.

class Widget {
private:
  int x{0};  // {}를 사용해서 비정적 멤버 변수 초기화 가능
  int y = 0; // =을 사용해서 비정적 멤버 변수 초기화 가능(C++11 부터)
  int z(0);  // 불가능
}

복사할 수 없는 객체 초기화

중괄호 초기화는 std::atomic 처럼 복사할 수 없는 객체를 초기화 할 수 있다.

std::atomic<int> ai1{0};  // {}를 사용해서 복사할 수 없는 객체 초기화 가능
std::atomic<int> ai2(0);  // ()를 사용해서 복사할 수 없는 객체 초기화 가능
std::atomic<int> ai3 = 0; // 불가능

이처럼 다른 객체 초기화 방식들은 사용할 수 없는 상황이 존재하지만 uniform initialization은 말 그대로 어디서나 사용할 수 있다.

암묵적 좁히기 변환(narrow conversion) 방지

중괄호 초기화는 narrow conversion을 방지하는 중요한 장점이 있다.

double x, y, z;

int sum1(x + y + z);  // double에서 int로 narrow conversion 발생
int sum2 = x + y + z; // double에서 int로 narrow conversion 발생

int sum3{x + y + z}; // 오류, narrow conversion 방지

성가신 구문 해석(most vexing parse)에서 자유로움

앞서 이야기한 초기화 구문이 주는 혼동의 대표적인 예가 most vexing parse이다.

most vexing parse는 “선언으로 해석할 수 있는 것은 선언으로 해석해야 한다”는 규칙이다.

most vexing parse로 인해 ()를 사용하여 객체를 초기화하면 함수 정의와 혼돈이 발생하는데 이를 중괄호 초기화를 통해 해결할 수 있다.

A a1(10) // 객체 생성, A 클래스 타입을 가지는 a1 객체 생성

A a2();  // 함수 정의, A 클래스 타입을 반환하는 a2 함수 정의

A a3{};  // 객체 생성, A 클래스 타입을 가지는 a3 객체 생성


uniform initialization의 단점

중괄호 초기화의 단점은 종종 예상치 못한 결과를 보이는 것이다.

예상치 못한 결과가 발생하는 경우들을 알아보자.

auto와 함께 사용하는 경우

Item2에서 말했듯 auto로 선언된 변수에 중괄호 초기화를 사용하면 언제나 std::initializer_list로 타입이 추론된다.

auto x1 = 3;   // x1의 타입은 int
auto x2(3);    // x2의 타입은 int
auto x3 = {3}; // x3의 타입은 std::initializer_list<int>
auto x4{3};    // x4의 타입은 std::initializer_list<int>

오버로딩에서 std::initializer_list가 강하게 선호되는 문제

생성자 호출에서 std::initializer_list가 매개변수에 없으면 (){}의 의미는 같다.

class Widget {
public:
  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과 0.5가 long double 타입으로 변환

컴파일러는 다른 생성자들이 인자와 타입이 정확하게 같더라도 std::initializer_list의 타입으로 변환이 가능하다면 무조건 std::initializer_list 생성자가 호출된다.

이는 복사 생성이나 이동 생성하는 상황에서도 발생하며 만약 std::initializer_list 타입으로 변환할 수 없으면 다른 비 std::initializer_list 생성자를 호출할 수 있음에도 컴파일 에러가 발생한다.

다만 빈 중괄호 쌍을 사용하는 경우 std::initializer_list 생성자가 아닌 기본 생성자가 호출된다.

class Widget {
public:
  Widget();
  Widget(std::initializer_list<int> il);
}

Widget w1;   // 첫번째 생성자 호출 
Widget w2{}; // 첫번째 생성자 호출 
Widget w3(); // Widget 타입을 반환하는 함수 선언

만약 빈 std::initializer_liststd::initializer_list 생성자를 호출하고 싶으면 {}를 감싸는 (){}가 있어야 한다.

Widget w4({});  // 두번째 생성자 호출
Widget w5{{}};  // 두번째 생성자 호출

std::initializer_list 생성자와 아닌 생성자가 다르게 구현되었을 경우

std::initializer_list 생성자와 비 std::initializer_list 생성자가 다르게 구현되어 있으면 상황에 맞게 중괄호 초기화를 사용해야 한다.

대표적인 예로 vector 컨테이너가 있다.

// std::initializer_list 생성자 호출
std::vector<int> v2{10, 20}; // 원소의 값이 각각 10, 20인 2개의 원소 생성

// 비 std::initializer_list 생성자 호출
std::vector<int> v1(10, 20); // 원소의 값이 20인 10개의 원소 생성


객체를 초기화할 때 ()(괄호)와 {}(중괄호)를 선택하는 기준

어떤 초기화 방식을 선택하든 하나를 기본으로 삼아서 항상 사용하고, 다른 하나는 꼭 필요한 경우에만 사용하는 일관성이 중요하다.

{}를 기본으로 사용하는 경우

{}를 기본으로 사용하면 앞서 말한 중괄호 초기화의 장점을 얻을 수 있다.

하지만 vector 컨테이너처럼 중괄호 대신 괄호를 사용해야 하는 경우(원소들을 하나의 값으로 초기화 하는 경우)가 있음을 이해해야 한다.

()를 기본으로 사용하는 경우

()를 기본으로 사용하면 autostd::initializer_list로 추론하는 문제나 객체 생성 시 의도치 않게 std::initializer_list 생성자가 호출되는 문제가 발생하지 않는 장점이 있다.

하지만 most vexing parse 문제나 vector 컨테이너에서 괄호 대신 중괄호를 사용해야 하는 경우(원소들을 각자 지정한 값으로 초기화 하는 경우)가 있음을 이해해야 한다.

템플릿 내부에서 객체를 초기화 하는 경우

템플릿 내부에서 객체를 {}로 초기화할지 ()로 초기화할지 선택하기는 쉽지 않다.

템플릿 작성자는 템플릿 사용자가 어떤 초기화 방식을 원하는지 판단할 수 없기 때문이다.

예를 들어 임의 개수의 인자를 받아 임의 타입의 객체를 생성하는 함수 템플릿을 구현해보자.

// 첫번째 템플릿, {}를 사용한 초기화
template <typename T, typename... Ts>
void doSomeWork(T&&... params) {
  T localObject{std::forward<ts>(params)...};
}

// 두번째 템플릿, ()를 사용한 초기화
template <typename T, typename... Ts>
void doSomeWork(T&&... params) {
  T localObject(std::forward<ts>(params)...);
}

std::vector<int> v;
doSomeWork(std::vector<int>>(10, 20);

첫번재 템플릿을 사용했으면 localObject는 원소가 10개인 std::vector<int>이고 두번째 템플릿을 사용했으면 localObject는 원소가 2개인 std::vector<int>이다.

두 가지 방법은 어떤 방법이 맞는지는 템플릿 작성자는 알 수가 없다.

이러한 문제를 해결하기 위해 표준 라이브러리 함수인 std::make_uniquestd::make_shared는 내부적으로 괄호를 사용하고 이를 인터페이스 일부에 문서화 하였다.


카테고리:

업데이트: