很久前曾寫過一篇範例,介紹將數字金額依不同權重拆成多筆的分贓程式寫法,最近專案又再度陷入算錢的漩渦,但局面有點改變。近一兩年被.NET 3.5/.NET 4寵壞了,已經有點"不用LINQ不會寫Code"的傾向,因此現在寫的帳務程式,就大量引用了List<SomeObject>的技巧處理資料。舉例來說,拆帳結果被裝在類似List<ResultObject>的IEnumerable裡,要做到依權重分攤,最直覺的寫法是跑個迴圈套用原本的分贓演算法,看起來就能輕鬆搞定:

List<ResultObject> list = ...建立物件清單...;
decimal amtSum = 10000;
decimal weightSum = ...計算權重數字總和...;
foreach (ResultObject o in list)
{
    o.Amount = Math.Round(amtSum * o.Weight / weightSum,
                                0, MidpointRounding.AwayFromZero);
    amtSum -= o.Amount;
    weightSum -= o.Weight;
}

但以上寫法有兩個缺點:

第一,這段程式法寫死了o.Weight, o.Amount等屬性名稱,要套用到不同物件上得經過修改,而所謂"修改"說穿了是"Copy & Paste & Modify",用多了程式會臭(Bad Smell),遲早該重構。

第二,如果ResultObject上同時有Amount, Tax, ServiceFee... 多個屬性都要依權重分配,就會出現一堆相似的程式碼:

o.Amount = Math.Round(amtSum * o.Weight / weightSum, 0, MidpointRounding.AwayFromZero);
o.Tax = Math.Round(taxSum * o.Weight / weightSum, 0, MidpointRounding.AwayFromZero);
o.ServiceFee = Math.Round(svcFeeSum * o.Weight / weightSum, 0, MidpointRounding.AwayFromZero);
amtSum –= o.Amount;
taxSum –= o.Tax;
svcFeeSum –= o.ServiceFee;
weightSum –= o.Weight;

看到這種大部分雷同,只有小處要修改的反覆程式碼,就代表有邪惡的”Copy & Paste”出沒,一樣會讓程式沾上壞味道。

針點以上缺點,理論上可以透過Func<T, decimal>、Action<T, decimal>的委派概念改善,再配合自訂IEnumerable<T> Extension Methods以及引進Lambda表示式,最後應該可以精簡成以下的樣子:

list
.PerfectDivide(10000, o => o.Weight, (o, v) => o.Amount = v)
.PerfectDivide(5000, o => o.Weight, (o, v) => o.Tax = v)
.PerfectDivide(400, o => o.Weight, (o, v) => o.ServiceFee = v);

是不是比原本的做法簡潔優雅多了? 而且它可以套用在各種物件上,權重或分配對象悉聽尊便。

心動不如馬上行動,那就立刻動手把函數生出來吧!!

程式範例如下: (看完記得為.NET按個【讚】!)

[2012-02-23更新]本演算法在極端案例下會崩壞,修正方法請參見補充文章

using System;
using System.Collections.Generic;
using System.Linq;
 
namespace ConsoleApplication1
{
    class Program
    {
        class Share
        {
            public decimal Weight { get; set; }
            public decimal Amount { get; set; }
            public decimal Tax { get; set; }
            public decimal ShippingFee { get; set; }
        }
 
        static void Main(string[] args)
        {
            //假設有一筆訂單,金額32767, 稅金438, 運費600
            //要依1.2 : 1.5 : 2拆成三份
            
            //建立三個分配結果物件,設定權重
            List<Share> shares = new List<Share>();
            shares.Add(new Share() { Weight = 1.2M });
            shares.Add(new Share() { Weight = 1.5M });
            shares.Add(new Share() { Weight = 2M });
            
            //執行分配,參數依序傳入待分配金額,小數位數,權重依據,取回結果
            //其中權重依據及取回結果可使用Lambda表示式
            shares
           .PerfectDivide<Share>(32767, 0, o => o.Weight, (o, v) => o.Amount = v)
           .PerfectDivide<Share>(438, 1, o => o.Weight, (o, v) => o.Tax = v)
       .PerfectDivide<Share>(600, 0, o => o.Weight, (o, v) => o.ShippingFee = v);
            foreach (var share in shares)
                Console.WriteLine("{0:N0}\t{1:N1}\t{2:N0}",
                                  share.Amount, share.Tax, share.ShippingFee);
            Console.Read();
        }
    }
 
    /// <summary>
    /// 無尾差之完美分贓演算法
    /// </summary>
    public static class PerfectDivider
    {
        /// <summary>
        /// 依提供比例/權重進行無尾差分配計算
        /// </summary>
        /// <param name="weights">權重陣列</param>
        /// <param name="amt">待分配的數量</param>
        /// <param name="decimals">小數位數</param>
        /// <returns>分配結果陣列</returns>
        public static decimal[] Divide(decimal[] weights, decimal amt, int decimals)
        {
            decimal weightSum = weights.Sum(o => o);
            decimal[] result = new decimal[weights.Length];
            for (int i = 0; i < weights.Length; i++)
            {
                result[i] = Math.Round(amt*weights[i]/weightSum, decimals,
                                       MidpointRounding.AwayFromZero);
                amt -= result[i];
                weightSum -= weights[i];
            }
            return result;
        }
 
        /// <summary>
        /// 以LINQ方式實不同權重之無尾差分配
        /// </summary>
        /// <typeparam name="T">物件型別</typeparam>
        /// <param name="ienum">IEnumerable集合</param>
        /// <param name="amt">待分配量</param>
        /// <param name="decimals">小數位數</param>
        /// <param name="getWeight">回傳權重數字之委派Func&lt;T, decimal&gt;</param>
        /// <param name="resultCallback">傳入分配結果之委派Action&lt;T, decimal&gt;</param>
        /// <returns>繼續傳回IEnumerable</returns>
        public static IEnumerable<T> PerfectDivide<T>(
            this IEnumerable<T> ienum,
            decimal amt, int decimals, Func<T, decimal> getWeight,
            Action<T, decimal> resultCallback)
        {
            decimal[] weights = ienum.Select(getWeight).ToArray();
            decimal[] results = Divide(weights, amt, decimals);
            for (int i = 0; i < results.Length; i++)
                resultCallback(ienum.ElementAt(i), results[i]);
            return ienum;
        }
    }
}

Comments

# by laneser

我後來發現 VS2010 對於 Generics 在參數也有型別的情況下可以自行推導, 所以 code 可以寫成 shares.PerfectDivide(32767, 0, o => o.Weight, (o, v) => o.Amount = v); 超棒! 日後 o 改型別都不用改 code ...XD

Post a comment