Skip to content
Go back

C# Flags 枚举:用位运算组合枚举值

C# Flags 枚举:用位运算组合枚举值

普通枚举一次只能存一个值。但有时变量需要同时表达多个状态——用户可以同时拥有 ReadWrite 权限,而不是只能二选一。这正是 C# [Flags] 枚举的用武之地。

[Flags] 属性把一个普通枚举变成位字段(bitfield)。每个成员对应一个独立的比特位,可以单独开关。用按位 OR 组合多个成员,用按位 AND 检测单个成员,ToString() 自动输出有意义的名称而不是数字。这篇文章从声明规则讲到真实场景,覆盖全部常用操作。

什么是 Flags 枚举

Flags 枚举是一个加了 [Flags] 属性的普通枚举,每个成员都被赋予 2 的幂次方值。因为 2 的幂次方之间没有重叠的位,任意组合都会产生唯一的整数:

None    = 0  = 0000
Read    = 1  = 0001
Write   = 2  = 0010
Execute = 4  = 0100

Read | Write 的结果是 0001 | 0010 = 0011 = 3,可以唯一还原出”Read 和 Write 都设置了”这个信息。

没有 [Flags],技术上也能对枚举做位运算,但 ToString() 会输出数字而非标志名,工具和序列化框架也会忽略这个设计意图。

声明 Flags 枚举

[Flags]
public enum FileAccess
{
    None      = 0,
    Read      = 1,            // 0001
    Write     = 2,            // 0010
    Execute   = 4,            // 0100
    ReadWrite = Read | Write  // 0011 -- 复合便捷值
}

声明时有三条规则:

  1. 包含 None = 0:表示”没有任何标志”,对默认初始化和清除操作很重要。
  2. 每个独立标志使用 2 的幂次方1, 2, 4, 8, 16...
  3. 复合便捷值用命名成员组合,不要直接写原始整数:ReadWrite = Read | WriteReadWrite = 3 更清晰。

一个常见错误是连续写 1, 2, 3, 4

// 错误 -- 3 与 1|2 的结果相同,无法区分
[Flags]
public enum Permissions
{
    Read    = 1,
    Write   = 2,
    Execute = 3,  // 应该是 4!
    Delete  = 4   // 应该是 8!
}

Execute = 3 时,你永远无法判断一个变量是持有”Execute”还是”Read 和 Write”。独立标志必须用 2 的幂次方。

设置与组合标志

用按位 OR 组合多个标志:

// 授予 Read 和 Execute 权限
FileAccess access = FileAccess.Read | FileAccess.Execute;
Console.WriteLine(access);      // "Read, Execute"(因为有 [Flags])
Console.WriteLine((int)access); // 5(0001 | 0100 = 0101)

// 追加一个标志
access |= FileAccess.Write;
Console.WriteLine(access);  // "Read, Write, Execute"

// 移除一个标志
access &= ~FileAccess.Write;
Console.WriteLine(access);  // "Read, Execute"

|= 追加标志;&= ~flag 移除标志。波浪号 ~ 是按位 NOT,把目标位取反后再 AND,只清除那一位。

检测标志是否设置

两种方式:按位 AND 或 HasFlag()

FileAccess access = FileAccess.Read | FileAccess.Execute;

// 按位 AND -- 明确,无额外开销
bool canRead    = (access & FileAccess.Read)    != 0;  // true
bool canWrite   = (access & FileAccess.Write)   != 0;  // false
bool canExecute = (access & FileAccess.Execute) != 0;  // true

// HasFlag -- 可读性好,旧运行时有装箱开销
bool alsoCanRead = access.HasFlag(FileAccess.Read);    // true

HasFlag 从 .NET Framework 4 开始提供。在现代 .NET(5+)中,JIT 经常内联 HasFlag,性能和按位 AND 相当。不过在极度性能敏感的热路径上,按位 AND 仍是最安全的选择。

检测是否全部设置

FileAccess required = FileAccess.Read | FileAccess.Write;
bool hasAll = (access & required) == required;

检测是否任意一个设置

bool hasAny = (access & required) != 0;

清除与切换标志

FileAccess access = FileAccess.Read | FileAccess.Write | FileAccess.Execute;

// 移除 Write
access &= ~FileAccess.Write;
Console.WriteLine(access);  // "Read, Execute"

// 清空所有标志
access = FileAccess.None;

// 切换标志(无论当前状态,翻转它)
access ^= FileAccess.Read;
Console.WriteLine(access);  // "Read"(原来关,现在开)
access ^= FileAccess.Read;
Console.WriteLine(access);  // "None"(原来开,现在关)

XOR(^=)切换的应用场景不多,但在不想先判断当前状态就直接翻转时很方便——比如实现 UI 中的”启用/禁用”开关。

ToString() 与解析

加了 [Flags] 后,ToString() 输出逗号分隔的标志名列表:

FileAccess access = FileAccess.Read | FileAccess.Execute;

Console.WriteLine(access.ToString());    // "Read, Execute"
Console.WriteLine(access.ToString("G")); // "Read, Execute"(通用格式)
Console.WriteLine(access.ToString("D")); // "5"(十进制)
Console.WriteLine(access.ToString("F")); // "Read, Execute"(Flags 格式)
Console.WriteLine(access.ToString("X")); // "00000005"(十六进制)

没有 [Flags] 时,access.ToString() 只输出 "5"

解析和普通枚举一样:

if (Enum.TryParse<FileAccess>("Read, Execute", out FileAccess parsed))
{
    Console.WriteLine(parsed);        // "Read, Execute"
    Console.WriteLine((int)parsed);   // 5
}

if (Enum.TryParse<FileAccess>("ReadWrite", out FileAccess rw))
{
    Console.WriteLine(rw.HasFlag(FileAccess.Read));   // true
    Console.WriteLine(rw.HasFlag(FileAccess.Write));  // true
}

底层类型用 long 的场景

默认 int 提供 32 位,但最高位(1 << 31)会产生负值。实际可用的独立标志数量限制在 31 个。需要更多时,换用 long

[Flags]
public enum LargePermissions : long
{
    None         = 0L,
    Permission1  = 1L << 0,
    Permission2  = 1L << 1,
    // ...
    Permission63 = 1L << 62
}

1L << n 语法清晰明了。直接写 1 << 31 会因为有符号溢出产生 -2147483648;换 long 后要用 1L << 31 才能避免这个问题。

实践中,如果一个枚举的标志超过 20-30 个,通常说明设计需要重新审视——可以考虑把标志拆分成多个职责更集中的小枚举。

Flags 枚举 vs 普通枚举

场景用 Flags用普通枚举
变量只持有一个值
变量可以同时持有多个值
示例:DayOfWeek否(一次只能是一天)
示例:工作日组合(周一+周二+周三)
示例:HttpMethod
示例:FileAccess

一个快速判断:变量能否同时合理地是”X 又是 Y”?能——用 Flags;不能——用普通枚举。

序列化 Flags 枚举

System.Text.Json 配合 JsonStringEnumConverter 会把 Flags 枚举序列化为逗号分隔的名称字符串:

using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions
{
    Converters = { new JsonStringEnumConverter() }
};

FileAccess access = FileAccess.Read | FileAccess.Execute;

string json = JsonSerializer.Serialize(access, options);
Console.WriteLine(json);  // "Read, Execute"

FileAccess deserialized = JsonSerializer.Deserialize<FileAccess>(json, options)!;
Console.WriteLine(deserialized.HasFlag(FileAccess.Read));    // true
Console.WriteLine(deserialized.HasFlag(FileAccess.Execute)); // true

在设计 API 时,字符串枚举序列化优于整数序列化:payload 自带文档含义,客户端代码也不会依赖内部数值。

真实示例:用户权限系统

Flags 枚举最常见的实际用途是权限系统:

[Flags]
public enum UserPermission
{
    None         = 0,
    ViewContent  = 1 << 0,   // 1
    CreatePost   = 1 << 1,   // 2
    EditPost     = 1 << 2,   // 4
    DeletePost   = 1 << 3,   // 8
    ManageUsers  = 1 << 4,   // 16
    AdminAccess  = 1 << 5,   // 32

    // 复合角色
    Contributor  = ViewContent | CreatePost,
    Editor       = Contributor | EditPost | DeletePost,
    Admin        = Editor | ManageUsers | AdminAccess
}

public class User
{
    public UserPermission Permissions { get; set; }

    public bool Can(UserPermission permission)
        => Permissions.HasFlag(permission);
}

// 使用示例
var user = new User { Permissions = UserPermission.Contributor };

Console.WriteLine(user.Can(UserPermission.ViewContent));  // true
Console.WriteLine(user.Can(UserPermission.ManageUsers));  // false

// 授予权限
user.Permissions |= UserPermission.EditPost;
Console.WriteLine(user.Can(UserPermission.EditPost));     // true

// 撤销权限
user.Permissions &= ~UserPermission.EditPost;
Console.WriteLine(user.Can(UserPermission.EditPost));     // false

这是一个整洁高效的权限模型:数据库只需要存一个整数列,权限检查是一次位运算,EditorAdmin 等复合角色让代码保持可读性。

常见错误

不使用 2 的幂次方1, 2, 3, 4 这样的值会产生歧义组合。改用 1, 2, 4, 81 << n 语法。

忘记 None = 0:没有 None 时,无法表达”没有任何标志”,default(MyFlags) 是一个没有名称的 0,在 switch 语句和 HasFlag 检查时会出问题。

== 0 而不是检查 None 成员access == FileAccess.None(int)access == 0 在语义上更清晰。

在非 2 的幂次方枚举上加 [Flags][Flags] 只是文档和格式化提示,C# 不会阻止你——但 ToString() 会输出令人困惑的结果,组合操作语义也会不明确。

参考


Tags


Previous

用 .NET Native AOT 编写 Node.js 原生插件

Next

EF Core 10 迁移清理指南:Squash、Reset 与历史管理