C# 入門指南

單元 20 - 程式結構與重構

~~學習進度表~~

C# 程式檔案的結構如下圖,主要分成兩大部分

using 區」就是引入 .NET 程式庫 (library) 相關內容的地方,「namespace 區」則是程式檔案的相關定義,凡是類別 (class) 、結構 (structure) 、介面 (interface) 等等,都在這裡面定義,如果這個命名空間 (namespace) 需要可以執行,就要定義含有 Main() 方法 (method) 的類別。

我們一路走來的範例程式都是這樣的程式結構來寫,要記住的有三大項

  1. 開頭引入 .NET 程式庫;
  2. 其餘程式相關定義都放在 namespace 裡;
  3. 可執行程式要定義含有 Main() 方法的類別。

接下來繼續討論重構,重構 (refactoring) 是指不改變軟體 (software) 外部行為下,對程式原始碼 (source code) 進行整理、修改,不外是希望提升原始碼的可讀性及更易於維護

簡單說,只要感覺原始碼有些不對勁就該重構,尤其在一個大的開發團隊中,每個小組負責的部份可能都不同,因而常常會發生識別字 (identifier) 命名不一致或是太多類別有共通特性的情況。所以需要適時的重構原始程式碼,這也是軟體開發過程中一項重要的工作。

我們利用數學公式設計一種演算法 (algorithm) ,利用這種演算法來建立密碼表,然而這個演算法並不是最理想的方式,因為這個公式建立的密碼表只有

5 × 10 = 50

依據 a 可能有 5 種數字, b 可能有 10 種數字,因此這個演算法最多就只能產生 50 種密碼表。

可是 26 個英文字母的排列會有

1 × 2 × 3 × ⋯⋯ × 25 × 26 = 4.0329 × 1026

由上可見有非常非常多種。

有沒有更理想的方式呢?有的,事實上我們可以直接攪亂字串中字母的順序,這需要另一個攪亂順序的演算法,外加密碼表欄位 (field) 應該要封裝 (encapsulate) 成屬性 (property) ,然後編碼 ToEncode()ToDecode() 都按照 Unicode 原始數值來做計算也太不直覺了,事實上有更簡單直覺的處理方式。

下面是重構後的程式碼

using System;

namespace EncryptNamespace
{
    class Encrypt
    {
        // 宣告私有欄位
        private string _letter, _code;

        // 存取 _letter 的屬性
        public string Letter {
            get {return _letter;}
            set {
                _letter = value;
            }
        }

        // 存取 _code 的屬性
        public string Code {
            get {return _code;}
            set {
                // value 必須是符合英文字母表的字串
                if (value is string && value.Length == 26)
                {
                    _code = value;
                }
                // 不符合條件不做設定,印出錯誤訊息
                else
                {
                    Console.WriteLine("密碼字串錯誤,請修改程式。");
                }
            }
        }

        // 沒有參數的建構子,建立隨機密碼表
        public Encrypt()
        {
            Letter = "abcdefghijklmnopqrstuvwxyz";
            Code = Shuffle(this.Letter);
        }

        // 有參數的的建構子,由參數建立密碼表
        public Encrypt(string newCode)
        {
            Letter = "abcdefghijklmnopqrstuvwxyz";
            Code = newCode;
        }

        // 攪亂字串順序
        string Shuffle(string s)
        {
            // 將參數字串轉換成字元陣列
            char[] s_array = s.ToCharArray();
            // 建立隨機物件
            Random r = new Random();
            // 取得字串長度
            int n = s.Length;
            // 依序攪亂字元陣列的順序
            while (n > 1)
            {
                n--;
                int k = r.Next(n + 1);
                var v = s_array[k];
                s_array[k] = s_array[n];
                s_array[n] = v;
            }

            // 最後回傳將字元陣列合併的字串
            return new string(s_array);
        }

        // 編碼方法
        public string ToEncode(string s)
        {
            // result 為處理過程中的暫存字串
            string result = "";
            // 逐一取得字元進行處理
            foreach (char i in s)
            {
                // 判斷是否為英文小寫字母
                if (this.Letter.Contains(i.ToString()))
                {
                    result += this.Code[this.Letter.IndexOf(i)];
                }
                // 不是英文小寫字母,直接將字元附加到 result 之後
                else
                {
                    result += i;
                }
            }
            // 回傳結果
            return result;
        }

        // 解碼方法
        public string ToDecode(string s)
        {
            // 建立暫存字串
            string result = "";
            // 逐一取得字元進行處理
            foreach (char i in s)
            {
                // 判斷是否為英文小寫字母
                if (this.Letter.Contains(i.ToString()))
                {
                    result += this.Letter[this.Code.IndexOf(i)];
                }
                // 不是英文小寫字母,直接將字元附加到 result 之後
                else
                {
                    result += i;
                }
            }
            // 回傳結果
            return result;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            // 建立密碼物件 e
            Encrypt e = new Encrypt();
            // 印出密碼表
            Console.WriteLine(e.Code);
            // 設定測試字串
            string s1 = "There is no spoon.";
            // 印出測試字串
            Console.WriteLine(s1);
            // 進行編碼
            string s2 = e.ToEncode(s1);
            // 印出編碼後的字串
            Console.WriteLine(s2);
            // 進行解碼
            string s3 = e.ToDecode(s2);
            // 印出解碼後的字串
            Console.WriteLine(s3);
            // 設定長度不符的新密碼物件
            Encrypt e2 = new Encrypt("no");
            // 設定長度相符的新密碼物件
            Encrypt e3 = new Encrypt("qazwsxedcrfvtgbyhnujmikolp");
            // 再次進行編碼
            string s4 = e3.ToEncode(s1);
            // 印出編碼後的字串
            Console.WriteLine(s4);
            // 再次進行解碼
            string s5 = e3.ToDecode(s4);
            // 印出解碼後的字串
            Console.WriteLine(s5);
        }
    }
}

//《程式語言教學誌》的範例程式
// http://kaiching.org/
// 專案:EncryptNamespace
// 檔名:Program.cs
// 功能:示範重構後的 Encrypt 類別
// 作者:張凱慶

Encrypt 類別中,首先封裝 _letter_code_letter 是英文小寫字母表, _code 則是攪亂順序的密碼表

// 宣告私有欄位
private string _letter, _code;

為什麼需要英文小寫字母表 _letter 呢?因為利用 _letter_code 兩者索引值對照的方式,就可以實現編碼及解碼。

下面建立 LetterCode 兩個屬性,分別存取 _letterCode

// 存取 _letter 的屬性
public string Letter {
    get {return _letter;}
    set {
        _letter = value;
    }
}

// 存取 _code 的屬性
public string Code {
    get {return _code;}
    set {
        // value 必須是符合英文字母表的字串
        if (value is string && value.Length == 26)
        {
            _code = value;
        }
        // 不符合條件不做設定,印出錯誤訊息
        else
        {
            Console.WriteLine("密碼字串錯誤,請修改程式。");
        }
    }
}

注意 Code 中的 set 進行條件檢查

// value 必須是符合英文字母表的字串
if (value is string && value.Length == 26)

這是說 _code 是會符合長度 26 的字串 (string) ,只要是符合長度 26 的字串都可以當作密碼表,並不一定全都要是小寫英文字母。

建構子 (constructor) 有兩個,沒有參數 (parameter) 的建構子利用 Shuffle() 攪亂 Letter 順序,產生隨機的密碼表,有參數的建構子利用參數建立密碼表

// 沒有參數的建構子,建立隨機密碼表
public Encrypt()
{
    Letter = "abcdefghijklmnopqrstuvwxyz";
    Code = Shuffle(this.Letter);
}

// 有參數的的建構子,由參數建立密碼表
public Encrypt(string newCode)
{
    Letter = "abcdefghijklmnopqrstuvwxyz";
    Code = newCode;
}

之所以要有參數的建構子版本,這是因為如果將密碼表存檔,載入後可以直接用密碼表設定 Encrypt 物件。

Shuffle() 方法用來攪亂字串順序

// 攪亂字串順序
        string Shuffle(string s)
{
    // 將參數字串轉換成字元陣列
    char[] s_array = s.ToCharArray();
    // 建立隨機物件
    Random r = new Random();
    // 取得字串長度
    int n = s.Length;
    // 依序攪亂字元陣列的順序
    while (n > 1)
    {
        n--;
        int k = r.Next(n + 1);
        var v = s_array[k];
        s_array[k] = s_array[n];
        s_array[n] = v;
    }

    // 最後回傳將字元陣列合併的字串
    return new string(s_array);
}

事實上,字串是不可變的,這意思是說一但建立字串物件後,就不能再去改變該字串物件中的字元,所以這裡將字串轉換成字元陣列 (character array) ,字元陣列就可以替換陣列 (array) 元素,也就是字元 (character)

// 將參數字串轉換成字元陣列
char[] s_array = s.ToCharArray();

攪亂字元陣列順序採用 while 迴圈,這叫做 Fisher–Yates shuffle 演算法,可以有效地攪亂陣列元素的順序

// 依序攪亂字元陣列的順序
while (n > 1)
{
    n--;
    int k = r.Next(n + 1);
    var v = s_array[k];
    s_array[k] = s_array[n];
    s_array[n] = v;
}

簡單講, Fisher–Yates shuffle 演算法隨機取得陣列元素,上例用 RandomNext() 取得隨機位置 k ,然後把該陣列元素放到暫存變數 v ,接著把 nk 的元素互換位置,因此像是小寫英文字母表,只要跑 26 次就可以得到攪亂順序的字元陣列,非常的有效率。

編碼方法 ToEncode() 改成用索引值對照的方式,注意到編碼工作實際上只有一行程式碼

result += this.Code[this.Letter.IndexOf(i)];

因為 Letter 中的索引值的字元,剛好就是密碼表中要轉換的字元,相對解碼方法 ToDecode() 中的解碼工作,也只有一行程式碼

result += this.Letter[this.Code.IndexOf(i)];

變成用 Code 的索引值取得 Letter 的字元

最後執行部分,除了將測試字串編碼、解碼外,另外增加第二個建構子的測試程式,除了長度不符的字串也有長度相符的字串

static void Main(string[] args)
{
    // 建立密碼物件 e
    Encrypt e = new Encrypt();
    // 印出密碼表
    Console.WriteLine(e.Code);
    // 設定測試字串
    string s1 = "There is no spoon.";
    // 印出測試字串
    Console.WriteLine(s1);
    // 進行編碼
    string s2 = e.ToEncode(s1);
    // 印出編碼後的字串
    Console.WriteLine(s2);
    // 進行解碼
    string s3 = e.ToDecode(s2);
    // 印出解碼後的字串
    Console.WriteLine(s3);
    // 設定長度不符的新密碼物件
    Encrypt e2 = new Encrypt("no");
    // 設定長度相符的新密碼物件
    Encrypt e3 = new Encrypt("qazwsxedcrfvtgbyhnujmikolp");
    // 再次進行編碼
    string s4 = e3.ToEncode(s1);
    // 印出編碼後的字串
    Console.WriteLine(s4);
    // 再次進行解碼
    string s5 = e3.ToDecode(s4);
    // 印出解碼後的字串
    Console.WriteLine(s5);
}

執行結果如下

C:\Encrypt> dotnet run
tgkqjpzamvnhedrlbuycwfxois
There is no spoon.
Tajuj my dr ylrrd.
There is no spoon.
密碼字串錯誤,請修改程式。
Tdsns cu gb uybbg.
There is no spoon.
C:\Encrypt>

這樣 Encrypt 專案的程式碼是不是更容易理解了呢?接下來我們就要進入 GUI 的部份了,在此之前先來認識一下 Visual Studio Community 2019 吧!

相關教學影片

上一頁 單元 19 - 解碼
回 C# 入門指南首頁
下一頁 單元 21 - 認識 Visual Studio Community 2019
回 C# 教材首頁
回程式語言教材首頁
中英文術語對照
演算法algorithm
陣列array
字元character
字元陣列character array
類別class
建構子constructor
封裝encapsulate
欄位field
識別字identifier
介面interface
程式庫library
方法method
命名空間namespace
參數parameter
屬性property
重構refactoring
軟體software
原始碼source code
字串string
結構structure
參考資料
1. C# 程式設計手冊 | Microsoft Docs - 類別
2. Visual Studio | Microsoft Docs - 重構
重點整理
1. C# 程式結構主要是定義命名空間,然後在命名空間中放所有的定義,如果這個命名空間是可以執行的,就要在類別中定義 Main() 方法。
2. 重構是指重新整理程式碼,讓程式更易於維護。
3. 常見的重構技術包括整理屬性、方法,挑出類別的共通特性定義父類別等等。
問題與討論
1. 什麼是重構?為什麼要替開發好的程式進行重構?
2. Encrppt 類別進行了哪些重構?為什麼要作這些重構?
練習
1. 承接上一個單元的猜數字遊戲,將新專案寫在 Exercise2001 中,替建構子新增一個參數 digit ,並且將 digit 預設為 4 ,注意呼叫 Substring() 也要改成 digit
2. 承上題,將新專案寫在 Exercise2002 中,將 Guess 類別的欄位 answerab 封裝,並設定相關屬性,注意原本在 Program 類別的 Main() 呼叫的欄位也要同時改成屬性。