CODE-分贓程式的寫法(LINQ進化版)
| | 1 | | ![]() |
很久前曾寫過一篇範例,介紹將數字金額依不同權重拆成多筆的分贓程式寫法,最近專案又再度陷入算錢的漩渦,但局面有點改變。近一兩年被.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<T, decimal></param>
/// <param name="resultCallback">傳入分配結果之委派Action<T, decimal></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