Q Number 格式是一种用于描述二进制定点数的记号。
定点数是 Solidity 中用于存储小数值的流行设计模式,因为该语言不支持浮点数。因此,为了“捕获”数字的小数部分,我们将该小数乘以一个整数,从而使得小数部分变成整数(可能会有一些精度损失)。
Ether 小数与 Q Number
Solidity 编程中最著名的定点数是“一 Ether”。一 Ether 实际上是 个基础单位(Wei)。“一” Ether 实际上是 1 乘以 。因为我们无法在 Solidity 中存储“0.5”,所以我们改为存储 或 500000000000000000。从本质上讲,用 乘以 0.5 可以使该小数作为整数被“保留”下来。然而,这并不是一个完美的解决方案,因为它无法容纳小于 的值。不过, 对于大多数应用程序来说已经足够好了,如果需要更高的精度,合约可以使用更大的数字。
另一方面,Q Number 将小数乘以 2 的幂而不是 10 的幂,因为在 EVM 中乘以(和除以)2 的幂在 Gas 上更高效,因为乘以或除以 2 的幂可以使用位移操作来完成。例如,x << n 等同于 x * 2**n,而 x >> n 等同于 x / 2**n。
Q Number 记法
Q Number 通常写成 Qm.n 的形式,其中 n 是用于乘以该数字的 2 的幂次,而 m 是整数部分的无符号整数大小。这完全等同于说 m 是用于存储整数部分的位数(bits),而 n 是用于存储小数部分的位数。
为了证明这种等价性,我们来看看一个 Q1.1 数字。这个数字为整数部分分配 1 位(m = 1),为小数部分分配 1 位(n = 1)。数字 1 将被表示为 ,这等同于 1 << 1。 或 1 << 1 的二进制表示法是 10。请注意,我们的数字是 2 位大小()。我们可以将 10₂ 解构为
因此,将“1”作为 Q1.1 存储的变量实际上将存储值 2,或二进制的 10₂。但是,如果该变量用于存储 Q1.1,我们会将值 2 “解释”或“视为” 1。
让我们来看一个 Q8.4 数字的例子。数字 1 被表示为 或 1 << 4。1 << 4 的二进制表示法是 10000₂。由于我们有 8 位用来表示整数部分,我们需要在左侧填充零,直到整个数字变为 12 位大小:
共有 12 位存储该数字,因为 8 + 4 = 12。“在底层(under the hood)”,我们实际上存储的值是 ,但我们将该变量的值解释为 1。这类似于我们可能将一个变量解释为包含“1 Ether”,但“在底层”该变量实际上存储的是 。当我们说“在底层”时,我们指的是内存或存储中的实际数字。
作为第三个例子,考虑一个 Q4.8 数字。数字 1 被表示为 或 1 << 8。1 << 8 的二进制表示法是 10000000₂。由于我们有 4 位用来表示整数部分,我们用三个零进行左侧填充,直到整个数字变为 12 位大小:
Q4.8 和 Q8.4 数字都需要 12 位来存储。然而,Q8.4 数字在整数部分分配了更多的位数,因此它可以存储更大的整数。另一方面,Q4.8 数字在小数部分分配了更多的位数,因此它在表示小数时可以更加精确。
作为定点数的值“1”
通常对于 Q Number 来说,“一”就是 1 乘以 ,其中 是用于存储小数的位数。因此,Q64.96 中的 1 是 或 1 << 96。Q128.128 中的“一”是 或 1 << 128。Q64.128 中的“一”同样是 1 << 128,因为我们只关心小数位数的数量。
对于 任何 Qm.n,整数 1 的表示方式始终是 1 << n,而正好 Q64.128 和 Q128.128 两者 的 n = 128。
请注意,n 表示 二进制 位数(以 2 为底),而不是如 10 的幂(以 10 为底)那样的“小数位数”。Qx.18 与我们使用 存储 1 ether 的方式并不相同。Qx.18 意味着“一”是 而不是 。
在 Solidity 中使用 Q Number
Qm.n 数字中的位数必须具有 m + n 位大小。因此:
- Q64.64 数字必须至少使用 uint128(128 = 64 + 64)
- Q64.96 数字必须至少使用 uint160(160 = 64 + 96)
- Q128.128 数字必须使用 uint256
解释 Q Number 的小数部分
让我们考虑 Q1.1 数字的所有可能值:
| “底层”值 | 浮点数值(”底层”值 ÷ 2^1) | |
|---|---|---|
| 0 0 | 0 | 0 |
| 0 1 | 1 | 0.5 |
| 1 0 | 2 | 1.0 |
| 1 1 | 3 | 1.5 |
我们可以看到,将“小数位”设置为 1 传达的意思是,小数部分中的这一位代表值 0.5。通常,小数位代表 2 的分数次幂。让我们以 Q1.2 数字为例来看看:
| “底层”值 | 浮点数值(”底层”值 ÷ 2^2 | |
|---|---|---|
| 0 00 | 0 | 0 |
| 0 01 | 1 | 0.25 (1/4) |
| 0 10 | 2 | 0.5 (2/4) |
| 0 11 | 3 | 0.75 (3/4) |
| 1 00 | 4 | 1.00 (4/4) |
| 1 01 | 5 | 1.25 (5/4) |
| 1 10 | 6 | 1.50 (6/4) |
| 1 11 | 7 | 1.75 (7/4) |
通常,Q Number 中的位被解释如下。请注意,小数点右侧的位表示 2 的分数次幂:
小数部分中的每一位都累加地表示 2 的分数次幂。例如,0.1₂ 表示 0.5,0.11₂ 表示 0.75,而 0.001₂ 表示 0.125 或八分之一。
Q Number 只能编码可以表示为 2 的幂的倒数之和的小数。如果我们尝试表示像 1/3 这样的数字,就必然会出现舍入误差。
尝试将各种小数值输入到下方的交互式工具中,以查看它们是如何转换为定点数表示形式的:
将整数转换为 Q Number
若要将 整数 1 转换为 Q64.96 定点数,我们计算 1 << 96。这将创建一个二进制数,其在第 96 位上为 1,并且在二进制值第 0 到 95 位(含)上全为零。
通常,我们可以通过将整数左移 n 位,从而将其转换为 Qm.n 数字。
下方的动画说明了这一点:
将 Q Number 转换为整数
Q Number 具有小数部分,而整数则没有。因此,为了将 Q Number 转换为整数,我们只需将整数部分移位,从而移除小数部分。
因此,如果我们想要提取定点数的整数部分,我们将数字右移 n 位(记住:n 是 Qm.n 中小数部分的位数)。这会使小数位消失,只给我们留下整数部分。换言之,我们通过砍掉所有的小数位,从而将定点数 Q Number 转换为整数。
考虑以下将 Q4.4 数字转换为整数的动画:
构造一个定点数值
假设我们想将数字“1.5”编码为 Q64.96。Solidity 不接受 1.5 * 2**96 或 1.5 << 96 作为有效语法。
相反,1.5 可以被计算为
1 * 2**96 + 2**96 / 2; // equivalent to 1 + 0.5
作为 Q64.96 数字,1.5 “在底层”存储的值将是 118842243771396506390315925504。我们可以在 Python 中按如下方式计算:
>>> int(1.5 * 2**96)
118842243771396506390315925504
在二进制中,我们可以看到有 96 位被用于小数:
>>> bin(118842243771396506390315925504)
'0b1 100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' # space added for clarity
将 Q Number 转换为浮点数(链下)
使用前面 1.5 的例子,我们可以将 118842243771396506390315925504 除以 并得到
当我们在链下计算 Q Number 的浮点数值时,我们除以 ,而不是位移 96。这是因为位移会“破坏”最右侧的 96 位,从而导致包含小数的信息丢失。
Q Number 所能容纳的最大值
Q Number 能容纳的最大 整数 值为 ,其中 是整数部分的位数。它所能容纳的 最大 总值 是最大的整数部分加上最大可表示的小数部分:
这在数学上等同于 。因此,Qm.n 数字所能存储的最大 总 值为 。
例如,Q96.64 的最大 整数 值为 ,即 79228162514264337593543950335。Q96.64 值所能存储的最大值(包含小数部分)为
即
79228162514264337593543950335.9999999999999999999457898913757247782996273599565029144287109375
两个 Q Number 相加
举个例子,如果我们想要将一个 Q128.128 数字与一个 Q64.64 数字相加,我们有两个选项。第一个选项是将 Q64.64 数字向左位移 64 位,以便小数点对齐。这实质上是将 Q64.64 数字转换为了 Q64.128 数字。
或者,我们有第二个选项。如果我们不需要 128 位的精度,或者我们特意想要 Q64.64 数字作为求和结果,我们可以将 Q128.128 数字向右位移 64 位,将其转换为 Q128.64 数字。当然,这会导致一些精度损失。
要将两个 Q Number 相加,我们只需要它们在小数部分具有相同的位数即可,也就是小数点必须对齐。然而,“目标”数据类型必须具有足够大的整数部分来处理求和结果,否则我们可能会遇到溢出。
如果我们想要将两个 Q Number 相减,我们遵循本节概述的相同逻辑。
乘法或除法
如果我们计算 ,我们期望得到 作为答案。然而在底层,数字 被表示为 。因此,如果我们将表示为 Q64.96 的数字 1 乘以它本身,我们实际上执行的操作是 ,但我们真正想要的答案是 。
因此,当我们将两个 Q Number 相乘时,我们必须在之后将乘积右移 n 位。在我们的 96 位示例中,这意味着 将被右移 96 位从而变成 。
Solidity 定点数代码示例
示例 1:5 除以 2
假设我们想要用 5 除以 2,并将结果作为 Q64.64 数字返回。由于 Q64.64 数字无法容纳大于 64 位的整数,我们不妨使用 uint64 来表示整数 5 和 2。由于实际容纳 Q64.64 需要 128 位,因此我们将使用 uint128 来容纳 Q64.64。
换句话说,整数使用 uint64 表示,因为这是 Q64.64 所能容纳的最大整数;但 Q64.64 数字使用 uint128 表示,因为它需要容纳 64 位的整数和 64 位的小数。
function divToQ64x64(uint64 x, uint64 y) public pure returns (uint128) {
// convert x (a uint64 integer)
// to a Q64.64 fixed-point number by left-shifting 64 bits.
uint128 x64_64 = x << 64;
// divide by y
return x64_64 / y;
}
divToQ64x64(5, 2); // returns 46116860184273879040
结果 46116860184273879040 编码的即为 2.5,因为 46116860184273879040 / 2**64 = 2.5。
示例 2:5 乘以 0.5
让我们在 5 和 0.5 都表示为 Q64x64 时将它们相乘。表示如下:
- 作为 Q64.64 数字的 5 是
5 << 64或5 * 2**64,其等于 92233720368547758080。 - 作为 Q64.64 数字的 0.5 是
1 << 64 / 2或2**64 / 2,其等于 9223372036854775808。
当我们将两个定点数相乘时,我们需要确保它们在除以 再次标准化之前不会溢出。这意味着我们将把乘积存储在一个 uint256 中,然后再向右移 64 位。
function mulU64x64(uint128 x, uint128 y) public pure returns (uint128) {
// note: Solidity performs multiplication using uint128 unless
// explicitly upcasted. This could overflow and revert.
uint256 temp = uint256(x) * uint256(y);
return uint128(temp >> 64);
}
mulU64x64(5 * 2**64, 2**64 / 2) // returns 46116860184273879040
这将返回预期的结果,因为 46116860184273879040 / 2**64 = 2.5。
示例 3:5 乘以 0.5,但 5 是整数而不是定点数
作为上述示例的一个变体,我们想要将 5(一个整数)乘以 0.5(一个定点数),并返回一个定点数。与上述代码唯一的区别就是将 5 转换为其定点数表示形式:
function mulUint64ByQ64x64(uint64 x, uint128 y) public pure returns (uint128) {
// convert uint64 to fixed point
uint128 x_fp = uint128(x) << 64;
uint256 temp = uint256(x_fp) * uint256(y);
return uint128(temp >> 64);
}
mulUint64ByQ64x64(5, 2**64 / 2) // returns 46116860184273879040
结果仍然是 2.5 的等价定点数。
先向左移 64 位,然后再向右移会有些浪费。上述相同的计算可以更高效地完成如下:
function mulUint64ByQ64x64(uint64 x, uint128 y) public pure returns (uint128) {
return uint128(uint256(x) * uint256(y));
}
mulUint64ByQ64x64(5, 2**64 / 2) // returns 46116860184273879040
Q Number 除法
如果我们计算 1 ÷ 1,我们期望结果为 1。假设我们使用的是 Q64.64。“1”就是 。如果我们计算 ,我们得到的结果是 1,而不是 。为了纠正这个问题,我们可以将结果向左移 n 位,但这违反了“先乘后除以避免精度损失”的原则。因此,将两个 Q Number 相除的正确方法是先左移分子,然后再做除法:
function divQ64x64ByQ64x64(uint128 x, uint128 y) public pure returns (uint128) {
return uint128(uint256(x) << 64 / uint256(y));
}
divQ64x64ByQ64x64(5, 2**64 / 2) // returns 46116860184273879040
总结
- Q Number 是一种用于在 Ethereum 中保存小数的设计模式。
- 它们比 Ethereum 的小数表示法更高效,因为乘法和除法都可以通过位移来完成。
- Q Number 表示为 Qm.n,其中
m是整数部分的位数,而n是小数部分的位数。 - 小数点后的每一位代表的值分别为 1/2、1/4、1/8……等等。
- 通过进行
n位的左移操作,可以将整数转换为 Q Number。通过截断小数位,或进行n位的右移操作,可以将 Q Number 转换为整数。 - 如果我们在一种支持浮点数的语言中将 Q Number 除以 ,我们可以看到其预期的小数表示形式。
- 给定两个整数
a和b,请确保a适合放入m位之内。通过a << n / b计算它们作为 Qm.n 数字的比率。用于容纳所得定点数的位数必须是a的位数(m)加上n的总和,也就是 Qm.n。 - 只要小数点是对齐的,Q Number 就可以“原样”相加。
- 如果我们将两个 Q Number 相乘,我们需要将结果向右移
n位,以便结果具有n个小数位。 - 如果我们将两个 Q Number 相除,我们需要首先将分子向左移
n位。 - 无论除法还是乘法,都需要小心避免临时溢出。