基本介绍
金融数学始于百分比。y的x百分比是多少?x的多少百分比是y?我们都知道答案:y的x百分比是x×y÷100,y是y×100÷x百分比。这是学校学的数学。
上面的公式是求解比例的特殊情况。通常比例是以下形式的方程:a÷b = c÷d,要求解比例,就是要找到一个知道其他三个值的值。例如可以从a,b和c中找到d,如下所示:d = b×c÷a。
正如在上一篇文章中所展示的那样,在主流编程语言中简单明了,在Solidity中,如此简单的计算令人惊讶地具有挑战性。这有两个原因:i)实体不支持分数;ii)Solidity中的数字类型可能会溢出。
在Javascript中,可以这样简单地计算x×y÷z:x * y / z。可靠地,这样的表达式不会通过安全审核,因为对于足够大的x和y乘法可能会溢出,因此计算结果可能不正确。使用SafeMath并没有多大帮助,因为即使最终计算结果适合256位,它也会使事务失败。在上一篇文章中,我们称这种情况为“幻像溢出”。在像x / z * y或y / z * x之类的乘法之前进行除法可以解决幻像溢出问题,但可能导致精度降低。
在本文中,我们发现Solidity中有哪些更好的方法来处理百分比和比例。
迈向全比例
本文的目标是在Solidity中实现以下功能:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
这将计算x×y÷z,将结果四舍五入,并在z为零的结果不适合uint的情况下抛出。让我们从以下简单的解决方案开始:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
{
return x * y / z;
}
此解决方案基本上满足大多数要求:似乎计算x×y÷z,将结果四舍五入,并在z为零的情况下抛出。但是,存在一个问题:它实际计算的是x×y mod2²⁵⁶÷z。这就是Solidity中乘法溢出的工作方式。当乘法结果不适合256位时,仅返回结果的最低256位。对于x和y较小的值,当x×y <2²⁵⁶时,没有差异,但是对于较大的x和y,这将产生错误的结果。所以第一个问题是:
我们如何防止溢出?
防止Solidity中乘法溢出的常用方法是使用SafeMath库中的mul函数:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
{
return mul (x, y) / z;
}
该代码保证了正确的结果,所以现在所有的要求似乎都满足了,对吧?没那么快。
要求是在结果不适合uint的情况下还原,并且此实现似乎可以满足要求。但是即使最终结果合适,当x×y不适合uint时,此实现也会还原。我们称这种情况为“幻像溢出”。在上一篇文章中,我们展示了如何以精确度为代价解决幻像溢出,但是该解决方案在这里不起作用,因为我们需要精确的精确结果。
由于无法还原幻像溢出,因此
我们如何避免幻像溢出保持精度?
让我们进行以下替换:x = a×z + b和y = c×z + d,其中a,b,c和d是整数,0≤b<z和0≤d<z。然后:
x×y÷z=
(a×z+b)×(c×z+d)÷z=
(a×b×z²+(a×d+b×c)×z+b×d)÷z=
a×b×z+a×d+b×c+b×d÷z
通过分别将x和y除以z,可以将值a,b,c和d计算为商和余数。
因此,该函数可以这样重写:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
{
uint a = x / z; uint b = x % z; // x = a * z + b
uint c = y / z; uint d = y % z; // y = c * z + d
return a * b * z + a * d + b * c + b * d / z;
}
在这里,我们使用普通的+和*运算符以提高可读性,而真实代码应使用SafeMath函数来防止真实(即非幻像)溢出。
在此实现中,幻像溢出仍然是可能的,但仅在最后一项中:b*d/z。但是由于b和d都小于z,因此保证了该代码在z≤212时能正常工作,因此可以保证b×d适合256位。因此可以在已知z不超过2^128的情况下使用此实现。一个常见的例子是18位小数的定点乘法:x×y÷10¹⁸。
我们如何才能完全避免幻影溢出?
幻像溢出问题的根源在于中间乘法结果不适合256位。因此,让我们使用更广泛的类型。Solidity本身不支持大于256位的数字类型,因此我们将不得不模拟它们。我们基本上需要两个操作:uint×uint→wide和wide÷uint→uint。
由于两个256位无符号整数的乘积不能超过512位,因此较宽的类型必须至少为512位宽。我们可以通过两个256位无符号整数对分别模拟512位无符号整数,这两个整数分别持有整个512位数字的低256位和高256位。
因此,代码可能如下所示:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
{
(uint l, uint h) = fullMul (x, y);
return fullDiv (l, h, z);
}
这里fullMul函数将两个256位无符号整数相乘,并将结果作为512位无符号整数分成两个256位部分返回。函数fullDiv将512位无符号整数相除,以两个256位无符号整数形式传递,但以256位无符号整数形式传递,并以256位无符号整数形式返回结果。
让我们以学校数学的方式实现这两个函数:
function fullMul (uint x, uint y)
public pure returns (uint l, uint h)
{
uint xl = uint128 (x); uint xh = x >> 128;
uint yl = uint128 (y); uint yh = y >> 128;
uint xlyl = xl * yl; uint xlyh = xl * yh;
uint xhyl = xh * yl; uint xhyh = xh * yh;
uint ll = uint128 (xlyl);
uint lh = (xlyl >> 128) + uint128 (xlyh) + uint128 (xhyl);
uint hl = uint128 (xhyh) + (xlyh >> 128) + (xhyl >> 128);
uint hh = (xhyh >> 128);
l = ll + (lh << 128);
h = (lh >> 128) + hl + (hh << 128);
}
和
function fullDiv (uint l, uint h, uint z)
public pure returns (uint r) {
require (h < z);
uint zShift = mostSignificantBit (z);
uint shiftedZ = z;
if (zShift <= 127) zShift = 0;
else
{
zShift -= 127;
shiftedZ = (shiftedZ – 1 >> zShift) + 1;
}
while (h > 0)
{
uint lShift = mostSignificantBit (h) + 1;
uint hShift = 256 – lShift;
uint e = ((h << hShift) + (l >> lShift)) / shiftedZ;
if (lShift > zShift) e <<= (lShift – zShift);
else e >>= (zShift – lShift);
r += e;
(uint tl, uint th) = fullMul (e, z);
h -= th;
if (tl > l) h -= 1;
l -= tl;
}
r += l / z;
}
这里的mostSignificantBit是一个函数,它返回参数最高有效位的从零开始的索引。此功能可以通过以下方式实现:
function mostSignificantBit (uint x) public pure returns (uint r) {
require (x > 0);
if (x >= 2**128) { x >>= 128; r += 128; }
if (x >= 2**64) { x >>= 64; r += 64; }
if (x >= 2**32) { x >>= 32; r += 32; }
if (x >= 2**16) { x >>= 16; r += 16; }
if (x >= 2**8) { x >>= 8; r += 8; }
if (x >= 2**4) { x >>= 4; r += 4; }
if (x >= 2**2) { x >>= 2; r += 2; }
if (x >= 2**1) { x >>= 1; r += 1; }
}
上面的代码相当复杂,可能需要解释,但我们现在将跳过解释,而将重点放在不同的问题上。这段代码的问题是,每次调用mulDiv函数大约要消耗2.5K gas,这是相当多的。所以,
我们可以便宜吗?
以下代码基于Remco Bloemen描述的令人兴奋的数学发现。
首先,我们重写fullMul函数:
function fullMul (uint x, uint y)
public pure returns (uint l, uint h)
{
uint mm = mulmod (x, y, uint (-1));
l = x * y;
h = mm – l;
if (mm < l) h -= 1;
}
每一次fullMul调用可以节省大约250gas。
然后我们重写mulDiv函数:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint) {
(uint l, uint h) = fullMul (x, y);
require (h < z);
uint mm = mulmod (x, y, z);
if (mm > l) h -= 1;
l -= mm;
uint pow2 = z & -z;
z /= pow2;
l /= pow2;
l += h * ((-pow2) / pow2 + 1);
uint r = 1;
r *= 2 – z * r;
r *= 2 – z * r;
r *= 2 – z * r;
r *= 2 – z * r;
r *= 2 – z * r;
r *= 2 – z * r;
r *= 2 – z * r;
r *= 2 – z * r;
return l * r;
}
这个实现每次mulDiv调用只消耗大约550 gas,并且可以进一步优化。比学校数学方法好5倍。很好!但是,实际上必须获得数学博士学位才能编写这样的代码,并且并非每个问题都具有这样的数学解决方案。如果可以的话,事情会简单得多。
在Solidity中使用浮点数
正如我们在本文开头所说的,在JavaScript中,只需编写一个*b/c,其余部分由语言来处理。如果我们能在Solidity上做同样的事呢?
实际上我们可以。虽然核心语言不支持浮点,但有些库支持。例如使用ABDKMathQuad库,可以编写:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint) {
return
ABDKMathQuad.toUInt (
ABDKMathQuad.div (
ABDKMathQuad.mul (
ABDKMathQuad.fromUInt (x),
ABDKMathQuad.fromUInt (y)
),
ABDKMathQuad.fromUInt (z)
)
);
}
不像JavaScript那样优雅,不像mathemagic解决方案那样便宜(甚至比学校的数学方法更具扩展性),但是非常简单和精确,因为这里使用的四倍精度浮点数有大约33个有效小数。
此实现方式消耗的气体有一半以上用于将uint值转换为浮点数和浮点数,比例计算本身仅消耗约1.4K gas。因此在所有智能合约中使用浮点数可能比混合整数和浮点数便宜得多。
结论
由于溢出和缺少分数支持,因此百分比和比例在Solidity中可能具有挑战性。但是,各种数学技巧都可以正确有效地解决比例问题。
该内容来自于互联网公开内容,非区块链原创内容,如若转载,请注明出处:https://qkljsw.com/archives/28122