C++11:使用元编程实现支持反射的非侵入性枚举类






4.93/5 (6投票s)
获取支持反射的 C++ 枚举的干净方法。
这两个项目都需要安装 Boost 1.48,第一个项目还需要安装 Google Testing Framework 1.6。第一个项目更像是一个单元测试,您可以在其中自然地看到大多数功能在运行,而第二个项目更像是一个简单的测试项目,可能更容易理解!
目录
引言
本文解决了 C++ 枚举的反射问题,特别是 C++11 附带的“enum class”。反射问题描述了执行诸如“枚举所有值”、“枚举成员字符串”、“字符串转换”、“整数安全转换”以及“标志或所谓的枚举集”等操作的能力。当然,您总是可以手动完成这些操作,但这需要编写大量无用的样板代码和难以维护的代码。我在这里提出的框架在一个大型软件项目(我的项目)中使用,并且会定期更新(如果我或您报告了错误等)。
关于这个主题已经有很多文章了,但它们无一例外(至少在我所知的范围内)未能满足任何大型项目的最重要要求。那就是它们要求您输入枚举成员两次。有些甚至更糟,要求您为枚举使用其他文件,甚至使用生成器(这是最糟糕的情况)。如果我们不考虑这一点,此外,它们大多数都存在以下至少一个或多个问题:
- 它们没有使用本机枚举类型
- 不可移植(此框架已知可在 VS2010、Intel Compiler 11、CLang 3 [Linux] 及其后续版本上运行)
- 不支持新的 C++11 扩展,例如 enum classes
- 不可定制,这对于大型项目来说非常重要
- 它们会搞乱 IntelliSense(令人惊讶的是,我的方法得到了 VS IntelliSense 的完全支持)
- 使用起来很冗长
- 好吧,可能还有更多,但我记不清了!
现在让我们看看如何使用此处描述的框架定义一个完全可反射的 enum class。
PP_MACRO_ENUM_CLASS_EX(
(DemoEnum)(SubNamespace), // put in namespace "DemoEnum::SubNamespace"
EType_B, // typename
(e_0, 2),
(f_1, 8),
(g_2, 1),
(all, e_0 | f_1 | g_2),
(alias, f_1)
);
就这样。您无需编写额外的代码,框架只需要这些。我不会详细介绍这里要求的奇怪语法用法。在“幕后”部分,您可以找到一些关于如何继续理解此框架基础的提示。
请注意,还有一个更简洁的快捷方式,我将在下面介绍。
仅考虑生成的类型时,使用 C++11 编译器(否则看起来会不同),上述宏(除其他内容外)将扩展为
namespace DemoEnum {
namespace SubNamespace
{
enum class EType_B
{
e_0 = 2,
f_1 = 8,
g_2 = 1,
all = e_0 | f_1 | g_2,
alias = f_1,
};
}
}
有些人可能会担心“enum class”。是的,这是 C++11,Visual Studio 2010 不支持,但 VS2011 会支持。此外,如果不支持 enum classes,该框架将自动选择一种回退(稍后描述),它在功能上与 enum classes 基本兼容,是一个很好的临时解决方案(只要您坚持一些基本规则,您编写的代码不需要知道其中的区别)。还有一个快捷方式,看起来像这样:
PP_MACRO_ENUM_CLASS(
(DemoEnum)(SubEx),
EType_A,
a_0, b_1, c_2, d_3
);
它的作用基本相同,除了它会自动为您的枚举成员分配从零到无穷大的枚举值。这与 C++ 的做法完全相同,其中“a_0
”将设置为零,“b_1
”设置为一,依此类推。如果您不需要自定义值,这在很多情况下都很方便,那么这个快捷方式会派上用场。
那么这给了我们什么?好吧,现在您可以将这种全新的类型与框架附带的一组预定义的枚举支持例程一起使用(TEnum
表示一个模板参数,它会在需要时被替换为正确的枚举类型)。
// is the given integer value a valid enumeration member?
bool Enum::IsValid(int inProbableEnum);
// returns a string list with all member values for a specific enumeration
vector<TEnum> Enum::GetValues();
// returns a string list with all member identifiers for a specific enumeration
vector<string> Enum::GetNames();
// looks for the enum member identifier that maps
// to the given value and returns it string representation
string Enum::ToString(TEnum inEnum);
// Safely converts the given string to an enumeration
// value by member identifier lookup (case-insensitive).
// Throws "PP_MACRO_ENUM_ARG_EXCEPTION" if conversion fails.
TEnum Enum::FromString(string inString);
bool Enum::TryParse(string inProbableEnum, TEnum* outValue);
bool Enum::TryParse(int inProbableEnum, TEnum* outValue);
// Safely converts the given int to an enumeration value by member value lookup.
// Throws "PP_MACRO_ENUM_ARG_EXCEPTION" if conversion fails.
TEnum Enum::FromInt(int inProbableEnum);
还有标志
// is the given integer value a valid flags value?
bool AreValid(int inProbableEnum);
// is the given flag "inFlagToCheck" set in "inFlags"?
bool IsSet(TEnum inFlags, TEnum inFlagToCheck);
// is any of the given flags "inFlagToCheck" set in "inFlags"?
bool IsAnySet(TEnum inFlags, TEnum inFlagsToCheck);
// are all the given flags "inFlagToCheck" set in "inFlags"?
bool AreAllSet(TEnum inFlags, TEnum inFlagsToCheck);
// returns a list of enumeration members that are set
// in "inFlags"? Useful for ToString() implementations...
vector<TEnum> Decompose(TEnum inFlags);
// returns a list of all valid flag values. Useful for FromString() implementations...
vector<TEnum> GetValues();
bool TryParse(int inProbableEnum, TEnum* outValue);
// Safely converts the given int to a flags value by member value lookup.
// Throws "PP_MACRO_ENUM_ARG_EXCEPTION" if conversion fails.
TEnum FromInt(int inProbableEnum);
由于枚举的大小是固定的,因此所有方法的 time complexity 自然是 O(1)。但即使我们想诚实地说,实际常数也很低,很大程度上取决于编译器优化。如果不支持,特别是枚举例程可能会遭受相当大的性能损失,但在大多数应用程序中您仍然不会注意到。如果这仍然是您的问题,请缓存结果或将枚举容器替换为std::array
。然而,要做到后者,您需要理解源代码中发生了什么。也许我将来会自己做这件事并发布更新。
使用枚举
首先,让我们看看如何将此框架包含在您的代码中。我将假设一个干净的 Visual Studio 2010 SP1 安装和一个全新的 C++ 控制台应用程序项目。现在,将您的 boost 1.48(较低版本可能也可以)安装的根目录添加为附加包含路径。当然,您还需要访问此项目附带的框架文件。它们是:
#include "EnumFramework_Config.h"
#include "EnumFramework_Magic.h"
#include "EnumFramework_Custom.h"
第一个提供了通用配置(请参阅“自定义”部分)。第二个是您不应随意修改的令人头疼的内部内容。第三个包含实现支持例程的一些常规 C++ 代码。如果您想自定义这个东西(请参阅“修改源代码”部分),这可能是最有趣的部分。在包含框架头文件之前,您需要包含其他一些依赖项!如果正确设置了 boost 包含目录,一切应该都没问题,并且看起来会像这样:
#include <stdio.h>
#include "include/gtest/gtest.h"
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <assert.h>
#include <boost/foreach.hpp>
#include <boost/unordered_map.hpp>
#include <boost/assign/list_of.hpp>
#include <boost/../libs/unordered/examples/case_insensitive.hpp>
#include <boost/preprocessor/cat.hpp>
#include <boost/preprocessor/tuple/to_list.hpp>
#include <boost/preprocessor/list/for_each.hpp>
#include <boost/preprocessor/facilities/empty.hpp>
#include <boost/preprocessor/facilities/expand.hpp>
#include <boost/preprocessor/selection/max.hpp>
#include <boost/preprocessor/tuple/elem.hpp>
#include <boost/preprocessor/punctuation/comma_if.hpp>
#include <boost/preprocessor/stringize.hpp>
#include <boost/preprocessor/seq/for_each.hpp>
#include <boost/preprocessor/control/expr_if.hpp>
#include <boost/preprocessor/control/iif.hpp>
#include <boost/preprocessor/logical/or.hpp>
#include "EnumFramework_Config.h"
#include "EnumFramework_Magic.h"
#include "EnumFramework_Custom.h"
现在您可以根据需要定义枚举了。
注意:要记住的一条规则是,您应该始终将宏放在根命名空间中!否则它将不起作用。例如:
namespace MyNamespace
{
PP_MACRO_ENUM_CLASS(
(DemoEnum)(SubEx),
EType_A,
a_0, b_1, c_2, d_3
);
}
不会将“EType_A
”放在“MyNamespace::DemoEnum::SubEx
”命名空间中。相反,它会引发大量错误!
我相信这是我有限的元编程技能所施加的限制。毕竟,我从事严肃的 C++ 开发只有几周时间(之前是 C#、Java 和“类 C C++”)......更有经验的元程序员应该能够消除这种限制,至少如果语言规范允许的话(我不确定)。我在这里遇到的主要问题是,我需要在全局命名空间之外定义命名空间,这似乎在子命名空间内是禁止的。为了解决这个问题,可能需要更复杂的**设计,而我不想负担。
另外请注意,如果您计划以后支持 C++11,您应该定期使用 C++11 编译器和CFLAGS_ENABLE_ENUM_CLASS_WORKAROUND
宏设置为0
来编译您的代码,最好作为编译器选项。这将导致 EnumFramework 生成 enum classes 而不是 enum hacks ;)。虽然两者在一定程度上是兼容的,但您仍然需要修复这两种情况下的某些编译器错误,才能使它们都能顺利运行相同的代码(它们在这里和那里都需要额外的转换或模板参数)。
现在一切都设置好了,您可能对一些实际的支持例程代码示例感兴趣。我将用小片段引导您完成,因为它们应该相当不言自明。有关完整演示,请参阅示例项目,该项目使用了大部分功能来测试它。
首先,我们声明一个小的枚举:
PP_MACRO_ENUM_CLASS_EX(
(System)(Drawing), // put in namespace "System::Drawing"
EColor, // typename
(Black, 2),
(White, 8),
(Red, 1),
(All, Black | Red | White),
(SimilarWhite, White)
);
从现在开始,在本节的其余部分,所有代码都应包含在以下“main
”函数中:
int main(int argc, char* argv[])
{
using namespace std;
using namespace System::Drawing;
using namespace System::Compiler;
using namespace MetaEnumerations;
return 0;
}
现在我们可以像这样遍历名称:
BOOST_FOREACH(string colorName, Enum::GetNames<EColor>())
{
cout << colorName << " = "
<< (int)Enum::FromString<EColor>(colorName) << endl;
}
/* Output of loop:
Black = 2
White = 8
Red = 1
All = 11
SimilarWhite = 8
*/
并类似地遍历所有值:
BOOST_FOREACH(EColor colorValue, Enum::GetValues<EColor>())
{
cout << Enum::ToString(colorValue) << " = "
<< (int)colorValue << endl;
}
/* Output of loop:
Black = 2
SimilarWhite = 8
Red = 1
All = 11
SimilarWhite = 8
*/
请注意,使用“SimilarWhite”而不是预期的“White”。这是因为没有办法区分它们,因为它们的数值相同。
您也可以从字符串输入读取值(默认配置下不区分大小写):
string colorName;
EColor colorValue;
do{
cout << endl << "Enter a valid color: ";
cin >> colorName;
}
while(!Enum::TryParse(colorName, &colorValue));
cout << endl << "You have entered \"" << Enum::ToString(colorValue) << "\"!" << endl;
正如您可能知道的,数值可以转换为枚举(即使是 enum classes),因此表达式 (EColor)443
是正确的 C++ 代码,但本质上是错误的!通过以下方式,您现在可以(轻松地)验证这些值:
cout << Enum::IsValid<EColor>(1); // true
cout << Enum::IsValid<EColor>(2); // true
cout << Enum::IsValid<EColor>(3); // false
cout << Enum::IsValid<EColor>(4); // false
cout << Enum::IsValid<EColor>(5); // false
cout << Enum::IsValid<EColor>(6); // false
cout << Enum::IsValid<EColor>(7); // false
cout << Enum::IsValid<EColor>(8); // true
cout << Enum::IsValid<EColor>(11); // true
请注意,所有转换函数,无论是接受int
还是enum
,并且不以“Is”或“Try”为前缀的函数,在您传递无效枚举值时都会抛出异常!
现在关于枚举没有太多需要了解的了。
使用标志/枚举集
相反,我们将看看标志或枚举集。与枚举集不同,标志旨在一次容纳多个枚举成员,同时仍保持在同一空间内。这是通过为每个枚举成员分配一个二的幂来实现的。为此包含了一个特殊的宏:
PP_MACRO_ENUM_CLASS_FLAGS(
(System)(Compiler), // put in namespace "System::Compiler"
EOpMode, // typename
Preprocess,
Compile,
Link,
Optimize,
Execute
);
这将定义一个枚举 EOpMode
,其中每个成员只有一位被设置,因此您可以随意组合它们。当然,您可以将任何枚举视为标志,但只有每位一个成员的枚举才有意义,除非您想指定掩码。如果您不想与本机代码互操作,将掩码放入枚举通常是一个坏主意,这就是为什么没有包含特殊构造的原因。如果您想使用掩码,您将不得不使用更冗长的 PP_MACRO_ENUM_CLASS_EX
来指定您的枚举。
您现在可以轻松地分解这些标志,这对于验证、为特定标志集调用特定任务、将标志转换为字符串或任何其他用途都很有用:
EOpMode opMode = Flags::Of(EOpMode::Compile, EOpMode::Link, EOpMode::Execute);
BOOST_FOREACH(EOpMode step, Flags::Decompose(opMode))
{
cout << Enum::ToString(step) << " = " << (int)step << endl;
}
/* Output of loop:
Compile = 2
Link = 4
Execute = 16
*/
Flags::Of
只是 OR
枚举值的快捷方式;特别是由于 enum classes 不支持此功能(有充分理由,因为它通常只在标志上才有意义),而无需转换为int
,因此此方法很有用。
此外,您可能可以获取枚举值的所有标志值。只有每个位恰好设置一个的枚举成员将包含在此枚举中:
BOOST_FOREACH(EColor color, Flags::GetValues<EColor>())
{
cout << Enum::ToString(color) << " = " << (int)color<< endl;
}
/* Output of loop:
Red = 1
Black = 2
SimilarWhite = 8
*/
如您所见,EColor::All
不包含在内,因为它由多个位组成。
标志与枚举面临相同的问题,即(EOpMode)-456 是一个有效表达式,但毫无意义。对于标志,您需要一个特殊的验证函数,因为它们的值通常不映射到成员:
Flags::AreValid<EColor>(1); // true
Flags::AreValid<EColor>(2); // true
Flags::AreValid<EColor>(8); // true
Flags::AreValid<EColor>(3); // true
Flags::AreValid<EColor>(11); // true
Flags::AreValid<EColor>(10); // true
Flags::AreValid<EColor>(9); // true
// false for everything else...
有一些掩码方法可用,例如 AreAllSet
、IsAnySet
、IsSet
:
opMode = Flags::Of(EOpMode::Link, EOpMode::Execute);
Flags::AreAllSet<EOpMode>(opMode, EOpMode::Preprocess); // false
Flags::AreAllSet<EOpMode>(opMode, EOpMode::Execute); // false
Flags::AreAllSet<EOpMode>(opMode, opMode); // true
Flags::AreAllSet<EOpMode>(opMode, Flags::Of(EOpMode::Link, EOpMode::Execute, EOpMode::Preprocess)); // true
好吧,还有更多方法可用,但它们有点不言自明。当然,还有更多可以想到的方法,例如基于条件掩码的验证标志,这些掩码根据复杂规则实际检查标志是否有效。欢迎您添加此功能!
集成现有枚举类
我不确定这是否真的需要,因为支持仅针对 enum classes,而且不应该已经有很多代码在使用它们了。不幸的是,常规 C++ 枚举无法被支持。您可以轻松集成现有的 enum classes,但那样您将不得不重新输入成员:
#if !CFLAGS_ENABLE_ENUM_CLASS_WORKAROUND
// consider the following to be your existing enum
namespace Some { namespace Nested { namespace Namespace {
enum class ESomeEnum
{
a_0 = 1, b_1 = 4, c_2 = 8, d_3 = 3,
};
}}}
// now it is simple to import it:
PP_MACRO_ENUM_CLASS_IMP((Some)(Nested)(Namespace), ESomeEnum, a_0, b_1, c_2, d_3)
#endif
值将自动从您的原始 enum class 中检索,您无需再次键入它们(也不支持)。上面的宏不会引入任何新的类型供您使用。相反,它只是跳过类型生成,只输出支持代码,以使您的枚举与此处提供的反射一起正常工作!CFLAGS_ENABLE_ENUM_CLASS_WORKAROUND
开关很重要,因为上面的定义只有在使用 enum classes 并得到支持(C++11 标准)时才有意义。
自定义
该框架允许进行一些配置,并带有用于大多数使用的**数据类型和命名空间**的宏占位符(我选择宏而不是typedef
,因为它们可能会在模板中引起麻烦,尤其是我无法依赖 C++11 中引入的模板别名,因为缺乏支持)。因此,您应该很容易地调整所有重要属性以满足您的需求。您可以更改标题文件“EnumFramework_Config.h”中的宏,或者在包含此标题文件之前定义它们,在这种情况下,它将采用您的宏而不是重新定义它。
在下面的文本中,我将简要解释您可以配置的内容。首先,看一下配置中使用的所有宏:
#define PP_MACRO_STD_VECTOR std::vector
#define PP_MACRO_STD_STRING std::string
#define PP_MACRO_STD_UNORDERED_MAP boost::unordered_map
#define PP_MACRO_STD_IEQUAL_TO hash_examples::iequal_to
#define PP_MACRO_STD_IHASH hash_examples::ihash
#define PP_MACRO_ENUM_ARG_EXCEPTION std::exception
#define PP_MACRO_STD_MAKE_PAIR std::make_pair
#define PP_MACRO_METAENUM_NAMESPACE (MetaEnumerations)
#define PP_MACRO_METAENUM_ENUM_NAMESPACE (MetaEnumerations)(Enum)
#define PP_MACRO_METAENUM_FLAGS_NAMESPACE (MetaEnumerations)(Flags)
前六个宏表示将在框架中使用的相应类型。这通常很有帮助,因为并非每个人都分别使用 STD 类型或 BOOST。因此,它们提供了一种使框架与您自己的类型兼容的便捷方式。
最后三个宏表示 EnumFramework 将放置其内部内容**的命名空间**以及Enum
支持例程和Flags
支持例程的名称。如果您更改后者两个,上述所有代码示例很可能将不再编译;)。例如,如果您这样做:
#define PP_MACRO_METAENUM_ENUM_NAMESPACE (System)(Enumeration)
您将不再在 MetaEnumerations
命名空间中找到 Enum::ToString()
等方法,而是在 System
命名空间中找到,并且必须使用 Enumeration::ToString()
,前提是您之前已内联 System
。
此外,还有一个 CFLAGS_ENABLE_ENUM_CLASS_WORKAROUND
宏。它目前评估为one
,因为 C++11 的标准检测似乎目前不起作用,但也许几年后就会;)。所以现在,如果您想启用 C++11 支持,即 enum classes,您必须在包含任何框架特定的头文件之前手动将此标志设置为zero
。
如果您仍然想深入了解,请继续阅读“修改源代码”部分。
故障排除
确保您使用的是正确的语法。如果您在声明枚举时输入了错误的内容,您很可能会收到大量不相关的错误,这些错误与实际原因的**相关性**与您盯着一罐肉差不多……所以要小心。另一个可能引起麻烦的事情是当您的枚举成员是宏名称时。虽然在大多数情况下使用原始 C++ 类型时这也**不会**起作用,但不同之处在于您不会获得有用的错误信息,而只会得到一堆可怕的垃圾。
在 Linux 的情况下,有一个未解决的问题,即链接器!它似乎过于认真地对待它的工作,并且不允许在多个编译单元中重新定义静态模板数据成员。这是一个严重的问题,我找到的唯一解决方法是创建一个 CPP 文件并将所有其他 CPP 文件包含在其中。然后它将工作,前提是您的代码支持这种编译(特别是如果您没有计划这样做,它实际上可能会引起一些麻烦)。如果有人有解决方案,我将非常感谢,因为我自己也需要 Linux 支持……目前,我无法想到除了使用静态模板数据成员之外的其他方法来完成这项工作(至少**不**需要两次输入枚举成员,这对我来说是绝对不可取的;那样我宁愿完全不使用枚举)。嗯,老实说,我有一个想法可能会奏效,但由于我并不迫切需要 Linux 修复,所以我现在不会去研究它,因为还有更重要的事情要做。它基于引入另一个宏,该宏仅接收命名空间和枚举名称(这是我可以接受的,因为您不必同步成员,并且枚举名称不太可能随着时间而改变,因为这会**导致大量的重构需求)。这个宏现在将**仅在每个枚举的**一个编译单元**中声明,并将作为提供反射数据的**良好命名的代理**。
修改源代码
如果您不怕麻烦,可以看一下源代码(它确实很丑陋,但您也无能为力;最终,它至少提供了让您的代码更干净的手段),而不是仅仅使用宏。我将提供一些关于从哪里开始以及您可以在**不**理解这个框架如何工作的情况下进行哪些更改的提示;)。
最重要的也是最容易的更改是添加新方法。为此,您必须为文件“EnumFramework_Custom.h”中位于 template<class TEnum> struct EnumSupport
的类添加一个新的公共静态方法。此外,您应该在EnumSupport
类定义下的Enum
或Flags
命名空间中创建一个别名。在那里,您只需创建一个像其中其他一样存在的别名。我认为如果您仔细看看,就**很明显**了。只是让自己**跟随**现有的方法。
您可能还想更改的是在没有 enum classes 可用时使用的兼容性**解决方法**。它位于文件底部,以struct TEnum
开头。我无法告诉您该怎么做,您自己会知道如何进行更改以满足您的需求。
示例应用程序使用 Google Testing Framework,并附带**一**系列基本测试,验证了**反射枚举**的整个概念。如果您更改了任何内容,应始终确保测试用例运行正常,并且代码能够**使用 MSVC 2010 和 CLang 3(由 Apple 支持)进行编译**。在我看来,这是最重要的编译器(它们不可能更**不**一样),如果您同时支持它们,特别是由于 CLang 高度符合标准,您的代码**很可能**也会与任何其他主要编译器**编译**,最多只需稍作修改。另请注意,CLang 是一项**惊人的技术**。尽管由于缺乏良好的工具和调试器,它尚未为**产品开发**做好准备,但它是**未来** C++ 工作的**首选**。它提供了**出色的错误报告机制**,没有它,这个项目**就不可能**完成!例如,在 Visual Studio 中,您会看到类似“Unexpected ',' at the end of identifier”的**错误**。如果您双击,它会显示**产生此错误的宏**。此外,命令行输出**不再显示更多信息**。是的,很好。根据宏的不同,这实际上可能意味着**数小时的试错代码更改**才能找出**真正的原因**。**花足够的时间**安装 CLang 编译器,让它提供**更多**的洞察。它将**生成**一个**完整**的宏展开和模板实例化**跟踪**,用于特定错误,并在每个跟踪步骤中**下划线**指示**哪个表达式被替换**为什么**,以及**在哪个源文件行**发生此跟踪步骤(即使**带有当前行的源代码转储**)。并且它会显示**有用的错误消息**,**精确地**指出**实际问题**,而不是一些**随机的奇怪垃圾**。CLang 确实是**进化**的东西,人们从 GCC 的失败(至少在 C++ 方面)中学到了很多东西。
幕后
如果您想了解更多信息,只需阅读一些关于“预处理器元编程”以及 C++ 模板的**优秀文档**。这里所做的只是**滥用**预处理器和模板实例化来进行代码生成,或者说是一种**领域特定语言**;在这种情况下,是一种定义**反射枚举**的语言。我现在的时间差不多**到了**,而且我认为在这种文章中添加此类信息**意义不大**,因为之前讨论的内容**非常直接**,而现在会变得**非常棘手**;)但我不得不承认,也**会**更有趣。但这篇文章是关于**如何使用**这个框架,而不是**如何重新实现**它!