CSharp中的枚举类型的几个常见需求

关于 C# 中的枚举类型

  • 怎样获取枚举类型的Description信息?
  • 枚举类型中发Flags标志到底起了什么作用?应该在怎么时候使用?
  • 是否可以定义String作为Value枚举类型?

本例测试代码: https://github.com/zhaokuohaha/SimpleTest/blob/master/SimpleTests/EnumTests.cs

ToString & Description

在枚举类型中,ToString方法会返回枚举变量定义的名字。

enum Operate
{
    A = 1,
    B = 2,
    C = 4,
}

Assert.Equal("A", OperateFlag.A.ToString());

一个很常见的需求是,有时候变量名的并不能实现程序需要表达的信息。尤其在中文环境下这个问题更加明显。 例如

enum Sex
{
	[Description("男性")]
	Man,
	[Description("女性")]
	Woman
}

这个时候我其实想返回的是 Description 特性里面的内容, 因为在里面我可以自定义许多我想表达的信息。而变量名其实不足以表达全部。 且不说还有命名规范等其他问题。

注: Description 特性在 using System.ComponentModel中实现

实现方法其实也不难, 写一个枚举类型的扩展方法(甚至可以是所有object的扩展方法, 因为这个特性可以加在任何类型上), 方法如下:

public static class EnumExtension
{
	public static string GetDescription(this Enum e)
	{
		var fi = e.GetType().GetField(e.ToString());
		var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false);
		if (attributes.Length > 0)
		{
			return attributes[0].Description;
		}
		return e.ToString();
	}
}

测试结果:

Assert.Equal("男性", Sex.Man.GetDescription());
Assert.Equal("Man", Sex.Man.ToString());

Flags

在 C# 的枚举类型中,有一个好像特别熟悉但又似乎不常用的 Attribute(特性) : Flag

MSDN 对这个特性下的定义是:“指示可将枚举视为位域(即一组标志)”。顾名思义,加了这个特性的枚举型可以看成是一组标志量, 直观的说, 跟普通枚举类型最大的区别就是这里枚举类型的值是用二进制标志位来指定的。即 3(0B11)包含了 2 (0B10) 和 1(0B01), 另一个直观的外在表现是 ToString() 方法也会表现出来, 例如下面的代码:

[Flags]
enum OperateFlag
{
	A = 1,
	B = 2,
	C = 4
}
enum Operate
{
	A = 1,
	B = 2,
	C = 4,
}

void test () {
	var f1 = (OperateFlag)5;
	Console.WriteLine(f1.ToString()) // 输出:A, C
	
	var f2 =  (Operate)5;
	Console.WriteLine(f2.ToString()) // 输出:5
}

C# 的枚举类型有一个方法, 叫 HasFlag , 方法的作用, 注释文档写的是: Determines whether one or more bit fields are set in the current instance, 即: A.HasFlag(B) B对应的二进制的所有"1"位在A都是"1", 这不仅限于1,2,4等这些单位位1的值, 同样适用于3,5之类, 例如:

Assert.True(((OperateFlag)6).HasFlag(OperateFlag.B)); // 6: 110, 2: 010
Assert.False(((OperateFlag)6).HasFlag((OperateFlag)3)); // 3: 011
Assert.True(((OperateFlag)7).HasFlag((OperateFlag)3)); // 7: 111
// 注意超出的部分运算结果如下
Assert.True(((Operate)9).HasFlag(Operate.A));
Assert.False(Operate.A.HasFlag((Operate)9));

那这个方法在没有加 [Flags] 特性的枚举类型上会不会生效呢? 把上面的代码换成 Operation 重新运行测试, 发现是生效的, 也就是说这个方法对所有枚举类型都会生效.

那么, 这么来看, [Flags] 特性还有什么用处呢? 难道仅仅是在 ToString 上展现出来的不同?

查阅一同资料之后, 我们可以大概得出这样的结论:是的!!!

当然可能也会影响到其他类似方法比如 IsDefined (未测试), Parse(经测试, 表现一致),但总归说, 对于一个枚举类型来说,是否添加了 Flags 标志, 对于类型本身的运算, 判断, 赋值 没有影响

为了描述方便,我们暂且将加了 Flags 标志的枚举成为标志枚举, 没有添加标志的为简单枚举。

也就是说, 如果非要在代码的角度来区分这两者的区别的话,这二者其实没有本质的差别, 只要在标志枚举上能够生效的用法, 在简单枚举上也一样生效。 那么, 这个标志位的意义在哪里呢?

通过一份早期的设计说明我们大概可以看的出来, Flags 并不是为了改变枚举类型的语法而设计, 而是为了解决简单枚举对于一些组合值表现的无力。 比如假设对数据的操作权限有 CRUD , 而一个人具体的操作权限可能是四种操作中任意种的组合, 如果按照简单枚举的概念, 一个枚举对应一种操作的话, 需要 2^4 = 16 个枚举值才能定义完, 而且随着“元数据”的增加, 这种复杂度是呈指数增加的。 而 Flags 标志的枚举, 就是规范这样一种枚举的使用概念:

每个枚举值表示一个标志位, 不同枚举值之间可以自由组合运算, 以表示无法一一定义,但具有合法含义的内容。

按照这样的概念,标志枚举的值应该定义为2的幂次, 如1,2,4,8 等等,即每一个值都用一个二进制位的“1”来表示, 其他的值则有这些值组合而成, 如 3 (011) = 1 (010) | 2 (001).

再次需要说明的是, 以上这些需求,在简单枚举上仍然可以实现, 在一个简单枚举上赋值为1,2,4,8, 并配合位运算, HasFlags 方法, 达到的效果跟标志枚举是一样的。 况且需要注意的是,即使是标志枚举,在定义时也需要手动赋值为对应的标志位值, 如果没有手动赋值, 默认的值跟简单枚举一样为0,1,2,3...

所以: Flags 标记的枚举类型和简单枚举的区别更多来说是概念和定义上,而不是实现上。或者说这可以看成是一种约定。将枚举类型定义为标志枚举, 说明

  • 它的枚举值应该是可以组合的(简单那枚举是互斥的)。

  • 它的枚举的取值应该是2的幂次值

  • 它可以通过位运算来计算或组合成新的值(简单枚举从约定上说不应该有这些操作)

  • 根据上一点,在将标志枚举用于方法或属性时,需要注意验证值, 因为它可能没有在枚举值中定义(通过位运算产生的一个合法的“组合值”)

  • 它可以通过HasFlags来判断一个枚举值是否包含某个位域(即枚举值)定义(简单枚举从约定上说也不会有这个操作, 即使语法上是正确的)

而简单枚举,虽然可以实现上面这些所有约定,但约定之所以称之为约定, 说明在将枚举定义为简单枚举时, 它不应该包含有上面这些特性:

  • 它的枚举值的应该是互斥的
  • 它不应该进行运算,也不该使用HasFlags方法来判断什么东西
  • 它的所有枚举值能够表达完该枚举类型的所有可能的值

等等。

另外需要注意的是HasFlags方法, 方法参数的枚举值也应该是标志枚举的枚举值, 也就是说只有一个二进制位为1。 这依旧是约定,因为即使不是,它也能得到一个可预测的稳定结果(如本节一开始说到)。 但是这个结果可能不是预期的(比如实际需求是二者有共同的标志位即可)。

OK. 结论是: 根据实际需求, 如果需要定义为标志枚举, 则在枚举类型定义前添加Flags标志,并按照定义约定进行编码。 这样, 可以使得代码更加规范和明确。 但二者在实际结果上没有区别。

StringValue Enums

想使用枚字符串作为值的枚举类型的一个需求是, 在某个字段定义为枚举类型,并且需要存到数据库中时,会存储其值,当过了一段时间或者字段变多了之后,直接查看数据库便很难弄明白其值的含义, 需要查阅代码才明白。于是便想到,枚举类型的值能不能是字符串呢?

当然把枚举类型的值定义为字符串的合理性不在此讨论范围内。

如果从这个需求角度出发,那么枚举的意思应该是指“能够穷举的一组值”。

而通过上面的分析,我们知道,在C#中, 枚举类型其实是具有运算的功能的。枚举类型可以进行位运算,组合。很显然, 如果值是字符串的话, 这些功能其实是不好实现的。 所以,C# 不能实现 strig value 的枚举类型

而如果确实不需要考虑枚举的值的这种数值功能,而要获取一个字符串为值的话, 可以考虑下面的降级方案。

  1. 使用char作为值,如:

    enum CharValue
    {
    	A = 'a',
    	B = 'b',
    	C = 'c'
    }
    
    Assert.Equal("A", CharValue.A.ToString());
    Assert.Equal((short)'a', (short)CharValue.A); // ASCII
    Assert.Equal(98, (short)CharValue.B); // ASCII
    

    这种方式其实本质上还是使用int作为值,从下面几个测试可以看出,它所有的方法跟简单枚举其实是一样的。而且其ToString()方法得到的值也是枚举变量名而不是值。 只是说,在使用该枚举的时候, 可以看到一个字符作为值。

  2. 使用静态类或结构体:

    struct StringEnum
    {
    	public static string A => "AAA";
    	public static string B => "BBB";
    	public static string C => "CCC";
    }
    
    Assert.Equal("AAA", StringEnum.A);
    

    这种方式实际上更加符合需求, 从使用和存储上都基本满足了“使用String作为一组可穷举变量的值”的这种需求。 当然自然也会牺牲一些特性,比如:

    • 牺牲枚举类型的数值特性,这个是不可避免的,不再赘述。
    • 定义的变量的类型其实是字符串而不是一种定义的类型。或者确切的说,不是StringEnum本身。

参考资料:

  1. https://stackoverflow.com/questions/8447/what-does-the-flags-enum-attribute-mean-in-c?rq=1
  2. https://docs.microsoft.com/zh-cn/dotnet/api/system.flagsattribute?view=netcore-3.0
  3. https://docs.microsoft.com/zh-cn/dotnet/api/system.enum.hasflag?view=netcore-3.0
  4. https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/enum
  5. https://social.msdn.microsoft.com/Forums/vstudio/en-US/562c4b8c-2960-4983-85ea-dcd7c06b6dce/getting-the-description-of-the-enum-value?forum=csharpgeneral