问题
NumberFormatter makes it quite easy to format currencies when presenting values on screen:
let decimal = Decimal(25.99)
let decimalNumberFormatter = NumberFormatter()
decimalNumberFormatter.numberStyle = .currencyAccounting
let output = decimalNumberFormatter.string(for: decimal)
// output = "$25.99"
The above code works well both for any Decimal or Double values. The amount of decimal digits always matches that of the locale being used.
Turns our that serializing a floating point currency value to JSON is not that trivial.
Having the following serializing method (mind the force unwraps):
func serialize(prices: Any...) {
let data = try! JSONSerialization.data(withJSONObject: ["value": prices], options: [])
let string = String(data: data, encoding: .utf8)!
print(string)
}
We can then call it with different values and types. Double, Decimal and NSDecimalNumber (which should be bridged from Swift's Decimal) fail to properly render the value in some cases.
serialize(prices: 125.99, 16.42, 88.56, 88.57, 0.1 + 0.2)
// {"value":[125.99,16.42,88.56,88.56999999999999,0.3]}
serialize(prices: Decimal(125.99), Decimal(16.42), Decimal(88.56), Decimal(88.57), Decimal(0.1) + Decimal(0.2))
// {"value":[125.98999999999997952,16.420000000000004096,88.56,88.57,0.3]}
serialize(prices: NSDecimalNumber(value: 125.99), NSDecimalNumber(value: 16.42), NSDecimalNumber(value: 88.56), NSDecimalNumber(value: 88.57), NSDecimalNumber(value: 0.1).adding(NSDecimalNumber(value: 0.2)))
// {"value":[125.98999999999997952,16.420000000000004096,88.56,88.57,0.3]}
I'm not looking to serialize numbers as currencies (no need for currency symbol, integers (5) or single decimal position (0.3) are fine). However I'm looking for a solution where the serialized output contains no more than the number of decimals allowed by a given currency (locale).
This is, is there any way to limit or specify the number of decimals to be used when serializing floating point values to JSON?
Update #1:
Tested with more data types, surprisingly seems like both Float and Float32 work well for two-decimal currencies. Float64 fails as Double (probably they are an alias of the same type).
serialize(prices: Float(125.99), Float(16.42), Float(88.56), Float(88.57), Float(0.1) + Float(0.2))
// {"value":[125.99,16.42,88.56,88.57,0.3]}
serialize(prices: Float32(125.99), Float32(16.42), Float32(88.56), Float32(88.57), Float32(0.1) + Float32(0.2))
// {"value":[125.99,16.42,88.56,88.57,0.3]}
serialize(prices: Float64(125.99), Float64(16.42), Float64(88.56), Float64(88.57), Float64(0.1) + Float64(0.2))
// {"value":[125.99,16.42,88.56,88.56999999999999,0.3]}
Hard to know if they work well in all cases, though. Float80 fails to serialize with a _NSJSONWriter exception.
回答1:
After doing some research in this matter, a coworker found that rounding the values specifying a behavior using NSDecimalNumberHandler solves the JSON serialization issue.
fileprivate let currencyBehavior = NSDecimalNumberHandler(roundingMode: .bankers, scale: 2, raiseOnExactness: false, raiseOnOverflow: false, raiseOnUnderflow: false, raiseOnDivideByZero: true)
extension Decimal {
var roundedCurrency: Decimal {
return (self as NSDecimalNumber).rounding(accordingToBehavior: currencyBehavior) as Decimal
}
}
Following the example code from the post, we get the desired output:
serialize(prices: Decimal(125.99).roundedCurrency, Decimal(16.42).roundedCurrency, Decimal(88.56).roundedCurrency, Decimal(88.57).roundedCurrency, (Decimal(0.1) + Decimal(0.2)).roundedCurrency)
// {"value":[125.99,16.42,88.56,88.57,0.3]}
It works! Ran a test for 10000 values (from 0.0 to 99.99) and found no issues.
If needed, the scale can be adjusted to the number of decimals from the current locale:
var currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currencyAccounting
let scale = currencyFormatter.maximumFractionDigits
// scale == 2
回答2:
The problem is that you are using Any as a variadic input parameter to the function instead of using a generic function/overloading the function. This way the exact type information is masked by upcasting to Any.
You have several methods to solve this issue:
- Keep the current implementation with
Anyas the type of the input parameters, but conditional downcast your value insideserializebefore printing them and use aNumberFormatter()for the printing. - Change the implementation of
serializeto be a generic function. - Implement 3 overloaded versions of
serialize, each accepting a different number type and working with the exact types.
If you need your JSON to contain your prices in a certain formatting, you should serialise the output of the NumberFormatter instead of the numbers themselves.
回答3:
A strategy for encoding currency amounts is to convert the amount to an integer by multiplying the value by a multiplication factor.
The multiplication factor in this case is given by the 10 raised to the power of the currency's maximumFractionDigits (e.g. 10^2 for currencies that use two digits in the fractional part).
Keep in mind that this approach is only suitable for storing amounts that are ready to be shown to the user. See here for details.
func serialize(prices: Double..., locale: Locale) {
let formatter = NumberFormatter()
formatter.locale = locale
formatter.numberStyle = .currencyAccounting
// We multiply the amount by as many orders of
// magnitude are needed to ensure it is an integer.
// In your implementation, you should store the value of
// maximumFractionDigits along with the amount to ensure
// you can always recover the correct value.
let items = prices.map {
$0 * pow(10, Double(formatter.maximumFractionDigits))
}
let data = try! JSONSerialization.data(withJSONObject: ["value": items], options: [])
let string = String(data: data, encoding: .utf8)!
print(string)
}
print(serialize(prices: 125.99, 16.42, 88.56, 88.57, 0.1 + 0.2, locale: Locale(identifier: "en_AU")))
Prints:
{"value":[12599,1642,8856,8857,30]}
来源:https://stackoverflow.com/questions/45420375/specify-number-of-decimals-when-serializing-currencies-with-jsonserialization