The two most important days in your life are the day you were born and the day you find out why. -Mark Twain

顯示具有 出神入化的用介面 標籤的文章。 顯示所有文章
顯示具有 出神入化的用介面 標籤的文章。 顯示所有文章

#81 出神入化的用介面 第四集 修改共用的介面 part.2

這一篇文章是上一集的延伸,再來說明新版本 interface 的後續實際使用的情況.先將此篇文章要解決問題再描述一次.想像一家公司出版了一套軟體,而這個產品裡包含了許多的元件檔案 (.dll),而且每一個元件是由不同的團隊產生.每個元件可以有獨立的發行時間,也就是說當 A 團隊要釋出新功能的版本時,他們可以自行釋出.在客戶端裡,可以透過該軟體裡的更新程式來下載並且安裝 A 團隊所製做的新版本元件.由於各元件釋出的時間並非一致,因此元件之間的互動將變得有挑戰性.

A 團隊裡的某些功能是透過 B 團隊的元件所完成的.例如,A 團隊提供的功能裡有一項是計算產品折扣,而這項功能的細節實作者其實是 B 團隊,因此 B 團隊會提供 interface component 給 A 團隊使用,如:



因此,在 A 團隊的程式碼裡,將可能利用下述的程式碼得到實做該介面的物件,然後來進行折扣計算,如:



接著,A, B 團隊從產品經理接收到新需求,他們必須提供一個新的功能 - 回饋金 (cash back).因此,比較簡單的做法便是將上述的 interface 加以延伸.但如上一集文章所提,B 團隊不能直接改 IProductUtils,所以他們必須要製造一個新的 interface,並且繼承自 IProductUtils.



A 團隊接著將他們的程式碼修改如下:



若 A 團隊用上述的方式更改程式,則有可能發生錯誤.當某個用戶端使用新版的 A 團隊程式而且用了舊版的 B 團隊程式,則執行到上述程式碼時,就會發生 TypeLoadException 的錯誤,因為此時電腦裡是舊版的 B 團隊元件,所以 runtime 看不懂 IProductUtils2,對它而言,這是一個 unknown type.

為了防止上述的錯誤發生,A 團隊必須採用一些方法來辨別 runtime 所載入的是舊版或新版的元件.可能的方法如下:

  • 讓 B 團隊為不同版本的元件使用不同的檔案名稱.如 BContract.1.dll 和 BContract.1.1.dll 等.B 團隊發行新版本時,可將兩個新舊兩版的 contract dll 一起釋出,同時在實做的程式碼裡也會同時實做新舊兩版的 interfaces.所以,A 團隊若用舊版 (BContract.1.dll),則 A 團隊只會看到 IProductUtil,若用新版 (BContract.1.1.dll),可看到 IProductUtil 和 IProductUtil2.這樣做的好處是讓新舊兩版的 interface 位於不同的元件裡,當其他團隊取得舊版時,其他團隊只能看到 IProductUtil.使用新版時,則可看到兩個 interfaces.利用實體檔案區別同一個功能的新舊版本.壞處就是可能會造成過多的元件檔案.




  • 讓 B 團隊將新舊版本的 interfaces 寫在同一個元件檔案裡,如 BContact.dll.這樣做將讓 A 團隊無法從元件檔名上知道他們正使用的是新版還是舊版.因此,就必須透過程式碼來解決這問題.A 團隊為了處理新舊兩版的情況,當他們載入 B 團隊的元件時,他們可先嘗試使用 IProductUtil2,若 IProductUtil2 使用時發生 TypeLoadException,則表示 B 團隊的元件是舊版的.此時 A 團隊便可以把回饋金功能的相關 UI 畫面關閉.請參考下列程式碼來看 A 團隊如何使用正確的版本.



    透過上述的方式,當 runtime 執行到 line 12 準備進入 TryGetNewVersion() 時,如果此時 A 團隊使用的是舊版 B 團隊元件,則 runtime 便會發生 TypeLoadException 的錯誤,因為 line 28 的 IPorductUtils2 是一個 unknown type,並且此錯誤會在 line 18 被抓住.接著 A 團隊便可以關閉回饋金的相關功能.如果 A 團隊使用新版本,則不會發生 TypeLoadException.只要能順利取得正確的物件就能呼叫 GetCashBack() 來得到回饋金的金額.若用這個方法,則 A 團隊必須使用 B 團隊的新版本元件在他們的編輯環境裡,才不會發生 build fail (unknown type) 的情況.




  • 這一篇文章提到的情境應該不會是大多數人的情境,畢竟同一個軟體產品裡每一個元件可以有不同的釋出時程是很少見的.如果你的工作情況真的有符合這樣的情境,希望這篇文章能給你一種解法來解決這情境的問題.若你的解法不同,也歡迎你分享給我.

    Hope it helps,

    Share:

    #63 出神入化的用介面 第三集_修改共用的介面

    上一集的內容中曾提過三個團隊負責三個不同的元件,團隊一負責 ClassLibrary1,團隊二負責 ClassLibrary2,團隊三負責主要的 UI 主體 (WindowsFormApp1) 以及 CommonLibary.你可以把這三部份的功能想像成是一個普通的軟體產品.在產品演進的過程中勢必會再提供更多的功能,這可能會讓 ClassLibrary1 和 ClassLibrary2 之間的互動會更多,這也代表修改共用的介面 (在 CommonLibrary 裡) 是必需的.如果這三個團隊擁有一致的產品釋出時程,則共用 Interface 的修改並不會造成任何影響.但如果不是如此,則修改共用的 Interface 將會是個棘手的事情.



    接下來,我們來看三個團隊的產品釋出時程是不一樣的情況.團隊一所負責的 ClassLibrary1,因其功能很容易受到市場影響,所以有著較短產品釋出時程,每隔二個月就會推出新版本.團隊二和團隊三所負責的 ClassLibrary2, CommonLibrary, WindowFormsApp1 是一些基本且較少變動的功能,所以其產品釋出的間隔較長,大約每隔半年才需要更新一次.所以一年裡,ClassLibrary1 會推出六個新版本,ClassLibrary2, CommonLibrary, WindowsFormsApp1 只會推出二個新版本.ClassLibrary1 可以各自獨立釋出,不受限要和 WindowsFormsApp1 或 ClassLibrary2 一起釋出.

    共用的介面仍維持跟前一個版本一樣的狀態.如上一集所講的,其共用介面的長相如下:

    public interface IOperation
    {
        string Name { get; }
        string Description { get; }
        int AddIntOperation(int i);
        string ChangeStringOperation(string input);
    }

    假設今天團隊二和團隊三釋出新版的 ClassLibrary2, CommonLibrary, WindowsFormsApp1,其中 ClassLibrary2 也提供一個新的 method 給 ClassLibrary1 使用.於是就會遇到以下的問題:

    1. 如果直接修改 IOperation,將新的 method 定義加上去的話,那麼對舊版的 ClassLibrary1 會造成問題,因為 IOperation 有不同的 interface 定義.
    2. 在不久的未來, ClassLibrary1 也將釋出新版,它可能會被更新在舊版的 WindowsFormsApp1 上,也可能被更新在新版的 WindowsFormsApp1 上,ClassLibrary1 怎麼知道它所面對的 IOperation 是新的還是舊的呢?

    問題當然不止這兩個,但這兩個算是最麻煩的了.首先,IOperation 在舊版裡只有三個 properties 一個 method,在新版裡多了一個 method,變成三個 properties 兩個 methods.如果直接對 IOperation 上修改,這一定行不通的,因為現有版本的 ClassLibrary1 認得的 IOperation 是三個 properties 一個 method.因此,為了讓現有版本的 ClassLibrary1 能繼續使用,所以 IOperation 不能變動.為了讓新版的 ClassLibrary2 提供新的 method,最簡單的方法就是創造一個新的 interface,並且將它繼承 IOperation,如下所示:

    public interface IOperation2 : IOperation
    {
        int MinusIntOperation(int i);
    }
    

    IOperation2 是 IOperation 的小孩,所以 IOperation 有的,IOperation2 都有,並且 IOperation2 增加了一個 method,用來實現 ClassLibrary2 提供的新功能,在 ClassLibrary2 會有一個新的 class 用來實作 IOperation2 的內容.這樣的做法解決了上面所提的第一個問題.一旦新版的 ClassLibrary2, CommonLibrary, WindowsFormsApp1 被釋出時,此時的 ClassLibrary1 還沒有新版,所以 ClassLibrary1 只認得 IOperation,不會認得 IOperation2.而 ClassLibrary2 和 CommonLibrary 裡都把 IOperation 的相關內容都保留了,因此新版的 ClassLibrary2 還是能照常提供原有的功能給 ClassLibrary1 使用.

    接下來 ClassLibrary1 也要釋出新版了.因為 IOperation2 已經釋出了,所以新版的 ClassLibrary1 必須設計成要能使用 IOperation2,同時新版的 ClassLibrary1 也必須保留原有的功能.接蓍 ClassLibrary1 就面臨到上述第二個問題,也就是 ClassLibrary1 被下載更新時,它有可能被更新在舊版的 ClassLibrary2 上 (沒有 IOperation2),也有可能被更新在新版的 ClassLibrary2 上 (有 IOperation2).此時 ClassLibrary1 怎麼知道它被下載更新後所面對的是舊版還是新版的 ClassLibrary2 呢? 方法應該有好幾種,在這提供兩個簡單且直覺的.

    第一: ClassLibrary1 可以檢查 CommonLibrary/ClassLibrary2 的 assembly version 或是 file version.因為 IOperation2 隨著新版的 ClassLibrary2, CommonLibrary, WindowsFormsApp1 釋出,所以 ClassLibrary1 可以知道他們釋出時的版本資訊.

    第二: 當 ClassLibrary1 想要使用 IOperation2 時,並不直接使用它.因為 IOperation2 是 IOperation 的小孩,所以當 ClassLibrary1 得到來自 ClassLibrary2 的物件時,一律先將它視為 IOperation,這樣就可以成功讓該物件進入到 ClassLibrary1 的領域中,然後再試著將該物件轉型 (type conversion) 成 IOperation2,如果可以轉型成功,那代表該物件是實作了 IOperation2,也就表示 ClassLibrary1 面對的是新版的 ClassLibrary2.以下是 ClassLibrary1 裡嘗試使用 IOperation2 的簡單 code:

    if (ObjectContainer.Operations.TryGetValue("operation2", out IOperation op))
    {
        IOperation2 op2 = op as IOperation2;
        if (op2 == null)
        {
            richTextBox1.Text = "We are using older version of interface";
            return;
        }
    
        int result = op2.MinusIntOperation(100);
        richTextBox1.Text = $"We are using a new version of interface and the result is {result}";
    }
    else
    {
        richTextBox1.Text = "Cannot find Operation2";
    }

    以上的情況在一般的大型軟體系統中其實是很常見的,解決的方法當然不止如上述的方法,而上述的內容也是用在 Visual Studio 裡,中間有很多細節省略了,但重點就是修改共用 interface 時,是生一個小孩來繼承它,把原有的 interface 原封不動地保留.
    Share:

    #61 出神入化的用介面 第二集_物件如何在大型軟體系統中移動

    在上一集中談到最基礎的 interface 應用和簡單的例子,因此從上一集的內容中應該能讓你了解到 interface 的用途之一.interface 的用途很廣,除了可以做一些物件抽象化的表示方式以外,也可以用來幫助一個物件在一個大型的軟體中不受元件範圍的限制而讓其他不同的元件來使用.在上一集的內容中,你已經看到了最基本的抽象化應用,透過 email interface 的建立,讓所有的團隊可以依照同一份 interface 的規格實做出各自所用的物件,在這一集的內容中將展現一個極為簡單的例子用來說明一個物件如何在大型的軟體系統中移動.

    首先,簡介此簡單的例子,下圖是這例子中的元件,一共有四個元件:


    WindowsFormsApp1.exe 是整個軟體的基礎,它提供 IDE 介面,以及負責尋找系統上有那些元件,並且呼叫各元件的註冊程式,將每個元件所提供的功能記錄下來.

    CommonLibrary.dll 是一個讓各元件都能使用到的共用內容,如一些共用的 interface 定義以及元件被註冊時所需要的空間.

    ClassLibrary1 包含了該元件所提供的 Form 和相關的功能,同樣地,ClassLibrary2 也包含了一些 Form 和相關的功能.

    上圖中的線條代表 dependency 關係,所以 WindowsFormsApp1 認識另外三元件,ClassLibrary1 只認識 CommonLibrary,ClassLibrary2 也只認識 CommonLibrary,所以 ClassLibrary1 和 ClassLibrary2 彼此並不認識,最後 CommonLibrary 完全不認識其他元件.這裡所用的 "認識" 就是 reference 的意思.

    假設以上四個元件分別是由不同的團隊所製作而成,現在 ClassLibrary1 圖隊需要在他們自己的 Form 上面做兩個按鈕,而這兩個按鈕所需的畫面和功能分別是由 ClassLibrary2 團隊所提供的.正常來說,如果一個團隊要用到另一個團隊所開發的功能時,最直接且直接的方法就是將對方的元件在自己的專案中加入 reference,這樣做就能讓自己團隊的元件可以認識另一個團隊的元件,但這樣子做在較大型的軟體團隊中是不方便的,因為第一集已經說明過了.因此,如第一集所說的內容,比較好的方法是要做一個共用的 interface 讓兩個團隊都可以認識這個共用的 interface.於是,ClassLibrary1, ClassLibrary2, 和 CommonLibrary 這三個團隊做成了一個協議,CommonLibrary 團隊將製作一份 interface 讓 ClassLibrary1 和 ClassLibrary2 可以實做.除此之外,CommonLibrary 團隊還提供了一個 Dictionary 用雙方可以將自己實做好的元件放在這個 Dictionary 裡頭.

    public interface IOperation
    {
        string Name { get; }
        string Description { get; }
        int AddIntOperation(int i);
        string ChangeStringOperation(string input);
    }
    
    public class ObjectContainer
    {
        public static Dictionary<string, IOperation> Operations = new Dictionary<string, IOperation>();
        public static Dictionary<string, Form> Dialogs = new Dictionary<string, Form>();
    }
    

    IOperation 介紹讓是讓雙方可以依自己的邏輯實做成物件,然後放在 Operations dictionary 裡面.因此,只要 ClassLibrary1 團隊知道如何在這個 dictionary 中取出 ClassLibrary2 所放入的物件,那麼 ClassLibrary1 可以使用 ClassLibrary2 團隊所提供的功能了,如 AddIntOperation(), ChangeStringOperation().

    於是 ClassLibrary2 團隊將 IOperation 實做如下:

    public class Operation2 : IOperation
    {
        public string Name => "Operation2";
    
        public string Description => "This is Operation2 from ClassLibrary2";
    
        public int AddIntOperation(int i)
        {
            if (i < int.MaxValue - 1)
            {
                return i + 2;
            }
    
            return i;
        }
    
        public string ChangeStringOperation(string input)
        {
            return string.IsNullOrEmpty(input) ? null : input.ToLower();
        }
    }

    然後 ClassLibrary1 團隊在一個按鈕的 code-behind 寫出以下的程式碼來使用 ClassLibrary2 的 IOperation.

    private void button2_Click(object sender, EventArgs e)
    {
        if (ObjectContainer.Operations.TryGetValue("operation2", out IOperation op))
        {
            int i = 5;
            richTextBox1.Text += $"int starts at {i}\n";
            i = op.AddIntOperation(5);
            richTextBox1.Text += $"int becomes {i} after Operation2\n";
    
            string s = "aBc";
            richTextBox1.Text += $"sting starts as {s}\n";
            s = op.ChangeStringOperation(s);
            richTextBox1.Text += $"string becomes {s} after Operation2";
        }
        else
        {
            richTextBox1.Text = "Cannot find Operation2";
        }
    }

    這樣做的話不能成功,因為在 CommonLibrary 的 Operations dictionary 裡面並沒有 ClassLibrary2 所製做的 IOperation 物件,因此 ClassLibrary1 團隊使用上述的程式碼時會看到 "Cannot find Operation2" 的訊息.比較簡單的方法是在整個軟體一開始啟動的時候,ClassLibrary2 就得把 IOperation 物件寫入到 CommonLibrary 的 Operations dictionary 裡.當然,這並不是最好的方法,只是在這極為簡單的例子中,我們暫用這個方法來簡化許多細節.

    因為 WindowsFormsApp1 是整個軟體的啟動點,所以我們就在 WindowsFormsApp1 啟動的時候來將相關的物件都寫入到 CommonLibrary 裡面.由於每個團隊會有不同的啟動邏輯,因此,每個團隊可以提供一個入口來讓 WindowsFormsApp1 直接呼叫,而這份入口裡的內容就是將自己的 IOperation 物件寫入到 CommonLibrary 的 Operations dictionary 裡.同樣地,除了 IOperation 以外,每個團隊也可以寫入不同的 Form 物件到 CommonLibrary 的 Dialogs dictionary 裡.

    以下是 ClassLibrary2 團隊所使用讓 WindowsFormsApp1 執行注冊的內容:

    public static class Starter
    {
        public static void Register()
        {
            ObjectContainer.Operations["operation2"] = new Operation2();
            ObjectContainer.Dialogs["library2"] = new Lib2WinForm1();
        }
    }

    以上的做法是一個極為簡化的方式,在 ClassLibrary2 裡直接做一個 static method 讓 WindowsFormsApp1 呼叫,所以 WindowsFormsApp1 必須知道這一個 "入口".在正常的方式來說,WindowsFormsApp1 找到並執行 ClassLibrary2 的入口不會用這種直接的方法,因為這樣會把程式碼限制住,這方面細節的內容以後將再寫文章來說明,現在就先假設 WindownsFormsApp1 可以找到 ClassLibrary2 並且執行 Starter.Register() 來達成將 Operation2 物件和 Lib2WinForm1 物件寫入到 CommonLibrary 的 dictionary 中.因此,WindowsFormsApp1 的啟動程式看起來如下:

    static class Program
    {
        [STAThread]
        static void Main()
        {
            // 尋找相關元件並且執行他們所提供的註冊方法
            ClassLibrary1.Starter.Register();
            ClassLibrary2.Starter.Register();
    
            Application.Run(new Form1());
        }
    }

    這樣一來,ClassLibrary1 團隊就可以在 CommonLibrary 的 dictionary 裡找到 ClassLibrary2 所製做的 Form 物件以及 IOperation 物件,並且使用它們,如下圖所示:



    Form1 是 WindowsFormsApp1 團隊製做的主要 Form,也就是整個軟體最基礎的 IDE,而 Lib1WinForm1 是 ClassLibrary1 所製做的 Form,透過 WindowsFormsApp1 的呼叫將它顯示在畫面上.在 Lib1WinForm1 裡第一個按鈕 (Let's show Lib2WinForm1) 的 code-behind 如下:

    private void button1_Click(object sender, EventArgs e)
    {
        if (ObjectContainer.Dialogs.TryGetValue("library2", out Form lib2WinForm1))
        {
            lib2WinForm1.ShowDialog();
        }
    }

    直接到 CommonLibrary 的 Dialogs dictionary 去找是否有 CommonLibrary2 的 Form 物件,如果有找到,就直接對它做 ShowDialog().因此,ClassLibrary1 團隊不一需要 "認識" ClassLibrary2 團隊的元件也可以將它提供的 Form 顯示在畫面上.

    同樣地,Lib1WinForm1 的第二個按鈕 (Run Operation2) 是使用 ClassLibrary2 的 IOperation 物件所執行的功能,它的程式碼如下:

    private void button2_Click(object sender, EventArgs e)
    {
        if (ObjectContainer.Operations.TryGetValue("operation2", out IOperation op))
        {
            int i = 5;
            richTextBox1.Text += $"int starts at {i}\n";
            i = op.AddIntOperation(5);
            richTextBox1.Text += $"int becomes {i} after Operation2\n";
    
            string s = "aBc";
            richTextBox1.Text += $"sting starts as {s}\n";
            s = op.ChangeStringOperation(s);
            richTextBox1.Text += $"string becomes {s} after Operation2";
        }
        else
        {
            richTextBox1.Text = "Cannot find Operation2";
        }
    }

    當這個按鈕被按下後,它的結果如下:



    你可以看到數字被加 2 (7 = 5+2) 並且字串變小寫 (abc),這都是前面提到 ClassLibrary2 所實做 IOperation 的內容.

    當我們把 Visual Studio 的 break point 設定在這程式碼時,你會看到如下畫面:



    此時,你可以看到 op 的 data type 是 IOperation,而它裡面真正的物件是 ClassLibrary2.Operation2.

    這個範例用極為簡化的方式說明了 Interface 如何幫助 ClassLibrary2 團隊的物件可以在 ClassLibrary1 的程式中呈現,並且是在兩團隊元件互相不 "認識" 的情況下.

    Operation2 的實做完全保留在 ClassLibrary2 裡面,其他團隊無法變更,也不需要知道實做細節,讓團隊之間的合作只需要關心 Interface 的定義.

    用這個例子可以讓你看到不同的團隊各司其職而達成一個共同的目標.以上的例子 IOperation 是定義在 CommonLibrary,通常來說,自已團隊所開發的 Interface 應該是放在自己所定義的 Interface 元件裡,然後再將這一個 Interface 元件公開給其他團隊來使用.因此,這份 Interface 是大家都看的到,理論上你就不能修改,否則別人用了就會出現問題.下一集的內容將說明當公開共用的 Interface 需要修改時,該怎麼處理比較好.

    Share:

    #57 出神入化的用介面 第一集_什麼是介面 (Interface)

    出神入化這詞用的誇張了,為何選用這詞呢? 在 2016 年底辦了一個 .Net公益課程,課後的問卷裡詢問未來若有機會,大家有興趣聽什麼主題的內容.結果有位朋友寫了 "出神入化的用介面".我想這應該是當天課程上曾提到跟 Interface 有關的事情.後來,我想了想,這主題要描述的清楚並不是件容易的事,也不是三兩句話可以交待的完.所以,用一個說故事的方式來說明這個主題.透過這個主題可以一直延伸到許多程式設計和軟體開發上的事情.

    如果你在業界有多年的軟體開發經驗,相信你對 Interface 一定有某種程度以上的使用與了解.但如果你現在還是一個在學的學生或是剛進入職場沒多久的社會新鮮人,也許你對 Interface 的了解可能只限於課本上或是老師口授而來的知識.由於 Interface 這種東西並不是什麼學術研究的題材 (應該說這是很舊的題材了),再加上許多學校裡的教授大部份很少有大量的產業界開發經驗,因此你能從課本或老師那邊得到的了解便是相當有限的.不論你是在業界打滾多年的好手或是剛進入職場沒幾年的菜烏,先來讓我們一起回想一下當初在學校裡上的物件導向程式語言的課程.也許許多人在學校並沒有上過這門課或是學校沒有開這門課,可能是一邊工作一邊看書學習.不論是那一種方式,我相信你一定都看到課程上或書本上介紹 Interface.當你看了 Interface 之後,你覺得你有什麼感覺嗎 ?

    老實話,我以前在學校學的時候,還真的沒掌握到重點.只是覺得奇怪,為什麼要多弄一個 Interface,感覺上好像多了一個用不到的東西.其實,後來才發現,並不是用不到,只是還不會用而己.在物件導向式程式設計的世界裡,你一定都知道什麼是 Class,也一定知道什麼是 Object.這關係就有點像關聯式 (Entity relational model) 資料庫裡的 table schema 與 table 裡的資料.舉個例子,在資料庫中建立一個表格時,你一定會告訴資料庫引擎你需要的表格是長什麼樣子.如一個 int 欄位,一個 varchar(50) 欄位,以及一個 boolean 欄位.因此,你能寫入的資料一定要符合這個 table schema 的定義.相同的感覺,當你的 Class 定義好它的長相時,之後依這個 Class 建立出來的 Object 裡面的內容值也一定符合這個 Class 的定義.在資料庫設計中,表格和表格之間可以建立所謂的一對一或一對多等等的關係,同樣的在 Class 和 Class 之間也可以設計出這樣的關係.物件導向式模型有一個東西是關聯式模型裡面所沒有的,那就是 Interface.

    Interface 和 Class 都是用來定義 Object 要長成什麼樣子.如下面看到的簡單例子.

    class IAmAClass {
        public int Id { get; set;}
        public string Name { get; set;}
    }
    

    在撰寫程式時,你可以直接透過 new 這關鍵字來告訴電腦你需要一個記憶體空間來放一個依照 IAmAClass Class 長相所建立出來的 Object,如下

    IAmAClass cls1 = new IAmAClass();
    IAmAClass cls2 = new IAmAClass();

    依照上面的程式碼,此時記憶體裡面有兩個 IAmAClass objects, 一個名字叫 cls1,另一個叫 clas2.這兩個 objects 是依照 IAmAClass 的長相所建立出來的,所以當我想要操作這兩個 objects 時,我就知道他有那些公開的屬性和方法可以使用.這些是最基本的事情,相信你一定知道.接下來,那 Interface 呢 ? 前面說了,Interface 和 Class 都是用來定義 object 要長成什麼樣子,那我們能直接定義一個 Interface 透過 new 關鍵字來建立 object 嗎? 很可惜,似乎不行.Interface 和 Class 好像蠻像的,但 Interface 卻不能直接透過 new 關鍵字來建立 object,這其中一定是有什麼設計上的考量!

    相信你以前也想過這樣的問題.Interface 的用途很多,在第一集的文章裡,我先提出一個用途讓你看到為什麼有 Interface 的存在是比較好的.

    假設,你的公司有好幾個部門,每個部門做的工作都是獨立又相依,意思就是說你要達成某項任務時,必須要使用其它部門的程式.舉例來說,你做的是一個寄信的程式,但信件的內容是根據各部門的需求而產生的,你不負責信件內容的產生,你只負責做寄信的動作.此時,A部門告訴你它的信件內容是 MailAContent class,它的長相如下 (細節忽略):

    class MailAContent {
        public string ReceipentEmail { get; set;}
        public string HtmlContent { get; set;}
        public string Subject { get; set;}
        public bool Importance { get; set;}
    }

    於是,當你寫寄信程式時,你必須要拿到 MailAContent class 的定義,也就是說你得把對方的 dll 加入到你的程式專案裡,因為要這麼做,你的程式才能明白什麼是 MailAContent class.因此,當你在製做寄信程式時,你的程式某個片段可能會長成如下:

    public void SendEmail(MailAContent mail) {
        EmailServer server = new EmailServer();
        server.IP = "1.1.1.1";
        server.Username = "admin";
        server.Password = "password";
        server.Connect();
        server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance); // 假設寄信程式只需要知道這四個資料
        server.Close();
    }

    到目前為止,看起來似乎合理.接下來,B部門也會產生 email,也要透過你的寄信程式來把信寄出去.但 B部門有自己的 email class,叫 MailBContent class,長相如下:

    class MailBContent {
        public string ReceipentEmail { get; set;}
        public string HtmlContent { get; set;}
        public string Subject { get; set;}
        public bool Importance { get; set;}
    }

    於是你為了要把 B部門的 email 寄出去,你也寫了以下的程式在你的寄信程式中.

    public void SendEmail(MailBContent mail) {
        EmailServer server = new EmailServer();
        server.IP = "1.1.1.1";
        server.Username = "admin";
        server.Password = "password";
        server.Connect();
        server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance);
        server.Close();
    }

    結果看到這程式,發現有點蠢,這麼多重覆目的的程式碼,於是進行了一個小小的 refactor :

    public void SendEmail(MailAContent mail)
    {
        SendEmailInternal(mail.ReceipentEmail , mail.HtmlContent , mail.Subject, mail.Importance);
    }
    
    public void SendEmail(MailBContent mail)
    {
        SendEmailInternal(mail.ReceipentEmail , mail.HtmlContent , mail.Subject, mail.Importance);
    }
    
    private void SendEmailInternal(string receipent, string content, string subject, bool importance) {
        EmailServer server = new EmailServer();
        server.IP = "1.1.1.1";
        server.Username = "admin";
        server.Password = "password";
        server.Connect();
        server.Send(receipent, content, subject, importance);
        server.Close();
    }

    經過小小的 refactor 之後,你會覺得程式碼好像比較沒那麼蠢了.但接下來, C部門,D部門相繼地把需求提過來,而你也發現每個部門的 email class 都是不一樣的 class,因此你就能想像你的寄信程式就要為 C部門再多弄一個 SendEmail(), 也要再為 D部門再多弄一個 SendEmail(),如果有十個部門而且每個部門的 email class 都不一樣,那麼你就得有十個 SendEmail(),這時你應該會發現程式碼真的有點蠢了.除此之外,因為你寫的寄信程式是要提供給其他部門使用,讓他們的程式碼呼叫你的 SendEmail() 才能寄信.此時,你會發現因為你的寄信程式 reference 每個部門的 email class,所以使得每個部門都要 reference 其他部門的 email class 才能正確地 compile.這真的是相當蠢的一件事.透過這個例子也剛好說明了 Class 真的是軟體開發的一個麻煩源頭之一.

    從上面的例子來看,你會發現如果每一個部門都採用相同的 email class 的話,那不就好了嗎? 可能的做法是把 email class 定義在某一個共享元件裡,然後要求每個部門都 reference 這一個共享元件以使得用到相同的 email class.理論上,這是一個可行的方法.在這個例子裡,email class 只是一個很簡單的 data structure,沒有任何 implementation code.如果今天遇到的是一個有 implementation code 的 email class 時,就會遇到一個小麻煩,它就是當這個 email class 改版時 (修改 implementation code),就得通知所有部門更新這一個共享元件.除了這方法以外,你還可以用 Interface.

    如前面所說,Interface 和 Class 都是用來定義 Object 要長成什麼樣子,但 Interface 不能用來建立 object,卻可以用來表達一個 object 是否具有其他的 "特定長相". 在元件之間的互動,認定的是長相而不是真正的 object 是什麼,並且放在共享元件裡的是 Interface 而不是 implementation code,因此 email class 改版時,共享元件不需要更新,只有在 interface 改版時才需要更新共享元件.

    因此,以上述的例子來說,在共享元件的 interface 長成這樣:

    Interface IMailContent {
        string ReceipentEmail { get; }
        string HtmlContent { get; }
        string Subject { get; }
        bool Importance { get; }
    }
    

    以 A部門來說,它的 email class 改成如下:

    class MailAContent : IMailContent 
    {
        public string ReceipentEmail { get; set;}
        public string HtmlContent { get; set;}
        public string Subject { get; set;}
        public bool Importance { get; set;}
    }
    

    MailAContent class 使用了 IMailContent,這表示 MailAContent class 一定要有 IMailContent 裡所有成員的 implementation code.一旦 MailAContent class 被建立成 object 時,這個 object 既是 MailAContent 也是 IMailContent,也就是說這個 object 有著兩種 data type 的感覺,這樣說不精確,但感覺起來像是如此.接著,寄信程式可以改成如下:

    public void SendEmail(IMailContent mail) {
        EmailServer server = new EmailServer();
        server.IP = "1.1.1.1";
        server.Username = "admin";
        server.Password = "password";
        server.Connect();
        server.Send(mail.receipent, mail.content, mail.subject, mail.Importance);
        server.Close();
    }

    當 SendEmail() 被 A部門呼叫時,傳進來的 IMailContent 其實是 MailAContent object,因為這個 object 實做了 IMailContent ,所以這樣傳進來並不會有什麼問題.在 SendEmail() 裡面只認得 IMailContent 的長相,因此在 SendEmail() 存取該物件的成員時,只能用那些 "看" 的到的成員,也就是 IMailContent 上有的成員.同理,當 SendEmail() 被 B部門呼叫時,傳進來的 object 是 MailBContent object,也因為此 object 實做 IMailContent,所以 SendEmail() 執行時不會造成問題.以推類推,所有部門的 email class 都實做了 IMailContent,這樣寄信程式只要一個 SendEmail() 就可以,這樣不是更好嗎?

    也許以上寄信這個例子不是很精準,但也把最基本的 Interface 應用呈現出來.如果你還沒機會參與多部門或多人開發的軟體專案,Interface 其實仍是重要的.因為它不僅用在跨部門的情況下相當好用,而且也能幫助你做更好的 unit test.

    這集的重點是:
    1. Interface 和 Class 都是用來定義 Object 要 "長" 成什麼樣子,但 Interface 不參與 implementation,只專心定義一個 object 能 "長" 成什麼樣子.Interface 提供了 object 一種 "外衣",不同的 email content object 只要有相同的 "外衣" (IMailContent),都可以在 SendEmail() 裡面使用.一個 object 可以有多個 "外衣",因此使得物件導向程式設計變得很彈性也很有趣.
    2. 當你要使用別人的 Class 時很可能會造成麻煩,應盡量使用 Interface.以上的故事也告訴你當你把一個 class 建立成一個 object 時,這其實就是許多問題的根源之一.

    Share:

    標籤分類