天天看點

你真的會數錢嗎?

本文已遷移至:http://thinkinside.tk/2013/01/01/money.html

快年底了,假如你們公司的美國總部給每個人發了一筆201212.21美元的特别獎金,作為程式員的你, 該如何把這筆錢收入囊中?

Table of Contents

  • 1 美元?美元!
  • 2 存入賬戶
  • 3 收稅
  • 4 轉成人民币
  • 5 分錢
  • 6 記賬
  • 7 來點進階的
  • 8 其他未盡事宜
  • 9 小結

1 美元?美元!

你可能覺得,這根本不是問題。在自己的賬戶中直接加上一筆“轉入”就行了。但是首先就遇到了币種的問題。

一般來說,銀行賬戶都是單币種的。你可能會說不對啊,我的一卡通就能存入不同的币種啊?但那是一個“賬号(Account Number)”對應的多個“賬戶(Account)”。 通常财務記賬的時候,一個“賬戶(Account)”都使用同一币種。

賬戶(Account)記錄了資金的往來,包含很多條目(Entry)。賬戶會記錄結餘,結餘等于所有條目中金額的總和。

我們不可能為每個币種設計一種條目,是以需要抽象出一個貨币類——Money,适用于各種不同的币種: 

你真的會數錢嗎?

Money類至少要記錄金額和币種:

  • 對于金額,由于貨币存在最小面額,是以金額的類型可以采用定點小數或者整型。考慮到會對金額進行一些運算,用整數處理應該更友善。如果用java語言實作,可以使用

lang類型。

  • 對于币種,java提供了java.util.Currency類,專門用于表示貨币,符合ISO 4217貨币代碼标準。Currency使用Singleton模式,需要用getInstance方法獲得執行個體。

主要的方法包括:

    • String getCurrencyCode() 擷取貨币的ISO 4217貨币代碼
    • int getDefaultFractionDigits() 擷取與此貨币一起使用的預設小數位數
    • static Currency getInstance(Locale locale) 傳回給定語言環境的國家/地區的 Currency 執行個體
    • static Currency getInstance(String currencyCode) 傳回給定貨币代碼的 Currency 執行個體。
    • String getSymbol() 擷取預設語言環境的貨币符号
    • String getSymbol(Locale locale) 擷取指定語言環境的貨币符号
    • String toString() 傳回此貨币的 ISO 4217 貨币代碼

通過Currency類的幫助,我們的Money類看起來大概是這個樣子(為了友善,提供多種構造函數):

public class Money {
    private long amount;
    private Currency currency;

    public double getAmount() {
        return BigDecimal.valueOf(amount, currency.getDefaultFractionDigits()).doubleValue();
        
    }

    public Currency getCurrency() {
        return currency;
    }

    public Money(double amount, Currency currency) {
        this.currency = currency;
        this.amount = Math.round(amount * centFactor());
    }

    public Money(long amount, Currency currency) {
        this.currency = currency;
        this.amount = amount * centFactor();
    }
    
    private static final int[] cents = new int[] { 1, 10, 100, 1000,10000 };

    private int centFactor() {
        return cents[currency.getDefaultFractionDigits()];
    }
}      

用Money類表示我們的$201212.21獎金,就是:

Money myMoney = new Money(201212.21,Currency.getInstance(Locale.US));      

2 存入賬戶

終于解決了币種的問題,可以把錢存入賬戶了。存入的邏輯是:在條目中記錄一筆賬目,并計算賬戶的餘額。

不同币種之間相加或相減是沒有意義的,為了避免人為錯誤,在Money的代碼中就要禁止這種操作。我們可以采用抛出異常的方式。 為了簡單起見,這裡不再定義一個單獨的"MoneyException",而是直接使用java.lang.Exception:

public Money add(Money money) throws Exception{
        if(!money.getCurrency().equals(this.currency)){
            throw(new Exception("different currency can't be add"));
        }
        BigDecimal value = this.getAmount().add(money.getAmount());
        Money result = new Money(value.doubleValue(),this.getCurrency());
        return result;
    }
    
    public Money minus(Money money) throws Exception{
        if(!money.getCurrency().equals(this.currency)){
            throw(new Exception("different currency can't be minus"));
        }
        
        BigDecimal value =this.getAmount().add(money.getAmount().negate());
        Money result = new Money(value.doubleValue(),this.getCurrency());
        return result;
        
    }      

3 收稅

先不要高興得太早,這筆錢屬于“一次性所得”,需要交20%的個人所得稅。稅後所得應該是多少?

你可能說:是80%。隻要為Money加上一個multiply(double factor)方法就可以進行計算了。

但是牽扯到了舍入的問題。由于貨币存在最小機關,在做乘/除法運算的時候就要考慮到舍入的問題了。最好是能夠控制舍入的行為。假如稅務部門對于 舍入的計算有明确規定,我們也可以做一個遵紀守法的好公民。

在java.math.BigDecimal中定義了7種舍入模式:

  • ROUNDUP:等于遠離0的數。
  • ROUNDDOWN:等于靠近0的數。
  • ROUNDCEILING:等于靠近正無窮的數。
  • ROUNDFLOOR:等于靠近負無窮的數。
  • ROUNDHALFUP:等于靠近的數,若舍入位為5,應用ROUNDUP。
  • ROUNDHALFDOWN:等于靠近的數,若舍入位為5,應用ROUNDDOWN。
  • ROUNDHALFEVEN:舍入位前一位為奇數,應用ROUNDHALFUP;舍入位前一位為偶數,應用ROUNDHALFDOWN。

我們可以借用這些模式作為參數:

public static final int ROUND_UP = BigDecimal.ROUND_UP;
    public static final int ROUND_DOWN = BigDecimal.ROUND_DOWN;
    public static final int ROUND_CEILING = BigDecimal.ROUND_CEILING;
    public static final int ROUND_FLOOR = BigDecimal.ROUND_FLOOR;
    public static final int ROUND_HALF_UP = BigDecimal.ROUND_HALF_UP;
    public static final int ROUND_HALF_DOWN = BigDecimal.ROUND_HALF_DOWN;
    public static final int ROUND_HALF_EVEN = BigDecimal.ROUND_HALF_EVEN;
    public static final int ROUND_UNNECESSARY = BigDecimal.ROUND_UNNECESSARY;


public Money multiply(double multiplicand, int roundingMode) {
        BigDecimal amount = this.getAmount().multiply(new BigDecimal(multiplicand));
        amount = amount.divide(BigDecimal.ONE,roundingMode);
        return new Money(amount.doubleValue(),this.getCurrency());
    }

public Money divide(double divisor, int roundingMode) {
        BigDecimal amount = this.getAmount().divide(new BigDecimal(divisor),
                roundingMode);
        Money result = new Money(amount.doubleValue(), this.getCurrency());
        return result;
    }      

4 轉成人民币

盡管各領域的國際化提了十幾年,但是在國内想直接用美元消費還是有一定困難。是以你決定将這筆錢換成人民币。

對于賬戶來說,就是在美元賬戶和人民币賬戶分别做一筆轉出和轉入。 轉入和轉出的amount值是不同的,因為涉及到币種轉換的問題。 顯然,賬戶對象不應該知道如何進行匯率轉換,責任又落在了Money類上。

最直覺的做法是在Money類上增加一個convertTo(Currency currency)的方法。 但匯率實在是一個複雜的問題:

  1. 匯率是經常變化的;
  2. 匯率轉換時的舍入處理會有相關的約定;

這些複雜的問題處理如果直接放在Money類上會顯得十分笨重,單獨設計一個MoneyConverter類會比較好:

import java.util.Currency;


public interface MoneyConverter {
    Money convertTo(Money money,Currency currency) throws Exception;
}      

我們實作一個最簡單的轉化器,使用固定的匯率值:

import java.math.BigDecimal;
import java.util.Currency;
import java.util.Locale;


public class SimpleMoneyConverter implements MoneyConverter {

    private static final BigDecimal DOLLAR_TO_CNY =  new BigDecimal(6.2365);
    private static final Currency DOLLAR = Currency.getInstance(Locale.US);
    private static final Currency CNY = Currency.getInstance(Locale.CHINA);
    @Override
    public Money convertTo(Money money,Currency target) throws Exception{
        if(!known(money.getCurrency()) || !known(target)){
            throw (new Exception("unknown currency"));
        }
        
        BigDecimal factorSource =BigDecimal.ONE, factorTarget = BigDecimal.ONE;
        if(money.getCurrency().equals(DOLLAR))
                factorSource = DOLLAR_TO_CNY;
        if(target.equals(DOLLAR))
                factorTarget = DOLLAR_TO_CNY;
        BigDecimal value = money.getAmount().multiply(factorSource).divide(factorTarget);
        
        return new Money(value.doubleValue(),target);
    }
    
    private boolean known(Currency currency){
        return(currency.equals(DOLLAR) || currency.equals(CNY) );
    }

}      

可以看到,即使是最簡單的轉換器,處理起來也比較麻煩。是以千萬不要在Money類中做這件事情。

通過轉換器可以很容易得到轉成人民币後的值。

5 分錢

有好處不能獨享。這筆錢你決定和老婆三七開。當然,你三!

這又是一個新的舍入問題:即使你指定各自的舍入計算方法,也不能保證各部分舍入後的值加總後仍等于原值。

前面的“可定制乘除法”似乎不能很好的解決這個問題,是以我們需要一個新的方法: Money[] allocate(double[] ratioes)

傳入配置設定比例的數組,傳回配置設定結果的數組。

為了保證配置設定的公平,可以使用僞随機數來處理誤差。

該方法的實作如下:

public Money[] allocate(double[] ratioes) throws Exception{
        if(ratioes.length==0){
            throw (new Exception("there is no ratio"));
        }
        
        double ratioTotal = 0;
        for(double ratio:ratioes){
            ratioTotal += ratio;
        }
        
        if(0==ratioTotal){
            throw(new Exception("total of ratioes is zero"));
        }
        
        
        double total = this.getAmount().doubleValue();
        double delta = total;
        Money[] results = new Money[ratioes.length];
        
        for(int i=0;i<ratioes.length;i++){
            double amount = total*ratioes[i]/ratioTotal;
            results[i] = new Money(amount,this.getCurrency());
            delta -= results[i].getAmount().doubleValue();
        }
        
        int i = (int)(Math.random() * ratioes.length); 
        results[i] = results[i].minus(new Money(delta,this.getCurrency()));
        return results;
    }
          

6 記賬

将一切重要的資料儲存到資料庫是很通常的做法。但是将Money儲存到資料庫的時候,你要小心了!

Money不能作為單獨的實體。如果把Money當做實體來處理,就會産生一些問題:

  1. 會有很多實體關聯到Money,比如本文中的Account,Entry等。
  2. 需要非常小心處理對Money對象的引用,避免多個實體引用到同一個Money對象。在第一點的前提下,這會變得很困難。

是以應該把Money嵌入到需要的實體中,而不是把Money作為單獨的實體。這樣,Money僅僅是實體對象(比如Entry)的一個屬性,隻不過其具有多個内置的屬性值。

在JPA中,可以使用@Embeddable來标注Money類。

更複雜的情況是,由于一個Account中的所有Entry都應該具有相同的Currency,将Currency儲存到Account中會更簡潔,Entry中隻記錄ammount。

可以為Money的currency屬性增加@Transient标注,在Entry類的getMoney中進行組裝。

7 來點進階的

在DDD(領域驅動設計)中,Money是典型的值對象(Value Object)。值對象與實體的根本差別是:值對象不需要進行辨別(ID)。

這會帶來一些處理上的不同:

  1. 實體對象根據ID判斷是否相等,值對象隻根據内部屬性值判斷是否相等
  2. 值對象通常小而且簡單,建立的代價較小
  3. 值對象隻傳遞值,不傳遞對象引用,不用判斷值對象是否指向同一個實體對象
  4. 通常将值對象設計為通過構造函數進行屬性設定,一旦建立就無法改變其屬性值

由于值對象根據内部屬性值判等,我們要為Money類覆寫equals方法: public boolean equals(Object other)

8 其他未盡事宜

  • 我們還可以為Money類增加互相比較的方法(略)
  • 可以在構造函數中進行格式校驗(略)
  • 可以增加一些幫助顯式的方法 使用currency的getSymbol(Locale locale)方法、和NumberFormat的format方法,比如:

    NumberFormat nf=NumberFormat.getCurrencyInstance(Locale.CHINA);

    String s=nf.format(73084.803984);// result:¥73,084.80

9 小結

本文探讨如何在應用中處理貨币類型,包括币種轉換、各種計算、如何持久化等内容。

貨币類型是典型的值對象,本文也介紹了一點值對象的特點。更多的内容可以參考DDD。

Date: 2013-01-01 02:27:05 CST

Author: Holbrook

Org version 7.8.11 with Emacs version 24

Validate XHTML 1.0