KoreanFoodie's Study

Effective Modern C++ | 항목 10 : 범위 없는 enum 보다 범위 있는 enum 을 선호하라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 10 : 범위 없는 enum 보다 범위 있는 enum 을 선호하라

GoldGiver 2022. 10. 26. 09:52

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 10 : 범위 없는 enum 보다 범위 있는 enum 을 선호하라

핵심 :

1. C++98 스타일의 enum 을 이제는 범위 없는 enum 이라고 부른다.
2. 범위 있는 enum 의 열거자들은 그 안에서만 보인다. 이 열거자들은 오직 캐스팅을 통해서만 다른 형식으로 변환된다.
3. 범위 있는 enum 과 범위 없는 enum 모두 바탕 형식 지정을 지원한다. 범위 있는 enum 의 기본 바탕 형식은 int 이다. 범위 없는 enum 에는 기본 바탕 형식이 없다.
4. 범위 있는 enum 은 항상 전방 선언이 가능하다. 범위 없는 enum 은 해당 선언에 바탕 형식을 지정하는 경우에만 전방 선언이 가능하다.

 

범위 없는(unscoped) enum 의 경우, enum 내부에서 선언된 변수는 자신을 정의하는 enum 의 범위로 새어 나간다. 예시를 보자.

enum Color { black, white, red };

// 오류! 이 범위에 이미 white 가 선언되어 있음
auto white = false;

이에 대응되는 C++11 의 새로운 열거형인 범위 있는(scoped) enum 에서는 그러한 이름 누수가 발생하지 않는다.

enum class Color { black, white, red };

// 이 범위에 다른 "white" 는 없음
auto white = false;

// 오류! 이 범위에 "white" 라는 이름의 열거자는 없음
Color c = white;

Color c = Color::white;

auto c = Color::white;

"enum class" 라는 구문으로 선언한다는 점 때문에, 범위 있는 enum 을 enum 클래스라고 부르기도 한다.

enum class 는 범위를 오염시키지 않는 장점 뿐만 아니라, 범위 없는 enum 의 열거자들이 암묵적으로 정수 형식으로 변환된다는, 잠재적인 재앙을 방지한다. 예시를 보자.

/* 범위 없는 enum 을 쓰는 경우 */
enum Color { black, white, red };

std::vector<std::size_t> primeFactors(std::size_t x);

Color c = red;

// ?? Color 를 double 과 비교!
if (c < 14.5) {
  // ??? Color 의 소인수들을 계산!
  auto factors = primeFactors(c);
}


/* 범위 있는 enum 을 쓰는 경우 */
enum class Color { black, white, red };

std::vector<std::size_t> primeFactors(std::size_t x);

Color c = Color::red;

// 에러! Color 와 double 을 비교할 수 없음
if (c < 14.5) {
  // 에러! std::size_t 에 Color 를 전달할 수 없음
  auto factors = primeFactors(c);
}

범위 없는 enum 의 경우, 다음과 고치면 정말로 이상하지만 컴파일은 된다! 다시금 강조하지만, 정말 이상하긴 하다.

/* 범위 없는 enum 을 쓰는 경우 */
enum Color { black, white, red };

std::vector<std::size_t> primeFactors(std::size_t x);

Color c = red;

// 컴파일은 된다, 컴파일은...
if (static_cast<double>(c) < 14.5) {
  // 마찬가지로 컴파일은 된다...
  auto factors = primeFactors(static_cast<std::size_t>(c));
}

 

추가적으로, enum class 의 경우 enum 과 다르게 전방 선언이 가능하다는 장점이 있다.

사실 C++11 에서는 범위 없는 enum 도 전방 선언이 가능하긴 하다.

enum Color { black, white, red };

위 코드는 표현해야 할 값이 3 개 뿐이므로 컴파일러는 바탕 형식(underlying type) 으로 char 을 선택해야 할 것이다. 하지만 다음과 같이 범위가 훨씬 큰 enum 은 어떨까?

enum Status { 
  good = 0;
  failed = 1,
  incomplete = 100,
  corrupt = 200,
  indeterminate = 0xFFFFFFFF
};

enum 이 나타내는 값들의 범위는 0 에서 0xFFFFFFFF 까지이다. char 가 적어도 32 비트인 경우를 제외하면, Status 값을 표현하기 위해 컴파일러는 char 보다 큰 정수 형식을 선택해야 한다. 메모리의 효율적 사용을 위해, 컴파일러들은 enum 값들을 표현할 수 있는 가장 작은 바탕 형식을 선택하는 경향이 있다. 그러나 경우에 따라서는 컴파일러가 크기 대신 속도를 위한 최적화를 적용하며, 이를 위해 C++98 은 오직 enum 의 정의만 지원하고 선언은 허용하지 않는다. enum 이 실제로 쓰이기 전에 컴파일러가 enum 의 바탕 형식을 선택할 수 있기 때문이다.

그러나 enum 을 전방 선언할 수 없으면 몇 가지 단점이 생기는데, 가장 주목할 만한 것은 아마도 컴파일 의존 관계가 늘어난다는 것이다.

enum test {
  a = 0;
  // b = 0; 을 추가?
};

즉 전방 선언이 불가능할 경우, 위와 같이 변수 하나를 추가했을때 전체 시스템 중 해당 enum 을 의존하는 모든 파일을 다시 컴파일해야 한다. 하지만 전방 선언이 가능할 경우, 해당 enum 의 정의가 바뀌어도 이 선언들을 담을 헤더는 다시 컴파일 할 필요가 없고, 기존 구현에 영향을 미치지 않는다면 구현부도 역시 다시 컴파일할 필요가 없다.

 

바탕 형식은 다음과 같이 지정할 수 있다. 범위 있는 enum 의 바탕 형식은 기본적으로 int 이다. 또한, 바탕 형식이 지정된 범위 없는 enum 은 전방 선언을 지원한다!

// 바탕 형식은 int
enum class EStatus;

// 바탕 형식 새로 지정
enum class EStatus: std::uint32_t; 
// 정의할 때도 지정 가능
enum class EStatus: std::uint32_t { a = 0 };

// 범위 없는 enum 바탕 형식 지정 (이제 전방 선언이 가능)
enum EStatus: std::uint8_t;

 

그런데 다음과 같은 경우에는 실제로 enum 이 유용할 때가 있다. 바로 C++11 의 std::tuple 안에 있는 필드들을 지칭할 때이다.

using UserInfo =
  std::tuple<
  std::string,
  std::string,
  std::size_t>;

enum UserInfoFields { uiName, uiEmail, uiReputation };

int main()
{
  UserInfo uInfo;

  // 필드 1 이 어떤 형식인지 알기 힘들다
  auto val = std::get<1>(uInfo);

  // 이메일 필드의 값을 얻음이 명확함
  auto valWithEnum = std::get<uiEmail>(uInfo);
}

위의 경우, 범위 없는 enum 을 쓰면 어떤 필드의 값을 얻는지가 명확히 보이게 된다. enum class 를 사용하면 코드가 조금 복잡해진다.

enum class UserInfoFields2 { uiName, uiEmail, uiReputation };

...

// enum class 사용시 코드가 복잡해짐
auto val3 = std::get<static_cast<std::size_t>(UserInfoFields2::uiEmail)>(uInfo);

물론 열거자를 받아 std::size_t 를 돌려주는 함수를 작성할 수는 있다. 그런데 std::get 은 템플릿이며, 여기에 넘겨주는 것은 함수 인수가 아니라 템플릿 인수이다. 따라서 열거자를 std::size_t 가 아닌, '해당 enum 의 바탕 형식' 으로 반환해야 하며, 이 함수는 결과를 컴파일 도중에 산출해야 한다(constexpr 함수이어야 함). std::underlying_type 으로 형식 특질을 사용해야 하고, noexcept 로 선언해야 한다. 결코 예외를 던지지 않을 것을 알고 있기 때문이다. 코드는 다음과 같다.

// C++ 11 버전
template<typename E>
constexpr typename std::underlying_type<E>::type 
  toUType(E enumerator) noexcept
{
  return static_cast<typename std::underlying_type<E>::type>(enumerator);
}

// C++ 14 버전
template<typename E>
constexpr std::underlying_type_t<E>
  toUType2(E enumerator) noexcept
{
  return static_cast<std::underlying_type_t<E>>(enumerator);
}

// C++ 14 버전, auto 사용
template<typename E>
constexpr auto
  toUType3(E enumerator) noexcept
{
  return static_cast<std::underlying_type_t<E>>(enumerator);
}

...

// 적용 예시
auto val4 = std::get<toUType(UserInfoFields2::uiEmail)>(uInfo);

위의 경우, 코드가 조금 길긴 하지만, 그래도 이름공간 오염을 피하고 열거자들과 관련된 의도치 않은 변환이 방지된다는 장점이 있다!

Comments