모두의 코드
씹어먹는 C++ - <16 - 1. C++ 유니폼 초기화(Uniform Initialization)>
이번 강좌에서는
균일한 초기화 (Uniform initialization)
초기화자 리스트 (
initializer_list
)
에 대해 다룹니다.
안녕하세요 여러분! 이번 강좌에서는 C++ 11 에서 추가된 기능인 균일한 초기화(Uniform Initialization) 에 대해 살펴보도록 하겠습니다.
아마도 여러분들은 아래와 같은 실수를 한 번쯤 하셨을 것이라 생각합니다.
#include <iostream> class A { public: A() { std::cout << "A 의 생성자 호출!" << std::endl; } }; int main() { A a(); // ? }
성공적으로 컴파일 하였다면
실행 결과
놀랍게도 아무것도 출력되지 않습니다. 왜일까요?
A a(); // ?
왜냐하면 사실은 위 코드가 A 의 객체 a 를 만든것이 아니라, A 를 리턴하고, 인자를 받지 않는 함수 a 를 정의한 것이기 때문입니다. 왜냐하면 C++ 의 컴파일러는 함수의 정의처럼 보이는 것들은 모두 함수의 정의로 해석 하기 때문입니다.
심지어 아래와 같은 코드는 더 헷갈립니다.
#include <iostream> class A { public: A() { std::cout << "A 의 생성자 호출!" << std::endl; } }; class B { public: B(A a) { std::cout << "B 의 생성자 호출!" << std::endl; } }; int main() { B b(A()); // 뭐가 출력될까요? }
성공적으로 컴파일 하였다면
실행 결과
와 같이 아무 것도 출력되지 않습니다.
사실 위 코드를 보면 마치 b 라는 클래스 B 의 객체를 생성하는 것 같이 보이지만, 사실은 인자로 A 를 리턴하고 인자가 없는 함수를 받으며, 리턴 타입이 B 인 함수 b 를 정의한 것입니다.
상당히 골치 아픈 일입니다. 이러한 문제가 발생하는 것은 () 가 함수의 인자들을 정의하는데도 사용되고, 그냥 일반적인 객체의 생성자를 호출하는데에도 사용되기 때문입니다.
따라서 C++ 11 에서는 이러한 문제를 해결하기 위해 균일한 초기화(Uniform Initialization) 라는 것을 도입하였습니다.
균일한 초기화 (Uniform Initialization)
균일한 초기화 문법을 사용하기 위해서는 생성자를 호출하기 위해 ()
를 사용하는 대신에 {}
를 사용하면 끝입니다.
#include <iostream> class A { public: A() { std::cout << "A 의 생성자 호출!" << std::endl; } }; int main() { A a{}; // 균일한 초기화! }
성공적으로 컴파일 하였다면
실행 결과
A 의 생성자 호출!
위와 같이 제대로 생성자가 호출되었음을 알 수 있습니다.
중괄호를 이용해서 생성자를 호출하는 문법은 동일합니다. 그냥 기존에 ()
자리를 {}
로 바꿔주기만 하면 됩니다. 하지만, ()
를 이용한 생성과 {}
를 이용한 생성의 경우 한 가지 큰 차이가 있는데 바로 일부 암시적 타입 변환들을 불허하고 있다는 점입니다.
예를 들어서 아래 코드를 살펴봅시다.
#include <iostream> class A { public: A(int x) { std::cout << "A 의 생성자 호출!" << std::endl; } }; int main() { A a(3.5); // Narrow-conversion 가능 A b{3.5}; // Narrow-conversion 불가 }
컴파일 하였다면 아래와 같은 오류가 발생합니다.
컴파일 오류
test.cc: In function ‘int main()’: test.cc:10:10: error: narrowing conversion of ‘3.5e+0’ from ‘double’ to ‘int’ inside { } [-Wnarrowing] A b{3.5}; // Narrow-conversion 불가 ^ A 의 생성자 호출!
보시다시피
A a(3.5); // Narrow-conversion 가능
위 코드는 성공적으로 컴파일 되었고 x
에는 3.5
의 정수 캐스팅 버전이 3 이 전달됩니다. (한 번 아래 b
생성을 지워보고 실행해보세요.)
반면에
A b{3.5}; // Narrow-conversion 불가
의 경우 위와 같이 double
인 3.5 를 int
로 변환할 수 없다는 오류가 발생하였습니다.
그 이유는 중괄호를 이용해서 생성자를 호출하는 경우 아래와 같은 암시적 타입 변환들이 불가능해집니다. 이들은 전부 데이터 손실이 있는(Narrowing) 변환 입니다.
부동 소수점 타입에서 정수 타입으로의 변환 (우리의 예시지요)
long double
에서double
혹은float
으로의 변환,double
에서float
으로의 변환.정수 타입에서 부동 소수점 타입으로의 변환
등등이 있습니다. 자세한 예시들은 여기 에서 확인할 수 있습니다.
따라서 {}
를 사용하게 된다면, 위와 같이 원하지 않는 타입 캐스팅을 방지해서 미연에 오류를 잡아낼 수 있습니다.
{}
를 이용한 생성의 또 다른 쓰임새로 함수 리턴 시에 굳이 생성하는 객체의 타입을 다시 명시 하지 않아도 됩니다.
#include <iostream> class A { public: A(int x, double y) { std::cout << "A 생성자 호출" << std::endl; } }; A func() { return {1, 2.3}; // A(1, 2.3) 과 동일 } int main() { func(); }
성공적으로 컴파일 하였다면
실행 결과
A 생성자 호출
와 같이 잘 나옵니다. {}
를 이용해서 생성하지 않았더라면 A(1, 2.3)
과 같이 클래스를 명시해줘야만 했지만, {}
를 이용할 경우 컴파일러가 알아서 함수의 리턴타입을 보고 추론해줍니다.
{}
의 쓰임새는 이 뿐만이 아닙니다. 아래를 보시지요.
초기화자 리스트 (Initializer list)
배열을 정의할 때 우리는 다음과 같이 작성하였습니다.
int arr[] = {1, 2, 3, 4};
그렇다면 중괄호를 이용해서 마찬가지 효과를 낼 수 없을까요? 예를 들면
vector<int> v = {1, 2, 3, 4};
와 같이 말이지요. 근데, 놀랍게도 C++ 11 에서 부터 이와 같은 문법을 사용할 수 있게 되었습니다.
#include <iostream> class A { public: A(std::initializer_list<int> l) { for (auto itr = l.begin(); itr != l.end(); ++itr) { std::cout << *itr << std::endl; } } }; int main() { A a = {1, 2, 3, 4, 5}; }
성공적으로 컴파일 하였다면
실행 결과
1 2 3 4 5
와 같이 나옵니다.
initializer_list
는 우리가 {}
를 이용해서 생성자를 호출할 때, 클래스의 생성자들 중에 initializer_list
를 인자로 받는 생성자가 있다면 전달됩니다.
주의 사항
()
를 사용해서 생성자를 호출한다면 intializer_list
가 생성되지 않습니다.
initializer_list
를 이용하면 컨테이너들을 간단하게 정의할 수 있습니다. 예를 들어서
#include <iostream> #include <map> #include <string> #include <vector> template <typename T> void print_vec(const std::vector<T>& vec) { std::cout << "["; for (const auto& e : vec) { std::cout << e << " "; } std::cout << "]" << std::endl; } template <typename K, typename V> void print_map(const std::map<K, V>& m) { for (const auto& kv : m) { std::cout << kv.first << " : " << kv.second << std::endl; } } int main() { std::vector<int> v = {1, 2, 3, 4, 5}; print_vec(v); std::cout << "----------------------" << std::endl; std::map<std::string, int> m = { {"abc", 1}, {"hi", 3}, {"hello", 5}, {"c++", 2}, {"java", 6}}; print_map(m); }
성공적으로 컴파일 하였다면
실행 결과
[1 2 3 4 5 ] ---------------------- abc : 1 c++ : 2 hello : 5 hi : 3 java : 6
와 같이 나옵니다.
std::vector<int> v = {1, 2, 3, 4, 5};
vector
의 경우 생각했던대로, vector
의 원소들을 그냥 나열해주면 됩니다. 마치 이전에 배열을 정의할 때 처럼 말이지요.
std::map<std::string, int> m = { {"abc", 1}, {"hi", 3}, {"hello", 5}, {"c++", 2}, {"java", 6}};
map
의 경우도 비슷합니다. map
의 경우 vector
와는 다르게 pair<Key, Value>
원소들을 초기화자 리스트의 원소들로 받습니다. pair
는 C++ STL 에서 지원하는 간단한 클래스로 그냥 두 개의 원소를 보관하는 객체라고 보시면 됩니다. map
에는 pair
의 첫 번째 원소로 키를, 두 번째 원소로 값을 전달해주면 됩니다.
initializer_list 사용 시 주의할 점
생성자들 중에서 initializer_list
를 받는 생성자가 있다면 한 가지 주의해야할 점이 있습니다. 만일 {}
를 이용해서 객체를 생성할 경우 생성자 오버로딩 시에 해당 함수가 최우선 으로 고려된다는 점입니다.
예를 들어서 vector
의 경우 아래와 같은 형태의 생성자가 존재합니다.
vector(size_type count);
이 생성자는 count 개수 만큼의 원소 자리를 미리 생성해놓습니다. 그렇다면
vector v{10};
은 해당 생성자를 호출할까요? 아닙니다. 그냥 원소 1 개 짜리 intializer_list
라고 생각해서 10
을 보관하고 있는 벡터를 생성하게 됩니다.
따라서, 이러한 불상사를 막기 위해서는 {}
로 생성하기 보다는 ()
를 이용해서
vector v(10);
과 같이 v 를 생성한다면 우리가 원하는 생성자를 호출할 수 있게 됩니다.
initializer_list
를 받는 생성자가 최우선적으로 고려된다는 말은, 컴파일러가 최선을 다해서 해당 생성자와 매칭시키려고 노력한다는 의미 입니다. 예를 들어서 아래와 같은 코드를 살펴봅시다.
#include <initializer_list> #include <iostream> class A { public: A(int x, double y) { std::cout << "일반 생성자! " << std::endl; } A(std::initializer_list<int> lst) { std::cout << "초기화자 사용 생성자! " << std::endl; } }; int main() { A a(3, 1.5); // Good A b{3, 1.5}; // Bad! }
컴파일 하였다면 아래와 같은 오류가 발생합니다.
컴파일 오류
test.cc: In function ‘int main()’: test.cc:15:13: error: narrowing conversion of ‘1.5e+0’ from ‘double’ to ‘int’ inside { } [-Wnarrowing] A b{3, 1.5}; // Bad! ^
일단 보시다시피
A a(3, 1.5); // Good
이 문장은 아무런 문제가 없습니다. () 를 이용해서 생성자를 호출하였기 때문에, A 의 첫 번째 생성자인 일반 생성자가 호출됩니다.
하지만 그 다음 문장을 살펴봅시다.
A b{3, 1.5}; // Bad!
앞서 C++ 컴파일러는 {}
를 이용해서 생성자를 호출하였을 경우 initializer_list
를 받는 생성자를 최우선으로 고려한다고 하였습니다. 따라서, 컴파일러는 initializer_list
를 이용하도록 최대한 노력하려고 하는데, 1.5 는 int
가 아니지만, double
에서 int
로 암시적 변환을 할 수 있으므로 이를 택하게 됩니다.
그런데 문제는 앞서 {}
는 데이터 손실이 있는 변환을 할 수 없다고 하였습니다. 그런데 double
에서 int
로의 타입 변환은 데이터 손실이 있는 변환이므로, 오류가 발생하게 됩니다. 사실 A(int x, double y)
이 생성자가 좀 더 나은 매칭이지만, C++ 컴파일러는 initializer_list
를 이용한 생성자를 최대한 고려하려고 합니다. 이러한 문제가 발생하지 않는 경우는 initializer_list
의 원소 타입으로 타입 변환 자체가 불가능한 경우여야만 합니다.
#include <initializer_list> #include <iostream> #include <string> class A { public: A(int x, double y) { std::cout << "일반 생성자! " << std::endl; } A(std::initializer_list<std::string> lst) { std::cout << "초기화자 사용 생성자! " << std::endl; } }; int main() { A a(3, 1.5); // 일반 A b{3, 1.5}; // 일반 A c{"abc", "def"}; // 초기화자 }
성공적으로 컴파일 하였다면
실행 결과
일반 생성자! 일반 생성자! 초기화자 사용 생성자!
와 같이 잘 나옵니다. 위 경우 int
나 double
이 string 으로 변환될 수 없기 때문에 initializer_list
를 받는 생성자는 아예 고려 대상에서 제외됩니다.
initializer_list 와 auto
만일 {}
를 이용해서 생성할 때 타입으로 auto
를 지정한다면 initializer_list
객체가 생성됩니다. 예를 들어서
auto list = {1, 2, 3};
을 하게 되면 list
는 initializer_list<int>
가 되겠지요.
그렇다면 아래는 어떨까요? 참고로 이 예제는 여기에서 가져왔습니다.
auto a = {1}; // std::initializer_list<int> auto b{1}; // std::initializer_list<int> auto c = {1, 2}; // std::initializer_list<int> auto d{1, 2}; // std::initializer_list<int>
상식적으로 적어도 b
는 int
로 추론되어야 할 것 같지만, C++ 11 에서는 위 a
, b
, c
, d
모두 std::initializer_list<int>
로 정의됩니다.
하지만 이는 꽤 비상식적이기 때문에 C++ 17 부터 아래와 같이 두 가지 형태로 구분해서 auto
타입이 추론됩니다.
auto x = {arg1, arg2...}
형태의 경우arg1
,arg2
... 들이 모두 같은 타입이라면x
는std::initializer_list<T>
로 추론됩니다.auto x {arg1, arg2, ...}
형태의 경우 만일 인자가 단 1 개라면 인자의 타입으로 추론되고, 여러 개일 경우 오류를 발생시킵니다.
따라서 C++ 17 부터는 아래와 같습니다.
auto a = {1}; // 첫 번째 형태이므로 std::initializer_list<int> auto b{1}; // 두 번째 형태 이므로 그냥 int auto c = {1, 2}; // 첫 번째 형태이므로 std::initializer_list<int> auto d{1, 2}; // 두 번째 형태 인데 인자가 2 개 이상이므로 컴파일 오류
좀 더 직관적으로 바뀐 것을 알 수 있습니다.
유니폼 초기화와 auto
를 같이 사용 할 때 또 한 가지 주의할 점은, 문자열을 다룰 때
auto list = {"a", "b", "cc"};
를 하게 된다면 list
는 initializer_list<std::string>
이 아닌 initializer_list<const char*>
이 된다는 점입니다. 물론 이 문제는 C++ 14 에서 추가된 리터럴 연산자를 통해 해결할 수 있습니다.
using namespace std::literals; // 문자열 리터럴 연산자를 사용하기 위해 // 추가해줘야함. auto list = {"a"s, "b"s, "c"s};
와 같이 하면, initializer_list<std::string>
으로 추론됩니다.
자 그럼 이것으로 이번 강좌를 마무리 하도록 하겠습니다. 다음 강좌에서는 C++ 11 에서 추가된 constexpr
와 가변 길이 템플릿에 대해 다루어보도록 하겠습니다.
뭘 배웠지?
유니폼 초기화 ({}
를 이용한 생성자 호출) 를 통해서 인자 없는 생성자가 함수의 정의로 오해되는 일을 막을 수 있으며 initializer_list
를 만들어 전달할 수 있습니다.
initializer_list
를 통해서 객체를 간단하게 생성할 수 있습니다.

댓글을 불러오는 중입니다..