Swift 中如何避免精度丢失

如果你开发过涉及金额计算的 iOS app, 那么你很有可能经历过在使用浮点型数字时精度丢失的问题

himg

让我们来看看为什么会丢失以及如何解决吧

浮点型数字的数值精度为何会丢失?

这里我不想系统地讲解浮点型是如何由基数尾数指数组成的, 直接说原因: 因为用二进制能表示的以 2 为底的指数必然是 2 的倍数, 也就是说只能为 0.5, 0.25, 0.125... 以此类推, 那么我们就可以发现无论将这些数字怎么组合, 都不可能达到 0.3 这个值, 因此计算机这个时候会给我们一个最接近 0.3 且恰好是这些数字之和的一个近似值.

himg

因此, 对于精度丢失我们可以得出如下结论:

  • 在 Swift 里面整数是不会有精度丢失的问题的, 因为整数的跨度为 1, 1 是可以被 2 进制表示出来的
  • 由于 Swift 编程语言存储浮点型的方式问题, 浮点型 (Double/Float) 的精度丢失问题是必然会发生的

数值精度丢失的影响

上面我们简单的解释了为什么会丢失精度, 那么精度丢失对我们在什么时候有影响呢?

根据我的经验, 我认为主要场景集中如下:

  • 在需要将数字以字面值向外界展示的时候
  • 在需要将数字发向服务器进行严格对比 (每一位都不能有差别)

所以, 精度丢失并不可怕 (起码出现的场景很少). 下面让我们看下如何才能在我们真的遇到了精度丢失问题时候进行解决

如何应对数值精度丢失

  1. 计算过程中全程使用 Double, 最后转为字符串

    由于 Swift 在精度丢失时会在保留很多位小数 (比如 0.3 存储为 0.29999999999999999), 这些小数与真实值的差距非常之小, 因此我们完全可以在过程中不对其进行任何操作, 仍然让其保持 Double 类型, 在最后时刻要发往服务器或者显示的时候我们将其四舍五入转换为字符串, 这样的结果基本不会出错.

    但是切记一定不要在计算过程中进行四舍五入, 否则极有可能会造成误差的累计, 从而导致误差变大不可接受.

  2. Decimal 格式进行接收并计算

    上面的方式简单, 只需要注意在最后时刻进行一次字符串转换即可, 但是有缺陷: 必须让服务器将原本的数字类型转为以字符串类型来接收, 这并不是一种友好的方式. 那么我们到底有没有办法让 app 向服务器发送一个带有精度不丢失的浮点数字的 json 数据包呢? 比如 {"num": 0.3}, 而不是 {"num": 0.29999999999999999}

    答案是可以. Swift 为我们提供了用于十进制计算的一个类型: Decimal, 这个类型也带有 +, -, *, / 运算符, 并且支持 Codable 协议, 我们完全可以定义此类型接受服务器的参数值, 然后以此类型进行运算然后使用, 最后, 因为其支持 Codable 协议, 我们可以将其值直接放入 json 包中. 没有特殊情况的话我们就完全避开了二进制浮点型数字了, 这样是不会有任何的误差的

    himg
    himg

NSDecimalNumber 与 Decimal 区别

NSDecimalNumberNSNumber 的一个子类, 比 NSNumber 的功能更为强大, 四舍五入, 取整, 输入后自动去掉数值前面无用的 0 等等. 由于 NSDecimalNumber 精度较高, 所以会比基本数据类型费时, 所以需要权衡考虑, 苹果官方建议在货币以及要求精度很高的场景下使用.

通常情况下我们会使用 NSDecimalNumberHandler 这个格式化器对其需要约束的格式进行设置, 然后构建出需要的 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 是一个值类型 Struct, NSDecimalNumber 是一个引用类型 Class, 看起来 NSDecimalNumber 的设置功能更为丰富, 但是如果只是需要对位数, 四舍五入方式有要求的话 Decimal 也完全可以满足, 而且性能会更好, 所以我认为 NSDecimalNumber 仅在 Decimal 无法实现某个功能时才作为备用考虑.

总的来说, NSDecimalNumberDecimal 的关系类似 NSStringString 的关系.

Decimal 的正确使用方式

正确使用 json 反序列化对 Decimal 进行赋值 -- 使用 ObjectMapper

当我们声明一个 Decimal 属性后, 然后使用一个 json 字符串对其进行赋值, 我们会发现精度仍然丢失了, 为什么会有这样的结果呢?

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

答案是简单的: 我们使用的 JSONDecoder() 内部使用了 JSONSerialization() 进行反序列化, 其逻辑非常简单, 在碰到 9021.234891 这个数字时, 其会毫不犹豫的将其看做 Double 类型, 然后再将 Double 转为 Decimal 是可以成功的, 但是这个时候已经是精度丢失的 Double 了, 转换得来的 Decimal 类型自然也是精度丢失的.

对于这个问题, 我们必须要能够控制其反序列化过程. 我现在的选择方案是使用 ObjectMapper, 其可以使用自定义规则灵活控制序列化与反序列化的过程.

ObjectMapper 默认情况下是不支持 Decimal 的, 我们可以自定义一个支持 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 应用于我们需要转换的属性上

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 有多种初始化方式, 我们可以传入整型值, 传入浮点型, 传入字符串方式进行初始化, 我认为正确的初始化方式应该是使用字符串.

himg

上面这张图应该很简单明了的说明了我为什么这么认为了. 其原因与上个反序列问题相似, 也是因为我们传入 Double 时, Swift 对其进行了一次承载, 这一次承载就对其造成了精度丢失, 根据已经丢失精度的 Double 初始化出 Decimal, 这个 Decimal 是精度丢失的也就不难理解了

参考

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,904评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,581评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,527评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,463评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,546评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,572评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,582评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,330评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,776评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,087评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,257评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,923评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,571评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,192评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,436评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,145评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容