C# Enum Advanced Tactics

C# Enum Advanced Tactics

Good reading experience, and not easy to make mistakes

Last updated 6/2/2022 9:32 PM
liamwang 精致码农
10 min read
Category
.NET
Tags
.NET C#

Let's start the article with an interview question:

When designing the database (assuming MySQL) for a small project, if you need to add a field (Roles) to the user table (User) to store user roles, what type would you set for this field? Hint: Consider that roles need to be represented as enums in backend development, and a user may have multiple roles.

The first answer that might come to mind is: varchar type, using a delimiter to store multiple roles, such as 1|2|3 or 1,2,3 to indicate that a user has multiple roles. Of course, if the number of roles might exceed single digits, considering database query convenience (e.g., using INSTR or POSITION to check if a user has a specific role), the role values should at least start from 10. This approach is feasible, but isn't it too simple? Is there a better solution?

A better answer would be an integer type (int, bigint, etc.). The advantages are that writing SQL query conditions is more convenient, and performance and storage space are better than varchar. But an integer is just a number—how does it represent multiple roles? If you're thinking about binary bitwise operations, you probably already have the answer.

Keep that answer in mind and continue reading this article. You might gain some unexpected insights, because practical applications may encounter a series of issues. To better explain the later parts, let's first review the basics of enums.

Enum Basics

The purpose of an enum type is to restrict its variables to a limited set of options. These options (enum members) each correspond to a number, starting from 0 by default and incrementing sequentially. For example:

public enum Days
{
    Sunday, Monday, Tuesday, // ...
}

Here, Sunday has a value of 0, Monday is 1, and so on. To make the value of each member clear at a glance, it's generally recommended to explicitly write out the member values rather than omitting them:

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

The default underlying type of C# enum members is int. By inheritance, you can declare enum members as other types, such as:

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

An enum type must inherit from one of the following: byte, sbyte, short, ushort, int, uint, long, or ulong. No other types are allowed.

Here are some common enum usages (using the Days enum above as an example):

// Enum to string
string foo = Days.Saturday.ToString(); // "Saturday"
string foo = Enum.GetName(typeof(Days), 6); // "Saturday"
// String to enum
Enum.TryParse("Tuesday", out Days bar); // true, bar = Days.Tuesday
(Days)Enum.Parse(typeof(Days), "Tuesday"); // Days.Tuesday

// Enum to numeric
byte foo = (byte)Days.Monday; // 1
// Numeric to enum
Days foo = (Days)2; // Days.Tuesday

// Get underlying numeric type of enum
Type foo = Enum.GetUnderlyingType(typeof(Days)); // System.Byte

// Get all enum members
Array foo = Enum.GetValues(typeof(MyEnum));
// Get all enum member names
string[] foo = Enum.GetNames(typeof(Days));

Additionally, it's worth noting that enums can produce unexpected values (values that don't correspond to any member). For example:

Days d = (Days)21; // No error
Enum.IsDefined(typeof(Days), d); // false

Even if an enum has no member with a value of 0, its default value is always 0.

var z = default(Days); // 0

Enums can use attributes like Description, Display, etc., to add useful auxiliary information to members. For example:

public enum ApiStatus
{
    [Description("Success")]
    OK = 0,
    [Description("Not Found")]
    NotFound = 2,
    [Description("Access Denied")]
    AccessDenied = 3
}

static class EnumExtensions
{
    public static string GetDescription(this Enum val)
    {
        var field = val.GetType().GetField(val.ToString());
        var customAttribute = Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute));
        if (customAttribute == null) { return val.ToString(); }
        else { return ((DescriptionAttribute)customAttribute).Description; }
    }
}

static void Main(string[] args)
{
    Console.WriteLine(ApiStatus.OK.GetDescription()); // "Success"
}

I believe these cover most of the daily enum knowledge we use. Now let's return to the user role storage problem mentioned at the beginning of the article.

User Role Storage Problem

First, let's define an enum type to represent two user roles:

public enum Roles
{
    Admin = 1,
    Member = 2
}

Thus, if a user has both Admin and Member roles, the Roles field in the User table should store 3. The question is: how should the SQL query be written to find all users with the Admin role?

For a seasoned programmer, this is simple—just use the bitwise AND operator (&) in the query.

SELECT * FROM `User` WHERE `Roles` & 1 = 1;

Similarly, to query users who have both roles, the SQL should be:

SELECT * FROM `User` WHERE `Roles` & 3 = 3;

Implementing this SQL query in C# (using Dapper for simplicity) looks like this:

public class User
{
    public int Id { get; set; }
    public Roles Roles { get; set; }
}

connection.Query<User>(
    "SELECT * FROM `User` WHERE `Roles` & @roles = @roles;",
    new { roles = Roles.Admin | Roles.Member });

Correspondingly, in C#, to check if a user has a specific role, you can do:

// Method 1
if (user.Roles & Roles.Admin == Roles.Admin)
{
    // Do admin stuff
}

// Method 2
if (user.Roles.HasFlag(Roles.Admin))
{
    // Do admin stuff
}

Similarly, in C#, you can perform any bitwise logical operations on enums, such as removing a role from an enum variable:

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

This solves the problem of using an integer to store multiple roles, as mentioned earlier. Both the database and C# language operations are feasible, convenient, and flexible.

Enum Flags Attribute

Let's provide a method to query users by role and demonstrate how to call it, as follows:

public IEnumerable<User> GetUsersInRoles(Roles roles)
{
    _logger.LogDebug(roles.ToString());
    _connection.Query<User>(
        "SELECT * FROM `User` WHERE `Roles` & @roles = @roles;",
        new { roles });
}

// Call
_repository.GetUsersInRoles(Roles.Admin | Roles.Member);

Roles.Admin | Roles.Member has a value of 3. Since the Roles enum type does not define a field with value 3, the roles parameter inside the method will display as 3. The number 3 is not very friendly for debugging or logging. Inside the method, we don't know what this 3 represents. To solve this, C# enums have a very useful attribute: FlagsAttribute.

[Flags]
public enum Roles
{
    Admin = 1,
    Member = 2
}

After adding the Flags attribute, when we debug the GetUsersInRoles(Roles roles) method, the roles parameter will display as Admin|Member. In short, the difference with or without Flags is:

var roles = Roles.Admin | Roles.Member;
Console.WriteLine(roles.ToString()); // "3", without Flags attribute
Console.WriteLine(roles.ToString()); // "Admin, Member", with Flags attribute

Adding the Flags attribute to an enum should be considered a best practice in C# programming. Whenever defining an enum, try to add the Flags attribute.

Solving Enum Value Conflicts: Powers of Two

At this point, the Roles enum type seems fine, but what happens if we need to add a new role: Manager? Following the rule of incrementing numeric values, Manager should be set to 3.

[Flags]
public enum Roles
{
    Admin = 1,
    Member = 2,
    Manager = 3
}

Can we set Manager to 3? Obviously not, because the bitwise OR of Admin and Member (i.e., Admin | Member) also equals 3, which means having both roles simultaneously. This conflicts with Manager. How should we assign values to avoid conflicts?

Since the bitwise OR operation conflicts with member values, we can use the rule of OR to solve it. The OR logic results in 1 if either side is 1 (e.g., 1|1 and 1|0 both yield 1), and only 0|0 yields 0. Therefore, we must ensure that no two values have a 1 in the same position. Based on binary's rule of carrying over when reaching 2, as long as each enum value is a power of two, this ensures no overlap. For example:

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

Further increments would be 16, 32, 64, etc. No matter how these values are summed, they will never conflict with any member's value. This solves the problem. So we should define the Roles enum values like this:

[Flags]
public enum Roles
{
    Admin = 1,
    Member = 2,
    Manager = 4,
    Operator = 8
}

However, when defining values, you need to do a little mental calculation. If you prefer a lazier approach, you can use the "shift" method to define them:

[Flags]
public enum Roles
{
    Admin    = 1 << 0,
    Member   = 1 << 1,
    Manager  = 1 << 2,
    Operator = 1 << 3
}

Just increment the shift count sequentially. This improves readability and reduces the chance of errors. Both methods are equivalent; constant shift calculations are performed at compile time, so there is no additional overhead.

Summary

This article explores a series of considerations about enums triggered by a small interview question. In small systems, storing user roles directly in the user table is common, and setting the role field to an integer type (e.g., int) is a good design choice. At the same time, best practices should be considered, such as using the Flags attribute to aid debugging and logging, and accounting for potential issues in development, such as conflicts between OR combinations of enum values and individual member values.

Keep Exploring

Related Reading

More Articles