모두의 코드
씹어먹는 C++ - <16 - 1. C++ 유니폼 초기화(Uniform Initialization)>

작성일 : 2019-06-23 이 글은 29691 번 읽혔습니다.

이번 강좌에서는

  • 균일한 초기화 (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"};  // 초기화자
}

성공적으로 컴파일 하였다면

실행 결과

일반 생성자! 
일반 생성자! 
초기화자 사용 생성자! 

와 같이 잘 나옵니다. 위 경우 intdoublestring 으로 변환될 수 없기 때문에 initializer_list 를 받는 생성자는 아예 고려 대상에서 제외됩니다.

initializer_list 와 auto

만일 {} 를 이용해서 생성할 때 타입으로 auto 를 지정한다면 initializer_list 객체가 생성됩니다. 예를 들어서

auto list = {1, 2, 3};

을 하게 되면 listinitializer_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>

상식적으로 적어도 bint 로 추론되어야 할 것 같지만, C++ 11 에서는 위 a, b, c, d 모두 std::initializer_list<int> 로 정의됩니다.

하지만 이는 꽤 비상식적이기 때문에 C++ 17 부터 아래와 같이 두 가지 형태로 구분해서 auto 타입이 추론됩니다.

  • auto x = {arg1, arg2...} 형태의 경우 arg1, arg2 ... 들이 모두 같은 타입이라면 xstd::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"};

를 하게 된다면 listinitializer_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 를 통해서 객체를 간단하게 생성할 수 있습니다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <씹어먹는 C++ - <16 - 1. C++ 유니폼 초기화(Uniform Initialization)>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 14 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

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