我该使用#define、枚举还是const?

130
在我正在处理的C++项目中,我有一个名为“flag”的值,可以有四个取值。这四个标志可以组合使用。 标志描述数据库中的记录,可能是:
  • 新记录
  • 已删除的记录
  • 修改过的记录
  • 现有记录
现在,对于每个记录,我希望保留此属性,因此我可以使用枚举:
enum { xNew, xDeleted, xModified, xExisting }

然而,在代码的其他地方,我需要选择哪些记录可见给用户,因此我希望能够将其作为单个参数传递,如:

但是,在代码的其他地方,我需要选择哪些记录可见给用户,因此我希望能够将其作为单个参数传递,例如:

showRecords(xNew | xDeleted);

所以,看起来我有三种可能的方法:

#define X_NEW      0x01
#define X_DELETED  0x02
#define X_MODIFIED 0x04
#define X_EXISTING 0x08
typedef enum { xNew = 1, xDeleted, xModified = 4, xExisting = 8 } RecordType;
或者
namespace RecordType {
    static const uint8 xNew = 1;
    static const uint8 xDeleted = 2;
    static const uint8 xModified = 4;
    static const uint8 xExisting = 8;
}

空间要求很重要(字节 vs 整型),但不是至关重要的。使用定义会失去类型安全,使用枚举会失去一些空间(整数)并且可能需要进行位运算时强制转换。使用const也可能会失去类型安全,因为一个随意的uint8可能会出错。

是否有其他更简洁的方法?

如果没有,你会使用什么方法以及为什么?

P.S. 其余代码都是相当干净的现代C++,没有#define,我在一些地方使用了命名空间和模板,所以它们也不是不可能的选择。


使用枚举会损失一些空间(整数)。不一定。请参见https://dev59.com/ZXRC5IYBdhLWcg3wP-dh和https://dev59.com/wXNA5IYBdhLWcg3wC5NR(以及gcc的[-fshort-enum](http://gcc.gnu.org/onlinedocs/gcc/Type-Attributes.html)。(我假设这些C的答案在C++中仍然是正确的。) - idbrii
如果您对C和C++的兼容性不确定,我发现这个链接非常有帮助,例如查看枚举类型http://david.tribble.com/text/cdiffs.htm#C99-enum-type - aka.nice
3
这是一个投票很高的旧话题,有没有不提到C++11枚举类的原因来解决这个问题呢? - Brandin
作为一条注释,enum RecordType : uint8_t结合了enum的类型安全性和uint8_t的小尺寸,尽管您仍需要提供位运算符。 - Justin Time - Reinstate Monica
15个回答

91

综合使用多种策略以减少单一方法的缺点。我从事嵌入式系统开发,因此以下解决方案基于整数和位运算符具有快速、低内存和闪存使用率的事实。

将枚举放置在命名空间中,以防止常量污染全局命名空间。

namespace RecordType {

枚举声明并定义了一种在编译时检查的类型。始终使用编译时类型检查以确保参数和变量被赋予正确的类型。在C++中不需要typedef。

enum TRecordType { xNew = 1, xDeleted = 2, xModified = 4, xExisting = 8,

创建一个无效状态的另一个成员。这可以用作错误代码;例如,当您想返回状态但I / O操作失败时。它也非常有用于调试;在初始化列表和析构函数中使用它,以确定变量的值是否应该被使用。

xInvalid = 16 };

考虑到您的两个目的,即跟踪记录的当前状态和创建掩码以选择特定状态的记录。创建一个内联函数来测试类型的值是否对您的目的有效;作为状态标记还是状态掩码。这将捕获错误,因为typedef只是一个int,而未初始化或错误指向变量的值(例如0xDEADBEEF)可能存在于变量中。

inline bool IsValidState( TRecordType v) {
    switch(v) { case xNew: case xDeleted: case xModified: case xExisting: return true; }
    return false;
}

 inline bool IsValidMask( TRecordType v) {
    return v >= xNew  && v < xInvalid ;
}

如果您经常使用该类型,请添加using指令。

using RecordType ::TRecordType ;

值检查函数在断言中非常有用,可以在使用错误的值时立即捕获异常。当运行时尽早发现 bug,就能减少其造成的损失。

以下是一些综合示例。

void showRecords(TRecordType mask) {
    assert(RecordType::IsValidMask(mask));
    // do stuff;
}

void wombleRecord(TRecord rec, TRecordType state) {
    assert(RecordType::IsValidState(state));
    if (RecordType ::xNew) {
    // ...
} in runtime

TRecordType updateRecord(TRecord rec, TRecordType newstate) {
    assert(RecordType::IsValidState(newstate));
    //...
    if (! access_was_successful) return RecordType ::xInvalid;
    return newstate;
}

确保正确值安全的唯一方法是使用带有运算符重载的专用类,这留给另一个读者作为练习。


1
大部分是一个不错的答案 - 但问题规定了标志可以组合,而IsValidState()函数不允许它们组合。 - Jonathan Leffler
3
从我的角度来看,我认为 'IsValidState' 不应该这样做,而是应该由 'IsValidMask' 来完成。 - João Portela
1
IsValidMask 不允许选择“无”(即 0)是期望的吗? - Joachim Sauer
2
运行时类型检查的想法是可憎的。 - Cheers and hth. - Alf

58

不要过度使用宏定义

它们会让你的代码变得混乱不堪。

位字段?

struct RecordFlag {
    unsigned isnew:1, isdeleted:1, ismodified:1, isexisting:1;
};

不要使用位域。你更关心速度而不是节约4个整数。使用位域实际上比访问任何其他类型的速度要慢。

然而,结构体中的位成员存在实际缺点。首先,在内存中的位序因编译器而异。此外,许多流行的编译器为读写位成员生成效率低下的代码,并且有潜在的线程安全问题与位字段相关(特别是在多处理器系统上),因为大多数机器无法操作内存中的任意位集,而必须加载和存储整个字。例如,以下内容尽管使用了互斥量,但仍不是线程安全的。

来源: http://en.wikipedia.org/wiki/Bit_field:

如果你需要更多理由不要使用位域,也许Raymond Chen会在他的The Old New Thing博客文章“用于一组布尔值的位域的成本效益分析”中说服你。链接:http://blogs.msdn.com/oldnewthing/archive/2008/11/26/9143050.aspx

const int?

namespace RecordType {
    static const uint8 xNew = 1;
    static const uint8 xDeleted = 2;
    static const uint8 xModified = 4;
    static const uint8 xExisting = 8;
}

将它们放入命名空间是很好的。如果它们在您的CPP或头文件中声明,它们的值将被内联。您将能够使用这些值进行switch语句,但这会略微增加耦合。

啊,是的:删除static关键字。当您使用static时,它在C++中已经过时了。如果uint8是一个内置类型,并且您不需要在由同一模块的多个源包含的头文件中声明此类型。最终代码应该是:

namespace RecordType {
    const uint8 xNew = 1;
    const uint8 xDeleted = 2;
    const uint8 xModified = 4;
    const uint8 xExisting = 8;
}

这种方法的问题在于,您的代码知道您的常量的值,这会略微增加耦合。

枚举

与const int相同,但具有更强的类型化。

typedef enum { xNew = 1, xDeleted, xModified = 4, xExisting = 8 } RecordType;

尽管如此,它们仍在污染全局命名空间。顺便说一句...移除typedef。你正在使用C++。枚举和结构体的typedef比其他任何东西都更加污染代码。

结果有点像:

enum RecordType { xNew = 1, xDeleted, xModified = 4, xExisting = 8 } ;

void doSomething(RecordType p_eMyEnum)
{
   if(p_eMyEnum == xNew)
   {
       // etc.
   }
}

正如你所看到的,枚举类型正在污染全局命名空间。 如果将此枚举类型放入命名空间中,你将会得到以下代码:

As you see, your enum is polluting the global namespace. If you put this enum in an namespace, you'll have something like:

namespace RecordType {
   enum Value { xNew = 1, xDeleted, xModified = 4, xExisting = 8 } ;
}

void doSomething(RecordType::Value p_eMyEnum)
{
   if(p_eMyEnum == RecordType::xNew)
   {
       // etc.
   }
}

extern const int ?

如果您想减少耦合度(即能够隐藏常量的值,从而可以根据需要修改这些值而无需进行完整的重新编译),您可以在头文件中将int声明为extern,在CPP文件中声明为constant,例如以下示例:

// Header.hpp
namespace RecordType {
    extern const uint8 xNew ;
    extern const uint8 xDeleted ;
    extern const uint8 xModified ;
    extern const uint8 xExisting ;
}

并且:

// Source.hpp
namespace RecordType {
    const uint8 xNew = 1;
    const uint8 xDeleted = 2;
    const uint8 xModified = 4;
    const uint8 xExisting = 8;
}

然而,您将无法在这些常量上使用 switch。所以最终,选择你的毒药... :-p


5
为什么你认为位域(bitfields)很慢?你是否实际上对使用它和另一种方法编写的代码进行过性能分析?即使是慢的,清晰度可能比速度更重要,因此"永远不要使用它"有些简化了。 - wnoise
"static const uint8 xNew;" 只是冗余的,因为在 C++ 中,const 命名空间作用域变量默认具有内部链接。移除 "const" 后,它就具有外部链接。此外,"enum { ... } RecordType;" 声明了一个名为 "RecordType" 的全局变量,其类型是匿名枚举。 - bk1e
onebyone: 首先,主要原因是收益(如果有的话只有几个字节)被损失所掩盖(访问速度变慢,无论是读还是写)。 - paercebal
3
其次,我在工作或家里编写的所有代码本质上都是线程安全的。这很容易实现:没有全局变量,没有静态变量,除非受到锁的保护,否则不在线程之间共享。使用这个习惯用语会破坏这种基本的线程安全性。而为什么要这样做呢?也许只是为了少用一些字节?... :-) ... - paercebal
增加了对Raymond Chen关于位域隐藏成本的文章的引用。 - paercebal

30

您是否已排除使用std::bitset?这正是用于标记集合的。请检查一下。

typedef std::bitset<4> RecordType;

然后

static const RecordType xNew(1);
static const RecordType xDeleted(2);
static const RecordType xModified(4);
static const RecordType xExisting(8);

由于位集(bitset)拥有许多运算符重载,因此您现在可以执行以下操作:

RecordType rt = whatever;      // unsigned long or RecordType expression
rt |= xNew;                    // set 
rt &= ~xDeleted;               // clear 
if ((rt & xModified) != 0) ... // test

或者是非常类似的 - 如果有任何更正,我会非常感激,因为我没有测试过这个。您也可以通过索引引用位,但通常最好仅定义一个常量集,并且RecordType常量可能更有用。

假设您已排除了bitset,我投票支持枚举

我不认同强制转换枚举类型是一个严重的缺点 - 好吧,这可能有点嘈杂,并且将超出范围的值赋给枚举类型是未定义行为,因此在某些不寻常的C++实现中理论上可能会导致自己犯错。但是,如果只在必要时执行此操作(即从int转换为enum时),它是人们以前看到过的完全正常的代码。

我对枚举的任何空间成本持怀疑态度。uint8变量和参数可能不会比ints使用任何更少的堆栈,因此仅类中的存储才有意义。有一些情况下,在结构体中打包多个字节将获胜(在这种情况下,您可以将枚举类型转换为uint8类型的存储),但通常填充将消除其中的任何优势。

因此,与其他内容相比,枚举没有缺点,并且具有一些类型安全的优点(您无法分配某些随机整数值而不显式转换),以及引用所有内容的清晰方法。

顺便说一句,我还希望在枚举中加入“= 2”。这不是必需的,但是“最小意外原则”表明,所有4个定义应该看起来相同。


1
实际上,我根本没有考虑位集。但是,我不确定它是否好用。使用位集,我必须将位地址为1、2、3、4,这会使代码变得不太易读 - 这意味着我可能会使用枚举来“命名”位。但这可能会节省空间。谢谢。 - Milan Babuškov
Milan,你不必使用枚举来“命名”位,你可以像上面所示一样直接使用预定义的位。如果你想要打开第一个位,而不是使用 my_bitset.flip(1),你可以使用 my_bitset |= xNew; - moswald
这段内容与STL有关,但我想问一下:为什么要使用bitset?它通常会转换为一个long(在我的实现中是这样的,如果我没记错的话),或者对于每个元素使用类似的整数类型,那么为什么不直接使用非混淆的整数呢?(或者,现在可以使用零存储的constexpr - underscore_d
我从来没有真正理解bitset类的基本原理,除了似乎在周围讨论中出现的一种反复出现的潜在思路:“我们必须掩盖语言的不良低级根源”。 - underscore_d
"uint8 变量和参数可能不会比 int 使用更少的堆栈" 是错误的。如果您有一个带有 8 位寄存器的 CPU,那么 int 至少需要 2 个寄存器,而 uint8_t 只需要 1 个,因此您将需要更多的堆栈空间,因为您更有可能用完寄存器(这也会更慢,并且可能会增加代码大小(取决于指令集))。 (您有一个类型错误,应该是 uint8_t 而不是 uint8 - 12431234123412341234123

8

这里有几篇关于const、宏和枚举的文章:

符号常量
枚举常量 vs. 常量对象

我认为你应该避免使用宏,特别是因为你写的大部分新代码都是用现代C++编写的。


5

如果可能,请不要使用宏。在现代C ++中,它们并不受太多欣赏。


4
没错。我自己讨厌宏的原因是,如果它们有问题,你无法进入其中查找错误。 - Carl
我想这是编译器可以修复的问题。 - celticminstrel

5

使用宏定义会导致我失去类型安全

并不一定...

// signed defines
#define X_NEW      0x01u
#define X_NEW      (unsigned(0x01))  // if you find this more readable...

使用枚举类型不一定会浪费存储空间(整型)。

但是,在存储点必须明确指定...

struct X
{
    RecordType recordType : 4;  // use exactly 4 bits...
    RecordType recordType2 : 4;  // use another 4 bits, typically in the same byte
    // of course, the overall record size may still be padded...
};

如果我想进行位运算,可能需要进行类型转换。

您可以创建自定义运算符来简化操作:

RecordType operator|(RecordType lhs, RecordType rhs)
{
    return RecordType((unsigned)lhs | (unsigned)rhs);
}

使用const关键字,可能会失去类型安全性,因为一个随机的uint8可能会错误地进入其中。
对于这些机制中的任何一个,都可能发生同样的情况:范围和值检查通常与类型安全无关(尽管用户定义的类型,即您自己的类,可以强制执行有关其数据的“不变量”)。使用枚举,编译器可以自由选择较大的类型来托管值,并且未初始化、损坏或仅仅是错误设置的枚举变量仍然可能将其位模式解释为您不希望得到的数字——与任何枚举标识符、它们的任何组合以及0比较时都不相等。
是否存在其他更简洁的方法?如果没有,你会使用什么?为什么?
最终,一旦涉及到位域和自定义运算符,经过时间考验的C风格枚举的按位OR效果非常好。您可以通过一些自定义验证函数和断言来进一步提高其鲁棒性,就像mat_geek的答案中所述的技术一样,这些技术同样适用于处理字符串、int、double值等。
您可以认为这是“更干净”的做法:
enum RecordType { New, Deleted, Modified, Existing };

showRecords([](RecordType r) { return r == New || r == Deleted; });

我觉得这个问题无关紧要:数据位可以更紧凑,但代码会显著增长......这取决于你有多少对象和lambda表达式(尽管它们很漂亮,但比特位或者更加混乱且更难以正确使用)。

顺便说一句,关于线程安全性的争论在我看来相当薄弱——最好记住它作为背景考虑因素而不是主导决策驱动力;即使不知道它们的打包情况,共享互斥量也是更常见的做法(互斥量是相对较大的数据成员,我必须非常关注性能才能考虑在一个对象的成员中有多个互斥量,并且我会仔细查看以注意到它们是位域)。任何子字长类型都可能出现相同的问题(例如uint8_t)。总之,如果你急需更高的并发性,可以尝试原子比较和交换操作。


1
+1 很好。但是在执行 | 操作之前,operator| 应该将其转换为整数类型(unsigned int)。否则,operator| 将会递归调用自身并导致运行时堆栈溢出。我建议使用:return RecordType( unsigned(lhs) | unsigned(rhs) );。祝好! - oHo

4

枚举类型更加适合,因为它们可以为标识符提供“含义”,同时还具有类型安全性。即使过了很多年,你仍然可以清楚地知道“xDeleted”属于“RecordType”,表示“记录类型”(哇!)。如果使用常量,需要添加注释,并且需要在代码中上下移动。


3
即使你必须使用4个字节来存储枚举(我对C ++不是很熟悉 - 我知道您可以在C#中指定基础类型),仍然值得使用枚举。在这个拥有GB级内存的服务器时代,应用程序级别上的4个字节与1个字节之类的内存问题通常并不重要。当然,如果在您特定的情况下,内存使用量非常重要(而且您无法让C ++使用一个字节来支持枚举),那么您可以考虑“static const”路线。最终,您必须问自己,为了数据结构的3个字节的内存节省而使用“static const”是否值得维护成本?还有一件事要记住 - 如果我没记错,在x86上,数据结构是4字节对齐的,因此除非您的“记录”结构中有多个字节宽度元素,否则实际上可能并不重要。在进行可维护性和性能/空间权衡之前,请测试并确保它确实重要。

在C++中,您可以指定底层类型,自从语言修订C++11以来。在那之前,我认为它是“至少足够大,可以存储和用作所有指定枚举器的位域,但可能是int,除非太小”。[如果您在C++11中不指定底层类型,则使用传统行为。相反,如果未另行指定,C++11 enum class的底层类型明确默认为int] - Justin Time - Reinstate Monica

3
如果你想要类的类型安全性,又需要枚举语法和位检查的便利性,请考虑使用C++中的Safe Labels。我曾经和作者合作过,他非常聪明。
但请注意,最终这个程序包使用了模板和宏!

看起来对于我的小应用来说有点过头了,但它似乎是一个不错的解决方案。 - Milan Babuškov

2
基于 KISS、高内聚低耦合的原则,提出以下问题 -
  • 谁需要知道?我的类、我的库、其他类、其他库、第三方
  • 我需要提供什么抽象级别?使用者是否理解比特操作。
  • 我是否需要从 VB/C# 等接口进行交互?

有一本很好的书 "大规模 C++ 软件设计",它推广将基类型暴露给外部。如果可以避免依赖另一个头文件/接口,就应该尝试。


1
a) 5-6个类。 b) 只有我,这是一个单人项目。 c) 没有接口。 - Milan Babuškov

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接