固定点小数被用来表示各种数据:百分比、温度、距离、质量等等。我想分享一种在Haskell中用safe-decimal ,安全有效地表示货币数据的方法。
我们要解决的问题
浮动点
我想知道有多少钱因为程序员选择了浮点类型来表示货币而被放错地方。我不会试图说服你使用Double 或Float 来表示货币值是不可接受的,这是一个众所周知的事实。像NaN,+/-Infinity 和+/-0 这样的值在处理货币时没有任何意义。此外,无法准确地表示大多数小数值应该是避免使用浮点的足够理由。
固定点小数
当数值逼近可以接受,并且你主要关心的是性能而不是正确性时,浮点类型就有意义。这在数值分析、信号处理和其他领域都是最常见的。在许多其他情况下,应该使用能够准确表示小数的类型来代替。与浮点不同的是,在Decimal 类型中,我们手动限制了小数点之后可以有多少个数字。这就是所谓的定点数字表示法。我们每天都在使用定点数字,如在商店用现金或卡付款,用里程表跟踪距离,从数字湿度计或温度计上读取数值。
我们可以在Haskell中使用一个积分类型来表示定点小数,这个类型被称为精度,还有一个刻度参数,用来记录小数点离右边有多远。在safe-decimal 中,我们定义了一个Decimal 类型,允许我们选择一个精度(p),并为我们的s 刻度参数提供类型级别的自然数:
newtype Decimal r (s :: Nat) p = Decimal p
deriving (Ord, Eq, NFData, Functor, Generic)
与浮点数不同,如果不改变比例参数,有时也不改变精度,我们就不能移动小数点。这意味着当我们使用乘法或除法这样的运算时,我们可能要做一些四舍五入。四舍五入的策略是在类型层面上通过r 类型变量选择的。在撰写本文时,最常见的四舍五入策略已经实现。RoundHalfEven,RoundHalfUp,RoundHalfDown,RoundDown 和RoundToZero 。有一个计划在未来增加更多的策略。
精度
出于直接的原因,使用Integer 这样的类型来表示小数是很常见的:
Integer易于使用Integer可以表示宇宙中的任何数字,如果你有足够的内存的话
让我们看一个例子,它从启用Haskell的扩展开始。我们需要打开DataKinds ,这样我们就可以使用类型级自然数:
>>> :set -XDataKinds
>>> x = Decimal 12345 :: Decimal RoundHalfUp 4 Integer
>>> x
1.2345
>>> x * 5
6.1725
>>> roundDecimal (x * 5) :: Decimal RoundHalfUp 3 Integer
6.173
由Integer 支持的具体的Decimal 类型有一个Num 的实例。这就是为什么我们能够使用字面意思的5 ,GHC为我们把它转换成了一个Decimal 。这就是同样的数字相乘后的Double 的样子:
>>> 1.2345 * 5 :: Double
6.172499999999999
存储和性能
Integer 是很好的,但是在一些应用中, 并不是我们数据的可接受的代表。我们可能需要在数据库中存储十进制值,通过网络传输,或者通过将数字存储在非盒式数组而不是盒式数组中来提高性能。将一个64位的整数值存储在数据库中,而不是像 ,将一个数字转换为blob中的字节序列,这样会更快。在网络上传输是另一个让人想到的限制。在UDP数据包上有508个字节的限制,对于基于 的数值来说,很快就会成为一个问题。Integer Integer Integer
解决这个问题的最好方法是使用固定宽度的整数类型,如Int64,Int32,Word64, 等等。如果需要超过64位的精度,有一些软件包可以提供128位、256位和其他变体的有符号/无符号整数。所有这些都可以与safe-decimal ,例如:
>>> import Data.Int (Int8, Int64)
>>> Decimal 12345 :: Decimal RoundHalfUp 6 Int64
0.012345
>>> Decimal 123 :: Decimal RoundHalfUp 6 Int8
0.000123
边界
即使不考虑对更好的性能的渴望,不考虑强加给我们的内存限制,也经常有一些类型有特定领域的边界。最常见的例子是,人们使用有符号的类型,如Int ,来表示没有合理的负值的数值。使用无符号类型,如Word 来表示应该没有负值的数值。
一些可以用十进制数字表示的值有一个我们估计的下限和上限。百分比从0%到100%,美元的总流通量约为14万亿,而恒星的表面温度则在225-40000K之间。如果我们使用我们领域的具体知识,我们可以想出一些安全的界限,而不是盲目地假设我们需要无限大的值。
不过要注意的是,使用带边界的积分类型会带来真正的危险:整数溢出和下溢。这些都是软件中出现错误的常见原因,导致了各种各样的漏洞。这也是safe-decimal 中的保护真正闪亮的地方,这里有一个例子说明它如何保护你:
>>> 123 + 4 :: Int8
127
>>> 123 + 5 :: Int8
-128
>>> x = Decimal 123 :: Decimal RoundHalfUp 6 Int8
>>> x
0.000123
>>> plusDecimalBounded x (Decimal 4) :: Maybe (Decimal RoundHalfUp 6 Int8)
Just 0.000127
>>> plusDecimalBounded x (Decimal 5) :: Maybe (Decimal RoundHalfUp 6 Int8)
Nothing
运行时异常
我们知道,除以0会导致DivideByZero 异常:
>>> 1 `div` 0 :: Int
*** Exception: divide by zero
鲜为人知的是,虽然有些积分运算会导致无声的溢出,但其他的会导致运行时异常:
>>> -1 * minBound :: Int
-9223372036854775808
>>> 1 `div` minBound :: Int
-1
>>> minBound `div` (-1) :: Int
*** Exception: arithmetic overflow
浮点数值也有一个除以0的悲惨故事。你会惊讶地发现,你经常可以在网上偶然发现这些数值:
>>> 0 / 0 :: Double
NaN
>>> 1 / 0 :: Double
Infinity
>>> -1 / 0 :: Double
-Infinity
长话短说,我们希望能够在纯代码中防止所有这些问题。 这正是safe-decimal ,为你做的:
>>> -1 * pure minBound :: Arith (Decimal RoundHalfUp 2 Int)
ArithError arithmetic overflow
>>> pure minBound / (-1) :: Arith (Decimal RoundHalfUp 2 Int)
ArithError arithmetic overflow
>>> 1 / 0 :: Arith (Decimal RoundHalfUp 2 Int)
ArithError divide by zero
Arith 是在 中定义的一个单体,用于处理可能因任何特定原因而失败的算术运算。它与 同构,这意味着从 单体到其他具有 实例的单体,可以直接转换为 和其他一些辅助函数。safe-decimal Either SomeException Arith MonadThrow arithM
>>> arithM (1 / 0 :: Arith (Decimal RoundHalfUp 2 Int))
*** Exception: divide by zero
>>> arithMaybe (1 / 0 :: Arith (Decimal RoundHalfUp 2 Int))
Nothing
用于加密的十进制
在文章的开头,我提到我们将实现一种货币。现在每个人似乎都在实现加密货币,那么我们为什么不做同样的事情呢?
在写这篇文章的时候,最流行的加密货币是比特币,所以我们将在这个例子中使用它。在我们开始之前,我们要做几个假设:
- 最大金额是21M BTC
- 不允许有负数
- 精度可达小数点后8位
- 最小的可表达值是0.00000001 BTC,也就是一个中本聪。它是以发表比特币开创性论文的假名中本聪命名的。
定义
在这里,我们将演示如何用safe-decimal 来表示比特币,如果你想跟随,这里是本博文中所有代码的要点。首先,我们声明将使用的原始金额Satoshi ,所以我们可以指定它的界限。接下来是Bitcoin ,围绕着Decimal ,指定所有我们需要知道的,以便对这种货币进行操作的包装。
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Bitcoin (Bitcoin) where
import Data.Word
import Numeric.Decimal
import Data.Coerce
newtype Satoshi = Satoshi Word64 deriving (Show, Eq, Ord, Enum, Num, Real, Integral)
instance Bounded Satoshi where
minBound = Satoshi 0
maxBound = Satoshi 21_000_000_00000000
data NoRounding
type BitcoinDecimal = Decimal NoRounding 8 Satoshi
newtype Bitcoin = Bitcoin BitcoinDecimal deriving (Eq, Ord, Bounded)
instance Show Bitcoin where
show (Bitcoin b) = show b
这些定义的重要部分是:
- 我们在
Word64周围使用了一个带有自定义边界的新类型包装器,这样库就可以保护我们不创建一个无效的值。在这种情况下,使用Int64不会有什么影响,但使用另一种可用位数较少的类型,就不足以容纳大的数值。 - 我们定义了无舍入策略,以确保在任何时候舍入都不会导致钱的出现或消失。
- 我们不输出
Bitcoin类型的构造函数,以确保不能手动构造无效的值。下面会有智能构造器,如果需要可以导出。
构造和算术
从Data.Coerce ,做零成本强制的辅助函数将被用来在不同类型之间进行,而不会让我们重复其签名:
toBitcoin :: BitcoinDecimal -> Bitcoin
toBitcoin = coerce
fromBitcoin :: Bitcoin -> BitcoinDecimal
fromBitcoin = coerce
mkBitcoin :: MonadThrow m => Rational -> m Bitcoin
mkBitcoin r = Bitcoin <$> fromRationalDecimalBoundedWithoutLoss r
plusBitcoins :: MonadThrow m => Bitcoin -> Bitcoin -> m Bitcoin
plusBitcoins b1 b2 = toBitcoin <$> (fromBitcoin b1 `plusDecimalBounded` fromBitcoin b2)
minusBitcoins :: MonadThrow m => Bitcoin -> Bitcoin -> m Bitcoin
minusBitcoins b1 b2 = toBitcoin <$> (fromBitcoin b1 `minusDecimalBounded` fromBitcoin b2)
mkBitcoin 给了我们一个构建新值的方法,同时给了我们一个自由选择单体的机会,我们想在这个单体中通过限制 ,为了简单起见,我们坚持使用 ,但它也可以是 、 、 和许多其他的。MonadThrow IO Maybe Either Arith
>>> mkBitcoin 1.23
1.23000000
>>> mkBitcoin (-1.23)
*** Exception: arithmetic underflow
下面的例子可以明显看出,我们要防止从Rational 中构建无效的值。
>>> :set -XNumericUnderscores
>>> mkBitcoin 21_000_000.00000000
21000000.00000000
>>> mkBitcoin 21_000_000.00000001
*** Exception: arithmetic overflow
>>> mkBitcoin 0.123456789
*** Exception: PrecisionLoss (123456789 % 1000000000) to 8 decimal spaces
同样的逻辑也适用于对Bitcoin 的操作。没有任何东西可以通过,任何可能产生无效值的操作都会导致失败。
>>> balance <- mkBitcoin 10.05
>>> receiveAmount <- mkBitcoin 2.345
>>> plusBitcoins balance receiveAmount
12.39500000
>>> maliciousReceiveBitcoin <- mkBitcoin 20999990.0
>>> plusBitcoins balance maliciousReceiveBitcoin
*** Exception: arithmetic overflow
>>> arithEither $ plusBitcoins balance maliciousReceiveBitcoin
Left arithmetic overflow
减值的处理方式也是如此。请注意,低于下限的操作将被报告为下溢,与普遍的看法相反,下溢不仅是浮点数的一个真实术语,而且也是整数的一个真实术语。
>>> balance <- mkBitcoin 10.05
>>> sendAmount <- mkBitcoin 1.01
>>> balance `minusBitcoins` sendAmount
9.04000000
>>> sendAmountTooMuch <- mkBitcoin 11.01
>>> balance `minusBitcoins` sendAmountTooMuch
*** Exception: arithmetic underflow
>>> sendAmountMalicious <- mkBitcoin 184467440737.09551616
*** Exception: arithmetic overflow
我想在上面的例子中强调这样一个事实:我们不必检查balance ,是否足以使金额完全被扣除。这意味着我们自动受到保护,避免了不正确的交易以及非常常见的攻击载体,其中一些确实发生在比特币和其他加密货币上。
Num和Fractional
使用一个特殊的智能构造函数很酷,但如果我们可以使用我们的常规数学运算符来处理Bitcoin ,并利用GHC desugarer来自动转换数字字面值,那会更酷。为此,我们需要Num和Fractional 的实例。我们不能创建这样的实例。
instance Num Bitcoin where
...
instance Fractional Bitcoin where
...
因为那样的话,我们将不得不使用部分函数来处理故障,这正是我们想要避免的。此外,有些函数对货币价值根本没有意义。将比特币相乘或相除,根本无法定义。我们将不得不通过一个异常来表示一种特殊类型的失败。这有点不幸,但无论如何我们都要接受它。
data UnsupportedOperation =
UnsupportedMultiplication | UnsupportedDivision
deriving Show
instance Exception UnsupportedOperation
instance Num (Arith Bitcoin) where
(+) = bindM2 plusBitcoins
(-) = bindM2 minusBitcoins
(*) = bindM2 (\_ _ -> throwM UnsupportedMultiplication)
abs = id
signum mb = fmap toBitcoin . signumDecimalBounded . fromBitcoin =<< mb
fromInteger i = toBitcoin <$> fromIntegerDecimalBoundedIntegral i
instance Fractional (Arith Bitcoin) where
(/) = bindM2 (\_ _ -> throwM UnsupportedDivision)
fromRational = mkBitcoin
需要注意的是,定义上面的实例严格来说是可选的,导出执行相同操作的辅助函数是最好的。我们现在有了这些实例,所以我们可以演示它们的使用。
>>> 7.8 + 10 - 0.4 :: Arith Bitcoin
Arith 17.40000000
>>> 7.8 - 10 + 0.4 :: Arith Bitcoin
ArithError arithmetic underflow
>>> 7.8 * 10 / 0.4 :: Arith Bitcoin
ArithError UnsupportedMultiplication
>>> 7.8 / 10 * 0.4 :: Arith Bitcoin
ArithError UnsupportedDivision
>>> 7.8 - 7.7 + 0.4 :: Arith Bitcoin
Arith 0.50000000
>>> 0.4 - 7.7 + 7.8 :: Arith Bitcoin
ArithError arithmetic underflow
操作的顺序可以欺骗你,这可能是坚持导出函数的另一个原因。mkBitcoin,plusBitcoins,minusBitcoins 以及我们可能需要的任何其他操作。
让我们看看一个更现实的例子,发送的金额是以Scientific ,可能来自某个JSON对象,我们想更新我们账户的余额。为了简单起见,我将使用State monad,但同样的方法在你有的任何有状态的设置中也可以工作。
newtype Balance = Balance Bitcoin deriving Show
sendBitcoin :: MonadThrow m => Balance -> Scientific -> m (Bitcoin, Balance)
sendBitcoin startingBalance rawAmount =
flip runStateT startingBalance $ do
amount <- toBitcoin <$> fromScientificDecimalBounded rawAmount
Balance balance <- get
newBalance <- minusBitcoins balance amount
put $ Balance newBalance
pure amount
这个简单函数的使用将向我们展示库中所采取的方法的力量以及它的局限性。
>>> balance <- mkBitcoin 10.05
>>> sendBitcoin (Balance balance) 0.5
(0.50000000,Balance 9.55000000)
>>> sendBitcoin (Balance balance) 1e-6
(0.00000100,Balance 10.04999900)
>>> sendBitcoin (Balance balance) 1e+6
*** Exception: arithmetic underflow
>>> arithEither $ sendBitcoin (Balance balance) (-1)
Left arithmetic underflow
我们可以看到Overflow/Underflow 错误,但我们几乎没有得到关于问题发生的确切位置和哪个值的信息。这是可以通过自定义异常来解决的,但是现在我们确实实现了最重要的目标,即保护我们的计算不受所有危险问题的影响,而不需要做任何明确的检查。
在sendBitcoin ,我们没有任何地方需要验证我们的输入、输出或中间值。没有一个if then else 语句。这是因为确定上述操作的有效性所需的所有信息都被编码到了类型中,并且库为程序员强制执行了这种有效性。
混合十进制类型
尽管将两个Bitcoin 值相乘没有意义,但计算一个金额和一个百分比的乘积却非常有意义。那么,我们如何去把不同的小数相乘呢?
在展示不同小数类型的互操作性的同时,我们还想展示一下如何用Decimal 来使用更高精度的积分。在这个例子中,我们将使用一个Word128 支持的Decimal 来计算未来值。有几个软件包提供了128位的积分类型,它来自哪个软件包并不重要。
我们的目标是计算30天内1.9% APY(年收益率)的储蓄账户余额,如果你从10,000 BTC开始,每天增加10 BTC。
我们将首先定义Word128 类型的舍入策略实现,并指定我们将用于计算的Decimal 类型。
instance Round RoundHalfUp Word128 where
roundDecimal = roundHalfUp
type CDecimal = Decimal RoundHalfUp 33 Word128
这并不是金融界所称的FV (未来值)函数的实现。它是对我们认为应计利息的工作方式的直接翻译。用简单的英语来说,我们可以说,为了计算明天的账户余额,我们把今天的余额,乘以日利率,再加上今天的余额和我们承诺每天充值的金额。
futureValue :: MonadThrow m => CDecimal -> CDecimal -> CDecimal -> Int -> m CDecimal
futureValue startBalance dailyRefill apy days = do
dailyScale <- -- apy is in % and the year of 2020 is a leap year
fromIntegralDecimalBounded (100 * 366)
dailyRate <- divideDecimalBoundedWithRounding apy dailyScale
let go curBalance day
| day < days = do
accruedDaily <- timesDecimalBoundedWithRounding curBalance dailyRate
nextDayBalance <- sumDecimalBounded [curBalance, accruedDaily, dailyRefill]
go nextDayBalance (day + 1)
| otherwise = pure curBalance
go startBalance 0
上面的实现是在CDecimal 类型上进行的。我们需要计算的是Bitcoin 。这意味着我们必须做一些类型转换和缩放,以便与futureValue 函数的类型相匹配。然后我们再做一些四舍五入和转换以降低精度,得到新的Balance 。
futureValueBitcoin :: MonadThrow m => Balance -> Bitcoin -> Rational -> Int -> m (Balance, CDecimal)
futureValueBitcoin (Balance (Bitcoin balance)) (Bitcoin dailyRefill) apy days = do
balance' <- scaleUpBounded (fromIntegral <$> castRounding balance)
dailyRefill' <- scaleUpBounded (fromIntegral <$> castRounding dailyRefill)
apy' <- fromRationalDecimalBoundedWithoutLoss apy
endBalance <- futureValue balance' dailyRefill' apy' days
endBalanceRounded <- integralDecimalToDecimalBounded (roundDecimal endBalance)
pure (Balance $ Bitcoin $ castRounding endBalanceRounded, endBalance)
现在我们可以计算出30天后我们的余额是多少。
computeBalance :: Arith (Balance, CDecimal)
computeBalance = do
balance <- Balance <$> 10000
topup <- 10
futureValueBitcoin balance topup 1.9 30
让我们看看我们得到了什么值,以及它们与在Double 上工作的实际FV 函数相比如何(为了好奇,这里有一个可能的实现numpy.fv)
>>> fst <$> arithM computeBalance
Balance 10315.81142818
>>> fv (1.9 / 36600) 30 (-10) (-10000)
10315.811428177167
这是很好的。我们得到了我们的新余额的准确四舍五入的结果。但是,在四舍五入之前,计算出的结果有多准确呢?在四舍五入的情况下,128位的准确度就可以做到了。
>>> snd <$> arithM computeBalance
10315.811428176906130029412612348658890
我们在这里得到的准确度比我们用Double 。这并不奇怪,因为我们有更多的比特可供支配,但准确性并不是这种计算的唯一好处。计算结果也是确定的!这一点在用计算机计算时是不可能保证的。在不同的平台和架构上,用浮点数计算几乎不可能保证这一点。
可用的解决方案
当一个新的库被宣布时,人们通常会问一个非常常见的问题:"目前可用的解决方案有什么问题?"。这是一个完全合理的问题,希望我们有一个令人信服的答案。
我们对安全性、正确性和性能有着强烈的要求。这是Haskell生态系统中的可用库都无法提供的组合。
我将使用来自base 的Data.Fixed 作为例子,并列出一些阻碍我们使用它的限制。
-
由
Integer支持,这使得它在普通情况下的速度比它应该的要慢。 -
截断,而不是一些更有用的舍入策略:
>>> 5.39 :: Fixed E1
5.3
>>> 5.499999999999 :: Fixed E1
5.4
- 没有针对运行时异常的内置保护:
>>> f = 5.49 :: Fixed E1
>>> f / 0
*** Exception: divide by zero
-
有数量有限的缩放类型。
E0,E1,E2,E3,E6,E9和E12。可以用HasResolution添加新的类型,但有点不方便。 -
没有内置的能力来指定边界。这意味着没有保护措施来防止诸如负值或超出人为施加的限制的情况。
类似的论点也可以适用于其他库。特别是关于性能的反对意见。这个反对意见不是没有根据的:我们的基准测试揭示了与现有的实现有实际关系的性能问题。
总结
我鼓励每个为金融、区块链和其他需要精确计算和安全的领域编写软件的人,认真考虑选择错误的数据类型来表示其数值的所有影响。
Haskell是一种非常安全的语言,但正如你在这篇文章中所看到的,当涉及到对数字值的操作时,它并没有提供理想的安全水平。希望我们能够说服你,至少对于十进制数字,这种安全性可以通过safe-decimal 库来实现。
如果你觉得这篇文章描述的问题对你来说很熟悉,而且你正在寻找解决方案,请联系我们,我们将很乐意提供帮助。