ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] 괄호 ()와 중괄호 {} 그리고 Uniform initialization
    C++/Effective Modern C++ 2022. 2. 16. 14:41

    C++11부터, 객체를 생성 할 때 사용할 수 있는 구문이 다양해졌습니다.

    구체적으로 아래와 같습니다.

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

    이번 글에서는 객체의 생성에 관해서 이야기 해 보겠습니다.

     


     

    1. Assignment & Initialization

     

    더보기

    배정 (Assignment)과 초기화 (Initialization)는 내장 형식에 대하여는 유사하게 작동합니다.

    하지만, 그 외의 클래스에 관해서는 서로 다른 함수들을 호출하는 정도의 차이가 존재합니다.

    Obj o1;
    Obj o2 = o1;
    o1 = o2;

    위 코드가 호출하는 함수는 아래와 같습니다.

    1. 기본 생성자를 호출합니다.
    2. 복사 생성자를 호출합니다
    3. 복사 배정 연산자 (operator=)를 호출합니다.

    배정과 초기화에 대한 구체적인 차이에 대하여는 stack overflow에 관련된 글이 있어 첨부하도록 하겠습니다.

    C++ Initialization and Assignment

     

    Initialisation and assignment

    What EXACTLY is the difference between INITIALIZATION and ASSIGNMENT ? PS : If possible please give examples in C and C++ , specifically . Actually , I was confused by these statements ... C++

    stackoverflow.com

     

    2. Uniform initialization

     

    더보기

    서론에 있는 int자료형에 대한 세 가지 초기화 구문은 모두 동일한 기능을 합니다.

    하지만, 사용된 구문인 괄호, 등호, 중괄호는 다른 경우에 사용 불가한 경우가 있습니다.

    class MyClass {
    private:
    	int x{ 0 };
    	int y = 0;
    	int z(0); // Error
    };

    위와 같이 자료 멤버의 기본 초기화 값을 지정하는 데에는 괄호를 사용하지 못 합니다.

    std::atomic<int> a1{ 0 };
    std::atomic<int> a2 = 0; // Error
    std::atomic<int> a3(0);

    위와 같이 복사 할 수 없는 객체를 초기화 하는 데에는 등호를 사용하지 못 합니다.

     

    위 예제를 살펴보면, 괄호와 등호는 사용할 수 없는 구문이 있는 반면에, 중괄호는 어디에서나 사용할 수 있음을 볼 수 있습니다.

    이러한 특징 때문에 중괄호 초기화를 균일 초기화 (Uniform initialization)이라고 부릅니다.  

     

    3. Narrowing conversion

     

    더보기

    균일 초기화는 기본적으로 좁히기 변환 (Narrowing conversion)을 방지해줍니다.

    #include <iostream>
    
    int main() {
        double x = 1.0, y = 2.2, z = 3.4;
        int sum1(x + y + z);
        int sum2 = x + y + z;
        std::cout << sum1 << " " << sum2;
    }
    6 6

    위와 같이 괄호나 등호는 객체의 묵시적 형 변환에 대한 점검을 수행하지 않습니다.

    이는 점검을 수행하도록 표준을 변경할 때, 컴파일 되지 않는 기존의 코드가 많아지기 때문이기도 합니다.

    #include <iostream>
    
    int main() {
        double x = 1.0, y = 2.2, z = 3.4;
        int sum{ x + y + z };
        std::cout << sum;
    }
    C2397 : 'double'에서 'int'(으)로의 변환에는 축소 변환이 필요합니다.

    균일 초기화는 표현식을 초기화하는 객체의 형식으로 온전히 표현할 수 있음이 보장되지 않는 경우, 컴파일러가 반드시 해당 사실을 보고합니다.

     

    4. Most vexing parse

     

    더보기

    가장 성가신 구문 해석 (Most vexing parse)는 C++의 규칙에서 비롯된 하나의 부작용입니다.

    C++에는 "선언으로 해석할 수 있는 것은 항상 선언으로 해석해야 한다"는 규칙이 있습니다.

     

    MyClass c1(10);
    MyClass c2(); //Most vexing parse
    MyClass c3{};

    위 예제의 두 번째 c2의 선언의 경우 파라미터 없이 MyClass의 생성자를 호출하는 것으로 사용할 수 있겠지만, 컴파일러는 반환형이 MyClass이고 파라미터가 없는 함수를 선언한 것으로 해석합니다.

    이와 같은 가장 성가신 구문 해석으로부터 자유롭다는 것이 균일 초기화의 또 다른 장점입니다.

     

    5. std::initializer_list

     

    더보기

    지금까지 살펴본 균일 초기화의 장점으로는 범용성이 높은 초기화 구문이며, 좁히기 변환과 가장 성가신 구문 해석을 방지해줍니다. 이렇게 유의미한 장점이 많은 중괄호 초기화지만, 무시하지 못 하는 단점 또한 존재합니다.

     

    std::initializer_list가 바로 그 단점 중 하나입니다. 생성자를 호출할 시 균일 초기화 구문은 std::initializer_list를 강하게 선호하는 경향이 있습니다.

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    
    class MyClass {
    public:
        MyClass(int i, bool b) { std::cout << "int, bool\n"; }
        MyClass(int i, double d) { std::cout << "int, double\n"; };
        MyClass(std::initializer_list<double> il) { std::cout << "initializer_list\n"; };
    };
    
    int main() {
        MyClass c1(10, true);
        MyClass c2{ 10, true };
        MyClass c3(10, 5.0);
        MyClass c4{ 10, 5.0 };
    }
    int, bool
    initializer_list
    int, double
    initializer_list

    위 코드에서 std::initializer_list를 파라미터로 하는 생성자가 없을 경우, c2와 c4는 각각 의도한 대로 { int, bool }과 { int, double }이 되지만, std::initializer_list가 있을 경우 균일 초기화 구문은 std::initializer_list를 파라미터로 받는 생성자를 호출하게 됩니다.

     

    복사 및 이동 생성 또한 마찬가지 입니다.

    #include <iostream>
    
    class MyClass {
    public:
        ...
        MyClass(const MyClass& rhs) { std::cout << "copy constructor\n"; }
        MyClass(MyClass&& rhs) { std::cout << "move constructor\n"; }
        
        operator float() const { std::cout << "operator float\n"; return 0.0; };
    };
    
    int main() {
        ...
        MyClass c5(c4);
        MyClass c6{ c4 };
    
        MyClass c7(std::move(c4));
        MyClass c8{ std::move(c4) };
    }
    copy constructor
    operator float
    initializer_list
    move constructor
    operator float
    initializer_list

    위의 경우 복사 및 이동 생성이 자연스러운 상황이지만, 컴파일러는 형 변환 연산을 수행하고, std::initializer_list의 생성자를 호출합니다.

     

    극단적인 경우에는 컴파일이 되지 않습니다.

    #include <iostream>
    
    class MyClass {
    public:
        MyClass(int i, bool b) { std::cout << "int, bool\n"; }
        MyClass(int i, double d) { std::cout << "int, double\n"; };
        MyClass(std::initializer_list<bool> il) { std::cout << "initializer_list\n"; };
    };
    
    int main() {
        MyClass c1(10, true);
        MyClass c2{ 10, true };
        MyClass c3(10, 5.0);
        MyClass c4{ 10, 5.0 };
    }
    C2398 : 요소 '1': 'int'에서 'bool'(으)로의 변환에는 축소 변환이 필요합니다.

     std::initializer_list로의 변환이 불가능한 상황 (예제의 경우는 좁히기 변환)에서는 컴파일을 거부합니다.

     

    예제를 하나 더 살펴보도록 하겠습니다. 이번 경우는 기본 생성자가 존재하는 경우입니다.

    #include <iostream>
    
    class MyClass {
    public:
        MyClass() { std::cout << "constructor\n"; }
        MyClass(std::initializer_list<int> il) { std::cout << "initializer_list\n"; };
    };
    
    int main() {
    	MyClass c9;
    	MyClass c10{};
    }
    constructor
    constructor

    표준에 따르면 이 경우에는 기본 생성자가 호출됩니다.

    만약 이러한 경우에 비어있는 std::initializer_list로 std::initializer_list 생성자를 호출해야 하는 경우 다음과 같이 중괄호쌍을 감싸는 중괄호쌍, 혹은 괄호를 이용할 수 있습니다.

    #include <iostream>
    
    class MyClass {
    public:
        MyClass() { std::cout << "constructor\n"; }
        MyClass(std::initializer_list<int> il) { std::cout << "initializer_list\n"; };
    };
    
    int main() {
        MyClass c11({});
        MyClass c12{ {} };
    }
    initializer_list
    initializer_list

     

    위 예제들처럼, 특정 경우에 의한 std::initializer_list의 선호도는 굉장히 높은 편 입니다.

    컴파일러가 std::initializer_list 생성자를 선택하지 않는 경우는 마지막 예제와 같이 파라미터가 비어있을 경우거나, 혹은 중괄호 초기치의 인수들을 std::initializer_list로 변환할 수 없는 경우 뿐 입니다.

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    
    class MyClass {
    public:
        MyClass(int i, bool b) { std::cout << "int, bool\n"; }
        MyClass(int i, double d) { std::cout << "int, double\n"; };
        MyClass(std::initializer_list<std::string> il) { std::cout << "initializer_list\n"; };
    };
    
    int main() {
        MyClass c1(10, true);
        MyClass c2{ 10, true };
        MyClass c3(10, 5.0);
        MyClass c4{ 10, 5.0 };
    }
    int, bool
    int, bool
    int, double
    int, double

    처음 예제의 std::initializer_list의 템플릿을 std::string으로 수정했습니다.

    이 경우 int, bool, double을 std::string으로 변환할 수 없으므로 의도된 형식의 생성자가 호출됩니다.

     

    위 예제와 같은 경우에 직접적인 영향을 받는 상황이 가까이에 한 가지 존재합니다.

    std::vector컨테이너가 바로 그 예시입니다.

    #include <iostream>
    #include <vector>
    
    int main() {
        std::vector<int> v1(10, 20);
        std::vector<int> v2{ 10, 20 };
    
        std::cout << v1.size() << "\n";
        std::cout << v2.size() << "\n";
    }
    10
    2

    괄호와 중괄호의 차이만 존재하는데, 결과는 매우 다릅니다.

    괄호의 int 자료형 두개를 통한 std::vector 초기화는 첫 파라미터는 vector의 요소의 개수, 두 번째 파라미터는 vector의 초기값을 의미합니다.

    중괄호를 통한 std::vector의 초기화는 중괄호 내부의 값을 각각 std::vector에 담습니다.

     

    6. 괄호()와 중괄호{}

     

    더보기

    위 문단들을 통해 C++11부터 추가된 균일 초기화에 대해 살펴보았습니다.

     

    중괄호를 이용한 초기화는 괄호보다 다양한 문맥에 적용시킬 수 있는 범용성, 가장 성가신 구문 해석으로부터의 자유로움 등의 장점과, 위 문단에서 살펴본 것과 같은 std::initializer_list 등의 auto및 초기화 구문에서의 연역이 복잡하다는 단점이 있습니다.

     

    괄호를 이용한 초기화는 C++의 구문적 전통과의 일관성, auto의 사용 및 객체의 생성에서 std::initializer_list 가 연역되는 일이 없다는 점 등의 장점이 있지만, 때때로 중괄호를 사용해야만 하는 경우가 있습니다.

     

    어느 한 쪽의 구문만을 사용하는 것은 사실상 불가능합니다.

    권장되는 것은 방향성을 일관되게 기본적으로 사용할 구문과, 특정 경우에 사용할 구문을 확실하게 구분짓는 것 혹은 팀의 컨벤션에 맞추는 것 입니다.

     


     

    설계적 관점에서 std::initializer_list는 생각지도 못한 오류를 발생시킬 수 있습니다.

    본문의 예제들처럼 컴파일러의 std::initializer_list에 대한 선호도가 굉장히 높기 때문에, 추가에 있어 굉장히 조심스럽게 접근해야 합니다.

     

    또한, 중복 적재를 추가해야 할 상황이 존재할 경우 소스 코드 외적으로 문서화를 통해 해결할 수도 있습니다.

    문서화의 대표적인 예시는 std::make_unique와 std::make_shared입니다. 해당 함수들은 내부적으로는 괄호를 사용하고, 이것과 관련된 내용을 인터페이스의 일부에 문서화 해놓았습니다.

     

    std::vector와 같은 경우는 인터페이스 설계가 잘못 된 것이라고 볼 수 있습니다.

    최선의 방법은 클라이언트가 괄호, 중괄호를 구분해서 사용해야 할 일이 없도록 설계하는 것 입니다.

     

    읽어주셔서 감사합니다.

    댓글

Designed by Tistory.