货币 (Money)

Build Status License Swift Version Cocoapods platforms Cocoapods compatible Carthage compatible

一种精确且类型安全的货币金额表示方式,适用于给定的货币。

此功能在Flight School Guide to Swift Numbers的第 3 章中进行了讨论。

要求

安装

Swift Package Manager

在你的 Package.swift 文件中,将 Money 包添加到你的目标依赖项中。

import PackageDescription

let package = Package(
  name: "YourProject",
  dependencies: [
    .package(
        url: "https://github.com/Flight-School/Money",
        from: "1.3.0"
    ),
  ]
)

然后运行 swift build 命令来构建你的项目。

CocoaPods

你可以通过 CocoaPods 安装 Money,只需将以下行添加到你的 Podfile 文件中:

pod 'Money-FlightSchool', '~> 1.3.0'

运行 pod install 命令来下载库并将其集成到你的 Xcode 项目中。

注意:此库的模块名称是 "Money" --- 也就是说,要使用它,你只需在 Swift 代码的顶部添加 import Money,就像使用任何其他安装方法一样。该 pod 被命名为 "Money-FlightSchool",因为已经存在一个名为 "Money" 的 pod。

Carthage

要使用 Carthage 在你的 Xcode 项目中使用 Money,请在 Cartfile 中指定它:

github "Flight-School/Money" ~> 1.3.0

然后运行 carthage update 命令来构建框架,并将构建的 Money.framework 拖到你的 Xcode 项目中。

用法

创建货币金额

Money 类型需要一个关联的 Currency 类型。这些货币类型根据它们的三个字母的 ISO 4701 货币代码命名。你可以使用 Decimal 值初始化货币:

let amount = Decimal(12)
let monetaryAmount = Money<USD>(amount)

某些货币指定了一个较小的单位。例如,美元金额通常以美分表示,每美分价值 1/100 美元。你可以从少量较小单位初始化货币金额。对于没有较小单位的货币,例如日元 (JPY),这等同于标准初始化器。

let twoCents = Money<USD>(minorUnits: 2)
twoCents.amount // 0.02

let ichimonEn = Money<JPY>(minorUnits: 10_000)
ichimonEn.amount // 10000

你还可以使用整数、浮点数和字符串字面量创建货币金额。

12 as Money<USD>
12.00 as Money<USD>
"12.00" as Money<USD>

重要提示:Swift 浮点数字面量当前使用二进制浮点数类型初始化,该类型无法精确地表达某些值。作为一种解决方法,从浮点数字面量初始化的货币金额会被四舍五入到较小货币单位的位数。如果你想表达更小的分数货币金额,请从字符串字面量或 Decimal 值初始化。

let preciseAmount: Money<USD> = "123.4567"
let roundedAmount: Money<USD> = 123.4567

preciseAmount.amount // 123.4567
roundedAmount.amount // 123.46

有关更多信息,请参见 https://bugs.swift.org/browse/SR-920

比较货币金额

你可以比较两种具有相同货币的货币金额。

let amountInWallet: Money<USD> = 60.00
let price: Money<USD> = 19.99

amountInWallet >= price // true

尝试比较具有不同货币的货币金额会导致编译器错误。

let dollarAmount: Money<USD> = 123.45
let euroAmount: Money<EUR> = 4567.89

dollarAmount == euroAmount // Error: Binary operator '==' cannot be applied

加、减和乘货币金额

可以使用标准二元算术运算符(+-*)添加、减去和乘以货币金额。

let prices: [Money<USD>] = [2.19, 5.39, 20.99, 2.99, 1.99, 1.99, 0.99]
let subtotal = prices.reduce(0.00, +) // "$36.53"
let tax = 0.08 * subtotal // "$2.92"
let total = subtotal + tax // "$39.45"

重要提示:将货币金额乘以浮点数会导致金额四舍五入到较小货币单位的位数。如果你想产生更小的分数货币金额,请改为乘以 Decimal 值。

格式化货币金额

你可以使用 NumberFormatter 创建货币金额的本地化表示形式。将格式化程序的 currencyCode 属性设置为 Money 值的 currency.code 属性,并将 amount 属性传递给格式化程序的 string(for:) 方法。

let allowance: Money<USD> = 10.00
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale(identifier: "fr-FR")
formatter.currencyCode = allowance.currency.code
formatter.string(for: allowance.amount) // "10,00 $US"

编码和解码货币金额

编码

默认情况下,Money 值被编码为键控容器,其中 amount 被编码为数字值。

let value: Money<USD> = 123.45

let encoder = JSONEncoder()
let data = try encoder.encode(value)
String(data: data, encoding: .utf8) // #"{"amount":123.45,"currencyCode":"USD"}"#

要配置编码行为,请设置 JSONEncoder.moneyEncodingOptions 属性或编码器 userInfo 属性中的 CodingUserInfoKey.moneyEncodingOptions 键。

var encoder = JSONEncoder()
encoder.moneyEncodingOptions = [.omitCurrency, .encodeAmountAsString]

let data = try encoder.encode([value])
String(data: data, encoding: .utf8) // #"["123.45"]"#

解码

默认的解码行为很灵活,支持键控和单值容器,amount 的字符串或数字值。

let json = #"""
[
    { "currencyCode": "USD", "amount": "100.00" },
    50.00,
    "10"
]
"""#.data(using: .utf8)!

let decoder = JSONDecoder()
let values = try decoder.decode([Money<USD>].self, from: json)
values.first?.amount // 100.00
values.last?.currency.code // "USD"

要配置解码行为,请设置 JSONDecoder.moneyDecodingOptions 属性或解码器 userInfo 属性中的 CodingUserInfoKey.moneyDecodingOptions 键。

var decoder = JSONDecoder()
decoder.moneyDecodingOptions = [.requireExplicitCurrency]

重要提示:Foundation 解码器当前使用二进制浮点数类型解码数字值,该类型无法精确地表达某些值。作为一种解决方法,你可以指定 requireStringAmount 解码选项,以要求从字符串表示形式精确解码货币金额。

let json = #"""
{ "currencyCode": "USD", "amount": "27.31" }
"""#.data(using: .utf8)!

var decoder = JSONDecoder()

try decoder.decode(Money<USD>.self, from: json) // DecodingError

decoder.moneyDecodingOptions = [.requireStringAmount]
let preciseAmount = try decoder.decode(Money<USD>.self, from: json)
preciseAmount.amount // 27.31

或者,你可以使用 roundFloatingPointAmount 解码选项将解码的浮点值四舍五入到较小货币单位的位数。

let json = #"""
{ "currencyCode": "USD", "amount": 27.31 }
"""#.data(using: .utf8)!

var decoder = JSONDecoder()

let impreciseAmount = try decoder.decode(Money<USD>.self, from: json)
impreciseAmount.amount // 27.30999999...

decoder.moneyDecodingOptions = [.roundFloatingPointAmount]
let roundedAmount = try decoder.decode(Money<USD>.self, from: json)
roundedAmount.amount // 27.31

有关更多信息,请参见 https://bugs.swift.org/browse/SR-7054

自定义编码键

默认情况下,Money 值使用字符串键 "amount""currencyCode" 进行编码和解码,这些键对应于它们各自的属性。

如果你正在处理以不同方式编码货币金额的数据,你可以设置 JSONDecoderkeyDecodingStrategy 属性以映射到不同的键名:

let json = #"""
 {
    "value": "3.33",
    "currency": "USD"
 }
 """#.data(using: .utf8)!

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .custom({ keys in
    switch keys.last?.stringValue {
    case "value":
        return MoneyCodingKeys.amount
    case "currency":
        return MoneyCodingKeys.currencyCode
    default:
        return keys.last!
    }
})

let amount = try decoder.decode(Money<USD>.self, from: json) // $3.33

或者,你可以创建与你的数据形状匹配的结构,并派生返回 Money 类型的计算属性。

struct Item: Codable {
    struct Price: Codable {
        let value: String
        let currency: String
    }

    let name: String
    private let unitPrice: Price

    var unitPriceInUSD: Money<USD>? {
        guard unitPrice.currency == USD.code else { return nil }
        return Money(unitPrice.value)
    }
}

let json = #"""
 {
    "name": "Widget",
    "unitPrice": {
       "value": "3.33",
       "currency": "USD"
    }
 }
 """#.data(using: .utf8)!

let decoder = JSONDecoder()
let item = try decoder.decode(Item.self, from: json)
item.unitPriceInUSD // $3.33

支持多种货币

考虑一个具有 price 属性的 Product 结构。如果你只支持一种货币,例如美元,你可以将 price 定义为 Money<USD> 类型。

struct Product {
    var price: Money<USD>
}

但是,如果你想支持多种货币,则无法在属性声明中指定显式的货币类型。相反,Product 必须定义为泛型类型:

struct Product<Currency: CurrencyType> {
    var price: Money<Currency>
}

不幸的是,这种方法很笨拙,因为与 Product 交互的每种类型也需要是泛型的,依此类推,直到整个代码库都对货币类型是泛型的。

class ViewController<Currency: CurrencyType> : UIViewController { ... } // 😭

更好的解决方案是定义一个新的 Price 协议,其需求与 Money 类型匹配。

protocol Price {
    var amount: Decimal { get }
    var currency: CurrencyType.Type { get }
}

extension Money: Price {}

这样做允许以多种货币定义价格,而无需使 Product 对货币类型是泛型的。

struct Product {
    var price: Price
}

let product = Product(price: 12.00 as Money<USD>)
product.price // "$12.00"

如果你只想支持某些货币,例如美元和欧元,你可以定义一个 SupportedCurrency 协议,并通过扩展将一致性添加到每种货币类型。

protocol SupportedCurrency: CurrencyType {}
extension USD: SupportedCurrency {}
extension EUR: SupportedCurrency {}

extension Money: Price where Currency: SupportedCurrency {}

现在,尝试使用不支持的货币的价格创建 Product 会导致编译器错误。

Product(price: 100.00 as Money<EUR>)
Product(price: 100.00 as Money<GBP>) // Error

支持的货币

此软件包为 ISO 4217 标准定义的每种货币提供了一个 Currency 类型,但特殊代码除外,例如 USN(美国美元,隔日)和 XBC(债券市场单位欧洲会计单位 9)。

定义可用货币的 源文件 使用 GYBCSV 文件 生成。此数据源与 ISO 4217 修订案第 169 号保持同步,该修订案于 2018 年 8 月 17 日发布。

你可以通过安装 GYB 并从终端运行 make 命令,从 Resources/iso4217.csv 重新生成 Sources/Money/Currency.swift

$ make

我们目前没有自动更新此数据源的机制。如果您知道对 ISO 4217 所做的任何新修订,请打开一个问题

你可以使用 iso4217Currency(for:) 函数,通过其三个字母的代码查找任何内置货币类型。

iso4217Currency(for: "USD")?.name // "US Dollar"
iso4217Currency(for: "invalid") // nil

添加自定义货币

你可以通过定义符合 CurrencyType 协议的枚举来创建自己的自定义货币类型。 例如,以下是如何表示比特币 (BTC):

enum BTC: CurrencyType {
    static var name: String { return "Bitcoin" }
    static var code: String { return "BTC" }
    static var minorUnit: Int { return 8 }
}

let satoshi: Money<BTC> = 0.00000001

NumberFormatter 仅支持 ISO 4217 定义的货币,因此你需要配置符号、货币代码和任何其他必要的参数。

let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencySymbol = ""
formatter.currencyCode = "BTC"
formatter.maximumFractionDigits = 8

formatter.string(for: satoshi.amount) // ₿0.00000001

重要提示iso4217Currency(for:) 仅返回内置货币,因此调用 iso4217Currency(for: "BTC") 将返回 nil

用 Emoji 展示

如果你是那种喜欢在源代码中放置剪贴画的人,那么这里有一个 真正 让你的队友印象深刻的技巧:

typealias 💵 = Money<USD>
typealias 💴 = Money<JPY>
typealias 💶 = Money<EUR>
typealias 💷 = Money<GBP>

let tubeFare: 💷 = 2.40 // "£2.40"

需要考虑的替代方案

像此包提供的类型安全的 Money 结构可以减少某些类型的编程错误的发生。但是,你可能会发现使用此抽象的成本超过了它可以在你的代码库中提供的好处。

如果是这种情况,你可以考虑使用嵌套的 Currency 枚举来实现你自己的简单 Money 类型,如下所示:

struct Money {
   enum Currency: String {
      case USD, EUR, GBP, CNY // supported currencies here
   }

   var amount: Decimal
   var currency: Currency
}

最终由你决定哪种抽象最适合你的特定用例。无论你选择什么,都要确保使用具有显式货币的 Decimal 类型来表示货币金额。

许可证

MIT

联系方式

Mattt (@mattt)