.NET 內建的數字型別裡有整數、浮點數,但如果我們計算 -2 * 1/3 + 3/4 想得到 1/12 而不是 0.08333333333,該怎麼做?

就來寫個可以加減乘除的自訂分數型別吧。就像下面這樣,是不是很酷?

Frac g = -2 * (Frac)"1/3" + new Frac(3, 4);
// -2 * 1/3 + 3/4 = 1/12
Console.WriteLine($"-2 * 1/3 + 3/4 = {g}");

看似神奇的效果,不過是用到 C# 隱含轉型及自訂運算子特性,要實作一點也不難。官方文件剛好有個雛型範例,已實作了分數加減乘除,我計劃再擴充讓它更完整,包含以下功能:

  • 由字串(例:"2/3"、"3/4")建立分數物件
  • 以整數建立分數物件
  • 將字串或整數轉為分數物作,例如:(Frac)"2/3"
  • 約分簡化函數 Simpkify() (例:4/6 變成 2/3)
  • 指定分母 WithDenominator(n) (例:2/3 指定分母 9 得到 6/9)
  • 相等比較 (==、Equals()) (可識別約分後相等,如 2/3 == 4/6)
  • 大於小於比較

Frac 型別是個唯讀結構(struc),標示唯讀明確表示資料賦值後即不可變(Immutability),有助於程式碼理解及推理(不必考慮狀態變化)、避免多執行緒衝突(省去同步化的效能損耗)、減少改變值導致錯誤的風險。我寫了一個簡單範例如下:

public readonly struct Frac
{
    private readonly int num;
    private readonly int den;

    // 標準建構式:分子、分母
    public Frac(int numerator, int denominator)
    {
        if (denominator == 0)
        {
            throw new ArgumentException("Denominator cannot be zero.", nameof(denominator));
        }
        if (denominator < 0)
        {
            numerator = -numerator;
            denominator = -denominator;
        }
        num = numerator;
        den = denominator;
    }
    // ValueTuple 建構式
    public Frac((int numerator, int denominator) frac) : this(frac.numerator, frac.denominator) { }
    // 由字串解析分子分母
    static Func<string, (int numerator, int denominator)> ParseFrac = fracStr =>
    {
        var parts = fracStr.Split('/');
        if (parts.Length != 2)
        {
            throw new ArgumentException("Invalid fraction string.", nameof(fracStr));
        }
        try
        {
            return (int.Parse(parts[0]), int.Parse(parts[1]));
        }
        catch
        {
            throw new ApplicationException($"Failed to convert string to farction - {fracStr}");
        }
    };
    // 使用字串建立分數
    public Frac(string fracStr) : this(ParseFrac(fracStr)) { }
    public static implicit operator Frac(string fracStr) => new Frac(fracStr);
    // 使用整數建立分數
    public Frac(int number) : this(number, 1) { }
    public static implicit operator Frac(int number) => new Frac(number);    

    // 正、負、加減乘除
    public static Frac operator +(Frac a) => a;
    public static Frac operator -(Frac a) => new Frac(-a.num, a.den);
    public static Frac operator +(Frac a, Frac b)
        => new Frac(a.num * b.den + b.num * a.den, a.den * b.den).Simplify();
    public static Frac operator -(Frac a, Frac b)
        => a + (-b);
    public static Frac operator *(Frac a, Frac b)
        => new Frac(a.num * b.num, a.den * b.den).Simplify();
    public static Frac operator /(Frac a, Frac b)
    {
        if (b.num == 0)
        {
            throw new DivideByZeroException();
        }
        return new Frac(a.num * b.den, a.den * b.num).Simplify();
    }

    // 指定分母
    public Frac WithDenominator(int newDenominator)
    {
        // 若無法整除,則無法指定分母
        if (newDenominator == 0 || newDenominator % den != 0)
        {
            throw new ArgumentException($"Cannot specify the denominator - {newDenominator}.", nameof(newDenominator));
        }
        return new Frac(num * newDenominator / den, newDenominator);
    }

    public override string ToString() => 
        den == 1 ? num.ToString() : $"{num}/{den}";

    // 約分
    public Frac Simplify()
    {
        int gcd = Gcd(num, den);
        return new Frac(num / gcd, den / gcd);
    }
    // 通分
    public static (Frac, Frac) CommonDenominator(Frac a, Frac b)
    {
        int lcm = Lcm(a.den, b.den);
        return (new Frac(a.num * lcm / a.den, lcm), new Frac(b.num * lcm / b.den, lcm));
    }
    // 最大公約数
    private static int Gcd(int a, int b)
    {
        if (b == 0)
        {
            return a;
        }
        return Gcd(b, a % b);
    }
    // 最小公倍数
    private static int Lcm(int a, int b)
    {
        return a * b / Gcd(a, b);
    }

    // 相等比較 (先約分再比較) 
    // 註:Equals、GetHashCode、==、!= 要一起實作
    public override bool Equals(object? obj) => obj is Frac other 
        && this.Simplify().ToString() == other.Simplify().ToString();
    public override int GetHashCode() => Simplify().ToString().GetHashCode();
    public static bool operator ==(Frac a, Frac b) => a.Equals(b);
    public static bool operator !=(Frac a, Frac b) => !a.Equals(b);

    // 大小比較 (先通分再比較) 註:大於小放要一起實作
    public static bool operator <(Frac a, Frac b) => 
        CommonDenominator(a, b).Item1.num < CommonDenominator(a, b).Item2.num;
    public static bool operator >(Frac a, Frac b) =>
        CommonDenominator(a, b).Item1.num > CommonDenominator(a, b).Item2.num;
    public static bool operator <=(Frac a, Frac b) => a < b || a == b;
    public static bool operator >=(Frac a, Frac b) => a > b || a == b;

}

驗證程式如下:

// 使用自訂分數型別進行數學運算
Frac a = new Frac(1, 2);
Frac b = new Frac("3/4");
Frac c = new Frac(2);
Frac d = "2/3";
Frac e = 3;
Frac f = "4/6";

Console.WriteLine($"a = {a}"); // 1 / 2
Console.WriteLine($"正號 +a = {a}"); // 1 / 2
Console.WriteLine($"負號 -a = {-a}"); // -1 / 2
Console.WriteLine($"加法 {a} + {b} = {a + b}"); // 5 / 4
Console.WriteLine($"減法 {a} - {b} = {a - b}"); // -1 / 4
Console.WriteLine($"乘法 {a} * {b} = {a * b}"); // 3 / 8
Console.WriteLine($"除法 {a} / {b} = {a / b}"); // 2 / 3
Console.WriteLine($"乘整數 {b} * -3 = {a * -3}"); // -3 / 2
Console.WriteLine($"除整數 {b} / 3 = {a / 3}"); // 1 / 6
Console.WriteLine($"大於 {b} > {d} = {b > d}"); // True
Console.WriteLine($"小於 {b} < {d} = {b < d}"); // False
Console.WriteLine($"等於 {d} == {f} = {d == f}"); // True
Console.WriteLine($"大於等於 {d} >= {f} = {d >= f}"); // True
Console.WriteLine($"相等 {d}.Equals({f}) = {d.Equals(f)}"); // True
Console.WriteLine($"指定分母(9) {d} == {d.WithDenominator(9)}"); // 6 / 9
try {
    Console.WriteLine($"指定分母(7) {d} == {d.WithDenominator(7)}");
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}
// -2 * 1/3 + 3/4 = 1/12
Frac g = -2 * (Frac)"1/3" + new Frac(3, 4);
Console.WriteLine($"-2 * 1/3 + 3/4 = {g}"); // 1 / 12

坦白一件事,以上程式大約只有 30 ~ 40% 由我輸入,其餘都是給個大方向寫個函數名稱 Github Copilot 便幫忙寫完程式邏輯,我再檢查跟微調。成對匹配的項目,像是 == 與 !=,> 跟 <,更是寫完第一個 Copilot 自動補上另一個。而最大公約數 Gcd()、最小公倍數 Lcm() 連同函式名稱含內容都是 Copilot 生出來的。

使用 Github 初期有點不太習慣,連公約數公倍數怎麼算還沒想,程式就寫完了,有種被餵飯的感覺,心裡不太踏實。但想想,過去 Intellisense 的出現幫大腦減輕了記憶方法、參數以及打字的負擔;Github Copilot 只是再進一步減輕大腦「記憶及構思演算法細節」的負擔,雖然失去部分鍛練大腦思考的機會,只要確保有看懂邏輯並有能力修正及優化,就安心享受開發加速的快感吧。

當改用先進工具已是不可擋的趨勢,討論該不該接受已無意義,不跟進就像人人都用電動起子你堅持用羅賴把鎖螺絲釘,自此失去競爭力。抱持正確心態,別對工具過度依賴,要求自己看懂 Copilot 生成的每一行程式碼,與湊到程式會動不知所以然的開發者形成區隔,方能立於不敗之地。

註:程式範例改放上 Github

An example using operator overloading and implicit conversion to create a useful fraction structure.


Comments

Be the first to post a comment

Post a comment