본문 바로가기
PROGRAMMING/Effective Modern C++

10.범위 없는 enum보다 범위 있는 enum을 선호하라.

by La-KanTo 2020. 12. 25.

- C++98 스타일의 enum을 이제는 범위없는 enum이라고 부른다.

C++98 스타일의 enum으로 선언된 열거자(enumerator)들에 대해서는 "한 중괄호 쌍 안에서 어떤 이름을 선언하면 그 이름의 가시성은 해당 중괄호 쌍이 정의하는 범위로 한정된다"는 일반적인 규칙이 적용되지 않는다.

그런 열거자 이름들은 enum을 포함하는 범위에 속하며, 따라서 그 범위에 같은 이름이 있으면 안 된다.이러한 이유로 C++98 스타일의 enum을 범위 없는(unscoped) enum이라고 부른다.

enum Color { black, white, red };
// black, white, red는
// Color가 속한 범위에 속함

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

 

- 범위 있는 enum의 열거자들은 그 안에서만 보인다. 이 열거자들은 오직 캐스팅을 통해서만 다른 타입으로 변환된다.

C++11의 새로운 열거형인 범위 있는 enum은 enum class 라는 구문으로 선언한다는 점 때문에 enum 클래스라고 부르기도 한다.

 

범위 있는 enum은 이름 누수가 발생하지 않는다.

enum class Color { black, white, red }; // black, white, red는 olor 범위에 속함
auto white = false; // OK: 이 범위에 다른 "white"는 없음
Color c = white; // 오류! 이 범위에 "white"라는 이름의 열거자가 없음
Color c = Color::white; // OK
auto c = Color::white; // OK

이름공간(namespace) 오염을 줄여줄 뿐만 아니라 열거자들에 타입이 훨씬 강력하게 적용된다는 장점도 있다.


범위 없는 enum의 열거자들은 암묵적으로 정수 타입으로 변환된다.

그리고 정수 타입으로부터 암묵적으로 부동소수점 타입으로 변환된다.

enum Color { black, white, red };// 범위 없는 enum

std::vector<std::size_t> // x의 소인수들을 돌려주는 함수
primeFactors(std::size_t x); 

Color c = red;

if (c < 14.5) {
    auto factors = primeFactors(c);
}

 

그러나 "enum" 다음에 그냥 "class"만 붙이면, 그래서 범위 없는 enum을 범위 있는 enum으로 바꾸면, 이야기가 아주 달라진다.
범위 있는 enum의 열거자는 암묵적으로 다른 타입으로 변환되지 않기 때문이다.

enum Color { black, white, red };// 범위 있는 enum

std::vector<std::size_t> // x의 소인수들을 돌려주는 함수
primeFactors(std::size_t x); 

Color c = red;

if (c < 14.5) { // 오류. Color와 double을 묵시적 변환 후 비교할 수 없음
    auto factors = primeFactors(c); // 오류. std::size_t 로 변환 할 수 없음
}

이런 이유로 Color를 다른 타입으로 변환하고 싶으면, 타입 시스템을 원하는 대로 굴복시킬 때 항상 사용하는 바로 그것, 즉 캐스팅을 사용하면 된다.

if (static_cast<double>(c) < 14.5) {// 이상한 코드이지만 유효함
    auto factors = primeFactors(static_cast<std::size_t>(c));    // 컴파일은 됨
}

 

범위 있는 enum의 이런 장점들 때문에, 범위 없는 enum은 더 이상 필요하지 않다고 생각할 수도 있지만, 놀랍게도 범위 없는 enum이 유용한 상황이 적어도 하나는 존재한다.
바로, C++11의 std::tuple 안에 있는 필드들을 지칭할 때이다.

예를 들어 어떤 소셜 네트워크 웹사이트의 사용자 이름과 이메일 주소, 그리고 평판치 (reputation value)를 담는 다음과 같은 튜플(tuple)이 있다고 하자.

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

주석에는 튜플의 각 필드가 뜻하는 바가 나와 있지만, 다른 어떤 소스 파일에서 다음과 같은 코드와 마주친다면 해당 필드가 무엇을 뜻하는지를 바로 알기 힘들다.

UserInfo uInfo
auto val = std::get<1>(uInfo); 

이럴 때 범위 없는 enum으로 필드 번호를 필드 이름에 연관시키면 그런 것들을 일일이 기억할 필요가 없다.

enum UserInfoFields { uiName, uiEmail, uiReputation };

UserInfo uInfo;
auto val = std::get<uiEmail>(uInfo);

 범위 있는 enum 버전은 훨씬 장황하다.

enum class UserInfoFields { uiName, uiEmail, uiReputation };

UserInfo uInfo;
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);

범위 없는 enum을 사용한 코드가 더 간단한 이유는, 범위 없는 enum은 정수 타입으로 암묵적인 변환이 가능하기 때문이다.

 

열거자(범위 있는) 하나를 받아서 그에 해당하는 std::size_t 값을 돌려주는 함수를 작성 한다면 장황함이 줄어들겠지만, 그런 함수를 작성하는 것이 다소 까다롭다.


std::get은 하나의 템플릿이며, 여기에 넘겨주는 것은 함수 인수가 아니라 템플릿 타입 인수이다.
따라서 열거자를 std::size_t로 변환하는 함수는 그 결과를 컴파일 도중에 산출해야 한다.
항목 15에서 설명하겠지만, 그러려면 그 함수는 반드시 constexpr 함수 템플릿이어야 한다.

함수 템플릿인 이유는 어떤 종류의 enum에 대해서도 작동하게 하기 위함인데, 이러한 일반화를 시도한다면, 반환 타입도 일반화할 필요가 있다.

즉, std::size_t를 돌려주는 것이 아니라 enum의 바탕 타입을 돌려주어야 한다.

이 부분은 std::underlying_type 타입 특성을 사용하면 된다(타입 특성에 관해서는 항목 9를 보라).

마지막으로, 그 함수는 noexcept(항목 14 참고) 로 선언해야 한다. 결코 예외를 던지지 않을 것을 알고 있기 때문이다.
다음은 이상의 사항들을 모두 고려해서 만든, 임의의 열거자 하나를 받아서 그 값을 컴파일 시점 상수로서 돌려주는 함수 템플릿 toUType이다.

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에서는 typename std::underlying_type<E>::type을 깔끔한 std::underlying_type_t(항목 9 참고)로 대체해서 toUType을 더욱 단순화할 수 있다.

template <typename E>
constexpr std::underlying_type_t<E>
toUType(E enumerator) noexcept
{
    return static_cast<std::underlying_type_t<E>>(enumerator);
}

C++14가 지원하는 auto 반환 타입(항목 3 참고) 을 적용하면 더욱 깔끔해진다.

template <typename E>
constexpr auto
toUType(E enumerator) noexcept
{
    return static_cast<std::underlying_type_t<E>>(enumerator);
}

어떤 형태로 구현했든, toUType을 이용하면 튜플의 한 필드에 다음과 같이 접근할 수 있다.

auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

범위 없는 enum을 사용할 때보다는 여전히 타자량이 많지만, 그래도 이름공간 오염을 피하고 열거자들과 관련된 의도치 않은 변환이 방지된다는 장점이 있다.
글자 몇 개를 더 타자해야 한다고 해도, 구세대 기술의 함정들을 피하는 것이 더 이득인 경우가 많다.

 

- 범위 있는 enum과 범위 없는 enum 모두 바탕 타입 지정을 지원한다. 범위 있는 enum의 기본 바탕 타입은 int이다. 범위 없는 enum에는 기본 바탕이 없다.

 

범위 있는 enum의 바탕 타입은 기본적으로 int이다.

enum class Status; // 타입은 int

그 기본 타입이 마음에 들지 않는다면 다른 타입을 명시적으로 지정하면 된다.

enum class Status : std::uint32_t; // 타입은 std::uint32_t(<cstdint>에 있음)

범위 없는 enum의 바탕 타입을 지정하는 방법은 범위 있는 enum에 대한 것과 동일하다.
그리고 바탕 타입이 지정된 범위 없는 enum은 전방 선언을 지원한다.

enum Color : std::uint8_t; 

enum을 '정의'할 때에도 바탕 타입을 지정할 수 있다.

enum class Status : std::uint32_t {
    good = 0,
    failed = 1,
    incomplete = 100,
    corrupt = 200,
    audited = 500,
    indeterminate = 0xFFFFFFFF
};

 

- 범위 있는 enum은 항상 전방 선언이 가능하다. 범위 없는 enum 은 해당 선언에 바탕 타입을 지정하는 경우에만 전방 선언이 가능하다.

메모리를 효율적으로 활용하기 위해, 컴파일러들은 주어진 enum의 열거자 값들의 범위를 표현할 수 있는 가장 작은 바탕 타입을 선택하는 경향이 있다.

그러나 경우에 따라서는 컴파일러가 크기 대신 속도를 위한 최적화를 적용하며, 그런 경우에는 허용되는 가장 작은 바탕 타입을 선택하지 않을 수도 있다.그렇다고 해도, 컴파일러는 크기를 위해 최적화를 적용할 수 있길 원한다.

이를 가능하게 하기 위해, C++98은 오직 enum 정의(모든 열거자가 나열된)만 지원하고 enum 선언은 허용하지 않는다.그러면 enum이 실제로 쓰이기 전에 컴파일러가 enum의 바탕 타입을 선택할 수 있기 때문이다.

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

그래서, 범위 없는 enum은 바탕 타입을 명시적으로 지정해 주는 경우에 한해 전방 선언을 할 수 있다.

enum Color: std::uint8_t;
// 범위 없는 enum의 전방 선언
// 바탕 타입은 std::uint8_t

enum Color;
// 오류! 바탕 타입 지정 없이
// 전방 선언 불가능

하지만, 범위 있는 enum의 경우 기본 바탕 타입이 int라고 못박아 두었기 때문에 항상 전방 선언이 가능하다.

enum class Status: std::uint32_t;
// Status의 바탕 타입은
// std::uint32_t

enum class Color;
// OK Color의 바탕 타입은
// 기본 바탕 타입인 int

 

댓글