[SDD] SDD 實作 #3 - 建立類別模版

  • 52
  • 0
  • SDD
  • 2025-11-21

本文繼續探討使用 SDD (規格驅動開發) 概念來進行開發的實例。

我在上一篇文章裡已經示範過如何使用事先擬定的需求文件來讓 Github Copilot 產生新的 form 了。接下來, 我寫了一個新的需求文件, 來讓 Github Copilot 產生類別。

或許你會覺得奇怪, 宣告類別不是最基本的事情嗎? 尤其是如果你有 Github Copilot 的話, 一般只要你把 public class abc 打出來, Copilot 就會幫你把後面通通補齊。那麼為什麼還要特地寫出預先定義的需求, 再去產生呢?

我個人把 class 分成兩種, 一種是任務型類別, 另一種是用來當作型別的。當然, 這兩種類別並沒有本質上的不同, 只是目的不同。在這裡, 我所講到的類別, 都是用來當作型別的, 也就是拿來產生 instance 用的。

在以下所舉的範例程式中, 我最終的目的是建立一個 StockEntry 型別。但是我不打算以手寫方式去產生, 而是先產生一個需求文件 (所以才叫做 SDD), 然後讓 Copilot 去產生這個類別。

為什麼要那麼麻煩? 原因很簡單:當我們在建立一個型別時, C# 並不會預先在上面附加我們需要的功能, 因為它也不知道你的需求是什麼; 所以我們必須一個一個加上去。然而, 如果你經常需要建立這類型別, 你就會發現有些重複的瑣事必須一個一個做。那麼為什麼我們不乾脆把所有重複且繁瑣的事交給電腦來做就好?

以下就是我先拿一個以前寫好的 C# 型別給 Copilot 看, 讓它幫我把需求產生出來, 然後我再將它修改之後產生的需求文件:

<!-- 程式一. ClassGeneration.md -->

# C# 類別生成需求文件

## 概述

本文件指定了在目前方案中生成新 C# 類別的需求。

## 類別定義

- **命名空間:** `[方案名稱]`
- **類別名稱:** `MyComparableObject`(或開發者指定的名稱)
- **實作介面:**
    - `IEquatable<MyComparableObject>`
    - `IComparable<MyComparableObject>`

## 屬性

類別必須包含以下屬性:

1.  **Id**
    - **型別:** `string`
    - **屬性標籤:** `[DisplayName("Id")]`
    - **文件註解:** `/// <summary>取得或設定識別碼。</summary>`

2.  **Name**
    - **型別:** `string`
    - **屬性標籤:** `[DisplayName("Name")]`
    - **文件註解:** `/// <summary>取得或設定名稱。</summary>`

## 必需方法

必須實作以下方法以滿足 `IEquatable` 和 `IComparable` 介面:

- `public bool Equals(MyComparableObject other)`:實作 `IEquatable` 介面,用於型別特定的相等性比較。
- `public int CompareTo(MyComparableObject other)`:實作 `IComparable` 介面,用於排序目的,通常基於 `Id` 或 `Name` 屬性。
- `public override bool Equals(object obj)`:覆寫基底類別的 `Equals` 方法。
- `public override int GetHashCode()`:覆寫基底類別的 `GetHashCode` 方法。
- `public static bool operator ==(MyComparableObject left, MyComparableObject right)`:等於運算子
- `public static bool operator !=(MyComparableObject left, MyComparableObject right)`:不等於運算子
- `public static bool operator <(MyComparableObject left, MyComparableObject right)`:小於運算子
- `public static bool operator <=(MyComparableObject left, MyComparableObject right)`:小於或等於運算子
- `public static bool operator >(MyComparableObject left, MyComparableObject right)`:大於運算子
- `public static bool operator >=(MyComparableObject left, MyComparableObject right)`:大於或等於運算子

### 比較邏輯建議

- **相等性比較**:建議基於 `Id` 屬性進行比較(如果 `Id` 是唯一識別符)
- **排序比較**:建議優先依據 `Name` 屬性,次要依據 `Id` 屬性進行排序

### 測試考量

生成的類別應能支援:
1. 物件相等性比較測試
2. 集合排序測試
3. `null` 參考處理測試
4. 雜湊碼一致性測試

---

## 範例實作

```csharp
using System;
using System.ComponentModel;

namespace [方案名稱]
{
    public class MyComparableObject : IEquatable<MyComparableObject>, IComparable<MyComparableObject>
    {
        /// <summary>取得或設定識別碼。</summary>
        [DisplayName("Id")]
        public string Id { get; set; }

        /// <summary>取得或設定名稱。</summary>
        [DisplayName("Name")]
        public string Name { get; set; }

        public int CompareTo(MyComparableObject other)
        {
            if (other == null) return 1;
            int nameComparison = string.Compare(this.Name, other.Name, StringComparison.Ordinal);
            if (nameComparison == 0)
            {
                return string.Compare(this.Id, other.Id, StringComparison.Ordinal);
            }
            return nameComparison;
        }

        public bool Equals(MyComparableObject other)
        {
            if (other is null) return false;
            if (ReferenceEquals(this, other)) return true;
            return this.Id == other.Id;
        }

        public override bool Equals(object obj)
        {
            return this.Equals(obj as MyComparableObject);
        }

        public override int GetHashCode()
        {
            return Id != null ? Id.GetHashCode() : 0;
        }

        public static bool operator ==(MyComparableObject left, MyComparableObject right)
        {
            if (left is null)
            {
                return right is null;
            }
            return left.Equals(right);
        }

        public static bool operator !=(MyComparableObject left, MyComparableObject right) => !(left == right);
        public static bool operator <(MyComparableObject left, MyComparableObject right) => left is null ? right is not null : left.CompareTo(right) < 0;
        public static bool operator <=(MyComparableObject left, MyComparableObject right) => left is null || left.CompareTo(right) <= 0;
        public static bool operator >(MyComparableObject left, MyComparableObject right) => left is not null && left.CompareTo(right) > 0;
        public static bool operator >=(MyComparableObject left, MyComparableObject right) => left is null ? right is null : left.CompareTo(right) >= 0;
    }
}
```

## 使用指南

當需要生成符合此需求的類別時:
1. 確定類別名稱
2. 根據業務邏輯調整屬性的 `DisplayName`
3. 根據實際需求調整相等性和比較邏輯
4. 確保所有必要的命名空間都已引用
5. 執行單元測試驗證功能正確性

## 後續注意事項

- 確保 `GetHashCode()` 與 `Equals()` 的實作保持一致
- 如果物件被用作字典鍵或加入 `HashSet`,請特別注意雜湊碼實作
- 考慮是否需要實作 `INotifyPropertyChanged` 介面(用於 WinForms 資料繫結)

基本上, 我是希望我未來新建的類別都自動加上 Id 和 Name 兩個屬性, 並將把屬性和方法的 decoration 都做好, 但主要是要求它幫我實作 IEquatable 以及 IComparable 這兩個最常用的介面。這麼一來, 你以後如果將它的 instance 放進集合時, 可以省下一點工夫, 不必回頭去改。

需求做好了, 要怎樣把它拿來用呢?

我以下示範我所使用的 prompt:

1. 根據 ClassGeneration.md 中述明的規格, 在本專案中建立一個 StockEntry 類別檔案。該類別中除了規則中指定的屬性之外, 必須包括以下屬性: StockType (股票種類) (of EnumStockType), SalesType (買賣類別) (of EnumSalesType), Amount (買賣金額) (of float), SalesDate (買賣日期) (of Datetime), Cost (成本) (of float) 等等。

2. 上述的 EnumStockType (股票種類) (of Enum) 有以下幾個值: Listed=0 (上市股票), OTC=1 (上櫃股票), Innovation=2 (創新板), ETF=3, Unlisted=99 (未上市股票)。

3. 上述的 EnumStockType (買賣類別) 有兩個值: Buy=0 (買進), Sell=1 (賣出)。 

我將這個 prompt 丟給 Gemini-CLI 去做, 它轟轟烈烈地做了很多動作 (我甚至不知道它到底為什麼還去改了我的 csproj.user 檔), 結果產出的類別檔案竟然完全沒有實作 IEquatable 以及 IComparable。

後來改回 Github Copilot 來產生, 以下就是做好的結果, 非常令人滿意。

// 程式二

using System;
using System.ComponentModel;

namespace MyStockApp
{
    /// <summary>
    /// 股票種類
    /// </summary>
    public enum EnumStockType
    {
        /// <summary>上市股票</summary>
        [Description("上市股票")]
        Listed = 0,

        /// <summary>上櫃股票</summary>
        [Description("上櫃股票")]
        OTC = 1,

        /// <summary>創新板</summary>
        [Description("創新板")]
        Innovation = 2,

        /// <summary>ETF</summary>
        [Description("ETF")]
        ETF = 3,

        /// <summary>未上市股票</summary>
        [Description("未上市股票")]
        Unlisted = 99
    }

    /// <summary>
    /// 買賣類別
    /// </summary>
    public enum EnumSalesType
    {
        /// <summary>買進</summary>
        [Description("買進")]
        Buy = 0,

        /// <summary>賣出</summary>
        [Description("賣出")]
        Sell = 1
    }

    /// <summary>
    /// 表示股票交易記錄項目。
    /// </summary>
    public class StockEntry : IEquatable<StockEntry>, IComparable<StockEntry>
    {
        #region 基本屬性

        /// <summary>取得或設定識別碼。</summary>
        [DisplayName("識別碼")]
        public string Id { get; set; }

        /// <summary>取得或設定名稱。</summary>
        [DisplayName("名稱")]
        public string Name { get; set; }

        #endregion

        #region 股票交易屬性

        /// <summary>取得或設定股票種類。</summary>
        [DisplayName("股票種類")]
        public EnumStockType StockType { get; set; }

        /// <summary>取得或設定買賣類別。</summary>
        [DisplayName("買賣類別")]
        public EnumSalesType SalesType { get; set; }

        /// <summary>取得或設定買賣金額。</summary>
        [DisplayName("買賣金額")]
        public float Amount { get; set; }

        /// <summary>取得或設定買賣日期。</summary>
        [DisplayName("買賣日期")]
        public DateTime SalesDate { get; set; }

        /// <summary>取得或設定成本。</summary>
        [DisplayName("成本")]
        public float Cost { get; set; }

        #endregion

        #region 建構函式

        /// <summary>
        /// 初始化 StockEntry 類別的新執行個體。
        /// </summary>
        public StockEntry()
        {
            SalesDate = DateTime.Now;
        }

        /// <summary>
        /// 使用指定的股票代號和股票名稱初始化 StockEntry 類別的新執行個體。
        /// </summary>
        /// <param name="id">股票代號</param>
        /// <param name="name">股票名稱</param>
        public StockEntry(string id, string name) : this()
        {
            Id = id;
            Name = name;
        }

        #endregion

        #region IEquatable<StockEntry> 實作

        /// <summary>
        /// 判斷指定的 StockEntry 物件是否等於目前物件(基於 Id 屬性)。
        /// </summary>
        /// <param name="other">要與目前物件比較的 StockEntry 物件。</param>
        /// <returns>如果指定的物件等於目前物件,則為 true;否則為 false。</returns>
        public bool Equals(StockEntry other)
        {
            if (other is null) return false;
            if (ReferenceEquals(this, other)) return true;

            // 基於 Id 進行相等性比較
            return string.Equals(this.Id, other.Id, StringComparison.Ordinal);
        }

        /// <summary>
        /// 判斷指定的物件是否等於目前物件。
        /// </summary>
        /// <param name="obj">要與目前物件比較的物件。</param>
        /// <returns>如果指定的物件等於目前物件,則為 true;否則為 false。</returns>
        public override bool Equals(object obj)
        {
            return this.Equals(obj as StockEntry);
        }

        /// <summary>
        /// 取得目前物件的雜湊碼。
        /// </summary>
        /// <returns>目前物件的雜湊碼。</returns>
        public override int GetHashCode()
        {
            // 使用 Id 計算雜湊碼,與 Equals 方法保持一致
            return Id != null ? Id.GetHashCode() : 0;
        }

        #endregion

        #region IComparable<StockEntry> 實作

        /// <summary>
        /// 比較目前物件與另一個 StockEntry 物件。
        /// 優先依據 Name 屬性排序,若 Name 相同則依據 Id 排序。
        /// </summary>
        /// <param name="other">要比較的 StockEntry 物件。</param>
        /// <returns>
        /// 小於零:目前執行個體在排序次序中,位於 <paramref name="other"/> 之前。
        /// 零:目前執行個體的排序位置與 <paramref name="other"/> 相同。
        /// 大於零:目前執行個體在排序次序中,位於 <paramref name="other"/> 之後。
        /// </returns>
        public int CompareTo(StockEntry other)
        {
            if (other == null) return 1;

            // 優先依據 Name 排序
            int nameComparison = string.Compare(this.Name, other.Name, StringComparison.Ordinal);
            if (nameComparison != 0)
            {
                return nameComparison;
            }

            // Name 相同時,依據 Id 排序
            return string.Compare(this.Id, other.Id, StringComparison.Ordinal);
        }

        #endregion

        #region 運算子多載

        /// <summary>
        /// 判斷兩個 StockEntry 執行個體是否相等。
        /// </summary>
        public static bool operator ==(StockEntry left, StockEntry right)
        {
            if (left is null)
            {
                return right is null;
            }
            return left.Equals(right);
        }

        /// <summary>
        /// 判斷兩個 StockEntry 執行個體是否不相等。
        /// </summary>
        public static bool operator !=(StockEntry left, StockEntry right)
        {
            return !(left == right);
        }

        /// <summary>
        /// 判斷左側執行個體是否小於右側執行個體。
        /// </summary>
        public static bool operator <(StockEntry left, StockEntry right)
        {
            return left is null ? right is not null : left.CompareTo(right) < 0;
        }

        /// <summary>
        /// 判斷左側執行個體是否小於或等於右側執行個體。
        /// </summary>
        public static bool operator <=(StockEntry left, StockEntry right)
        {
            return left is null || left.CompareTo(right) <= 0;
        }

        /// <summary>
        /// 判斷左側執行個體是否大於右側執行個體。
        /// </summary>
        public static bool operator >(StockEntry left, StockEntry right)
        {
            return left is not null && left.CompareTo(right) > 0;
        }

        /// <summary>
        /// 判斷左側執行個體是否大於或等於右側執行個體。
        /// </summary>
        public static bool operator >=(StockEntry left, StockEntry right)
        {
            return left is null ? right is null : left.CompareTo(right) >= 0;
        }

        #endregion

        #region 輔助方法

        /// <summary>
        /// 傳回表示目前物件的字串。
        /// </summary>
        /// <returns>表示目前物件的字串。</returns>
        public override string ToString()
        {
            return $"[{Id}] {Name} - {StockType} | {SalesType} | {SalesDate:yyyy-MM-dd} | 金額:{Amount:N2} | 成本:{Cost:N2}";
        }

        #endregion
    }
}

雖然它很雞婆地在每個註解的最後都給我加上全形句號, 但因為無傷大雅, 就不管它了。

大家應該可以看出來, 與其自己手動慢慢刻, 若使用 AI 來幫忙做那些重複瑣碎的事, 確實可以省下很多工夫。


Dev 2Share @ 點部落