Swift 中如何避免精度丟失

如果你開發(fā)過涉及金額計(jì)算的 iOS app, 那么你很有可能經(jīng)歷過在使用浮點(diǎn)型數(shù)字時(shí)精度丟失的問題

himg

讓我們來看看為什么會(huì)丟失以及如何解決吧

浮點(diǎn)型數(shù)字的數(shù)值精度為何會(huì)丟失?

這里我不想系統(tǒng)地講解浮點(diǎn)型是如何由基數(shù)尾數(shù)指數(shù)組成的, 直接說原因: 因?yàn)橛枚M(jìn)制能表示的以 2 為底的指數(shù)必然是 2 的倍數(shù), 也就是說只能為 0.5, 0.25, 0.125... 以此類推, 那么我們就可以發(fā)現(xiàn)無論將這些數(shù)字怎么組合, 都不可能達(dá)到 0.3 這個(gè)值, 因此計(jì)算機(jī)這個(gè)時(shí)候會(huì)給我們一個(gè)最接近 0.3 且恰好是這些數(shù)字之和的一個(gè)近似值.

himg

因此, 對(duì)于精度丟失我們可以得出如下結(jié)論:

  • 在 Swift 里面整數(shù)是不會(huì)有精度丟失的問題的, 因?yàn)檎麛?shù)的跨度為 1, 1 是可以被 2 進(jìn)制表示出來的
  • 由于 Swift 編程語言存儲(chǔ)浮點(diǎn)型的方式問題, 浮點(diǎn)型 (Double/Float) 的精度丟失問題是必然會(huì)發(fā)生的

數(shù)值精度丟失的影響

上面我們簡(jiǎn)單的解釋了為什么會(huì)丟失精度, 那么精度丟失對(duì)我們?cè)谑裁磿r(shí)候有影響呢?

根據(jù)我的經(jīng)驗(yàn), 我認(rèn)為主要場(chǎng)景集中如下:

  • 在需要將數(shù)字以字面值向外界展示的時(shí)候
  • 在需要將數(shù)字發(fā)向服務(wù)器進(jìn)行嚴(yán)格對(duì)比 (每一位都不能有差別)

所以, 精度丟失并不可怕 (起碼出現(xiàn)的場(chǎng)景很少). 下面讓我們看下如何才能在我們真的遇到了精度丟失問題時(shí)候進(jìn)行解決

如何應(yīng)對(duì)數(shù)值精度丟失

  1. 計(jì)算過程中全程使用 Double, 最后轉(zhuǎn)為字符串

    由于 Swift 在精度丟失時(shí)會(huì)在保留很多位小數(shù) (比如 0.3 存儲(chǔ)為 0.29999999999999999), 這些小數(shù)與真實(shí)值的差距非常之小, 因此我們完全可以在過程中不對(duì)其進(jìn)行任何操作, 仍然讓其保持 Double 類型, 在最后時(shí)刻要發(fā)往服務(wù)器或者顯示的時(shí)候我們將其四舍五入轉(zhuǎn)換為字符串, 這樣的結(jié)果基本不會(huì)出錯(cuò).

    但是切記一定不要在計(jì)算過程中進(jìn)行四舍五入, 否則極有可能會(huì)造成誤差的累計(jì), 從而導(dǎo)致誤差變大不可接受.

  2. Decimal 格式進(jìn)行接收并計(jì)算

    上面的方式簡(jiǎn)單, 只需要注意在最后時(shí)刻進(jìn)行一次字符串轉(zhuǎn)換即可, 但是有缺陷: 必須讓服務(wù)器將原本的數(shù)字類型轉(zhuǎn)為以字符串類型來接收, 這并不是一種友好的方式. 那么我們到底有沒有辦法讓 app 向服務(wù)器發(fā)送一個(gè)帶有精度不丟失的浮點(diǎn)數(shù)字的 json 數(shù)據(jù)包呢? 比如 {"num": 0.3}, 而不是 {"num": 0.29999999999999999}

    答案是可以. Swift 為我們提供了用于十進(jìn)制計(jì)算的一個(gè)類型: Decimal, 這個(gè)類型也帶有 +, -, *, / 運(yùn)算符, 并且支持 Codable 協(xié)議, 我們完全可以定義此類型接受服務(wù)器的參數(shù)值, 然后以此類型進(jìn)行運(yùn)算然后使用, 最后, 因?yàn)槠渲С?Codable 協(xié)議, 我們可以將其值直接放入 json 包中. 沒有特殊情況的話我們就完全避開了二進(jìn)制浮點(diǎn)型數(shù)字了, 這樣是不會(huì)有任何的誤差的

    himg
    himg

NSDecimalNumber 與 Decimal 區(qū)別

NSDecimalNumberNSNumber 的一個(gè)子類, 比 NSNumber 的功能更為強(qiáng)大, 四舍五入, 取整, 輸入后自動(dòng)去掉數(shù)值前面無用的 0 等等. 由于 NSDecimalNumber 精度較高, 所以會(huì)比基本數(shù)據(jù)類型費(fèi)時(shí), 所以需要權(quán)衡考慮, 蘋果官方建議在貨幣以及要求精度很高的場(chǎng)景下使用.

通常情況下我們會(huì)使用 NSDecimalNumberHandler 這個(gè)格式化器對(duì)其需要約束的格式進(jìn)行設(shè)置, 然后構(gòu)建出需要的 NSDecimalNumber

let ouncesDecimal: NSDecimalNumber = NSDecimalNumber(value: doubleValue)
let behavior: NSDecimalNumberHandler = NSDecimalNumberHandler(roundingMode: mode,
                                                              scale: Int16(decimal),
                                                              raiseOnExactness: false,
                                                              raiseOnOverflow: false,
                                                              raiseOnUnderflow: false,
                                                              raiseOnDivideByZero: false)
let roundedOunces: NSDecimalNumber = ouncesDecimal.rounding(accordingToBehavior: behavior)

NSDecimalNumberDecimal 基本是無縫橋接的, Decimal 是一個(gè)值類型 Struct, NSDecimalNumber 是一個(gè)引用類型 Class, 看起來 NSDecimalNumber 的設(shè)置功能更為豐富, 但是如果只是需要對(duì)位數(shù), 四舍五入方式有要求的話 Decimal 也完全可以滿足, 而且性能會(huì)更好, 所以我認(rèn)為 NSDecimalNumber 僅在 Decimal 無法實(shí)現(xiàn)某個(gè)功能時(shí)才作為備用考慮.

總的來說, NSDecimalNumberDecimal 的關(guān)系類似 NSStringString 的關(guān)系.

Decimal 的正確使用方式

正確使用 json 反序列化對(duì) Decimal 進(jìn)行賦值 -- 使用 ObjectMapper

當(dāng)我們聲明一個(gè) Decimal 屬性后, 然后使用一個(gè) json 字符串對(duì)其進(jìn)行賦值, 我們會(huì)發(fā)現(xiàn)精度仍然丟失了, 為什么會(huì)有這樣的結(jié)果呢?

struct Money: Codable {
    let amount: Decimal
    let currency: String
}

let json = "{\"amount\": 9021.234891,\"currency\": \"CNY\"}"
let jsonData = json.data(using: .utf8)!
let decoder = JSONDecoder()

let money = try! decoder.decode(Money.self, from: jsonData)
print(money.amount)
himg

答案是簡(jiǎn)單的: 我們使用的 JSONDecoder() 內(nèi)部使用了 JSONSerialization() 進(jìn)行反序列化, 其邏輯非常簡(jiǎn)單, 在碰到 9021.234891 這個(gè)數(shù)字時(shí), 其會(huì)毫不猶豫的將其看做 Double 類型, 然后再將 Double 轉(zhuǎn)為 Decimal 是可以成功的, 但是這個(gè)時(shí)候已經(jīng)是精度丟失的 Double 了, 轉(zhuǎn)換得來的 Decimal 類型自然也是精度丟失的.

對(duì)于這個(gè)問題, 我們必須要能夠控制其反序列化過程. 我現(xiàn)在的選擇方案是使用 ObjectMapper, 其可以使用自定義規(guī)則靈活控制序列化與反序列化的過程.

ObjectMapper 默認(rèn)情況下是不支持 Decimal 的, 我們可以自定義一個(gè)支持 Decimal 類型的 TransformType, 如下:

open class DecimalTransform: TransformType {
    public typealias Object = Decimal
    public typealias JSON = Decimal

    public init() {}

    open func transformFromJSON(_ value: Any?) -> Decimal? {
        if let number = value as? NSNumber {
            return Decimal(string: number.description)
        } else if let string = value as? String {
            return Decimal(string: string)
        }
        return nil
    }

    open func transformToJSON(_ value: Decimal?) -> Decimal? {
        return value
    }
}

然后將此 TransformType 應(yīng)用于我們需要轉(zhuǎn)換的屬性上

struct Money: Mappable {
    var amount: Decimal?
    var currency: String?

    init() { }
    init?(map: Map) { }

    mutating func mapping(map: Map) {
        amount <- (map["amount"], DecimalTransform())
        currency <- map["currency"]
    }
}
himg

正確使用 Decimal 的初始化方式

Decimal 有多種初始化方式, 我們可以傳入整型值, 傳入浮點(diǎn)型, 傳入字符串方式進(jìn)行初始化, 我認(rèn)為正確的初始化方式應(yīng)該是使用字符串.

himg

上面這張圖應(yīng)該很簡(jiǎn)單明了的說明了我為什么這么認(rèn)為了. 其原因與上個(gè)反序列問題相似, 也是因?yàn)槲覀儌魅?Double 時(shí), Swift 對(duì)其進(jìn)行了一次承載, 這一次承載就對(duì)其造成了精度丟失, 根據(jù)已經(jīng)丟失精度的 Double 初始化出 Decimal, 這個(gè) Decimal 是精度丟失的也就不難理解了

參考

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容