前陣子碰到個資料表儲存方式,因為這種位元運算的方式也常用在權限管理等地方,這邊就順手紀錄一下。

平常遇到二元的情形(例如 開/關、有/沒有),我們會直接宣告個 Boolean 來處理。但這次遇到的是同時有多個「有/沒有」的狀況,但我遇到的程式碼並沒有分成多個 Bool 去做處理,而是直接儲存成一個數值。

由於「有/沒有」只佔據一個位元,那麼將多個狀況按照位元順序排列的話,就只需要一個數字就可以紀錄或傳遞給其他系統了。例如最常見的用處是在權限系統,若有「讀」、「寫」、「執行」等權限,那麼我們按照這個順序去排列,當 讀=可、寫=不可、執行=可 的時候,就記做 101 = 5。這種直接用一組位元表示狀態的方式就叫做位元旗標(Bit flag)

1 讀   = 可
0 寫   = 不可
1 執行 = 可

/* 橫放 */
=> 101 (2進位) 
=> 5 

假使某天老闆靈光一現,決定接下來的新人員工都要記錄他們會的程式語言,並且他們報到的時候就會發一張公司列好的程式語言清單請他們勾選。

Enum & Flags

在 C# 中已經有方便的工具可以處理數字列表,我們可以建立一個叫做 SkillEnum 的列舉(Enum),並且按照上面說明的,將老闆提到的每個技能各自用一個位元來表示。

[Flags]
public enum SkillEnum
{
    C = 1,     // 0001
    PHP = 2,   // 0010
    SQL = 4,   // 0100
    Java = 8,  // 1000
}

註:[Flags]的標籤是指 C# 專門提供給位元旗標使用的 Enum,請參見 FlagsAttribute

只要在 enum 上加上 Flags 的屬性,除了自動按照 2 的次元增加以外,在使用 ToString() 也能更方便看見旗標內容

有了這個列舉之後,我們就可以表達不同排列組合的狀況了。例如:

* C: C
* P: PHP
* S: SQL
* J: Java
===========
J S P C
0 0 0 0 => 什麼都不會
0 0 0 1 => 只會 C
0 0 1 1 => 同時會 C 和 PHP
1 0 1 0 => 同時 PHP 和 Java
1 1 1 1 => 全部都會

查詢 => AND

那麼假使今天來的新人 Bob,他會 C, PHP 兩種語言,那現在他的資料欄位,就會是這兩種語言對應的位置為 1,其餘為 0,也就是 0011 = 3

var BobSkills = 3;
// var BobSkills = 0b_0011; // C + PHP

查詢 的時候,就將 Bob 的數值和目標數值做 AND 運算。例如說我們想知道 Bob 會不會 C,就可以將 3(二進位 0011) 和代表 C 的 1 (二進位 0001) 做 &,即可知道 Bob 會不會 C。

    J S P C
    0 0 1 1 Bob
AND 0 0 0 1 SkillEnum.C
-----------
    0 0 0 1 => 1 > 0 => True
    J S P C
    0 0 1 1 Bob
AND 1 0 0 0 SkillEnum.Java
-----------
    0 0 0 0 => 0 => False

也就是必須要 同個位置的值都是 1,在這邊就是指 Bob 的技能中 Java 的欄位,和 SkillEnum 中對應的欄位都要是 1 的時候,才會有數值。否則就會是 0:

var BobSkills = 3;
// var BobSkills = 0b_0011; // C + PHP

// 確認是否有某個 Flag,使用 And(&)
var isBobKnowC = BobSkills & (int)SkillEnum.C;
$"Bob 會使用 C 嗎?{Convert.ToBoolean(isBobKnowC)}".Dump(); // True

var isBobKnowJava = BobSkills & (int)SkillEnum.Java;
$"Bob 會使用 Java 嗎?{Convert.ToBoolean(isBobKnowJava)}".Dump(); // False

賦予 => OR

那假設 Bob 經過一番苦練,又掌握了 SQL 這門語言呢?當我們要 賦予 的時候,就需要用 OR

也就是當 兩者之間任一為 1 的時候,在這邊也就是當 Bob 的技能和我們要求給他的 SkillEnum 同位置只要有一個是 1,那就會有數值:

    J S P C
    0 0 1 1 Bob
 OR 0 1 0 0 SkillEnum.SQL
-----------
    0 1 1 1 => New Bob

例如說原本 Bob 不會 SQL,所以 SQL 那一欄就會是 0 。而我們把它和 SkillEnum.SQL(也就是只有 SQL 那一欄是 1 )做 OR 運算後,接著我們只要將運算好的結果再賦值給 Bob,這樣 Bob 的 SQL 欄位就會變成 1 了,同時也不會影響到其他欄位。

現在我們就可以用 OR 把 SQL 的技能傳授給 Bob:

var isBobKnowSQL = BobSkills & (int)SkillEnum.SQL;
$"Bob 會使用 SQL 嗎?{Convert.ToBoolean(isBobKnowSQL)}".Dump(); // False

// 賦予某個 Flag,使用 OR (|)
BobSkills = BobSkills | (int)SkillEnum.SQL;
$"(Bob 學習 SQL 中)".Dump();

isBobKnowSQL = BobSkills & (int)SkillEnum.SQL;
$"Bob 會使用 SQL 嗎?{Convert.ToBoolean(isBobKnowSQL)}".Dump(); // True

移除 => XOR

經過了很久很久以後,Bob 已經忘記當年學的 SQL 怎麼寫了。我們又要怎麼把他的 SQL 這項技能給拿掉呢?

當我們要 移除 某一項旗標的時候,只需要使用 XOR 就行了。XOR 是指互斥,就像磁碟兩極一樣。當兩者不同為 1,若相同時則為 0

因此當 Bob 代表 SQL 的欄位為 1 的時候,我們再將 SQL 為 1 的 的數值丟進去做 XOR,就可以把兩者同時為 1 的欄位給變回 0,並且讓原本為 1 的欄位持續為 1,原本為 0 的欄位持續為 0,達到移除指定目標的效果。

但在使用上要注意,必須先確認目標欄位的確有數值,也就是 Bob 是真的已經會 SQL,否則若原本不會 (0) 的將不小心學會 (0 XOR 1 => 1)。

    J S P C
    0 1 1 1 Bob
XOR 0 1 0 0 SkillEnum.SQL
-----------
    0 0 1 1 => New Bob

// 警告:使用 XOR 之前一定要先檢查,若原本是關閉 (0) 的將會被打開 (0 XOR 1 => 1)

現在我們就讓 Bob 忘記他曾經學過的 SQL:

isBobKnowSQL = BobSkills & (int)SkillEnum.SQL;
$"Bob 會使用 SQL 嗎?{Convert.ToBoolean(isBobKnowSQL)}".Dump(); // True

// 挪除某個 Flag,使用 XOR (^)
BobSkills = BobSkills ^ (int)SkillEnum.SQL;
$"(Bob ... 忘記了SQL!)".Dump();

isBobKnowSQL = BobSkills & (int)SkillEnum.SQL;
$"Bob 會使用 SQL 嗎?{Convert.ToBoolean(isBobKnowSQL)}".Dump(); // False

多項賦予 => OR, OR, OR

隨著 Bob 逐漸老去,公司也招來了新員工。如今換成 Bob 來幫他維護技能表了,那我們要怎麼用 SkillEnum 給這個菜雞預設值呢?

聰明的你應該能猜出其實這也就是賦值!只要把有的項目全部 OR 起來就可以了,這位新菜雞他會 C, SQL, Java:

    J S P C
    0 0 0 0 Newbie
 OR 0 0 0 1 SkillEnum.C
 OR 0 1 0 0 SkillEnum.SQL
 OR 1 0 0 0 SkillEnum.Java
-----------
    1 1 0 1 => 13 => Newbie's Skills

那麼現在就讓我們用 C# 實作:

// 賦與多個值 = 一路 OR 下去
var NewbieSkills =    
    (int)SkillEnum.C |   
    (int)SkillEnum.SQL |     
    (int)SkillEnum.Java; 

$"Bob 技能欄的十進位為:{NewbieSkills}".Dump(); // 13
$"Bob 技能欄的二進位為:{Convert.ToString(NewbieSkills, 2)}".Dump(); // 1101

列出內容 => Foreach, Flags

那如果我想要確認現在有哪些欄位是開啟的呢?例如說,當我們要確認 Bob 會哪些程式語言的時候怎麼做呢?

既然用 AND 可以查詢其中一個位置,那麼只要將列舉和位元用迴圈逐一 AND 出來,就可以還原 Bob 的列表囉

var enumCount = Enum.GetNames(typeof(SkillEnum)).Count();
var NewbieSkillsList = new List<string>();

for (var i = 0; i < enumCount; i++)
    if(Convert.ToBoolean(Bob2Skills & (int)Math.Pow(2, i))) // AND 運算
        NewbieSkillsList.Add(((SkillEnum)Math.Pow(2, i)).ToString());
$"Newbie 會的語言有:{String.Join(", ", NewbieSkillsList)}".Dump();
// C, SQL, Java

不過上面這個依序列印的方式還是太麻煩了。如果有像前半段提到的,替 Enum 加上 [Flags] 標籤的話,用起來就更簡單了:

$"Newbie 掌握的技能為:{(SkillEnum)NewbieSkills}".Dump(); 
// C, SQL, Java

最後再總結一下:

  • 檢查的時候用 AND 找出目標的位置是否為 1
  • 賦予內容時則用 OR 讓指定的位置變成 1
  • 移除的時候則用 XOR 讓目標位置的 1 抵銷為 0

整理來說概念並不困難,只是一個位元對應一個對象,再視情況進行運算而已。但能應用的範圍相當廣泛,除了最常用的權限管理,其他諸如活動月份、門鎖狀況等等只要符合條件的情形都可以借這個方法來處理。這邊稍作紀錄,希望以後能派上用場。

延伸閱讀及參考資料