
記事の冒頭で、まず面接の問題を出します。
ある小規模プロジェクトのデータベース(MySQLと仮定)を設計する際、ユーザーテーブル(User)にユーザーのロールを格納するフィールド(Roles)を追加する場合、このフィールドにはどのような型を設定しますか?ヒント:ロールはバックエンド開発で列挙型で表現される必要があり、かつ1人のユーザーが複数のロールを持つ可能性があることを考慮してください。
最初に思い浮かぶ答えは、おそらく varchar 型で、区切り文字を使って複数のロールを格納する方法でしょう。例えば、1|2|3 や 1,2,3 のようにして、ユーザーが複数のロールを持つことを表現します。もちろん、ロールの数が1桁を超える可能性がある場合、データベースのクエリの利便性(例えば INSTR や POSITION を使ってユーザーが特定のロールを持っているか判断する)を考慮すると、ロールの値は少なくとも10から始める必要があります。この方法は実用的ですが、少しシンプルすぎませんか?もっと良い方法はないでしょうか?
より良い答えは、整数型(int、bigint など)です。利点は、SQLのクエリ条件を記述するのがより便利で、パフォーマンスやスペースの面でも varchar よりも優れていることです。しかし整数は単なる数字であり、どのように複数のロールを表現するのでしょうか?ここでバイナリのビット演算を思い浮かべたあなたは、きっと心の中に答えがあるはずです。
その答えを心に留めつつ、この記事を読み進めてください。もしかすると、思わぬ発見があるかもしれません。実際のアプリケーションでは、さらに連続した問題に遭遇する可能性があるからです。後続の問題をより明確に説明するために、まずは列挙型の基礎知識を復習しましょう。
列挙型の基礎
列挙型の役割は、その変数が限られた選択肢からの値のみを取れるように制限することです。これらの選択肢(列挙型のメンバー)はそれぞれ数字に対応し、デフォルトでは0から始まり、1ずつ増加します。例えば:
public enum Days
{
Sunday, Monday, Tuesday, // ...
}
ここで、Sunday の値は0、Monday は1、といった具合です。各メンバーが表す値を一目で分かるようにするため、一般的にはメンバーの値を明示的に記述し、省略しないことを推奨します。
public enum Days
{
Sunday = 0, Monday = 1, Tuesday = 2, // ...
}
C# の列挙型メンバーの型はデフォルトで int 型ですが、継承によって他の型を宣言することもできます。例えば:
public enum Days : byte
{
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
Sunday = 7
}
列挙型は必ず byte、sbyte、short、ushort、int、uint、long、ulong のいずれかから継承する必要があり、他の型は使用できません。
以下は、列挙型の一般的な使用法のいくつかです(上記の Days 列挙型を例に):
// 列挙型を文字列に変換
string foo = Days.Saturday.ToString(); // "Saturday"
string foo = Enum.GetName(typeof(Days), 6); // "Saturday"
// 文字列を列挙型に変換
Enum.TryParse("Tuesday", out Days bar); // true, bar = Days.Tuesday
(Days)Enum.Parse(typeof(Days), "Tuesday"); // Days.Tuesday
// 列挙型を数値に変換
byte foo = (byte)Days.Monday; // 1
// 数値を列挙型に変換
Days foo = (Days)2; // Days.Tuesday
// 列挙型の基になる数値型を取得
Type foo = Enum.GetUnderlyingType(typeof(Days))); // System.Byte
// すべての列挙型メンバーを取得
Array foo = Enum.GetValues(typeof(MyEnum));
// すべての列挙型メンバーのフィールド名を取得
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 などの属性を使ってメンバーに役立つ補足情報を追加することができます。例えば:
public enum ApiStatus
{
[Description("成功")]
OK = 0,
[Description("リソースが見つかりません")]
NotFound = 2,
[Description("アクセスが拒否されました")]
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()); // "成功"
}
上記の内容で、日常生活で必要な列挙型の知識のほとんどをカバーできていると思います。それでは、記事の冒頭で述べたユーザーロールの保存問題に戻り自しょう。
ユーザーロールの保存問題
まず、2種類のユーザーロールを表す列挙型を定義します。
public enum Roles
{
Admin = 1,
Member = 2
}
これにより、あるユーザーが Admin と Member の両方のロールを持っている場合、User テーブルの Roles フィールドには3が格納されます。では、すべての Admin ロールを持つユーザーを検索するSQLはどのように記述すればよいでしょうか?
基本的な知識を持つプログラマーにとっては、この問題は簡単です。ビット演算子の論理積(&)を使ってクエリを実行すればよいのです。
SELECT * FROM `User` WHERE `Roles` & 1 = 1;
同様に、両方のロールを持つユーザーを検索するSQL文は次のようになります。
SELECT * FROM `User` WHERE `Roles` & 3 = 3;
このSQL文をC#で実装すると次のようになります(簡略化のため、ここではDapperを使用します)。
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 });
対応して、C#でユーザーが特定のロールを持っているかどうかを判断するには、次のようにします。
// 方法1
if (user.Roles & Roles.Admin == Roles.Admin)
{
// 管理者ができることを行う
}
// 方法2
if (user.Roles.HasFlag(Roles.Admin))
{
// 管理者ができることを行う
}
同様に、C#では列挙型に対して任意のビット論理演算を実行できます。例えば、列挙型変数からロールを削除するには:
var foo = Roles.Admin | Roles.Member;
var bar = foo & ~foo;
これにより、記事の前半で述べた整数型を使用して複数のロールを保存する問題が解決されます。データベースでもC#言語でも操作が可能であり、非常に便利で柔軟です。
列挙型のFlags属性
次に、ロールに基づいてユーザーを検索するメソッドを提供し、その呼び出し方法を示します。
public IEnumerable<User> GetUsersInRoles(Roles roles)
{
_logger.LogDebug(roles.ToString());
_connection.Query<User>(
"SELECT * FROM `User` WHERE `Roles` & @roles = @roles;",
new { roles });
}
// 呼び出し
_repository.GetUsersInRoles(Roles.Admin | Roles.Member);
Roles.Admin | Roles.Member の値は3です。Roles 列挙型には値3のフィールドが定義されていないため、メソッド内では roles パラメータは3と表示されます。この3という情報は、デバッグやログ出力には不便です。メソッド内部では、この3が何を表しているのかわかりません。この問題を解決するために、C#の列挙型には便利な属性、FlagsAttribute があります。
[Flags]
public enum Roles
{
Admin = 1,
Member = 2
}
この Flags 属性を追加すると、GetUsersInRoles(Roles roles) メソッドをデバッグするときに、roles パラメータの値が Admin|Member と表示されるようになります。簡単に言うと、Flags を付けるか付けないかの違いは次の通りです。
var roles = Roles.Admin | Roles.Member;
Console.WriteLine(roles.ToString()); // "3" (Flags属性なし)
Console.WriteLine(roles.ToString()); // "Admin, Member" (Flags属性あり)
列挙型に Flags 属性を付けることは、C#プログラミングのベストプラクティスの1つと考えるべきであり、列挙型を定義する際には可能な限り Flags 属性を付けるようにしましょう。
列挙値の衝突解決:2のべき乗
ここまでは、Roles 列挙型に特に問題はありませんでした。しかし、ここで新しいロール Manager を追加するとどうなるでしょうか?数値を順に増やすルールに従うと、Manager の値は3になります。
[Flags]
public enum Roles
{
Admin = 1,
Member = 2,
Manager = 3
}
Manager の値を3に設定してもよいでしょうか?明らかにできません。なぜなら、Admin と Member のビット論理和(Admin | Member)の値も3であり、これは両方のロールを持つことを意味し、Manager と衝突してしまうからです。では、どのように値を設定すれば衝突を避けられるのでしょうか?
バイナリの論理和演算がメンバーの値と衝突する原因は、論理和の性質にあります。論理和では、どちらか一方が1であれば結果は1になります(例:1|1、1|0 は1、0|0 のみ0)。したがって、任意の2つの値が同じ位置で1にならないようにする必要があります。バイナリは2進数で桁上がりする性質を利用して、列挙型の各値が2のべき乗になるようにすれば問題ありません。例えば:
1: 00000001
2: 00000010
4: 00000100
8: 00001000
さらに増やす場合は、16、32、64... となります。これらの値は、どのように加算しても他のメンバーの値と衝突することはありません。これで問題は解決しました。したがって、Roles 列挙型の値は次のように定義する必要があります。
[Flags]
public enum Roles
{
Admin = 1,
Member = 2,
Manager = 4,
Operator = 8
}
ただし、値を定義する際には少し計算が必要です。もっと簡単にしたい場合は、以下のような「シフト」を使った方法で定義することもできます。
[Flags]
public enum Roles
{
Admin = 1 << 0,
Member = 1 << 1,
Manager = 1 << 2,
Operator = 1 << 3
}
このように順に増やしていけば、読みやすく、間違えにくくなります。2つの方法は同等であり、定数のシフト計算はコンパイル時に行われるため、追加のオーバーヘッドはありません。
まとめ
本記事では、小さな面接問題から始まり、列挙型に関する一連の考察を引き出しました。小規模なシステムでは、ユーザーロールをユーザーテーブルに直接保存することはよくある手法であり、その場合、ロールフィールドを整数型(例えばint)に設定するのが良い設計です。同時に、Flags 属性を使用してデバッグやログ出力を改善するなどのベストプラクティスも考慮する必要があります。また、複数の列挙値を論理和(|)で結合した場合にメンバー値と衝突する問題など、実際の開発で発生する様々な潜在的な問題についても考慮しなければなりません。
