一种精确且类型安全的货币金额表示方式,适用于给定的货币。
此功能在Flight School Guide to Swift Numbers的第 3 章中进行了讨论。
在你的 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 安装 Money
,只需将以下行添加到你的 Podfile
文件中:
pod 'Money-FlightSchool', '~> 1.3.0'
运行 pod install
命令来下载库并将其集成到你的 Xcode 项目中。
注意:此库的模块名称是 "Money" --- 也就是说,要使用它,你只需在 Swift 代码的顶部添加
import Money
,就像使用任何其他安装方法一样。该 pod 被命名为 "Money-FlightSchool",因为已经存在一个名为 "Money" 的 pod。
要使用 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"
进行编码和解码,这些键对应于它们各自的属性。
如果你正在处理以不同方式编码货币金额的数据,你可以设置 JSONDecoder
的 keyDecodingStrategy
属性以映射到不同的键名:
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)。
定义可用货币的 源文件 使用 GYB 从 CSV 文件 生成。此数据源与 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
。
如果你是那种喜欢在源代码中放置剪贴画的人,那么这里有一个 真正 让你的队友印象深刻的技巧:
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)