定点数是一种仅存储分数分子而分母是隐含的整数。
这种类型的运算在大多数编程语言中并不需要,因为它们有浮点数。但在 Solidity 中却是必需的,因为 Solidity 只有整数,而我们经常需要对分数进行运算。
定点数在大多数 DeFi 智能合约中都很常见,因此理解它们是必须的。
例如,如果“隐含分母”是 100,那么包含“10”的定点数将被解释为 0.1。
Solidity 中最常见的定点数隐含分母是 :这也是以太坊和大多数 ERC-20 代币的“小数位数”(decimals)。当我们读取一个以太坊地址的余额时,我们隐式地将该数字除以 以确定其 Ether 数量。例如,一个余额为 的地址被解释为拥有 10 Ether —— 因为除以 是隐含的。
分母为 的定点数非常常见,以至于 Solidity 社区的工程师们将其称为“Wad”(该名称最初由 MakerDAO 引入)。有时,一个 18 位的定点数会被解释为将最右侧的 18 位数字分配给小数部分,例如,数字“10”表示如下:
然而,我们发现这种心智模型会让定点数运算更难学习,因此本文将使用这样一种心智模型:定点数保存的是分子,而 分母是隐含的。
在本文中,我们将学习如何使用定点数进行算术运算,并解释流行的定点数库是如何工作的。
将整数转换为定点数
要将整数转换为定点数,请将该整数乘以隐含分母。例如,“2 ether”是 ,因此要将整数 2 转换为“2 ether”,我们将其乘以 。隐含分母的 会与乘上的 相互抵消。
定点数相乘
要将两个定点数相乘,我们遵循分数相乘的规则:
- 分子相乘
- 分母相乘
- 简化结果。
例如:
但是,我们在实践中可以优化此计算,因为对于定点数,分母始终是相同的。
现在让我们考虑另一组具有公分母的分数:
然而,我们不想返回一个隐含分母为 的结果,因为这将与我们选择的隐含分母不兼容。因此,我们需要将分子和分母除以 ,以返回一个与我们选择的分母一致的定点数。
因此,如果 和 是隐含分母为 的定点数,我们可以将其乘积计算为 。
定点数相乘的代码示例
Solady 库有一个 mulWad 数学运算函数,用于将两个隐含 Wad 分母()的定点数相乘。下面展示了该代码,并解释它与我们前面的讨论有何联系:

核心算法位于截图底部(在绿色框内)。我们在那里计算 ,其中 是 WAD 或 (如截图顶部声明 WAD 的地方所示)。
实际示例
假设一个用户有 1 DAI(具有 18 位小数),我们希望在假设其存款赚取了 15% 利息的情况下来计算其余额。这是一个需要定点数算术的明显例子,因为我们无法在 Solidity 中直接将一个数字乘以 1.15。

除以 1e18 后的输出结果是 1.15。当然,我们实际上不能除以 1e18,因为那会抹去小数部分。我们需要使用定点数表示法,因为 1.15 不能表示为整数。上面的代码可以在这里的 Remix 上进行测试。
定点数与整数相乘
将分数 乘以整数 等同于将 乘以 :
因此,当我们将定点数与整数相乘时,不需要任何额外的步骤。我们只需将返回值解释为分母不变的定点数即可。
定点数相除
要对分数进行除法运算,我们将第二个分数“翻转”(取倒数)并将它们相乘。例如:
现在让我们考虑一个它们具有相同分母的例子:
请注意,公分母 10 被抵消了。如果我们想用 10 的隐含分母来表示 2(即将其表示为分母为 10 的定点数),我们需要再将其乘以 10:
因此,对于具有公分母 的一般情况下的 和 ,如果我们希望用隐含分母 来表示输出,我们必须执行以下操作:
因此,如果 和 是隐含分母为 的定点数,我们可以将其商计算为 。
/// @dev Equivalent to `(x * WAD) / y` rounded down.
function divWad(uint256 x, uint256 y) internal pure returns (uint256 z) {
/// @solidity memory-safe-assembly
assembly {
// Equivalent to `require((y != 0 && (WAD == 0 || x <= type(uint256).max / WAD))`.
if iszero(mul(y, iszero(mul(WAD, gt(x, div(not(0), WAD)))))) {
mstore(0x00, 0x7c5f487d) // `DivWadFailed()`.
revert(0x1c, 0x04)
}
z := div(mul(x, WAD), y)
}
}
如果我们将 mulWad() 和 divWad() 并排放在一起,我们可以看到它们之间唯一的区别(在计算步骤,而不是溢出检查中)在于,除法(div)情况下乘以的是一个倒置的分数。

定点数除以整数
假设我们要将 2.5 除以 2(或者一般来说将某个分数除以一个整数)。没有必要通过 将 2 转换为定点数。
将分数 除以整数 等同于将 的分子除以 。
请注意,,而不是 11.666,因为我们使用的是整数除法,而不是浮点数。我们只需将该定点数除以整数,并将结果解释为定点数。与定点数乘以整数的情况一样,分母保持不变。
定点数加减法
分母相同的分数相加减时,只需简单地进行相加减,而忽略分母。我们将总和解释为与加数具有相同隐含分母的定点数。例如,
因此,当对相同分母的定点数进行加法运算时,我们只需像对普通整数一样将它们加在一起即可。
考虑一个隐含分母为 100 的例子:
为了计算它,我们只需进行 ,我们不需要将 100 纳入计算中。
二进制与十进制定点数
二进制定点数是指分母可以表示为 的定点数。二进制定点数通常使用 Q 格式(Q notation)表示。例如,UQ112x112 使用 作为分母。U 代表“无符号(unsigned)”。用于保存 UQ112x112 的数据类型将是 224。另一种解释方式是,“小数点后的小数部分”保留在最右侧的 112 位中,而“整数部分”保留在左侧的 112 位中。
再举个例子,UQ64x64(或 UQ64.64)是一个 uint128,它将“小数部分”保留在最低有效(least significant)的 64 位中,并将“整数部分”保留在最高有效(most significant)的位数中。这依然可以被解释为具有 的隐含分母,我们接下来就会看到。
二进制定点数的优点是我们可以使用节省 Gas 的左移位操作来代替乘以分母(在将整数转换为定点数时),或在除法时使用右移位操作。
作为一个基本示例,请考虑:
(1) 2 的二进制表示为 10
(2) 16 的二进制表示为 10000
(3)
(4) binary(100) = binary(10) << 3
请注意,3 是 (3) 中的指数,也是在 (4) 中我们向左移动位数的数量。
移动 e 位的位移操作与乘以 之间的这种关系在通常情况下都成立。以下操作是等价的:
// x * 2¹¹² equals x left bitshifted by 112 bits
x * 2 ** 112 == x << 112
// x / 2¹¹² equals x right bitshifted by 112 bits
x / 2 ** 112 == x >> 112
x 可以是任意数字,只要它适合该无符号整数的数据类型。
ABDK 库 使用以下函数将无符号整数转换为定点数(隐含分母为 ):

require 语句确保 x 小于 type(int64).max,因为 ABDK 库使用的是有符号定点数。向左移 64 位等同于乘以 。
类似地,当 ABDK 执行乘法运算时,它不会将 x 和 y 的乘积除以 ,而是向右移 64 位:

Uniswap V2 定点数库
Uniswap V2 的定点数库非常简单,因为 Uniswap V2 使用定点数进行的唯一操作就是加法以及将定点数除以整数。
pragma solidity =0.5.16;
// A library for handling binary fixed point numbers (https://en.wikipedia.org/wiki/Q_(number_format))
// range: [0, 2**112 - 1]
// resolution: 1 / 2**112
library UQ112x112 {
uint224 constant Q112 = 2**112;
// encode a uint112 as a UQ112x112
function encode(uint112 y) internal pure returns (uint224 z) {
z = uint224(y) * Q112; // never overflows
}
// divide a UQ112x112 by a uint112, returning a UQ112x112
function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
z = x / uint224(y);
}
}
encode() 函数将 uint112 转换为存储在 uint224 中的定点数。Uniswap V2 使用的隐含分母为 2**112。如果它使用位移而不是乘法,可能会更节省 Gas(这可能是 Uniswap 开发者犯的一个错误)。
定点数存储在 uint224 中,它的大小是与之交互的 uint112 的两倍。在编码(encode)操作期间,uint112 数字的位实际上被移动到了 uint224 的最高有效 112 位。
使用较小的 uint 大小时,这种“编码”操作更容易形象化。让我们使用一个假设隐含分母为 的定点数。下面,我们将展示在将 uint8 编码为分母为 的定点数时会发生什么:
从二进制表示为 01111101 的数字 125 开始,如果将其乘以 ,乘积为 32000,将其存储在 16 位 uint 中表示为 0111110100000000。请注意,将 125 乘以 具有与向左移 8 位相同的效果。
uqdiv() 函数只是执行定点数除以整数的操作,这不需要额外的步骤。
Uniswap 使用此库来累加下面的 TWAP 预言机的价格。每次更新发生时,TWAP 都会将最新价格添加到一个累加器中(该累加器用于在额外步骤中计算平均价格,这超出了本文的讨论范围)。由于价格表示为分数,定点数是表示它们的理想方式。
变量 _reserve0 和 _reserve1 保存着资金池中最新的代币余额,数据类型为 uint112。price0CumulativeLast 和 price1CumulativeLast 为 UQ112x112(隐含分母为 的定点数)。下面这段来自 Uniswap V2 的代码将分子转换为定点数(UQ112x112)并将其除以一个整数(分母不转换为 UQ112x112)。其结果是一个定点数。

向上取整与向下取整
定点数库在执行除法时通常具有向上取整的选项。例如,Solady 有:
mulWadUp—— 将两个定点数相乘,但在除以 d 时向上取整。回想一下,将两个定点数相乘的公式是 。mulDivUp—— 两个定点数相除,但在除法运算时向上取整
Solidity 的除法始终向下取整,例如 。然而,如果我们向上取整, 将等于 4。在计算记账金额(credits)或价格时,人们应始终做出对协议有利、对用户不利的取整操作。例如,如果我们在计算用户应为固定数量的另一种资产支付多少费用时,我们应该向上取整价格。
例如:
- 向下取整为 3.3333
- 向上取整为 3.3334(具体取决于我们分母的大小)
向上取整只是意味着如果余数不为零,则将结果加 1。例如, 能够整除,因此我们不应该返回 4。然而, 和 的余数分别为 1 和 2,因此我们应该将除法的结果加上 1。
这是 Solmate 库 的实现方式:

在绿色下划线部分,代码检查取模结果是否大于零。如果是,则将 1 添加到结果中(向上取整),否则添加 0(不向上取整)。
最初发布于 6 月 10 日