-
[Effective Modern C++] 괄호 ()와 중괄호 {} 그리고 Uniform initializationC++/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;
위 코드가 호출하는 함수는 아래와 같습니다.
- 기본 생성자를 호출합니다.
- 복사 생성자를 호출합니다
- 복사 배정 연산자 (operator=)를 호출합니다.
배정과 초기화에 대한 구체적인 차이에 대하여는 stack overflow에 관련된 글이 있어 첨부하도록 하겠습니다.
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와 같은 경우는 인터페이스 설계가 잘못 된 것이라고 볼 수 있습니다.
최선의 방법은 클라이언트가 괄호, 중괄호를 구분해서 사용해야 할 일이 없도록 설계하는 것 입니다.
읽어주셔서 감사합니다.
'C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] using과 typedef (0) 2022.04.05 [Effective Modern C++] 0과 NULL과 nullptr (0) 2022.03.30 [Effective Modern C++] auto와 std::vector, 그리고 Proxy pattern (0) 2022.02.14 [Effective Modern C++] auto의 사용을 고려해야 할 상황들 (0) 2022.01.10 [Effective Modern C++] Type deduction : typeid, boost/type_index (0) 2022.01.06