第14章 强枚举类型(C++11 C++17 C++20)

C++之父本贾尼·斯特劳斯特卢普曾经在他的The Design And Evolution Of C++一书中写道“C enumerations constitute a curiously half-baked concept.”。翻译过来就是“C语言的枚举类型构成了一个奇怪且半生不熟的概念”,可见这位C++之父对于enum类型的现状是不满意的,主要原因是enum类型破坏了C++的类型安全。大多数情况下,我们说C++是一门类型安全的强类型语言,但是枚举类型在一定程度上却是一个例外,具体来说有以下几个方面的原因。

首先,虽然枚举类型存在一定的安全检查功能,一个枚举类型不允许分配到另外一种枚举类型,而且整型也无法隐式转换成枚举类型。但是枚举类型却可以隐式转换为整型,因为C++标准文档提到“枚举类型可以采用整型提升的方法转换成整型”。请看下面的代码示例:

enum School {
  principal,
  teacher,
  student
};

enum Company {
  chairman,
  manager,
  employee
};

int main()
{
  School x = student;
  Company y = manager;
  bool b = student >= manager;    // 不同类型之间的比较操作
  b = x < employee;
  int y = student;                // 隐式转换为int
}

在上面的代码中两个不同类型的枚举标识符studentmanager可以进行比较,这在C++语言的其他类型中是很少看到的。这种比较合法的原因是枚举类型先被隐式转换为整型,然后才进行比较。同样的问题也出现在student直接赋值到int类型变量上的情况中。另外,下面的代码会触发C++对枚举的检查,它们是无法编译通过的:

School x = chairman;    // 类型不匹配,无法通过编译
Company y = student;    // 类型不匹配,无法通过编译
x = 1;                  // 整型无法隐式转换到枚举类型

然后是枚举类型的作用域问题,枚举类型会把其内部的枚举标识符导出到枚举被定义的作用域。也是就说,我们使用枚举标识符的时候,可以跳过对于枚举类型的描述:

School x = student;
Company y = manager;

无论是初始化x,还是初始化y,我们都没有对studentmanager的枚举类型进行描述。因为它们已经跳出了SchoolCompany。在我们看到的第一个例子中,这没有什么问题,两种类型相安无事。但是如果遇到下面的这种情况就会让人头痛了:

enum HighSchool {
  student,
  teacher,
  principal
};

enum University {
  student,
  professor,
  principal
};

HighSchoolUniversity都有studentprincipal,而枚举类型又会将其枚举标识符导出到定义它们的作用域,这样就会发生重复定义,无法通过编译。解决此类问题的一个办法是使用命名空间,例如:

enum HighSchool {
  student,
  teacher,
  principal
};

namespace AcademicInstitution
{
enum University {
  student,
  professor,
  principal
};
}

这样一来,University的枚举标识符就会被导出到AcademicInstitution的作用域,和HighSchool的全局作用域区分开来。

对于上面两个问题,有一个比较好但并不完美的解决方案,代码如下:

#include <iostream>

class AuthorityType {
 enum InternalType
 {
     ITBan,
     ITGuest,
     ITMember,
     ITAdmin,
     ITSystem,
 };

 InternalType self_;

public:
 AuthorityType(InternalType self) : self_(self) {}

 bool operator < (const AuthorityType &other) const
 {
     return self_ < other.self_;
 }

 bool operator > (const AuthorityType &other) const
 {
     return self_ > other.self_;
 }

 bool operator <= (const AuthorityType &other) const
 {
     return self_ <= other.self_;
 }

 bool operator >= (const AuthorityType &other) const
 {
     return self_ >= other.self_;
 }

 bool operator == (const AuthorityType &other) const
 {
     return self_ == other.self_;
 }

 bool operator != (const AuthorityType &other) const
 {
     return self_ != other.self_;
 }

 const static AuthorityType System, Admin, Member, Guest, Ban;
};

#define DEFINE_AuthorityType(x) const AuthorityType \
 AuthorityType::x(AuthorityType::IT ## x)
DEFINE_AuthorityType(System);
DEFINE_AuthorityType(Admin);
DEFINE_AuthorityType(Member);
DEFINE_AuthorityType(Guest);
DEFINE_AuthorityType(Ban);

int main()
{
 bool b = AuthorityType::System > AuthorityType::Admin;
 std::cout << std::boolalpha << b << std::endl;
}

让我们先看一看以上代码的优点。

  将枚举类型变量封装成类私有数据成员,保证无法被外界访问。访问枚举类型的数据成员必须通过对应的常量静态对象。另外,根据C++标准的约束,访问静态对象必须指明对象所属类型。也就是说,如果我们想访问ITSystem这个枚举标识符,就必须访问常量静态对象System,而访问System对象,就必须说明其所属类型,这使我们需要将代码写成AuthorityType:: System才能编译通过。

  由于我们实现了比较运算符,因此可以对枚举类型进行比较。但是比较运算符函数只接受同类型的参数,所以只允许相同类型进行比较。

当然很明显,这样做也有缺点。

  最大的缺点是实现起来要多敲很多代码。

  枚举类型本身是一个POD类型,而我们实现的类破坏了这种特性。

还有一个严重的问题是,无法指定枚举类型的底层类型。因此,不同的编译器对于相同枚举类型可能会有不同的底层类型,甚至有无符号也会不同。来看下面这段代码:

enum E {
 e1 = 1,
 e2 = 2,
 e3 = 0xfffffff0
};

int main()
{
 bool b = e1 < e3;
 std::cout << std::boolalpha << b << std::endl;
}

读者可以思考一下,上面这段代码的输出结果是什么?答案是不同的编译器会得到不同的结果。在GCC中,结果返回true,我们可以认为E的底层类型为unsigned int。如果输出e3,会发现其值为4294967280。但是在MSVC中结果输出为false,很明显在编译器内部将E定义为了int类型,输出e3的结果为−16。这种编译器上的区别会使在编写跨平台程序时出现重大问题。

虽然说了这么多枚举类型存在的问题,但是我这里想强调一个观点,如果代码中有需要表达枚举语义的地方,还是应该使用枚举类型。原因就是在第一个问题中讨论的,枚举类型还是有一定的类型检查能力。我们应该避免使用宏和const int的方法去实现枚举,因为其缺点更加严重。

值得一提的是,枚举类型缺乏类型检查的问题倒是成就了一种特殊用法。如果读者了解模板元编程,那么肯定见过一种被称为enum hack的枚举类型的用法。简单来说就是利用枚举值在编译期就能确定下来的特性,让编译器帮助我们完成一些计算:

#include <iostream>
template<int a, int b>
struct add {
    enum {
        result = a + b
    };
};

int main()
{
    std::cout << add<5, 8>::result << std::endl;
}

用GCC查看其GIMPLE的中间代码:

main ()
{
  int D.39267;
  _1 = std::basic_ostream<char>::operator<< (&cout, 13);
  std::basic_ostream<char>::operator<< (_1, endl);
  D.39267 = 0;
  return D.39267;
}

可以看到add<5, 8>::result在编译器编译代码的时候就已经计算出来了,运行时直接使用<<运算符输出结果13。

由于枚举类型确实存在一些类型安全的问题,因此C++标准委员会在C++11标准中对其做出了重大升级,增加了强枚举类型。另外,为了保证老代码的兼容性,也保留了枚举类型之前的特性。强枚举类型具备以下3个新特性。

1.枚举标识符属于强枚举类型的作用域。

2.枚举标识符不会隐式转换为整型。

3.能指定强枚举类型的底层类型,底层类型默认为int类型。

定义强枚举类型的方法非常简单,只需要在枚举定义的enum关键字之后加上class关键字就可以了。下面将HighSchoolUniversity改写为强枚举类型:

#include <iostream>

enum class HighSchool {
    student,
    teacher,
    principal
};

enum class University {
    student,
    professor,
    principal
};

int main()
{
    HighSchool x = HighSchool::student;
    University y = University::student;
    bool b = x < HighSchool::headmaster;
    std::cout << std::boolalpha << b << std::endl;
}

观察上面的代码可以发现,首先,在不使用命名空间的情况下,两个有着相同枚举标识符的强枚举类型可以在一个作用域内共存。这符合强枚举类型的第一个特性,其枚举标识符属于强枚举类型的作用域,无法从外部直接访问它们,所以在访问时必须加上枚举类型名,否则会编译失败,如HighSchool::student。其次,相同枚举类型的枚举标识符可以进行比较,但是不同枚举类型就无法比较其枚举标识符了,因为它们失去了隐式转换为整型的能力,这一点符合强枚举类型的第二个特性:

HighSchool x = student;              // 编译失败,找不到student的定义
bool b = University::student < HighSchool::headmaster;// 编译失败,比较的类型不同
int y = University::student;         // 编译失败,无法隐式转换为int类型

有了这两个特性的支持,强枚举类型就可以完美替代14.1节中实现的AuthorityType类,强枚举类型不仅实现起来非常简洁,而且还是POD类型。

对于强枚举类型的第三个特性,我们可以在定义类型的时候使用:符号来指明其底层类型。利用它可以消除不同编译器带来的歧义:

enum class E : unsigned int {
    e1 = 1,
    e2 = 2,
    e3 = 0xfffffff0
};

int main()
{
    bool b = e1 < e3;
    std::cout << std::boolalpha << b << std::endl;
}

上面这段代码明确指明了枚举类型E的底层类型是无符号整型,这样一来无论使用GCC还是MSVC,最后返回的结果都是true。如果这里不指定具体的底层类型,编译器会使用int类型。但GCC和MSVC的行为又出现了一些区别:MSVC会编译成功,e3被编译为一个负值;而GCC则会报错,因为0xfffffff0超过了int能表达的最大正整数范围。

在C++11标准中,我们除了能指定强枚举类型的底层类型,还可以指定枚举类型的底层类型,例如:

enum E : unsigned int {
    e1 = 1,
    e2 = 2,
    e3 = 0xfffffff0
};

int main()
{
    bool b = e1 < e3;
    std::cout << std::boolalpha << b << std::endl;
}

另外,虽然我们多次强调了强枚举类型的枚举标识符是无法隐式转换为整型的,但还是可以通过static_cast对其进行强制类型转换,但我建议不要这样做。最后说一点,强枚举类型不允许匿名,我们必须给定一个类型名,否则无法通过编译。

从C++17标准开始,对有底层类型的枚举类型对象可以直接使用列表初始化。这条规则适用于所有的强枚举类型,因为它们都有默认的底层类型int,而枚举类型就必须显式地指定底层类型才能使用该特性:

enum class Color {
  Red,
  Green,
  Blue
};
int main()
{
  Color c{ 5 };          // 编译成功
  Color c1 = 5;          // 编译失败
  Color c2 = { 5 };      // 编译失败
  Color c3(5);           // 编译失败
}

在上面的代码中,c可以在C++17环境下成功编译运行,因为Color有默认底层类型int,所以能够通过列表初始化对象,但是c1c2c3就没有那么幸运了,它们的初始化方法都是非法的。同样的道理,下面的代码能编译通过:

enum class Color1 : char {};
enum Color2 : short {};

int main()
{
  Color1 c{ 7 };
  Color2 c1{ 11 };
  Color2 c2 = Color2{ 5 };
}

请注意,虽然Color2 c2 = Color2{ 5 }Color c2 = { 5 }在代码上有些类似,但是其含义是完全不同的。对于Color2 c2 = Color2{ 5 }来说,代码先通过列表初始化了一个临时对象,然后再赋值到c2,而Color c2 = { 5 }则没有这个过程。另外,没有指定底层类型的枚举类型是无法使用列表初始化的,比如:

enum Color3 {};

int main()
{
  Color3 c{ 7 };
}

以上代码一定会编译报错,因为无论是C++17还是在此之前的标准,Color3都没有底层类型。同所有的列表初始化一样,它禁止缩窄转换,所以下面的代码也是不允许的:

enum class Color1 : char {};

int main()
{
  Color1 c{ 7.11 };
}

到此为止,读者应该都会有这样一个疑问,C++11标准中对强枚举类型初始化做了严格限制,目的就是防止枚举类型的滥用。可是C++17又打破了这种严格的限制,我们似乎看不出这样做的好处。实际上,让有底层类型的枚举类型支持列表初始化的确有一个十分合理的动机。

现在假设一个场景,我们需要一个新整数类型,该类型必须严格区别于其他整型,也就是说不能够和其他整型做隐式转换,显然使用typedef的方法是不行的。另外,虽然通过定义一个类的方法可以到达这个目的,但是这个方法需要编写大量的代码来重载运算符,也不是一个理想的方案。所以,C++的专家把目光投向了有底层类型的枚举类型,其特性几乎完美地符合以上要求,除了初始化整型值的时候需要用到强制类型转换。于是,C++17为有底层类型的枚举类型放宽了初始化的限制,让其支持列表初始化:

#include <iostream>
enum class Index : int {};

int main()
{
  Index a{ 5 };
  Index b{ 10 };
  // a = 12;
  // int c = b;
  std::cout << "a < b is " 
       << std::boolalpha 
       << (a < b) << std::endl;
}

在上面的代码中,定义了Index的底层类型为int,所以可以使用列表初始化ab,由于ab的枚举类型相同,因此所有a < b的用法也是合法的。但是a = 12int c = b无法成功编译,因为强枚举类型是无法与整型隐式相互转换的。

最后提示一点,在C++17的标准库中新引入的std::byte类型就是用这种方法定义的。

C++20标准扩展了using功能,它可以打开强枚举类型的命名空间。在一些情况下,这样做会让代码更加简洁易读,例如:

enum class Color {
  Red,
  Green,
  Blue
};

const char* ColorToString(Color c)
{
  switch (c)
  {
  case Color::Red: return "Red";
  case Color::Green: return "Green";
  case Color::Blue: return "Blue";
  default:
       return "none";
  }
}

在上面的代码中,函数ColorToString中需要不断使用Color::来指定枚举标识符,这显然会让代码变得冗余。通过using我们可以简化这部分代码:

const char* ColorToString(Color c)
{
  switch (c)
  {
  using enum Color;
  case Red: return "Red";
  case Green: return "Green";
  case Blue: return "Blue";
  default:
       return "none";
  }
}

以上代码使用using enum Color;Color中的枚举标识符引入swtich-case作用域。请注意,swtich-case作用域之外依然需要使用Color::来指定枚举标识符。除了引入整个枚举标识符之外,using还可以指定引入的标识符,例如:

const char* ColorToString(Color c)
{
  switch (c)
  {
  using Color::Red;
  case Red: return "Red";
  case Color::Green: return "Green";
  case Color::Blue: return "Blue";
  default:
       return "none";
  }
}

以上代码使用using Color::Red;Red引入swtich-case作用域,其他枚举标识符依然需要使用Color::来指定。

本章介绍的强枚举类型不仅修正了枚举类型的缺点并且全面地扩展了枚举类型的特性。在编程过程中应该总是优先考虑强枚举类型,这样让我们更容易在编译期发现枚举类型上的疏漏,从而更早修复这些问题。