프로그래밍 지식/Effective Modern C++

Effective Modern C++, 항목 7 : 객체 생성시 괄호와 중괄호 구분하기

게임이 더 좋아 2022. 5. 16. 22:57
반응형
728x170

 

C++ 11부터 객체 생성 구문이 아주 다양해졌다.

그래서 우리는 이 항목을 배우는 것이다.

 


 

일반적으로 

C++11 에서는 초기화 값을 3가지 형태로 지정할 수 있다.

 

1. 괄호

2. 등호

3. 중괄호

int x(0);
int y = 0;
int z { 0 };

 

사실 하나 더 있다.

등호와 중괄호를 함께 사용하는 경우다.

int z = { 0 };

**위 코드는 대체로 중괄호를 단독으로 사용한 것과 동일하게 취급되어 작동한다.

 

 

위처럼 기본 타입이 아니라

사용자 정의 형식에서는 위처럼 간단하게 초기화되거나 assign이 일어나지는 않는다.

 

예를 들면

Widget w1; //기본 생성자
Widget w2 = w1; //복사 생성자 => w1을 w2에 대입하는 것이 아님
w1 = w2; // 이건 복사 대입 연산자다. w2의 내용이 w1으로 복사된다.

 

C++98 과 같이 이전에는 여러가지 초기화 구문을 지원했지만

실제로 사용자가 원하는 초기화를 표현할 수 없는 상황이 있었다.

예를 들면 컨테이너의 원소를 정해서 초기화하는 방식이 이전에는 지원되지 않았다.

그래서 C++11에서는 균일 초기화, Uniform Initialization을 도입했다.

**균일의 의미는 어디에서나 가능하다는 뜻이다.

 

중괄호 초기화로 우리는 현재 컨테이너의 원소를 정해서 초기화를 할 수 있다.

**이러한 초기화를  braced initialization, 중괄호 초기화라고 한다.

std::vector<int> v{ 1, 3, 5 };  //중괄호 초기화가 생겨남.

 

 

특히 중괄호 초기화는 비정적 자료 멤버의 기본 초기화 값을 지정하는데에도 사용된다.

다시 말하면 실제로 클래스를 선언하는 과정 중에서도 default value를 중괄호로 정할 수 있다는 말이다.

class Widget {
    ...
 
private:
    int x{ 0 };                    // OK, x의 기본값은 0
    int y = 0;                    // 역시 OK
    int z(0);                    // 오류!
};

** 단 괄호로 값을 초기화하는 방식은 클래스 내부에서 오류를 일으킴

 

** 참고로 모든 멤버가 되는 것은 아니며

복사할 수 없는 객체의 경우는 중괄호와 괄호로만 초기화가 가능하고 =(등호)를 쓰지 못한다.

std::atomic<int> ai1{ 0 };        // OK
 
std::atomic<int> ai2(0);        // OK
 
std::atomic<int> ai3 = 0;        // 오류!

 

보다보면 알겠지만 앞서 말한 초기화 표현식 방법 중에 중괄호는 모두 초기화를 시킬 수 있다.

여기서 균일 초기화라는 이름의 의미를 느낄 수 있다.

 

특히 중괄호 초기화의 가장 큰 기능 중 하나는 

암묵적 좁히기,  narrowing conversion을 방지해준다.

=> 말이 어렵지만 정보를 잃어버리는 type casting을 말한다.

 

double x, y, z;
...
int sum1{ x + y + z };            // 오류! double들의 합을 int로
                                // 표현하지 못할 수 있음.

 

최근에 TroubleShooting 과정 중에 division by zero 오류가 뜬 적이 있는데

실제로 반환하는 값은 0이 아니었지만

double에서 float으로 캐스팅되는 과정 중에 0이 나와서 오류가 생긴 적이 있다.

아무튼 중괄호를 이용한 초기화는 저런 일이 일어나지 않는다는 말이다.

 

그렇다면 중괄호를 쓰지 말고 그냥 = 을 그렇게 바꾸면 되는거 아냐??



라고 생각할 수 있다.

하지만 지금까지 C++11 이전으로 만들어온 레거시 코드를 다 망가뜨릴 수는 없다.

때문에 = 의 초기화 방법을 바꾸지는 않았다.

 

또한 중괄호 초기화의 특징 중 하나는

가장 성가신 구문 해석에 자유롭다는 점이다. (most vexing parse)

가장 성가신 구문 해석이란 "선언으로 해석할 수 있는 것은 항상 선언으로 해석한다"는 규칙이다.

다시 말해서 

객체를 생성할 때  생성자를 불러오고 싶었지만 클래스에 있는 함수가 실행되는 경우가 바로 그것이다.

 

Widget w1(10);                    // 인수 10으로 Widget의 생성자를 호출
 
Widget w2();                    // 가장 성가신 구문 해석! Widget의 기본
                                // 생성자를 호출하는 것이 아니라, Widget을
                                // 돌려주는, w2라는 이름의 함수를 선언한다
 
Widget w3{}                        // 인수 없이 Widget의 생성자를 호출

 

여기서 주목해야할 것은 w2인데

w2는 생성자를 호출하는 것이 아니라

Widget을 return 하는 함수 w2를 선언한 것이다.

 

?? 누가봐도 여기선 생성자 아니야???

 

사람은 알 수 있지만 C++은 그저 선언같이 생기면 그것은 선언이라고 인식하기 때문이다.

 

인수가 없는 생성자를 괄호로는 호출이 안되고 선언으로 인식하지만

 중괄호로 호출이 가능하다. 

다시 말해서 함수의 선언으로 오해할 일이 전혀 없게 생성자를 호출이 가능하다는 말이다.

 

아니 그럼 만능이네?? 왜 괄호랑 중괄호랑 구분하는데??

어차피 중괄호가 더 좋아보이는데??

 

 

바로 생성자를 오버로딩(중복 적재)를 할 때 그렇다.

중괄호 초기화는 가능한 한 std::initializer_list 매개변수가 있는 생성자와 부합한다.

(심지어 겉으로 보기에 그보다 인수들에 더 잘 부합하는 생성자가 있어도 그렇다)

 

class Widget {
public:
    Widget(int i, bool b);            // std::initializer_list
    Widget(int i, double d);        // 매개변수를 선언하지 않는
    ...                                // 생성자
};
 
Widget w1(10, true);                // 첫 생성자를 호출
 
Widget w2{ 10, true };                // 역시 첫 생성자를 호출
 
Widget w3(10, 5.0);                    // 둘째 생성자를 호출
 
Widget w4{ 10, 5.0 };                // 역시 둘째 생성자를 호출

 

위에서 Initializer_list를 인수로 가지는 생성자를 만들어봤다.

 

// 예를 들어 위의 Widget 클래스에
// std::initializer_list<long double>을
// 받는 생성자를 하나 추가해보자.
 
class Widget {
public:
    Widget(int i, bool b);                            // 이전과 동일
    Widget(int i, double d);                        // 이전과 동일
    Widget(std::initializer_list<long double> il);    // 추가됨
    ...
};
 
// 이렇게 하면, 다음 코드의 주석에서 보듯이
// Widget의 인스턴스 w2와 w4는 새 생성자를
// 통해서 생성된다(더 나쁜 부합임에도).
 
Widget w1(10, true);                // 괄호를 사용한 경우; 이전처럼
                                    // 첫 생성자를 호출
 
Widget w2{ 10, true };                // 중괄호를 사용한 경우; 이번에는
                                    // std::initializer_list 생성자 호출
                                    // (10과 true가 long double로 변환됨)
 
Widget w3(10, 5.0);                    // 괄호를 사용한 경우; 이전처럼 둘째
                                    // 생성자를 호출한다.
 
Widget w4{ 10, 5.0 };                // 중괄호를 사용한 경우; 이번에는
                                    // std::initializer_list 생성자 호출
                                    // (10과 5.0이 long double로 변환됨)

 

중괄호로 초기화를 시도하면 무조건 3번째 생성자가 호출된다.

보통은 복사 생성이나 이동 생성으로 호출될 만한데.. 초기화 리스트를 생성자의 인수로 만들어버리면

예상치 못한 상황이 발생한다.

 


class Widget {
public:
    Widget(int i, bool b);                            // 이전과 동일
    Widget(int i, double d);                        // 이전과 동일
    Widget(std::initializer_list<long double> il);    // 이전과 동일
    
    operator float(void) const;                        // float 로
    ...                                                // 변환
};
 
Widget w5(w4);                       // 괄호 사용, 복사 생성자 호출
 
Widget w6{ w4 };                    // 중괄호 사용, std::initializer
                                    // _list 생성자 호출
                                    // (w4가 float으로 변환되고 그
                                    // float이 long double로 변환됨)
 
Widget w7(std::move(w4));            // 괄호 사용, 이동 생성자 호출
 
Widget w8{ std::move(w4) };            // 중괄호 사용, std::initializer
                                    // _list 생성자 호출(w6에서와
                                    // 마찬가지의 변환들이 일어남)

 

중괄호만 사용하면 컴파일러가 이상하게 동작한다.

 

중괄호를 사용해도 제대로 동작할 때가 있는데

 

그 때는 중괄호 초기치의 인수 타입들을 std::initializer_list 안의 타입으로 변환하는 방법이 아예 없을 때이다.

 


 
class Widget {
public:
    Widget(int i, bool b);                        // 이전과 동일
    Widget(int i, double d);                    // 이전과 동일
    
    // 이제는 std::initializer_list의 원소 타입이 std::string
    Widget(std::initializer_list<std::string> il);
    ...                                            // 암묵적 변환
};                                                // 함수 없음
 
Widget w1(10, true);                // 괄호 사용, 여전히 첫 생성자를 호출
 
Widget w2{ 10, true };                // 중괄호 사용, 이제는 첫 생성자 호출
 
Widget w3(10, 5.0);                    // 괄호 사용, 여전히 둘째 생성자를 호출
 
Widget w4{ 10, 5.0 };                // 중괄호 사용, 이제는 둘째 생성자 호출

 

int, bool, double을 std::string으로 암묵적으로 변환할 방법이 없기 때문이다.

중괄호로 초기화를 하더라도 우리가 예상한대로 동작한다.

 

그렇다면 빈 중괄호를 넣으면 무엇이 호출될까???

역시 initializer_list를 가진 생성자가 호출될까??

 

여기에도 예외가 있는데

빈 중괄호를 넣으면 인수가 없는 것으로 인식해서 기본 생성자가 호출된다.

그 이유는 빈 중괄호 쌍은 빈 std::initializer_list가 아니라 인수 없음을 뜻하기 때문이다.
 

 


class Widget {
public:
    Widget(void);                            // 기본 생성자
    
    Widget(std::initializer_list<int> il);    // std::initializer
                                            // _list 생성자
 
    ...                                        // 암묵적 변환
};                                            // 함수 없음
 
Widget w1;                            // 기본 생성자를 호출
 
Widget w2{};                        // 역시 기본 생성자를 호출
 
Widget w3();                        // 가장 성가신 구문 해석! 함수 선언임!

 

?? 아니 난 빈 initializer_list로 생성자를 호출하고 싶은건데???

 

그렇다면 빈 중괄호 쌍을 괄호로 감싸거나 빈 중괄호 쌍을 도 다른 중괄호 쌍으로 감싸면 된다.

 


 
Widget w4({})                        // std::initializer_list 생성자를
                                    // 빈 초기치 리스트로 호출
 
Widget w5{{}}                        // 마찬가지

 

이러한 생성자에 가장 큰 영향을 받는 클래스는 우리가 자주 사용하는

std :: vector 이다.

vector를 초기화하는 여러가지 방법이 있는데 생성자가 아주 많고

Initializer_list 생성자도 오버로딩 되어 있다.

 

std::vector를 생성할 때 생성자에 인수 두 개를 지정한다면

그 인수들을 괄호로 감싸느냐 중괄호로 감싸느냐에 따라 아주 다른 결과가 나온다.

 


 

 
std::vector<int> v1(10, 20);    // 비std::initializer_list
                                // 생성자를 사용: 모든 요소의
                                // 값이 20인, 요소 10개짜리
                                // std::vector가 생성됨
 
std::vector<int> v2{ 10, 20 };    // std::initializer_list
                                // 생성자를 사용: 값이 각각 10과
                                // 20인 두 요소를 담은
                                // std::vector가 생성됨

 

 

생성자를 함부로 만들다가는 예상치 못한 위와 같은 상황이 벌어지므로

우리는 클래스를 설계하거나 사용할 때 몇 가지를 명심할 필요가 있다.

 

1. 오버로딩된 함수, 다시 말해서 initializer_list를 인수로 하는 함수가 하나 이상 존재한다면

중괄호 초기화 구문을 이용하는 코드에는 initializer_list 오버로딩 함수들만 적용될 수 있다.

 

요약하자면 괄호냐 중괄호냐에 따라 서로 다른 오버로딩된 것들이 호출하는 일이 없도록 하는 것이 최선이다.

 

2. 클래스를 설계하는 입장이 아니라 사용하는 입장에서 보자면

이미 짜여진 모든 클래스는 바꿀 수 없다.(그렇다고 하자)

그러므로 사용자가 괄호와 중괄호를 세심하게 선택해서 사용해야 한다.

둘의 취사 선택을 하기보다는 일관성을 가지는 것이 더 좋다고 저자는 말한다.

 

 

** 추가로 템플릿 안에서 객체를 생성할 때는 괄호를 사용할 지 중괄호를 사용할 지 애매한 경우가 있다.

 

// 예를 들어 임의의 개수의 인수들을 지정해서
// 임의의 타입의 객체를 생성한다고 하자.
 
// 개념적으로는, 가변 인수 템플릿
// (variadic template)을 이용하면
// 그런 템플릿을 만드는 것이 전혀
// 복잡하지 않다.
 
template <typename T,                // 생성할 객체의 타입
        typename... Ts>                // 사용할 인수들의 타입들
void doSomeWork(Ts&&... params)
{
    params...으로부터 지역 T 객체를 생성한다;
    
    ...
}
 
// 의사코드로 표시된 줄을 실제 코드로
// 바꾸는 방법은 다음 두 가지이다.
 
T localObject(std::forward<T>(params)...);        // 괄호를 사용
 
T localObject{ std::forward<T>(params)... };    // 중괄호를 사용
 
// 이제 다음과 같은 호출 코드를
// 생각해 보자.
 
std::vector<int> v;
...
doSomeWork<std::vector<int> >(10, 20);
 
// 만일 doSomeWork가 괄호를 이용해서
// localObject를 생성한다면, 그 결과는
// 요소가 10개인 std::vector이다.
 
// doSomeWork가 중괄호를 이용한다면
// 결과는 요소가 2개인 std::vector이다.
 
// 어느 쪽이 옳은지는 doSomeWork 작성자가
// 알 수 없다. 오직 호출자만이 알 수
// 있을 뿐이다.

 

반응형
그리드형