C#枚举高级战术

枚举基础

枚举类型的作用是限制其变量只能从有限的选项中取值,这些选项(枚举类型的成员)各自对应于一个数字,数字默认从 0 开始,并以此递增。例如:

  1. public enum Days
  2. {
  3. Sunday, Monday, Tuesday, // ...
  4. }

其中 Sunday 的值是 0,Monday 是 1,以此类推。为了一眼能看出每个成员代表的值,一般推荐显示地将成员值写出来,不要省略:

  1. public enum Days
  2. {
  3. Sunday = 0, Monday = 1, Tuesday = 2, // ...
  4. }

C# 枚举成员的类型默认是 int 类型,通过继承可以声明枚举成员为其它类型,比如:

  1. public enum Days : byte
  2. {
  3. Monday = 1,
  4. Tuesday = 2,
  5. Wednesday = 3,
  6. Thursday = 4,
  7. Friday = 5,
  8. Saturday = 6,
  9. Sunday = 7
  10. }

枚举类型一定是继承自 byte、sbyte、short、ushort、int、uint、long 和 ulong 中的一种,不能是其它类型。
下面是几个枚举的常见用法(以上面的 Days 枚举为例):

  1. // 枚举转字符串
  2. string foo = Days.Saturday.ToString(); // "Saturday"
  3. string foo = Enum.GetName(typeof(Days), 6); // "Saturday"
  4. // 字符串转枚举
  5. Enum.TryParse("Tuesday", out Days bar); // true, bar = Days.Tuesday
  6. (Days)Enum.Parse(typeof(Days), "Tuesday"); // Days.Tuesday
  7. // 枚举转数字
  8. byte foo = (byte)Days.Monday; // 1
  9. // 数字转枚举
  10. Days foo = (Days)2; // Days.Tuesday
  11. // 获取枚举所属的数字类型
  12. Type foo = Enum.GetUnderlyingType(typeof(Days))); // System.Byte
  13. // 获取所有的枚举成员
  14. Array foo = Enum.GetValues(typeof(MyEnum);
  15. // 获取所有枚举成员的字段名
  16. string[] foo = Enum.GetNames(typeof(Days));

另外,值得注意的是,枚举可能会得到非预期的值(值没有对应的成员)。比如:

Days d = (Days)21; // 不会报错 Enum.IsDefined(typeof(Days), d); // false

即使枚举没有值为 0 的成员,它的默认值永远都是 0。

var z = default(Days); // 0

枚举可以通过 Description、Display 等特性来为成员添加有用的辅助信息,比如:

  1. using System;
  2. using System.ComponentModel;
  3. public class Program
  4. {
  5. public static void Main()
  6. {
  7. Console.WriteLine(ApiStatus.OK.GetDescription()); // "成功"
  8. Console.WriteLine("OK".GetDescription<ApiStatus>()); // "成功"
  9. Console.WriteLine("0".GetDescription<ApiStatus>()); // "成功"
  10. Console.WriteLine(0.GetDescription<ApiStatus>()); // "成功"
  11. Console.WriteLine(EnumException.GetDescription<ApiStatus>("0")); // "成功"
  12. }
  13. }
  14. public enum ApiStatus
  15. {
  16. [Description("成功")]
  17. OK = 0,
  18. [Description("资源未找到")]
  19. NotFound = 2,
  20. [Description("拒绝访问")]
  21. AccessDenied = 3
  22. }
  23. public static class EnumExtension
  24. {
  25. public static string GetDescription(this Enum @enum)
  26. {
  27. var fi = @enum.GetType().GetField(@enum.ToString());
  28. var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false);
  29. if (attributes != null && attributes.Length > 0)
  30. return attributes[0].Description;
  31. else
  32. return @enum.ToString();
  33. }
  34. public static string GetDescription<TEnum>(this object @object) where TEnum : struct
  35. {
  36. //return Enum.TryParse(@object.ToString(), out TEnum @enum)
  37. // && Enum.IsDefined(typeof(TEnum), @enum) //需要通过此方法再次确定是否是枚举实际定义的内容
  38. // ? (@enum as Enum).GetDescription() : @object.ToString();
  39. //return EnumException.TryParseDefined(@object.ToString(), out TEnum @enum)
  40. // ? (@enum as Enum).GetDescription() : @object.ToString();
  41. return EnumException.GetDescription<TEnum>(@object.ToString());
  42. }
  43. }
  44. public static class EnumException
  45. {
  46. public static bool TryParseDefined<TEnum>(string value, out TEnum result) where TEnum : struct
  47. {
  48. return TryParseDefined(value,false,out result);
  49. }
  50. public static bool TryParseDefined<TEnum>(string value, bool ignoreCase, out TEnum result) where TEnum : struct
  51. {
  52. return Enum.TryParse(value, ignoreCase, out result) && Enum.IsDefined(typeof(TEnum), result);
  53. }
  54. public static string GetDescription<TEnum>(string value) where TEnum : struct
  55. {
  56. return GetDescription<TEnum>(value, false);
  57. }
  58. public static string GetDescription<TEnum>(string value, bool ignoreCase) where TEnum : struct
  59. {
  60. return TryParseDefined(value, ignoreCase, out TEnum result) ? (result as Enum).GetDescription() : value;
  61. }
  62. }

注意:Enum.TryParse(),不在枚举定义内的数值验证时依然通过,需要Enum.IsDefined()再次确定是否是枚举实际定义的内容

Enum.TryParse 方法 如果 value 是与的命名常数不对应的名称 TEnum ,则该方法将返回 false 。 如果 value 是整数的字符串表示形式,而该整数不表示枚举的基础值 TEnum ,则该方法将返回其基础值 value 转换为整型的枚举成员。 如果不需要此行为,请调用 IsDefined 方法以确保整数的特定字符串表示形式确实为的成员 TEnum 。

用户角色存储问题

我们先定义一个枚举类型来表示两种用户角色:

  1. public enum Roles
  2. {
  3. Admin = 1,
  4. Member = 2
  5. }

这样,如果某个用户同时拥有 Admin 和 Member 两种角色,那么 User 表的 Roles 字段就应该存 3。那问题来了,此时若查询所有拥有 Admin 角色的用户的 SQL 该怎么写呢?
对于有基础的程序员来说,这个问题很简单,只要用位操作符逻辑与(‘&’)来查询即可。

SELECT * FROMUserWHERERoles & 1 = 1;

同理,查询同时拥有这两种角色的用户,SQL 语句应该这么写:

SELECT * FROMUserWHERERoles & 3 = 3;

对这条 SQL 语句用 C# 来实现查询是这样的(为了简单,这里使用了 Dapper):

  1. public class User
  2. {
  3. public int Id { get; set; }
  4. public Roles Roles { get; set; }
  5. }
  6. connection.Query<User>(
  7. "SELECT * FROM `User` WHERE `Roles` & @roles = @roles;",
  8. new { roles = Roles.Admin | Roles.Member });

对应的,在 C# 中要判断用户是否拥有某个角色,可以这么判断:

  1. // 方式一
  2. if (user.Roles & Roles.Admin == Roles.Admin)
  3. {
  4. // 做管理员可以做的事情
  5. }
  6. // 方式二
  7. if (user.Roles.HasFlag(Roles.Admin))
  8. {
  9. // 做管理员可以做的事情
  10. }

同理,在 C# 中你可以对枚举进行任意位逻辑运算,比如要把角色从某个枚举变量中移除:

var foo = Roles.Admin | Roles.Member;
var bar = foo & ~foo;

枚举的 Flags 特性

下面我们提供一个通过角色来查询用户的方法,并演示如何调用,如下:

  1. public IEnumerable<User> GetUsersInRoles(Roles roles)
  2. {
  3. _logger.LogDebug(roles.ToString());
  4. _connection.Query<User>(
  5. "SELECT * FROM `User` WHERE `Roles` & @roles = @roles;",
  6. new { roles });
  7. }
  8. // 调用
  9. _repository.GetUsersInRoles(Roles.Admin | Roles.Member);

Roles.Admin | Roles.Member 的值是 3,由于 Roles 枚举类型中并没有定义一个值为 3 的字段,所以在方法内 roles 参数显示的是 3。3 这个信息对于我们调试或打印日志很不友好。在方法内,我们并不知道这个 3 代表的是什么。为了解决这个问题,C# 枚举有个很有用的特性:FlagsAtrribute。

  1. [Flags]
  2. public enum Roles
  3. {
  4. Admin = 1,
  5. Member = 2
  6. }

加上这个 Flags 特性后,我们再来调试 GetUsersInRoles(Roles roles) 方法时,roles 参数的值就会显示为 Admin|Member 了。简单来说,加不加 Flags 的区别是:

  1. var roles = Roles.Admin | Roles.Member;
  2. Console.WriteLing(roles.ToString()); // "3",没有 Flags 特性
  3. Console.WriteLing(roles.ToString()); // "Admin, Member",有 Flags 特性

给枚举加上 Flags 特性,我觉得应当视为 C# 编程的一种最佳实践,在定义枚举时尽量加上 Flags 特性。

解决枚举值冲突:2 的幂

到这,枚举类型 Roles 一切看上去没什么问题,但如果现在要增加一个角色:Mananger,会发生什么情况?按照数字值递增的规则,Manager 的值应当设为 3。

  1. [Flags]
  2. public enum Roles
  3. {
  4. Admin = 1,
  5. Member = 2,
  6. Manager = 3
  7. }

能不能把 Manager 的值设为 3?显然不能,因为 Admin 和 Member 进行位的或逻辑运算(即:Admin | Member) 的值也是 3,表示同时拥有这两种角色,这和 Manager 冲突了。那怎样设值才能避免冲突呢?
既然是二进制逻辑运算“或”会和成员值产生冲突,那就利用逻辑运算或的规律来解决。我们知道“或”运算的逻辑是两边只要出现一个 1 结果就是 1,比如 1|1、1|0 结果都是 1,只有 0|0 的情况结果才是 0。那么我们就要避免任意两个值在相同的位置上出现 1。根据二进制满 2 进 1 的特点,只要保证枚举的各项值都是 2 的幂即可。比如:

1: 00000001
2: 00000010
4: 00000100
8: 00001000

再往后增加的话就是 16、32、64…,其中各值不论怎么相加都不会和成员的任一值冲突。这样问题就解决了,所以我们要这样定义 Roles 枚举的值:

  1. [Flags]
  2. public enum Roles
  3. {
  4. Admin = 1,
  5. Member = 2,
  6. Manager = 4,
  7. Operator = 8
  8. }

不过在定义值的时候要在心中小小计算一下,如果你想懒一点,可以用下面这种“位移”的方法来定义:

  1. [Flags]
  2. public enum Roles
  3. {
  4. Admin = 1 << 0,
  5. Member = 1 << 1,
  6. Manager = 1 << 2,
  7. Operator = 1 << 3
  8. }

一直往下递增编值即可,阅读体验好,也不容易编错。两种方式是等效的,常量位移的计算是在编译的时候进行的,所以相比不会有额外的开销。

总结

本文通过一道小小的面试题引发一连串对枚举的思考。在小型系统中,把用户角色直接存储在用户表是很常见的做法,此时把角色字段设为整型(比如 int)是比较好的设计方案。但与此同时,也要考虑到一些最佳实践,比如使用 Flags 特性来帮助更好的调试和日志输出。也要考虑到实际开发中的各种潜在问题,比如多个枚举值进行或(‘|’)运算与成员值发生冲突的问题。